mirror of
https://github.com/carey314/mio-plugin-clipper.git
synced 2026-08-10 07:44:32 +00:00
221 lines
8.9 KiB
Swift
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() }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|