276d2bb6-619d-4888-ba11-8a6.../Sources/data/DeepSeekClient.swift
徐翔宇 4e22618364 v0.1.0: initial 划词侠 — AI Text Actions plugin for MioIsland
Quick AI actions on clipboard or selected text. Four actions:
- 翻译 (中/英/日)
- 总结 (3-bullet Chinese)
- 改语气 (正式/口语/友好/严谨)
- 邮件回复

Powered by DeepSeek with Server-Sent Events streaming for
typewriter feedback (~600ms first byte). Two source modes:
clipboard (no permission) and selected-text via NSAccessibility
(graceful fallback to clipboard + permission banner if denied).

Five-state UI machine: idle → input → loading (cancellable) →
result → missing-config. Last 20 actions persisted to UserDefaults.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:21:17 +08:00

221 lines
8.9 KiB
Swift

//
// 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:<actionPrompt>},
// {role:"user",content:<inputText>}
// ],
// 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<String, Error> {
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() }
}
}
}