mirror of
https://github.com/carey314/mio-plugin-clipper.git
synced 2026-08-09 23:34:32 +00:00
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>
358 lines
13 KiB
Swift
358 lines
13 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|