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) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-04-27 20:21:17 +08:00
commit 4e22618364
19 changed files with 2006 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
build/
.DS_Store
*.swiftmodule
*.dSYM
.build/

35
Info.plist Normal file
View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleExecutable</key>
<string>ClipperPlugin</string>
<key>CFBundleIdentifier</key>
<string>com.mioisland.plugin.clipper</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>划词侠</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSPrincipalClass</key>
<string>ClipperPlugin.ClipperPlugin</string>
<!--
Panel size: 380×520.
- 40pt notch reservation strip at the top (matches Music Player /
看盘侠 pattern so the host's floating back-chevron has room).
- 480pt content area below: header + source preview + 2x2 action
grid + footer toggle.
-->
<key>MioPluginPreferredWidth</key>
<integer>380</integer>
<key>MioPluginPreferredHeight</key>
<integer>520</integer>
</dict>
</plist>

21
LICENSE Normal file
View File

@ -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.

83
README.md Normal file
View File

@ -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).

View File

@ -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
}
}

18
Sources/MioPlugin.swift Normal file
View File

@ -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?
}

View File

@ -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
}
}
}

View File

@ -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:<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() }
}
}
}

View File

@ -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
}
}
}

View File

@ -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))
}
}
}
}
}

View File

@ -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<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
}
}

View File

@ -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[..<i]) + ""
}
}

145
Sources/ui/ActionGrid.swift Normal file
View File

@ -0,0 +1,145 @@
//
// ActionGrid.swift
// plugin
//
// 2x2 grid of the four primary actions plus the segmented sub-options
// for translate-target and rewrite-tone. Tappable even when input is
// empty store decides whether to short-circuit.
//
import SwiftUI
struct ActionGrid: View {
@ObservedObject var store: ClipperStore
var body: some View {
VStack(spacing: 10) {
HStack(spacing: 10) {
actionCard(
icon: "globe",
title: "翻译",
subtitle: targetSubLabel,
accent: ClipperTheme.cyan
) {
store.runAction(.translate(target: store.translateTarget))
}
actionCard(
icon: "list.bullet.rectangle",
title: "总结",
subtitle: "3 条要点",
accent: ClipperTheme.lime
) {
store.runAction(.summarize)
}
}
HStack(spacing: 10) {
actionCard(
icon: "bubble.left.and.bubble.right",
title: "改语气",
subtitle: store.rewriteTone.label,
accent: ClipperTheme.amber
) {
store.runAction(.rewrite(tone: store.rewriteTone))
}
actionCard(
icon: "envelope",
title: "写邮件回复",
subtitle: "礼貌专业",
accent: Color(red: 0xFF/255, green: 0x9E/255, blue: 0xC4/255)
) {
store.runAction(.emailReply)
}
}
// Segmented controls for the two actions that have options.
VStack(spacing: 6) {
segmented(
title: "翻译目标",
options: ClipperAction.TranslateTarget.allCases.map { ($0.label, $0) },
selection: $store.translateTarget
)
segmented(
title: "改写语气",
options: ClipperAction.ToneOption.allCases.map { ($0.label, $0) },
selection: $store.rewriteTone
)
}
}
}
private var targetSubLabel: String {
"译为 " + store.translateTarget.label
}
// MARK: subviews
private func actionCard(icon: String,
title: String,
subtitle: String,
accent: Color,
action: @escaping () -> 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<T: Hashable>(
title: String,
options: [(String, T)],
selection: Binding<T>
) -> 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)
}
}
}
}

View File

@ -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
)
)
}
}

View File

@ -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)
)
)
}
}

108
Sources/ui/ResultView.swift Normal file
View File

@ -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)
}
}

View File

@ -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)
)
)
}
}

41
Sources/ui/Theme.swift Normal file
View File

@ -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)
}

65
build.sh Executable file
View File

@ -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