Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion apps/desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,23 @@
},
"providers": {
"openRouter": "OpenRouter",
"ollama": "Ollama"
"ollama": "Ollama",
"appleIntelligence": "Apple Intelligence"
},
"appleIntelligence": {
"checking": "Checking...",
"available": "Available",
"unavailable": "Not Available",
"sync": "Sync Model",
"syncing": "Syncing...",
"descriptionAvailable": "On-device language model powered by Apple Intelligence. No API key required.",
"descriptionUnavailable": "Apple Intelligence is not available: {{reason}}",
"descriptionUnavailableGeneric": "Apple Intelligence is not available on this device. Requires macOS 26 or later with Apple Silicon.",
"toast": {
"synced": "Apple Intelligence model synced successfully!",
"notAvailable": "Apple Intelligence is not available on this device.",
"syncFailed": "Failed to sync Apple Intelligence model."
}
},
"provider": {
"status": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { FormattingProvider, FormatParams } from "../../core/pipeline-types";
import { logger } from "../../../main/logger";
import { constructFormatterPrompt } from "./formatter-prompt";
import type { NativeBridge } from "../../../services/platform/native-bridge-service";

export class AppleIntelligenceFormatter implements FormattingProvider {
readonly name = "apple-intelligence";

constructor(private nativeBridge: NativeBridge) {}

async format(params: FormatParams): Promise<string> {
try {
const { text, context } = params;
// Use amical-notes formatting for on-device models to ensure
// consistent Markdown output with smart structure detection.
const { systemPrompt } = constructFormatterPrompt(context, {
overrideAppType: "amical-notes",
});

logger.pipeline.debug("Apple Intelligence formatting request", {
systemPrompt,
userPrompt: text,
});

// Wrap user text explicitly so the on-device model treats it as
// text to format rather than a conversational query to respond to.
const userPrompt = `Format the following transcribed text:\n\n${text}`;

const result = await this.nativeBridge.call(
"generateWithFoundationModel",
{
systemPrompt,
userPrompt,
temperature: 0.1,
},
30000,
);

logger.pipeline.debug("Apple Intelligence formatting raw response", {
rawResponse: result.content,
});

// Extract formatted text from XML tags (same pattern as Ollama/OpenRouter)
const match = result.content.match(
/<formatted_text>([\s\S]*?)<\/formatted_text>/,
);
const formattedText = match ? match[1] : result.content;

logger.pipeline.debug("Apple Intelligence formatting completed", {
original: text,
formatted: formattedText,
hadXmlTags: !!match,
});

// If formatted text is empty, fall back to original text
// On-device models may return empty tags for short inputs
if (!formattedText || formattedText.trim().length === 0) {
logger.pipeline.warn(
"Apple Intelligence returned empty formatted text, using original",
);
return text;
}

return formattedText;
} catch (error) {
logger.pipeline.error("Apple Intelligence formatting failed:", error);
return params.text;
}
}
}
33 changes: 27 additions & 6 deletions apps/desktop/src/pipeline/providers/formatting/formatter-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ const BASE_INSTRUCTIONS = [
"Maintain the original meaning and tone",
"Use the custom vocabulary to correct domain-specific terms",
"Remove unnecessary filler words (um, uh, etc.) but keep natural speech patterns",
"If the text is empty, return <formatted_text></formatted_text>",
"Return ONLY the formatted text enclosed in <formatted_text></formatted_text> tags",
"Do not include any commentary, explanations, or text outside the XML tags",
];
Expand Down Expand Up @@ -54,10 +53,28 @@ const APPLICATION_TYPE_RULES: Record<AppType, string[]> = {
"Use bullet points (-) for unordered lists of items, ideas, or notes",
"Use numbered lists (1. 2. 3.) for sequential steps, priorities, or ranked items",
"Use headers for distinct topics or sections (## for main sections, ### for subsections)",
"Use bold (**text**) for emphasis on key terms or action items",
"Use code blocks (```) for technical content, commands, or code snippets",
"Do NOT use bold (**text**) or italic (*text*) markup - output plain text with list and header formatting only",
"Keep formatting minimal and purposeful - don't over-format simple content",
"Preserve natural speech flow while adding structure where it improves clarity",
"Detect implicit structure in speech: when someone lists items (e.g. 'A, B, and C'), format them as a bullet list",
"",
"Examples:",
'Input: "My favorite foods are ramen, curry, and oyakodon."',
"Output:",
"<formatted_text>My favorite foods are:",
"- Ramen",
"- Curry",
"- Oyakodon</formatted_text>",
"",
'Input: "First you need to install Node then run npm install and finally start the server"',
"Output:",
"<formatted_text>1. Install Node",
"2. Run `npm install`",
"3. Start the server</formatted_text>",
"",
'Input: "The meeting went well we discussed the budget and the timeline"',
"Output:",
'<formatted_text>The meeting went well. We discussed the budget and the timeline.</formatted_text>',
],
default: [
"Apply standard formatting for general text",
Expand Down Expand Up @@ -131,13 +148,17 @@ const URL_PATTERNS: Partial<Record<AppType, RegExp[]>> = {
],
};

export function constructFormatterPrompt(context: FormatParams["context"]): {
export function constructFormatterPrompt(
context: FormatParams["context"],
options?: { overrideAppType?: AppType },
): {
systemPrompt: string;
} {
const { accessibilityContext, vocabulary } = context;

// Detect application type
const applicationType = detectApplicationType(accessibilityContext);
// Use override if provided, otherwise detect from accessibility context
const applicationType =
options?.overrideAppType ?? detectApplicationType(accessibilityContext);

// Build instructions array
const instructions = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";

export default function AppleIntelligenceProvider() {
const { t } = useTranslation();
const [isSyncing, setIsSyncing] = useState(false);

const isMac = window.electronAPI?.platform === "darwin";

const availabilityQuery =
api.models.checkAppleIntelligenceAvailability.useQuery(undefined, {
enabled: isMac,
});

const utils = api.useUtils();
const syncMutation = api.models.syncAppleIntelligenceModel.useMutation({
onMutate: () => setIsSyncing(true),
onSuccess: (result) => {
setIsSyncing(false);
if (result.available) {
toast.success(t("settings.aiModels.appleIntelligence.toast.synced"));
utils.models.getSyncedProviderModels.invalidate();
utils.models.getDefaultLanguageModel.invalidate();
utils.models.getModels.invalidate();
} else {
toast.error(
t("settings.aiModels.appleIntelligence.toast.notAvailable"),
);
}
},
onError: () => {
setIsSyncing(false);
toast.error(t("settings.aiModels.appleIntelligence.toast.syncFailed"));
},
});

if (!isMac) return null;

const available = availabilityQuery.data?.available ?? false;
const reason = availabilityQuery.data?.reason;
const isLoading = availabilityQuery.isLoading;

return (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">
{t("settings.aiModels.providers.appleIntelligence")}
</span>
{isLoading ? (
<Badge variant="secondary" className="text-xs">
<Loader2 className="h-3 w-3 animate-spin mr-1" />
{t("settings.aiModels.appleIntelligence.checking")}
</Badge>
) : (
<Badge
variant="secondary"
className={cn(
"text-xs flex items-center gap-1",
available
? "text-green-500 border-green-500"
: "text-muted-foreground border-muted",
)}
>
<span
className={cn(
"w-2 h-2 rounded-full inline-block mr-1",
available ? "bg-green-500 animate-pulse" : "bg-muted-foreground",
)}
/>
{available
? t("settings.aiModels.appleIntelligence.available")
: t("settings.aiModels.appleIntelligence.unavailable")}
</Badge>
)}
</div>
{available && (
<Button
variant="outline"
size="sm"
onClick={() => syncMutation.mutate()}
disabled={isSyncing}
>
{isSyncing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("settings.aiModels.appleIntelligence.syncing")}
</>
) : (
t("settings.aiModels.appleIntelligence.sync")
)}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{available
? t("settings.aiModels.appleIntelligence.descriptionAvailable")
: reason
? t("settings.aiModels.appleIntelligence.descriptionUnavailable", {
reason,
})
: t(
"settings.aiModels.appleIntelligence.descriptionUnavailableGeneric",
)}
</p>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Accordion } from "@/components/ui/accordion";
import SyncedModelsList from "../components/synced-models-list";
import DefaultModelCombobox from "../components/default-model-combobox";
import ProviderAccordion from "../components/provider-accordion";
import AppleIntelligenceProvider from "../components/apple-intelligence-provider";
import { useTranslation } from "react-i18next";

export default function LanguageTab() {
Expand All @@ -17,6 +18,9 @@ export default function LanguageTab() {
title={t("settings.aiModels.defaultModels.language")}
/>

{/* Apple Intelligence (macOS only, auto-detected) */}
<AppleIntelligenceProvider />

{/* Providers Accordions */}
<Accordion type="multiple" className="w-full">
<ProviderAccordion provider="OpenRouter" modelType="language" />
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop/src/services/model-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "../types/providers";
import { SettingsService } from "./settings-service";
import { AuthService } from "./auth-service";
import type { NativeBridge } from "./platform/native-bridge-service";
import { logger } from "../main/logger";
import { getUserAgent } from "../utils/http-client";

Expand Down Expand Up @@ -822,6 +823,67 @@ class ModelService extends EventEmitter {
}
}

// ============================================
// Apple Intelligence Model Sync
// ============================================

/**
* Sync Apple Intelligence model based on Foundation Model availability.
* Registers the model if available, removes it if not.
*/
async syncAppleIntelligenceModel(
nativeBridge: NativeBridge,
): Promise<{ available: boolean; reason?: string }> {
if (process.platform !== "darwin") {
return { available: false, reason: "notMacOS" };
}

try {
const result = await nativeBridge.call(
"checkFoundationModelAvailability",
{},
);

if (result.available) {
await upsertModel({
id: "apple-intelligence",
provider: "AppleIntelligence",
name: "Apple Intelligence",
type: "language",
description: "On-device Apple Intelligence model",
size: null,
context: null,
checksum: null,
speed: null,
accuracy: null,
localPath: null,
sizeBytes: null,
downloadedAt: null,
originalModel: null,
});
logger.main.info(
"Apple Intelligence model registered (Foundation Model available)",
);
} else {
// Remove from DB if previously registered
await removeModel("AppleIntelligence", "apple-intelligence").catch(
() => {},
);
logger.main.info(
"Apple Intelligence model not available, removed from DB",
{ reason: result.reason },
);
}

return { available: result.available, reason: result.reason };
} catch (error) {
logger.main.warn("Failed to check Apple Intelligence availability", {
error: error instanceof Error ? error.message : String(error),
});
return { available: false, reason: "checkFailed" };
}
}

// ============================================
// Provider Model Methods (OpenRouter, Ollama)
// ============================================
Expand Down
Loading