commit 4e226183641c224c58a4eee1f803526937af6618 Author: 徐翔宇 Date: Mon Apr 27 20:21:17 2026 +0800 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33f779a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build/ +.DS_Store +*.swiftmodule +*.dSYM +.build/ diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..aebfa28 --- /dev/null +++ b/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleExecutable + ClipperPlugin + CFBundleIdentifier + com.mioisland.plugin.clipper + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + 划词侠 + CFBundlePackageType + BNDL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + NSPrincipalClass + ClipperPlugin.ClipperPlugin + + MioPluginPreferredWidth + 380 + MioPluginPreferredHeight + 520 + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a44fc63 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MioMioOS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7667d5 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# 划词侠 — AI Text Actions for MioIsland + +Quick AI actions on any clipboard or selected text. Translate, +summarize, rewrite tone, or draft an email reply — all powered by +DeepSeek with streaming output for instant feedback. + +## Features + +- **One-shot actions** — 翻译(中/英/日) / 总结 / 改语气(正式/口语/友好/严谨) / 邮件回复. +- **Streaming output** — typewriter effect via Server-Sent Events. + Long replies start showing within ~600ms instead of after the + full completion lands. +- **Two text sources** — clipboard (works without permissions) or + selected text via NSAccessibility (requires the user to grant + Accessibility permission). Falls back gracefully with a banner + explaining the System Settings → Privacy step. +- **History** — last 20 actions persisted in UserDefaults; tap to + re-copy the result without re-running the model. +- **Five-state UI** — idle, input, loading (cancellable), result, + missing-config. Each transition is animated. + +## Configuration + +Drop your DeepSeek API key into +`~/.config/codeisland/clipper-config.json`: + +```json +{ + "deepseek_api_key": "sk-...", + "default_translate_target": "zh" +} +``` + +Get a key at https://platform.deepseek.com/ — DeepSeek's pricing is +roughly **¥0.001/1k tokens for input, ¥0.002/1k for output** (~1/100 +of GPT-4), so a typical 划词侠 session costs less than a fraction of +a cent. + +Without the file, the panel shows a friendly missing-config state +with the file path one click away on the clipboard. + +## Requirements + +- macOS 15.0+ +- MioIsland v2.2.0+ +- DeepSeek API key (for actual AI calls; UI works without it) +- Optional: Accessibility permission (for selected-text source) + +## Building from source + +```bash +./build.sh # produce build/clipper.bundle + build/clipper.zip +./build.sh install # build + copy to ~/.config/codeisland/plugins/ +``` + +Restart MioIsland (Cmd+Q + reopen) to load the new build. + +## Structure + +``` +Sources/ +├── MioPlugin.swift # protocol (verbatim from host) +├── ClipperPlugin.swift # principal class +├── ui/ +│ ├── ExpandedView.swift # 380×520 panel, state machine +│ ├── ActionGrid.swift # 2×2 action buttons + sub-options +│ ├── SourcePreview.swift # truncated input preview +│ ├── ResultView.swift # scrollable streaming output +│ ├── PermissionBanner.swift # AX permission hint +│ └── Theme.swift # design tokens +├── data/ +│ ├── DeepSeekClient.swift # actor + URLSession + SSE streaming +│ ├── TextSource.swift # clipboard / selection providers +│ └── ClipperConfig.swift # config loader +└── engine/ + ├── ClipperStore.swift # @MainActor state machine + history + ├── HistoryStore.swift # UserDefaults, last 20 actions + └── ClipperDebugLog.swift # /tmp/clipper-plugin.log +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/Sources/ClipperPlugin.swift b/Sources/ClipperPlugin.swift new file mode 100644 index 0000000..a82b2aa --- /dev/null +++ b/Sources/ClipperPlugin.swift @@ -0,0 +1,37 @@ +// +// ClipperPlugin.swift +// Mio Island plugin: 划词侠 +// +// Principal class. Module = ClipperPlugin, Class = ClipperPlugin → +// NSPrincipalClass = "ClipperPlugin.ClipperPlugin". +// + +import AppKit +import SwiftUI + +final class ClipperPlugin: NSObject, MioPlugin { + var id: String { "clipper" } + var name: String { "划词侠" } + var icon: String { "wand.and.stars" } + var version: String { "0.1.0" } + + func activate() { + ClipperDebugLog.write("plugin activate") + Task { @MainActor in + ClipperStore.shared.activate() + } + } + + func deactivate() { + ClipperDebugLog.write("plugin deactivate") + Task { @MainActor in + ClipperStore.shared.deactivate() + } + } + + func makeView() -> NSView { + let view = NSHostingView(rootView: ExpandedView()) + view.autoresizingMask = [.width, .height] + return view + } +} diff --git a/Sources/MioPlugin.swift b/Sources/MioPlugin.swift new file mode 100644 index 0000000..a28cba2 --- /dev/null +++ b/Sources/MioPlugin.swift @@ -0,0 +1,18 @@ +// +// MioPlugin.swift +// Mio Island Plugin SDK (verbatim copy from host). +// Runtime conformance is by ObjC selector, not module identity. +// + +import AppKit + +@objc protocol MioPlugin: AnyObject { + var id: String { get } + var name: String { get } + var icon: String { get } + var version: String { get } + func activate() + func deactivate() + func makeView() -> NSView + @objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? +} diff --git a/Sources/data/ClipperConfig.swift b/Sources/data/ClipperConfig.swift new file mode 100644 index 0000000..ebbde27 --- /dev/null +++ b/Sources/data/ClipperConfig.swift @@ -0,0 +1,75 @@ +// +// ClipperConfig.swift +// 划词侠 plugin +// +// Loads ~/.config/codeisland/clipper-config.json on demand. +// Schema: +// { "deepseek_api_key": "sk-...", +// "default_translate_target": "zh" } +// +// Never bundles a key. If the file is missing/empty we surface a +// state-E "请先填" screen with a helper that copies the path to the +// clipboard so the user can `open -e` it. +// + +import Foundation + +struct ClipperConfig { + var deepseekApiKey: String + var defaultTranslateTarget: String // "zh" | "en" | "ja" + + static let configPath: String = { + let home = NSHomeDirectory() + return "\(home)/.config/codeisland/clipper-config.json" + }() + + static var configDir: String { + let home = NSHomeDirectory() + return "\(home)/.config/codeisland" + } + + /// Best-effort load. If anything is missing we return nil so the UI + /// can render the onboarding banner — never throws to the caller. + static func load() -> ClipperConfig? { + let url = URL(fileURLWithPath: configPath) + guard let data = try? Data(contentsOf: url) else { + ClipperDebugLog.write("config: file missing at \(configPath)") + return nil + } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + ClipperDebugLog.write("config: malformed JSON") + return nil + } + let key = (obj["deepseek_api_key"] as? String) ?? "" + let target = (obj["default_translate_target"] as? String) ?? "zh" + if key.trimmingCharacters(in: .whitespaces).isEmpty + || key.trimmingCharacters(in: .whitespaces) == "TODO" { + ClipperDebugLog.write("config: deepseek_api_key empty/TODO") + return nil + } + return ClipperConfig(deepseekApiKey: key, defaultTranslateTarget: target) + } + + /// Write a stub file the user can edit. Idempotent — won't clobber + /// an existing real config. + @discardableResult + static func ensureStubExists() -> Bool { + let fm = FileManager.default + if fm.fileExists(atPath: configPath) { return false } + try? fm.createDirectory(atPath: configDir, withIntermediateDirectories: true) + let stub = """ + { + "deepseek_api_key": "TODO", + "default_translate_target": "zh" + } + """ + do { + try stub.write(toFile: configPath, atomically: true, encoding: .utf8) + ClipperDebugLog.write("config: wrote stub at \(configPath)") + return true + } catch { + ClipperDebugLog.write("config: stub write failed: \(error)") + return false + } + } +} diff --git a/Sources/data/DeepSeekClient.swift b/Sources/data/DeepSeekClient.swift new file mode 100644 index 0000000..5e88854 --- /dev/null +++ b/Sources/data/DeepSeekClient.swift @@ -0,0 +1,220 @@ +// +// 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:}, +// {role:"user",content:} +// ], +// 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 { + 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() } + } + } +} diff --git a/Sources/data/TextSource.swift b/Sources/data/TextSource.swift new file mode 100644 index 0000000..662a99b --- /dev/null +++ b/Sources/data/TextSource.swift @@ -0,0 +1,106 @@ +// +// TextSource.swift +// 划词侠 plugin +// +// Two source modes: +// .clipboard — NSPasteboard.general.string(forType:.string). +// No permissions needed. +// .selection — Try NSWorkspace + AXUIElement to read the focused +// element's selected-text. Requires Accessibility +// permission. If denied, return .permissionDenied so +// the UI can prompt + fall back to clipboard. +// + +import AppKit +import ApplicationServices + +enum TextSourceMode: String, CaseIterable, Identifiable { + case clipboard + case selection + var id: String { rawValue } + + var label: String { + switch self { + case .clipboard: return "粘贴板" + case .selection: return "选中文本" + } + } +} + +enum TextSourceResult { + case ok(String) + case empty + case permissionDenied + case error(String) +} + +enum TextSource { + + /// Read the system pasteboard (.string type). No permissions. + static func readClipboard() -> TextSourceResult { + let pb = NSPasteboard.general + guard let s = pb.string(forType: .string) else { + return .empty + } + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return .empty } + return .ok(s) + } + + /// Best-effort read of the *focused* UI element's selected text via + /// AX. Requires Accessibility permission for Mio Island's host + /// process. Returns .permissionDenied if not granted (caller should + /// surface the banner), or .empty if granted-but-nothing-selected. + static func readSelectionViaAX() -> TextSourceResult { + // AXIsProcessTrustedWithOptions with the prompt option will pop + // the system "open System Settings" sheet on first call. We + // pass false so we don't trigger the prompt unexpectedly — the + // UI banner explicitly tells the user where to enable it. + let opts: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] + guard AXIsProcessTrustedWithOptions(opts) else { + ClipperDebugLog.write("selection: AX not trusted") + return .permissionDenied + } + + let systemWide = AXUIElementCreateSystemWide() + var focused: CFTypeRef? + let focusErr = AXUIElementCopyAttributeValue( + systemWide, + kAXFocusedUIElementAttribute as CFString, + &focused + ) + guard focusErr == .success, let element = focused else { + ClipperDebugLog.write("selection: no focused element (err=\(focusErr.rawValue))") + return .empty + } + // swiftlint:disable:next force_cast + let axElement = element as! AXUIElement + + var selValue: CFTypeRef? + let selErr = AXUIElementCopyAttributeValue( + axElement, + kAXSelectedTextAttribute as CFString, + &selValue + ) + guard selErr == .success, let s = selValue as? String else { + ClipperDebugLog.write("selection: no selected text (err=\(selErr.rawValue))") + return .empty + } + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return .empty } + return .ok(s) + } + + /// Convenience: read whichever source is active, with fallback. + static func read(mode: TextSourceMode) -> TextSourceResult { + switch mode { + case .clipboard: + return readClipboard() + case .selection: + let r = readSelectionViaAX() + // If AX gave us empty (granted but nothing selected) we keep + // the empty result — caller decides whether to fall back. + return r + } + } +} diff --git a/Sources/engine/ClipperDebugLog.swift b/Sources/engine/ClipperDebugLog.swift new file mode 100644 index 0000000..8b3e8ba --- /dev/null +++ b/Sources/engine/ClipperDebugLog.swift @@ -0,0 +1,41 @@ +// +// ClipperDebugLog.swift +// 划词侠 plugin +// +// Mirrors the FundDebugLog pattern — plugin-bundle NSLog calls don't +// reliably surface in `log show` from the host process. This helper +// appends timestamped lines to /tmp/clipper-plugin.log so we can +// `tail -f` during debugging. +// + +import Foundation + +enum ClipperDebugLog { + static let path = "/tmp/clipper-plugin.log" + private static let queue = DispatchQueue(label: "clipper.debug.log") + private static let dateFmt: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone.current + f.dateFormat = "HH:mm:ss.SSS" + return f + }() + + static func write(_ message: String) { + let stamp = dateFmt.string(from: Date()) + let line = "[\(stamp)] \(message)\n" + queue.async { + if let data = line.data(using: .utf8) { + if FileManager.default.fileExists(atPath: path) { + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) { + try? handle.seekToEnd() + try? handle.write(contentsOf: data) + try? handle.close() + } + } else { + try? data.write(to: URL(fileURLWithPath: path)) + } + } + } + } +} diff --git a/Sources/engine/ClipperStore.swift b/Sources/engine/ClipperStore.swift new file mode 100644 index 0000000..fbca9dc --- /dev/null +++ b/Sources/engine/ClipperStore.swift @@ -0,0 +1,357 @@ +// +// 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 + } +} diff --git a/Sources/engine/HistoryStore.swift b/Sources/engine/HistoryStore.swift new file mode 100644 index 0000000..59eca92 --- /dev/null +++ b/Sources/engine/HistoryStore.swift @@ -0,0 +1,60 @@ +// +// HistoryStore.swift +// 划词侠 plugin +// +// Persists last 20 actions in UserDefaults under a single JSON key. +// Cheap, durable, no external deps. Records actionLabel + input +// preview + output preview + timestamp. +// + +import Foundation + +struct HistoryEntry: Codable, Identifiable, Equatable { + var id: UUID + var actionLabel: String // e.g. "翻译·中文" / "总结" / "改语气·正式" + var inputPreview: String // truncated to 80 chars + var outputPreview: String // truncated to 120 chars + var timestamp: Date + + init(id: UUID = UUID(), + actionLabel: String, + inputPreview: String, + outputPreview: String, + timestamp: Date = Date()) { + self.id = id + self.actionLabel = actionLabel + self.inputPreview = inputPreview + self.outputPreview = outputPreview + self.timestamp = timestamp + } +} + +enum HistoryStore { + private static let key = "com.mioisland.plugin.clipper.history.v1" + private static let maxEntries = 20 + + static func load() -> [HistoryEntry] { + guard let data = UserDefaults.standard.data(forKey: key) else { return [] } + return (try? JSONDecoder().decode([HistoryEntry].self, from: data)) ?? [] + } + + static func save(_ entries: [HistoryEntry]) { + guard let data = try? JSONEncoder().encode(entries) else { return } + UserDefaults.standard.set(data, forKey: key) + } + + static func append(_ entry: HistoryEntry) -> [HistoryEntry] { + var list = load() + list.insert(entry, at: 0) + if list.count > maxEntries { list = Array(list.prefix(maxEntries)) } + save(list) + return list + } + + static func truncate(_ s: String, max: Int) -> String { + let collapsed = s.replacingOccurrences(of: "\n", with: " ") + if collapsed.count <= max { return collapsed } + let i = collapsed.index(collapsed.startIndex, offsetBy: max) + return String(collapsed[.. Void) -> some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 6) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(accent) + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(ClipperTheme.fgPrimary) + Text(subtitle) + .font(.system(size: 10.5)) + .foregroundColor(ClipperTheme.fg55) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(ClipperTheme.overlay06) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(ClipperTheme.overlay08, lineWidth: 0.5) + ) + ) + } + .buttonStyle(.plain) + } + + private func segmented( + title: String, + options: [(String, T)], + selection: Binding + ) -> some View { + HStack(spacing: 6) { + Text(title) + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(ClipperTheme.fg45) + .frame(width: 56, alignment: .leading) + HStack(spacing: 4) { + ForEach(options, id: \.1) { (label, value) in + let isSel = selection.wrappedValue == value + Button { + selection.wrappedValue = value + } label: { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(isSel + ? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255) + : ClipperTheme.fg70) + .padding(.horizontal, 10) + .frame(height: 22) + .background( + Capsule().fill(isSel ? ClipperTheme.lime : ClipperTheme.overlay04) + ) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + } + } +} diff --git a/Sources/ui/ExpandedView.swift b/Sources/ui/ExpandedView.swift new file mode 100644 index 0000000..8c4f47c --- /dev/null +++ b/Sources/ui/ExpandedView.swift @@ -0,0 +1,375 @@ +// +// ExpandedView.swift +// 划词侠 plugin +// +// Top-level panel container — 380×520 to match design spec. Renders +// the title bar + state-driven body + footer source-toggle. +// +// States (per spec): +// A — IDLE last action result + history +// B — INPUT picked source preview + 2x2 action grid + segmented opts +// C — LOADING spinner + cancel +// D — RESULT scrollable text + 复制 / 重新生成 / 完成 +// E — MISSING CFG onboarding card pointing to clipper-config.json +// + +import SwiftUI +import AppKit + +struct ExpandedView: View { + @ObservedObject var store: ClipperStore = .shared + + var body: some View { + VStack(spacing: 0) { + // Notch reservation strip — same 40pt pattern as 看盘侠. + // Host's floating back-chevron lives in this band. + Color.clear.frame(height: 40) + topBar + body_ + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + footer + } + .frame(width: 380, height: 520) + .background( + ZStack { + ClipperTheme.panelBg + RadialGradient( + colors: [Color.white.opacity(0.04), Color.clear], + center: .top, + startRadius: 4, endRadius: 220 + ) + } + ) + .clipShape( + UnevenRoundedRectangle( + cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0) + ) + ) + .onAppear { + store.activate() + } + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: 10) { + HStack(spacing: 8) { + Circle() + .fill(ClipperTheme.lime) + .frame(width: 7, height: 7) + .shadow(color: ClipperTheme.lime.opacity(0.6), radius: 4) + Text("划词侠") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(ClipperTheme.fgPrimary) + } + Spacer() + + Button { + store.refreshSource() + } label: { + Image(systemName: "arrow.down.doc") + .font(.system(size: 12)) + .foregroundColor(ClipperTheme.fg55) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .help("从当前源重新读取文本") + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 8) + } + + // MARK: - Body switch + + @ViewBuilder + private var body_: some View { + switch store.viewState { + case .missingConfig: + ScrollView { + VStack(alignment: .leading, spacing: 12) { + MissingConfigCard(store: store) + } + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + case .idle: + idleBody + case .input(let text): + inputBody(text: text) + case .loading(let label): + loadingBody(action: label) + case .result(let label, let output, _): + ScrollView { + VStack(spacing: 10) { + if store.permissionDenied { + AccessibilityBanner(onOpenSettings: openAccessibilityPrefs) + } + ResultView(store: store, actionLabel: label, output: output) + .frame(minHeight: 320) + } + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 8) + } + } + } + + // MARK: - State A: idle + + private var idleBody: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + if store.permissionDenied { + AccessibilityBanner(onOpenSettings: openAccessibilityPrefs) + } + + // Hint card + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(ClipperTheme.lime) + Text("复制文本以激活") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(ClipperTheme.fgPrimary) + } + Text("先把任意文本复制到粘贴板(⌘C),再点上方的刷新按钮,下面就会出现 4 个动作。") + .font(.system(size: 11.5)) + .foregroundColor(ClipperTheme.fg55) + .fixedSize(horizontal: false, vertical: true) + Button { + store.refreshSource() + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.down.doc") + .font(.system(size: 11, weight: .semibold)) + Text("从粘贴板读取") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255)) + .padding(.horizontal, 14) + .frame(height: 30) + .background(Capsule().fill(ClipperTheme.lime)) + } + .buttonStyle(.plain) + .padding(.top, 4) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(ClipperTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(ClipperTheme.overlay08, lineWidth: 0.5) + ) + ) + + if let err = store.lastError { + errorChip(text: err) + } + + if !store.history.isEmpty { + historySection + } + } + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 12) + } + } + + private var historySection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("最近 3 条") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundColor(ClipperTheme.fg45) + .padding(.leading, 4) + ForEach(store.history.prefix(3)) { entry in + historyRow(entry) + } + } + } + + private func historyRow(_ entry: HistoryEntry) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(entry.actionLabel) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(ClipperTheme.lime) + Spacer() + Text(relative(entry.timestamp)) + .font(.system(size: 10)) + .foregroundColor(ClipperTheme.fg40) + .monospacedDigit() + } + Text(entry.outputPreview) + .font(.system(size: 11.5)) + .foregroundColor(ClipperTheme.fg70) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(ClipperTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(ClipperTheme.overlay08, lineWidth: 0.5) + ) + ) + } + + // MARK: - State B: input picked + + private func inputBody(text: String) -> some View { + ScrollView { + VStack(spacing: 12) { + if store.permissionDenied { + AccessibilityBanner(onOpenSettings: openAccessibilityPrefs) + } + SourcePreview(text: text) + if let err = store.lastError { + errorChip(text: err) + } + ActionGrid(store: store) + } + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 12) + } + } + + // MARK: - State C: loading + + private func loadingBody(action: String) -> some View { + VStack(spacing: 14) { + Spacer() + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1.2) + .tint(ClipperTheme.lime) + Text("处理中…") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(ClipperTheme.fgPrimary) + Text(action) + .font(.system(size: 11)) + .foregroundColor(ClipperTheme.fg55) + Button { + store.cancelCurrent() + } label: { + Text("取消") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(ClipperTheme.danger) + .padding(.horizontal, 16) + .frame(height: 28) + .background( + Capsule() + .stroke(ClipperTheme.danger.opacity(0.5), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .padding(.top, 4) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - shared bits + + private func errorChip(text: String) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 11)) + .foregroundColor(ClipperTheme.danger) + .padding(.top, 1) + Text(text) + .font(.system(size: 11)) + .foregroundColor(ClipperTheme.fg85) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(ClipperTheme.danger.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(ClipperTheme.danger.opacity(0.25), lineWidth: 0.5) + ) + ) + } + + private func relative(_ d: Date) -> String { + let s = Int(Date().timeIntervalSince(d)) + if s < 60 { return "\(s)s 前" } + if s < 3600 { return "\(s / 60)分前" } + if s < 86400 { return "\(s / 3600)时前" } + return "\(s / 86400)天前" + } + + private func openAccessibilityPrefs() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } + + // MARK: - Footer (source toggle) + + private var footer: some View { + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 10)) + .foregroundColor(ClipperTheme.fg45) + Text("源") + .font(.system(size: 10.5, weight: .medium)) + .foregroundColor(ClipperTheme.fg45) + + ForEach(TextSourceMode.allCases) { mode in + let isSel = store.sourceMode == mode + Button { + store.setSourceMode(mode) + } label: { + Text(mode.label) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundColor(isSel + ? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255) + : ClipperTheme.fg70) + .padding(.horizontal, 10) + .frame(height: 22) + .background( + Capsule().fill(isSel ? ClipperTheme.lime : ClipperTheme.overlay04) + ) + } + .buttonStyle(.plain) + } + Spacer() + if let cfg = store.config { + Text(cfg.deepseekApiKey.isEmpty ? "未配置" : "已配置 ✓") + .font(.system(size: 10)) + .foregroundColor(cfg.deepseekApiKey.isEmpty ? ClipperTheme.amber : ClipperTheme.fg45) + } else { + Text("未配置") + .font(.system(size: 10)) + .foregroundColor(ClipperTheme.amber) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Rectangle() + .fill(Color.clear) + .overlay( + Rectangle() + .fill(ClipperTheme.overlay04) + .frame(height: 0.5), + alignment: .top + ) + ) + } +} diff --git a/Sources/ui/PermissionBanner.swift b/Sources/ui/PermissionBanner.swift new file mode 100644 index 0000000..817c1d3 --- /dev/null +++ b/Sources/ui/PermissionBanner.swift @@ -0,0 +1,165 @@ +// +// PermissionBanner.swift +// 划词侠 plugin +// +// Two banners share this file: +// • Accessibility-permission warning (when sourceMode = .selection) +// • Missing-config (state E) full-card with copy-path helper +// + +import SwiftUI + +struct AccessibilityBanner: View { + var onOpenSettings: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(ClipperTheme.amber) + .font(.system(size: 12)) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 4) { + Text("选中文本需要辅助功能权限") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundColor(ClipperTheme.fg85) + Text("前往 系统设置 > 隐私与安全性 > 辅助功能,勾选 Mio Island 后回来重试。当前已自动回退到粘贴板。") + .font(.system(size: 10.5)) + .foregroundColor(ClipperTheme.fg55) + .fixedSize(horizontal: false, vertical: true) + Button(action: onOpenSettings) { + Text("打开 系统设置") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(ClipperTheme.amber) + } + .buttonStyle(.plain) + .padding(.top, 2) + } + Spacer(minLength: 0) + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(ClipperTheme.amber.opacity(0.08)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(ClipperTheme.amber.opacity(0.25), lineWidth: 0.5) + ) + ) + } +} + +struct MissingConfigCard: View { + @ObservedObject var store: ClipperStore + @State private var copied = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "key.horizontal") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(ClipperTheme.amber) + Text("先填一个 DeepSeek API Key") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(ClipperTheme.fgPrimary) + } + + VStack(alignment: .leading, spacing: 6) { + Text("打开下面的配置文件,把 deepseek_api_key 改成你的真实 key,然后点 重新加载。") + .font(.system(size: 11.5)) + .foregroundColor(ClipperTheme.fg70) + .fixedSize(horizontal: false, vertical: true) + + Text(ClipperConfig.configPath) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundColor(ClipperTheme.fg55) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(ClipperTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(ClipperTheme.overlay08, lineWidth: 0.5) + ) + ) + .textSelection(.enabled) + } + + HStack(spacing: 8) { + Button { + store.copyToPasteboard(ClipperConfig.configPath) + copied = true + Task { + try? await Task.sleep(nanoseconds: 1_200_000_000) + await MainActor.run { copied = false } + } + } label: { + HStack(spacing: 6) { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 11, weight: .semibold)) + Text(copied ? "路径已复制" : "复制路径") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(copied ? ClipperTheme.lime : ClipperTheme.fg70) + .frame(maxWidth: .infinity) + .frame(height: 30) + .background( + Capsule().fill(ClipperTheme.overlay06) + .overlay(Capsule().stroke(ClipperTheme.overlay08, lineWidth: 0.5)) + ) + } + .buttonStyle(.plain) + + Button { + NSWorkspace.shared.open(URL(fileURLWithPath: ClipperConfig.configDir)) + } label: { + HStack(spacing: 6) { + Image(systemName: "folder") + .font(.system(size: 11, weight: .semibold)) + Text("打开文件夹") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(ClipperTheme.fg70) + .frame(maxWidth: .infinity) + .frame(height: 30) + .background( + Capsule().fill(ClipperTheme.overlay06) + .overlay(Capsule().stroke(ClipperTheme.overlay08, lineWidth: 0.5)) + ) + } + .buttonStyle(.plain) + + Button { + store.reloadConfig() + } label: { + HStack(spacing: 6) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .semibold)) + Text("重新加载") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255)) + .frame(maxWidth: .infinity) + .frame(height: 30) + .background(Capsule().fill(ClipperTheme.lime)) + } + .buttonStyle(.plain) + } + + Text("没有 key?去 platform.deepseek.com 注册一个,新用户有免费额度。") + .font(.system(size: 10.5)) + .foregroundColor(ClipperTheme.fg45) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(ClipperTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(ClipperTheme.overlay08, lineWidth: 0.5) + ) + ) + } +} diff --git a/Sources/ui/ResultView.swift b/Sources/ui/ResultView.swift new file mode 100644 index 0000000..320c752 --- /dev/null +++ b/Sources/ui/ResultView.swift @@ -0,0 +1,108 @@ +// +// ResultView.swift +// 划词侠 plugin +// +// Scrollable result text + 复制 / 重新生成 / 完成 buttons. +// + +import SwiftUI + +struct ResultView: View { + @ObservedObject var store: ClipperStore + let actionLabel: String + let output: String + + @State private var copyConfirmed = false + + var body: some View { + VStack(spacing: 10) { + // Action header + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(ClipperTheme.lime) + Text(actionLabel) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(ClipperTheme.fgPrimary) + Spacer() + Text("\(output.count) 字") + .font(.system(size: 10.5)) + .foregroundColor(ClipperTheme.fg40) + .monospacedDigit() + } + + // Result body — scrollable. + ScrollView { + Text(output) + .font(.system(size: 13)) + .foregroundColor(ClipperTheme.fg85) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(ClipperTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(ClipperTheme.overlay08, lineWidth: 0.5) + ) + ) + + // Action buttons + HStack(spacing: 8) { + resultButton(label: copyConfirmed ? "已复制" : "复制", + icon: copyConfirmed ? "checkmark" : "doc.on.doc", + tint: copyConfirmed ? ClipperTheme.lime : ClipperTheme.fg70) { + store.copyToPasteboard(output) + copyConfirmed = true + Task { + try? await Task.sleep(nanoseconds: 1_200_000_000) + await MainActor.run { copyConfirmed = false } + } + } + resultButton(label: "重新生成", + icon: "arrow.clockwise", + tint: ClipperTheme.fg70) { + store.regenerate() + } + resultButton(label: "完成", + icon: "checkmark.circle.fill", + tint: Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255), + filled: true) { + store.finishResult() + } + } + } + } + + private func resultButton(label: String, + icon: String, + tint: Color, + filled: Bool = false, + action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + Text(label) + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(tint) + .frame(maxWidth: .infinity) + .frame(height: 32) + .background( + Capsule() + .fill(filled ? ClipperTheme.lime : ClipperTheme.overlay06) + .overlay( + Capsule() + .stroke(filled ? Color.clear : ClipperTheme.overlay08, lineWidth: 0.5) + ) + ) + } + .buttonStyle(.plain) + } +} diff --git a/Sources/ui/SourcePreview.swift b/Sources/ui/SourcePreview.swift new file mode 100644 index 0000000..867ec84 --- /dev/null +++ b/Sources/ui/SourcePreview.swift @@ -0,0 +1,49 @@ +// +// SourcePreview.swift +// 划词侠 plugin +// +// Compact card showing the current source text — 3 lines, soft border, +// monospace fallback for long URLs/code. +// + +import SwiftUI + +struct SourcePreview: View { + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "text.alignleft") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(ClipperTheme.fg45) + Text("待处理文本") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundColor(ClipperTheme.fg45) + Spacer() + Text("\(text.count) 字") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(ClipperTheme.fg40) + .monospacedDigit() + } + Text(text) + .font(.system(size: 12)) + .foregroundColor(ClipperTheme.fg85) + .lineLimit(3) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(ClipperTheme.overlay04) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(ClipperTheme.overlay08, lineWidth: 0.5) + ) + ) + } +} diff --git a/Sources/ui/Theme.swift b/Sources/ui/Theme.swift new file mode 100644 index 0000000..0beb448 --- /dev/null +++ b/Sources/ui/Theme.swift @@ -0,0 +1,41 @@ +// +// Theme.swift +// 划词侠 plugin +// +// Same visual language as 看盘侠 — pure black panel + lime accent. +// + +import SwiftUI + +enum ClipperTheme { + // Lime accent — same value as 看盘侠 `#d4ff3a`. + static let lime = Color(red: 0xD4/255, green: 0xFF/255, blue: 0x3A/255) + + // Soft cyan for "info" affordances (translate-target chips, etc.) + static let cyan = Color(red: 0x6E/255, green: 0xE7/255, blue: 0xFF/255) + + // Warning amber for permission banners. + static let amber = Color(red: 0xFF/255, green: 0xC4/255, blue: 0x54/255) + + // Soft red for error / cancel states. + static let danger = Color(red: 0xFF/255, green: 0x6E/255, blue: 0x6E/255) + + // Panel background. + static let panelBg = Color(red: 0x05/255, green: 0x05/255, blue: 0x05/255) + + // Subtle white overlays. + static let overlay04 = Color.white.opacity(0.04) + static let overlay06 = Color.white.opacity(0.06) + static let overlay08 = Color.white.opacity(0.08) + static let overlay12 = Color.white.opacity(0.12) + static let overlay18 = Color.white.opacity(0.18) + + // Foreground tints. + static let fgPrimary = Color(red: 0xF4/255, green: 0xF4/255, blue: 0xF5/255) + static let fg85 = Color.white.opacity(0.85) + static let fg70 = Color.white.opacity(0.7) + static let fg55 = Color.white.opacity(0.55) + static let fg45 = Color.white.opacity(0.45) + static let fg40 = Color.white.opacity(0.4) + static let fg35 = Color.white.opacity(0.35) +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..55276f0 --- /dev/null +++ b/build.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Build the 划词侠 plugin as a .bundle for Mio Island. +# +# Usage: +# ./build.sh # produce build/clipper.bundle + build/clipper.zip +# ./build.sh install # also copy bundle to ~/.config/codeisland/plugins/ + +set -e +set -o pipefail + +PLUGIN_NAME="clipper" +MODULE_NAME="ClipperPlugin" +BUNDLE_NAME="${PLUGIN_NAME}.bundle" +BUILD_DIR="build" + +SOURCES=$(find Sources -name "*.swift" -type f) +SOURCE_COUNT=$(echo "$SOURCES" | wc -l | tr -d ' ') + +echo "Building ${PLUGIN_NAME} plugin (${SOURCE_COUNT} swift files)..." + +rm -rf "${BUILD_DIR}" +mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS" + +# arm64-only is fine for v0.1 — Mio Island host requires macOS 15+ +# which means Apple Silicon dominant. +swiftc \ + -emit-library \ + -module-name "${MODULE_NAME}" \ + -target arm64-apple-macos15.0 \ + -sdk "$(xcrun --show-sdk-path)" \ + -O \ + -o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/${MODULE_NAME}" \ + ${SOURCES} + +cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/" + +if [ -d "Resources" ] && [ "$(ls -A Resources 2>/dev/null)" ]; then + mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources" + cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/" +fi + +# Ad-hoc sign the whole bundle. +codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}" + +echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}" + +# zip for marketplace upload (顶层是 .bundle 目录) +cd "${BUILD_DIR}" +rm -f "${PLUGIN_NAME}.zip" +zip -rq "${PLUGIN_NAME}.zip" "${BUNDLE_NAME}" +cd .. +echo "✓ Created ${BUILD_DIR}/${PLUGIN_NAME}.zip" + +if [ "${1:-}" = "install" ]; then + PLUGIN_DIR="${HOME}/.config/codeisland/plugins" + mkdir -p "${PLUGIN_DIR}" + rm -rf "${PLUGIN_DIR}/${BUNDLE_NAME}" + cp -R "${BUILD_DIR}/${BUNDLE_NAME}" "${PLUGIN_DIR}/" + echo "✓ Installed to ${PLUGIN_DIR}/${BUNDLE_NAME}" + echo " Restart Mio Island (Cmd+Q + reopen) to load the new build." +else + echo "" + echo "Install locally:" + echo " ./build.sh install" +fi