// // DeepSeekClient.swift // 划词侠 plugin // // Tiny actor wrapping URLSession for the DeepSeek chat/completions // endpoint. v0.1 uses non-streaming for simplicity; the call site is // cancellable via Task cancellation. // // POST https://api.deepseek.com/chat/completions // Body: { // model: "deepseek-chat", // messages: [ // {role:"system",content:}, // {role:"user",content:} // ], // stream: false, // temperature: 0.7 // } // import Foundation enum DeepSeekError: Error, LocalizedError { case missingKey case badStatus(Int, String) case malformed(String) case network(Error) case cancelled var errorDescription: String? { switch self { case .missingKey: return "未配置 DeepSeek API Key" case .badStatus(let c, let m): return "HTTP \(c) — \(m)" case .malformed(let m): return "返回格式异常: \(m)" case .network(let e): return "网络错误: \(e.localizedDescription)" case .cancelled: return "已取消" } } } actor DeepSeekClient { static let shared = DeepSeekClient() private let session: URLSession init() { let cfg = URLSessionConfiguration.default // DeepSeek can take 5-15s for a 200-token completion. Budget // generously so the user doesn't see a timeout on long inputs. cfg.timeoutIntervalForRequest = 60 cfg.timeoutIntervalForResource = 90 cfg.requestCachePolicy = .reloadIgnoringLocalCacheData self.session = URLSession(configuration: cfg) } /// Run a chat-completion request. Returns the assistant's text. /// Throws DeepSeekError; respects Task.isCancelled at boundaries. func chat(systemPrompt: String, userText: String, apiKey: String) async throws -> String { guard !apiKey.isEmpty else { throw DeepSeekError.missingKey } let url = URL(string: "https://api.deepseek.com/chat/completions")! var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") let body: [String: Any] = [ "model": "deepseek-chat", "stream": false, "temperature": 0.7, "messages": [ ["role": "system", "content": systemPrompt], ["role": "user", "content": userText], ], ] do { req.httpBody = try JSONSerialization.data(withJSONObject: body) } catch { throw DeepSeekError.malformed("encode body: \(error.localizedDescription)") } if Task.isCancelled { throw DeepSeekError.cancelled } let data: Data let resp: URLResponse do { (data, resp) = try await session.data(for: req) } catch is CancellationError { throw DeepSeekError.cancelled } catch { throw DeepSeekError.network(error) } if Task.isCancelled { throw DeepSeekError.cancelled } if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { let snippet = String(data: data, encoding: .utf8)?.prefix(240) ?? "" ClipperDebugLog.write("deepseek HTTP \(http.statusCode): \(snippet)") throw DeepSeekError.badStatus(http.statusCode, String(snippet)) } struct Envelope: Decodable { struct Choice: Decodable { struct Message: Decodable { let content: String } let message: Message } let choices: [Choice] } do { let env = try JSONDecoder().decode(Envelope.self, from: data) guard let first = env.choices.first?.message.content else { throw DeepSeekError.malformed("no choices") } return first.trimmingCharacters(in: .whitespacesAndNewlines) } catch let e as DeepSeekError { throw e } catch { throw DeepSeekError.malformed("decode: \(error.localizedDescription)") } } } // MARK: - Streaming extension DeepSeekClient { /// Server-sent-events streaming variant. Returns an /// AsyncThrowingStream of partial text chunks (one per delta). The /// caller accumulates chunks as they arrive — gives a typewriter /// effect in the UI for long replies (邮件回复, 总结). /// /// Static + own URLSession so we don't fight actor isolation when /// passing the stream across MainActor boundaries. Per-call session /// cost is negligible for one-shot AI requests. static func chatStream( systemPrompt: String, userText: String, apiKey: String ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let task = Task.detached { do { guard !apiKey.isEmpty else { throw DeepSeekError.missingKey } let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 60 cfg.timeoutIntervalForResource = 120 cfg.requestCachePolicy = .reloadIgnoringLocalCacheData let session = URLSession(configuration: cfg) let url = URL(string: "https://api.deepseek.com/chat/completions")! var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("text/event-stream", forHTTPHeaderField: "Accept") req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") let body: [String: Any] = [ "model": "deepseek-chat", "stream": true, "temperature": 0.7, "messages": [ ["role": "system", "content": systemPrompt], ["role": "user", "content": userText], ], ] req.httpBody = try JSONSerialization.data(withJSONObject: body) let (bytes, resp) = try await session.bytes(for: req) if let http = resp as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { // Drain a small prefix of the error body for the message. var buf = Data() for try await b in bytes { buf.append(b) if buf.count > 2048 { break } } let snippet = String(data: buf, encoding: .utf8)?.prefix(240) ?? "" ClipperDebugLog.write("deepseek stream HTTP \(http.statusCode): \(snippet)") throw DeepSeekError.badStatus(http.statusCode, String(snippet)) } struct Delta: Decodable { struct Choice: Decodable { struct DeltaContent: Decodable { let content: String? } let delta: DeltaContent } let choices: [Choice] } for try await line in bytes.lines { if Task.isCancelled { throw DeepSeekError.cancelled } // SSE format: "data: {...}\n\n", terminated by "data: [DONE]" let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("data: ") else { continue } let payload = String(trimmed.dropFirst(6)) if payload == "[DONE]" { break } guard let payloadData = payload.data(using: .utf8) else { continue } // A malformed chunk shouldn't kill the whole stream; // skip and keep going so the user gets whatever // text was already produced. if let chunk = try? JSONDecoder().decode(Delta.self, from: payloadData), let content = chunk.choices.first?.delta.content, !content.isEmpty { continuation.yield(content) } } continuation.finish() } catch is CancellationError { continuation.finish(throwing: DeepSeekError.cancelled) } catch let e as DeepSeekError { continuation.finish(throwing: e) } catch { continuation.finish(throwing: DeepSeekError.network(error)) } } continuation.onTermination = { _ in task.cancel() } } } }