276d2bb6-619d-4888-ba11-8a6.../Sources/engine/ClipperStore.swift

358 lines
13 KiB
Swift
Raw Normal View History

//
// 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<Void, Never>?
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
}
}