diff --git a/.gitignore b/.gitignore index cab7726..ad201ee 100644 --- a/.gitignore +++ b/.gitignore @@ -116,4 +116,5 @@ Temporary Items CLAUDE.md ANTHROPIC_DEVELOPER_PROMPT.txt GIT_SYNC_PHASE1_COMPLETE.md -build-dmg.sh \ No newline at end of file +build-dmg.sh +.claude/ diff --git a/oAI.xcodeproj/project.pbxproj b/oAI.xcodeproj/project.pbxproj index d02b9da..1b40277 100644 --- a/oAI.xcodeproj/project.pbxproj +++ b/oAI.xcodeproj/project.pbxproj @@ -279,7 +279,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.2; + MARKETING_VERSION = 2.3; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -323,7 +323,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 2.2; + MARKETING_VERSION = 2.3; PRODUCT_BUNDLE_IDENTIFIER = com.oai.oAI; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/oAI/Models/AgentSkill.swift b/oAI/Models/AgentSkill.swift new file mode 100644 index 0000000..403ac83 --- /dev/null +++ b/oAI/Models/AgentSkill.swift @@ -0,0 +1,42 @@ +// +// AgentSkill.swift +// oAI +// +// SKILL.md-style behavioral skills — markdown instruction files injected into the system prompt +// + +import Foundation + +struct AgentSkill: Codable, Identifiable { + var id: UUID + var name: String // display name, e.g. "Code Review" + var skillDescription: String // short summary shown in the list + var content: String // full markdown content (the actual instructions) + var isActive: Bool // when true, injected into the system prompt + var createdAt: Date + var updatedAt: Date + + init(id: UUID = UUID(), name: String, skillDescription: String = "", content: String, + isActive: Bool = true, createdAt: Date = Date(), updatedAt: Date = Date()) { + self.id = id + self.name = name + self.skillDescription = skillDescription + self.content = content + self.isActive = isActive + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// Extract a brief description from the content if skillDescription is empty + var resolvedDescription: String { + guard skillDescription.isEmpty else { return skillDescription } + // Return first non-heading, non-empty line + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty && !trimmed.hasPrefix("#") { + return String(trimmed.prefix(100)) + } + } + return name + } +} diff --git a/oAI/Models/Skill.swift b/oAI/Models/Skill.swift new file mode 100644 index 0000000..09b2cdc --- /dev/null +++ b/oAI/Models/Skill.swift @@ -0,0 +1,32 @@ +// +// Shortcut.swift +// oAI +// +// User-defined slash command templates (prompt shortcuts/macros) +// + +import Foundation + +struct Shortcut: Codable, Identifiable { + var id: UUID + var command: String // e.g. "/summarize" (always starts with /) + var description: String // shown in dropdown + var template: String // prompt text, may contain {{input}} + var createdAt: Date + var updatedAt: Date + + init(id: UUID = UUID(), command: String, description: String, template: String, + createdAt: Date = Date(), updatedAt: Date = Date()) { + self.id = id + self.command = command + self.description = description + self.template = template + self.createdAt = createdAt + self.updatedAt = updatedAt + } + + /// True when the template uses {{input}} and needs the user to provide text + var needsInput: Bool { + template.contains("{{input}}") + } +} diff --git a/oAI/Providers/ProviderRegistry.swift b/oAI/Providers/ProviderRegistry.swift index 711f3a9..9f67956 100644 --- a/oAI/Providers/ProviderRegistry.swift +++ b/oAI/Providers/ProviderRegistry.swift @@ -36,15 +36,11 @@ class ProviderRegistry { provider = OpenRouterProvider(apiKey: apiKey) case .anthropic: - if AnthropicOAuthService.shared.isAuthenticated { - // OAuth (Pro/Max subscription) takes precedence - provider = AnthropicProvider(oauth: true) - } else if let apiKey = settings.anthropicAPIKey, !apiKey.isEmpty { - provider = AnthropicProvider(apiKey: apiKey) - } else { - Log.api.warning("No API key or OAuth configured for Anthropic") + guard let apiKey = settings.anthropicAPIKey, !apiKey.isEmpty else { + Log.api.warning("No API key configured for Anthropic") return nil } + provider = AnthropicProvider(apiKey: apiKey) case .openai: guard let apiKey = settings.openaiAPIKey, !apiKey.isEmpty else { diff --git a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html index 262a345..751acec 100644 --- a/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html +++ b/oAI/Resources/oAI.help/Contents/Resources/en.lproj/index.html @@ -33,6 +33,8 @@
  • Managing Conversations
  • Git Sync (Backup & Sync)
  • Email Handler (AI Assistant)
  • +
  • Shortcuts (Prompt Templates)
  • +
  • Agent Skills (SKILL.md)
  • Keyboard Shortcuts
  • Settings
  • System Prompts
  • @@ -234,6 +236,18 @@
    Enable/disable write permissions
    +

    Shortcuts & Skills Commands

    +
    +
    /shortcuts
    +
    Open the Shortcuts manager to create, edit, and manage your custom prompt template commands
    + +
    /skills
    +
    Open the Agent Skills manager to install and manage SKILL.md-style behavioral instruction files
    + +
    /<your-command>
    +
    Run any shortcut you've created. Example: /summarize to run your "Summarize" shortcut
    +
    +

    Settings Commands

    /config, /settings
    @@ -929,6 +943,218 @@ AI Assistant + +
    +

    Shortcuts (Prompt Templates)

    +

    Shortcuts are personal slash commands you define yourself. Each shortcut expands to a prompt template and can optionally ask for input. Think of them as saved prompts with custom command names.

    + +
    + 💡 Example: Create a /summarize shortcut that sends "Please summarize the following text concisely:\n\n{{input}}" — then just type /summarize and paste any text after it. +
    + +

    Creating a Shortcut

    +
      +
    1. Type /shortcuts or go to Settings → Shortcuts tab
    2. +
    3. Click New Shortcut
    4. +
    5. Set a command (e.g. /summarize)
    6. +
    7. Add a short description shown in the dropdown
    8. +
    9. Write your prompt template
    10. +
    11. Click Save
    12. +
    + +

    Using {{input}} Placeholder

    +

    If your template contains {{input}}, the AI waits for you to type something after the command. Your text replaces {{input}} before sending.

    + +
    + With {{input}}: +
    Command: /translate-no
    +Template: Translate the following text to Norwegian:\n\n{{input}}
    +
    +Usage: /translate-no Hello, how are you today?
    +→ Sends: "Translate the following text to Norwegian:\n\nHello, how are you today?"
    +
    + +

    If your template has no {{input}}, the shortcut executes immediately when selected — no extra input needed.

    + +
    + Without {{input}}: +
    Command: /hello
    +Template: Say hello in 5 different languages with a brief cultural note for each.
    +
    +Usage: Type /hello → executes immediately
    +
    + +

    Shortcuts in the Command Dropdown

    +

    Your shortcuts appear in the / dropdown with a prefix. Type the first letters of your command to filter them.

    + +

    More Shortcut Examples

    +
    +
    /roast     "Give me a light-hearted roast of this text: {{input}}"
    +/eli5      "Explain {{input}} as if I were 5 years old"
    +/debug     "Find the bug in this code and explain the fix:\n\n{{input}}"
    +/tweet     "Rewrite this as a punchy tweet under 280 chars: {{input}}"
    +/standup   "Based on these notes, write a daily standup update: {{input}}"
    +
    + +

    Import & Export

    + +
    + + +
    +

    Agent Skills (SKILL.md)

    +

    Agent Skills are markdown instruction files that teach the AI how to behave. Active skills are automatically injected into the system prompt for every conversation — the AI reads them and applies the instructions when relevant.

    + +

    Skills follow the SKILL.md open standard, making them compatible with a growing ecosystem of skill marketplaces and community collections.

    + +
    + 💡 Example: Install a "Code Review" skill and the AI will automatically apply code review best practices whenever you share code — no need to ask every time. +
    + +

    Installing a Skill

    +
      +
    1. Type /skills or go to Settings → Skills tab
    2. +
    3. Click Import to load a .md file or a .zip skill bundle, or
    4. +
    5. Click New Skill to write your own from scratch
    6. +
    7. Toggle the skill Active to inject it into the system prompt
    8. +
    + +

    SKILL.md Format

    +

    A skill is a plain Markdown file. The first # Heading becomes the skill name. Write instructions in natural language — the AI decides when to apply them.

    + +
    +
    # Security Auditor
    +
    +When reviewing code, always:
    +- Check for injection vulnerabilities (SQL, command, XSS)
    +- Look for insecure storage of credentials or secrets
    +- Verify authentication and authorization logic
    +- Suggest specific fixes, not just observations
    +- Rate severity: Critical / High / Medium / Low
    +
    + +
    +
    # Norwegian Translator
    +
    +Whenever the user asks you to translate something, translate it to Norwegian Bokmål.
    +- Maintain the original tone and register
    +- Keep technical terms in English unless there is a well-established Norwegian equivalent
    +- If the text is already in Norwegian, translate it to English instead
    +
    + +

    Writing Your Own Skills

    + + +

    How Skills Are Applied

    +

    Active skills are appended to the system prompt under an "Installed Skills" section. The AI sees them with every message and applies relevant guidance automatically. You can toggle skills on/off without deleting them.

    + +

    Skill Support Files

    +

    Each skill can have data files attached — JSON, YAML, CSV, TXT, or any plain-text format. When a skill is active, its files are injected into the system prompt right after the skill's markdown content, so the AI can read and reason over them.

    + +
    + 💡 Example use cases: +
      +
    • news_sources.json — a list of URLs for a "Daily AI News" skill
    • +
    • search_queries.md — template search strings for a research skill
    • +
    • output_templates.md — report formats for a writing skill
    • +
    • config.yaml — parameters a skill should follow
    • +
    +
    + +

    Adding Files to a Skill

    +
      +
    1. Open the skill editor (click Edit on any skill)
    2. +
    3. Scroll to the Files section at the bottom
    4. +
    5. Click Add File and select one or more text files
    6. +
    7. Files appear in a list with name and size
    8. +
    9. Click the trash icon next to any file to remove it
    10. +
    + +

    How Files Are Injected

    +

    In the system prompt, each file is included as a labelled fenced code block immediately after the skill's instructions:

    +
    +
    ### Daily AI News
    +
    +[skill instructions here]
    +
    +**Skill Data Files:**
    +
    +**news_sources.json:**
    +```json
    +{ "sources": [...] }
    +```
    +
    +**search_queries.md:**
    +```md
    +...
    +```
    +
    + +
    + ⚠️ Token budget: Large files inflate the system prompt. A warning appears in the editor if any file exceeds 200 KB. For very large datasets, consider summarizing or splitting the data. +
    + +

    File Storage

    +

    Files are stored locally at:

    +
    +
    ~/Library/Application Support/oAI/skills/<skill-uuid>/
    +├── news_sources.json
    +└── search_queries.md
    +
    +

    Deleting a skill also deletes its file directory automatically.

    + +

    Import & Export

    + +

    Importing

    +

    Click Import in the Skills manager. You can select:

    + +

    If a skill with the same name already exists, it is replaced. Data files from a zip are merged into the existing skill's directory.

    + +

    Exporting

    + + +
    + 💡 Sharing skills: Zip bundles are the recommended format for sharing skills that include reference data — import the .zip directly on another machine to restore both the instructions and all attached files. +
    + +

    Finding Skills to Download

    +

    The SKILL.md community has produced hundreds of ready-made skills you can import directly:

    + + +
    + Learn more: Read the SKILL.md open standard article to understand the format in depth, or browse the Anthropic skills repository for high-quality examples. +
    +
    +

    Keyboard Shortcuts

    @@ -999,6 +1225,81 @@ AI Assistant
  • Configure gitignore respect
  • +

    Sync Tab

    +

    Configure Git synchronization for backing up and syncing conversations across devices.

    + + +
    + 💡 Tip: Use access tokens instead of passwords for GitHub (Settings → Developer settings → Personal access tokens). Enable auto-sync on your primary machine only to avoid conflicts. +
    + +

    Email Tab

    +

    Configure AI-powered email auto-responder to automatically reply to incoming emails.

    + + +
    + ⚠️ Security Note: All email credentials are encrypted with AES-256-GCM. For Gmail, use app-specific passwords (not your main password). Keep your subject identifier private to prevent abuse. +
    + +

    Shortcuts Tab

    +

    Create and manage personal prompt template commands. See Shortcuts section for full details.

    + +

    Skills Tab

    +

    Manage SKILL.md-style behavioral instruction files injected into the system prompt. See Agent Skills section for full details.

    +

    Appearance Tab

    @@ -1064,6 +1368,7 @@ AI Assistant
  • Default Prompt (always included)
  • + Your Custom Prompt (if set)
  • + MCP Instructions (if MCP is enabled)
  • +
  • + Active Agent Skills (if any skills are active)
  • This combined prompt is sent with every message to ensure consistent behavior.

    @@ -1094,7 +1399,7 @@ AI Assistant diff --git a/oAI/Services/AgentSkillFilesService.swift b/oAI/Services/AgentSkillFilesService.swift new file mode 100644 index 0000000..cee7ea0 --- /dev/null +++ b/oAI/Services/AgentSkillFilesService.swift @@ -0,0 +1,71 @@ +// +// AgentSkillFilesService.swift +// oAI +// +// Manages per-skill file directories in Application Support/oAI/skills// +// + +import Foundation +import UniformTypeIdentifiers + +final class AgentSkillFilesService { + static let shared = AgentSkillFilesService() + + private let baseDirectory: URL = { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, + in: .userDomainMask).first! + return appSupport.appendingPathComponent("oAI/skills", isDirectory: true) + }() + + func skillDirectory(for id: UUID) -> URL { + baseDirectory.appendingPathComponent(id.uuidString, isDirectory: true) + } + + func ensureDirectory(for id: UUID) { + try? FileManager.default.createDirectory( + at: skillDirectory(for: id), withIntermediateDirectories: true) + } + + func listFiles(for id: UUID) -> [URL] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: skillDirectory(for: id), + includingPropertiesForKeys: [.fileSizeKey], + options: .skipsHiddenFiles) + else { return [] } + return contents.sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + func addFile(from sourceURL: URL, to id: UUID) throws { + ensureDirectory(for: id) + let dest = skillDirectory(for: id).appendingPathComponent(sourceURL.lastPathComponent) + if FileManager.default.fileExists(atPath: dest.path) { + try FileManager.default.removeItem(at: dest) + } + try FileManager.default.copyItem(at: sourceURL, to: dest) + } + + func deleteFile(at url: URL) { + try? FileManager.default.removeItem(at: url) + } + + func deleteAll(for id: UUID) { + try? FileManager.default.removeItem(at: skillDirectory(for: id)) + } + + func hasFiles(for id: UUID) -> Bool { + !listFiles(for: id).isEmpty + } + + /// Returns (filename, content) for all readable text files + func readTextFiles(for id: UUID) -> [(name: String, content: String)] { + listFiles(for: id).compactMap { url in + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } + return (url.lastPathComponent, content) + } + } + + /// Returns file size in bytes, or nil if unavailable + func fileSize(at url: URL) -> Int? { + (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize + } +} diff --git a/oAI/Services/AnytypeMCPService.swift b/oAI/Services/AnytypeMCPService.swift new file mode 100644 index 0000000..056b866 --- /dev/null +++ b/oAI/Services/AnytypeMCPService.swift @@ -0,0 +1,542 @@ +// +// AnytypeMCPService.swift +// oAI +// +// Anytype MCP integration via local HTTP API at http://127.0.0.1:31009 +// + +import Foundation +import os + +@Observable +class AnytypeMCPService { + static let shared = AnytypeMCPService() + + private let settings = SettingsService.shared + private let log = Logger(subsystem: "com.oai.oAI", category: "mcp") + private let apiVersion = "2025-11-08" + private let timeout: TimeInterval = 10 + + private(set) var isConnected = false + + private init() {} + + // MARK: - Connection Test + + func testConnection() async -> Result { + do { + let result = try await request(endpoint: "/v1/spaces", method: "GET", body: nil) + if let spaces = result["data"] as? [[String: Any]] { + isConnected = true + return .success("Connected (\(spaces.count) space\(spaces.count == 1 ? "" : "s"))") + } else { + isConnected = true + return .success("Connected to Anytype") + } + } catch { + isConnected = false + return .failure(error) + } + } + + // MARK: - Tool Schemas + + func getToolSchemas() -> [Tool] { + return [ + makeTool( + name: "anytype_search_global", + description: "Search across all Anytype spaces for objects matching a query. Returns matching objects with their IDs, names, types, and space info.", + properties: [ + "query": prop("string", "The search query text"), + "limit": prop("number", "Maximum number of results to return (default: 20)") + ], + required: ["query"] + ), + makeTool( + name: "anytype_list_spaces", + description: "List all available Anytype spaces (workspaces). Returns space IDs, names, and basic info.", + properties: [:], + required: [] + ), + makeTool( + name: "anytype_get_space_objects", + description: "Get objects in a specific Anytype space. Returns a list of objects with their IDs, names, and types. Use search tools to find specific objects rather than listing all.", + properties: [ + "space_id": prop("string", "The ID of the Anytype space"), + "limit": prop("number", "Maximum number of objects to return (default: 20, max recommended: 50)") + ], + required: ["space_id"] + ), + makeTool( + name: "anytype_get_object", + description: "Get the full details and content of a specific Anytype object by ID. The 'body' field contains the COMPLETE markdown including Anytype internal links (anytype://object?...) and file links that MUST be preserved exactly when updating.", + properties: [ + "space_id": prop("string", "The ID of the space containing the object"), + "object_id": prop("string", "The ID of the object to retrieve") + ], + required: ["space_id", "object_id"] + ), + makeTool( + name: "anytype_create_object", + description: "Create a new object (note, task, or page) in an Anytype space.", + properties: [ + "space_id": prop("string", "The ID of the space to create the object in"), + "name": prop("string", "The name/title of the object"), + "body": prop("string", "The text content/body of the object"), + "type": prop("string", "The type of object: 'note', 'task', or 'page' (default: note)") + ], + required: ["space_id", "name"] + ), + makeTool( + name: "anytype_update_object", + description: """ + Replace the full markdown body or rename an Anytype object. \ + IMPORTANT: For toggling a checkbox (to-do item), use anytype_toggle_checkbox instead — \ + it is safer and does not risk modifying other content. \ + Use anytype_update_object ONLY for large content changes (adding paragraphs, rewriting sections, etc.). \ + CRITICAL RULES when using this tool: \ + 1) Always call anytype_get_object first to get the current EXACT markdown. \ + 2) Make ONLY the minimal requested change — nothing else. \ + 3) Copy ALL other content CHARACTER-FOR-CHARACTER, including anytype://object?objectId=... links, \ + file links, headings, and all formatting. Do NOT summarise, paraphrase, or reformat anything you are not asked to change. + """, + properties: [ + "space_id": prop("string", "The ID of the space containing the object"), + "object_id": prop("string", "The ID of the object to update"), + "name": prop("string", "New name/title for the object (optional)"), + "body": prop("string", "COMPLETE replacement markdown content — must preserve ALL existing content with ONLY the minimal intended change applied") + ], + required: ["space_id", "object_id"] + ), + makeTool( + name: "anytype_set_done", + description: "Mark a task as done or undone in Anytype by setting its 'done' relation. This is the correct way to check/uncheck tasks — do not use anytype_update_object body text for this.", + properties: [ + "space_id": prop("string", "The ID of the space containing the task"), + "object_id": prop("string", "The ID of the task to update"), + "done": prop("boolean", "true to mark as done, false to mark as undone") + ], + required: ["space_id", "object_id", "done"] + ), + makeTool( + name: "anytype_toggle_checkbox", + description: """ + Surgically toggle a specific checkbox in an Anytype object without rewriting the full body. \ + Use this INSTEAD of anytype_update_object when marking a to-do item done/undone. \ + Provide partial text that uniquely identifies the checkbox line (do not include the '- [ ]' or '- [x]' prefix). \ + The tool finds the matching line and flips its checkbox state, preserving all other content exactly. + """, + properties: [ + "space_id": prop("string", "The ID of the space containing the object"), + "object_id": prop("string", "The ID of the object containing the checkbox"), + "line_text": prop("string", "Partial text of the checkbox line to find (e.g. 'Buy groceries'). Do NOT include the '- [ ]' prefix."), + "done": prop("boolean", "true to check the box, false to uncheck it") + ], + required: ["space_id", "object_id", "line_text", "done"] + ), + makeTool( + name: "anytype_search_space", + description: "Search within a specific Anytype space for objects matching a query.", + properties: [ + "space_id": prop("string", "The ID of the Anytype space to search in"), + "query": prop("string", "The search query text"), + "limit": prop("number", "Maximum number of results (default: 20)") + ], + required: ["space_id", "query"] + ) + ] + } + + // MARK: - Tool Execution + + func executeTool(name: String, arguments: String) async -> [String: Any] { + log.info("Executing Anytype tool: \(name)") + + guard let argData = arguments.data(using: .utf8), + let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else { + return ["error": "Invalid arguments JSON"] + } + + return await executeToolAsync(name: name, args: args) + } + + private func executeToolAsync(name: String, args: [String: Any]) async -> [String: Any] { + do { + switch name { + case "anytype_search_global": + guard let query = args["query"] as? String else { + return ["error": "Missing required parameter: query"] + } + let limit = args["limit"] as? Int ?? 20 + return try await searchGlobal(query: query, limit: limit) + + case "anytype_list_spaces": + return try await listSpaces() + + case "anytype_get_space_objects": + guard let spaceId = args["space_id"] as? String else { + return ["error": "Missing required parameter: space_id"] + } + let limit = min(args["limit"] as? Int ?? 20, 50) + return try await getSpaceObjects(spaceId: spaceId, limit: limit) + + case "anytype_get_object": + guard let spaceId = args["space_id"] as? String, + let objectId = args["object_id"] as? String else { + return ["error": "Missing required parameters: space_id, object_id"] + } + return try await getObject(spaceId: spaceId, objectId: objectId) + + case "anytype_create_object": + guard let spaceId = args["space_id"] as? String, + let name = args["name"] as? String else { + return ["error": "Missing required parameters: space_id, name"] + } + let body = args["body"] as? String ?? "" + let type_ = args["type"] as? String ?? "note" + return try await createObject(spaceId: spaceId, name: name, body: body, type: type_) + + case "anytype_update_object": + guard let spaceId = args["space_id"] as? String, + let objectId = args["object_id"] as? String else { + return ["error": "Missing required parameters: space_id, object_id"] + } + let name = args["name"] as? String + let body = args["body"] as? String + return try await updateObject(spaceId: spaceId, objectId: objectId, name: name, body: body) + + case "anytype_set_done": + guard let spaceId = args["space_id"] as? String, + let objectId = args["object_id"] as? String else { + return ["error": "Missing required parameters: space_id, object_id"] + } + // Accept done as Bool or as Int (1/0) since JSON can arrive either way + let done: Bool + if let b = args["done"] as? Bool { + done = b + } else if let n = args["done"] as? Int { + done = n != 0 + } else { + return ["error": "Missing or invalid parameter: done (expected boolean)"] + } + return try await setDone(spaceId: spaceId, objectId: objectId, done: done) + + case "anytype_toggle_checkbox": + guard let spaceId = args["space_id"] as? String, + let objectId = args["object_id"] as? String, + let lineText = args["line_text"] as? String else { + return ["error": "Missing required parameters: space_id, object_id, line_text"] + } + let done: Bool + if let b = args["done"] as? Bool { done = b } + else if let n = args["done"] as? Int { done = n != 0 } + else { return ["error": "Missing or invalid parameter: done (expected boolean)"] } + return try await toggleCheckbox(spaceId: spaceId, objectId: objectId, lineText: lineText, done: done) + + case "anytype_search_space": + guard let spaceId = args["space_id"] as? String, + let query = args["query"] as? String else { + return ["error": "Missing required parameters: space_id, query"] + } + let limit = args["limit"] as? Int ?? 20 + return try await searchSpace(spaceId: spaceId, query: query, limit: limit) + + default: + return ["error": "Unknown Anytype tool: \(name)"] + } + } catch AnytypeError.notRunning { + return ["error": "Cannot connect to Anytype. Make sure the desktop app is running."] + } catch AnytypeError.unauthorized { + return ["error": "Invalid API key. Check your Anytype API key in Settings > MCP."] + } catch AnytypeError.httpError(let code, let msg) { + return ["error": "Anytype API error \(code): \(msg)"] + } catch { + return ["error": "Anytype error: \(error.localizedDescription)"] + } + } + + // MARK: - API Operations + + private func searchGlobal(query: String, limit: Int) async throws -> [String: Any] { + let body: [String: Any] = ["query": query, "limit": limit] + let result = try await request(endpoint: "/v1/search", method: "POST", body: body) + + if let objects = result["data"] as? [[String: Any]] { + let formatted = objects.map { formatObject($0) } + return ["count": formatted.count, "objects": formatted] + } + return ["count": 0, "objects": [], "message": "No results found"] + } + + private func listSpaces() async throws -> [String: Any] { + let result = try await request(endpoint: "/v1/spaces", method: "GET", body: nil) + + if let spaces = result["data"] as? [[String: Any]] { + let formatted = spaces.map { space -> [String: Any] in + [ + "id": space["id"] ?? "", + "name": space["name"] ?? "Unnamed Space", + "type": space["spaceType"] ?? "unknown" + ] + } + return ["count": formatted.count, "spaces": formatted] + } + return ["count": 0, "spaces": []] + } + + private func getSpaceObjects(spaceId: String, limit: Int) async throws -> [String: Any] { + let result = try await request( + endpoint: "/v1/spaces/\(spaceId)/objects", + method: "GET", + body: nil, + queryParams: ["limit": String(limit)] + ) + + if let objects = result["data"] as? [[String: Any]] { + let formatted = Array(objects.prefix(limit).map { formatObject($0) }) + return ["count": formatted.count, "objects": formatted] + } + return ["count": 0, "objects": []] + } + + private func getObject(spaceId: String, objectId: String) async throws -> [String: Any] { + let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil) + + if let object = result["object"] as? [String: Any] { + return formatObjectDetail(object) + } + // Return full raw response if expected structure not found + return result + } + + private func createObject(spaceId: String, name: String, body: String, type: String) async throws -> [String: Any] { + let typeKey: String + switch type.lowercased() { + case "task": typeKey = "ot-task" + case "page": typeKey = "ot-page" + default: typeKey = "ot-note" + } + + let requestBody: [String: Any] = [ + "name": name, + "body": body, // some API versions use "body" + "markdown": body, // newer API versions use "markdown" + "typeKey": typeKey, + "template_id": "" + ] + + let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects", method: "POST", body: requestBody) + + if let object = result["object"] as? [String: Any] { + return ["success": true, "id": object["id"] ?? "", "name": name, "message": "Object created successfully"] + } + return ["success": true, "message": "Object created"] + } + + private func updateObject(spaceId: String, objectId: String, name: String?, body: String?) async throws -> [String: Any] { + var requestBody: [String: Any] = [:] + if let name = name { requestBody["name"] = name } + // The Anytype API GET response uses "markdown" for body content, not "body" + if let body = body { requestBody["markdown"] = body } + + guard !requestBody.isEmpty else { + return ["error": "No fields to update. Provide name or body."] + } + + let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: requestBody) + if result.isEmpty { + return ["success": true, "message": "Object updated (empty response from Anytype API)"] + } + return result + } + + private func toggleCheckbox(spaceId: String, objectId: String, lineText: String, done: Bool) async throws -> [String: Any] { + // Fetch current content + let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "GET", body: nil) + guard let object = result["object"] as? [String: Any] else { + return ["error": "Object not found"] + } + + let markdown: String + if let md = object["markdown"] as? String { markdown = md } + else if let body = object["body"] as? String { markdown = body } + else { return ["error": "Object has no markdown body"] } + + // Find the line containing the search text (case-insensitive) + let lines = markdown.components(separatedBy: "\n") + let searchLower = lineText.lowercased() + guard let idx = lines.firstIndex(where: { + let stripped = $0.replacingOccurrences(of: "- [ ] ", with: "") + .replacingOccurrences(of: "- [x] ", with: "") + .replacingOccurrences(of: "- [X] ", with: "") + return stripped.lowercased().contains(searchLower) + }) else { + return ["error": "No checkbox line found containing: '\(lineText)'"] + } + + var line = lines[idx] + let wasDone = line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") + + if done == wasDone { + return ["success": true, "message": "Checkbox was already \(done ? "checked" : "unchecked")", "line": line] + } + + // Flip the checkbox marker, preserving the rest of the line exactly + if done { + if line.hasPrefix("- [ ] ") { line = "- [x] " + line.dropFirst(6) } + } else { + if line.hasPrefix("- [x] ") || line.hasPrefix("- [X] ") { line = "- [ ] " + line.dropFirst(6) } + } + + var updated = lines + updated[idx] = line + let newMarkdown = updated.joined(separator: "\n") + + let patchResult = try await request( + endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", + method: "PATCH", + body: ["markdown": newMarkdown] + ) + return ["success": true, "done": done, "line": line, "message": "Checkbox updated"] + } + + private func setDone(spaceId: String, objectId: String, done: Bool) async throws -> [String: Any] { + // Anytype stores task completion as a relation called "done" + let requestBody: [String: Any] = [ + "properties": [ + "done": done + ] + ] + let result = try await request(endpoint: "/v1/spaces/\(spaceId)/objects/\(objectId)", method: "PATCH", body: requestBody) + if result.isEmpty { + return ["success": true, "done": done, "message": "setDone sent (empty response from Anytype API — verify in app)"] + } + return result + } + + private func searchSpace(spaceId: String, query: String, limit: Int) async throws -> [String: Any] { + let body: [String: Any] = ["query": query, "limit": limit] + let result = try await request(endpoint: "/v1/spaces/\(spaceId)/search", method: "POST", body: body) + + if let objects = result["data"] as? [[String: Any]] { + let formatted = objects.map { formatObject($0) } + return ["count": formatted.count, "objects": formatted] + } + return ["count": 0, "objects": [], "message": "No results found"] + } + + // MARK: - HTTP Client + + private func request(endpoint: String, method: String, body: [String: Any]?, queryParams: [String: String] = [:]) async throws -> [String: Any] { + guard let apiKey = settings.anytypeMcpAPIKey, !apiKey.isEmpty else { + throw AnytypeError.unauthorized + } + + let baseURL = settings.anytypeMcpEffectiveURL + var urlString = baseURL + endpoint + if !queryParams.isEmpty { + var comps = URLComponents(string: urlString) ?? URLComponents() + comps.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) } + urlString = comps.url?.absoluteString ?? urlString + } + guard let url = URL(string: urlString) else { + throw AnytypeError.httpError(0, "Invalid URL: \(urlString)") + } + + var urlRequest = URLRequest(url: url, timeoutInterval: timeout) + urlRequest.httpMethod = method + urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue(apiVersion, forHTTPHeaderField: "Anytype-Version") + + if let body = body, method != "GET" { + urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: body) + } + + do { + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AnytypeError.httpError(0, "Invalid response") + } + + if httpResponse.statusCode == 401 { + throw AnytypeError.unauthorized + } + + guard (200...299).contains(httpResponse.statusCode) else { + let msg = String(data: data, encoding: .utf8) ?? "Unknown error" + throw AnytypeError.httpError(httpResponse.statusCode, msg) + } + + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [:] + } + return json + + } catch let error as AnytypeError { + throw error + } catch { + // Connection refused or network error = Anytype not running + throw AnytypeError.notRunning + } + } + + // MARK: - Helpers + + private func formatObject(_ obj: [String: Any]) -> [String: Any] { + [ + "id": obj["id"] ?? "", + "name": obj["name"] ?? "Unnamed", + "type": obj["type"] ?? obj["objectType"] ?? "unknown", + "space_id": obj["spaceId"] ?? "" + ] + } + + private func formatObjectDetail(_ obj: [String: Any]) -> [String: Any] { + var result: [String: Any] = formatObject(obj) + // Anytype API returns content as "markdown" not "body" + if let markdown = obj["markdown"] as? String { result["body"] = markdown } + else if let body = obj["body"] as? String { result["body"] = body } + if let snippet = obj["snippet"] as? String { result["snippet"] = snippet } + return result + } + + private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool { + Tool( + type: "function", + function: Tool.Function( + name: name, + description: description, + parameters: Tool.Function.Parameters( + type: "object", + properties: properties, + required: required + ) + ) + ) + } + + private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property { + Tool.Function.Parameters.Property(type: type, description: description, enum: nil) + } +} + +// MARK: - Error Types + +enum AnytypeError: LocalizedError { + case notRunning + case unauthorized + case httpError(Int, String) + + var errorDescription: String? { + switch self { + case .notRunning: + return "Cannot connect to Anytype. Make sure the desktop app is running." + case .unauthorized: + return "Invalid API key. Check your Anytype API key in Settings > MCP." + case .httpError(let code, let msg): + return "Anytype API error \(code): \(msg)" + } + } +} diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index 9f374fb..5b7e0bd 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -463,7 +463,7 @@ final class DatabaseService: Sendable { .filter(Column("conversationId") == record.id) .fetchCount(db)) ?? 0 - // Get last message date + // Get last message (for date + model fallback) let lastMsg = try? MessageRecord .filter(Column("conversationId") == record.id) .order(Column("sortOrder").desc) @@ -471,15 +471,18 @@ final class DatabaseService: Sendable { let lastDate = lastMsg.flatMap { self.isoFormatter.date(from: $0.timestamp) } ?? updatedAt + // Derive primary model: prefer the stored field, fall back to last message's modelId + let primaryModel = record.primaryModel ?? lastMsg?.modelId + // Create conversation with empty messages array but correct metadata var conv = Conversation( id: id, name: record.name, messages: Array(repeating: Message(role: .user, content: ""), count: messageCount), createdAt: createdAt, - updatedAt: lastDate + updatedAt: lastDate, + primaryModel: primaryModel ) - // We store placeholder messages just for the count; lastMessageDate uses updatedAt conv.updatedAt = lastDate return conv } diff --git a/oAI/Services/EmailService.swift b/oAI/Services/EmailService.swift index e557afb..6aa0a64 100644 --- a/oAI/Services/EmailService.swift +++ b/oAI/Services/EmailService.swift @@ -130,31 +130,37 @@ final class EmailService { // Search for ALL unseen emails first let allUnseenUIDs = try await client.searchUnseen() + // Remove UIDs that are no longer unseen (emails were deleted/marked read) + checkedUIDs = checkedUIDs.intersection(Set(allUnseenUIDs)) + // Check each email for the subject identifier for uid in allUnseenUIDs { - if !checkedUIDs.contains(uid) { - checkedUIDs.insert(uid) + // Skip if we've already checked this UID (for non-matching emails only) + if checkedUIDs.contains(uid) { + continue + } - do { - let email = try await client.fetchEmail(uid: uid) + do { + let email = try await client.fetchEmail(uid: uid) - // Check if email has the correct subject identifier - if email.subject.contains(settings.emailSubjectIdentifier) { - // Valid email - process it - log.info("New email found: \(email.subject) from \(email.from)") + // Check if email has the correct subject identifier + if email.subject.contains(self.settings.emailSubjectIdentifier) { + // Valid email - process it (don't add to checkedUIDs, handler will delete it) + log.info("Found matching email: \(email.subject) from \(email.from)") - // Call callback on main thread - await MainActor.run { - onNewEmail?(email) - } - } else { - // Wrong subject - delete it - log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)") - try await client.deleteEmail(uid: uid) + // Call callback on main thread + await MainActor.run { + onNewEmail?(email) } - } catch { - log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)") + } else { + // Wrong subject - delete it and remember we checked it + log.warning("Deleting email without subject identifier: \(email.subject) from \(email.from)") + try await client.deleteEmail(uid: uid) + checkedUIDs.insert(uid) // Only track non-matching emails } + } catch { + log.error("Failed to fetch/process email \(uid): \(error.localizedDescription)") + checkedUIDs.insert(uid) // Track failed emails to avoid retry loops } } diff --git a/oAI/Services/GitSyncService.swift b/oAI/Services/GitSyncService.swift index dca83b9..a031316 100644 --- a/oAI/Services/GitSyncService.swift +++ b/oAI/Services/GitSyncService.swift @@ -16,6 +16,12 @@ class GitSyncService { // Debounce tracking private var pendingSyncTask: Task? + private init() { + // Check if repository is cloned at initialization (synchronous check) + let localPath = expandPath(settings.syncLocalPath) + syncStatus.isCloned = FileManager.default.fileExists(atPath: localPath + "/.git") + } + // MARK: - Repository Operations /// Test connection to remote repository @@ -366,9 +372,17 @@ class GitSyncService { /// Sync on app startup (pull + import only, no push) /// Runs silently in background to fetch changes from other devices func syncOnStartup() async { + // First, update status to check if repo is actually cloned + await updateStatus() + // Only run if configured and cloned - guard settings.syncConfigured && syncStatus.isCloned else { - log.debug("Skipping startup sync (not configured or not cloned)") + guard settings.syncConfigured else { + log.debug("Skipping startup sync (sync not configured)") + return + } + + guard syncStatus.isCloned else { + log.debug("Skipping startup sync (repository not cloned)") return } diff --git a/oAI/Services/MCPService.swift b/oAI/Services/MCPService.swift index b50eb69..4b68c09 100644 --- a/oAI/Services/MCPService.swift +++ b/oAI/Services/MCPService.swift @@ -91,6 +91,8 @@ class MCPService { var canMoveFiles: Bool { settings.mcpCanMoveFiles } var respectGitignore: Bool { settings.mcpRespectGitignore } + private let anytypeService = AnytypeMCPService.shared + // MARK: - Tool Schema Generation func getToolSchemas() -> [Tool] { @@ -189,6 +191,11 @@ class MCPService { )) } + // Add Anytype tools if enabled and configured + if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured { + tools.append(contentsOf: anytypeService.getToolSchemas()) + } + return tools } @@ -213,7 +220,7 @@ class MCPService { // MARK: - Tool Execution - func executeTool(name: String, arguments: String) -> [String: Any] { + func executeTool(name: String, arguments: String) async -> [String: Any] { Log.mcp.info("Executing tool: \(name)") guard let argData = arguments.data(using: .utf8), let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else { @@ -303,6 +310,10 @@ class MCPService { return copyFile(source: source, destination: destination) default: + // Route anytype_* tools to AnytypeMCPService + if name.hasPrefix("anytype_") { + return await anytypeService.executeTool(name: name, arguments: arguments) + } return ["error": "Unknown tool: \(name)"] } } diff --git a/oAI/Services/SettingsService.swift b/oAI/Services/SettingsService.swift index 0e065f1..bdd0e1b 100644 --- a/oAI/Services/SettingsService.swift +++ b/oAI/Services/SettingsService.swift @@ -23,6 +23,7 @@ class SettingsService { static let openaiAPIKey = "openaiAPIKey" static let googleAPIKey = "googleAPIKey" static let googleSearchEngineID = "googleSearchEngineID" + static let anytypeMcpAPIKey = "anytypeMcpAPIKey" } // Old keychain keys (for migration only) @@ -313,6 +314,120 @@ class SettingsService { } } + // MARK: - User Shortcuts (prompt template macros) + + var userShortcuts: [Shortcut] { + get { + guard let json = cache["userShortcuts"], + let data = json.data(using: .utf8) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return (try? decoder.decode([Shortcut].self, from: data)) ?? [] + } + set { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(newValue), + let json = String(data: data, encoding: .utf8) { + cache["userShortcuts"] = json + DatabaseService.shared.setSetting(key: "userShortcuts", value: json) + } + } + } + + func addShortcut(_ shortcut: Shortcut) { userShortcuts = userShortcuts + [shortcut] } + + func updateShortcut(_ shortcut: Shortcut) { + userShortcuts = userShortcuts.map { $0.id == shortcut.id ? shortcut : $0 } + } + + func deleteShortcut(id: UUID) { userShortcuts = userShortcuts.filter { $0.id != id } } + + // MARK: - Agent Skills (SKILL.md-style behavioral instructions) + + var agentSkills: [AgentSkill] { + get { + guard let json = cache["agentSkills"], + let data = json.data(using: .utf8) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return (try? decoder.decode([AgentSkill].self, from: data)) ?? [] + } + set { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(newValue), + let json = String(data: data, encoding: .utf8) { + cache["agentSkills"] = json + DatabaseService.shared.setSetting(key: "agentSkills", value: json) + } + } + } + + func addAgentSkill(_ skill: AgentSkill) { agentSkills = agentSkills + [skill] } + + func updateAgentSkill(_ skill: AgentSkill) { + agentSkills = agentSkills.map { $0.id == skill.id ? skill : $0 } + } + + func deleteAgentSkill(id: UUID) { + agentSkills = agentSkills.filter { $0.id != id } + AgentSkillFilesService.shared.deleteAll(for: id) + } + + func toggleAgentSkill(id: UUID) { + agentSkills = agentSkills.map { s in + s.id == id ? AgentSkill(id: s.id, name: s.name, skillDescription: s.skillDescription, + content: s.content, isActive: !s.isActive, + createdAt: s.createdAt, updatedAt: Date()) : s + } + } + + // MARK: - Anytype MCP Settings + + var anytypeMcpEnabled: Bool { + get { cache["anytypeMcpEnabled"] == "true" } + set { + cache["anytypeMcpEnabled"] = String(newValue) + DatabaseService.shared.setSetting(key: "anytypeMcpEnabled", value: String(newValue)) + } + } + + var anytypeMcpURL: String { + get { cache["anytypeMcpURL"] ?? "" } + set { + let trimmed = newValue.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + cache.removeValue(forKey: "anytypeMcpURL") + DatabaseService.shared.deleteSetting(key: "anytypeMcpURL") + } else { + cache["anytypeMcpURL"] = trimmed + DatabaseService.shared.setSetting(key: "anytypeMcpURL", value: trimmed) + } + } + } + + var anytypeMcpEffectiveURL: String { + let url = anytypeMcpURL + return url.isEmpty ? "http://127.0.0.1:31009" : url + } + + var anytypeMcpAPIKey: String? { + get { try? DatabaseService.shared.getEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey) } + set { + if let value = newValue, !value.isEmpty { + try? DatabaseService.shared.setEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey, value: value) + } else { + DatabaseService.shared.deleteEncryptedSetting(key: EncryptedKeys.anytypeMcpAPIKey) + } + } + } + + var anytypeMcpConfigured: Bool { + guard let key = anytypeMcpAPIKey else { return false } + return !key.isEmpty + } + // MARK: - Search Settings var searchProvider: Settings.SearchProvider { diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index 56d8fb8..977bc6a 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -33,6 +33,8 @@ class ChatViewModel { var showHelp: Bool = false var showCredits: Bool = false var showHistory: Bool = false + var showShortcuts: Bool = false + var showSkills: Bool = false var modelInfoTarget: ModelInfo? = nil var commandHistory: [String] = [] var historyIndex: Int = 0 @@ -139,6 +141,23 @@ Don't narrate future actions ("Let me...") - just use the tools. prompt += "\n\n---\n\nAdditional Instructions:\n" + customPrompt } + // Append active agent skills (SKILL.md-style behavioral instructions) + let activeSkills = settings.agentSkills.filter { $0.isActive } + if !activeSkills.isEmpty { + prompt += "\n\n---\n\n## Installed Skills\n\nThe following skills are active. Apply them when relevant:\n\n" + for skill in activeSkills { + prompt += "### \(skill.name)\n\n\(skill.content)\n\n" + let files = AgentSkillFilesService.shared.readTextFiles(for: skill.id) + if !files.isEmpty { + prompt += "**Skill Data Files:**\n\n" + for (name, content) in files { + let ext = URL(fileURLWithPath: name).pathExtension.lowercased() + prompt += "**\(name):**\n```\(ext)\n\(content)\n```\n\n" + } + } + } + } + return prompt } @@ -367,10 +386,79 @@ Don't narrate future actions ("Let me...") - just use the tools. } showSystemMessage("Loaded conversation '\(conversation.name)'") + + // Auto-switch to the provider/model this conversation was created with + if let modelId = conversation.primaryModel { + Task { await switchToConversationModel(modelId) } + } } catch { showSystemMessage("Failed to load: \(error.localizedDescription)") } } + + /// Infer which provider owns a given model ID based on naming conventions. + private func inferProvider(from modelId: String) -> Settings.Provider? { + // OpenRouter models always contain a "/" (e.g. "anthropic/claude-3-5-sonnet") + if modelId.contains("/") { return .openrouter } + // Anthropic direct (e.g. "claude-sonnet-4-5-20250929") + if modelId.hasPrefix("claude-") { return .anthropic } + // OpenAI direct + if modelId.hasPrefix("gpt-") || modelId.hasPrefix("o1") || modelId.hasPrefix("o3") + || modelId.hasPrefix("dall-e-") || modelId.hasPrefix("chatgpt-") { return .openai } + // Ollama uses short local names with no vendor prefix + return .ollama + } + + /// Silently switch provider + model to match a loaded conversation. + /// Shows a system message only on failure or on a successful switch. + @MainActor + private func switchToConversationModel(_ modelId: String) async { + guard let targetProvider = inferProvider(from: modelId) else { + showSystemMessage("⚠️ Could not determine provider for model '\(modelId)' — keeping current model") + return + } + + guard providerRegistry.hasValidAPIKey(for: targetProvider) else { + showSystemMessage("⚠️ No API key for \(targetProvider.displayName) — keeping current model") + return + } + + // Switch provider if needed, or load models if not yet loaded + if targetProvider != currentProvider || availableModels.isEmpty { + if targetProvider != currentProvider { + settings.defaultProvider = targetProvider + currentProvider = targetProvider + selectedModel = nil + availableModels = [] + providerRegistry.clearCache() + } + + isLoadingModels = true + do { + guard let provider = providerRegistry.getProvider(for: targetProvider) else { + isLoadingModels = false + showSystemMessage("⚠️ Could not connect to \(targetProvider.displayName) — keeping current model") + return + } + availableModels = try await provider.listModels() + isLoadingModels = false + } catch { + isLoadingModels = false + showSystemMessage("⚠️ Could not load \(targetProvider.displayName) models — keeping current model") + return + } + } + + guard let model = availableModels.first(where: { $0.id == modelId }) else { + showSystemMessage("⚠️ Model '\(modelId)' not available — keeping current model") + return + } + + guard selectedModel?.id != modelId else { return } // already on it, no message needed + + selectedModel = model + showSystemMessage("Switched to \(model.name) · \(targetProvider.displayName)") + } func retryLastMessage() { guard let lastUserMessage = messages.last(where: { $0.role == .user }) else { @@ -517,11 +605,38 @@ Don't narrate future actions ("Let me...") - just use the tools. case "/credits": showCredits = true - + + case "/shortcuts": + showShortcuts = true + + case "/skills": + showSkills = true + case "/mcp": handleMCPCommand(args: args) - + default: + // Check user-defined shortcuts + if let shortcut = settings.userShortcuts.first(where: { $0.command == cmd.lowercased() }) { + let userInput = args.joined(separator: " ") + let prompt = shortcut.needsInput + ? shortcut.template.replacingOccurrences(of: "{{input}}", with: userInput) + : shortcut.template + let msg = Message( + role: .user, + content: prompt, + tokens: prompt.estimateTokens(), + cost: nil, + timestamp: Date(), + attachments: nil, + modelId: selectedModel?.id + ) + messages.append(msg) + sessionStats.addMessage(inputTokens: msg.tokens, outputTokens: nil, cost: nil) + generateEmbeddingForMessage(msg) + generateAIResponse(to: prompt, attachments: nil) + return + } showSystemMessage("Unknown command: \(cmd)\nType /help for available commands") } } @@ -544,12 +659,13 @@ Don't narrate future actions ("Let me...") - just use the tools. Log.ui.info("Sending message: model=\(modelId), messages=\(self.messages.count)") - // Dispatch to tool-aware path when MCP is enabled with folders + // Dispatch to tool-aware path when MCP is enabled with folders or Anytype is enabled // Skip for image generation models — they don't support tool calling let mcp = MCPService.shared let mcpActive = mcpEnabled || settings.mcpEnabled + let anytypeActive = settings.anytypeMcpEnabled && settings.anytypeMcpConfigured let modelSupportTools = selectedModel?.capabilities.tools ?? false - if mcpActive && !mcp.allowedFolders.isEmpty && modelSupportTools { + if modelSupportTools && (anytypeActive || (mcpActive && !mcp.allowedFolders.isEmpty)) { generateAIResponseWithTools(provider: provider, modelId: modelId) return } @@ -777,7 +893,16 @@ Don't narrate future actions ("Let me...") - just use the tools. for rawPath in paths { // Expand ~ and resolve path let expanded = (rawPath as NSString).expandingTildeInPath - let resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded) + var resolvedPath = expanded.hasPrefix("/") ? expanded : (fm.currentDirectoryPath as NSString).appendingPathComponent(expanded) + + // If not found, try iCloud Drive path + if !fm.fileExists(atPath: resolvedPath) { + let icloudBase = (("~/Library/Mobile Documents" as NSString).expandingTildeInPath as NSString) + let candidate = icloudBase.appendingPathComponent(rawPath) + if fm.fileExists(atPath: candidate) { + resolvedPath = candidate + } + } // Check file exists guard fm.fileExists(atPath: resolvedPath) else { @@ -835,6 +960,46 @@ Don't narrate future actions ("Let me...") - just use the tools. return attachments } + // MARK: - Text Tool Call Parsing + + /// Fallback parser for models that write tool calls as text instead of using structured tool_calls. + /// Handles two patterns: + /// tool_name{"arg": "val"} (no space between name and args) + /// tool_name({"arg": "val"}) (with wrapping parens) + private func parseTextToolCalls(from content: String) -> [ToolCallInfo] { + var results: [ToolCallInfo] = [] + + // Match: word_chars optionally followed by ( then { ... } optionally followed by ) + // Use a broad pattern and validate JSON manually + let pattern = #"([a-z_][a-z0-9_]*)\s*\(?\s*(\{[\s\S]*?\})\s*\)?"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + return [] + } + + let nsContent = content as NSString + let matches = regex.matches(in: content, range: NSRange(location: 0, length: nsContent.length)) + let knownTools = Set(MCPService.shared.getToolSchemas().map { $0.function.name }) + + for match in matches { + guard let nameRange = Range(match.range(at: 1), in: content), + let argsRange = Range(match.range(at: 2), in: content) else { continue } + + let name = String(content[nameRange]) + let argsStr = String(content[argsRange]) + + // Only handle known tool names to avoid false positives + guard knownTools.contains(name) else { continue } + + // Validate the JSON + guard let _ = try? JSONSerialization.jsonObject(with: Data(argsStr.utf8)) else { continue } + + Log.ui.info("Parsed text tool call: \(name)") + results.append(ToolCallInfo(id: UUID().uuidString, type: "function", functionName: name, arguments: argsStr)) + } + + return results + } + // MARK: - MCP Command Handling private func handleMCPCommand(args: [String]) { @@ -955,17 +1120,28 @@ Don't narrate future actions ("Let me...") - just use the tools. } // Build initial messages as raw dictionaries for the tool loop - let folderList = mcp.allowedFolders.joined(separator: "\n - ") - var capabilities = "You can read files, list directories, and search for files." - var writeCapabilities: [String] = [] - if mcp.canWriteFiles { writeCapabilities.append("write and edit files") } - if mcp.canDeleteFiles { writeCapabilities.append("delete files") } - if mcp.canCreateDirectories { writeCapabilities.append("create directories") } - if mcp.canMoveFiles { writeCapabilities.append("move and copy files") } - if !writeCapabilities.isEmpty { - capabilities += " You can also \(writeCapabilities.joined(separator: ", "))." + var systemParts: [String] = [] + let mcpActive = mcpEnabled || settings.mcpEnabled + + if mcpActive && !mcp.allowedFolders.isEmpty { + let folderList = mcp.allowedFolders.joined(separator: "\n - ") + var capabilities = "You can read files, list directories, and search for files." + var writeCapabilities: [String] = [] + if mcp.canWriteFiles { writeCapabilities.append("write and edit files") } + if mcp.canDeleteFiles { writeCapabilities.append("delete files") } + if mcp.canCreateDirectories { writeCapabilities.append("create directories") } + if mcp.canMoveFiles { writeCapabilities.append("move and copy files") } + if !writeCapabilities.isEmpty { + capabilities += " You can also \(writeCapabilities.joined(separator: ", "))." + } + systemParts.append("You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths.") } - var systemContent = "You have access to the user's filesystem through tool calls. \(capabilities) The user has granted you access to these folders:\n - \(folderList)\n\nWhen the user asks about their files, use the tools proactively with the allowed paths. Always use absolute paths." + + if settings.anytypeMcpEnabled && settings.anytypeMcpConfigured { + systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.") + } + + var systemContent = systemParts.joined(separator: "\n\n") // Append the complete system prompt (default + custom) systemContent += "\n\n---\n\n" + effectiveSystemPrompt @@ -994,7 +1170,25 @@ Don't narrate future actions ("Let me...") - just use the tools. ] var apiMessages: [[String: Any]] = [systemPrompt] + messagesToSend.map { msg in - ["role": msg.role.rawValue, "content": msg.content] + let hasAttachments = msg.attachments?.contains(where: { $0.data != nil }) ?? false + if hasAttachments { + var contentArray: [[String: Any]] = [["type": "text", "text": msg.content]] + for attachment in msg.attachments ?? [] { + guard let data = attachment.data else { continue } + switch attachment.type { + case .image, .pdf: + let base64String = data.base64EncodedString() + let dataURL = "data:\(attachment.mimeType);base64,\(base64String)" + contentArray.append(["type": "image_url", "image_url": ["url": dataURL]]) + case .text: + let filename = (attachment.path as NSString).lastPathComponent + let textContent = String(data: data, encoding: .utf8) ?? "" + contentArray.append(["type": "text", "text": "File: \(filename)\n\n\(textContent)"]) + } + } + return ["role": msg.role.rawValue, "content": contentArray] + } + return ["role": msg.role.rawValue, "content": msg.content] } let maxIterations = 10 // Increased from 5 to reduce hitting client-side limit @@ -1019,8 +1213,14 @@ Don't narrate future actions ("Let me...") - just use the tools. if let usage = response.usage { totalUsage = usage } // Check if the model wants to call tools - guard let toolCalls = response.toolCalls, !toolCalls.isEmpty else { + // Also parse text-based tool calls for models that don't use structured tool_calls + let structuredCalls = response.toolCalls ?? [] + let textCalls = structuredCalls.isEmpty ? parseTextToolCalls(from: response.content) : [] + let toolCalls = structuredCalls.isEmpty ? textCalls : structuredCalls + + guard !toolCalls.isEmpty else { // No tool calls — this is the final text response + // Strip any unparseable tool call text from display finalContent = response.content break } @@ -1029,46 +1229,71 @@ Don't narrate future actions ("Let me...") - just use the tools. let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ") showSystemMessage("🔧 Calling: \(toolNames)") - // Append assistant message with tool_calls to conversation - var assistantMsg: [String: Any] = ["role": "assistant"] - if !response.content.isEmpty { - assistantMsg["content"] = response.content - } - let toolCallDicts: [[String: Any]] = toolCalls.map { tc in - [ - "id": tc.id, - "type": tc.type, - "function": [ - "name": tc.functionName, - "arguments": tc.arguments + let usingTextCalls = !textCalls.isEmpty + + if usingTextCalls { + // Text-based tool calls: keep assistant message as-is (the text content) + apiMessages.append(["role": "assistant", "content": response.content]) + } else { + // Structured tool_calls: append assistant message with tool_calls field + var assistantMsg: [String: Any] = ["role": "assistant"] + if !response.content.isEmpty { + assistantMsg["content"] = response.content + } + let toolCallDicts: [[String: Any]] = toolCalls.map { tc in + [ + "id": tc.id, + "type": tc.type, + "function": [ + "name": tc.functionName, + "arguments": tc.arguments + ] ] - ] + } + assistantMsg["tool_calls"] = toolCallDicts + apiMessages.append(assistantMsg) } - assistantMsg["tool_calls"] = toolCallDicts - apiMessages.append(assistantMsg) // Execute each tool and append results + var toolResultLines: [String] = [] for tc in toolCalls { if Task.isCancelled { wasCancelled = true break } - let result = mcp.executeTool(name: tc.functionName, arguments: tc.arguments) + let result = await mcp.executeTool(name: tc.functionName, arguments: tc.arguments) let resultJSON: String if let data = try? JSONSerialization.data(withJSONObject: result), let str = String(data: data, encoding: .utf8) { - resultJSON = str + // Cap tool results at 50 KB to avoid HTTP 413 on the next API call + let maxBytes = 50_000 + if str.utf8.count > maxBytes { + let truncated = String(str.utf8.prefix(maxBytes))! + resultJSON = truncated + "\n... (result truncated, use a smaller limit or more specific query)" + } else { + resultJSON = str + } } else { resultJSON = "{\"error\": \"Failed to serialize result\"}" } - apiMessages.append([ - "role": "tool", - "tool_call_id": tc.id, - "name": tc.functionName, - "content": resultJSON - ]) + if usingTextCalls { + // Inject results as a user message for text-call models + toolResultLines.append("Tool result for \(tc.functionName):\n\(resultJSON)") + } else { + apiMessages.append([ + "role": "tool", + "tool_call_id": tc.id, + "name": tc.functionName, + "content": resultJSON + ]) + } + } + + if usingTextCalls && !toolResultLines.isEmpty { + let combined = toolResultLines.joined(separator: "\n\n") + apiMessages.append(["role": "user", "content": combined]) } // If this was the last iteration, note it diff --git a/oAI/Views/Main/ChatView.swift b/oAI/Views/Main/ChatView.swift index a44b248..0c0de94 100644 --- a/oAI/Views/Main/ChatView.swift +++ b/oAI/Views/Main/ChatView.swift @@ -111,6 +111,12 @@ struct ChatView: View { FooterView(stats: viewModel.sessionStats) } .background(Color.oaiBackground) + .sheet(isPresented: $viewModel.showShortcuts) { + ShortcutsView() + } + .sheet(isPresented: $viewModel.showSkills) { + AgentSkillsView() + } } } diff --git a/oAI/Views/Main/InputBar.swift b/oAI/Views/Main/InputBar.swift index 9685b26..b1027a1 100644 --- a/oAI/Views/Main/InputBar.swift +++ b/oAI/Views/Main/InputBar.swift @@ -23,7 +23,7 @@ struct InputBar: View { /// Commands that execute immediately without additional arguments private static let immediateCommands: Set = [ "/help", "/history", "/model", "/clear", "/retry", "/stats", "/config", - "/settings", "/credits", "/list", "/load", + "/settings", "/credits", "/list", "/load", "/shortcuts", "/skills", "/memory on", "/memory off", "/online on", "/online off", "/mcp on", "/mcp off", "/mcp status", "/mcp list", "/mcp write on", "/mcp write off", @@ -197,6 +197,13 @@ struct InputBar: View { // Execute immediately text = command onSend() + } else if let shortcut = SettingsService.shared.userShortcuts.first(where: { $0.command == command }) { + if shortcut.needsInput { + text = command + " " + } else { + text = command + onSend() + } } else { // Put in input for user to complete text = command + " " @@ -214,7 +221,8 @@ struct InputBar: View { guard panel.runModal() == .OK else { return } let paths = panel.urls.map { $0.path } - let attachmentText = paths.map { "@\($0)" }.joined(separator: " ") + // Use @ format (angle brackets) to safely handle paths with spaces + let attachmentText = paths.map { "@<\($0)>" }.joined(separator: " ") if text.isEmpty { text = attachmentText + " " @@ -245,12 +253,14 @@ struct CommandSuggestionsView: View { let selectedIndex: Int let onSelect: (String) -> Void - static let allCommands: [(command: String, description: String)] = [ + static let builtInCommands: [(command: String, description: String)] = [ ("/help", "Show help and available commands"), ("/history", "View command history"), ("/model", "Select AI model"), ("/clear", "Clear chat history"), ("/retry", "Retry last message"), + ("/shortcuts", "Manage your prompt shortcuts"), + ("/skills", "Manage your agent skills"), ("/memory on", "Enable conversation memory"), ("/memory off", "Disable conversation memory"), ("/online on", "Enable web search"), @@ -274,9 +284,16 @@ struct CommandSuggestionsView: View { ("/mcp write off", "Disable MCP write permissions"), ] + static func allCommands() -> [(command: String, description: String)] { + let shortcuts = SettingsService.shared.userShortcuts.map { s in + (s.command, "⚡ \(s.description)") + } + return builtInCommands + shortcuts + } + static func filteredCommands(for searchText: String) -> [(command: String, description: String)] { let search = searchText.lowercased() - return allCommands.filter { $0.command.contains(search) || search == "/" } + return allCommands().filter { $0.command.contains(search) || search == "/" } } private var suggestions: [(command: String, description: String)] { diff --git a/oAI/Views/Screens/AgentSkillEditorSheet.swift b/oAI/Views/Screens/AgentSkillEditorSheet.swift new file mode 100644 index 0000000..1af2838 --- /dev/null +++ b/oAI/Views/Screens/AgentSkillEditorSheet.swift @@ -0,0 +1,294 @@ +// +// AgentSkillEditorSheet.swift +// oAI +// +// Create or edit a SKILL.md-style agent skill, with optional support files +// + +import SwiftUI +import UniformTypeIdentifiers + +struct AgentSkillEditorSheet: View { + @Environment(\.dismiss) var dismiss + + let isNew: Bool + let onSave: (AgentSkill) -> Void + + @State private var skillID: UUID + @State private var name: String + @State private var skillDescription: String + @State private var content: String + @State private var isActive: Bool + @State private var createdAt: Date + + // File management + @State private var skillFiles: [URL] = [] + @State private var didSave = false + + init(skill: AgentSkill? = nil, onSave: @escaping (AgentSkill) -> Void) { + self.isNew = skill == nil + self.onSave = onSave + let s = skill ?? AgentSkill(name: "", content: "") + _skillID = State(initialValue: s.id) + _name = State(initialValue: s.name) + _skillDescription = State(initialValue: s.skillDescription) + _content = State(initialValue: s.content) + _isActive = State(initialValue: s.isActive) + _createdAt = State(initialValue: s.createdAt) + } + + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty + && !content.trimmingCharacters(in: .whitespaces).isEmpty + } + + private var hasLargeFile: Bool { + skillFiles.contains { url in + let size = AgentSkillFilesService.shared.fileSize(at: url) ?? 0 + return size > 200_000 + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text(isNew ? "New Skill" : "Edit Skill") + .font(.system(size: 18, weight: .bold)) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2).foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) + } + .padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Name + VStack(alignment: .leading, spacing: 6) { + Text("Name") + .font(.system(size: 13, weight: .semibold)) + TextField("e.g. Code Review, Test Writer, Security Auditor", text: $name) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13)) + } + + // Description (optional) + VStack(alignment: .leading, spacing: 6) { + Text("Description") + .font(.system(size: 13, weight: .semibold)) + TextField("Brief summary (optional — auto-extracted from content if left blank)", text: $skillDescription) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13)) + } + + // Active toggle + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Active") + .font(.system(size: 13, weight: .semibold)) + Text("Inject into system prompt for every conversation") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Toggle("", isOn: $isActive).labelsHidden() + } + .padding(10) + .background(.secondary.opacity(0.07), in: RoundedRectangle(cornerRadius: 8)) + + // Content (markdown) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Content (Markdown)") + .font(.system(size: 13, weight: .semibold)) + Spacer() + Text("\(content.count) chars") + .font(.caption).foregroundStyle(.secondary) + } + + TextEditor(text: $content) + .font(.system(size: 12, design: .monospaced)) + .frame(minHeight: 200) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(6) + .overlay(RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1)) + + // Format hint + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "doc.text").foregroundStyle(.purple).font(.callout) + Text("SKILL.md format — write instructions in plain Markdown.") + .font(.caption).fontWeight(.medium) + } + Text("The AI reads this content and decides when to apply it. Describe **what** the AI should do and **how** — be specific and concise.") + .font(.caption).foregroundStyle(.secondary) + Text("Example structure:") + .font(.caption).foregroundStyle(.secondary).fontWeight(.medium) + Text("# When reviewing code, always:\n- Check for security vulnerabilities\n- Verify error handling\n- Suggest tests for edge cases") + .font(.system(size: 11, design: .monospaced)) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.purple.opacity(0.06), in: RoundedRectangle(cornerRadius: 6)) + } + .padding(10) + .background(.purple.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) + } + + // Files section + filesSection + } + .padding(.horizontal, 24).padding(.vertical, 16) + } + + Divider() + + HStack { + Button("Cancel") { dismiss() }.buttonStyle(.bordered) + Spacer() + Button("Save") { + let skill = AgentSkill( + id: skillID, + name: name.trimmingCharacters(in: .whitespaces), + skillDescription: skillDescription.trimmingCharacters(in: .whitespaces), + content: content, + isActive: isActive, + createdAt: createdAt, + updatedAt: Date() + ) + didSave = true + onSave(skill) + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(!isValid) + .keyboardShortcut(.return, modifiers: [.command]) + } + .padding(.horizontal, 24).padding(.vertical, 12) + } + .frame(minWidth: 560, idealWidth: 640, minHeight: 640, idealHeight: 760) + .onAppear { + skillFiles = AgentSkillFilesService.shared.listFiles(for: skillID) + } + .onDisappear { + // If this was a new skill that was cancelled, clean up any files the user added + if isNew && !didSave { + AgentSkillFilesService.shared.deleteAll(for: skillID) + } + } + } + + // MARK: - Files Section + + @ViewBuilder + private var filesSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Files") + .font(.system(size: 13, weight: .semibold)) + Spacer() + Button { + addFiles() + } label: { + Label("Add File", systemImage: "plus") + .font(.caption) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + if skillFiles.isEmpty { + Text("No files attached. Add JSON, YAML, CSV or TXT files to inject data into the system prompt alongside this skill.") + .font(.caption).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.secondary.opacity(0.05), in: RoundedRectangle(cornerRadius: 6)) + } else { + VStack(spacing: 0) { + ForEach(Array(skillFiles.enumerated()), id: \.element) { idx, url in + HStack(spacing: 8) { + Image(systemName: "doc") + .font(.caption).foregroundStyle(.secondary) + Text(url.lastPathComponent) + .font(.system(size: 12)).lineLimit(1) + Spacer() + if let size = AgentSkillFilesService.shared.fileSize(at: url) { + Text(formatBytes(size)) + .font(.caption2).foregroundStyle(.tertiary) + } + Button { + AgentSkillFilesService.shared.deleteFile(at: url) + skillFiles = AgentSkillFilesService.shared.listFiles(for: skillID) + } label: { + Image(systemName: "trash").font(.caption2) + } + .buttonStyle(.plain) + .foregroundStyle(.red) + .help("Remove file") + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + if idx < skillFiles.count - 1 { Divider() } + } + } + .background(Color(nsColor: .textBackgroundColor), in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1)) + } + + if hasLargeFile { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + Text("Large files inflate the system prompt and may hit token limits.") + .font(.caption) + } + .padding(8) + .background(.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 6)) + } + + HStack(spacing: 6) { + Image(systemName: "info.circle").foregroundStyle(.secondary).font(.caption2) + Text("Text files are injected into the system prompt alongside the skill.") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + + // MARK: - Actions + + private func addFiles() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowedContentTypes = [ + .plainText, .json, .xml, .commaSeparatedText, + UTType(filenameExtension: "yaml") ?? .plainText, + UTType(filenameExtension: "toml") ?? .plainText, + UTType(filenameExtension: "md") ?? .plainText + ] + panel.message = "Select text data files (JSON, YAML, CSV, TXT, MD…)" + guard panel.runModal() == .OK else { return } + for url in panel.urls { + try? AgentSkillFilesService.shared.addFile(from: url, to: skillID) + } + skillFiles = AgentSkillFilesService.shared.listFiles(for: skillID) + } + + private func formatBytes(_ bytes: Int) -> String { + if bytes < 1024 { return "\(bytes) B" } + if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) } + return String(format: "%.1f MB", Double(bytes) / 1_048_576) + } +} + +#Preview { + AgentSkillEditorSheet { _ in } +} diff --git a/oAI/Views/Screens/AgentSkillsView.swift b/oAI/Views/Screens/AgentSkillsView.swift new file mode 100644 index 0000000..86e9b3c --- /dev/null +++ b/oAI/Views/Screens/AgentSkillsView.swift @@ -0,0 +1,662 @@ +// +// AgentSkillsView.swift +// oAI +// +// Modal for managing SKILL.md-style agent skills (opened via /skills command) +// + +import SwiftUI +import UniformTypeIdentifiers + +/// Wrapper so .sheet(item:) always gets a fresh identity, avoiding the timing bug +/// where the sheet captures state before editingSkill is set. +private struct SkillEditContext: Identifiable { + let id = UUID() + let skill: AgentSkill? // nil → new, non-nil → edit +} + +struct AgentSkillsView: View { + @Environment(\.dismiss) var dismiss + @Bindable private var settings = SettingsService.shared + + @State private var editContext: SkillEditContext? = nil + @State private var statusMessage: String? = nil + + private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + VStack(alignment: .leading, spacing: 2) { + Label("Agent Skills", systemImage: "brain") + .font(.system(size: 18, weight: .bold)) + if activeCount > 0 { + Text("\(activeCount) active — injected into every conversation") + .font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2).foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) + } + .padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 12) + + // Toolbar + HStack(spacing: 10) { + Button { editContext = SkillEditContext(skill: nil) } label: { + Label("New Skill", systemImage: "plus") + } + .buttonStyle(.bordered) + + Button { importSkills() } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + + Button { exportAll() } label: { + Label("Export All", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + .disabled(settings.agentSkills.isEmpty) + + Spacer() + + if let msg = statusMessage { + Text(msg).font(.caption).foregroundStyle(.secondary) + } + } + .padding(.horizontal, 24).padding(.bottom, 12) + + Divider() + + if settings.agentSkills.isEmpty { + VStack(spacing: 12) { + Image(systemName: "brain") + .font(.system(size: 40)).foregroundStyle(.tertiary) + Text("No skills yet") + .font(.title3).foregroundStyle(.secondary) + Text("Skills are markdown instruction files that teach the AI how to behave. Active skills are automatically injected into the system prompt.") + .font(.callout).foregroundStyle(.secondary) + .multilineTextAlignment(.center).frame(maxWidth: 380) + Text("You can import any SKILL.md file from skill0.io or write your own.") + .font(.caption).foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity).padding() + } else { + List { + ForEach(settings.agentSkills) { skill in + AgentSkillRow( + skill: skill, + onToggle: { settings.toggleAgentSkill(id: skill.id) }, + onEdit: { editContext = SkillEditContext(skill: skill) }, + onExport: { exportOne(skill) }, + onDelete: { settings.deleteAgentSkill(id: skill.id) } + ) + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + + Divider() + + HStack(spacing: 8) { + Image(systemName: "info.circle").foregroundStyle(.purple).font(.callout) + Text("Active skills are appended to the system prompt. Toggle them per-skill to control what the AI knows.") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: []) + } + .padding(.horizontal, 24).padding(.vertical, 12) + } + .frame(minWidth: 620, idealWidth: 700, minHeight: 480, idealHeight: 600) + .sheet(item: $editContext) { ctx in + AgentSkillEditorSheet(skill: ctx.skill) { saved in + if ctx.skill != nil { settings.updateAgentSkill(saved) } + else { settings.addAgentSkill(saved) } + } + } + } + + // MARK: - Import / Export + + private func importSkills() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowedContentTypes = [ + .plainText, + UTType(filenameExtension: "md") ?? .plainText, + .zip + ] + panel.message = "Select SKILL.md or .zip skill bundles to import" + guard panel.runModal() == .OK else { return } + + var imported = 0 + for url in panel.urls { + let ext = url.pathExtension.lowercased() + if ext == "zip" { + if importZip(url) { imported += 1 } + } else { + if importMarkdown(url) { imported += 1 } + } + } + if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") } + } + + @discardableResult + private func importMarkdown(_ url: URL) -> Bool { + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return false } + let name = skillName(from: content, fallback: url.deletingPathExtension().lastPathComponent) + let description = skillDescription(from: content) + let skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true) + if let existing = settings.agentSkills.first(where: { $0.name == name }) { + var updated = skill; updated.id = existing.id + settings.updateAgentSkill(updated) + } else { + settings.addAgentSkill(skill) + } + return true + } + + @discardableResult + private func importZip(_ zipURL: URL) -> Bool { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + unzip(zipURL, to: tmpDir) + + // Recursively enumerate all files (zip may contain a subdirectory) + let allFiles = recursiveFiles(in: tmpDir) + + // Prefer skill.md by name, fall back to any .md + guard let mdURL = allFiles.first(where: { $0.lastPathComponent.lowercased() == "skill.md" }) + ?? allFiles.first(where: { $0.pathExtension.lowercased() == "md" }), + let content = try? String(contentsOf: mdURL, encoding: .utf8) + else { return false } + + let name = skillName(from: content, fallback: zipURL.deletingPathExtension().lastPathComponent) + let description = skillDescription(from: content) + + // Find or create skill + var skill: AgentSkill + if let existing = settings.agentSkills.first(where: { $0.name == name }) { + skill = AgentSkill(id: existing.id, name: name, skillDescription: description, + content: content, isActive: existing.isActive, + createdAt: existing.createdAt, updatedAt: Date()) + settings.updateAgentSkill(skill) + } else { + skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true) + settings.addAgentSkill(skill) + } + + // Copy all non-skill-md files to skill directory (flatten hierarchy) + let dataFiles = allFiles.filter { $0 != mdURL } + if !dataFiles.isEmpty { + AgentSkillFilesService.shared.ensureDirectory(for: skill.id) + for file in dataFiles { + try? AgentSkillFilesService.shared.addFile(from: file, to: skill.id) + } + } + + return true + } + + /// Recursively list all regular files under a directory + private func recursiveFiles(in directory: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { return [] } + return (enumerator.allObjects as? [URL] ?? []).filter { + (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true + } + } + + private func exportAll() { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + var exported = 0 + for skill in settings.agentSkills { + let safeName = skill.name.lowercased() + .components(separatedBy: .whitespaces).joined(separator: "-") + let files = AgentSkillFilesService.shared.listFiles(for: skill.id) + if files.isEmpty { + let url = downloadsURL.appendingPathComponent(safeName + ".md") + try? skill.content.write(to: url, atomically: true, encoding: .utf8) + } else { + let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip") + exportAsZip(skill: skill, files: files, to: zipURL) + } + exported += 1 + } + show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads") + } + + private func exportOne(_ skill: AgentSkill) { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + let safeName = skill.name.lowercased() + .components(separatedBy: .whitespaces).joined(separator: "-") + let files = AgentSkillFilesService.shared.listFiles(for: skill.id) + + if files.isEmpty { + let url = downloadsURL.appendingPathComponent(safeName + ".md") + try? skill.content.write(to: url, atomically: true, encoding: .utf8) + show("Exported \(safeName).md") + } else { + let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip") + exportAsZip(skill: skill, files: files, to: zipURL) + show("Exported \(safeName).zip") + } + } + + private func exportAsZip(skill: AgentSkill, files: [URL], to zipURL: URL) { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + try? skill.content.write(to: tmp.appendingPathComponent("skill.md"), + atomically: true, encoding: .utf8) + for f in files { + try? FileManager.default.copyItem(at: f, to: tmp.appendingPathComponent(f.lastPathComponent)) + } + zip(directory: tmp, to: zipURL) + } + + // MARK: - zip / unzip helpers (use system binaries, always present on macOS) + + private func unzip(_ zipURL: URL, to destDir: URL) { + try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", zipURL.path, "-d", destDir.path] + try? process.run(); process.waitUntilExit() + } + + private func zip(directory: URL, to destZip: URL) { + if FileManager.default.fileExists(atPath: destZip.path) { + try? FileManager.default.removeItem(at: destZip) + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.currentDirectoryURL = directory + process.arguments = ["-r", destZip.path, "."] + try? process.run(); process.waitUntilExit() + } + + // MARK: - Helpers + + /// Extract skill name from first # heading, fallback to filename + private func skillName(from content: String, fallback: String) -> String { + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("# ") { + return String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces) + } + } + return fallback.isEmpty ? "Untitled Skill" : fallback + } + + /// Extract first non-heading, non-empty line as description + private func skillDescription(from content: String) -> String { + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty && !trimmed.hasPrefix("#") { + return String(trimmed.prefix(120)) + } + } + return "" + } + + private func show(_ text: String) { + statusMessage = text + Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil } + } +} + +// MARK: - Agent Skill Row + +private struct AgentSkillRow: View { + let skill: AgentSkill + let onToggle: () -> Void + let onEdit: () -> Void + let onExport: () -> Void + let onDelete: () -> Void + + private var fileCount: Int { + AgentSkillFilesService.shared.listFiles(for: skill.id).count + } + + var body: some View { + HStack(spacing: 12) { + // Active toggle + Toggle("", isOn: Binding(get: { skill.isActive }, set: { _ in onToggle() })) + .labelsHidden() + .help(skill.isActive ? "Skill is active — click to deactivate" : "Skill is inactive — click to activate") + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(skill.name) + .font(.system(size: 13, weight: .semibold)) + if skill.isActive { + Text("active") + .font(.caption2).foregroundStyle(.white) + .padding(.horizontal, 5).padding(.vertical, 2) + .background(.purple, in: Capsule()) + } + } + Text(skill.resolvedDescription) + .font(.callout).foregroundStyle(.secondary).lineLimit(1) + } + + Spacer() + + // File count badge + if fileCount > 0 { + Label("\(fileCount) file\(fileCount == 1 ? "" : "s")", systemImage: "doc") + .font(.caption2).foregroundStyle(.secondary) + .padding(.horizontal, 6).padding(.vertical, 3) + .background(.blue.opacity(0.1), in: Capsule()) + } + + Text("\(skill.content.count) chars") + .font(.caption2).foregroundStyle(.tertiary) + .padding(.horizontal, 6).padding(.vertical, 3) + .background(.secondary.opacity(0.1), in: Capsule()) + + Button(action: onEdit) { Text("Edit").font(.caption) } + .buttonStyle(.bordered).controlSize(.small) + + Button(action: onExport) { Image(systemName: "square.and.arrow.up") } + .buttonStyle(.bordered).controlSize(.small) + .help(fileCount > 0 ? "Export as .zip" : "Export as .md") + + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") } + .buttonStyle(.bordered).controlSize(.small).help("Delete skill") + } + .padding(.vertical, 4) + } +} + +// MARK: - Agent Skills Tab Content (embedded in SettingsView) + +struct AgentSkillsTabContent: View { + @Bindable private var settings = SettingsService.shared + + @State private var editContext: SkillEditContext? = nil + @State private var statusMessage: String? = nil + + private var activeCount: Int { settings.agentSkills.filter { $0.isActive }.count } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Intro + HStack(spacing: 10) { + Image(systemName: "brain").foregroundStyle(.purple).font(.title3) + VStack(alignment: .leading, spacing: 2) { + Text("Agent Skills") + .font(.system(size: 14, weight: .semibold)) + Text("Markdown instruction files injected into the system prompt. Compatible with SKILL.md format.") + .font(.caption).foregroundStyle(.secondary) + } + } + .padding(10) + .background(.purple.opacity(0.07), in: RoundedRectangle(cornerRadius: 8)) + + if activeCount > 0 { + Label("\(activeCount) skill\(activeCount == 1 ? "" : "s") active — appended to every system prompt", systemImage: "checkmark.circle.fill") + .font(.caption).foregroundStyle(.green) + } + + // Toolbar + HStack(spacing: 10) { + Button { editContext = SkillEditContext(skill: nil) } label: { + Label("New Skill", systemImage: "plus") + } + .buttonStyle(.bordered) + + Button { importSkills() } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + + Button { exportAll() } label: { + Label("Export All", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + .disabled(settings.agentSkills.isEmpty) + + Spacer() + if let msg = statusMessage { + Text(msg).font(.caption).foregroundStyle(.secondary) + } + } + + if settings.agentSkills.isEmpty { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "brain") + .font(.system(size: 32)).foregroundStyle(.tertiary) + Text("No skills yet — click New Skill or Import to get started.") + .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) + } + Spacer() + } + .padding(.vertical, 32) + } else { + VStack(spacing: 0) { + ForEach(Array(settings.agentSkills.enumerated()), id: \.element.id) { idx, skill in + AgentSkillRow( + skill: skill, + onToggle: { settings.toggleAgentSkill(id: skill.id) }, + onEdit: { editContext = SkillEditContext(skill: skill) }, + onExport: { exportOne(skill) }, + onDelete: { settings.deleteAgentSkill(id: skill.id) } + ) + if idx < settings.agentSkills.count - 1 { Divider() } + } + } + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary)) + } + } + .sheet(item: $editContext) { ctx in + AgentSkillEditorSheet(skill: ctx.skill) { saved in + if ctx.skill != nil { settings.updateAgentSkill(saved) } + else { settings.addAgentSkill(saved) } + } + } + } + + private func importSkills() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true; panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowedContentTypes = [ + .plainText, + UTType(filenameExtension: "md") ?? .plainText, + .zip + ] + panel.message = "Select SKILL.md or .zip skill bundles to import" + guard panel.runModal() == .OK else { return } + var imported = 0 + for url in panel.urls { + let ext = url.pathExtension.lowercased() + if ext == "zip" { + if importZip(url) { imported += 1 } + } else { + if importMarkdown(url) { imported += 1 } + } + } + if imported > 0 { show("Imported \(imported) skill\(imported == 1 ? "" : "s")") } + } + + @discardableResult + private func importMarkdown(_ url: URL) -> Bool { + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return false } + let name = skillName(from: content, fallback: url.deletingPathExtension().lastPathComponent) + let description = skillDescription(from: content) + let skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true) + if let existing = settings.agentSkills.first(where: { $0.name == name }) { + var updated = skill; updated.id = existing.id; settings.updateAgentSkill(updated) + } else { + settings.addAgentSkill(skill) + } + return true + } + + @discardableResult + private func importZip(_ zipURL: URL) -> Bool { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + unzip(zipURL, to: tmpDir) + + // Recursively enumerate all files (zip may contain a subdirectory) + let allFiles = recursiveFiles(in: tmpDir) + + guard let mdURL = allFiles.first(where: { $0.lastPathComponent.lowercased() == "skill.md" }) + ?? allFiles.first(where: { $0.pathExtension.lowercased() == "md" }), + let content = try? String(contentsOf: mdURL, encoding: .utf8) + else { return false } + + let name = skillName(from: content, fallback: zipURL.deletingPathExtension().lastPathComponent) + let description = skillDescription(from: content) + + var skill: AgentSkill + if let existing = settings.agentSkills.first(where: { $0.name == name }) { + skill = AgentSkill(id: existing.id, name: name, skillDescription: description, + content: content, isActive: existing.isActive, + createdAt: existing.createdAt, updatedAt: Date()) + settings.updateAgentSkill(skill) + } else { + skill = AgentSkill(name: name, skillDescription: description, content: content, isActive: true) + settings.addAgentSkill(skill) + } + + let dataFiles = allFiles.filter { $0 != mdURL } + if !dataFiles.isEmpty { + AgentSkillFilesService.shared.ensureDirectory(for: skill.id) + for file in dataFiles { + try? AgentSkillFilesService.shared.addFile(from: file, to: skill.id) + } + } + + return true + } + + /// Recursively list all regular files under a directory + private func recursiveFiles(in directory: URL) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { return [] } + return (enumerator.allObjects as? [URL] ?? []).filter { + (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true + } + } + + private func exportAll() { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + var exported = 0 + for skill in settings.agentSkills { + let safeName = skill.name.lowercased() + .components(separatedBy: .whitespaces).joined(separator: "-") + let files = AgentSkillFilesService.shared.listFiles(for: skill.id) + if files.isEmpty { + let url = downloadsURL.appendingPathComponent(safeName + ".md") + try? skill.content.write(to: url, atomically: true, encoding: .utf8) + } else { + let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip") + exportAsZip(skill: skill, files: files, to: zipURL) + } + exported += 1 + } + show("Exported \(exported) skill\(exported == 1 ? "" : "s") to Downloads") + } + + private func exportOne(_ skill: AgentSkill) { + let downloadsURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + let safeName = skill.name.lowercased() + .components(separatedBy: .whitespaces).joined(separator: "-") + let files = AgentSkillFilesService.shared.listFiles(for: skill.id) + + if files.isEmpty { + let url = downloadsURL.appendingPathComponent(safeName + ".md") + try? skill.content.write(to: url, atomically: true, encoding: .utf8) + show("Exported \(safeName).md") + } else { + let zipURL = downloadsURL.appendingPathComponent(safeName + ".zip") + exportAsZip(skill: skill, files: files, to: zipURL) + show("Exported \(safeName).zip") + } + } + + private func exportAsZip(skill: AgentSkill, files: [URL], to zipURL: URL) { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + try? FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + try? skill.content.write(to: tmp.appendingPathComponent("skill.md"), + atomically: true, encoding: .utf8) + for f in files { + try? FileManager.default.copyItem(at: f, to: tmp.appendingPathComponent(f.lastPathComponent)) + } + zip(directory: tmp, to: zipURL) + } + + private func unzip(_ zipURL: URL, to destDir: URL) { + try? FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-o", zipURL.path, "-d", destDir.path] + try? process.run(); process.waitUntilExit() + } + + private func zip(directory: URL, to destZip: URL) { + if FileManager.default.fileExists(atPath: destZip.path) { + try? FileManager.default.removeItem(at: destZip) + } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + process.currentDirectoryURL = directory + process.arguments = ["-r", destZip.path, "."] + try? process.run(); process.waitUntilExit() + } + + private func skillName(from content: String, fallback: String) -> String { + for line in content.components(separatedBy: .newlines) { + let t = line.trimmingCharacters(in: .whitespaces) + if t.hasPrefix("# ") { return String(t.dropFirst(2)).trimmingCharacters(in: .whitespaces) } + } + return fallback.isEmpty ? "Untitled Skill" : fallback + } + + private func skillDescription(from content: String) -> String { + for line in content.components(separatedBy: .newlines) { + let t = line.trimmingCharacters(in: .whitespaces) + if !t.isEmpty && !t.hasPrefix("#") { return String(t.prefix(120)) } + } + return "" + } + + private func show(_ text: String) { + statusMessage = text + Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil } + } +} + +#Preview { AgentSkillsView() } diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 9a66aeb..93771e4 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -17,6 +17,8 @@ struct ConversationListView: View { @State private var useSemanticSearch = false @State private var semanticResults: [Conversation] = [] @State private var isSearching = false + @State private var selectedIndex: Int = 0 + @FocusState private var searchFocused: Bool private let settings = SettingsService.shared var onLoad: ((Conversation) -> Void)? @@ -88,11 +90,34 @@ struct ConversationListView: View { .foregroundStyle(.secondary) TextField("Search conversations...", text: $searchText) .textFieldStyle(.plain) + .focused($searchFocused) .onChange(of: searchText) { + selectedIndex = 0 if useSemanticSearch && settings.embeddingsEnabled && !searchText.isEmpty { performSemanticSearch() } } + #if os(macOS) + .onKeyPress(.upArrow) { + if selectedIndex > 0 { + selectedIndex -= 1 + } + return .handled + } + .onKeyPress(.downArrow) { + if selectedIndex < filteredConversations.count - 1 { + selectedIndex += 1 + } + return .handled + } + .onKeyPress(.return, phases: .down) { _ in + guard !isSelecting, !filteredConversations.isEmpty else { return .ignored } + let conv = filteredConversations[min(selectedIndex, filteredConversations.count - 1)] + onLoad?(conv) + dismiss() + return .handled + } + #endif if !searchText.isEmpty { Button { searchText = "" } label: { Image(systemName: "xmark.circle.fill") @@ -143,80 +168,98 @@ struct ConversationListView: View { } Spacer() } else { - List { - ForEach(filteredConversations) { conversation in - HStack(spacing: 12) { - if isSelecting { - Button { - toggleSelection(conversation.id) - } label: { - Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle") - .foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary) - .font(.title2) - } - .buttonStyle(.plain) - } - - ConversationRow(conversation: conversation) - .contentShape(Rectangle()) - .onTapGesture { - if isSelecting { + ScrollViewReader { proxy in + List { + ForEach(Array(filteredConversations.enumerated()), id: \.element.id) { index, conversation in + HStack(spacing: 12) { + if isSelecting { + Button { toggleSelection(conversation.id) - } else { - onLoad?(conversation) - dismiss() + } label: { + Image(systemName: selectedConversations.contains(conversation.id) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedConversations.contains(conversation.id) ? .blue : .secondary) + .font(.title2) } + .buttonStyle(.plain) } - Spacer() + ConversationRow(conversation: conversation) + .contentShape(Rectangle()) + .onTapGesture { + if isSelecting { + toggleSelection(conversation.id) + } else { + selectedIndex = index + onLoad?(conversation) + dismiss() + } + } - if !isSelecting { - Button { + Spacer() + + if !isSelecting { + Button { + deleteConversation(conversation) + } label: { + Image(systemName: "trash") + .foregroundStyle(.red) + .font(.system(size: 16)) + } + .buttonStyle(.plain) + .help("Delete conversation") + } + } + .listRowBackground( + !isSelecting && index == selectedIndex + ? Color.oaiAccent.opacity(0.15) + : Color.clear + ) + .id(conversation.id) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { deleteConversation(conversation) } label: { - Image(systemName: "trash") - .foregroundStyle(.red) - .font(.system(size: 16)) + Label("Delete", systemImage: "trash") } - .buttonStyle(.plain) - .help("Delete conversation") + + Button { + exportConversation(conversation) + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + .tint(.blue) } } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - deleteConversation(conversation) - } label: { - Label("Delete", systemImage: "trash") - } - - Button { - exportConversation(conversation) - } label: { - Label("Export", systemImage: "square.and.arrow.up") - } - .tint(.blue) + } + .listStyle(.plain) + .onChange(of: selectedIndex) { + guard !filteredConversations.isEmpty else { return } + let clamped = min(selectedIndex, filteredConversations.count - 1) + withAnimation(.easeInOut(duration: 0.1)) { + proxy.scrollTo(filteredConversations[clamped].id, anchor: .center) } } } - .listStyle(.plain) } Divider() // Bottom bar HStack { + Text("↑↓ navigate ↩ open") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) Spacer() Button("Done") { dismiss() } - .keyboardShortcut(.return, modifiers: []) .buttonStyle(.borderedProminent) .controlSize(.regular) - Spacer() } .padding(.horizontal, 24) .padding(.vertical, 12) } .onAppear { loadConversations() + searchFocused = true } .frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) } @@ -251,6 +294,7 @@ struct ConversationListView: View { selectedConversations.removeAll() isSelecting = false } + selectedIndex = 0 } private func deleteConversation(_ conversation: Conversation) { @@ -259,6 +303,7 @@ struct ConversationListView: View { withAnimation { conversations.removeAll { $0.id == conversation.id } } + selectedIndex = min(selectedIndex, max(0, filteredConversations.count - 1)) } catch { Log.db.error("Failed to delete conversation: \(error.localizedDescription)") } @@ -274,7 +319,6 @@ struct ConversationListView: View { Task { do { - // Use user's selected provider, or fall back to best available guard let provider = EmbeddingService.shared.getSelectedProvider() else { Log.api.warning("No embedding providers available - skipping semantic search") await MainActor.run { @@ -283,13 +327,11 @@ struct ConversationListView: View { return } - // Generate embedding for search query let embedding = try await EmbeddingService.shared.generateEmbedding( text: searchText, provider: provider ) - // Search conversations let results = try DatabaseService.shared.searchConversationsBySemantic( queryEmbedding: embedding, limit: 20 @@ -297,6 +339,7 @@ struct ConversationListView: View { await MainActor.run { semanticResults = results.map { $0.0 } + selectedIndex = 0 isSearching = false Log.ui.info("Semantic search found \(results.count) results using \(provider.displayName)") } @@ -333,26 +376,47 @@ struct ConversationRow: View { private var formattedDate: String { let formatter = DateFormatter() - formatter.dateFormat = "dd.MM.yyyy HH:mm:ss" + formatter.dateFormat = "dd.MM.yyyy HH:mm" return formatter.string(from: conversation.updatedAt) } - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(conversation.name) - .font(.system(size: 16, weight: .semibold)) + /// Strips the provider prefix from OpenRouter-style IDs (e.g. "anthropic/claude-3" → "claude-3") + private var modelDisplayName: String? { + guard let model = conversation.primaryModel, !model.isEmpty else { return nil } + if let slash = model.lastIndex(of: "/") { + return String(model[model.index(after: slash)...]) + } + return model + } - HStack(spacing: 8) { + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(conversation.name) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1) + + HStack(spacing: 6) { Label("\(conversation.messageCount)", systemImage: "message") - .font(.system(size: 13)) - Text("\u{2022}") - .font(.system(size: 13)) + .font(.system(size: 12)) + + Text("•") + .font(.system(size: 12)) + Text(formattedDate) - .font(.system(size: 13)) + .font(.system(size: 12)) + + if let model = modelDisplayName { + Text("•") + .font(.system(size: 12)) + Text(model) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + } } .foregroundColor(.secondary) } - .padding(.vertical, 6) + .padding(.vertical, 5) } } diff --git a/oAI/Views/Screens/HelpView.swift b/oAI/Views/Screens/HelpView.swift index 0e85ac2..67995e1 100644 --- a/oAI/Views/Screens/HelpView.swift +++ b/oAI/Views/Screens/HelpView.swift @@ -189,11 +189,41 @@ struct HelpView: View { @Environment(\.dismiss) var dismiss @State private var searchText = "" @State private var expandedCommandID: UUID? + private let settings = SettingsService.shared + + private var allCategories: [CommandCategory] { + var cats = helpCategories + let shortcuts = settings.userShortcuts + if !shortcuts.isEmpty { + let shortcutCommands = shortcuts.map { s in + CommandDetail( + command: s.command + (s.needsInput ? " " : ""), + brief: s.description, + detail: "Template: \(s.template)", + examples: s.needsInput ? ["\(s.command) your text here"] : [s.command] + ) + } + cats.append(CommandCategory(name: "Your Shortcuts", icon: "bolt.fill", commands: shortcutCommands)) + } + let activeSkills = settings.agentSkills.filter { $0.isActive } + if !activeSkills.isEmpty { + let skillCommands = activeSkills.map { skill in + CommandDetail( + command: skill.name, + brief: skill.skillDescription, + detail: "Active skill — injected into system prompt automatically.\n\nContent:\n\(skill.content)", + examples: [] + ) + } + cats.append(CommandCategory(name: "Active Skills", icon: "brain", commands: skillCommands)) + } + return cats + } private var filteredCategories: [CommandCategory] { - if searchText.isEmpty { return helpCategories } + if searchText.isEmpty { return allCategories } let q = searchText.lowercased() - return helpCategories.compactMap { cat in + return allCategories.compactMap { cat in let matched = cat.commands.filter { $0.command.lowercased().contains(q) || $0.brief.lowercased().contains(q) || diff --git a/oAI/Views/Screens/SettingsView.swift b/oAI/Views/Screens/SettingsView.swift index 1127c1e..c0c71ca 100644 --- a/oAI/Views/Screens/SettingsView.swift +++ b/oAI/Views/Screens/SettingsView.swift @@ -39,12 +39,6 @@ struct SettingsView: View { @State private var syncTestResult: String? @State private var isSyncing = false - // OAuth state - @State private var oauthCode = "" - @State private var oauthError: String? - @State private var showOAuthCodeField = false - private var oauthService = AnthropicOAuthService.shared - // Email handler state @State private var showEmailLog = false @State private var showEmailModelSelector = false @@ -98,6 +92,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak Text("Advanced").tag(3) Text("Sync").tag(4) Text("Email").tag(5) + Text("Shortcuts").tag(6) + Text("Skills").tag(7) } .pickerStyle(.segmented) .padding(.horizontal, 24) @@ -120,6 +116,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak syncTab case 5: emailTab + case 6: + shortcutsTab + case 7: + agentSkillsTab default: generalTab } @@ -179,75 +179,15 @@ It's better to admit "I need more information" or "I cannot do that" than to fak ProviderRegistry.shared.clearCache() } } - // Anthropic: OAuth or API key + // Anthropic: API key row("Anthropic") { - VStack(alignment: .leading, spacing: 8) { - if oauthService.isAuthenticated { - // Logged in via OAuth - HStack(spacing: 8) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - Text("Logged in via Claude Pro/Max") - .font(.system(size: 14)) - Spacer() - Button("Logout") { - oauthService.logout() - ProviderRegistry.shared.clearCache() - } - .font(.system(size: 14)) - .foregroundStyle(.red) - } - } else if showOAuthCodeField { - // Waiting for code paste - HStack(spacing: 8) { - TextField("Paste authorization code...", text: $oauthCode) - .textFieldStyle(.roundedBorder) - Button("Submit") { - Task { await submitOAuthCode() } - } - .disabled(oauthCode.isEmpty || oauthService.isLoggingIn) - Button("Cancel") { - showOAuthCodeField = false - oauthCode = "" - oauthError = nil - } - .font(.system(size: 14)) - } - if let error = oauthError { - Text(error) - .font(.system(size: 13)) - .foregroundStyle(.red) - } - } else { - // Login button + API key field - HStack(spacing: 8) { - Button { - startOAuthLogin() - } label: { - HStack(spacing: 4) { - Image(systemName: "person.circle") - Text("Login with Claude Pro/Max") - } - .font(.system(size: 14)) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - - Text("or") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - - SecureField("sk-ant-... (API key)", text: $anthropicKey) - .textFieldStyle(.roundedBorder) - .onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" } - .onChange(of: anthropicKey) { - settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey - ProviderRegistry.shared.clearCache() - } + SecureField("sk-ant-... (API key)", text: $anthropicKey) + .textFieldStyle(.roundedBorder) + .onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" } + .onChange(of: anthropicKey) { + settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey + ProviderRegistry.shared.clearCache() } - } - .frame(width: 400, alignment: .leading) } row("OpenAI") { SecureField("sk-...", text: $openaiKey) @@ -531,6 +471,8 @@ It's better to admit "I need more information" or "I cannot do that" than to fak .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } + + // Anytype integration UI hidden (work in progress — see AnytypeMCPService.swift) } // MARK: - Appearance Tab @@ -1742,6 +1684,20 @@ It's better to admit "I need more information" or "I cannot do that" than to fak } } + // MARK: - Shortcuts Tab + + @ViewBuilder + private var shortcutsTab: some View { + ShortcutsTabContent() + } + + // MARK: - Agent Skills Tab + + @ViewBuilder + private var agentSkillsTab: some View { + AgentSkillsTabContent() + } + // MARK: - Layout Helpers private func sectionHeader(_ title: String) -> some View { @@ -1820,32 +1776,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak isTestingEmailConnection = false } - // MARK: - OAuth Helpers - - private func startOAuthLogin() { - let url = oauthService.generateAuthorizationURL() - #if os(macOS) - NSWorkspace.shared.open(url) - #endif - showOAuthCodeField = true - oauthError = nil - oauthCode = "" - } - - private func submitOAuthCode() async { - oauthService.isLoggingIn = true - oauthError = nil - do { - try await oauthService.exchangeCode(oauthCode) - showOAuthCodeField = false - oauthCode = "" - ProviderRegistry.shared.clearCache() - } catch { - oauthError = error.localizedDescription - } - oauthService.isLoggingIn = false - } - // MARK: - Sync Helpers private func testSyncConnection() async { diff --git a/oAI/Views/Screens/SkillEditorSheet.swift b/oAI/Views/Screens/SkillEditorSheet.swift new file mode 100644 index 0000000..d572f7b --- /dev/null +++ b/oAI/Views/Screens/SkillEditorSheet.swift @@ -0,0 +1,147 @@ +// +// ShortcutEditorSheet.swift +// oAI +// +// Create or edit a user-defined shortcut (prompt template) +// + +import SwiftUI + +struct ShortcutEditorSheet: View { + @Environment(\.dismiss) var dismiss + + let isNew: Bool + let onSave: (Shortcut) -> Void + + @State private var shortcutID: UUID + @State private var command: String + @State private var description: String + @State private var template: String + @State private var createdAt: Date + + init(shortcut: Shortcut? = nil, onSave: @escaping (Shortcut) -> Void) { + self.isNew = shortcut == nil + self.onSave = onSave + let s = shortcut ?? Shortcut(command: "/", description: "", template: "") + _shortcutID = State(initialValue: s.id) + _command = State(initialValue: s.command) + _description = State(initialValue: s.description) + _template = State(initialValue: s.template) + _createdAt = State(initialValue: s.createdAt) + } + + private var normalizedCommand: String { + var c = command.lowercased().trimmingCharacters(in: .whitespaces) + if !c.hasPrefix("/") { c = "/" + c } + c = c.components(separatedBy: .whitespaces).joined() + return c + } + + private var isValid: Bool { + normalizedCommand.count > 1 + && !description.trimmingCharacters(in: .whitespaces).isEmpty + && !template.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text(isNew ? "New Shortcut" : "Edit Shortcut") + .font(.system(size: 18, weight: .bold)) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2).foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) + } + .padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Command").font(.system(size: 13, weight: .semibold)) + TextField("/command", text: $command) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13, design: .monospaced)) + .onChange(of: command) { + if !command.isEmpty && !command.hasPrefix("/") { + command = "/" + command + } + } + Text("Lowercase letters, numbers, and hyphens only. No spaces.") + .font(.caption).foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Description").font(.system(size: 13, weight: .semibold)) + TextField("Brief description shown in the command dropdown", text: $description) + .textFieldStyle(.roundedBorder).font(.system(size: 13)) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Template").font(.system(size: 13, weight: .semibold)) + TextEditor(text: $template) + .font(.system(size: 13, design: .monospaced)) + .frame(minHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(6) + .overlay(RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1)) + + HStack(alignment: .top, spacing: 8) { + Image(systemName: "info.circle").foregroundStyle(.blue).font(.callout) + VStack(alignment: .leading, spacing: 4) { + Text("Use **{{input}}** to insert whatever you type after the command.") + .font(.caption) + if template.contains("{{input}}") { + Label("Needs input — user types after the command", systemImage: "checkmark.circle.fill") + .font(.caption).foregroundStyle(.green) + } else { + Label("Executes immediately — no extra input needed", systemImage: "bolt.fill") + .font(.caption).foregroundStyle(.orange) + } + } + } + .padding(10) + .background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) + } + } + .padding(.horizontal, 24).padding(.vertical, 16) + } + + Divider() + + HStack { + Button("Cancel") { dismiss() }.buttonStyle(.bordered) + Spacer() + Button("Save") { + let shortcut = Shortcut( + id: shortcutID, + command: normalizedCommand, + description: description.trimmingCharacters(in: .whitespaces), + template: template, + createdAt: createdAt, + updatedAt: Date() + ) + onSave(shortcut) + dismiss() + } + .buttonStyle(.borderedProminent) + .disabled(!isValid) + .keyboardShortcut(.return, modifiers: [.command]) + } + .padding(.horizontal, 24).padding(.vertical, 12) + } + .frame(minWidth: 480, idealWidth: 540, minHeight: 460, idealHeight: 520) + } +} + +#Preview { + ShortcutEditorSheet { _ in } +} diff --git a/oAI/Views/Screens/SkillsView.swift b/oAI/Views/Screens/SkillsView.swift new file mode 100644 index 0000000..1b20415 --- /dev/null +++ b/oAI/Views/Screens/SkillsView.swift @@ -0,0 +1,413 @@ +// +// ShortcutsView.swift +// oAI +// +// Modal for managing user-defined shortcuts (opened via /shortcuts command) +// + +import SwiftUI +import UniformTypeIdentifiers + +struct ShortcutsView: View { + @Environment(\.dismiss) var dismiss + @Bindable private var settings = SettingsService.shared + + @State private var showEditor = false + @State private var editingShortcut: Shortcut? = nil + @State private var importConflicts: [ShortcutConflict] = [] + @State private var showConflictAlert = false + @State private var pendingImport: [Shortcut] = [] + @State private var statusMessage: String? = nil + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Label("Your Shortcuts", systemImage: "bolt.fill") + .font(.system(size: 18, weight: .bold)) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) + } + .padding(.horizontal, 24) + .padding(.top, 20) + .padding(.bottom, 12) + + // Toolbar + HStack(spacing: 10) { + Button { editingShortcut = nil; showEditor = true } label: { + Label("New Shortcut", systemImage: "plus") + } + .buttonStyle(.bordered) + + Button { importShortcuts() } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + + Button { exportAll() } label: { + Label("Export All", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + .disabled(settings.userShortcuts.isEmpty) + + Spacer() + + if let msg = statusMessage { + Text(msg).font(.caption).foregroundStyle(.secondary) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 12) + + Divider() + + if settings.userShortcuts.isEmpty { + VStack(spacing: 12) { + Image(systemName: "bolt.slash") + .font(.system(size: 40)) + .foregroundStyle(.tertiary) + Text("No shortcuts yet") + .font(.title3) + .foregroundStyle(.secondary) + Text("Create a shortcut to save a reusable prompt template accessible from the / command dropdown.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 340) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + List { + ForEach(settings.userShortcuts) { shortcut in + ShortcutRow( + shortcut: shortcut, + onEdit: { editingShortcut = shortcut; showEditor = true }, + onExport: { exportOne(shortcut) }, + onDelete: { settings.deleteShortcut(id: shortcut.id) } + ) + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + } + + Divider() + + HStack(spacing: 8) { + Image(systemName: "lightbulb") + .foregroundStyle(.yellow) + .font(.callout) + Text("Use **{{input}}** in the template to insert whatever you type after the command.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button("Done") { dismiss() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: []) + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + } + .frame(minWidth: 560, idealWidth: 640, minHeight: 440, idealHeight: 560) + .sheet(isPresented: $showEditor) { + ShortcutEditorSheet(shortcut: editingShortcut) { saved in + if editingShortcut != nil { settings.updateShortcut(saved) } + else { settings.addShortcut(saved) } + editingShortcut = nil + } + } + .alert("Duplicate Shortcut", isPresented: $showConflictAlert, presenting: importConflicts.first) { c in + Button("Replace") { resolveConflict(c, action: .replace) } + Button("Keep Both") { resolveConflict(c, action: .keepBoth) } + Button("Skip") { resolveConflict(c, action: .skip) } + } message: { c in + Text("A shortcut with command \(c.incoming.command) already exists.") + } + } + + // MARK: - Import / Export + + private func importShortcuts() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowedContentTypes = [.json] + panel.message = "Select shortcut JSON files to import" + guard panel.runModal() == .OK else { return } + + var incoming: [Shortcut] = [] + for url in panel.urls { + guard let data = try? Data(contentsOf: url) else { continue } + if let pack = try? JSONDecoder().decode([Shortcut].self, from: data) { + incoming.append(contentsOf: pack) + } else if let single = try? JSONDecoder().decode(Shortcut.self, from: data) { + incoming.append(single) + } + } + guard !incoming.isEmpty else { show("No valid shortcuts found"); return } + pendingImport = incoming + processNext() + } + + private func processNext() { + guard !pendingImport.isEmpty else { importConflicts = []; return } + let item = pendingImport.removeFirst() + if settings.userShortcuts.first(where: { $0.command == item.command }) != nil { + importConflicts.append(ShortcutConflict(incoming: item)) + showConflictAlert = true + } else { + settings.addShortcut(item) + show("Imported \(item.command)") + processNext() + } + } + + private func resolveConflict(_ c: ShortcutConflict, action: ConflictAction) { + importConflicts.removeFirst() + switch action { + case .replace: + if let ex = settings.userShortcuts.first(where: { $0.command == c.incoming.command }) { + var u = c.incoming; u.id = ex.id; settings.updateShortcut(u) + } + case .keepBoth: + var copy = c.incoming; copy.id = UUID(); copy.command += "-copy"; settings.addShortcut(copy) + case .skip: break + } + processNext() + } + + private func exportAll() { + let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601 + enc.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? enc.encode(settings.userShortcuts) else { return } + let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + .appendingPathComponent("shortcuts.json") + try? data.write(to: url) + show("Exported to Downloads/shortcuts.json") + } + + private func exportOne(_ shortcut: Shortcut) { + let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601 + enc.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? enc.encode(shortcut) else { return } + let filename = String(shortcut.command.dropFirst()) + ".json" + let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + .appendingPathComponent(filename) + try? data.write(to: url) + show("Exported \(filename)") + } + + private func show(_ text: String) { + statusMessage = text + Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil } + } + + // MARK: - Types + + struct ShortcutConflict { let incoming: Shortcut } + enum ConflictAction { case replace, keepBoth, skip } +} + +// MARK: - Shortcut Row + +private struct ShortcutRow: View { + let shortcut: Shortcut + let onEdit: () -> Void + let onExport: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(shortcut.command) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + Text(shortcut.description) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + if shortcut.needsInput { + Label("needs input", systemImage: "text.cursor") + .font(.caption2).foregroundStyle(.secondary) + .padding(.horizontal, 6).padding(.vertical, 3) + .background(.secondary.opacity(0.12), in: Capsule()) + } else { + Label("immediate", systemImage: "bolt") + .font(.caption2).foregroundStyle(.orange) + .padding(.horizontal, 6).padding(.vertical, 3) + .background(.orange.opacity(0.12), in: Capsule()) + } + Button(action: onEdit) { Text("Edit").font(.caption) } + .buttonStyle(.bordered).controlSize(.small) + Button(action: onExport) { Image(systemName: "square.and.arrow.up") } + .buttonStyle(.bordered).controlSize(.small).help("Export shortcut") + Button(role: .destructive, action: onDelete) { Image(systemName: "trash") } + .buttonStyle(.bordered).controlSize(.small).help("Delete shortcut") + } + .padding(.vertical, 4) + } +} + +// MARK: - Shortcuts Tab Content (embedded in SettingsView) + +struct ShortcutsTabContent: View { + @Bindable private var settings = SettingsService.shared + + @State private var showEditor = false + @State private var editingShortcut: Shortcut? = nil + @State private var importConflicts: [ShortcutsView.ShortcutConflict] = [] + @State private var showConflictAlert = false + @State private var pendingImport: [Shortcut] = [] + @State private var statusMessage: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Button { editingShortcut = nil; showEditor = true } label: { + Label("New Shortcut", systemImage: "plus") + } + .buttonStyle(.bordered) + + Button { importShortcuts() } label: { + Label("Import", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + + Button { exportAll() } label: { + Label("Export All", systemImage: "square.and.arrow.up") + } + .buttonStyle(.bordered) + .disabled(settings.userShortcuts.isEmpty) + + Spacer() + if let msg = statusMessage { + Text(msg).font(.caption).foregroundStyle(.secondary) + } + } + + if settings.userShortcuts.isEmpty { + HStack { + Spacer() + VStack(spacing: 8) { + Image(systemName: "bolt.slash") + .font(.system(size: 32)).foregroundStyle(.tertiary) + Text("No shortcuts yet — click New Shortcut to create one.") + .font(.callout).foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, 32) + } else { + VStack(spacing: 0) { + ForEach(Array(settings.userShortcuts.enumerated()), id: \.element.id) { idx, shortcut in + ShortcutRow( + shortcut: shortcut, + onEdit: { editingShortcut = shortcut; showEditor = true }, + onExport: { exportOne(shortcut) }, + onDelete: { settings.deleteShortcut(id: shortcut.id) } + ) + if idx < settings.userShortcuts.count - 1 { Divider() } + } + } + .background(.background, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary)) + } + + HStack(spacing: 8) { + Image(systemName: "lightbulb").foregroundStyle(.yellow).font(.callout) + Text("Use **{{input}}** in the template to insert whatever you type after the command.") + .font(.caption).foregroundStyle(.secondary) + } + .padding(10) + .background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) + } + .sheet(isPresented: $showEditor) { + ShortcutEditorSheet(shortcut: editingShortcut) { saved in + if editingShortcut != nil { settings.updateShortcut(saved) } + else { settings.addShortcut(saved) } + editingShortcut = nil + } + } + .alert("Duplicate Shortcut", isPresented: $showConflictAlert, presenting: importConflicts.first) { c in + Button("Replace") { resolveConflict(c, action: .replace) } + Button("Keep Both") { resolveConflict(c, action: .keepBoth) } + Button("Skip") { resolveConflict(c, action: .skip) } + } message: { c in + Text("A shortcut with command \(c.incoming.command) already exists.") + } + } + + private func importShortcuts() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true; panel.canChooseFiles = true + panel.canChooseDirectories = false; panel.allowedContentTypes = [.json] + panel.message = "Select shortcut JSON files to import" + guard panel.runModal() == .OK else { return } + var incoming: [Shortcut] = [] + for url in panel.urls { + guard let data = try? Data(contentsOf: url) else { continue } + if let pack = try? JSONDecoder().decode([Shortcut].self, from: data) { incoming.append(contentsOf: pack) } + else if let single = try? JSONDecoder().decode(Shortcut.self, from: data) { incoming.append(single) } + } + guard !incoming.isEmpty else { show("No valid shortcuts found"); return } + pendingImport = incoming; processNext() + } + + private func processNext() { + guard !pendingImport.isEmpty else { importConflicts = []; return } + let item = pendingImport.removeFirst() + if settings.userShortcuts.first(where: { $0.command == item.command }) != nil { + importConflicts.append(ShortcutsView.ShortcutConflict(incoming: item)); showConflictAlert = true + } else { + settings.addShortcut(item); show("Imported \(item.command)"); processNext() + } + } + + private func resolveConflict(_ c: ShortcutsView.ShortcutConflict, action: ShortcutsView.ConflictAction) { + importConflicts.removeFirst() + switch action { + case .replace: + if let ex = settings.userShortcuts.first(where: { $0.command == c.incoming.command }) { + var u = c.incoming; u.id = ex.id; settings.updateShortcut(u) + } + case .keepBoth: + var copy = c.incoming; copy.id = UUID(); copy.command += "-copy"; settings.addShortcut(copy) + case .skip: break + } + processNext() + } + + private func exportAll() { + let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601 + enc.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? enc.encode(settings.userShortcuts) else { return } + let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + .appendingPathComponent("shortcuts.json") + try? data.write(to: url); show("Exported to Downloads/shortcuts.json") + } + + private func exportOne(_ shortcut: Shortcut) { + let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601 + enc.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? enc.encode(shortcut) else { return } + let filename = String(shortcut.command.dropFirst()) + ".json" + let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first! + .appendingPathComponent(filename) + try? data.write(to: url); show("Exported \(filename)") + } + + private func show(_ text: String) { + statusMessage = text + Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil } + } +} + +#Preview { ShortcutsView() } diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index a1ec3e5..0515f7a 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -54,6 +54,13 @@ struct oAIApp: App { } } + CommandGroup(replacing: .appSettings) { + Button("Settings...") { + chatViewModel.showSettings = true + } + .keyboardShortcut(",", modifiers: .command) + } + CommandGroup(replacing: .help) { Button("oAI Help") { openHelp()