BroV: single Mac target, chat brain = local claude CLI (stream-json, resume)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -217,6 +217,10 @@ final class ClaudeService {
|
||||
// MARK: - Chat (multi-turn, natural text + web search)
|
||||
|
||||
func chat(query: String, context: PromptContext?, state: AppState) async {
|
||||
if state.chatProvider == .claudeCode {
|
||||
await chatClaudeCode(query: query, context: context, state: state)
|
||||
return
|
||||
}
|
||||
guard state.chatProvider == .anthropic else {
|
||||
await chatOpenAICompatible(query: query, context: context, state: state)
|
||||
return
|
||||
@@ -264,6 +268,66 @@ final class ClaudeService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
|
||||
|
||||
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
|
||||
let cli = ClaudeCodeCLI.shared
|
||||
var prompt = query
|
||||
// Context goes with the first message of a conversation, like the API chat.
|
||||
if cli.sessionID == nil, let context {
|
||||
switch context {
|
||||
case .window(let app, let title, let url):
|
||||
var prefix = "Context — App: \(app), Window: \(title)"
|
||||
if let url { prefix += ", URL: \(url)" }
|
||||
prompt = prefix + "\n\n" + query
|
||||
case .file(let name, let fileURL):
|
||||
// The CLI reads files itself: hand it the path.
|
||||
if let fileURL {
|
||||
prompt = "Attached file: \(fileURL.path)\n(read it before answering)\n\n" + query
|
||||
} else {
|
||||
prompt = "File: \(name)\n\n" + query
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let placeholder = ChatMessage(role: .assistant, content: "")
|
||||
let msgId = placeholder.id
|
||||
state.chatHistory.append(placeholder)
|
||||
state.stateOverride = .thinking
|
||||
|
||||
do {
|
||||
let final = try await cli.send(
|
||||
prompt: prompt,
|
||||
model: state.claudeCodeModel,
|
||||
workingDirectory: state.chatWorkingDirectory,
|
||||
binaryPath: state.claudeBinaryPath,
|
||||
permissionMode: state.claudePermissionMode
|
||||
) { [state, msgId] visible in
|
||||
if !visible.isEmpty, state.stateOverride == .thinking {
|
||||
state.stateOverride = nil // hide typing dots on first visible text
|
||||
}
|
||||
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
|
||||
state.chatHistory[idx].content = visible
|
||||
}
|
||||
}
|
||||
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
|
||||
if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
|
||||
else { state.chatHistory[idx].content = trimmed }
|
||||
}
|
||||
state.stateOverride = nil
|
||||
state.view = .prompt
|
||||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
|
||||
} catch is CancellationError {
|
||||
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
|
||||
state.stateOverride = nil
|
||||
} catch {
|
||||
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
|
||||
state.stateOverride = nil
|
||||
await showError(error.localizedDescription, state: state)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpenAI-compatible chat (Google Gemini / OpenAI / Ollama / LM Studio)
|
||||
|
||||
func chatOpenAICompatible(query: String, context: PromptContext?, state: AppState) async {
|
||||
@@ -279,7 +343,7 @@ final class ClaudeService {
|
||||
switch provider {
|
||||
case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
case .openai: baseURL = "https://api.openai.com/v1"
|
||||
case .anthropic, .ollama, .lmstudio: baseURL = ""
|
||||
case .anthropic, .ollama, .lmstudio, .claudeCode: baseURL = ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user