Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51b7d4ab0a | |||
| f3a0c45331 | |||
| 8451db1142 | |||
| cd0ceeab41 | |||
| 098c3c3d1e | |||
| 3d6ac578db | |||
| 0f9dc05774 | |||
| 375b8fb345 | |||
| c5c2667553 | |||
| f375b1172b |
@@ -279,11 +279,11 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 2.3.9;
|
MARKETING_VERSION = 2.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -323,11 +323,11 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
IPHONEOS_DEPLOYMENT_TARGET = 27.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 27.0;
|
||||||
MARKETING_VERSION = 2.3.9;
|
MARKETING_VERSION = 2.4.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
language = "nb"
|
language = "en"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
|||||||
@@ -6,5 +6,13 @@
|
|||||||
<string>oAI.help</string>
|
<string>oAI.help</string>
|
||||||
<key>CFBundleHelpBookName</key>
|
<key>CFBundleHelpBookName</key>
|
||||||
<string>oAI Help</string>
|
<string>oAI Help</string>
|
||||||
|
<key>CFBundleLocalizations</key>
|
||||||
|
<array>
|
||||||
|
<string>en</string>
|
||||||
|
<string>nb</string>
|
||||||
|
<string>da</string>
|
||||||
|
<string>de</string>
|
||||||
|
<string>sv</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
|
"·" : {
|
||||||
|
"comment" : "A separator between the message count and the date.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"· %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
"(always used)" : {
|
"(always used)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -432,6 +439,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"^[%@ message](inflect: true)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"^[%@ token](inflect: true)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"© 2026 [Rune Olsen](https://blog.rune.pm)" : {
|
"© 2026 [Rune Olsen](https://blog.rune.pm)" : {
|
||||||
"comment" : "A copyright notice with the copyright holder's name.",
|
"comment" : "A copyright notice with the copyright holder's name.",
|
||||||
@@ -521,6 +534,7 @@
|
|||||||
},
|
},
|
||||||
"⌘N New • ⌘M Model • ⌘S Save" : {
|
"⌘N New • ⌘M Model • ⌘S Save" : {
|
||||||
"comment" : "A hint that appears on macOS when using keyboard shortcuts.",
|
"comment" : "A hint that appears on macOS when using keyboard shortcuts.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -549,6 +563,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"⚠️ Beta — Paperless integration is under active development. Some features may be incomplete or behave unexpectedly." : {
|
||||||
|
"comment" : "A warning displayed in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"⚠️ Custom prompt active — only this prompt will be sent to the model." : {
|
"⚠️ Custom prompt active — only this prompt will be sent to the model." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -894,6 +912,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"🧠" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"1. Open Anytype → Settings → Integrations" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"1. Open Paperless-NGX → Settings → API Tokens" : {
|
"1. Open Paperless-NGX → Settings → API Tokens" : {
|
||||||
"comment" : "A step in the process of getting a Paperless-NGX API token.",
|
"comment" : "A step in the process of getting a Paperless-NGX API token.",
|
||||||
@@ -925,6 +949,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"2. Create a new API key" : {
|
||||||
|
"comment" : "A step in the process of getting an API key from Anytype.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"2. Create or copy your token" : {
|
"2. Create or copy your token" : {
|
||||||
"comment" : "A step in the process of getting a Paperless-NGX API token.",
|
"comment" : "A step in the process of getting a Paperless-NGX API token.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -1132,6 +1160,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Agent" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Agent Skills" : {
|
"Agent Skills" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1160,6 +1191,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Agents" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Allow Shell Command?" : {
|
"Allow Shell Command?" : {
|
||||||
"comment" : "A title for a modal that asks the user if they want to allow a shell command.",
|
"comment" : "A title for a modal that asks the user if they want to allow a shell command.",
|
||||||
@@ -1279,6 +1313,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Apple Intelligence" : {
|
||||||
|
"comment" : "A heading for the Apple Intelligence credits view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Auto-execute mode: commands run without approval. Use with caution." : {
|
"Auto-execute mode: commands run without approval. Use with caution." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -1566,6 +1604,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Category" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Changing these values affects how the AI generates responses. The defaults work well for most use cases." : {
|
"Changing these values affects how the AI generates responses. The defaults work well for most use cases." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1654,6 +1695,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Choose an agent from the list to view details and run history" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clear All" : {
|
"Clear All" : {
|
||||||
"comment" : "A button to clear all email activity logs.",
|
"comment" : "A button to clear all email activity logs.",
|
||||||
@@ -1918,6 +1962,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Cost" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Cost Examples" : {
|
"Cost Examples" : {
|
||||||
"comment" : "A heading for the cost examples of a model.",
|
"comment" : "A heading for the cost examples of a model.",
|
||||||
@@ -2092,6 +2139,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Disabled" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Each command will require your approval before running." : {
|
"Each command will require your approval before running." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2468,6 +2518,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Filter by Category" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Generate an API key in your Jarvis settings and paste it above." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Google (Gemini embedding)" : {
|
"Google (Gemini embedding)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2526,6 +2582,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"High (~80%)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"How to get your API key:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"How to get your API token:" : {
|
"How to get your API token:" : {
|
||||||
"comment" : "A heading for a section that describes how to get your API token.",
|
"comment" : "A heading for a section that describes how to get your API token.",
|
||||||
@@ -2640,6 +2702,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Input" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Large files inflate the system prompt and may hit token limits." : {
|
"Large files inflate the system prompt and may hit token limits." : {
|
||||||
"comment" : "A warning displayed when a user adds a large file to a skill.",
|
"comment" : "A warning displayed when a user adds a large file to a skill.",
|
||||||
@@ -2726,6 +2791,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Low (~20%)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Lowercase letters, numbers, and hyphens only. No spaces." : {
|
"Lowercase letters, numbers, and hyphens only. No spaces." : {
|
||||||
"comment" : "A description of the format of a shortcut's command.",
|
"comment" : "A description of the format of a shortcut's command.",
|
||||||
@@ -2842,6 +2910,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Medium (~50%)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"messages" : {
|
"messages" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2870,6 +2941,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Minimal (~10%)" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Model Context Protocol" : {
|
"Model Context Protocol" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2928,6 +3002,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Model thinks internally but reasoning is not shown in chat" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Multi-provider AI chat client" : {
|
"Multi-provider AI chat client" : {
|
||||||
"comment" : "A description of oAI.",
|
"comment" : "A description of oAI.",
|
||||||
@@ -3018,6 +3095,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"New Chat" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No credit data available" : {
|
"No credit data available" : {
|
||||||
"comment" : "A message displayed when there is no credit data available.",
|
"comment" : "A message displayed when there is no credit data available.",
|
||||||
@@ -3196,6 +3276,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"No runs yet" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No shortcuts yet" : {
|
"No shortcuts yet" : {
|
||||||
"comment" : "A message displayed when a user has no shortcuts.",
|
"comment" : "A message displayed when a user has no shortcuts.",
|
||||||
@@ -3347,6 +3430,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates." : {
|
||||||
|
"comment" : "A warning that Intel Macs are no longer supported.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Ollama (Local)" : {
|
"Ollama (Local)" : {
|
||||||
"comment" : "A label displayed above the credits information for the local Ollie.",
|
"comment" : "A label displayed above the credits information for the local Ollie.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@@ -3377,6 +3464,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"On-Device (4K context)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"On-device and free — no credits or API key needed." : {
|
||||||
|
"comment" : "A description of the on-device version of the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Only emails with this text in the subject line will be processed. Example: \"[OAIBOT] What's the weather?\"" : {
|
"Only emails with this text in the subject line will be processed. Example: \"[OAIBOT] What's the weather?\"" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -3574,6 +3668,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"OpenRouter Balance" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"OpenRouter Credits" : {
|
"OpenRouter Credits" : {
|
||||||
"comment" : "A heading for the user's OpenRouter credits.",
|
"comment" : "A heading for the user's OpenRouter credits.",
|
||||||
@@ -3604,6 +3701,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Output" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Prompt" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Read access (always enabled)" : {
|
"Read access (always enabled)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3632,6 +3735,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Reasoning" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Remote: %@" : {
|
"Remote: %@" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3690,6 +3796,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Run History" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Run some agents to see usage statistics" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Running locally — no credits needed!" : {
|
"Running locally — no credits needed!" : {
|
||||||
"comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.",
|
"comment" : "A message displayed when using an on-device LLM like the one provided by the `.ollama` provider.",
|
||||||
@@ -3721,6 +3833,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Runs" : {
|
||||||
|
"comment" : "A column header for the number of runs.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Security Recommendation" : {
|
"Security Recommendation" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -3866,6 +3982,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Sort" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"SSH Key" : {
|
"SSH Key" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4180,6 +4299,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Thinking…" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"This default prompt is always included to ensure accurate, helpful responses." : {
|
"This default prompt is always included to ensure accurate, helpful responses." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4296,6 +4418,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Total" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Total Credits" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Total Used" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Try adjusting your search or filters" : {
|
"Try adjusting your search or filters" : {
|
||||||
"comment" : "A description of the error that occurs when no models match the user's search.",
|
"comment" : "A description of the error that occurs when no models match the user's search.",
|
||||||
@@ -4444,6 +4575,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Usage" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Use @filename to attach files to your message" : {
|
"Use @filename to attach files to your message" : {
|
||||||
"comment" : "A description of how to attach files to a message.",
|
"comment" : "A description of how to attach files to a message.",
|
||||||
@@ -4565,6 +4699,7 @@
|
|||||||
},
|
},
|
||||||
"v%@" : {
|
"v%@" : {
|
||||||
"comment" : "A label showing the current version of oAI.",
|
"comment" : "A label showing the current version of oAI.",
|
||||||
|
"extractionState" : "stale",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"da" : {
|
"da" : {
|
||||||
@@ -4744,6 +4879,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"β" : {
|
||||||
|
"comment" : "A beta badge.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -58,17 +58,25 @@ struct Settings: Codable {
|
|||||||
case anthropic
|
case anthropic
|
||||||
case openai
|
case openai
|
||||||
case ollama
|
case ollama
|
||||||
|
case appleOnDevice = "apple_on_device"
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
rawValue.capitalized
|
switch self {
|
||||||
|
case .openrouter: return "OpenRouter"
|
||||||
|
case .anthropic: return "Anthropic"
|
||||||
|
case .openai: return "OpenAI"
|
||||||
|
case .ollama: return "Ollama"
|
||||||
|
case .appleOnDevice: return "Apple Intelligence"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var iconName: String {
|
var iconName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .openrouter: return "network"
|
case .openrouter: return "network"
|
||||||
case .anthropic: return "brain"
|
case .anthropic: return "brain"
|
||||||
case .openai: return "sparkles"
|
case .openai: return "sparkles"
|
||||||
case .ollama: return "server.rack"
|
case .ollama: return "server.rack"
|
||||||
|
case .appleOnDevice: return "apple.logo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// AppleFoundationProvider.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Apple Foundation Models provider (on-device Apple Intelligence)
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as
|
||||||
|
// published by the Free Software Foundation, either version 3 of the
|
||||||
|
// License, or (at your option) any later version.
|
||||||
|
//
|
||||||
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||||
|
// Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public
|
||||||
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
import os
|
||||||
|
|
||||||
|
final class AppleFoundationProvider: AIProvider {
|
||||||
|
let name = "Apple Intelligence"
|
||||||
|
|
||||||
|
let capabilities = ProviderCapabilities(
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsVision: false,
|
||||||
|
supportsTools: false,
|
||||||
|
supportsOnlineSearch: false,
|
||||||
|
maxContextLength: 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// MARK: - Models
|
||||||
|
|
||||||
|
func listModels() async throws -> [ModelInfo] {
|
||||||
|
[
|
||||||
|
ModelInfo(
|
||||||
|
id: "apple-on-device",
|
||||||
|
name: "Apple On-Device",
|
||||||
|
description: "On-device Apple Intelligence model. Private, free, and works offline. 4K context window.",
|
||||||
|
contextLength: 4096,
|
||||||
|
pricing: ModelInfo.Pricing(prompt: 0, completion: 0),
|
||||||
|
capabilities: ModelInfo.ModelCapabilities(
|
||||||
|
vision: false,
|
||||||
|
tools: false,
|
||||||
|
online: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func getModel(_ id: String) async throws -> ModelInfo? {
|
||||||
|
try await listModels().first { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCredits() async throws -> Credits? { nil }
|
||||||
|
|
||||||
|
// MARK: - Streaming chat
|
||||||
|
|
||||||
|
func streamChat(request: ChatRequest) -> AsyncThrowingStream<StreamChunk, Error> {
|
||||||
|
AsyncThrowingStream { continuation in
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let session = try self.makeSession(for: request)
|
||||||
|
let prompt = self.lastUserMessage(from: request)
|
||||||
|
|
||||||
|
// streamResponse(to: String) → ResponseStream<String>
|
||||||
|
// Each snapshot.content is the full accumulated text so far (snapshot model).
|
||||||
|
// We compute deltas by comparing each snapshot to the previous.
|
||||||
|
let stream = session.streamResponse(to: prompt)
|
||||||
|
var lastContent = ""
|
||||||
|
|
||||||
|
for try await snapshot in stream {
|
||||||
|
let current = snapshot.content
|
||||||
|
if current.count > lastContent.count {
|
||||||
|
let delta = String(current.dropFirst(lastContent.count))
|
||||||
|
continuation.yield(StreamChunk(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
model: request.model,
|
||||||
|
delta: StreamChunk.Delta(content: delta, role: "assistant"),
|
||||||
|
finishReason: nil,
|
||||||
|
usage: nil
|
||||||
|
))
|
||||||
|
lastContent = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.yield(StreamChunk(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
model: request.model,
|
||||||
|
delta: StreamChunk.Delta(content: nil, role: nil),
|
||||||
|
finishReason: "stop",
|
||||||
|
usage: nil
|
||||||
|
))
|
||||||
|
continuation.finish()
|
||||||
|
|
||||||
|
} catch let genError as LanguageModelSession.GenerationError {
|
||||||
|
continuation.finish(throwing: self.mapGenerationError(genError))
|
||||||
|
} catch {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Non-streaming chat
|
||||||
|
|
||||||
|
func chat(request: ChatRequest) async throws -> ChatResponse {
|
||||||
|
let session = try makeSession(for: request)
|
||||||
|
let prompt = lastUserMessage(from: request)
|
||||||
|
let response: LanguageModelSession.Response<String> = try await session.respond(to: prompt)
|
||||||
|
return ChatResponse(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
model: request.model,
|
||||||
|
content: response.content,
|
||||||
|
role: "assistant",
|
||||||
|
finishReason: "stop",
|
||||||
|
usage: nil,
|
||||||
|
created: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tool messages (not supported in Phase 1)
|
||||||
|
|
||||||
|
func chatWithToolMessages(model: String, messages: [[String: Any]], tools: [Tool]?, maxTokens: Int?, temperature: Double?) async throws -> ChatResponse {
|
||||||
|
throw ProviderError.unknown("Tool calling requires Apple Foundation Models Phase 3.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session construction
|
||||||
|
|
||||||
|
private func makeSession(for request: ChatRequest) throws -> LanguageModelSession {
|
||||||
|
guard case .available = SystemLanguageModel.default.availability else {
|
||||||
|
throw availabilityError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build instructions: system prompt + prior conversation turns as formatted text.
|
||||||
|
// Foundation Models sessions don't accept a message array — we inject history inline.
|
||||||
|
var instructions = request.systemPrompt ?? ""
|
||||||
|
let priorMessages = request.messages.dropLast().filter { $0.role != .system }
|
||||||
|
|
||||||
|
if !priorMessages.isEmpty {
|
||||||
|
let history = priorMessages
|
||||||
|
.map { m -> String in
|
||||||
|
let label = m.role == .user ? "User" : "Assistant"
|
||||||
|
return "\(label): \(m.content)"
|
||||||
|
}
|
||||||
|
.joined(separator: "\n")
|
||||||
|
instructions += "\n\nConversation so far:\n\(history)\n\nContinue from here."
|
||||||
|
}
|
||||||
|
|
||||||
|
return instructions.isEmpty
|
||||||
|
? LanguageModelSession()
|
||||||
|
: LanguageModelSession(instructions: instructions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lastUserMessage(from request: ChatRequest) -> String {
|
||||||
|
request.messages.last(where: { $0.role == .user })?.content ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error mapping
|
||||||
|
|
||||||
|
private func availabilityError() -> Error {
|
||||||
|
switch SystemLanguageModel.default.availability {
|
||||||
|
case .unavailable(.deviceNotEligible):
|
||||||
|
return ProviderError.unknown("This Mac doesn't support Apple Intelligence. Apple Silicon is required.")
|
||||||
|
case .unavailable(.appleIntelligenceNotEnabled):
|
||||||
|
return ProviderError.unknown("Apple Intelligence is not enabled. Open System Settings → Apple Intelligence to turn it on.")
|
||||||
|
case .unavailable(.modelNotReady):
|
||||||
|
return ProviderError.unknown("Apple Intelligence model is still downloading. Please wait and try again.")
|
||||||
|
default:
|
||||||
|
return ProviderError.unknown("Apple Intelligence is not available on this device.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mapGenerationError(_ error: LanguageModelSession.GenerationError) -> Error {
|
||||||
|
switch error {
|
||||||
|
case .exceededContextWindowSize:
|
||||||
|
return ProviderError.unknown("Apple Intelligence context limit exceeded (4,096 tokens). Start a new chat or enable Progressive Summarization in Settings → Advanced.")
|
||||||
|
case .rateLimited:
|
||||||
|
return ProviderError.rateLimitExceeded
|
||||||
|
case .guardrailViolation:
|
||||||
|
return ProviderError.unknown("Apple Intelligence declined to respond to this message.")
|
||||||
|
default:
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,9 @@ class ProviderRegistry {
|
|||||||
|
|
||||||
case .ollama:
|
case .ollama:
|
||||||
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
provider = OllamaProvider(baseURL: settings.ollamaEffectiveURL)
|
||||||
|
|
||||||
|
case .appleOnDevice:
|
||||||
|
provider = AppleFoundationProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache and return
|
// Cache and return
|
||||||
@@ -106,6 +109,8 @@ class ProviderRegistry {
|
|||||||
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
||||||
case .ollama:
|
case .ollama:
|
||||||
return settings.ollamaConfigured
|
return settings.ollamaConfigured
|
||||||
|
case .appleOnDevice:
|
||||||
|
return true // no API key needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,15 +134,29 @@ final class DatabaseService: Sendable {
|
|||||||
nonisolated static let shared = DatabaseService()
|
nonisolated static let shared = DatabaseService()
|
||||||
|
|
||||||
private let dbQueue: DatabaseQueue
|
private let dbQueue: DatabaseQueue
|
||||||
private let isoFormatter: ISO8601DateFormatter
|
|
||||||
|
|
||||||
// Command history limit - keep most recent 5000 entries
|
// Command history limit - keep most recent 5000 entries
|
||||||
private static let maxHistoryEntries = 5000
|
private nonisolated static let maxHistoryEntries = 5000
|
||||||
|
|
||||||
|
// ISO8601DateFormatter is @MainActor in macOS 27. Use Date.ISO8601FormatStyle (value type, Sendable).
|
||||||
|
private nonisolated static let isoStyle = Date.ISO8601FormatStyle(
|
||||||
|
dateSeparator: .dash,
|
||||||
|
dateTimeSeparator: .standard,
|
||||||
|
timeSeparator: .colon,
|
||||||
|
timeZoneSeparator: .colon,
|
||||||
|
includingFractionalSeconds: true,
|
||||||
|
timeZone: .gmt
|
||||||
|
)
|
||||||
|
|
||||||
|
private nonisolated static func isoString(from date: Date) -> String {
|
||||||
|
isoStyle.format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func isoDate(from string: String) -> Date? {
|
||||||
|
(try? isoStyle.parse(string)) ?? (try? Date(string, strategy: .iso8601))
|
||||||
|
}
|
||||||
|
|
||||||
nonisolated private init() {
|
nonisolated private init() {
|
||||||
isoFormatter = ISO8601DateFormatter()
|
|
||||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
||||||
|
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
|
let dbDirectory = appSupport.appendingPathComponent("oAI", isDirectory: true)
|
||||||
@@ -156,7 +170,7 @@ final class DatabaseService: Sendable {
|
|||||||
try! migrator.migrate(dbQueue)
|
try! migrator.migrate(dbQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var migrator: DatabaseMigrator {
|
private nonisolated var migrator: DatabaseMigrator {
|
||||||
var migrator = DatabaseMigrator()
|
var migrator = DatabaseMigrator()
|
||||||
|
|
||||||
migrator.registerMigration("v1") { db in
|
migrator.registerMigration("v1") { db in
|
||||||
@@ -375,7 +389,7 @@ final class DatabaseService: Sendable {
|
|||||||
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
|
nonisolated func saveConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws -> Conversation {
|
||||||
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
|
Log.db.info("Saving conversation '\(name)' with \(messages.count) messages (primaryModel: \(primaryModel ?? "none"))")
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let nowString = isoFormatter.string(from: now)
|
let nowString = Self.isoString(from: now)
|
||||||
|
|
||||||
let convRecord = ConversationRecord(
|
let convRecord = ConversationRecord(
|
||||||
id: id.uuidString,
|
id: id.uuidString,
|
||||||
@@ -394,7 +408,7 @@ final class DatabaseService: Sendable {
|
|||||||
content: msg.content,
|
content: msg.content,
|
||||||
tokens: msg.tokens,
|
tokens: msg.tokens,
|
||||||
cost: msg.cost,
|
cost: msg.cost,
|
||||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
timestamp: Self.isoString(from: msg.timestamp),
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
modelId: msg.modelId
|
modelId: msg.modelId
|
||||||
)
|
)
|
||||||
@@ -420,7 +434,7 @@ final class DatabaseService: Sendable {
|
|||||||
|
|
||||||
/// Update an existing conversation in-place: rename it, replace all its messages.
|
/// Update an existing conversation in-place: rename it, replace all its messages.
|
||||||
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
|
nonisolated func updateConversation(id: UUID, name: String, messages: [Message], primaryModel: String?) throws {
|
||||||
let nowString = isoFormatter.string(from: Date())
|
let nowString = Self.isoString(from: Date())
|
||||||
|
|
||||||
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
let messageRecords = messages.enumerated().compactMap { index, msg -> MessageRecord? in
|
||||||
guard msg.role != .system else { return nil }
|
guard msg.role != .system else { return nil }
|
||||||
@@ -431,7 +445,7 @@ final class DatabaseService: Sendable {
|
|||||||
content: msg.content,
|
content: msg.content,
|
||||||
tokens: msg.tokens,
|
tokens: msg.tokens,
|
||||||
cost: msg.cost,
|
cost: msg.cost,
|
||||||
timestamp: isoFormatter.string(from: msg.timestamp),
|
timestamp: Self.isoString(from: msg.timestamp),
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
modelId: msg.modelId
|
modelId: msg.modelId
|
||||||
)
|
)
|
||||||
@@ -466,7 +480,7 @@ final class DatabaseService: Sendable {
|
|||||||
let messages = messageRecords.compactMap { record -> Message? in
|
let messages = messageRecords.compactMap { record -> Message? in
|
||||||
guard let msgId = UUID(uuidString: record.id),
|
guard let msgId = UUID(uuidString: record.id),
|
||||||
let role = MessageRole(rawValue: record.role),
|
let role = MessageRole(rawValue: record.role),
|
||||||
let timestamp = self.isoFormatter.date(from: record.timestamp)
|
let timestamp = Self.isoDate(from: record.timestamp)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
|
let starred = (try? MessageMetadataRecord.fetchOne(db, key: record.id))?.user_starred == 1
|
||||||
@@ -484,8 +498,8 @@ final class DatabaseService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let convId = UUID(uuidString: convRecord.id),
|
guard let convId = UUID(uuidString: convRecord.id),
|
||||||
let createdAt = self.isoFormatter.date(from: convRecord.createdAt),
|
let createdAt = Self.isoDate(from: convRecord.createdAt),
|
||||||
let updatedAt = self.isoFormatter.date(from: convRecord.updatedAt)
|
let updatedAt = Self.isoDate(from: convRecord.updatedAt)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let conversation = Conversation(
|
let conversation = Conversation(
|
||||||
@@ -509,8 +523,8 @@ final class DatabaseService: Sendable {
|
|||||||
|
|
||||||
return records.compactMap { record -> Conversation? in
|
return records.compactMap { record -> Conversation? in
|
||||||
guard let id = UUID(uuidString: record.id),
|
guard let id = UUID(uuidString: record.id),
|
||||||
let createdAt = self.isoFormatter.date(from: record.createdAt),
|
let createdAt = Self.isoDate(from: record.createdAt),
|
||||||
let updatedAt = self.isoFormatter.date(from: record.updatedAt)
|
let updatedAt = Self.isoDate(from: record.updatedAt)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
// Fetch message count without loading all messages
|
// Fetch message count without loading all messages
|
||||||
@@ -524,7 +538,7 @@ final class DatabaseService: Sendable {
|
|||||||
.order(Column("sortOrder").desc)
|
.order(Column("sortOrder").desc)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
|
|
||||||
let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt
|
let lastDate = lastMsg.flatMap { Self.isoDate(from: $0.timestamp) } ?? updatedAt
|
||||||
|
|
||||||
// Derive primary model: prefer the stored field, fall back to last message's modelId
|
// Derive primary model: prefer the stored field, fall back to last message's modelId
|
||||||
let primaryModel = record.primaryModel ?? lastMsg?.modelId
|
let primaryModel = record.primaryModel ?? lastMsg?.modelId
|
||||||
@@ -574,7 +588,7 @@ final class DatabaseService: Sendable {
|
|||||||
convRecord.name = name
|
convRecord.name = name
|
||||||
}
|
}
|
||||||
|
|
||||||
convRecord.updatedAt = self.isoFormatter.string(from: Date())
|
convRecord.updatedAt = Self.isoString(from: Date())
|
||||||
try convRecord.update(db)
|
try convRecord.update(db)
|
||||||
|
|
||||||
if let messages = messages {
|
if let messages = messages {
|
||||||
@@ -589,7 +603,7 @@ final class DatabaseService: Sendable {
|
|||||||
content: msg.content,
|
content: msg.content,
|
||||||
tokens: msg.tokens,
|
tokens: msg.tokens,
|
||||||
cost: msg.cost,
|
cost: msg.cost,
|
||||||
timestamp: self.isoFormatter.string(from: msg.timestamp),
|
timestamp: Self.isoString(from: msg.timestamp),
|
||||||
sortOrder: index
|
sortOrder: index
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -610,7 +624,7 @@ final class DatabaseService: Sendable {
|
|||||||
let record = HistoryRecord(
|
let record = HistoryRecord(
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
input: input,
|
input: input,
|
||||||
timestamp: isoFormatter.string(from: now)
|
timestamp: Self.isoString(from: now)
|
||||||
)
|
)
|
||||||
|
|
||||||
try? dbQueue.write { db in
|
try? dbQueue.write { db in
|
||||||
@@ -643,7 +657,7 @@ final class DatabaseService: Sendable {
|
|||||||
.fetchAll(db)
|
.fetchAll(db)
|
||||||
|
|
||||||
return records.compactMap { record in
|
return records.compactMap { record in
|
||||||
guard let date = isoFormatter.date(from: record.timestamp) else {
|
guard let date = Self.isoDate(from: record.timestamp) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (input: record.input, timestamp: date)
|
return (input: record.input, timestamp: date)
|
||||||
@@ -659,7 +673,7 @@ final class DatabaseService: Sendable {
|
|||||||
.fetchAll(db)
|
.fetchAll(db)
|
||||||
|
|
||||||
return records.compactMap { record in
|
return records.compactMap { record in
|
||||||
guard let date = isoFormatter.date(from: record.timestamp) else {
|
guard let date = Self.isoDate(from: record.timestamp) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (input: record.input, timestamp: date)
|
return (input: record.input, timestamp: date)
|
||||||
@@ -672,7 +686,7 @@ final class DatabaseService: Sendable {
|
|||||||
nonisolated func saveEmailLog(_ log: EmailLog) {
|
nonisolated func saveEmailLog(_ log: EmailLog) {
|
||||||
let record = EmailLogRecord(
|
let record = EmailLogRecord(
|
||||||
id: log.id.uuidString,
|
id: log.id.uuidString,
|
||||||
timestamp: isoFormatter.string(from: log.timestamp),
|
timestamp: Self.isoString(from: log.timestamp),
|
||||||
sender: log.sender,
|
sender: log.sender,
|
||||||
subject: log.subject,
|
subject: log.subject,
|
||||||
emailContent: log.emailContent,
|
emailContent: log.emailContent,
|
||||||
@@ -698,7 +712,7 @@ final class DatabaseService: Sendable {
|
|||||||
.fetchAll(db)
|
.fetchAll(db)
|
||||||
|
|
||||||
return records.compactMap { record in
|
return records.compactMap { record in
|
||||||
guard let timestamp = isoFormatter.date(from: record.timestamp),
|
guard let timestamp = Self.isoDate(from: record.timestamp),
|
||||||
let status = EmailLogStatus(rawValue: record.status),
|
let status = EmailLogStatus(rawValue: record.status),
|
||||||
let id = UUID(uuidString: record.id) else {
|
let id = UUID(uuidString: record.id) else {
|
||||||
return nil
|
return nil
|
||||||
@@ -805,7 +819,7 @@ final class DatabaseService: Sendable {
|
|||||||
// MARK: - Embedding Operations
|
// MARK: - Embedding Operations
|
||||||
|
|
||||||
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
nonisolated func saveMessageEmbedding(messageId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
||||||
let now = isoFormatter.string(from: Date())
|
let now = Self.isoString(from: Date())
|
||||||
let record = MessageEmbeddingRecord(
|
let record = MessageEmbeddingRecord(
|
||||||
message_id: messageId.uuidString,
|
message_id: messageId.uuidString,
|
||||||
embedding: embedding,
|
embedding: embedding,
|
||||||
@@ -825,7 +839,7 @@ final class DatabaseService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
nonisolated func saveConversationEmbedding(conversationId: UUID, embedding: Data, model: String, dimension: Int) throws {
|
||||||
let now = isoFormatter.string(from: Date())
|
let now = Self.isoString(from: Date())
|
||||||
let record = ConversationEmbeddingRecord(
|
let record = ConversationEmbeddingRecord(
|
||||||
conversation_id: conversationId.uuidString,
|
conversation_id: conversationId.uuidString,
|
||||||
embedding: embedding,
|
embedding: embedding,
|
||||||
@@ -881,7 +895,7 @@ final class DatabaseService: Sendable {
|
|||||||
return Array(results.prefix(limit))
|
return Array(results.prefix(limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deserializeEmbedding(_ data: Data) -> [Float] {
|
private nonisolated func deserializeEmbedding(_ data: Data) -> [Float] {
|
||||||
var embedding: [Float] = []
|
var embedding: [Float] = []
|
||||||
embedding.reserveCapacity(data.count / 4)
|
embedding.reserveCapacity(data.count / 4)
|
||||||
|
|
||||||
@@ -905,7 +919,7 @@ final class DatabaseService: Sendable {
|
|||||||
model: String?,
|
model: String?,
|
||||||
tokenCount: Int?
|
tokenCount: Int?
|
||||||
) throws {
|
) throws {
|
||||||
let now = isoFormatter.string(from: Date())
|
let now = Self.isoString(from: Date())
|
||||||
let record = ConversationSummaryRecord(
|
let record = ConversationSummaryRecord(
|
||||||
id: UUID().uuidString,
|
id: UUID().uuidString,
|
||||||
conversation_id: conversationId.uuidString,
|
conversation_id: conversationId.uuidString,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ enum EmbeddingProvider {
|
|||||||
// MARK: - Embedding Service
|
// MARK: - Embedding Service
|
||||||
|
|
||||||
final class EmbeddingService {
|
final class EmbeddingService {
|
||||||
static let shared = EmbeddingService()
|
nonisolated static let shared = EmbeddingService()
|
||||||
|
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ final class EmbeddingService {
|
|||||||
// MARK: - Similarity Calculation
|
// MARK: - Similarity Calculation
|
||||||
|
|
||||||
/// Calculate cosine similarity between two embeddings
|
/// Calculate cosine similarity between two embeddings
|
||||||
func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
|
nonisolated func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
|
||||||
guard a.count == b.count else {
|
guard a.count == b.count else {
|
||||||
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
|
Log.api.error("Embedding dimension mismatch: \(a.count) vs \(b.count)")
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import CryptoKit
|
|||||||
import IOKit
|
import IOKit
|
||||||
|
|
||||||
class EncryptionService {
|
class EncryptionService {
|
||||||
static let shared = EncryptionService()
|
nonisolated static let shared = EncryptionService()
|
||||||
|
|
||||||
private let salt = "oAI-secure-storage-v1" // App-specific salt
|
private let salt = "oAI-secure-storage-v1" // App-specific salt
|
||||||
private lazy var encryptionKey: SymmetricKey = {
|
private lazy var encryptionKey: SymmetricKey = {
|
||||||
@@ -41,7 +41,7 @@ class EncryptionService {
|
|||||||
// MARK: - Public Interface
|
// MARK: - Public Interface
|
||||||
|
|
||||||
/// Encrypt a string value
|
/// Encrypt a string value
|
||||||
func encrypt(_ value: String) throws -> String {
|
nonisolated func encrypt(_ value: String) throws -> String {
|
||||||
guard let data = value.data(using: .utf8) else {
|
guard let data = value.data(using: .utf8) else {
|
||||||
throw EncryptionError.invalidInput
|
throw EncryptionError.invalidInput
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ class EncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a string value
|
/// Decrypt a string value
|
||||||
func decrypt(_ encryptedValue: String) throws -> String {
|
nonisolated func decrypt(_ encryptedValue: String) throws -> String {
|
||||||
guard let data = Data(base64Encoded: encryptedValue) else {
|
guard let data = Data(base64Encoded: encryptedValue) else {
|
||||||
throw EncryptionError.invalidInput
|
throw EncryptionError.invalidInput
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ class GitSyncService {
|
|||||||
|
|
||||||
// Check if conversation already exists (by ID)
|
// Check if conversation already exists (by ID)
|
||||||
if let existingId = UUID(uuidString: export.id) {
|
if let existingId = UUID(uuidString: export.id) {
|
||||||
if let existing = try? db.loadConversation(id: existingId) {
|
if (try? db.loadConversation(id: existingId)) != nil {
|
||||||
// Already exists - skip
|
// Already exists - skip
|
||||||
log.debug("Skipping existing conversation: \(export.name)")
|
log.debug("Skipping existing conversation: \(export.name)")
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
|||||||
@@ -300,6 +300,24 @@ class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Input bar height in points — default 80
|
||||||
|
var inputBarHeight: Double {
|
||||||
|
get { cache["inputBarHeight"].flatMap(Double.init) ?? 80.0 }
|
||||||
|
set {
|
||||||
|
cache["inputBarHeight"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "inputBarHeight", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the sidebar is visible — default true
|
||||||
|
var sidebarVisible: Bool {
|
||||||
|
get { cache["sidebarVisible"] != "false" }
|
||||||
|
set {
|
||||||
|
cache["sidebarVisible"] = String(newValue)
|
||||||
|
DatabaseService.shared.setSetting(key: "sidebarVisible", value: String(newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - MCP Permissions
|
// MARK: - MCP Permissions
|
||||||
|
|
||||||
var mcpCanWriteFiles: Bool {
|
var mcpCanWriteFiles: Bool {
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ final class UpdateCheckService {
|
|||||||
|
|
||||||
var updateAvailable: Bool = false
|
var updateAvailable: Bool = false
|
||||||
var latestVersion: String? = nil
|
var latestVersion: String? = nil
|
||||||
|
var downloadURL: URL? = nil
|
||||||
|
|
||||||
|
// Manual check state — drives the update alert in ContentView
|
||||||
|
var isCheckingManually: Bool = false
|
||||||
|
var manualCheckMessage: String? = nil
|
||||||
|
|
||||||
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
|
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
|
||||||
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
|
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
|
||||||
@@ -48,6 +53,24 @@ final class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manual check triggered from the Help menu. Non-blocking — result surfaces via manualCheckMessage.
|
||||||
|
func checkForUpdatesManually() {
|
||||||
|
guard !isCheckingManually else { return }
|
||||||
|
isCheckingManually = true
|
||||||
|
Task.detached(priority: .background) {
|
||||||
|
await self.performCheck()
|
||||||
|
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||||
|
await MainActor.run {
|
||||||
|
if self.updateAvailable, let v = self.latestVersion {
|
||||||
|
self.manualCheckMessage = String(localized: "Version \(v) is available.")
|
||||||
|
} else {
|
||||||
|
self.manualCheckMessage = String(localized: "You're up to date (v\(current)).")
|
||||||
|
}
|
||||||
|
self.isCheckingManually = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func performCheck() async {
|
private func performCheck() async {
|
||||||
guard let url = URL(string: apiURL) else { return }
|
guard let url = URL(string: apiURL) else { return }
|
||||||
|
|
||||||
@@ -69,9 +92,16 @@ final class UpdateCheckService {
|
|||||||
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
|
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
|
||||||
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
||||||
|
|
||||||
|
// Extract direct DMG download URL from release assets
|
||||||
|
let dmgURL: URL? = (release["assets"] as? [[String: Any]])?
|
||||||
|
.first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") }
|
||||||
|
.flatMap { $0["browser_download_url"] as? String }
|
||||||
|
.flatMap { URL(string: $0) }
|
||||||
|
|
||||||
if isNewer(latestVer, than: currentVer) {
|
if isNewer(latestVer, than: currentVer) {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.latestVersion = latestVer
|
self.latestVersion = latestVer
|
||||||
|
self.downloadURL = dmgURL
|
||||||
self.updateAvailable = true
|
self.updateAvailable = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,10 +60,11 @@ extension Color {
|
|||||||
|
|
||||||
static func providerColor(_ provider: Settings.Provider) -> Color {
|
static func providerColor(_ provider: Settings.Provider) -> Color {
|
||||||
switch provider {
|
switch provider {
|
||||||
case .openrouter: return Color(hex: "#7c3aed") // Purple
|
case .openrouter: return Color(hex: "#7c3aed") // Purple
|
||||||
case .anthropic: return Color(hex: "#d4895a") // Orange
|
case .anthropic: return Color(hex: "#d4895a") // Orange
|
||||||
case .openai: return Color(hex: "#10a37f") // Green
|
case .openai: return Color(hex: "#10a37f") // Green
|
||||||
case .ollama: return Color(hex: "#ffffff") // White
|
case .ollama: return Color(hex: "#ffffff") // White
|
||||||
|
case .appleOnDevice: return Color(hex: "#636366") // Apple grey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-18
@@ -114,41 +114,43 @@ final class FileLogger: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - App Logger (wraps os.Logger + file)
|
// MARK: - App Logger (wraps os.Logger + file)
|
||||||
|
|
||||||
struct AppLogger {
|
// os.Logger methods are @MainActor in macOS 27. AppLogger is Sendable and all methods are
|
||||||
let osLogger: Logger
|
// nonisolated — FileLogger runs on its own serial queue, os.Logger dispatches to main actor.
|
||||||
|
struct AppLogger: Sendable {
|
||||||
|
let subsystem: String
|
||||||
let category: String
|
let category: String
|
||||||
|
|
||||||
func debug(_ message: String) {
|
nonisolated func debug(_ message: String) {
|
||||||
FileLogger.shared.write(.debug, category: category, message: message)
|
FileLogger.shared.write(.debug, category: category, message: message)
|
||||||
osLogger.debug("\(message, privacy: .public)")
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).debug("\(message, privacy: .public)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
func info(_ message: String) {
|
nonisolated func info(_ message: String) {
|
||||||
FileLogger.shared.write(.info, category: category, message: message)
|
FileLogger.shared.write(.info, category: category, message: message)
|
||||||
osLogger.info("\(message, privacy: .public)")
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).info("\(message, privacy: .public)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
func warning(_ message: String) {
|
nonisolated func warning(_ message: String) {
|
||||||
FileLogger.shared.write(.warning, category: category, message: message)
|
FileLogger.shared.write(.warning, category: category, message: message)
|
||||||
osLogger.warning("\(message, privacy: .public)")
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).warning("\(message, privacy: .public)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
func error(_ message: String) {
|
nonisolated func error(_ message: String) {
|
||||||
FileLogger.shared.write(.error, category: category, message: message)
|
FileLogger.shared.write(.error, category: category, message: message)
|
||||||
osLogger.error("\(message, privacy: .public)")
|
Task { @MainActor [self] in Logger(subsystem: subsystem, category: category).error("\(message, privacy: .public)") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Log Namespace
|
// MARK: - Log Namespace
|
||||||
|
|
||||||
enum Log {
|
enum Log {
|
||||||
private static let subsystem = "com.oai.oAI"
|
private nonisolated static let subsystem = "com.oai.oAI"
|
||||||
|
|
||||||
static let api = AppLogger(osLogger: Logger(subsystem: subsystem, category: "api"), category: "api")
|
nonisolated static let api = AppLogger(subsystem: subsystem, category: "api")
|
||||||
static let db = AppLogger(osLogger: Logger(subsystem: subsystem, category: "database"), category: "database")
|
nonisolated static let db = AppLogger(subsystem: subsystem, category: "database")
|
||||||
static let mcp = AppLogger(osLogger: Logger(subsystem: subsystem, category: "mcp"), category: "mcp")
|
nonisolated static let mcp = AppLogger(subsystem: subsystem, category: "mcp")
|
||||||
static let settings = AppLogger(osLogger: Logger(subsystem: subsystem, category: "settings"), category: "settings")
|
nonisolated static let settings = AppLogger(subsystem: subsystem, category: "settings")
|
||||||
static let search = AppLogger(osLogger: Logger(subsystem: subsystem, category: "search"), category: "search")
|
nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
|
||||||
static let ui = AppLogger(osLogger: Logger(subsystem: subsystem, category: "ui"), category: "ui")
|
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
||||||
static let general = AppLogger(osLogger: Logger(subsystem: subsystem, category: "general"), category: "general")
|
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,6 +424,8 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
func inferProviderPublic(from modelId: String) -> Settings.Provider? { inferProvider(from: modelId) }
|
||||||
|
|
||||||
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
private func inferProvider(from modelId: String) -> Settings.Provider? {
|
||||||
|
// Apple Foundation Models
|
||||||
|
if modelId.hasPrefix("apple-") { return .appleOnDevice }
|
||||||
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
// OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet")
|
||||||
if modelId.contains("/") { return .openrouter }
|
if modelId.contains("/") { return .openrouter }
|
||||||
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
// Anthropic direct (e.g. "claude-sonnet-4-5-20250929")
|
||||||
@@ -1313,7 +1315,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
// Append the complete system prompt (default + custom)
|
// Append the complete system prompt (default + custom)
|
||||||
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
|
systemContent += "\n\n---\n\n" + effectiveSystemPrompt
|
||||||
|
|
||||||
var messagesToSend: [Message] = memoryEnabled
|
let messagesToSend: [Message] = memoryEnabled
|
||||||
? messages.filter { $0.role != .system }
|
? messages.filter { $0.role != .system }
|
||||||
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
|
: [messages.last(where: { $0.role == .user })].compactMap { $0 }
|
||||||
|
|
||||||
@@ -1659,7 +1661,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
|||||||
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
|
if isOverloaded && attempt < maxAttempts && !Task.isCancelled {
|
||||||
let delay = Double(1 << attempt) // 2s, 4s, 8s
|
let delay = Double(1 << attempt) // 2s, 4s, 8s
|
||||||
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
|
Log.api.warning("API overloaded, retrying in \(Int(delay))s (attempt \(attempt)/\(maxAttempts - 1))...")
|
||||||
await MainActor.run {
|
_ = await MainActor.run {
|
||||||
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
|
showSystemMessage("⏳ API overloaded, retrying in \(Int(delay))s… (attempt \(attempt)/\(maxAttempts - 1))")
|
||||||
}
|
}
|
||||||
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
|||||||
@@ -37,10 +37,6 @@ struct ChatView: View {
|
|||||||
HeaderView(
|
HeaderView(
|
||||||
provider: viewModel.currentProvider,
|
provider: viewModel.currentProvider,
|
||||||
model: viewModel.selectedModel,
|
model: viewModel.selectedModel,
|
||||||
stats: viewModel.sessionStats,
|
|
||||||
onlineMode: viewModel.onlineMode,
|
|
||||||
mcpEnabled: viewModel.mcpEnabled,
|
|
||||||
mcpStatus: viewModel.mcpStatus,
|
|
||||||
onModelSelect: onModelSelect,
|
onModelSelect: onModelSelect,
|
||||||
onProviderChange: onProviderChange
|
onProviderChange: onProviderChange
|
||||||
)
|
)
|
||||||
@@ -85,10 +81,13 @@ struct ChatView: View {
|
|||||||
InputBar(
|
InputBar(
|
||||||
text: $viewModel.inputText,
|
text: $viewModel.inputText,
|
||||||
isGenerating: viewModel.isGenerating,
|
isGenerating: viewModel.isGenerating,
|
||||||
mcpStatus: viewModel.mcpStatus,
|
|
||||||
onlineMode: viewModel.onlineMode,
|
onlineMode: viewModel.onlineMode,
|
||||||
onSend: viewModel.sendMessage,
|
onSend: viewModel.sendMessage,
|
||||||
onCancel: viewModel.cancelGeneration
|
onCancel: viewModel.cancelGeneration,
|
||||||
|
onToggleOnline: {
|
||||||
|
viewModel.onlineMode.toggle()
|
||||||
|
SettingsService.shared.onlineMode = viewModel.onlineMode
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
@@ -96,7 +95,9 @@ struct ChatView: View {
|
|||||||
stats: viewModel.sessionStats,
|
stats: viewModel.sessionStats,
|
||||||
conversationName: viewModel.currentConversationName,
|
conversationName: viewModel.currentConversationName,
|
||||||
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
hasUnsavedChanges: viewModel.hasUnsavedChanges,
|
||||||
onQuickSave: viewModel.quickSave
|
onQuickSave: viewModel.quickSave,
|
||||||
|
onlineMode: viewModel.onlineMode,
|
||||||
|
mcpEnabled: viewModel.mcpEnabled
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(Color.oaiBackground)
|
.background(Color.oaiBackground)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// ContentView.swift
|
// ContentView.swift
|
||||||
// oAI
|
// oAI
|
||||||
//
|
//
|
||||||
// Root navigation container
|
// Root navigation container — NavigationSplitView with collapsible sidebar
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
@@ -24,30 +24,34 @@
|
|||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import Darwin // uname, sysctlbyname
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(ChatViewModel.self) var chatViewModel
|
@Environment(ChatViewModel.self) var chatViewModel
|
||||||
|
private var updateService = UpdateCheckService.shared
|
||||||
|
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||||
|
@State private var showIntelWarning = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@Bindable var vm = chatViewModel
|
@Bindable var vm = chatViewModel
|
||||||
NavigationStack {
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||||
|
SidebarView()
|
||||||
|
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
|
||||||
|
} detail: {
|
||||||
ChatView(
|
ChatView(
|
||||||
onModelSelect: { chatViewModel.showModelSelector = true },
|
onModelSelect: { chatViewModel.showModelSelector = true },
|
||||||
onProviderChange: { newProvider in
|
onProviderChange: { newProvider in
|
||||||
chatViewModel.changeProvider(newProvider)
|
chatViewModel.changeProvider(newProvider)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.navigationTitle("")
|
|
||||||
.toolbar {
|
|
||||||
#if os(macOS)
|
|
||||||
macOSToolbar
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(minWidth: 860, minHeight: 560)
|
.frame(minWidth: 860, minHeight: 560)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
||||||
|
checkIntelWarning()
|
||||||
}
|
}
|
||||||
.onKeyPress(.return, phases: .down) { press in
|
.onKeyPress(.return, phases: .down) { press in
|
||||||
if press.modifiers.contains(.command) {
|
if press.modifiers.contains(.command) {
|
||||||
@@ -65,7 +69,6 @@ struct ContentView: View {
|
|||||||
let oldModel = chatViewModel.selectedModel
|
let oldModel = chatViewModel.selectedModel
|
||||||
chatViewModel.selectModel(model)
|
chatViewModel.selectModel(model)
|
||||||
chatViewModel.showModelSelector = false
|
chatViewModel.showModelSelector = false
|
||||||
// Trigger auto-save on model switch
|
|
||||||
Task {
|
Task {
|
||||||
await chatViewModel.onModelSwitch(from: oldModel, to: model)
|
await chatViewModel.onModelSwitch(from: oldModel, to: model)
|
||||||
}
|
}
|
||||||
@@ -113,125 +116,56 @@ struct ContentView: View {
|
|||||||
chatViewModel.inputText = input
|
chatViewModel.inputText = input
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
|
||||||
|
Button("Got It") {
|
||||||
|
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text("oAI v2.4 is the last version to support Intel Macs and Rosetta. Starting with macOS 28, oAI will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
|
||||||
|
}
|
||||||
|
.alert("Software Update", isPresented: Binding(
|
||||||
|
get: { updateService.manualCheckMessage != nil },
|
||||||
|
set: { if !$0 { updateService.manualCheckMessage = nil } }
|
||||||
|
)) {
|
||||||
|
if updateService.updateAvailable {
|
||||||
|
if let url = updateService.downloadURL {
|
||||||
|
Button("Download v\(updateService.latestVersion ?? "")") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Release Page") { updateService.openReleasesPage() }
|
||||||
|
Button("Later", role: .cancel) { }
|
||||||
|
} else {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(updateService.manualCheckMessage ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@ToolbarContentBuilder
|
private func checkIntelWarning() {
|
||||||
private var macOSToolbar: some ToolbarContent {
|
guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
|
||||||
let settings = SettingsService.shared
|
guard isIntelNative || isRosetta else { return }
|
||||||
let showLabels = settings.showToolbarLabels
|
showIntelWarning = true
|
||||||
let iconSize = settings.toolbarIconSize
|
}
|
||||||
|
|
||||||
ToolbarItemGroup(placement: .automatic) {
|
private var isIntelNative: Bool {
|
||||||
// New conversation
|
var systemInfo = utsname()
|
||||||
Button(action: { chatViewModel.newConversation() }) {
|
uname(&systemInfo)
|
||||||
ToolbarLabel(title: "New Chat", systemImage: "square.and.pencil", showLabels: showLabels, iconSize: iconSize)
|
let machine = withUnsafeBytes(of: &systemInfo.machine) {
|
||||||
}
|
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
|
||||||
.help("New conversation")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showConversations = true }) {
|
|
||||||
ToolbarLabel(title: "Conversations", systemImage: "clock.arrow.circlepath", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("l", modifiers: .command)
|
|
||||||
.help("Saved conversations (Cmd+L)")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHistory = true }) {
|
|
||||||
ToolbarLabel(title: "History", systemImage: "list.bullet", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("h", modifiers: .command)
|
|
||||||
.help("Command history (Cmd+H)")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showModelSelector = true }) {
|
|
||||||
ToolbarLabel(title: "Model", systemImage: "cpu", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("m", modifiers: .command)
|
|
||||||
.help("Select AI model (Cmd+M)")
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
if let model = chatViewModel.selectedModel {
|
|
||||||
chatViewModel.modelInfoTarget = model
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
ToolbarLabel(title: "Info", systemImage: "info.circle", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("i", modifiers: .command)
|
|
||||||
.help("Model info (Cmd+I)")
|
|
||||||
.disabled(chatViewModel.selectedModel == nil)
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showStats = true }) {
|
|
||||||
ToolbarLabel(title: "Stats", systemImage: "chart.bar", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.help("Session statistics")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showCredits = true }) {
|
|
||||||
ToolbarLabel(title: "Credits", systemImage: "creditcard", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.help("Check API credits")
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showSettings = true }) {
|
|
||||||
ToolbarLabel(title: "Settings", systemImage: "gearshape", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut(",", modifiers: .command)
|
|
||||||
.help("Settings (Cmd+,)")
|
|
||||||
|
|
||||||
Button(action: { chatViewModel.showHelp = true }) {
|
|
||||||
ToolbarLabel(title: "Help", systemImage: "questionmark.circle", showLabels: showLabels, iconSize: iconSize)
|
|
||||||
}
|
|
||||||
.keyboardShortcut("/", modifiers: .command)
|
|
||||||
.help("Help & commands (Cmd+/)")
|
|
||||||
}
|
}
|
||||||
|
return machine.contains("x86_64")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRosetta: Bool {
|
||||||
|
var ret: Int32 = 0
|
||||||
|
var size = MemoryLayout<Int32>.size
|
||||||
|
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
|
||||||
|
return ret == 1
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper view for toolbar labels
|
|
||||||
struct ToolbarLabel: View {
|
|
||||||
let title: LocalizedStringKey
|
|
||||||
let systemImage: String
|
|
||||||
let showLabels: Bool
|
|
||||||
let iconSize: Double
|
|
||||||
|
|
||||||
// imageScale for the original range (≤32); explicit font size for the new extra-large range (>32)
|
|
||||||
private var scale: Image.Scale {
|
|
||||||
switch iconSize {
|
|
||||||
case ...18: return .small
|
|
||||||
case 19...24: return .medium
|
|
||||||
default: return .large
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
if iconSize > 32 {
|
|
||||||
// Extra-large: explicit font size above the system .large ceiling
|
|
||||||
// Offset by 16 so slider 34→18pt, 36→20pt, 38→22pt, 40→24pt
|
|
||||||
if showLabels {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.labelStyle(.titleAndIcon)
|
|
||||||
.font(.system(size: iconSize - 16))
|
|
||||||
} else {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.font(.system(size: iconSize - 16))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Original behaviour — imageScale keeps existing look intact
|
|
||||||
if showLabels {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.labelStyle(.titleAndIcon)
|
|
||||||
.imageScale(scale)
|
|
||||||
} else {
|
|
||||||
Label(title, systemImage: systemImage)
|
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
.imageScale(scale)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -30,15 +30,22 @@ struct FooterView: View {
|
|||||||
let conversationName: String?
|
let conversationName: String?
|
||||||
let hasUnsavedChanges: Bool
|
let hasUnsavedChanges: Bool
|
||||||
let onQuickSave: (() -> Void)?
|
let onQuickSave: (() -> Void)?
|
||||||
|
let onlineMode: Bool
|
||||||
|
let mcpEnabled: Bool
|
||||||
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
init(stats: SessionStats,
|
init(stats: SessionStats,
|
||||||
conversationName: String? = nil,
|
conversationName: String? = nil,
|
||||||
hasUnsavedChanges: Bool = false,
|
hasUnsavedChanges: Bool = false,
|
||||||
onQuickSave: (() -> Void)? = nil) {
|
onQuickSave: (() -> Void)? = nil,
|
||||||
|
onlineMode: Bool = false,
|
||||||
|
mcpEnabled: Bool = false) {
|
||||||
self.stats = stats
|
self.stats = stats
|
||||||
self.conversationName = conversationName
|
self.conversationName = conversationName
|
||||||
self.hasUnsavedChanges = hasUnsavedChanges
|
self.hasUnsavedChanges = hasUnsavedChanges
|
||||||
self.onQuickSave = onQuickSave
|
self.onQuickSave = onQuickSave
|
||||||
|
self.onlineMode = onlineMode
|
||||||
|
self.mcpEnabled = mcpEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -71,6 +78,21 @@ struct FooterView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Status pills — Online, MCP, Sync
|
||||||
|
#if os(macOS)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if onlineMode {
|
||||||
|
StatusPill(icon: "globe", label: "Online", color: .green)
|
||||||
|
}
|
||||||
|
if mcpEnabled {
|
||||||
|
StatusPill(icon: "folder", label: "MCP", color: .blue)
|
||||||
|
}
|
||||||
|
if settings.syncEnabled && settings.syncAutoSave {
|
||||||
|
SyncStatusPill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Save indicator (only when chat has messages)
|
// Save indicator (only when chat has messages)
|
||||||
if stats.messageCount > 0 {
|
if stats.messageCount > 0 {
|
||||||
SaveIndicator(
|
SaveIndicator(
|
||||||
@@ -80,17 +102,11 @@ struct FooterView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update available badge
|
// Update available badge (shows only when an update exists — no version number)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
UpdateBadge()
|
UpdateBadge()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Shortcuts hint
|
|
||||||
#if os(macOS)
|
|
||||||
Text("⌘N New • ⌘M Model • ⌘S Save")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -242,7 +258,6 @@ struct SyncStatusFooter: View {
|
|||||||
|
|
||||||
struct UpdateBadge: View {
|
struct UpdateBadge: View {
|
||||||
private let updater = UpdateCheckService.shared
|
private let updater = UpdateCheckService.shared
|
||||||
private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?"
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if updater.updateAvailable {
|
if updater.updateAvailable {
|
||||||
@@ -258,10 +273,6 @@ struct UpdateBadge: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("A new version is available — click to open the releases page")
|
.help("A new version is available — click to open the releases page")
|
||||||
} else {
|
|
||||||
Text("v\(currentVersion)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// HeaderView.swift
|
// HeaderView.swift
|
||||||
// oAI
|
// oAI
|
||||||
//
|
//
|
||||||
// Header bar with provider, model, and stats
|
// Slim header — provider, model name, star only.
|
||||||
|
// Status pills and stats live in SidebarView and FooterView respectively.
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
@@ -28,18 +29,13 @@ import SwiftUI
|
|||||||
struct HeaderView: View {
|
struct HeaderView: View {
|
||||||
let provider: Settings.Provider
|
let provider: Settings.Provider
|
||||||
let model: ModelInfo?
|
let model: ModelInfo?
|
||||||
let stats: SessionStats
|
|
||||||
let onlineMode: Bool
|
|
||||||
let mcpEnabled: Bool
|
|
||||||
let mcpStatus: String?
|
|
||||||
let onModelSelect: () -> Void
|
let onModelSelect: () -> Void
|
||||||
let onProviderChange: (Settings.Provider) -> Void
|
let onProviderChange: (Settings.Provider) -> Void
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
private let registry = ProviderRegistry.shared
|
private let registry = ProviderRegistry.shared
|
||||||
private let gitSync = GitSyncService.shared
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: 12) {
|
||||||
// Provider picker dropdown — only shows configured providers
|
// Provider picker dropdown — only shows configured providers
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(registry.configuredProviders, id: \.self) { p in
|
ForEach(registry.configuredProviders, id: \.self) { p in
|
||||||
@@ -75,7 +71,7 @@ struct HeaderView: View {
|
|||||||
.fixedSize()
|
.fixedSize()
|
||||||
.help("Switch provider")
|
.help("Switch provider")
|
||||||
|
|
||||||
// Model info (clickable → model selector)
|
// Model name (clickable → model selector)
|
||||||
Button(action: onModelSelect) {
|
Button(action: onModelSelect) {
|
||||||
if let model = model {
|
if let model = model {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
@@ -116,7 +112,6 @@ struct HeaderView: View {
|
|||||||
Text("No model selected")
|
Text("No model selected")
|
||||||
.font(.system(size: settings.guiTextSize))
|
.font(.system(size: settings.guiTextSize))
|
||||||
.foregroundColor(.oaiSecondary)
|
.foregroundColor(.oaiSecondary)
|
||||||
|
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.oaiSecondary)
|
.foregroundColor(.oaiSecondary)
|
||||||
@@ -126,6 +121,7 @@ struct HeaderView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help("Select model")
|
.help("Select model")
|
||||||
|
|
||||||
|
// Favourite star
|
||||||
if let model = model {
|
if let model = model {
|
||||||
let isFav = settings.favoriteModelIds.contains(model.id)
|
let isFav = settings.favoriteModelIds.contains(model.id)
|
||||||
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
||||||
@@ -138,40 +134,9 @@ struct HeaderView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Status indicators
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if model?.capabilities.imageGeneration == true {
|
|
||||||
StatusPill(icon: "paintbrush", label: "Image", color: .purple)
|
|
||||||
}
|
|
||||||
if onlineMode {
|
|
||||||
StatusPill(icon: "globe", label: "Online", color: .green)
|
|
||||||
}
|
|
||||||
if mcpEnabled {
|
|
||||||
StatusPill(icon: "folder", label: "MCP", color: .blue)
|
|
||||||
}
|
|
||||||
if settings.syncEnabled && settings.syncAutoSave {
|
|
||||||
SyncStatusPill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Divider between status and stats
|
|
||||||
if onlineMode || mcpEnabled || model?.capabilities.imageGeneration == true || (settings.syncEnabled && settings.syncAutoSave) {
|
|
||||||
Divider()
|
|
||||||
.frame(height: 16)
|
|
||||||
.opacity(0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick stats
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
StatItem(icon: "message", value: "\(stats.messageCount)")
|
|
||||||
StatItem(icon: "arrow.up.arrow.down", value: stats.totalTokensDisplay)
|
|
||||||
StatItem(icon: "dollarsign", value: stats.totalCostDisplay)
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
.background(.ultraThinMaterial)
|
.background(.ultraThinMaterial)
|
||||||
.overlay(
|
.overlay(
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -182,22 +147,7 @@ struct HeaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatItem: View {
|
// MARK: - Status Pills (used by SidebarView)
|
||||||
let icon: String
|
|
||||||
let value: String
|
|
||||||
private let settings = SettingsService.shared
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: settings.guiTextSize - 3))
|
|
||||||
.foregroundColor(.oaiSecondary)
|
|
||||||
Text(value)
|
|
||||||
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
|
|
||||||
.foregroundColor(.oaiPrimary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StatusPill: View {
|
struct StatusPill: View {
|
||||||
let icon: String
|
let icon: String
|
||||||
@@ -284,15 +234,6 @@ struct SyncStatusPill: View {
|
|||||||
HeaderView(
|
HeaderView(
|
||||||
provider: .openrouter,
|
provider: .openrouter,
|
||||||
model: ModelInfo.mockModels.first,
|
model: ModelInfo.mockModels.first,
|
||||||
stats: SessionStats(
|
|
||||||
totalInputTokens: 125,
|
|
||||||
totalOutputTokens: 434,
|
|
||||||
totalCost: 0.00111,
|
|
||||||
messageCount: 4
|
|
||||||
),
|
|
||||||
onlineMode: true,
|
|
||||||
mcpEnabled: true,
|
|
||||||
mcpStatus: "MCP",
|
|
||||||
onModelSelect: {},
|
onModelSelect: {},
|
||||||
onProviderChange: { _ in }
|
onProviderChange: { _ in }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// InputBar.swift
|
// InputBar.swift
|
||||||
// oAI
|
// oAI
|
||||||
//
|
//
|
||||||
// Message input bar with status indicators
|
// Message input bar with resizable height and online toggle
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
// Copyright (C) 2026 Rune Olsen
|
// Copyright (C) 2026 Rune Olsen
|
||||||
@@ -24,20 +24,31 @@
|
|||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct InputBar: View {
|
struct InputBar: View {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
let isGenerating: Bool
|
let isGenerating: Bool
|
||||||
let mcpStatus: String?
|
|
||||||
let onlineMode: Bool
|
let onlineMode: Bool
|
||||||
let onSend: () -> Void
|
let onSend: () -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
|
let onToggleOnline: () -> Void
|
||||||
|
|
||||||
private let settings = SettingsService.shared
|
private let settings = SettingsService.shared
|
||||||
|
|
||||||
|
// Resizable input height — persisted to settings
|
||||||
|
@State private var inputHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
|
||||||
|
@State private var dragStartHeight: CGFloat = CGFloat(SettingsService.shared.inputBarHeight)
|
||||||
|
|
||||||
@State private var showCommandDropdown = false
|
@State private var showCommandDropdown = false
|
||||||
@State private var selectedSuggestionIndex: Int = 0
|
@State private var selectedSuggestionIndex: Int = 0
|
||||||
@FocusState private var isInputFocused: Bool
|
@FocusState private var isInputFocused: Bool
|
||||||
|
|
||||||
|
private static let minInputHeight: CGFloat = 56
|
||||||
|
private static let maxInputHeight: CGFloat = 320
|
||||||
|
|
||||||
/// Commands that execute immediately without additional arguments
|
/// Commands that execute immediately without additional arguments
|
||||||
private static let immediateCommands: Set<String> = [
|
private static let immediateCommands: Set<String> = [
|
||||||
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
|
"/help", "/history", "/model", "/clear", "/retry", "/stats", "/config",
|
||||||
@@ -56,48 +67,42 @@ struct InputBar: View {
|
|||||||
CommandSuggestionsView(
|
CommandSuggestionsView(
|
||||||
searchText: text,
|
searchText: text,
|
||||||
selectedIndex: selectedSuggestionIndex,
|
selectedIndex: selectedSuggestionIndex,
|
||||||
onSelect: { command in
|
onSelect: selectCommand
|
||||||
selectCommand(command)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
.frame(width: 400)
|
.frame(width: 400)
|
||||||
.frame(maxHeight: 200)
|
.frame(maxHeight: 200)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.leading, 96) // Align with input box (status badges + spacing)
|
.padding(.leading, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input area
|
// Drag-to-resize handle
|
||||||
HStack(alignment: .bottom, spacing: 12) {
|
dragHandle
|
||||||
// Status indicators
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
if let mcp = mcpStatus {
|
|
||||||
StatusBadge(text: mcp, color: .blue)
|
|
||||||
}
|
|
||||||
if onlineMode {
|
|
||||||
StatusBadge(text: "🌐", color: .green)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 80, alignment: .leading)
|
|
||||||
|
|
||||||
// Text input
|
// Input row
|
||||||
|
HStack(alignment: .bottom, spacing: 12) {
|
||||||
|
// Text input with globe toggle in bottom-left corner
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
|
// Placeholder
|
||||||
if text.isEmpty {
|
if text.isEmpty {
|
||||||
Text("Type a message or / for commands...")
|
Text("Type a message or / for commands...")
|
||||||
.font(.system(size: settings.inputTextSize))
|
.font(.system(size: settings.inputTextSize))
|
||||||
.foregroundColor(.oaiSecondary)
|
.foregroundColor(.oaiSecondary)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 10)
|
.padding(.top, 10)
|
||||||
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Editor — fills the fixed-height box, bottom area reserved for globe
|
||||||
TextEditor(text: $text)
|
TextEditor(text: $text)
|
||||||
.font(.system(size: settings.inputTextSize))
|
.font(.system(size: settings.inputTextSize))
|
||||||
.foregroundColor(.oaiPrimary)
|
.foregroundColor(.oaiPrimary)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.frame(minHeight: 44, maxHeight: 120)
|
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 6)
|
.padding(.top, 6)
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.focused($isInputFocused)
|
.focused($isInputFocused)
|
||||||
.onChange(of: text) {
|
.onChange(of: text) {
|
||||||
showCommandDropdown = text.hasPrefix("/")
|
showCommandDropdown = text.hasPrefix("/")
|
||||||
@@ -105,7 +110,6 @@ struct InputBar: View {
|
|||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.onKeyPress(.upArrow) {
|
.onKeyPress(.upArrow) {
|
||||||
// Navigate command dropdown
|
|
||||||
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
if showCommandDropdown && selectedSuggestionIndex > 0 {
|
||||||
selectedSuggestionIndex -= 1
|
selectedSuggestionIndex -= 1
|
||||||
return .handled
|
return .handled
|
||||||
@@ -113,7 +117,6 @@ struct InputBar: View {
|
|||||||
return .ignored
|
return .ignored
|
||||||
}
|
}
|
||||||
.onKeyPress(.downArrow) {
|
.onKeyPress(.downArrow) {
|
||||||
// Navigate command dropdown
|
|
||||||
if showCommandDropdown {
|
if showCommandDropdown {
|
||||||
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
let count = CommandSuggestionsView.filteredCommands(for: text).count
|
||||||
if selectedSuggestionIndex < count - 1 {
|
if selectedSuggestionIndex < count - 1 {
|
||||||
@@ -124,25 +127,12 @@ struct InputBar: View {
|
|||||||
return .ignored
|
return .ignored
|
||||||
}
|
}
|
||||||
.onKeyPress(.escape) {
|
.onKeyPress(.escape) {
|
||||||
// If command dropdown is showing, close it
|
if showCommandDropdown { showCommandDropdown = false; return .handled }
|
||||||
if showCommandDropdown {
|
if isGenerating { onCancel(); return .handled }
|
||||||
showCommandDropdown = false
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
// If model is generating, cancel it
|
|
||||||
if isGenerating {
|
|
||||||
onCancel()
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
return .ignored
|
return .ignored
|
||||||
}
|
}
|
||||||
.onKeyPress(.return, phases: .down) { press in
|
.onKeyPress(.return, phases: .down) { press in
|
||||||
// Shift+Return: always insert newline (let system handle)
|
if press.modifiers.contains(.shift) { return .ignored }
|
||||||
if press.modifiers.contains(.shift) {
|
|
||||||
return .ignored
|
|
||||||
}
|
|
||||||
|
|
||||||
// If command dropdown is showing, select the highlighted command
|
|
||||||
if showCommandDropdown {
|
if showCommandDropdown {
|
||||||
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
|
let suggestions = CommandSuggestionsView.filteredCommands(for: text)
|
||||||
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
|
if !suggestions.isEmpty && selectedSuggestionIndex < suggestions.count {
|
||||||
@@ -150,27 +140,40 @@ struct InputBar: View {
|
|||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Return (plain or with Cmd): send message
|
if !text.isEmpty { onSend(); return .handled }
|
||||||
if !text.isEmpty {
|
|
||||||
onSend()
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
// Empty text: do nothing
|
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Online / offline toggle — bottom-left of the text box
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Button(action: onToggleOnline) {
|
||||||
|
Image(systemName: onlineMode ? "globe" : "network.slash")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(onlineMode ? Color.green : Color.secondary)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(onlineMode
|
||||||
|
? "Online mode on — click to go offline"
|
||||||
|
: "Offline — click to go online")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.frame(height: inputHeight)
|
||||||
.background(Color.oaiSurface)
|
.background(Color.oaiSurface)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
.stroke(isInputFocused ? Color.oaiAccent : Color.oaiBorder, lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action buttons
|
// Send / stop + attach buttons
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
// File attach button
|
|
||||||
Button(action: pickFile) {
|
Button(action: pickFile) {
|
||||||
Image(systemName: "paperclip")
|
Image(systemName: "paperclip")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@@ -209,21 +212,47 @@ struct InputBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Drag handle
|
||||||
|
|
||||||
|
private var dragHandle: some View {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 8)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.overlay {
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.secondary.opacity(0.25))
|
||||||
|
.frame(width: 36, height: 3)
|
||||||
|
}
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 1)
|
||||||
|
.onChanged { value in
|
||||||
|
let proposed = dragStartHeight - value.translation.height
|
||||||
|
inputHeight = max(Self.minInputHeight, min(Self.maxInputHeight, proposed))
|
||||||
|
}
|
||||||
|
.onEnded { _ in
|
||||||
|
dragStartHeight = inputHeight
|
||||||
|
settings.inputBarHeight = Double(inputHeight)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#if os(macOS)
|
||||||
|
.onHover { hovering in
|
||||||
|
if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func selectCommand(_ command: String) {
|
private func selectCommand(_ command: String) {
|
||||||
showCommandDropdown = false
|
showCommandDropdown = false
|
||||||
if Self.immediateCommands.contains(command) {
|
if Self.immediateCommands.contains(command) {
|
||||||
// Execute immediately
|
|
||||||
text = command
|
text = command
|
||||||
onSend()
|
onSend()
|
||||||
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
} else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) {
|
||||||
if shortcut.needsInput {
|
text = shortcut.needsInput ? command + " " : command
|
||||||
text = command + " "
|
if !shortcut.needsInput { onSend() }
|
||||||
} else {
|
|
||||||
text = command
|
|
||||||
onSend()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Put in input for user to complete
|
|
||||||
text = command + " "
|
text = command + " "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,36 +264,14 @@ struct InputBar: View {
|
|||||||
panel.canChooseDirectories = false
|
panel.canChooseDirectories = false
|
||||||
panel.canChooseFiles = true
|
panel.canChooseFiles = true
|
||||||
panel.message = "Select files to attach"
|
panel.message = "Select files to attach"
|
||||||
|
|
||||||
guard panel.runModal() == .OK else { return }
|
guard panel.runModal() == .OK else { return }
|
||||||
|
let attachmentText = panel.urls.map { "@<\($0.path)>" }.joined(separator: " ")
|
||||||
let paths = panel.urls.map { $0.path }
|
text = text.isEmpty ? attachmentText + " " : text + " " + attachmentText
|
||||||
// Use @<path> format (angle brackets) to safely handle paths with spaces
|
|
||||||
let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ")
|
|
||||||
|
|
||||||
if text.isEmpty {
|
|
||||||
text = attachmentText + " "
|
|
||||||
} else {
|
|
||||||
text += " " + attachmentText
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StatusBadge: View {
|
// MARK: - Command suggestions
|
||||||
let text: String
|
|
||||||
let color: Color
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Text(text)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(color)
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(color.opacity(0.15))
|
|
||||||
.cornerRadius(4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct CommandSuggestionsView: View {
|
struct CommandSuggestionsView: View {
|
||||||
let searchText: String
|
let searchText: String
|
||||||
@@ -304,10 +311,9 @@ struct CommandSuggestionsView: View {
|
|||||||
]
|
]
|
||||||
|
|
||||||
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
|
static func allCommands() -> [(command: String, description: LocalizedStringKey)] {
|
||||||
let shortcuts = SettingsService.shared.userShortcuts.map { s in
|
SettingsService.shared.userShortcuts.map { s in
|
||||||
(s.command, LocalizedStringKey("⚡ \(s.description)"))
|
(s.command, LocalizedStringKey("⚡ \(s.description)"))
|
||||||
}
|
} + builtInCommands
|
||||||
return builtInCommands + shortcuts
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
|
static func filteredCommands(for searchText: String) -> [(command: String, description: LocalizedStringKey)] {
|
||||||
@@ -344,26 +350,20 @@ struct CommandSuggestionsView: View {
|
|||||||
.id(suggestion.command)
|
.id(suggestion.command)
|
||||||
|
|
||||||
if index < suggestions.count - 1 {
|
if index < suggestions.count - 1 {
|
||||||
Divider()
|
Divider().background(Color.oaiBorder)
|
||||||
.background(Color.oaiBorder)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedIndex) {
|
.onChange(of: selectedIndex) {
|
||||||
if selectedIndex < suggestions.count {
|
if selectedIndex < suggestions.count {
|
||||||
withAnimation {
|
withAnimation { proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center) }
|
||||||
proxy.scrollTo(suggestions[selectedIndex].command, anchor: .center)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.oaiSurface)
|
.background(Color.oaiSurface)
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.overlay(
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.oaiBorder, lineWidth: 1))
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.stroke(Color.oaiBorder, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,10 +373,10 @@ struct CommandSuggestionsView: View {
|
|||||||
InputBar(
|
InputBar(
|
||||||
text: .constant(""),
|
text: .constant(""),
|
||||||
isGenerating: false,
|
isGenerating: false,
|
||||||
mcpStatus: "📁 Files",
|
|
||||||
onlineMode: true,
|
onlineMode: true,
|
||||||
onSend: {},
|
onSend: {},
|
||||||
onCancel: {}
|
onCancel: {},
|
||||||
|
onToggleOnline: {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.background(Color.oaiBackground)
|
.background(Color.oaiBackground)
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//
|
||||||
|
// SidebarView.swift
|
||||||
|
// oAI
|
||||||
|
//
|
||||||
|
// Collapsible sidebar: new chat, conversation list, status pills
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
// Copyright (C) 2026 Rune Olsen
|
||||||
|
//
|
||||||
|
// This file is part of oAI.
|
||||||
|
//
|
||||||
|
// oAI is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as
|
||||||
|
// published by the Free Software Foundation, either version 3 of the
|
||||||
|
// License, or (at your option) any later version.
|
||||||
|
//
|
||||||
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
||||||
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
||||||
|
// Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public
|
||||||
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct SidebarView: View {
|
||||||
|
@Environment(ChatViewModel.self) private var chatViewModel
|
||||||
|
@State private var conversations: [Conversation] = []
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
private var filteredConversations: [Conversation] {
|
||||||
|
guard !searchText.isEmpty else { return conversations }
|
||||||
|
return conversations.filter { $0.name.lowercased().contains(searchText.lowercased()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// New Chat button
|
||||||
|
Button(action: { chatViewModel.newConversation() }) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "square.and.pencil")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
Text("New Chat")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.foregroundColor(.oaiPrimary)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
// Search field
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("Search conversations…", text: $searchText)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
Button {
|
||||||
|
searchText = ""
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
Divider().frame(height: 12)
|
||||||
|
Button {
|
||||||
|
chatViewModel.showConversations = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Advanced search — semantic search, bulk delete, export")
|
||||||
|
}
|
||||||
|
.padding(7)
|
||||||
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Conversation list
|
||||||
|
if filteredConversations.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: searchText.isEmpty ? "tray" : "magnifyingglass")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(searchText.isEmpty ? "No Saved Conversations" : "No Matches")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(filteredConversations) { conversation in
|
||||||
|
SidebarConversationRow(conversation: conversation)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
chatViewModel.loadConversation(conversation)
|
||||||
|
}
|
||||||
|
.listRowBackground(
|
||||||
|
chatViewModel.currentConversationName == conversation.name
|
||||||
|
? Color.oaiAccent.opacity(0.15)
|
||||||
|
: Color.clear
|
||||||
|
)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteConversation(conversation)
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
renameConversation(conversation)
|
||||||
|
} label: {
|
||||||
|
Label("Rename", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.sidebar)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
.onAppear { loadConversations() }
|
||||||
|
.onChange(of: chatViewModel.currentConversationName) { loadConversations() }
|
||||||
|
.onChange(of: chatViewModel.messages.count) { loadConversations() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadConversations() {
|
||||||
|
conversations = (try? DatabaseService.shared.listConversations()) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteConversation(_ conversation: Conversation) {
|
||||||
|
_ = try? DatabaseService.shared.deleteConversation(id: conversation.id)
|
||||||
|
withAnimation {
|
||||||
|
conversations.removeAll { $0.id == conversation.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func renameConversation(_ conversation: Conversation) {
|
||||||
|
#if os(macOS)
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Rename Conversation"
|
||||||
|
alert.addButton(withTitle: "Rename")
|
||||||
|
alert.addButton(withTitle: "Cancel")
|
||||||
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
||||||
|
input.stringValue = conversation.name
|
||||||
|
input.selectText(nil)
|
||||||
|
alert.accessoryView = input
|
||||||
|
alert.window.initialFirstResponder = input
|
||||||
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||||
|
let newName = input.stringValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !newName.isEmpty, newName != conversation.name else { return }
|
||||||
|
do {
|
||||||
|
_ = try DatabaseService.shared.updateConversation(id: conversation.id, name: newName, messages: nil)
|
||||||
|
if let i = conversations.firstIndex(where: { $0.id == conversation.id }) {
|
||||||
|
conversations[i].name = newName
|
||||||
|
conversations[i].updatedAt = Date()
|
||||||
|
}
|
||||||
|
chatViewModel.didRenameConversation(id: conversation.id, newName: newName)
|
||||||
|
} catch {
|
||||||
|
Log.db.error("Failed to rename conversation: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sidebar conversation row
|
||||||
|
|
||||||
|
struct SidebarConversationRow: View {
|
||||||
|
let conversation: Conversation
|
||||||
|
|
||||||
|
private var formattedDate: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "dd.MM.yyyy"
|
||||||
|
return formatter.string(from: conversation.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(conversation.name)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text("^[\(conversation.messageCount) message](inflect: true)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Text("·")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Text(formattedDate)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SidebarView()
|
||||||
|
.environment(ChatViewModel())
|
||||||
|
.frame(width: 240, height: 600)
|
||||||
|
}
|
||||||
@@ -80,6 +80,17 @@ struct CreditsView: View {
|
|||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundColor(.green)
|
.foregroundColor(.green)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|
||||||
|
case .appleOnDevice:
|
||||||
|
Text("Apple Intelligence")
|
||||||
|
.font(.headline)
|
||||||
|
Text("On-device and free — no credits or API key needed.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Image(systemName: "apple.logo")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -306,6 +307,29 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apple Intelligence
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
sectionHeader("Apple Intelligence")
|
||||||
|
formSection {
|
||||||
|
row("Status") {
|
||||||
|
appleIntelligenceStatusBadge
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("Model") {
|
||||||
|
Text("On-Device (4K context)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
rowDivider()
|
||||||
|
row("") {
|
||||||
|
Button("Open Apple Intelligence Settings") {
|
||||||
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.aisettings") {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Features
|
// Features
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
sectionHeader("Features")
|
sectionHeader("Features")
|
||||||
@@ -2649,6 +2673,28 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
|||||||
formatter.unitsStyle = .full
|
formatter.unitsStyle = .full
|
||||||
return formatter.localizedString(for: date, relativeTo: .now)
|
return formatter.localizedString(for: date, relativeTo: .now)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var appleIntelligenceStatusBadge: some View {
|
||||||
|
let availability = SystemLanguageModel.default.availability
|
||||||
|
switch availability {
|
||||||
|
case .available:
|
||||||
|
Label("Available", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
case .unavailable(.deviceNotEligible):
|
||||||
|
Label("Not supported on this Mac", systemImage: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
case .unavailable(.appleIntelligenceNotEnabled):
|
||||||
|
Label("Not enabled — open Apple Intelligence Settings", systemImage: "exclamationmark.circle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
case .unavailable(.modelNotReady):
|
||||||
|
Label("Model downloading…", systemImage: "arrow.down.circle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
default:
|
||||||
|
Label("Unavailable", systemImage: "questionmark.circle.fill")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ struct oAIApp: App {
|
|||||||
CommandGroup(after: .newItem) {
|
CommandGroup(after: .newItem) {
|
||||||
Button("Open Chat…") { chatViewModel.showConversations = true }
|
Button("Open Chat…") { chatViewModel.showConversations = true }
|
||||||
.keyboardShortcut("o", modifiers: .command)
|
.keyboardShortcut("o", modifiers: .command)
|
||||||
|
Button("Search Conversations") { chatViewModel.showConversations = true }
|
||||||
|
.keyboardShortcut("l", modifiers: .command)
|
||||||
}
|
}
|
||||||
|
|
||||||
CommandGroup(replacing: .saveItem) {
|
CommandGroup(replacing: .saveItem) {
|
||||||
@@ -113,10 +115,44 @@ struct oAIApp: App {
|
|||||||
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── View menu ─────────────────────────────────────────────────
|
||||||
|
CommandMenu("View") {
|
||||||
|
Button("Select Model") { chatViewModel.showModelSelector = true }
|
||||||
|
.keyboardShortcut("m", modifiers: .command)
|
||||||
|
|
||||||
|
Button("Model Info") {
|
||||||
|
chatViewModel.modelInfoTarget = chatViewModel.selectedModel
|
||||||
|
}
|
||||||
|
.keyboardShortcut("i", modifiers: .command)
|
||||||
|
.disabled(chatViewModel.selectedModel == nil)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button("Command History") { chatViewModel.showHistory = true }
|
||||||
|
.keyboardShortcut("h", modifiers: .command)
|
||||||
|
|
||||||
|
Button("In-App Help") { chatViewModel.showHelp = true }
|
||||||
|
.keyboardShortcut("/", modifiers: .command)
|
||||||
|
|
||||||
|
Button("Credits") { chatViewModel.showCredits = true }
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
|
||||||
|
chatViewModel.onlineMode.toggle()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("o", modifiers: [.command, .shift])
|
||||||
|
}
|
||||||
|
|
||||||
// ── Help menu ─────────────────────────────────────────────────
|
// ── Help menu ─────────────────────────────────────────────────
|
||||||
CommandGroup(replacing: .help) {
|
CommandGroup(replacing: .help) {
|
||||||
Button("oAI Help") { openHelp() }
|
Button("oAI Help") { openHelp() }
|
||||||
.keyboardShortcut("?", modifiers: .command)
|
.keyboardShortcut("?", modifiers: .command)
|
||||||
|
Divider()
|
||||||
|
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
|
||||||
|
UpdateCheckService.shared.checkForUpdatesManually()
|
||||||
|
}
|
||||||
|
.disabled(UpdateCheckService.shared.isCheckingManually)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
Reference in New Issue
Block a user