// // ClipperStore.swift // 划词侠 plugin // // Central ObservableObject. Holds: // • viewState — A/B/C/D/E (state machine) // • sourceText — current input (clipboard or selection) // • sourceMode — clipboard / selection toggle // • lastResult — DeepSeek output for state D // • currentAction — label string for state D / history // • history — last 20 actions // • config — DeepSeek key + defaults (nil = state E) // // Action prompts live here as a static catalog so views just reference // ClipperAction.translateZh etc. // import Foundation import AppKit import SwiftUI // MARK: - Action catalog enum ClipperAction: Hashable { case translate(target: TranslateTarget) case summarize case rewrite(tone: ToneOption) case emailReply enum TranslateTarget: String, CaseIterable, Identifiable, Hashable { case zh, en, ja var id: String { rawValue } var label: String { switch self { case .zh: return "中文" case .en: return "英文" case .ja: return "日文" } } } enum ToneOption: String, CaseIterable, Identifiable, Hashable { case formal, casual, friendly, rigorous var id: String { rawValue } var label: String { switch self { case .formal: return "正式" case .casual: return "口语" case .friendly: return "友好" case .rigorous: return "严谨" } } } var label: String { switch self { case .translate(let t): return "翻译·\(t.label)" case .summarize: return "总结" case .rewrite(let tone): return "改语气·\(tone.label)" case .emailReply: return "邮件回复" } } var systemPrompt: String { switch self { case .translate(.zh): return "请把用户输入翻译为流畅自然的简体中文,保持原意,不要解释。直接输出译文。" case .translate(.en): return "Translate the user input to fluent natural English. Output translation only, no commentary." case .translate(.ja): return "ユーザー入力を自然な日本語に翻訳してください。訳文のみを出力してください。" case .summarize: return "请用 3 条要点中文总结用户输入,每条以 - 开头,简洁明了。" case .rewrite(.formal): return "请将用户输入改写为更正式书面的语气,保持原意和长度相近。直接输出改写结果。" case .rewrite(.casual): return "请将用户输入改写为更口语化、轻松的语气,保持原意和长度相近。直接输出改写结果。" case .rewrite(.friendly): return "请将用户输入改写为更友好、有人情味的语气,保持原意和长度相近。直接输出改写结果。" case .rewrite(.rigorous): return "请将用户输入改写为更严谨、不带情绪、用词精确的语气,保持原意和长度相近。直接输出改写结果。" case .emailReply: return "用户提供了一封邮件原文。请用与原文相同的语言起草一封礼貌、专业的回复邮件,正文不超过 5 段。直接输出回复正文,不要包含称呼/署名占位符。" } } } // MARK: - View state enum ClipperViewState: Equatable { case idle // A case input(text: String) // B case loading(action: String) // C case result(action: String, output: String, input: String) // D case missingConfig // E } @MainActor final class ClipperStore: ObservableObject { static let shared = ClipperStore() @Published var viewState: ClipperViewState = .idle @Published var sourceMode: TextSourceMode = .clipboard @Published var permissionDenied: Bool = false // for selection mode banner @Published var history: [HistoryEntry] = [] @Published var config: ClipperConfig? @Published var lastError: String? @Published var translateTarget: ClipperAction.TranslateTarget = .zh @Published var rewriteTone: ClipperAction.ToneOption = .formal private var currentTask: Task? private init() {} // MARK: lifecycle func activate() { ClipperDebugLog.write("store activate") history = HistoryStore.load() reloadConfig() if let dt = config?.defaultTranslateTarget, let parsed = ClipperAction.TranslateTarget(rawValue: dt) { translateTarget = parsed } // If we already have config, try to seed input from clipboard. if config != nil { seedFromClipboardIfPresent() } } func deactivate() { ClipperDebugLog.write("store deactivate") currentTask?.cancel() currentTask = nil } func reloadConfig() { let loaded = ClipperConfig.load() self.config = loaded if loaded == nil { // Drop a stub the user can edit, only if nothing exists yet. ClipperConfig.ensureStubExists() viewState = .missingConfig } else if case .missingConfig = viewState { // Just got a key, recover into idle. viewState = .idle seedFromClipboardIfPresent() } } // MARK: source /// Pull whatever is on the clipboard. If non-empty, transition to /// state B so the user immediately sees actionable text. func seedFromClipboardIfPresent() { switch TextSource.readClipboard() { case .ok(let s): viewState = .input(text: s) default: // leave as-is (idle) break } } func refreshSource() { if config == nil { viewState = .missingConfig; return } switch TextSource.read(mode: sourceMode) { case .ok(let s): permissionDenied = false viewState = .input(text: s) case .empty: permissionDenied = false viewState = .idle case .permissionDenied: // Keep clipboard text if any, but show banner. permissionDenied = true // Fall back to clipboard automatically so user can still act. if case .ok(let s) = TextSource.readClipboard() { viewState = .input(text: s) } else { viewState = .idle } case .error(let msg): lastError = msg permissionDenied = false } } func setSourceMode(_ mode: TextSourceMode) { sourceMode = mode refreshSource() } // MARK: actions /// Trigger one of the four big buttons. Reads input from current /// state.input, transitions to .loading, runs the request, then /// transitions to .result. func runAction(_ action: ClipperAction) { guard let cfg = config else { viewState = .missingConfig return } let inputText: String if case .input(let t) = viewState { inputText = t } else if case .result(_, _, let t) = viewState { inputText = t } else { // No input yet — try clipboard one more time. switch TextSource.readClipboard() { case .ok(let s): inputText = s default: lastError = "没有可处理的文本" return } } currentTask?.cancel() let label = action.label viewState = .loading(action: label) lastError = nil let prompt = action.systemPrompt let key = cfg.deepseekApiKey // Streaming: accumulate chunks and update viewState live. Each // delta from DeepSeek triggers a SwiftUI re-render, giving the // typewriter effect. On the first non-empty chunk we transition // from .loading → .result (output grows with subsequent ticks). currentTask = Task { [weak self] in var accumulated = "" do { let stream = DeepSeekClient.chatStream( systemPrompt: prompt, userText: inputText, apiKey: key ) for try await chunk in stream { if Task.isCancelled { return } accumulated += chunk let snapshot = accumulated await MainActor.run { guard let self else { return } self.viewState = .result(action: label, output: snapshot, input: inputText) } } // If the stream completed but yielded nothing, surface a // helpful error rather than leaving the user with an // empty result panel. if accumulated.isEmpty { await MainActor.run { guard let self else { return } self.lastError = "DeepSeek 未返回内容" self.viewState = .input(text: inputText) } } } catch let e as DeepSeekError { if case .cancelled = e { ClipperDebugLog.write("action cancelled") return } ClipperDebugLog.write("action stream failed: \(e.localizedDescription)") // If we already streamed some text, keep it on screen as a // partial result; otherwise drop back to input + error. if !accumulated.isEmpty { await MainActor.run { guard let self else { return } self.lastError = e.localizedDescription // viewState already in .result with partial output } } else { await MainActor.run { guard let self else { return } self.lastError = e.localizedDescription self.viewState = .input(text: inputText) } } } catch { ClipperDebugLog.write("action stream failed: \(error.localizedDescription)") await MainActor.run { guard let self else { return } self.lastError = error.localizedDescription if accumulated.isEmpty { self.viewState = .input(text: inputText) } } } } } func cancelCurrent() { currentTask?.cancel() currentTask = nil if case .loading(_) = viewState { // Restore to whatever input we had — fall back to idle if unknown. switch TextSource.readClipboard() { case .ok(let s): viewState = .input(text: s) default: viewState = .idle } } } /// Called from the result-screen 完成 button. Saves to history and /// returns to idle. func finishResult() { if case .result(let action, let output, let input) = viewState { let entry = HistoryEntry( actionLabel: action, inputPreview: HistoryStore.truncate(input, max: 80), outputPreview: HistoryStore.truncate(output, max: 120) ) history = HistoryStore.append(entry) } viewState = .idle } /// 重新生成 — re-runs the last action with the same input. func regenerate() { guard case .result(let label, _, let input) = viewState else { return } // Reverse-map label → action. We carry the actual action through // via the label; simplest: reuse the input as state.input and let // user click again. To support 1-tap, we re-run by remembering // the last action selection here. if let act = lastActionFromLabel(label) { viewState = .input(text: input) runAction(act) } } /// Copy a string to the system pasteboard. func copyToPasteboard(_ s: String) { let pb = NSPasteboard.general pb.clearContents() pb.setString(s, forType: .string) ClipperDebugLog.write("copied \(s.count) chars to pasteboard") } /// Helper: rebuild a ClipperAction from its display label so 重新生成 /// can re-run. Mirror of ClipperAction.label. private func lastActionFromLabel(_ label: String) -> ClipperAction? { if label.hasPrefix("翻译·") { let rest = String(label.dropFirst("翻译·".count)) for t in ClipperAction.TranslateTarget.allCases where t.label == rest { return .translate(target: t) } } if label == "总结" { return .summarize } if label.hasPrefix("改语气·") { let rest = String(label.dropFirst("改语气·".count)) for tone in ClipperAction.ToneOption.allCases where tone.label == rest { return .rewrite(tone: tone) } } if label == "邮件回复" { return .emailReply } return nil } }