SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
6.8 KB · 166 lines swift
Raw Blame History
1//2//  AIService.swift3//  Zyquo Atlas4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Orchestrates browser AI actions against the ported provider clients: builds9//  the grounded, injection-safe request, streams tokens into published state,10//  and cancels the in-flight request on navigation / Stop (docs/11//  AI-BROWSER-RESEARCH.md §5 — dropping the stream cancels the URLSession task).12//  One AIService drives one AI surface (e.g. a tab's chat-with-page sidebar).13//1415import Foundation16import Combine1718@MainActor19final class AIService: ObservableObject {20    /// Streamed answer text for the current action.21    @Published private(set) var output: String = ""22    @Published private(set) var isStreaming: Bool = false23    @Published private(set) var errorText: String?24    /// Progress note during map-reduce ("Reading section 3/9…").25    @Published private(set) var statusNote: String?2627    private var task: Task<Void, Never>?2829    /// Cancels any in-flight request (call on navigation, Stop, tab close).30    func cancel() {31        task?.cancel()32        task = nil33        if isStreaming { isStreaming = false }34    }3536    deinit { task?.cancel() }3738    /// Runs an action over a page context, streaming the answer into `output`.39    func run(_ action: AIAction,40             on context: PageContext,41             model: AIModel,42             apiKey: String,43             extra: String? = nil) {44        cancel()45        reset()46        isStreaming = true4748        task = Task { [weak self] in49            guard let self else { return }50            do {51                if action == .summarize {52                    try await self.summarize(context: context, model: model, apiKey: apiKey)53                } else {54                    let messages = [55                        Message(role: .user, text: action.userPrompt(for: context, extra: extra))56                    ]57                    try await self.stream(system: action.systemGuidance,58                                          messages: messages, model: model, apiKey: apiKey)59                }60                await MainActor.run { self.isStreaming = false }61            } catch is CancellationError {62                // Expected on navigation/Stop — leave partial output in place.63            } catch {64                await MainActor.run {65                    self.errorText = (error as? LocalizedError)?.errorDescription66                        ?? error.localizedDescription67                    self.isStreaming = false68                }69            }70        }71    }7273    /// Streams an arbitrary prompt (used by omnibox ask, ask-about-history,74    /// multi-tab compare). The caller builds the grounded, hygiene-wrapped text.75    func runRawPrompt(system: String?, userText: String, model: AIModel, apiKey: String) {76        cancel()77        reset()78        isStreaming = true79        task = Task { [weak self] in80            guard let self else { return }81            do {82                try await self.stream(system: system,83                                      messages: [Message(role: .user, text: userText)],84                                      model: model, apiKey: apiKey)85                await MainActor.run { self.isStreaming = false }86            } catch is CancellationError {87            } catch {88                await MainActor.run {89                    self.errorText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription90                    self.isStreaming = false91                }92            }93        }94    }9596    // MARK: - Summarize (stuff-first, map-reduce for long pages)9798    private func summarize(context: PageContext, model: AIModel, apiKey: String) async throws {99        switch Summarizer.plan(for: context, model: model) {100        case .stuff:101            let messages = [Message(role: .user, text: AIAction.summarize.userPrompt(for: context))]102            try await stream(system: AIAction.summarize.systemGuidance,103                             messages: messages, model: model, apiKey: apiKey)104105        case .mapReduce:106            let chunks = Summarizer.mapChunks(for: context)107            var partials: [String] = []108            for (i, chunk) in chunks.enumerated() {109                try Task.checkCancellation()110                await MainActor.run { self.statusNote = "Reading section \(i + 1)/\(chunks.count)…" }111                let partial = try await complete(112                    system: AIAction.summarize.systemGuidance,113                    userText: Summarizer.mapPrompt(chunk: chunk, title: context.title),114                    model: model, apiKey: apiKey115                )116                partials.append(partial)117            }118            await MainActor.run { self.statusNote = nil }119            let messages = [Message(role: .user,120                                    text: Summarizer.reducePrompt(partials: partials, title: context.title))]121            try await stream(system: AIAction.summarize.systemGuidance,122                             messages: messages, model: model, apiKey: apiKey)123        }124    }125126    // MARK: - Streaming primitive127128    /// Streams a chat request into `output`. Cancellation propagates to the129    /// provider stream by dropping the for-await loop.130    private func stream(system: String?, messages: [Message], model: AIModel, apiKey: String) async throws {131        let client = ProviderRegistry.client(for: model)132        // Reasoning models stream hidden thinking before the answer — budget for it.133        var params = ChatParameters(maxTokens: model.capabilities.reasoning ? 8192 : 1024)134        if model.parameterSupport.reasoningEffort { params.reasoningEffort = "low" }135        let request = ChatRequest(model: model, systemPrompt: system, messages: messages, parameters: params)136137        for try await event in client.streamChat(request, apiKey: apiKey) {138            try Task.checkCancellation()139            switch event {140            case .textDelta(let delta):141                await MainActor.run { self.output += delta }142            case .reasoningDelta, .citations, .usage, .finished:143                break144            }145        }146    }147148    /// Non-streaming completion used by the map phase.149    private func complete(system: String?, userText: String, model: AIModel, apiKey: String) async throws -> String {150        let client = ProviderRegistry.client(for: model)151        var params = ChatParameters(maxTokens: 512)152        if model.parameterSupport.reasoningEffort { params.reasoningEffort = "low" }153        let request = ChatRequest(model: model, systemPrompt: system,154                                  messages: [Message(role: .user, text: userText)],155                                  parameters: params, stream: false)156        let reply = try await client.complete(request, apiKey: apiKey)157        return reply.text158    }159160    private func reset() {161        output = ""162        errorText = nil163        statusNote = nil164    }165}166