Added skills, shortcuts, and bugifixes++
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user