Added skills, shortcuts, and bugifixes++

This commit is contained in:
2026-02-18 11:58:45 +01:00
parent 09463d7620
commit 54a8c47df4
24 changed files with 3172 additions and 239 deletions

View File

@@ -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