feat: ttree 初始提交
轻量 macOS AI 翻译器(Tauri 2 + Svelte 5 + Rust): - 三引擎翻译:谷歌 / 必应(免费网页端接口)+ OpenAI 兼容(SSE 流式) - 截图 OCR(Apple Vision)、划词翻译/划词 AI(Accessibility) - AI 多轮对话、双栏对照、固定窗口、自定义全局快捷键 - 系统托盘、开机自启、毛玻璃界面 工程加固: - 修复流式译文中文跨 chunk 截断(字节缓冲 + memchr 按行解码) - 全局复用 reqwest::Client 连接池,OpenAI/Google/Bing 加超时 - 截图临时文件加原子序号防并发覆盖 - 托盘图标 unwrap 改为优雅错误 - 收紧 Tauri CSP(default-src 'self' + 受控白名单) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
No files matched your search
@@ -0,0 +1,11 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/dist
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"svelte.enable-ts-plugin": true
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
ttree is a lightweight macOS AI translator built with **Tauri 2 + Svelte 5 + Rust**. It is designed as a hidden spotlight-style app: the main window starts hidden, global shortcuts show it instantly, and the window auto-hides on blur.
|
||||
|
||||
Core features:
|
||||
- `CmdOrCtrl+Shift+Space` by default toggles the translation window.
|
||||
- `CmdOrCtrl+Shift+S` by default triggers screenshot OCR translation.
|
||||
- Translation engines: Google free web endpoint and OpenAI-compatible streaming chat completions.
|
||||
- macOS OCR uses Apple Vision via `objc2-vision` (`VNRecognizeTextRequest`).
|
||||
- Settings persist locally through `tauri-plugin-store`; shortcuts are user-configurable and re-registered immediately after saving.
|
||||
|
||||
## Common Commands
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
If npm blocks native install scripts in this environment, approve the packages already listed under `allowScripts` in `package.json`:
|
||||
|
||||
```bash
|
||||
npm approve-scripts esbuild fsevents
|
||||
```
|
||||
|
||||
Run the app in development mode:
|
||||
|
||||
```bash
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
The Tauri window is configured with `visible: false`, so the app may appear to launch with no window. Use the configured global shortcut (default `CmdOrCtrl+Shift+Space`) to show it.
|
||||
|
||||
Frontend-only development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend checks and build:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
```
|
||||
|
||||
Frontend tests:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Rust backend checks/tests:
|
||||
|
||||
```bash
|
||||
cd src-tauri && cargo check
|
||||
cd src-tauri && cargo test
|
||||
cd src-tauri && cargo test <test_name>
|
||||
cd src-tauri && cargo clippy
|
||||
cd src-tauri && cargo fmt
|
||||
```
|
||||
|
||||
Production app build:
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
Build outputs are under `src-tauri/target/release/bundle/`, including `bundle/macos/ttree.app` and `bundle/dmg/*.dmg`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Tauri / Rust backend
|
||||
|
||||
`src-tauri/src/lib.rs` is the backend composition point. It registers plugins, commands, global shortcuts, tray setup, macOS vibrancy, and the blur-to-hide window behavior.
|
||||
|
||||
Important backend modules:
|
||||
|
||||
- `commands.rs` exposes Tauri commands used by the Svelte frontend:
|
||||
- translation (Google / OpenAI streaming)
|
||||
- AI chat (`chat`, `get/set_chat_ai_config`) — separate config from translation
|
||||
- screenshot OCR
|
||||
- show main window
|
||||
- get/set OpenAI translation config
|
||||
- get/set shortcut config, `set_shortcut_recording` (suppresses global shortcuts during key capture)
|
||||
- `get_pinned` / `set_pinned` — window always-on-top + blur-no-hide toggle
|
||||
- `shortcut.rs` builds the global shortcut plugin and dispatches five registered shortcuts: `toggle` (show/hide window, emits `trigger-translate` on show to reset the frontend to the translate view), `ocr` (screenshot OCR), `ai_dialog` (open AI chat), `selection_translate` (capture selected text and translate), and `selection_ai_dialog` (capture selected text and send to AI). All are optional. Two pairs may intentionally share the same key — `toggle`↔`selection_translate` and `ai_dialog`↔`selection_ai_dialog`; on collision the selection action wins (dispatch order in `ParsedShortcuts::entries` lists selection actions first), so a shared key runs the selection behavior when text is selected and falls back to the plain toggle/dialog otherwise. All other key collisions are rejected at validation. Startup uses persisted shortcut config; saving validates, unregisters existing shortcuts, and registers the deduplicated set immediately.
|
||||
- `config.rs` owns persisted config models (`OpenAiConfig`, `ChatAiConfig`, `ShortcutConfig`) and store reads/writes through `tauri-plugin-store`. Translation and chat use separate AI config entries; each carries its own optional prompt (`translate_prompt`, `chat_prompt`). `ShortcutConfig` holds five independently-optional shortcuts: `toggle`, `ocr`, `ai_dialog`, `selection_translate`, `selection_ai_dialog` (empty string disables one).
|
||||
- `window.rs` centralizes main-window show/hide/focus behavior. The app keeps the window hidden instead of destroying it for instant recall.
|
||||
- `translate/google.rs` calls the free `translate.googleapis.com` endpoint and parses its nested array response.
|
||||
- `translate/openai.rs` streams OpenAI-compatible SSE responses and emits `translate-chunk` (translation) or `chat-chunk` (AI chat) events to the frontend.
|
||||
- `selection/macos.rs` captures the currently selected text via the macOS Accessibility API; falls back to a temporary `Cmd+C` + clipboard read if Accessibility fails. Non-macOS returns `None`.
|
||||
- `screenshot.rs` shells out to `/usr/sbin/screencapture -i -x -r` for interactive region capture.
|
||||
- `ocr/vision.rs` performs macOS-native OCR with Apple Vision (`VNRecognizeTextRequest`).
|
||||
- `tray.rs` creates the menu bar tray menu (`翻译`, `设置`, `退出`).
|
||||
|
||||
### Svelte frontend
|
||||
|
||||
`src/App.svelte` switches between the translation panel and settings view. It also listens for tray `open-settings` events.
|
||||
|
||||
Important frontend areas:
|
||||
|
||||
- `src/lib/components/TranslatePanel.svelte` is the main spotlight UI. It handles Enter-to-translate, Esc-to-hide, engine/language selectors, and listens for screenshot OCR shortcut events.
|
||||
- `src/lib/views/ChatPanel.svelte` is the AI chat view. It sends multi-turn messages and streams the assistant reply via `chat-chunk` events.
|
||||
- `src/lib/stores/translation.svelte.ts` and `src/lib/stores/chat.svelte.ts` own their respective feature state. Both use the `.svelte.ts` extension because they contain Svelte 5 runes (`$state`). Do not move rune-based stores back to plain `.ts`; doing so leaves `$state` uncompiled and causes a runtime white screen.
|
||||
- `src/lib/shortcuts/shortcutRecorder.ts` is a pure utility that converts keyboard events into Tauri-style shortcut strings. It has a Vitest unit test (`shortcutRecorder.test.ts`).
|
||||
- `src/lib/api/tauri.ts` is the frontend boundary for all `invoke` calls and event subscriptions.
|
||||
- `src/lib/views/Settings.svelte` edits OpenAI config, custom shortcuts, and autostart.
|
||||
- Styling is based on CSS custom properties in `src/styles/tokens.css` plus global rules in `src/styles/global.css`.
|
||||
|
||||
## Runtime Notes
|
||||
|
||||
- Screenshot OCR requires macOS Screen Recording permission. In dev mode the permission prompt may refer to the terminal or the dev binary.
|
||||
- OpenAI translation results stream via the `translate-chunk` event; AI chat streams via `chat-chunk`; Google returns a complete string from the `translate` command.
|
||||
- When the window is pinned (`set_pinned`), it stays always-on-top and does not auto-hide on blur.
|
||||
- The Google endpoint is an unofficial free endpoint and may be rate-limited.
|
||||
- API keys are stored in the local Tauri store; there is not yet a Keychain integration.
|
||||
|
||||
## Current Caveats
|
||||
|
||||
- The README may lag behind implementation details; check `config.rs`, `shortcut.rs`, and `Settings.svelte` for the current shortcut behavior.
|
||||
- There are Rust unit tests for translation response parsing, SSE parsing, and selection text normalization. Frontend unit tests cover the shortcut recorder utility (`src/lib/shortcuts/shortcutRecorder.test.ts`); no component-level test suite yet.
|
||||
@@ -0,0 +1,121 @@
|
||||
# ttree
|
||||
|
||||
> 轻量 macOS AI 翻译器。快捷键秒级呼出,截图 OCR、划词翻译、AI 对话,多引擎流式输出。
|
||||
|
||||
基于 **Tauri 2 + Svelte 5 + Rust** 构建:窗口隐藏而非销毁,保证快捷键瞬时召回;macOS 端 OCR 直接调用系统 Vision 框架,划词捕获走原生 Accessibility,零重型依赖。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能
|
||||
|
||||
- **快捷键呼出翻译** — 默认 `⌘⇧Space` 呼出/收起窗口,输入文本回车翻译,`Esc` 收起,失焦自动隐藏
|
||||
- **截图 OCR 翻译** — `⌘⇧S` 框选屏幕区域,调用 macOS 原生 **Vision** 框架识别文字并自动翻译(中/英/日繁等多语言)
|
||||
- **划词翻译 / 划词 AI** — 选中任意应用内的文本,一键翻译或发送给 AI(基于 macOS Accessibility,未授权时回退到临时复制并恢复剪贴板)
|
||||
- **AI 对话模式** — 独立于翻译的多轮对话,支持自定义 system 提示词
|
||||
- **三引擎**:
|
||||
- **谷歌翻译**(免费网页端接口,无需 key)
|
||||
- **必应翻译**(免费网页端接口,自动抓取一次性令牌)
|
||||
- **OpenAI 兼容接口**(流式逐字输出,可配置 base_url / api_key / 模型 / 翻译提示词)
|
||||
- **双栏对照** — 网页译文与 AI 译文并列展示,可一键切换网页引擎、复制结果
|
||||
- **固定窗口** — 置顶且失焦不隐藏,方便对照查阅
|
||||
- **自定义快捷键** — 五个动作均可独立配置,支持「呼出」与「划词」共用同一组合键(有选中文本时执行划词)
|
||||
- **系统托盘 + 开机自启**(可选)
|
||||
- 毛玻璃磨砂界面,明暗主题自适应
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 动作 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| 呼出 / 收起翻译窗口 | `⌘⇧Space` | 显示时自动复位到翻译页 |
|
||||
| 截图 OCR 翻译 | `⌘⇧S` | 框选区域 → Vision 识别 → 自动翻译 |
|
||||
| 呼出 AI 对话 | —(可在设置配置) | 独立对话模式 |
|
||||
| 划词翻译 | —(可在设置配置) | 捕获选中文本直接翻译 |
|
||||
| 划词 AI 对话 | —(可在设置配置) | 捕获选中文本发送给 AI |
|
||||
|
||||
所有快捷键均可在设置中自定义,留空即禁用;必须包含 `Cmd/Ctrl/Option` 中的至少一个修饰键。
|
||||
|
||||
## 📋 系统权限
|
||||
|
||||
首次使用相关功能时,macOS 会弹出授权请求,请在 **系统设置 → 隐私与安全性** 中开启:
|
||||
|
||||
| 权限 | 用途 |
|
||||
|------|------|
|
||||
| **屏幕录制** | 截图 OCR |
|
||||
| **辅助功能(Accessibility)** | 划词翻译 / 划词 AI(捕获选中文本)|
|
||||
|
||||
> 开发模式下授权对象可能是终端或 dev 二进制。
|
||||
|
||||
## 🚀 开发
|
||||
|
||||
**前置**:Node.js 18+、Rust(stable)、Xcode Command Line Tools。
|
||||
|
||||
```bash
|
||||
npm install # 安装前端依赖
|
||||
npm run tauri dev # 启动开发模式
|
||||
```
|
||||
|
||||
> Tauri 主窗口配置为 `visible: false`,开发模式下启动可能看不到窗口——用默认快捷键 `⌘⇧Space` 呼出。
|
||||
|
||||
仅启动前端(无后端):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📦 构建
|
||||
|
||||
```bash
|
||||
npm run tauri build # 产出 .app 与 .dmg
|
||||
```
|
||||
|
||||
产物位于 `src-tauri/target/release/bundle/`(含 `bundle/macos/ttree.app` 与 `bundle/dmg/*.dmg`)。
|
||||
|
||||
## 🏗️ 架构
|
||||
|
||||
```
|
||||
src-tauri/src/ Rust 后端
|
||||
├── lib.rs 插件注册、毛玻璃、失焦隐藏、setup
|
||||
├── commands.rs 暴露给前端的 Tauri 命令
|
||||
├── config.rs 配置读写(tauri-plugin-store)
|
||||
├── shortcut.rs 全局快捷键注册与分发(含冲突校验)
|
||||
├── window.rs 窗口显示/隐藏/居中/聚焦/固定
|
||||
├── tray.rs 系统托盘菜单
|
||||
├── screenshot.rs screencapture -i 区域截图
|
||||
├── ocr/vision.rs Apple Vision OCR(VNRecognizeTextRequest)
|
||||
├── selection/macos.rs 划词捕获(Accessibility + 剪贴板回退)
|
||||
└── translate/
|
||||
├── mod.rs 引擎分发 + 共享 HTTP 客户端
|
||||
├── google.rs 谷歌免费端接口
|
||||
├── bing.rs 必应免费端接口(令牌抓取)
|
||||
└── openai.rs OpenAI 兼容 SSE 流式(翻译 + 对话)
|
||||
|
||||
src/ Svelte 5 前端(runes)
|
||||
├── App.svelte 翻译 / 对话 / 设置 路由
|
||||
├── lib/components/ TranslatePanel / EngineTabs / LangSelector / ResultView
|
||||
├── lib/views/ ChatPanel / Settings
|
||||
├── lib/stores/ translation / chat(.svelte.ts runes)/ languages
|
||||
├── lib/shortcuts/ 快捷键录制工具(shortcutRecorder)
|
||||
└── lib/api/tauri.ts invoke / event 封装
|
||||
```
|
||||
|
||||
## ✅ 测试与检查
|
||||
|
||||
```bash
|
||||
# Rust
|
||||
cd src-tauri && cargo test # 翻译解析 / SSE 解析 / 快捷键 / 划词归一化单测
|
||||
cd src-tauri && cargo clippy -- -D warnings
|
||||
|
||||
# 前端
|
||||
npm test # Vitest(快捷键录制器)
|
||||
npm run check # svelte-check 类型检查
|
||||
```
|
||||
|
||||
## ⚠️ 说明与限制
|
||||
|
||||
- 谷歌 / 必应走的是**非官方免费端点**,可能随时被限流或失效;OpenAI 兼容接口需自备 API Key。
|
||||
- API Key 当前以明文存于本地 Tauri store(用户目录),后续可接入 macOS Keychain。
|
||||
- 仅支持 **macOS 11.0+**(依赖 Vision、Accessibility、screencapture 等系统框架)。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ttree</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "ttree",
|
||||
"version": "0.1.0",
|
||||
"description": "轻量 macOS AI 翻译器",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"@tauri-apps/plugin-store": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.25.12": true,
|
||||
"fsevents@2.3.3": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,58 @@
|
||||
[package]
|
||||
name = "ttree"
|
||||
version = "0.1.0"
|
||||
description = "轻量 macOS AI 翻译器"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "ttree_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] }
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
window-vibrancy = "0.7"
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "cookies"] }
|
||||
futures-util = "0.3"
|
||||
anyhow = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
urlencoding = "2"
|
||||
memchr = "2"
|
||||
|
||||
# macOS 原生 OCR(Apple Vision)
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", features = [
|
||||
"NSPasteboard",
|
||||
"NSPasteboardItem",
|
||||
] }
|
||||
objc2-core-graphics = { version = "0.3", features = [
|
||||
"CGEvent",
|
||||
"CGEventTypes",
|
||||
"CGRemoteOperation",
|
||||
] }
|
||||
objc2-foundation = { version = "0.3", features = [
|
||||
"NSString",
|
||||
"NSArray",
|
||||
"NSData",
|
||||
"NSDictionary",
|
||||
"NSError",
|
||||
"NSObject",
|
||||
] }
|
||||
objc2-vision = { version = "0.3", features = [
|
||||
"VNRequest",
|
||||
"VNRecognizeTextRequest",
|
||||
"VNObservation",
|
||||
"VNRequestHandler",
|
||||
"VNTypes",
|
||||
] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "主窗口能力",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:event:default",
|
||||
"core:app:default",
|
||||
"positioner:default",
|
||||
"autostart:default",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,144 @@
|
||||
//! 暴露给前端的 Tauri command。
|
||||
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
use crate::config::{self, ChatAiConfig, OpenAiConfig, ShortcutConfig};
|
||||
use crate::ocr;
|
||||
use crate::screenshot;
|
||||
use crate::shortcut;
|
||||
use crate::translate::{self, ChatMessage, Engine, TranslateRequest};
|
||||
use crate::window;
|
||||
|
||||
/// 读取 OpenAI 配置。
|
||||
#[tauri::command]
|
||||
pub fn get_openai_config<R: Runtime>(app: AppHandle<R>) -> OpenAiConfig {
|
||||
config::load_openai(&app)
|
||||
}
|
||||
|
||||
/// 保存 OpenAI 配置。
|
||||
#[tauri::command]
|
||||
pub fn set_openai_config<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
config: OpenAiConfig,
|
||||
) -> Result<(), String> {
|
||||
crate::config::save_openai(&app, &config)
|
||||
}
|
||||
|
||||
/// 读取 AI 对话配置。
|
||||
#[tauri::command]
|
||||
pub fn get_chat_ai_config<R: Runtime>(app: AppHandle<R>) -> ChatAiConfig {
|
||||
config::load_chat_ai(&app)
|
||||
}
|
||||
|
||||
/// 保存 AI 对话配置。
|
||||
#[tauri::command]
|
||||
pub fn set_chat_ai_config<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
config: ChatAiConfig,
|
||||
) -> Result<(), String> {
|
||||
crate::config::save_chat_ai(&app, &config)
|
||||
}
|
||||
|
||||
/// 读取快捷键配置。
|
||||
#[tauri::command]
|
||||
pub fn get_shortcut_config<R: Runtime>(app: AppHandle<R>) -> ShortcutConfig {
|
||||
config::load_shortcuts(&app)
|
||||
}
|
||||
|
||||
/// 保存快捷键配置并立即重注册。
|
||||
#[tauri::command]
|
||||
pub fn set_shortcut_config<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
config: ShortcutConfig,
|
||||
) -> Result<(), String> {
|
||||
shortcut::apply_shortcuts(&app, &config).map_err(|e| e.to_string())?;
|
||||
crate::config::save_shortcuts(&app, &config)
|
||||
}
|
||||
|
||||
/// 录制快捷键时临时取消注册全局快捷键,使按键能传到前端;结束时恢复。
|
||||
#[tauri::command]
|
||||
pub fn set_shortcut_recording<R: Runtime>(app: AppHandle<R>, active: bool) {
|
||||
shortcut::set_recording_active(&app, active);
|
||||
}
|
||||
|
||||
/// 显示并聚焦主窗口(前端在 OCR 完成后调用)。
|
||||
#[tauri::command]
|
||||
pub fn show_main<R: Runtime>(app: AppHandle<R>) {
|
||||
window::show_main(&app);
|
||||
}
|
||||
|
||||
/// 普通 AI 对话命令:不使用翻译提示词。
|
||||
#[tauri::command]
|
||||
pub async fn chat<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
messages: Vec<ChatMessage>,
|
||||
) -> Result<(), String> {
|
||||
if messages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let cfg = config::load_chat_ai(&app);
|
||||
translate::openai::chat_stream(&app, &cfg, &messages).await
|
||||
}
|
||||
|
||||
/// 读取固定窗口状态。
|
||||
#[tauri::command]
|
||||
pub fn get_pinned() -> bool {
|
||||
window::is_pinned()
|
||||
}
|
||||
|
||||
/// 设置固定窗口状态。固定时置顶且失焦不隐藏。
|
||||
#[tauri::command]
|
||||
pub fn set_pinned<R: Runtime>(app: AppHandle<R>, pinned: bool) -> Result<bool, String> {
|
||||
let Some(w) = window::main_window(&app) else {
|
||||
return Err("主窗口不存在".to_string());
|
||||
};
|
||||
window::set_pinned(&w, pinned).map_err(|e| format!("设置固定窗口失败: {e}"))
|
||||
}
|
||||
|
||||
/// 翻译命令。
|
||||
///
|
||||
/// - Google:一次性返回完整译文(String)
|
||||
/// - OpenAI:流式,通过 `translate-chunk` 事件推送,命令本身返回空串
|
||||
#[tauri::command]
|
||||
pub async fn translate<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
req: TranslateRequest,
|
||||
) -> Result<String, String> {
|
||||
let text = req.text.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
match req.engine {
|
||||
Engine::Google => {
|
||||
translate::google::translate(text, &req.source, &req.target).await
|
||||
}
|
||||
Engine::Bing => {
|
||||
translate::bing::translate(text, &req.source, &req.target).await
|
||||
}
|
||||
Engine::Openai => {
|
||||
let cfg = config::load_openai(&app);
|
||||
translate::openai::translate_stream(&app, &cfg, text, &req.source, &req.target)
|
||||
.await?;
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图 OCR 命令:交互式框选 → 系统 OCR → 返回识别文本。
|
||||
///
|
||||
/// 用户取消截图时返回空串。在阻塞线程执行(screencapture 与 Vision 均为阻塞调用)。
|
||||
#[tauri::command]
|
||||
pub async fn screenshot_ocr() -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
let Some(path) = screenshot::capture_interactive()? else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let result = ocr::recognize_file(&path.to_string_lossy());
|
||||
// 清理临时文件
|
||||
let _ = std::fs::remove_file(&path);
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("OCR 任务失败: {e}"))?
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! 配置读写:基于 tauri-plugin-store 持久化用户设置。
|
||||
//!
|
||||
//! 存储项:OpenAI base_url / api_key / model、默认引擎、默认语言对、快捷键。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
pub const STORE_FILE: &str = "settings.json";
|
||||
|
||||
/// OpenAI 兼容接口配置。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OpenAiConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub translate_prompt: String,
|
||||
}
|
||||
|
||||
impl Default for OpenAiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
api_key: String::new(),
|
||||
model: "gpt-4o-mini".to_string(),
|
||||
translate_prompt: "你是一个专业翻译引擎。将用户输入从 {source} 翻译成 {target}。只输出译文,不要解释、不要引号、不要附加任何说明。".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 对话接口配置(与 AI 翻译分开)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatAiConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
/// 自定义系统提示词;空字符串表示不附加。
|
||||
#[serde(default)]
|
||||
pub chat_prompt: String,
|
||||
}
|
||||
|
||||
impl Default for ChatAiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
api_key: String::new(),
|
||||
model: "gpt-4o-mini".to_string(),
|
||||
chat_prompt: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局快捷键配置。空字符串表示该快捷键未启用。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShortcutConfig {
|
||||
/// 呼出/收起翻译窗口。
|
||||
pub toggle: String,
|
||||
/// 截图 OCR 翻译。
|
||||
pub ocr: String,
|
||||
/// 呼出 AI 对话窗口。
|
||||
#[serde(default)]
|
||||
pub ai_dialog: String,
|
||||
/// 划词翻译:捕获选中文本并翻译。
|
||||
#[serde(default)]
|
||||
pub selection_translate: String,
|
||||
/// 划词 AI 对话:捕获选中文本并发送给 AI。
|
||||
#[serde(default)]
|
||||
pub selection_ai_dialog: String,
|
||||
}
|
||||
|
||||
impl Default for ShortcutConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: String::new(),
|
||||
selection_translate: String::new(),
|
||||
selection_ai_dialog: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 store 读取 OpenAI 配置,缺失则回退默认值。
|
||||
pub fn load_openai<R: Runtime>(app: &AppHandle<R>) -> OpenAiConfig {
|
||||
let Ok(store) = app.store(STORE_FILE) else {
|
||||
return OpenAiConfig::default();
|
||||
};
|
||||
let mut cfg = OpenAiConfig::default();
|
||||
if let Some(v) = store.get("openai.base_url").and_then(|v| v.as_str().map(String::from)) {
|
||||
if !v.is_empty() {
|
||||
cfg.base_url = v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = store.get("openai.api_key").and_then(|v| v.as_str().map(String::from)) {
|
||||
cfg.api_key = v;
|
||||
}
|
||||
if let Some(v) = store.get("openai.model").and_then(|v| v.as_str().map(String::from)) {
|
||||
if !v.is_empty() {
|
||||
cfg.model = v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = store.get("openai.translate_prompt").and_then(|v| v.as_str().map(String::from)) {
|
||||
if !v.is_empty() {
|
||||
cfg.translate_prompt = v;
|
||||
}
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
/// 保存 OpenAI 配置到 store。
|
||||
pub fn save_openai<R: Runtime>(app: &AppHandle<R>, cfg: &OpenAiConfig) -> Result<(), String> {
|
||||
let store = app.store(STORE_FILE).map_err(|e| format!("打开配置失败: {e}"))?;
|
||||
store.set("openai.base_url", cfg.base_url.clone());
|
||||
store.set("openai.api_key", cfg.api_key.clone());
|
||||
store.set("openai.model", cfg.model.clone());
|
||||
store.set("openai.translate_prompt", cfg.translate_prompt.clone());
|
||||
store.save().map_err(|e| format!("保存配置失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 store 读取 AI 对话配置,缺失则回退默认值。
|
||||
pub fn load_chat_ai<R: Runtime>(app: &AppHandle<R>) -> ChatAiConfig {
|
||||
let Ok(store) = app.store(STORE_FILE) else {
|
||||
return ChatAiConfig::default();
|
||||
};
|
||||
let mut cfg = ChatAiConfig::default();
|
||||
if let Some(v) = store.get("chat_ai.base_url").and_then(|v| v.as_str().map(String::from)) {
|
||||
if !v.is_empty() {
|
||||
cfg.base_url = v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = store.get("chat_ai.api_key").and_then(|v| v.as_str().map(String::from)) {
|
||||
cfg.api_key = v;
|
||||
}
|
||||
if let Some(v) = store.get("chat_ai.model").and_then(|v| v.as_str().map(String::from)) {
|
||||
if !v.is_empty() {
|
||||
cfg.model = v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = store.get("chat_ai.chat_prompt").and_then(|v| v.as_str().map(String::from)) {
|
||||
cfg.chat_prompt = v;
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
/// 保存 AI 对话配置到 store。
|
||||
pub fn save_chat_ai<R: Runtime>(app: &AppHandle<R>, cfg: &ChatAiConfig) -> Result<(), String> {
|
||||
let store = app.store(STORE_FILE).map_err(|e| format!("打开配置失败: {e}"))?;
|
||||
store.set("chat_ai.base_url", cfg.base_url.clone());
|
||||
store.set("chat_ai.api_key", cfg.api_key.clone());
|
||||
store.set("chat_ai.model", cfg.model.clone());
|
||||
store.set("chat_ai.chat_prompt", cfg.chat_prompt.clone());
|
||||
store.save().map_err(|e| format!("保存 AI 对话配置失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 store 读取快捷键配置,缺失则回退默认值。
|
||||
pub fn load_shortcuts<R: Runtime>(app: &AppHandle<R>) -> ShortcutConfig {
|
||||
let Ok(store) = app.store(STORE_FILE) else {
|
||||
return ShortcutConfig::default();
|
||||
};
|
||||
let mut cfg = ShortcutConfig::default();
|
||||
if let Some(v) = store.get("shortcut.toggle").and_then(|v| v.as_str().map(String::from)) {
|
||||
if !v.is_empty() {
|
||||
cfg.toggle = v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = store.get("shortcut.ocr").and_then(|v| v.as_str().map(String::from)) {
|
||||
if !v.is_empty() {
|
||||
cfg.ocr = v;
|
||||
}
|
||||
}
|
||||
if let Some(v) = store
|
||||
.get("shortcut.ai_dialog")
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
{
|
||||
cfg.ai_dialog = v;
|
||||
}
|
||||
if let Some(v) = store
|
||||
.get("shortcut.selection_translate")
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
{
|
||||
cfg.selection_translate = v;
|
||||
}
|
||||
if let Some(v) = store
|
||||
.get("shortcut.selection_ai_dialog")
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
{
|
||||
cfg.selection_ai_dialog = v;
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
/// 保存快捷键配置到 store。
|
||||
pub fn save_shortcuts<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
cfg: &ShortcutConfig,
|
||||
) -> Result<(), String> {
|
||||
let store = app.store(STORE_FILE).map_err(|e| format!("打开配置失败: {e}"))?;
|
||||
store.set("shortcut.toggle", cfg.toggle.clone());
|
||||
store.set("shortcut.ocr", cfg.ocr.clone());
|
||||
store.set("shortcut.ai_dialog", cfg.ai_dialog.clone());
|
||||
store.set("shortcut.selection_translate", cfg.selection_translate.clone());
|
||||
store.set("shortcut.selection_ai_dialog", cfg.selection_ai_dialog.clone());
|
||||
store.save().map_err(|e| format!("保存快捷键失败: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! ttree —— 轻量 macOS AI 翻译器后端入口。
|
||||
|
||||
mod commands;
|
||||
mod config;
|
||||
mod ocr;
|
||||
mod screenshot;
|
||||
mod selection;
|
||||
mod shortcut;
|
||||
mod tray;
|
||||
mod translate;
|
||||
mod window;
|
||||
|
||||
use tauri::{Manager, WindowEvent};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_positioner::init())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
None,
|
||||
))
|
||||
.plugin(shortcut::build_plugin())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::translate,
|
||||
commands::chat,
|
||||
commands::screenshot_ocr,
|
||||
commands::show_main,
|
||||
commands::get_openai_config,
|
||||
commands::set_openai_config,
|
||||
commands::get_chat_ai_config,
|
||||
commands::set_chat_ai_config,
|
||||
commands::get_shortcut_config,
|
||||
commands::set_shortcut_config,
|
||||
commands::set_shortcut_recording,
|
||||
commands::get_pinned,
|
||||
commands::set_pinned
|
||||
])
|
||||
.setup(|app| {
|
||||
// 应用毛玻璃磨砂效果
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(win) = app.get_webview_window(window::MAIN_WINDOW) {
|
||||
let _ = apply_vibrancy(
|
||||
&win,
|
||||
NSVisualEffectMaterial::HudWindow,
|
||||
Some(NSVisualEffectState::Active),
|
||||
Some(16.0),
|
||||
);
|
||||
}
|
||||
|
||||
// 注册持久化快捷键配置
|
||||
shortcut::register_saved(app.handle())?;
|
||||
|
||||
// 构建系统托盘
|
||||
tray::setup_tray(app.handle())?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|win, event| {
|
||||
// 失焦自动隐藏(仅主窗口)。固定窗口时不隐藏。
|
||||
if win.label() == window::MAIN_WINDOW && !window::is_pinned() {
|
||||
if let WindowEvent::Focused(false) = event {
|
||||
let _ = win.hide();
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
ttree_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! OCR 模块入口。
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod vision;
|
||||
|
||||
/// 对图片文件执行 OCR,返回识别文本。
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn recognize_file(path: &str) -> Result<String, String> {
|
||||
vision::recognize_file(path)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn recognize_file(_path: &str) -> Result<String, String> {
|
||||
Err("当前平台暂不支持 OCR".to_string())
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! macOS 原生 OCR —— 通过 Apple Vision 框架的 VNRecognizeTextRequest 识别图片文字。
|
||||
//!
|
||||
//! 仅 macOS。加载 PNG 为 NSData → VNImageRequestHandler → 同步 perform →
|
||||
//! 读取 VNRecognizedTextObservation 的 topCandidates(1) 拼接。
|
||||
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::AnyThread;
|
||||
use objc2_foundation::{NSArray, NSData, NSString};
|
||||
use objc2_vision::{
|
||||
VNImageRequestHandler, VNRecognizeTextRequest, VNRequestTextRecognitionLevel,
|
||||
};
|
||||
|
||||
/// 对指定图片文件路径执行 OCR,返回识别文本(按行拼接)。
|
||||
pub fn recognize_file(path: &str) -> Result<String, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("读取截图失败: {e}"))?;
|
||||
recognize_bytes(&bytes)
|
||||
}
|
||||
|
||||
/// 对内存中的图片字节执行 OCR。
|
||||
pub fn recognize_bytes(bytes: &[u8]) -> Result<String, String> {
|
||||
// SAFETY: Vision API 须在持有有效图像数据期间调用;以下对象均由我们创建并在本函数内存活。
|
||||
unsafe {
|
||||
let data = NSData::with_bytes(bytes);
|
||||
|
||||
// 创建文字识别请求,accurate 级别 + 中英多语言
|
||||
let request: Retained<VNRecognizeTextRequest> = VNRecognizeTextRequest::new();
|
||||
request.setRecognitionLevel(VNRequestTextRecognitionLevel::Accurate);
|
||||
request.setUsesLanguageCorrection(true);
|
||||
let langs = NSArray::from_retained_slice(&[
|
||||
NSString::from_str("zh-Hans"),
|
||||
NSString::from_str("zh-Hant"),
|
||||
NSString::from_str("en-US"),
|
||||
NSString::from_str("ja-JP"),
|
||||
]);
|
||||
request.setRecognitionLanguages(&langs);
|
||||
|
||||
// 基于图像数据构建 handler 并同步执行
|
||||
let handler = VNImageRequestHandler::initWithData_options(
|
||||
VNImageRequestHandler::alloc(),
|
||||
&data,
|
||||
&objc2_foundation::NSDictionary::new(),
|
||||
);
|
||||
|
||||
let requests = NSArray::from_retained_slice(&[Retained::into_super(
|
||||
Retained::into_super(request.clone()),
|
||||
)]);
|
||||
handler
|
||||
.performRequests_error(&requests)
|
||||
.map_err(|e| format!("OCR 执行失败: {e}"))?;
|
||||
|
||||
Ok(collect_text(&request))
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求结果收集识别文本。
|
||||
unsafe fn collect_text(request: &VNRecognizeTextRequest) -> String {
|
||||
let Some(observations) = request.results() else {
|
||||
return String::new();
|
||||
};
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for obs in observations.iter() {
|
||||
let candidates = obs.topCandidates(1);
|
||||
if let Some(top) = candidates.firstObject() {
|
||||
lines.push(top.string().to_string());
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! 屏幕区域截图 —— 调用 macOS 系统 `screencapture -i` 让用户框选。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// 全局自增序号,配合进程 id 生成唯一临时文件名,避免并发 OCR 互相覆盖。
|
||||
static SHOT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// 触发交互式区域截图,落到临时 PNG 文件。
|
||||
///
|
||||
/// 返回截图文件路径;用户取消选择时返回 `Ok(None)`。
|
||||
pub fn capture_interactive() -> Result<Option<PathBuf>, String> {
|
||||
let seq = SHOT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"ttree_shot_{}_{}.png",
|
||||
std::process::id(),
|
||||
seq
|
||||
));
|
||||
|
||||
// -i 交互式框选,-x 静音,-r 不加窗口阴影
|
||||
let status = Command::new("/usr/sbin/screencapture")
|
||||
.args(["-i", "-x", "-r"])
|
||||
.arg(&tmp)
|
||||
.status()
|
||||
.map_err(|e| format!("调用 screencapture 失败: {e}"))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("截图进程异常退出".to_string());
|
||||
}
|
||||
|
||||
// 用户按 Esc 取消时不会生成文件
|
||||
if !tmp.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(tmp))
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! macOS 选中文本捕获实现。
|
||||
|
||||
use std::ptr;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use core_foundation::base::{Boolean, CFRelease, CFTypeRef, TCFType};
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
use objc2_app_kit::{NSPasteboard, NSPasteboardType, NSPasteboardTypeString};
|
||||
use objc2_core_graphics::{CGEvent, CGEventFlags, CGEventTapLocation};
|
||||
use objc2_foundation::{NSData, NSInteger, NSString};
|
||||
|
||||
use super::normalize_selected_text;
|
||||
|
||||
const AX_ERROR_SUCCESS: i32 = 0;
|
||||
const COPY_KEY_CODE: u16 = 8;
|
||||
const COPY_TIMEOUT: Duration = Duration::from_millis(450);
|
||||
const COPY_POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
|
||||
#[repr(C)]
|
||||
struct __AXUIElement {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
type AXUIElementRef = *const __AXUIElement;
|
||||
|
||||
#[link(name = "ApplicationServices", kind = "framework")]
|
||||
extern "C" {
|
||||
fn AXIsProcessTrusted() -> Boolean;
|
||||
fn AXUIElementCreateSystemWide() -> AXUIElementRef;
|
||||
fn AXUIElementCopyAttributeValue(
|
||||
element: AXUIElementRef,
|
||||
attribute: CFStringRef,
|
||||
value: *mut CFTypeRef,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PasteboardSnapshot {
|
||||
entries: Vec<PasteboardEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PasteboardEntry {
|
||||
type_name: String,
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub(crate) fn capture_selected_text() -> Result<Option<String>> {
|
||||
if let Some(text) = capture_accessibility_selected_text()? {
|
||||
return Ok(Some(text));
|
||||
}
|
||||
|
||||
capture_clipboard_selected_text()
|
||||
}
|
||||
|
||||
fn capture_accessibility_selected_text() -> Result<Option<String>> {
|
||||
// SAFETY: We only call documented ApplicationServices Accessibility APIs. All returned
|
||||
// CoreFoundation objects that follow the Create/Copy rule are released or wrapped exactly once.
|
||||
unsafe {
|
||||
if AXIsProcessTrusted() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let system_element = AXUIElementCreateSystemWide();
|
||||
if system_element.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let focused_attr = CFString::new("AXFocusedUIElement");
|
||||
let mut focused_value: CFTypeRef = ptr::null();
|
||||
let focused_error = AXUIElementCopyAttributeValue(
|
||||
system_element,
|
||||
focused_attr.as_concrete_TypeRef(),
|
||||
&mut focused_value,
|
||||
);
|
||||
CFRelease(system_element as CFTypeRef);
|
||||
|
||||
if focused_error != AX_ERROR_SUCCESS || focused_value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let selected_attr = CFString::new("AXSelectedText");
|
||||
let mut selected_value: CFTypeRef = ptr::null();
|
||||
let selected_error = AXUIElementCopyAttributeValue(
|
||||
focused_value as AXUIElementRef,
|
||||
selected_attr.as_concrete_TypeRef(),
|
||||
&mut selected_value,
|
||||
);
|
||||
CFRelease(focused_value);
|
||||
|
||||
if selected_error != AX_ERROR_SUCCESS || selected_value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let selected = CFString::wrap_under_create_rule(selected_value as CFStringRef).to_string();
|
||||
Ok(normalize_selected_text(selected))
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_clipboard_selected_text() -> Result<Option<String>> {
|
||||
let pasteboard = NSPasteboard::generalPasteboard();
|
||||
let snapshot = snapshot_pasteboard(&pasteboard);
|
||||
let before_copy = pasteboard.changeCount();
|
||||
|
||||
post_copy_keypress()?;
|
||||
|
||||
let Some(copy_change_count) = wait_for_pasteboard_change(&pasteboard, before_copy) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let copied_text = read_plain_text(&pasteboard).and_then(normalize_selected_text);
|
||||
|
||||
if should_restore_pasteboard(pasteboard.changeCount(), copy_change_count) {
|
||||
restore_pasteboard(&pasteboard, &snapshot);
|
||||
}
|
||||
|
||||
Ok(copied_text)
|
||||
}
|
||||
|
||||
fn snapshot_pasteboard(pasteboard: &NSPasteboard) -> PasteboardSnapshot {
|
||||
let entries = pasteboard
|
||||
.types()
|
||||
.map(|types| {
|
||||
types
|
||||
.to_vec()
|
||||
.into_iter()
|
||||
.filter_map(|type_name| {
|
||||
pasteboard.dataForType(&type_name).map(|data| PasteboardEntry {
|
||||
type_name: type_name.to_string(),
|
||||
data: data.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
PasteboardSnapshot { entries }
|
||||
}
|
||||
|
||||
fn restore_pasteboard(pasteboard: &NSPasteboard, snapshot: &PasteboardSnapshot) {
|
||||
let _ = pasteboard.clearContents();
|
||||
|
||||
for entry in &snapshot.entries {
|
||||
let type_name = NSString::from_str(&entry.type_name);
|
||||
let data = NSData::with_bytes(&entry.data);
|
||||
let _ = pasteboard.setData_forType(Some(&data), &type_name);
|
||||
}
|
||||
}
|
||||
|
||||
fn should_restore_pasteboard(current_change_count: NSInteger, copy_change_count: NSInteger) -> bool {
|
||||
current_change_count == copy_change_count
|
||||
}
|
||||
|
||||
fn wait_for_pasteboard_change(
|
||||
pasteboard: &NSPasteboard,
|
||||
before_copy: NSInteger,
|
||||
) -> Option<NSInteger> {
|
||||
let deadline = Instant::now() + COPY_TIMEOUT;
|
||||
|
||||
while Instant::now() < deadline {
|
||||
let current = pasteboard.changeCount();
|
||||
if current != before_copy {
|
||||
return Some(current);
|
||||
}
|
||||
thread::sleep(COPY_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn read_plain_text(pasteboard: &NSPasteboard) -> Option<String> {
|
||||
pasteboard
|
||||
.stringForType(string_pasteboard_type())
|
||||
.map(|text| text.to_string())
|
||||
}
|
||||
|
||||
fn string_pasteboard_type() -> &'static NSPasteboardType {
|
||||
// SAFETY: NSPasteboardTypeString is an AppKit constant with static lifetime.
|
||||
unsafe { NSPasteboardTypeString }
|
||||
}
|
||||
|
||||
fn post_copy_keypress() -> Result<()> {
|
||||
let key_down = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, true)
|
||||
.ok_or_else(|| anyhow!("创建复制按下事件失败"))?;
|
||||
let key_up = CGEvent::new_keyboard_event(None, COPY_KEY_CODE, false)
|
||||
.ok_or_else(|| anyhow!("创建复制释放事件失败"))?;
|
||||
|
||||
CGEvent::set_flags(Some(&key_down), CGEventFlags::MaskCommand);
|
||||
CGEvent::set_flags(Some(&key_up), CGEventFlags::MaskCommand);
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_down));
|
||||
CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&key_up));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pasteboard_restore_requires_unchanged_copy_count() {
|
||||
assert!(should_restore_pasteboard(42, 42));
|
||||
assert!(!should_restore_pasteboard(43, 42));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! 系统选中文本捕获。
|
||||
//!
|
||||
//! macOS 上优先通过 Accessibility 读取当前焦点控件的选中文本,失败后回退到
|
||||
//! 临时 Cmd+C + 剪贴板读取。非 macOS 平台返回空结果。
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use macos::capture_selected_text;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn capture_selected_text() -> anyhow::Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_selected_text(text: impl AsRef<str>) -> Option<String> {
|
||||
let trimmed = text.as_ref().trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_blank_text_to_none() {
|
||||
assert_eq!(normalize_selected_text(" \n\t "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_non_empty_selected_text() {
|
||||
assert_eq!(normalize_selected_text(" hello "), Some("hello".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
//! 全局快捷键注册与分发。
|
||||
//!
|
||||
//! - 呼出快捷键:toggle 主窗口可见性
|
||||
//! - 截图 OCR 快捷键:广播 `trigger-ocr`,前端调用截图/OCR/翻译流程
|
||||
//! - AI 对话快捷键:呼出 AI 对话窗口
|
||||
//! - 划词翻译快捷键:捕获选中文本并自动翻译
|
||||
//! - 划词 AI 对话快捷键:捕获选中文本并发送给 AI
|
||||
//!
|
||||
//! 所有快捷键均可选;空字符串表示未启用。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_global_shortcut::{
|
||||
Builder as ShortcutBuilder, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState,
|
||||
};
|
||||
|
||||
use crate::config::{self, ShortcutConfig};
|
||||
use crate::selection;
|
||||
use crate::window;
|
||||
|
||||
static IS_RECORDING_SHORTCUT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ParsedShortcuts {
|
||||
toggle: Option<Shortcut>,
|
||||
ocr: Option<Shortcut>,
|
||||
ai_dialog: Option<Shortcut>,
|
||||
selection_translate: Option<Shortcut>,
|
||||
selection_ai_dialog: Option<Shortcut>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ShortcutAction {
|
||||
Toggle,
|
||||
Ocr,
|
||||
AiDialog,
|
||||
SelectionTranslate,
|
||||
SelectionAiDialog,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct SelectedTextPayload {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct AiDialogPayload {
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl ParsedShortcuts {
|
||||
/// 分发优先级顺序:划词动作排在对应的纯呼出动作之前,
|
||||
/// 这样当两者共用同一快捷键时,按更丰富的划词行为执行。
|
||||
fn entries(&self) -> [(ShortcutAction, &Option<Shortcut>); 5] {
|
||||
[
|
||||
(ShortcutAction::SelectionTranslate, &self.selection_translate),
|
||||
(ShortcutAction::SelectionAiDialog, &self.selection_ai_dialog),
|
||||
(ShortcutAction::Toggle, &self.toggle),
|
||||
(ShortcutAction::Ocr, &self.ocr),
|
||||
(ShortcutAction::AiDialog, &self.ai_dialog),
|
||||
]
|
||||
}
|
||||
|
||||
/// 需要注册的快捷键集合,去重(共用同一键的只注册一次)。
|
||||
fn shortcuts(&self) -> Vec<Shortcut> {
|
||||
let mut out: Vec<Shortcut> = Vec::new();
|
||||
for (_, shortcut) in self.entries() {
|
||||
if let Some(shortcut) = shortcut {
|
||||
if !out.contains(shortcut) {
|
||||
out.push(*shortcut);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn action_for(&self, shortcut: &Shortcut) -> Option<ShortcutAction> {
|
||||
self.entries()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| candidate.as_ref() == Some(shortcut))
|
||||
.map(|(action, _)| action)
|
||||
}
|
||||
|
||||
/// 校验快捷键不冲突。允许两组共用同一键:
|
||||
/// 呼出翻译↔划词翻译、呼出 AI 对话↔划词 AI 对话;其余两两不得相同。
|
||||
fn reject_disallowed_duplicates(&self) -> Result<(), anyhow::Error> {
|
||||
let fields: [(&str, &Option<Shortcut>); 5] = [
|
||||
("呼出翻译快捷键", &self.toggle),
|
||||
("截图 OCR 快捷键", &self.ocr),
|
||||
("呼出 AI 对话快捷键", &self.ai_dialog),
|
||||
("划词翻译快捷键", &self.selection_translate),
|
||||
("划词 AI 对话快捷键", &self.selection_ai_dialog),
|
||||
];
|
||||
|
||||
for (i, (left_label, left)) in fields.iter().enumerate() {
|
||||
let Some(left) = left else { continue };
|
||||
for (right_label, right) in fields.iter().skip(i + 1) {
|
||||
let Some(right) = right else { continue };
|
||||
if left == right && !is_allowed_shared_pair(left_label, right_label) {
|
||||
return Err(anyhow::anyhow!("{left_label}和{right_label}不能相同"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 这两组快捷键允许共用同一键(划词动作叠加在纯呼出动作上)。
|
||||
fn is_allowed_shared_pair(a: &str, b: &str) -> bool {
|
||||
const ALLOWED: [(&str, &str); 2] = [
|
||||
("呼出翻译快捷键", "划词翻译快捷键"),
|
||||
("呼出 AI 对话快捷键", "划词 AI 对话快捷键"),
|
||||
];
|
||||
ALLOWED
|
||||
.iter()
|
||||
.any(|&(x, y)| (a == x && b == y) || (a == y && b == x))
|
||||
}
|
||||
|
||||
/// 进入/退出快捷键录制。
|
||||
///
|
||||
/// 录制期间取消注册所有全局快捷键,否则与已注册键相同的按键会被 OS 级全局热键
|
||||
/// 拦截而无法作为普通 keydown 事件传到前端(表现为“按相同键没反应”)。
|
||||
/// 退出录制时按已保存配置重新注册。
|
||||
pub fn set_recording_active<R: tauri::Runtime>(app: &AppHandle<R>, is_active: bool) {
|
||||
IS_RECORDING_SHORTCUT.store(is_active, Ordering::SeqCst);
|
||||
|
||||
if is_active {
|
||||
let _ = app.global_shortcut().unregister_all();
|
||||
} else {
|
||||
let saved = config::load_shortcuts(app);
|
||||
let _ = apply_shortcuts(app, &saved);
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 global-shortcut 插件,带统一处理器。
|
||||
pub fn build_plugin<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
ShortcutBuilder::<R>::new()
|
||||
.with_handler(move |app, shortcut, event| {
|
||||
if event.state() != ShortcutState::Pressed {
|
||||
return;
|
||||
}
|
||||
handle_shortcut(app, shortcut);
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
fn handle_shortcut<R: tauri::Runtime>(app: &AppHandle<R>, shortcut: &Shortcut) {
|
||||
if IS_RECORDING_SHORTCUT.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cfg = config::load_shortcuts(app);
|
||||
let Ok(shortcuts) = parse_shortcut_config(&cfg) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match shortcuts.action_for(shortcut) {
|
||||
Some(ShortcutAction::Toggle) => {
|
||||
// 仅在切换为显示时复位到翻译页。
|
||||
if window::toggle_window(app) {
|
||||
let _ = app.emit("trigger-translate", ());
|
||||
}
|
||||
}
|
||||
Some(ShortcutAction::Ocr) => {
|
||||
let _ = app.emit("trigger-ocr", ());
|
||||
}
|
||||
Some(ShortcutAction::AiDialog) => handle_ai_dialog_shortcut(app),
|
||||
Some(ShortcutAction::SelectionTranslate) => handle_selection_translate_shortcut(app),
|
||||
Some(ShortcutAction::SelectionAiDialog) => handle_selection_ai_dialog_shortcut(app),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 划词翻译:有选中文本则翻译,否则仅 toggle 窗口。
|
||||
fn handle_selection_translate_shortcut<R: tauri::Runtime>(app: &AppHandle<R>) {
|
||||
match selection::capture_selected_text() {
|
||||
Ok(Some(text)) => {
|
||||
window::show_main(app);
|
||||
let _ = app.emit("trigger-selected-translate", SelectedTextPayload { text });
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
window::toggle_window(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 呼出 AI 对话窗口(不捕获划词)。
|
||||
fn handle_ai_dialog_shortcut<R: tauri::Runtime>(app: &AppHandle<R>) {
|
||||
window::show_main(app);
|
||||
let _ = app.emit("trigger-ai-dialog", AiDialogPayload { text: None });
|
||||
}
|
||||
|
||||
/// 划词 AI 对话:有选中文本则连同原文发送给 AI,否则仅呼出对话窗口。
|
||||
fn handle_selection_ai_dialog_shortcut<R: tauri::Runtime>(app: &AppHandle<R>) {
|
||||
let text = selection::capture_selected_text().ok().flatten();
|
||||
window::show_main(app);
|
||||
let _ = app.emit("trigger-ai-dialog", AiDialogPayload { text });
|
||||
}
|
||||
|
||||
/// 注册持久化配置中的快捷键。在 setup 中调用。
|
||||
pub fn register_saved<R: tauri::Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
|
||||
let saved = config::load_shortcuts(app);
|
||||
if parse_shortcut_config(&saved).is_ok() && apply_shortcuts(app, &saved).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let defaults = ShortcutConfig::default();
|
||||
if apply_shortcuts(app, &defaults).is_ok() {
|
||||
let _ = config::save_shortcuts(app, &defaults);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证并应用快捷键配置。保存设置时调用,立即生效。
|
||||
pub fn apply_shortcuts<R: tauri::Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
cfg: &ShortcutConfig,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let shortcuts = parse_shortcut_config(cfg)?;
|
||||
|
||||
let previous = parse_shortcut_config(&config::load_shortcuts(app)).ok();
|
||||
let gs = app.global_shortcut();
|
||||
|
||||
let _ = gs.unregister_all();
|
||||
|
||||
for shortcut in shortcuts.shortcuts() {
|
||||
if let Err(error) = gs.register(shortcut) {
|
||||
let _ = gs.unregister_all();
|
||||
restore_shortcuts(app, previous);
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_shortcuts<R: tauri::Runtime>(app: &AppHandle<R>, previous: Option<ParsedShortcuts>) {
|
||||
let Some(previous) = previous else {
|
||||
return;
|
||||
};
|
||||
|
||||
let gs = app.global_shortcut();
|
||||
for shortcut in previous.shortcuts() {
|
||||
let _ = gs.register(shortcut);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_shortcut_config(cfg: &ShortcutConfig) -> Result<ParsedShortcuts, anyhow::Error> {
|
||||
let toggle = parse_optional_shortcut("呼出翻译快捷键", &cfg.toggle)?;
|
||||
let ocr = parse_optional_shortcut("截图 OCR 快捷键", &cfg.ocr)?;
|
||||
let ai_dialog = parse_optional_shortcut("呼出 AI 对话快捷键", &cfg.ai_dialog)?;
|
||||
let selection_translate =
|
||||
parse_optional_shortcut("划词翻译快捷键", &cfg.selection_translate)?;
|
||||
let selection_ai_dialog =
|
||||
parse_optional_shortcut("划词 AI 对话快捷键", &cfg.selection_ai_dialog)?;
|
||||
|
||||
let parsed = ParsedShortcuts {
|
||||
toggle,
|
||||
ocr,
|
||||
ai_dialog,
|
||||
selection_translate,
|
||||
selection_ai_dialog,
|
||||
};
|
||||
parsed.reject_disallowed_duplicates()?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn parse_required_shortcut(label: &str, spec: &str) -> Result<Shortcut, anyhow::Error> {
|
||||
let shortcut = spec
|
||||
.trim()
|
||||
.parse::<Shortcut>()
|
||||
.map_err(|e| anyhow::anyhow!("{label}无效: {e}"))?;
|
||||
|
||||
if !shortcut
|
||||
.mods
|
||||
.intersects(Modifiers::SUPER | Modifiers::CONTROL | Modifiers::ALT)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"{label}必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(shortcut)
|
||||
}
|
||||
|
||||
fn parse_optional_shortcut(label: &str, spec: &str) -> Result<Option<Shortcut>, anyhow::Error> {
|
||||
if spec.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
parse_required_shortcut(label, spec).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shortcut_config(toggle: &str, ocr: &str, ai_dialog: &str) -> ShortcutConfig {
|
||||
ShortcutConfig {
|
||||
toggle: toggle.to_string(),
|
||||
ocr: ocr.to_string(),
|
||||
ai_dialog: ai_dialog.to_string(),
|
||||
selection_translate: String::new(),
|
||||
selection_ai_dialog: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_default_shortcuts_with_unset_ai_dialog() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
"",
|
||||
));
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().ai_dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_configured_ai_dialog_shortcut() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
"CmdOrCtrl+Shift+A",
|
||||
));
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().ai_dialog.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_all_empty_shortcuts() {
|
||||
let result = parse_shortcut_config(&shortcut_config("", "", ""));
|
||||
|
||||
assert!(result.is_ok());
|
||||
let parsed = result.unwrap();
|
||||
assert!(parsed.toggle.is_none());
|
||||
assert!(parsed.ocr.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_selection_shortcuts() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: String::new(),
|
||||
selection_translate: "CmdOrCtrl+Shift+T".to_string(),
|
||||
selection_ai_dialog: "CmdOrCtrl+Shift+D".to_string(),
|
||||
};
|
||||
let result = parse_shortcut_config(&cfg);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let parsed = result.unwrap();
|
||||
assert!(parsed.selection_translate.is_some());
|
||||
assert!(parsed.selection_ai_dialog.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_option_space_with_modifier() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"Option+Space",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
"",
|
||||
));
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treats_whitespace_ai_dialog_as_unset() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
" ",
|
||||
));
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().ai_dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_toggle_without_modifier() {
|
||||
let result = parse_shortcut_config(&shortcut_config("Space", "CmdOrCtrl+Shift+S", ""));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("呼出翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ocr_without_modifier() {
|
||||
let result = parse_shortcut_config(&shortcut_config("CmdOrCtrl+Shift+Space", "S", ""));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("截图 OCR 快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ai_dialog_without_modifier() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
"A",
|
||||
));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("呼出 AI 对话快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_selection_translate_without_modifier() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: String::new(),
|
||||
selection_translate: "T".to_string(),
|
||||
selection_ai_dialog: String::new(),
|
||||
};
|
||||
let result = parse_shortcut_config(&cfg);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("划词翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_shift_only_shortcut() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"Shift+S",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
"",
|
||||
));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("呼出翻译快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_modifier_only_shortcut() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"Shift",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
"",
|
||||
));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("呼出翻译快捷键无效"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_toggle_and_ocr_shortcuts() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
"",
|
||||
));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("呼出翻译快捷键和截图 OCR 快捷键不能相同"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_toggle_and_ai_dialog_shortcuts() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("呼出翻译快捷键和呼出 AI 对话快捷键不能相同"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_ocr_and_ai_dialog_shortcuts() {
|
||||
let result = parse_shortcut_config(&shortcut_config(
|
||||
"CmdOrCtrl+Shift+Space",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
"CmdOrCtrl+Shift+S",
|
||||
));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("截图 OCR 快捷键和呼出 AI 对话快捷键不能相同"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_selection_shortcuts() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: String::new(),
|
||||
selection_translate: "CmdOrCtrl+Shift+T".to_string(),
|
||||
selection_ai_dialog: "CmdOrCtrl+Shift+T".to_string(),
|
||||
};
|
||||
let result = parse_shortcut_config(&cfg);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("划词翻译快捷键和划词 AI 对话快捷键不能相同"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_shared_toggle_and_selection_translate() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: String::new(),
|
||||
selection_translate: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
selection_ai_dialog: String::new(),
|
||||
};
|
||||
let result = parse_shortcut_config(&cfg);
|
||||
|
||||
assert!(result.is_ok());
|
||||
// 共用同一键时只注册一次。
|
||||
assert_eq!(result.unwrap().shortcuts().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_shared_ai_dialog_and_selection_ai_dialog() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
|
||||
selection_translate: String::new(),
|
||||
selection_ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
|
||||
};
|
||||
let result = parse_shortcut_config(&cfg);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().shortcuts().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_translate_key_dispatches_to_selection_action() {
|
||||
// 呼出翻译与划词翻译共用同一键时,划词动作优先。
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: String::new(),
|
||||
selection_translate: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
selection_ai_dialog: String::new(),
|
||||
};
|
||||
let shortcuts = parse_shortcut_config(&cfg).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
shortcuts.action_for(&"CmdOrCtrl+Shift+Space".parse().unwrap()),
|
||||
Some(ShortcutAction::SelectionTranslate)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_ai_key_dispatches_to_selection_action() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
|
||||
selection_translate: String::new(),
|
||||
selection_ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
|
||||
};
|
||||
let shortcuts = parse_shortcut_config(&cfg).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
shortcuts.action_for(&"CmdOrCtrl+Shift+A".parse().unwrap()),
|
||||
Some(ShortcutAction::SelectionAiDialog)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_group_shared_key() {
|
||||
// 呼出翻译与划词 AI 对话不属于允许共用的组合。
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: String::new(),
|
||||
selection_translate: String::new(),
|
||||
selection_ai_dialog: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
};
|
||||
let result = parse_shortcut_config(&cfg);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("不能相同"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_known_shortcut_actions() {
|
||||
let cfg = ShortcutConfig {
|
||||
toggle: "CmdOrCtrl+Shift+Space".to_string(),
|
||||
ocr: "CmdOrCtrl+Shift+S".to_string(),
|
||||
ai_dialog: "CmdOrCtrl+Shift+A".to_string(),
|
||||
selection_translate: "CmdOrCtrl+Shift+T".to_string(),
|
||||
selection_ai_dialog: "CmdOrCtrl+Shift+D".to_string(),
|
||||
};
|
||||
let shortcuts = parse_shortcut_config(&cfg).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
shortcuts.action_for(&"CmdOrCtrl+Shift+Space".parse().unwrap()),
|
||||
Some(ShortcutAction::Toggle)
|
||||
);
|
||||
assert_eq!(
|
||||
shortcuts.action_for(&"CmdOrCtrl+Shift+S".parse().unwrap()),
|
||||
Some(ShortcutAction::Ocr)
|
||||
);
|
||||
assert_eq!(
|
||||
shortcuts.action_for(&"CmdOrCtrl+Shift+A".parse().unwrap()),
|
||||
Some(ShortcutAction::AiDialog)
|
||||
);
|
||||
assert_eq!(
|
||||
shortcuts.action_for(&"CmdOrCtrl+Shift+T".parse().unwrap()),
|
||||
Some(ShortcutAction::SelectionTranslate)
|
||||
);
|
||||
assert_eq!(
|
||||
shortcuts.action_for(&"CmdOrCtrl+Shift+D".parse().unwrap()),
|
||||
Some(ShortcutAction::SelectionAiDialog)
|
||||
);
|
||||
assert_eq!(shortcuts.action_for(&"CmdOrCtrl+Shift+X".parse().unwrap()), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! 必应翻译 —— 免费网页端接口(bing.com/ttranslatev3)。
|
||||
//!
|
||||
//! 注意:这是非官方端点。需要先抓取翻译页拿到一次性令牌(IG / IID / key / token),
|
||||
//! 再带令牌 POST 翻译文本。可能随时失效或被限流。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::shared_client;
|
||||
|
||||
const TRANSLATOR_URL: &str = "https://www.bing.com/translator";
|
||||
const TRANSLATE_URL: &str = "https://www.bing.com/ttranslatev3";
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const USER_AGENT: &str =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
|
||||
(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
/// 翻译页里抓取的一次性令牌。
|
||||
struct BingToken {
|
||||
ig: String,
|
||||
iid: String,
|
||||
key: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// 把内部语言代码映射成必应使用的代码。
|
||||
fn map_lang(code: &str) -> &str {
|
||||
match code {
|
||||
"auto" => "auto-detect",
|
||||
"zh-CN" => "zh-Hans",
|
||||
"zh-TW" => "zh-Hant",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 调用必应免费端接口,返回完整译文。
|
||||
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
|
||||
let client = shared_client(REQUEST_TIMEOUT, true);
|
||||
|
||||
let token = fetch_token(&client).await?;
|
||||
|
||||
let url = format!(
|
||||
"{TRANSLATE_URL}?isVertical=1&IG={ig}&IID={iid}",
|
||||
ig = token.ig,
|
||||
iid = token.iid,
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("Referer", TRANSLATOR_URL)
|
||||
.form(&[
|
||||
("fromLang", map_lang(source)),
|
||||
("to", map_lang(target)),
|
||||
("text", text),
|
||||
("token", &token.token),
|
||||
("key", &token.key),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("必应翻译请求失败: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("必应翻译返回状态 {}", resp.status()));
|
||||
}
|
||||
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("必应翻译响应解析失败: {e}"))?;
|
||||
|
||||
parse_response(&body).ok_or_else(|| "必应翻译未返回译文".to_string())
|
||||
}
|
||||
|
||||
/// 抓取翻译页并解析出一次性令牌。
|
||||
async fn fetch_token(client: &reqwest::Client) -> Result<BingToken, String> {
|
||||
let html = client
|
||||
.get(TRANSLATOR_URL)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("必应翻译页请求失败: {e}"))?
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("必应翻译页读取失败: {e}"))?;
|
||||
|
||||
parse_token(&html).ok_or_else(|| "必应翻译令牌解析失败(端点可能已变更)".to_string())
|
||||
}
|
||||
|
||||
/// 从翻译页 HTML 解析 IG / IID / key / token。
|
||||
fn parse_token(html: &str) -> Option<BingToken> {
|
||||
let ig = extract_between(html, "IG:\"", "\"")?;
|
||||
let iid = extract_between(html, "data-iid=\"", "\"")?;
|
||||
|
||||
// params_AbusePreventionHelper = [key,"token",time];
|
||||
let helper = extract_between(html, "params_AbusePreventionHelper = [", "]")?;
|
||||
let mut parts = helper.split(',');
|
||||
let key = parts.next()?.trim().to_string();
|
||||
let token = parts.next()?.trim().trim_matches('"').to_string();
|
||||
|
||||
if key.is_empty() || token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(BingToken {
|
||||
ig,
|
||||
iid,
|
||||
key,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取 `start` 与其后第一个 `end` 之间的子串。
|
||||
fn extract_between(haystack: &str, start: &str, end: &str) -> Option<String> {
|
||||
let begin = haystack.find(start)? + start.len();
|
||||
let rest = &haystack[begin..];
|
||||
let stop = rest.find(end)?;
|
||||
Some(rest[..stop].to_string())
|
||||
}
|
||||
|
||||
/// 解析必应响应:结构为 `[{"translations":[{"text":"译文"}], ...}]`。
|
||||
fn parse_response(body: &Value) -> Option<String> {
|
||||
body.get(0)?
|
||||
.get("translations")?
|
||||
.get(0)?
|
||||
.get("text")?
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn maps_internal_lang_codes() {
|
||||
assert_eq!(map_lang("auto"), "auto-detect");
|
||||
assert_eq!(map_lang("zh-CN"), "zh-Hans");
|
||||
assert_eq!(map_lang("zh-TW"), "zh-Hant");
|
||||
assert_eq!(map_lang("en"), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_substring_between_markers() {
|
||||
assert_eq!(
|
||||
extract_between("a IG:\"ABC123\" b", "IG:\"", "\""),
|
||||
Some("ABC123".to_string())
|
||||
);
|
||||
assert_eq!(extract_between("no marker", "IG:\"", "\""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_token_fields() {
|
||||
let html = r#"<html>var IG:"FF00";<div data-iid="translator.5028"></div>
|
||||
<script>params_AbusePreventionHelper = [167060,"abcdef",3600000];</script></html>"#;
|
||||
let token = parse_token(html).expect("token");
|
||||
assert_eq!(token.ig, "FF00");
|
||||
assert_eq!(token.iid, "translator.5028");
|
||||
assert_eq!(token.key, "167060");
|
||||
assert_eq!(token.token, "abcdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_token_missing() {
|
||||
assert!(parse_token("<html>nothing here</html>").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_translation_text() {
|
||||
let body = json!([{
|
||||
"detectedLanguage": { "language": "en", "score": 1.0 },
|
||||
"translations": [{ "text": "你好", "to": "zh-Hans" }]
|
||||
}]);
|
||||
assert_eq!(parse_response(&body), Some("你好".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_on_malformed_response() {
|
||||
assert_eq!(parse_response(&json!({ "error": "bad" })), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! 谷歌翻译 —— 免费网页端接口(translate.googleapis.com)。
|
||||
//!
|
||||
//! 注意:这是非官方端点,可能被限流。设置项预留官方 Cloud API key 入口。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::shared_client;
|
||||
|
||||
const ENDPOINT: &str = "https://translate.googleapis.com/translate_a/single";
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 调用谷歌免费端接口,返回完整译文。
|
||||
pub async fn translate(
|
||||
text: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Result<String, String> {
|
||||
let url = format!(
|
||||
"{ENDPOINT}?client=gtx&sl={sl}&tl={tl}&dt=t&q={q}",
|
||||
sl = urlencoding::encode(source),
|
||||
tl = urlencoding::encode(target),
|
||||
q = urlencoding::encode(text),
|
||||
);
|
||||
|
||||
let resp = shared_client(REQUEST_TIMEOUT, false)
|
||||
.get(&url)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("谷歌翻译请求失败: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("谷歌翻译返回状态 {}", resp.status()));
|
||||
}
|
||||
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("谷歌翻译响应解析失败: {e}"))?;
|
||||
|
||||
Ok(parse_response(&body))
|
||||
}
|
||||
|
||||
/// 解析谷歌响应:结构为 `[[[译文, 原文, ...], ...], ...]`。
|
||||
/// 拼接第一段数组中所有片段的译文。
|
||||
fn parse_response(body: &Value) -> String {
|
||||
let mut out = String::new();
|
||||
if let Some(segments) = body.get(0).and_then(|v| v.as_array()) {
|
||||
for seg in segments {
|
||||
if let Some(t) = seg.get(0).and_then(|v| v.as_str()) {
|
||||
out.push_str(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parses_single_segment() {
|
||||
let body = json!([[["你好", "hello", null, null, 10]], null, "en"]);
|
||||
assert_eq!(parse_response(&body), "你好");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concatenates_multiple_segments() {
|
||||
let body = json!([
|
||||
[["你好", "hello"], ["世界", "world"]],
|
||||
null,
|
||||
"en"
|
||||
]);
|
||||
assert_eq!(parse_response(&body), "你好世界");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_empty_on_malformed() {
|
||||
let body = json!({ "error": "bad" });
|
||||
assert_eq!(parse_response(&body), "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 翻译引擎:统一分发 + 各引擎实现。
|
||||
|
||||
pub mod bing;
|
||||
pub mod google;
|
||||
pub mod openai;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 复用连接池的 HTTP 客户端。
|
||||
///
|
||||
/// `cookie_store=true` 时返回一个独立实例(带 cookie jar),否则返回默认实例。
|
||||
/// 相同参数多次调用复用同一客户端,避免反复握手建连。
|
||||
pub(crate) fn shared_client(timeout: Duration, cookie_store: bool) -> Client {
|
||||
static DEFAULT: OnceLock<Client> = OnceLock::new();
|
||||
static COOKIE: OnceLock<Client> = OnceLock::new();
|
||||
|
||||
let cell = if cookie_store { &COOKIE } else { &DEFAULT };
|
||||
cell.get_or_init(|| {
|
||||
Client::builder()
|
||||
.timeout(timeout)
|
||||
.cookie_store(cookie_store)
|
||||
.build()
|
||||
.expect("构建 HTTP 客户端失败")
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 翻译引擎类型。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Engine {
|
||||
Openai,
|
||||
Google,
|
||||
Bing,
|
||||
}
|
||||
|
||||
/// 翻译请求参数。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TranslateRequest {
|
||||
pub text: String,
|
||||
pub engine: Engine,
|
||||
/// 源语言代码,"auto" 表示自动检测。
|
||||
pub source: String,
|
||||
/// 目标语言代码。
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// AI 对话消息。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// 流式 chunk 事件载荷(emit 到前端)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TranslateChunk {
|
||||
/// 本次增量文本。
|
||||
pub delta: String,
|
||||
/// 是否结束。
|
||||
pub done: bool,
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! OpenAI 兼容流式能力:翻译流 + 普通 AI 对话流。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::{json, Value};
|
||||
use tauri::{AppHandle, Emitter, Runtime};
|
||||
|
||||
use crate::config::{ChatAiConfig, OpenAiConfig};
|
||||
use crate::translate::{shared_client, ChatMessage, TranslateChunk};
|
||||
|
||||
/// 流式翻译事件名。
|
||||
pub const CHUNK_EVENT: &str = "translate-chunk";
|
||||
/// 流式 AI 对话事件名。
|
||||
pub const CHAT_CHUNK_EVENT: &str = "chat-chunk";
|
||||
|
||||
/// 单次请求的整体超时(含流式),避免网络挂起永久占用 loading 态。
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
fn render_translate_prompt(cfg: &OpenAiConfig, source: &str, target: &str) -> String {
|
||||
let src = if source == "auto" {
|
||||
"自动检测的源语言".to_string()
|
||||
} else {
|
||||
source.to_string()
|
||||
};
|
||||
cfg.translate_prompt
|
||||
.replace("{source}", &src)
|
||||
.replace("{target}", target)
|
||||
}
|
||||
|
||||
fn build_translate_messages(cfg: &OpenAiConfig, text: &str, source: &str, target: &str) -> Vec<Value> {
|
||||
let prompt = render_translate_prompt(cfg, source, target);
|
||||
vec![
|
||||
json!({ "role": "system", "content": prompt }),
|
||||
json!({
|
||||
"role": "user",
|
||||
"content": format!(
|
||||
"请严格执行以下翻译提示词:\n{prompt}\n\n待处理文本:\n{text}"
|
||||
)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/// 调用 OpenAI 兼容接口做流式翻译,每个 delta 通过事件推送前端。
|
||||
pub async fn translate_stream<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
cfg: &OpenAiConfig,
|
||||
text: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
) -> Result<(), String> {
|
||||
let messages = build_translate_messages(cfg, text, source, target);
|
||||
stream_messages(app, &cfg.base_url, &cfg.api_key, &cfg.model, messages, CHUNK_EVENT).await
|
||||
}
|
||||
|
||||
/// 普通 AI 对话:使用独立 AI 对话配置。若配置了自定义提示词则作为 system 消息前置。
|
||||
pub async fn chat_stream<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
cfg: &ChatAiConfig,
|
||||
messages: &[ChatMessage],
|
||||
) -> Result<(), String> {
|
||||
let mut payload: Vec<Value> = Vec::with_capacity(messages.len() + 1);
|
||||
if !cfg.chat_prompt.trim().is_empty() {
|
||||
payload.push(json!({ "role": "system", "content": cfg.chat_prompt }));
|
||||
}
|
||||
payload.extend(
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| json!({ "role": m.role, "content": m.content })),
|
||||
);
|
||||
stream_messages(
|
||||
app,
|
||||
&cfg.base_url,
|
||||
&cfg.api_key,
|
||||
&cfg.model,
|
||||
payload,
|
||||
CHAT_CHUNK_EVENT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stream_messages<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
messages: Vec<Value>,
|
||||
event_name: &str,
|
||||
) -> Result<(), String> {
|
||||
if api_key.is_empty() {
|
||||
return Err("未配置 API Key".to_string());
|
||||
}
|
||||
|
||||
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
"stream": true,
|
||||
"messages": messages,
|
||||
});
|
||||
|
||||
let resp = shared_client(REQUEST_TIMEOUT, false)
|
||||
.post(&url)
|
||||
.bearer_auth(api_key)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("OpenAI 请求失败: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("OpenAI 返回状态 {status}: {body}"));
|
||||
}
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
// 以原始字节缓冲,按 '\n' 切出完整行后再解码,避免多字节字符(中文)
|
||||
// 跨 chunk 被截断成 U+FFFD。
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let bytes = chunk.map_err(|e| format!("读取流失败: {e}"))?;
|
||||
buffer.extend_from_slice(&bytes);
|
||||
|
||||
while let Some(pos) = memchr::memchr(b'\n', &buffer) {
|
||||
// 先把这一行(含换行符)移出 buffer,再解码,规避借用冲突。
|
||||
let line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
|
||||
|
||||
let Ok(line) = std::str::from_utf8(&line_bytes) else {
|
||||
continue;
|
||||
};
|
||||
let line = line.trim();
|
||||
|
||||
if let Some(delta) = parse_sse_line(line) {
|
||||
match delta {
|
||||
SseDelta::Content(t) => {
|
||||
let _ = app.emit(
|
||||
event_name,
|
||||
TranslateChunk { delta: t, done: false },
|
||||
);
|
||||
}
|
||||
SseDelta::Done => {
|
||||
let _ = app.emit(
|
||||
event_name,
|
||||
TranslateChunk { delta: String::new(), done: true },
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
event_name,
|
||||
TranslateChunk { delta: String::new(), done: true },
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 单行 SSE 解析结果。
|
||||
enum SseDelta {
|
||||
Content(String),
|
||||
Done,
|
||||
}
|
||||
|
||||
/// 解析一行 SSE:`data: {...}` 或 `data: [DONE]`。
|
||||
fn parse_sse_line(line: &str) -> Option<SseDelta> {
|
||||
let data = line.strip_prefix("data:")?.trim();
|
||||
if data == "[DONE]" {
|
||||
return Some(SseDelta::Done);
|
||||
}
|
||||
let v: Value = serde_json::from_str(data).ok()?;
|
||||
let content = v
|
||||
.get("choices")?
|
||||
.get(0)?
|
||||
.get("delta")?
|
||||
.get("content")?
|
||||
.as_str()?;
|
||||
Some(SseDelta::Content(content.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_content_delta() {
|
||||
let line = r#"data: {"choices":[{"delta":{"content":"你好"}}]}"#;
|
||||
match parse_sse_line(line) {
|
||||
Some(SseDelta::Content(t)) => assert_eq!(t, "你好"),
|
||||
_ => panic!("expected content"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_done() {
|
||||
assert!(matches!(parse_sse_line("data: [DONE]"), Some(SseDelta::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_data_lines() {
|
||||
assert!(parse_sse_line(": keep-alive").is_none());
|
||||
assert!(parse_sse_line("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_delta_without_content() {
|
||||
let line = r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#;
|
||||
assert!(parse_sse_line(line).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_translate_prompt_placeholders() {
|
||||
let cfg = OpenAiConfig {
|
||||
translate_prompt: "from {source} to {target}".to_string(),
|
||||
..OpenAiConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
render_translate_prompt(&cfg, "auto", "zh-CN"),
|
||||
"from 自动检测的源语言 to zh-CN"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_custom_translate_prompt_in_system_and_user_messages() {
|
||||
let cfg = OpenAiConfig {
|
||||
translate_prompt: "CUSTOM {source} => {target}".to_string(),
|
||||
..OpenAiConfig::default()
|
||||
};
|
||||
let messages = build_translate_messages(&cfg, "hello", "en", "zh-CN");
|
||||
assert_eq!(messages[0]["role"], "system");
|
||||
assert_eq!(messages[0]["content"], "CUSTOM en => zh-CN");
|
||||
assert!(messages[1]["content"].as_str().unwrap().contains("CUSTOM en => zh-CN"));
|
||||
assert!(messages[1]["content"].as_str().unwrap().contains("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepends_chat_prompt_as_system_message() {
|
||||
let prompt = "你是一个友好的助手";
|
||||
let history = [ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: "你好".to_string(),
|
||||
}];
|
||||
|
||||
let mut payload: Vec<Value> = Vec::new();
|
||||
if !prompt.trim().is_empty() {
|
||||
payload.push(json!({ "role": "system", "content": prompt }));
|
||||
}
|
||||
payload.extend(
|
||||
history
|
||||
.iter()
|
||||
.map(|m| json!({ "role": m.role, "content": m.content })),
|
||||
);
|
||||
|
||||
assert_eq!(payload.len(), 2);
|
||||
assert_eq!(payload[0]["role"], "system");
|
||||
assert_eq!(payload[0]["content"], prompt);
|
||||
assert_eq!(payload[1]["role"], "user");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! 系统托盘:图标 + 菜单(翻译 / 设置 / 退出)。
|
||||
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
AppHandle, Emitter, Runtime,
|
||||
};
|
||||
|
||||
use crate::window;
|
||||
|
||||
/// 在 setup 中构建系统托盘。
|
||||
pub fn setup_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
|
||||
let open = MenuItem::with_id(app, "open", "翻译", true, None::<&str>)?;
|
||||
let settings = MenuItem::with_id(app, "settings", "设置…", true, None::<&str>)?;
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "退出 ttree", true, None::<&str>)?;
|
||||
|
||||
let menu = Menu::with_items(app, &[&open, &settings, &sep, &quit])?;
|
||||
|
||||
let icon = app
|
||||
.default_window_icon()
|
||||
.ok_or_else(|| tauri::Error::AssetNotFound("window icon".into()))?;
|
||||
|
||||
TrayIconBuilder::with_id("main-tray")
|
||||
.icon(icon.clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => {
|
||||
if let Some(w) = window::main_window(app) {
|
||||
window::show_window(&w);
|
||||
}
|
||||
}
|
||||
"settings" => {
|
||||
if let Some(w) = window::main_window(app) {
|
||||
window::show_window(&w);
|
||||
}
|
||||
let _ = app.emit("open-settings", ());
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! 窗口管理:显示/隐藏/切换,以及失焦自动隐藏。
|
||||
//!
|
||||
//! 关键策略:窗口**隐藏而非销毁**,保证快捷键秒级呼出。
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
|
||||
pub const MAIN_WINDOW: &str = "main";
|
||||
|
||||
static IS_PINNED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 获取主窗口句柄。
|
||||
pub fn main_window<R: tauri::Runtime>(app: &AppHandle<R>) -> Option<WebviewWindow<R>> {
|
||||
app.get_webview_window(MAIN_WINDOW)
|
||||
}
|
||||
|
||||
/// 显示并聚焦窗口(居中)。
|
||||
pub fn show_window<R: tauri::Runtime>(window: &WebviewWindow<R>) {
|
||||
let _ = window.center();
|
||||
let _ = window.set_always_on_top(is_pinned());
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
/// 显示并聚焦主窗口。
|
||||
pub fn show_main<R: tauri::Runtime>(app: &AppHandle<R>) {
|
||||
if let Some(window) = main_window(app) {
|
||||
show_window(&window);
|
||||
}
|
||||
}
|
||||
|
||||
/// 主窗口当前是否拥有焦点。
|
||||
#[allow(dead_code)]
|
||||
pub fn is_main_focused<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
|
||||
main_window(app)
|
||||
.and_then(|window| window.is_focused().ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 隐藏主窗口。
|
||||
pub fn hide_window<R: tauri::Runtime>(window: &WebviewWindow<R>) {
|
||||
let _ = window.hide();
|
||||
}
|
||||
|
||||
/// 当前是否处于固定窗口模式。
|
||||
pub fn is_pinned() -> bool {
|
||||
IS_PINNED.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 设置固定窗口模式。固定时窗口置顶且失焦不自动隐藏。
|
||||
pub fn set_pinned<R: tauri::Runtime>(window: &WebviewWindow<R>, pinned: bool) -> tauri::Result<bool> {
|
||||
IS_PINNED.store(pinned, Ordering::Relaxed);
|
||||
window.set_always_on_top(pinned)?;
|
||||
if pinned {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
Ok(pinned)
|
||||
}
|
||||
|
||||
/// 切换主窗口可见性。返回 true 表示切换后窗口为显示状态。
|
||||
pub fn toggle_window<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
|
||||
let Some(window) = main_window(app) else {
|
||||
return false;
|
||||
};
|
||||
match window.is_visible() {
|
||||
Ok(true) => {
|
||||
hide_window(&window);
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
show_window(&window);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ttree",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.samwstu.ttree",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"macOSPrivateApi": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "ttree",
|
||||
"width": 680,
|
||||
"height": 460,
|
||||
"minWidth": 480,
|
||||
"minHeight": 300,
|
||||
"resizable": true,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"alwaysOnTop": false,
|
||||
"visible": false,
|
||||
"skipTaskbar": true,
|
||||
"center": true,
|
||||
"shadow": true,
|
||||
"titleBarStyle": "Overlay"
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "Productivity",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import TranslatePanel from "./lib/components/TranslatePanel.svelte";
|
||||
import ChatPanel from "./lib/views/ChatPanel.svelte";
|
||||
import Settings from "./lib/views/Settings.svelte";
|
||||
import {
|
||||
onOpenSettings,
|
||||
onSelectedTranslate,
|
||||
onTriggerAiDialog,
|
||||
onTriggerOcr,
|
||||
onTriggerTranslate,
|
||||
} from "./lib/api/tauri";
|
||||
import type { MainMode } from "./lib/components/EngineTabs.svelte";
|
||||
import { chatStore } from "./lib/stores/chat.svelte";
|
||||
import { translation } from "./lib/stores/translation.svelte";
|
||||
|
||||
let mode = $state<MainMode>("translate");
|
||||
let view = $state<"main" | "settings">("main");
|
||||
|
||||
onMount(() => {
|
||||
const unlisteners = [
|
||||
onOpenSettings(() => (view = "settings")),
|
||||
onTriggerTranslate(() => {
|
||||
view = "main";
|
||||
mode = "translate";
|
||||
}),
|
||||
onTriggerOcr(() => {
|
||||
view = "main";
|
||||
mode = "translate";
|
||||
void translation.captureAndTranslate();
|
||||
}),
|
||||
onSelectedTranslate(({ text }) => {
|
||||
view = "main";
|
||||
mode = "translate";
|
||||
void translation.translateText(text);
|
||||
}),
|
||||
onTriggerAiDialog(({ text }) => {
|
||||
view = "main";
|
||||
mode = "chat";
|
||||
if (text) {
|
||||
void chatStore.sendText(text);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
unlisteners.forEach((unlisten) => unlisten.then((un) => un()));
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if view === "settings"}
|
||||
<Settings onClose={() => (view = "main")} />
|
||||
{:else if mode === "chat"}
|
||||
<ChatPanel
|
||||
{mode}
|
||||
onModeChange={(nextMode) => (mode = nextMode)}
|
||||
onSettings={() => (view = "settings")}
|
||||
/>
|
||||
{:else}
|
||||
<TranslatePanel
|
||||
{mode}
|
||||
onModeChange={(nextMode) => (mode = nextMode)}
|
||||
onSettings={() => (view = "settings")}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,166 @@
|
||||
/** Tauri 后端调用与事件订阅封装。 */
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
export type Engine = "openai" | "google" | "bing";
|
||||
|
||||
export interface TranslateRequest {
|
||||
text: string;
|
||||
engine: Engine;
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface TranslateChunk {
|
||||
delta: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** 普通 AI 对话:不附加翻译提示词,结果通过 chat-chunk 事件返回。 */
|
||||
export function chat(messages: ChatMessage[]): Promise<void> {
|
||||
return invoke<void>("chat", { messages });
|
||||
}
|
||||
|
||||
/** 订阅 AI 对话 chunk 事件。 */
|
||||
export function onChatChunk(
|
||||
handler: (chunk: TranslateChunk) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<TranslateChunk>("chat-chunk", (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发翻译。
|
||||
* - google:返回完整译文字符串
|
||||
* - openai:返回空串,结果通过 onChunk 流式到达
|
||||
*/
|
||||
export function translate(req: TranslateRequest): Promise<string> {
|
||||
return invoke<string>("translate", { req });
|
||||
}
|
||||
|
||||
/** 订阅流式翻译 chunk 事件。 */
|
||||
export function onChunk(
|
||||
handler: (chunk: TranslateChunk) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<TranslateChunk>("translate-chunk", (e) => handler(e.payload));
|
||||
}
|
||||
|
||||
/** 触发交互式截图 + 系统 OCR,返回识别文本(用户取消时为空串)。 */
|
||||
export function screenshotOcr(): Promise<string> {
|
||||
return invoke<string>("screenshot_ocr");
|
||||
}
|
||||
|
||||
/** 显示并聚焦主窗口。 */
|
||||
export function showMain(): Promise<void> {
|
||||
return invoke<void>("show_main");
|
||||
}
|
||||
|
||||
/** 读取固定窗口状态。 */
|
||||
export function getPinned(): Promise<boolean> {
|
||||
return invoke<boolean>("get_pinned");
|
||||
}
|
||||
|
||||
/** 设置固定窗口状态。 */
|
||||
export function setPinned(pinned: boolean): Promise<boolean> {
|
||||
return invoke<boolean>("set_pinned", { pinned });
|
||||
}
|
||||
|
||||
export interface OpenAiConfig {
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
translate_prompt: string;
|
||||
}
|
||||
|
||||
export interface ChatAiConfig {
|
||||
base_url: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
chat_prompt: string;
|
||||
}
|
||||
|
||||
export interface ShortcutConfig {
|
||||
toggle: string;
|
||||
ocr: string;
|
||||
ai_dialog: string;
|
||||
selection_translate: string;
|
||||
selection_ai_dialog: string;
|
||||
}
|
||||
|
||||
export interface SelectedTranslatePayload {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AiDialogPayload {
|
||||
text: string | null;
|
||||
}
|
||||
|
||||
/** 读取 OpenAI 配置。 */
|
||||
export function getOpenAiConfig(): Promise<OpenAiConfig> {
|
||||
return invoke<OpenAiConfig>("get_openai_config");
|
||||
}
|
||||
|
||||
/** 保存 OpenAI 配置。 */
|
||||
export function setOpenAiConfig(config: OpenAiConfig): Promise<void> {
|
||||
return invoke<void>("set_openai_config", { config });
|
||||
}
|
||||
|
||||
/** 读取 AI 对话配置。 */
|
||||
export function getChatAiConfig(): Promise<ChatAiConfig> {
|
||||
return invoke<ChatAiConfig>("get_chat_ai_config");
|
||||
}
|
||||
|
||||
/** 保存 AI 对话配置。 */
|
||||
export function setChatAiConfig(config: ChatAiConfig): Promise<void> {
|
||||
return invoke<void>("set_chat_ai_config", { config });
|
||||
}
|
||||
|
||||
/** 读取快捷键配置。 */
|
||||
export function getShortcutConfig(): Promise<ShortcutConfig> {
|
||||
return invoke<ShortcutConfig>("get_shortcut_config");
|
||||
}
|
||||
|
||||
/** 保存快捷键配置并让后端立即重注册。 */
|
||||
export function setShortcutConfig(config: ShortcutConfig): Promise<void> {
|
||||
return invoke<void>("set_shortcut_config", { config });
|
||||
}
|
||||
|
||||
/** 录制快捷键时临时忽略全局快捷键触发。 */
|
||||
export function setShortcutRecording(active: boolean): Promise<void> {
|
||||
return invoke<void>("set_shortcut_recording", { active });
|
||||
}
|
||||
|
||||
/** 订阅托盘"设置"菜单事件。 */
|
||||
export function onOpenSettings(handler: () => void): Promise<UnlistenFn> {
|
||||
return listen("open-settings", () => handler());
|
||||
}
|
||||
|
||||
/** 订阅截图 OCR 触发事件(快捷键按下时由后端发出)。 */
|
||||
export function onTriggerOcr(handler: () => void): Promise<UnlistenFn> {
|
||||
return listen("trigger-ocr", () => handler());
|
||||
}
|
||||
|
||||
/** 订阅呼出翻译页事件(toggle 快捷键显示窗口时由后端发出)。 */
|
||||
export function onTriggerTranslate(handler: () => void): Promise<UnlistenFn> {
|
||||
return listen("trigger-translate", () => handler());
|
||||
}
|
||||
|
||||
/** 订阅划词翻译事件。 */
|
||||
export function onSelectedTranslate(
|
||||
handler: (payload: SelectedTranslatePayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<SelectedTranslatePayload>("trigger-selected-translate", (e) =>
|
||||
handler(e.payload),
|
||||
);
|
||||
}
|
||||
|
||||
/** 订阅 AI 对话呼出事件。 */
|
||||
export function onTriggerAiDialog(
|
||||
handler: (payload: AiDialogPayload) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<AiDialogPayload>("trigger-ai-dialog", (e) => handler(e.payload));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
export type MainMode = "translate" | "chat";
|
||||
|
||||
interface Props {
|
||||
mode: MainMode;
|
||||
onchange: (mode: MainMode) => void;
|
||||
}
|
||||
let { mode, onchange }: Props = $props();
|
||||
|
||||
const tabs: { id: MainMode; label: string }[] = [
|
||||
{ id: "translate", label: "翻译" },
|
||||
{ id: "chat", label: "AI" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="segmented" role="tablist" aria-label="模式切换">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === tab.id}
|
||||
class="segment"
|
||||
class:active={mode === tab.id}
|
||||
onclick={() => onchange(tab.id)}
|
||||
>
|
||||
<span class="state-layer"></span>
|
||||
<span class="label">{tab.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.segmented {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--md-outline, var(--border));
|
||||
border-radius: 999px;
|
||||
background: var(--md-surface-container, var(--surface-sunken));
|
||||
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.05);
|
||||
}
|
||||
.segment {
|
||||
position: relative;
|
||||
min-width: 72px;
|
||||
min-height: 40px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--md-outline, var(--border));
|
||||
background: transparent;
|
||||
color: var(--md-on-surface-variant, var(--text-muted));
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
color var(--duration-fast) var(--ease),
|
||||
background var(--duration-fast) var(--ease);
|
||||
}
|
||||
.segment:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.state-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease);
|
||||
}
|
||||
.label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.segment:hover .state-layer {
|
||||
opacity: 0.08;
|
||||
}
|
||||
.segment:active .state-layer {
|
||||
opacity: 0.12;
|
||||
}
|
||||
.segment.active {
|
||||
background: var(--md-secondary-container, var(--accent-soft));
|
||||
color: var(--md-on-secondary-container, var(--accent));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { LANGUAGES, TARGET_LANGUAGES } from "../stores/languages";
|
||||
|
||||
interface Props {
|
||||
source: string;
|
||||
target: string;
|
||||
onSource: (code: string) => void;
|
||||
onTarget: (code: string) => void;
|
||||
}
|
||||
let { source, target, onSource, onTarget }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="lang-row">
|
||||
<select
|
||||
class="lang"
|
||||
aria-label="源语言"
|
||||
value={source}
|
||||
onchange={(e) => onSource(e.currentTarget.value)}
|
||||
>
|
||||
{#each LANGUAGES as lang (lang.code)}
|
||||
<option value={lang.code}>{lang.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
|
||||
<select
|
||||
class="lang"
|
||||
aria-label="目标语言"
|
||||
value={target}
|
||||
onchange={(e) => onTarget(e.currentTarget.value)}
|
||||
>
|
||||
{#each TARGET_LANGUAGES as lang (lang.code)}
|
||||
<option value={lang.code}>{lang.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.lang-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.lang {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--duration-fast) var(--ease);
|
||||
}
|
||||
.lang:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.arrow {
|
||||
color: var(--text-faint);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
WEB_ENGINE_LABELS,
|
||||
WEB_ENGINES,
|
||||
type WebEngine,
|
||||
} from "../stores/translation.svelte";
|
||||
|
||||
interface Props {
|
||||
webOutput: string;
|
||||
aiOutput: string;
|
||||
webEngine: WebEngine;
|
||||
webLoading: boolean;
|
||||
aiLoading: boolean;
|
||||
webError: string | null;
|
||||
aiError: string | null;
|
||||
error: string | null;
|
||||
onSelectWebEngine: (engine: WebEngine) => void;
|
||||
}
|
||||
let {
|
||||
webOutput,
|
||||
aiOutput,
|
||||
webEngine,
|
||||
webLoading,
|
||||
aiLoading,
|
||||
webError,
|
||||
aiError,
|
||||
error,
|
||||
onSelectWebEngine,
|
||||
}: Props = $props();
|
||||
|
||||
let copied = $state<"web" | "ai" | null>(null);
|
||||
let engineMenuOpen = $state(false);
|
||||
|
||||
async function copy(kind: "web" | "ai", text: string) {
|
||||
if (!text) return;
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = kind;
|
||||
setTimeout(() => (copied = null), 1400);
|
||||
}
|
||||
|
||||
function selectEngine(engine: WebEngine) {
|
||||
engineMenuOpen = false;
|
||||
if (engine !== webEngine) {
|
||||
onSelectWebEngine(engine);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={() => (engineMenuOpen = false)} />
|
||||
|
||||
<div class="result-grid">
|
||||
{#if error}
|
||||
<p class="error global-error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<section class="column" aria-label="网页翻译结果">
|
||||
<header class="column-head">
|
||||
<div class="engine-select">
|
||||
<button
|
||||
class="engine-trigger"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
engineMenuOpen = !engineMenuOpen;
|
||||
}}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={engineMenuOpen}
|
||||
aria-label={`翻译源:${WEB_ENGINE_LABELS[webEngine]},点击切换`}
|
||||
>
|
||||
{WEB_ENGINE_LABELS[webEngine]}
|
||||
<svg class="caret-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 10l5 5 5-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if engineMenuOpen}
|
||||
<ul class="engine-menu" role="listbox">
|
||||
{#each WEB_ENGINES as engine (engine)}
|
||||
<li>
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={engine === webEngine}
|
||||
class="engine-option"
|
||||
class:active={engine === webEngine}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectEngine(engine);
|
||||
}}
|
||||
>
|
||||
{WEB_ENGINE_LABELS[engine]}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{#if webOutput}
|
||||
<button class="copy" onclick={() => copy("web", webOutput)} aria-label="复制翻译结果">
|
||||
{copied === "web" ? "已复制" : "复制"}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if webError}
|
||||
<p class="error">{webError}</p>
|
||||
{:else if webOutput}
|
||||
<p class="output">{webOutput}</p>
|
||||
{:else if webLoading}
|
||||
<p class="placeholder">翻译中…</p>
|
||||
{:else}
|
||||
<p class="placeholder">译文将显示在这里</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="column ai-column" aria-label="AI 翻译结果">
|
||||
<header class="column-head">
|
||||
<span>AI</span>
|
||||
{#if aiOutput}
|
||||
<button class="copy" onclick={() => copy("ai", aiOutput)} aria-label="复制 AI 翻译结果">
|
||||
{copied === "ai" ? "已复制" : "复制"}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if aiError}
|
||||
<p class="error">{aiError}</p>
|
||||
{:else if aiOutput}
|
||||
<p class="output">{aiOutput}{#if aiLoading}<span class="caret"></span>{/if}</p>
|
||||
{:else if aiLoading}
|
||||
<p class="placeholder">AI 翻译中…</p>
|
||||
{:else}
|
||||
<p class="placeholder">AI 译文将显示在这里</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
min-height: 100%;
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
.global-error {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.column {
|
||||
min-width: 0;
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
.ai-column {
|
||||
padding-left: var(--space-4);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.column-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-faint);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.engine-select {
|
||||
position: relative;
|
||||
}
|
||||
.engine-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 4px 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--duration-fast) var(--ease);
|
||||
}
|
||||
.engine-trigger:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.caret-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
fill: currentColor;
|
||||
}
|
||||
.engine-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
min-width: 120px;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 8px 24px oklch(0% 0 0 / 0.18);
|
||||
}
|
||||
.engine-option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--duration-fast) var(--ease),
|
||||
background var(--duration-fast) var(--ease);
|
||||
}
|
||||
.engine-option:hover {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
.engine-option.active {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.output {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.placeholder {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--danger);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--danger-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.1em;
|
||||
margin-left: 1px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--accent);
|
||||
animation: blink 1s steps(2, start) infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.copy {
|
||||
border: none;
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--duration-fast) var(--ease),
|
||||
background var(--duration-fast) var(--ease);
|
||||
}
|
||||
.copy:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.result-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.ai-column {
|
||||
padding-left: 0;
|
||||
padding-top: var(--space-3);
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { onMount } from "svelte";
|
||||
import { translation } from "../stores/translation.svelte";
|
||||
import { getPinned, setPinned } from "../api/tauri";
|
||||
import EngineTabs, { type MainMode } from "./EngineTabs.svelte";
|
||||
import LangSelector from "./LangSelector.svelte";
|
||||
import ResultView from "./ResultView.svelte";
|
||||
|
||||
const MIN_SOURCE_HEIGHT = 88;
|
||||
const MIN_RESULT_HEIGHT = 120;
|
||||
const AUTO_TRANSLATE_DELAY_MS = 1000;
|
||||
|
||||
const t = translation;
|
||||
let panel: HTMLElement | undefined = $state();
|
||||
let textarea: HTMLTextAreaElement | undefined = $state();
|
||||
let sourceHeight = $state(210);
|
||||
let isDraggingSplit = $state(false);
|
||||
let isPinned = $state(false);
|
||||
|
||||
interface Props {
|
||||
mode: MainMode;
|
||||
onModeChange: (mode: MainMode) => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
let { mode, onModeChange, onSettings }: Props = $props();
|
||||
|
||||
// 呼出后自动聚焦输入框
|
||||
$effect(() => {
|
||||
textarea?.focus();
|
||||
});
|
||||
|
||||
// 输入有文字并静止 1 秒后自动翻译;继续输入会重置计时器。
|
||||
$effect(() => {
|
||||
const text = t.state.input.trim();
|
||||
if (!t.shouldAutoRun(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (t.shouldAutoRun(text) && t.state.input.trim() === text) {
|
||||
void t.run();
|
||||
}
|
||||
}, AUTO_TRANSLATE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
});
|
||||
|
||||
// 初始化固定状态
|
||||
onMount(() => {
|
||||
getPinned().then((pinned) => (isPinned = pinned));
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// Enter 翻译;Cmd/Shift+Enter 换行
|
||||
if (e.key === "Enter" && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
t.run();
|
||||
}
|
||||
// Esc 隐藏窗口(用户显式关闭即使固定也允许)
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
getCurrentWindow().hide();
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePinned() {
|
||||
isPinned = await setPinned(!isPinned);
|
||||
}
|
||||
|
||||
function maxSourceHeight(): number {
|
||||
const height = panel?.clientHeight ?? 460;
|
||||
// 预留 toolbar、语言栏、间距和译文最小高度。
|
||||
return Math.max(MIN_SOURCE_HEIGHT, height - MIN_RESULT_HEIGHT - 128);
|
||||
}
|
||||
|
||||
function clampSourceHeight(nextHeight: number): number {
|
||||
return Math.min(Math.max(nextHeight, MIN_SOURCE_HEIGHT), maxSourceHeight());
|
||||
}
|
||||
|
||||
function startSplitResize(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = sourceHeight;
|
||||
isDraggingSplit = true;
|
||||
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
sourceHeight = clampSourceHeight(startHeight + moveEvent.clientY - startY);
|
||||
};
|
||||
const onUp = () => {
|
||||
isDraggingSplit = false;
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onUp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="panel" bind:this={panel} class:dragging={isDraggingSplit}>
|
||||
<!-- 顶部工具栏:可拖拽,只放引擎与设置 -->
|
||||
<header class="toolbar" data-tauri-drag-region>
|
||||
<EngineTabs
|
||||
{mode}
|
||||
onchange={onModeChange}
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<button
|
||||
class="md-icon-button pin"
|
||||
class:active={isPinned}
|
||||
onclick={togglePinned}
|
||||
aria-pressed={isPinned}
|
||||
aria-label={isPinned ? "取消固定窗口" : "固定窗口"}
|
||||
title={isPinned ? "取消固定窗口" : "固定窗口"}
|
||||
>
|
||||
<span class="state-layer"></span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14 4.5c0-.83.67-1.5 1.5-1.5S17 3.67 17 4.5v6.15l2.27 2.27c.47.47.14 1.28-.53 1.28H13v5.55c0 .41-.34.75-.75.75s-.75-.34-.75-.75V14.2H5.76c-.67 0-1-.81-.53-1.28L7.5 10.65V4.5C7.5 3.67 8.17 3 9 3s1.5.67 1.5 1.5v5.55c0 .2-.08.39-.22.53L8.36 12.5h7.28l-1.92-1.92a.75.75 0 0 1-.22-.53V4.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="md-icon-button gear" onclick={onSettings} aria-label="设置" title="设置">
|
||||
<span class="state-layer"></span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M19.43 12.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.6-.22l-2.49 1a7.3 7.3 0 0 0-1.7-.98L14.5 2.42A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.42L9.12 5.07c-.61.24-1.18.56-1.7.98l-2.49-1a.5.5 0 0 0-.6.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.06.65-.06.98s.02.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46c.13.22.39.31.6.22l2.49-1c.52.4 1.09.73 1.7.98l.38 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.38-2.65c.61-.24 1.18-.56 1.7-.98l2.49 1c.22.09.48 0 .6-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.13-1.65ZM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 原文区:拖动中间分割条调整高度 -->
|
||||
<section
|
||||
class="pane source-pane"
|
||||
aria-label="原文"
|
||||
style:height={`${sourceHeight}px`}
|
||||
>
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
bind:value={t.state.input}
|
||||
onkeydown={onKeydown}
|
||||
class="input"
|
||||
placeholder="输入要翻译的文本,回车翻译"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- 语言选择:置于上下两条线中间,左对齐;上方隐形命中区可拖动调高度 -->
|
||||
<div class="language-strip" class:active={isDraggingSplit}>
|
||||
<button
|
||||
class="resize-handle"
|
||||
aria-label="调整原文和译文高度"
|
||||
onpointerdown={startSplitResize}
|
||||
></button>
|
||||
<LangSelector
|
||||
source={t.state.source}
|
||||
target={t.state.target}
|
||||
onSource={(c) => (t.state.source = c)}
|
||||
onTarget={(c) => (t.state.target = c)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 译文区:随原文高度变化自动获得剩余空间 -->
|
||||
<section class="pane result-pane" aria-label="译文">
|
||||
<ResultView
|
||||
webOutput={t.state.webOutput}
|
||||
aiOutput={t.state.aiOutput}
|
||||
webEngine={t.state.webEngine}
|
||||
webLoading={t.state.webLoading}
|
||||
aiLoading={t.state.aiLoading}
|
||||
webError={t.state.webError}
|
||||
aiError={t.state.aiError}
|
||||
error={t.state.error}
|
||||
onSelectWebEngine={(engine) => t.setWebEngine(engine)}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-window);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel.dragging {
|
||||
user-select: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-2);
|
||||
}
|
||||
.pane {
|
||||
margin-inline: var(--space-4);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border-radius: var(--radius);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.pane::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.source-pane {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.result-pane {
|
||||
flex: 1 1 auto;
|
||||
min-height: 120px;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
}
|
||||
.input::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.language-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-2) var(--space-4) var(--space-2);
|
||||
padding: var(--space-3) 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.language-strip.active,
|
||||
.language-strip:has(.resize-handle:hover) {
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
inset: -7px 0 auto 0;
|
||||
height: 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--md-surface-container, var(--surface-sunken));
|
||||
}
|
||||
.md-icon-button {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--md-on-surface-variant, var(--text-muted));
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
color var(--duration-fast) var(--ease),
|
||||
background var(--duration-fast) var(--ease),
|
||||
box-shadow var(--duration-fast) var(--ease),
|
||||
transform var(--duration-fast) var(--ease);
|
||||
}
|
||||
.md-icon-button svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
.md-icon-button .state-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease);
|
||||
}
|
||||
.md-icon-button:hover .state-layer {
|
||||
opacity: 0.08;
|
||||
}
|
||||
.md-icon-button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.md-icon-button:active .state-layer {
|
||||
opacity: 0.12;
|
||||
}
|
||||
.md-icon-button.pin.active {
|
||||
background: var(--md-secondary-container, var(--accent-soft));
|
||||
color: var(--md-on-secondary-container, var(--accent));
|
||||
box-shadow: inset 0 0 0 1px oklch(70% 0.16 266 / 0.14);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
applyShortcutRecord,
|
||||
clearShortcutField,
|
||||
recordShortcutFromEvent,
|
||||
type ShortcutDraft,
|
||||
type ShortcutKeyboardEvent,
|
||||
} from "./shortcutRecorder";
|
||||
|
||||
function keyboardEvent(
|
||||
overrides: Partial<ShortcutKeyboardEvent>,
|
||||
): ShortcutKeyboardEvent {
|
||||
return {
|
||||
key: "s",
|
||||
code: "KeyS",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
repeat: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("recordShortcutFromEvent", () => {
|
||||
test("records command shift space in the backend shortcut format", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: " ",
|
||||
code: "Space",
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "recorded",
|
||||
shortcut: "CmdOrCtrl+Shift+Space",
|
||||
});
|
||||
});
|
||||
|
||||
test("records option space", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: " ",
|
||||
code: "Space",
|
||||
altKey: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "recorded", shortcut: "Option+Space" });
|
||||
});
|
||||
|
||||
test("normalizes letter keys from the physical keyboard code", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: "s",
|
||||
code: "KeyS",
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "recorded", shortcut: "CmdOrCtrl+Shift+S" });
|
||||
});
|
||||
|
||||
test("outputs modifiers in a stable order", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: "k",
|
||||
code: "KeyK",
|
||||
shiftKey: true,
|
||||
altKey: true,
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "recorded",
|
||||
shortcut: "CmdOrCtrl+Control+Option+Shift+K",
|
||||
});
|
||||
});
|
||||
|
||||
test("records function keys", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({ key: "F1", code: "F1", metaKey: true }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "recorded", shortcut: "CmdOrCtrl+F1" });
|
||||
});
|
||||
|
||||
test("records arrow keys", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({ key: "ArrowUp", code: "ArrowUp", metaKey: true }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "recorded",
|
||||
shortcut: "CmdOrCtrl+ArrowUp",
|
||||
});
|
||||
});
|
||||
|
||||
test("records named editing keys", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({ key: "Enter", code: "Enter", ctrlKey: true }),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "recorded", shortcut: "Control+Enter" });
|
||||
});
|
||||
|
||||
test("keeps waiting when the user only presses a modifier key", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: "Shift",
|
||||
code: "ShiftLeft",
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "pending" });
|
||||
});
|
||||
|
||||
test("rejects shift-only shortcuts", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: "s",
|
||||
code: "KeyS",
|
||||
shiftKey: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("invalid");
|
||||
if (result.status === "invalid") {
|
||||
expect(result.message).toContain("Cmd/Ctrl/Option");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects main keys without a modifier", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({ key: "s", code: "KeyS" }),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("invalid");
|
||||
if (result.status === "invalid") {
|
||||
expect(result.message).toContain("修饰键");
|
||||
}
|
||||
});
|
||||
|
||||
test("cancels recording with Escape", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: "Escape",
|
||||
code: "Escape",
|
||||
metaKey: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "cancelled" });
|
||||
});
|
||||
|
||||
test("ignores repeated keydown events", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: "s",
|
||||
code: "KeyS",
|
||||
metaKey: true,
|
||||
repeat: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "pending" });
|
||||
});
|
||||
|
||||
test("rejects unsupported physical keys", () => {
|
||||
const result = recordShortcutFromEvent(
|
||||
keyboardEvent({
|
||||
key: "¥",
|
||||
code: "IntlYen",
|
||||
metaKey: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe("invalid");
|
||||
if (result.status === "invalid") {
|
||||
expect(result.message).toContain("不支持");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyShortcutRecord", () => {
|
||||
const draft: ShortcutDraft = {
|
||||
toggle: "CmdOrCtrl+Shift+Space",
|
||||
ocr: "CmdOrCtrl+Shift+S",
|
||||
ai_dialog: "",
|
||||
selection_translate: "",
|
||||
selection_ai_dialog: "",
|
||||
};
|
||||
|
||||
test("updates only the selected shortcut field", () => {
|
||||
const updated = applyShortcutRecord(draft, "ocr", {
|
||||
status: "recorded",
|
||||
shortcut: "Option+Space",
|
||||
});
|
||||
|
||||
expect(updated).toEqual({
|
||||
toggle: "CmdOrCtrl+Shift+Space",
|
||||
ocr: "Option+Space",
|
||||
ai_dialog: "",
|
||||
selection_translate: "",
|
||||
selection_ai_dialog: "",
|
||||
});
|
||||
expect(draft).toEqual({
|
||||
toggle: "CmdOrCtrl+Shift+Space",
|
||||
ocr: "CmdOrCtrl+Shift+S",
|
||||
ai_dialog: "",
|
||||
selection_translate: "",
|
||||
selection_ai_dialog: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("updates only the AI dialog shortcut field", () => {
|
||||
const updated = applyShortcutRecord(draft, "ai_dialog", {
|
||||
status: "recorded",
|
||||
shortcut: "CmdOrCtrl+Shift+A",
|
||||
});
|
||||
|
||||
expect(updated).toEqual({
|
||||
toggle: "CmdOrCtrl+Shift+Space",
|
||||
ocr: "CmdOrCtrl+Shift+S",
|
||||
ai_dialog: "CmdOrCtrl+Shift+A",
|
||||
selection_translate: "",
|
||||
selection_ai_dialog: "",
|
||||
});
|
||||
expect(draft.ai_dialog).toBe("");
|
||||
});
|
||||
|
||||
test("leaves the draft unchanged for invalid input", () => {
|
||||
const updated = applyShortcutRecord(draft, "toggle", {
|
||||
status: "invalid",
|
||||
message: "快捷键必须包含至少一个修饰键",
|
||||
});
|
||||
|
||||
expect(updated).toBe(draft);
|
||||
});
|
||||
|
||||
test("leaves the draft unchanged for pending and cancelled input", () => {
|
||||
expect(applyShortcutRecord(draft, "ai_dialog", { status: "pending" })).toBe(
|
||||
draft,
|
||||
);
|
||||
expect(applyShortcutRecord(draft, "ai_dialog", { status: "cancelled" })).toBe(
|
||||
draft,
|
||||
);
|
||||
});
|
||||
|
||||
test("clears only the selected shortcut field", () => {
|
||||
const configured: ShortcutDraft = {
|
||||
...draft,
|
||||
ai_dialog: "CmdOrCtrl+Shift+A",
|
||||
};
|
||||
|
||||
expect(clearShortcutField(configured, "ai_dialog")).toEqual({
|
||||
toggle: "CmdOrCtrl+Shift+Space",
|
||||
ocr: "CmdOrCtrl+Shift+S",
|
||||
ai_dialog: "",
|
||||
selection_translate: "",
|
||||
selection_ai_dialog: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("clears a selection shortcut field independently", () => {
|
||||
const configured: ShortcutDraft = {
|
||||
...draft,
|
||||
selection_translate: "CmdOrCtrl+Shift+T",
|
||||
selection_ai_dialog: "CmdOrCtrl+Shift+D",
|
||||
};
|
||||
|
||||
expect(clearShortcutField(configured, "selection_translate")).toEqual({
|
||||
toggle: "CmdOrCtrl+Shift+Space",
|
||||
ocr: "CmdOrCtrl+Shift+S",
|
||||
ai_dialog: "",
|
||||
selection_translate: "",
|
||||
selection_ai_dialog: "CmdOrCtrl+Shift+D",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
export type ShortcutField =
|
||||
| "toggle"
|
||||
| "ocr"
|
||||
| "ai_dialog"
|
||||
| "selection_translate"
|
||||
| "selection_ai_dialog";
|
||||
|
||||
export interface ShortcutDraft {
|
||||
toggle: string;
|
||||
ocr: string;
|
||||
ai_dialog: string;
|
||||
selection_translate: string;
|
||||
selection_ai_dialog: string;
|
||||
}
|
||||
|
||||
export interface ShortcutKeyboardEvent {
|
||||
key: string;
|
||||
code: string;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
shiftKey: boolean;
|
||||
repeat: boolean;
|
||||
}
|
||||
|
||||
export type ShortcutRecordResult =
|
||||
| { status: "recorded"; shortcut: string }
|
||||
| { status: "invalid"; message: string }
|
||||
| { status: "cancelled" }
|
||||
| { status: "pending" };
|
||||
|
||||
const MODIFIER_KEYS = new Set([
|
||||
"Alt",
|
||||
"AltGraph",
|
||||
"Control",
|
||||
"Meta",
|
||||
"Shift",
|
||||
]);
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
"AltLeft",
|
||||
"AltRight",
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
"MetaLeft",
|
||||
"MetaRight",
|
||||
"ShiftLeft",
|
||||
"ShiftRight",
|
||||
]);
|
||||
|
||||
const NAMED_KEYS = new Map<string, string>([
|
||||
["ArrowDown", "ArrowDown"],
|
||||
["ArrowLeft", "ArrowLeft"],
|
||||
["ArrowRight", "ArrowRight"],
|
||||
["ArrowUp", "ArrowUp"],
|
||||
["Backspace", "Backspace"],
|
||||
["Delete", "Delete"],
|
||||
["End", "End"],
|
||||
["Enter", "Enter"],
|
||||
["Home", "Home"],
|
||||
["Insert", "Insert"],
|
||||
["PageDown", "PageDown"],
|
||||
["PageUp", "PageUp"],
|
||||
["Space", "Space"],
|
||||
["Tab", "Tab"],
|
||||
]);
|
||||
|
||||
const PUNCTUATION_KEYS = new Map<string, string>([
|
||||
["Backquote", "`"],
|
||||
["Backslash", "\\"],
|
||||
["BracketLeft", "["],
|
||||
["BracketRight", "]"],
|
||||
["Comma", ","],
|
||||
["Equal", "="],
|
||||
["Minus", "-"],
|
||||
["Period", "."],
|
||||
["Quote", "'"],
|
||||
["Semicolon", ";"],
|
||||
["Slash", "/"],
|
||||
]);
|
||||
|
||||
export function recordShortcutFromEvent(
|
||||
event: ShortcutKeyboardEvent,
|
||||
): ShortcutRecordResult {
|
||||
if (event.key === "Escape" || event.code === "Escape") {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
|
||||
if (event.repeat || isModifierOnlyEvent(event)) {
|
||||
return { status: "pending" };
|
||||
}
|
||||
|
||||
if (!hasRequiredModifier(event)) {
|
||||
return {
|
||||
status: "invalid",
|
||||
message: "快捷键必须包含 Cmd/Ctrl/Option 中的至少一个修饰键。",
|
||||
};
|
||||
}
|
||||
|
||||
const mainKey = getMainKey(event.code);
|
||||
if (!mainKey) {
|
||||
return {
|
||||
status: "invalid",
|
||||
message: "不支持这个按键,请选择字母、数字、方向键或功能键。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "recorded",
|
||||
shortcut: [...getModifierParts(event), mainKey].join("+"),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyShortcutRecord(
|
||||
draft: ShortcutDraft,
|
||||
field: ShortcutField,
|
||||
result: ShortcutRecordResult,
|
||||
): ShortcutDraft {
|
||||
if (result.status !== "recorded") {
|
||||
return draft;
|
||||
}
|
||||
|
||||
return {
|
||||
...draft,
|
||||
[field]: result.shortcut,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearShortcutField(
|
||||
draft: ShortcutDraft,
|
||||
field: ShortcutField,
|
||||
): ShortcutDraft {
|
||||
return {
|
||||
...draft,
|
||||
[field]: "",
|
||||
};
|
||||
}
|
||||
|
||||
function hasRequiredModifier(event: ShortcutKeyboardEvent): boolean {
|
||||
return event.metaKey || event.ctrlKey || event.altKey;
|
||||
}
|
||||
|
||||
function isModifierOnlyEvent(event: ShortcutKeyboardEvent): boolean {
|
||||
return MODIFIER_KEYS.has(event.key) || MODIFIER_CODES.has(event.code);
|
||||
}
|
||||
|
||||
function getModifierParts(event: ShortcutKeyboardEvent): string[] {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (event.metaKey) {
|
||||
parts.push("CmdOrCtrl");
|
||||
}
|
||||
|
||||
if (event.ctrlKey) {
|
||||
parts.push("Control");
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
parts.push("Option");
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
parts.push("Shift");
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function getMainKey(code: string): string | null {
|
||||
if (/^Key[A-Z]$/.test(code)) {
|
||||
return code.slice(3);
|
||||
}
|
||||
|
||||
if (/^Digit[0-9]$/.test(code)) {
|
||||
return code.slice(5);
|
||||
}
|
||||
|
||||
if (/^F(?:[1-9]|1[0-9]|2[0-4])$/.test(code)) {
|
||||
return code;
|
||||
}
|
||||
|
||||
return NAMED_KEYS.get(code) ?? PUNCTUATION_KEYS.get(code) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** AI 对话状态与编排(Svelte 5 runes)。 */
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
import {
|
||||
chat as apiChat,
|
||||
onChatChunk,
|
||||
type ChatMessage,
|
||||
} from "../api/tauri";
|
||||
|
||||
interface ChatState {
|
||||
messages: ChatMessage[];
|
||||
input: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function createChatStore() {
|
||||
const state = $state<ChatState>({
|
||||
messages: [],
|
||||
input: "",
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
async function ensureChunkListener() {
|
||||
if (unlisten) return;
|
||||
|
||||
unlisten = await onChatChunk((chunk) => {
|
||||
const lastIndex = state.messages.length - 1;
|
||||
const last = state.messages[lastIndex];
|
||||
if (!last || last.role !== "assistant") return;
|
||||
|
||||
state.messages = state.messages.map((message, index) =>
|
||||
index === lastIndex
|
||||
? { ...message, content: message.content + chunk.delta }
|
||||
: message,
|
||||
);
|
||||
|
||||
if (chunk.done) {
|
||||
state.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = state.input.trim();
|
||||
if (!text || state.loading) return;
|
||||
|
||||
state.input = "";
|
||||
await sendMessage(text);
|
||||
}
|
||||
|
||||
async function sendText(value: string) {
|
||||
const text = value.trim();
|
||||
if (!text) return;
|
||||
|
||||
if (state.loading) {
|
||||
state.input = text;
|
||||
state.error = "上一条 AI 回复仍在生成,已将划词内容放入输入框。";
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessage(text);
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
state.error = null;
|
||||
state.messages = [
|
||||
...state.messages,
|
||||
{ role: "user", content: text },
|
||||
{ role: "assistant", content: "" },
|
||||
];
|
||||
state.loading = true;
|
||||
|
||||
await ensureChunkListener();
|
||||
|
||||
try {
|
||||
await apiChat(state.messages.slice(0, -1));
|
||||
} catch (e) {
|
||||
state.error = typeof e === "string" ? e : String(e);
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
state.messages = [];
|
||||
state.error = null;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.messages = [];
|
||||
state.input = "";
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
}
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
send,
|
||||
sendText,
|
||||
clearHistory,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const chatStore = createChatStore();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { resolveAdaptiveTarget } from "./languages";
|
||||
|
||||
describe("resolveAdaptiveTarget", () => {
|
||||
test("returns English target when input contains Chinese", () => {
|
||||
expect(resolveAdaptiveTarget("你好,世界")).toBe("en");
|
||||
expect(resolveAdaptiveTarget("hello 世界")).toBe("en");
|
||||
});
|
||||
|
||||
test("returns Simplified Chinese target for English input", () => {
|
||||
expect(resolveAdaptiveTarget("hello world")).toBe("zh-CN");
|
||||
expect(resolveAdaptiveTarget("Translate this sentence, please.")).toBe("zh-CN");
|
||||
});
|
||||
|
||||
test("returns Simplified Chinese target when no Chinese is detected", () => {
|
||||
expect(resolveAdaptiveTarget("12345!?")).toBe("zh-CN");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 支持的语言列表。 */
|
||||
export interface Lang {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 自适应目标:根据输入在中/英之间自动切换。 */
|
||||
export const ADAPTIVE_TARGET = "adaptive";
|
||||
|
||||
export const LANGUAGES: Lang[] = [
|
||||
{ code: "auto", name: "自动检测" },
|
||||
{ code: "zh-CN", name: "中文(简体)" },
|
||||
{ code: "zh-TW", name: "中文(繁体)" },
|
||||
{ code: "en", name: "英语" },
|
||||
{ code: "ja", name: "日语" },
|
||||
{ code: "ko", name: "韩语" },
|
||||
{ code: "fr", name: "法语" },
|
||||
{ code: "de", name: "德语" },
|
||||
{ code: "es", name: "西班牙语" },
|
||||
{ code: "ru", name: "俄语" },
|
||||
];
|
||||
|
||||
/** 目标语言:首项为自适应,其余不含 auto。 */
|
||||
export const TARGET_LANGUAGES: Lang[] = [
|
||||
{ code: ADAPTIVE_TARGET, name: "自适应 中⇄英" },
|
||||
...LANGUAGES.filter((l) => l.code !== "auto"),
|
||||
];
|
||||
|
||||
export function langName(code: string): string {
|
||||
if (code === ADAPTIVE_TARGET) return "自适应 中⇄英";
|
||||
return LANGUAGES.find((l) => l.code === code)?.name ?? code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应目标策略:主语言为中/英。
|
||||
* 输入含中日韩表意文字(以中文为主)时翻译为英文,否则翻译为中文。
|
||||
*/
|
||||
export function resolveAdaptiveTarget(text: string): "en" | "zh-CN" {
|
||||
return containsChinese(text) ? "en" : "zh-CN";
|
||||
}
|
||||
|
||||
/** 文本是否包含中文字符(CJK 统一表意文字基本区)。 */
|
||||
function containsChinese(text: string): boolean {
|
||||
return /[一-鿿]/.test(text);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 快捷键展示标签(与后端 shortcut.rs 默认值保持一致)。 */
|
||||
export const shortcut = {
|
||||
toggle: "⌘⇧Space",
|
||||
ocr: "⌘⇧S",
|
||||
} as const;
|
||||
@@ -0,0 +1,215 @@
|
||||
/** 翻译状态与编排(Svelte 5 runes)。 */
|
||||
import {
|
||||
translate as apiTranslate,
|
||||
screenshotOcr,
|
||||
showMain,
|
||||
onChunk,
|
||||
} from "../api/tauri";
|
||||
import { ADAPTIVE_TARGET, resolveAdaptiveTarget } from "./languages";
|
||||
import type { UnlistenFn } from "@tauri-apps/api/event";
|
||||
|
||||
/** 网页翻译引擎(可点按切换)。 */
|
||||
export type WebEngine = "google" | "bing";
|
||||
|
||||
export const WEB_ENGINES: WebEngine[] = ["google", "bing"];
|
||||
|
||||
export const WEB_ENGINE_LABELS: Record<WebEngine, string> = {
|
||||
google: "谷歌翻译",
|
||||
bing: "必应翻译",
|
||||
};
|
||||
|
||||
interface TranslationState {
|
||||
input: string;
|
||||
webOutput: string;
|
||||
aiOutput: string;
|
||||
webEngine: WebEngine;
|
||||
source: string;
|
||||
target: string;
|
||||
webLoading: boolean;
|
||||
aiLoading: boolean;
|
||||
webError: string | null;
|
||||
aiError: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function createTranslationStore() {
|
||||
const state = $state<TranslationState>({
|
||||
input: "",
|
||||
webOutput: "",
|
||||
aiOutput: "",
|
||||
webEngine: "google",
|
||||
source: "auto",
|
||||
target: ADAPTIVE_TARGET,
|
||||
webLoading: false,
|
||||
aiLoading: false,
|
||||
webError: null,
|
||||
aiError: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
let lastRunText = "";
|
||||
|
||||
function refreshLoading() {
|
||||
state.loading = state.webLoading || state.aiLoading;
|
||||
}
|
||||
|
||||
async function ensureChunkListener() {
|
||||
if (unlisten) return;
|
||||
unlisten = await onChunk((chunk) => {
|
||||
state.aiOutput += chunk.delta;
|
||||
if (chunk.done) {
|
||||
state.aiLoading = false;
|
||||
refreshLoading();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const text = state.input.trim();
|
||||
if (!text || state.loading) return;
|
||||
|
||||
await runText(text);
|
||||
}
|
||||
|
||||
/** 计算实际请求用的源/目标语言:自适应目标按输入在中/英间切换。 */
|
||||
function resolveLangPair(text: string): { source: string; target: string } {
|
||||
if (state.target === ADAPTIVE_TARGET) {
|
||||
return { source: "auto", target: resolveAdaptiveTarget(text) };
|
||||
}
|
||||
return { source: state.source, target: state.target };
|
||||
}
|
||||
|
||||
async function runText(text: string) {
|
||||
lastRunText = text;
|
||||
state.error = null;
|
||||
state.webError = null;
|
||||
state.aiError = null;
|
||||
state.webOutput = "";
|
||||
state.aiOutput = "";
|
||||
state.webLoading = true;
|
||||
state.aiLoading = true;
|
||||
refreshLoading();
|
||||
|
||||
await ensureChunkListener();
|
||||
|
||||
const common = {
|
||||
text,
|
||||
...resolveLangPair(text),
|
||||
};
|
||||
|
||||
const webTask = apiTranslate({
|
||||
...common,
|
||||
engine: state.webEngine,
|
||||
})
|
||||
.then((result) => {
|
||||
state.webOutput = result;
|
||||
})
|
||||
.catch((e) => {
|
||||
state.webError = typeof e === "string" ? e : String(e);
|
||||
})
|
||||
.finally(() => {
|
||||
state.webLoading = false;
|
||||
refreshLoading();
|
||||
});
|
||||
|
||||
const aiTask = apiTranslate({
|
||||
...common,
|
||||
engine: "openai",
|
||||
})
|
||||
.catch((e) => {
|
||||
state.aiError = typeof e === "string" ? e : String(e);
|
||||
state.aiLoading = false;
|
||||
refreshLoading();
|
||||
});
|
||||
|
||||
await Promise.allSettled([webTask, aiTask]);
|
||||
refreshLoading();
|
||||
}
|
||||
|
||||
/** 切换网页翻译引擎;若已有输入则只重跑网页侧翻译。 */
|
||||
async function setWebEngine(engine: WebEngine) {
|
||||
if (state.webEngine === engine) return;
|
||||
state.webEngine = engine;
|
||||
|
||||
const text = state.input.trim();
|
||||
if (!text || state.webLoading) return;
|
||||
|
||||
state.webError = null;
|
||||
state.webOutput = "";
|
||||
state.webLoading = true;
|
||||
refreshLoading();
|
||||
|
||||
try {
|
||||
state.webOutput = await apiTranslate({
|
||||
text,
|
||||
...resolveLangPair(text),
|
||||
engine: engine,
|
||||
});
|
||||
} catch (e) {
|
||||
state.webError = typeof e === "string" ? e : String(e);
|
||||
} finally {
|
||||
state.webLoading = false;
|
||||
refreshLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async function translateText(value: string) {
|
||||
const text = value.trim();
|
||||
if (!text || state.loading) return;
|
||||
state.input = text;
|
||||
await runText(text);
|
||||
}
|
||||
|
||||
function setInput(v: string) {
|
||||
state.input = v;
|
||||
}
|
||||
|
||||
function shouldAutoRun(text: string): boolean {
|
||||
return Boolean(text.trim()) && !state.loading && text.trim() !== lastRunText;
|
||||
}
|
||||
|
||||
/** 截图 OCR:框选 → 识别 → 填入输入框 → 自动翻译。 */
|
||||
async function captureAndTranslate() {
|
||||
if (state.loading) return;
|
||||
state.error = null;
|
||||
try {
|
||||
const text = await screenshotOcr();
|
||||
if (!text.trim()) return; // 用户取消或未识别到文字
|
||||
await showMain(); // OCR 有结果后再唤起窗口
|
||||
await translateText(text);
|
||||
} catch (e) {
|
||||
state.error = typeof e === "string" ? e : String(e);
|
||||
await showMain();
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.input = "";
|
||||
state.webOutput = "";
|
||||
state.aiOutput = "";
|
||||
state.webError = null;
|
||||
state.aiError = null;
|
||||
state.error = null;
|
||||
state.webLoading = false;
|
||||
state.aiLoading = false;
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
run,
|
||||
translateText,
|
||||
setInput,
|
||||
shouldAutoRun,
|
||||
setWebEngine,
|
||||
captureAndTranslate,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export const translation = createTranslationStore();
|
||||
@@ -0,0 +1,404 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { onMount } from "svelte";
|
||||
import { getPinned, setPinned } from "../api/tauri";
|
||||
import EngineTabs, { type MainMode } from "../components/EngineTabs.svelte";
|
||||
import { chatStore } from "../stores/chat.svelte";
|
||||
|
||||
interface Props {
|
||||
mode: MainMode;
|
||||
onModeChange: (mode: MainMode) => void;
|
||||
onSettings: () => void;
|
||||
}
|
||||
let { mode, onModeChange, onSettings }: Props = $props();
|
||||
|
||||
const c = chatStore;
|
||||
let isPinned = $state(false);
|
||||
let showHistory = $state(false);
|
||||
|
||||
let historyItems = $derived(
|
||||
c.state.messages
|
||||
.map((message, index) => ({ ...message, index }))
|
||||
.filter((message) => message.role === "user"),
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
getPinned().then((pinned) => (isPinned = pinned));
|
||||
});
|
||||
|
||||
async function togglePinned() {
|
||||
isPinned = await setPinned(!isPinned);
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
c.send();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
getCurrentWindow().hide();
|
||||
}
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
c.clearHistory();
|
||||
showHistory = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="chat-panel">
|
||||
<header class="toolbar" data-tauri-drag-region>
|
||||
<EngineTabs {mode} onchange={onModeChange} />
|
||||
|
||||
<div class="toolbar-actions">
|
||||
<button
|
||||
class="md-icon-button pin"
|
||||
class:active={isPinned}
|
||||
onclick={togglePinned}
|
||||
aria-pressed={isPinned}
|
||||
aria-label={isPinned ? "取消固定窗口" : "固定窗口"}
|
||||
title={isPinned ? "取消固定窗口" : "固定窗口"}
|
||||
>
|
||||
<span class="state-layer"></span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14 4.5c0-.83.67-1.5 1.5-1.5S17 3.67 17 4.5v6.15l2.27 2.27c.47.47.14 1.28-.53 1.28H13v5.55c0 .41-.34.75-.75.75s-.75-.34-.75-.75V14.2H5.76c-.67 0-1-.81-.53-1.28L7.5 10.65V4.5C7.5 3.67 8.17 3 9 3s1.5.67 1.5 1.5v5.55c0 .2-.08.39-.22.53L8.36 12.5h7.28l-1.92-1.92a.75.75 0 0 1-.22-.53V4.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="md-icon-button history"
|
||||
class:active={showHistory}
|
||||
onclick={() => (showHistory = !showHistory)}
|
||||
aria-expanded={showHistory}
|
||||
aria-label="查看对话历史"
|
||||
title="查看对话历史"
|
||||
>
|
||||
<span class="state-layer"></span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M13 3a9 9 0 1 1-8.2 5.3.75.75 0 1 1 1.36.63A7.5 7.5 0 1 0 13 4.5c-2.2 0-4.17.95-5.54 2.46H10a.75.75 0 0 1 0 1.5H5.75A.75.75 0 0 1 5 7.71V3.5a.75.75 0 0 1 1.5 0v2.1A8.96 8.96 0 0 1 13 3Zm0 4.25c.41 0 .75.34.75.75v4.04l2.73 1.58a.75.75 0 1 1-.75 1.3l-3.1-1.8a.75.75 0 0 1-.38-.65V8c0-.41.34-.75.75-.75Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="md-icon-button gear" onclick={onSettings} aria-label="设置" title="设置">
|
||||
<span class="state-layer"></span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M19.43 12.98c.04-.32.07-.65.07-.98s-.02-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.6-.22l-2.49 1a7.3 7.3 0 0 0-1.7-.98L14.5 2.42A.5.5 0 0 0 14 2h-4a.5.5 0 0 0-.5.42L9.12 5.07c-.61.24-1.18.56-1.7.98l-2.49-1a.5.5 0 0 0-.6.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.06.65-.06.98s.02.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46c.13.22.39.31.6.22l2.49-1c.52.4 1.09.73 1.7.98l.38 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.38-2.65c.61-.24 1.18-.56 1.7-.98l2.49 1c.22.09.48 0 .6-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.13-1.65ZM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showHistory}
|
||||
<div class="history-popover">
|
||||
<div class="history-head">
|
||||
<span>对话历史</span>
|
||||
{#if c.state.messages.length > 0}
|
||||
<button onclick={clearHistory}>清空</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if historyItems.length === 0}
|
||||
<p class="history-empty">暂无历史</p>
|
||||
{:else}
|
||||
<div class="history-list">
|
||||
{#each historyItems as item}
|
||||
<button class="history-item" onclick={() => (showHistory = false)}>
|
||||
<span>你</span>
|
||||
<p>{item.content}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="messages" aria-label="AI 对话">
|
||||
{#if c.state.messages.length === 0}
|
||||
<p class="placeholder">和 AI 对话,不会使用翻译提示词。</p>
|
||||
{/if}
|
||||
{#each c.state.messages as message, index}
|
||||
<article class="message" class:user={message.role === "user"} class:assistant={message.role === "assistant"}>
|
||||
<span class="role">{message.role === "user" ? "你" : "AI"}</span>
|
||||
<p>{message.content}{#if c.state.loading && index === c.state.messages.length - 1}<span class="caret"></span>{/if}</p>
|
||||
</article>
|
||||
{/each}
|
||||
{#if c.state.error}
|
||||
<p class="error">{c.state.error}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<footer class="composer">
|
||||
<textarea
|
||||
bind:value={c.state.input}
|
||||
onkeydown={onKeydown}
|
||||
placeholder="输入问题,回车发送,Shift+Enter 换行"
|
||||
spellcheck="false"
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button onclick={c.send} disabled={c.state.loading || !c.state.input.trim()}>{c.state.loading ? "…" : "发送"}</button>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-window);
|
||||
overflow: hidden;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-2);
|
||||
}
|
||||
.toolbar-actions {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--md-surface-container, var(--surface-sunken));
|
||||
}
|
||||
.md-icon-button {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--md-on-surface-variant, var(--text-muted));
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
color var(--duration-fast) var(--ease),
|
||||
background var(--duration-fast) var(--ease),
|
||||
box-shadow var(--duration-fast) var(--ease),
|
||||
transform var(--duration-fast) var(--ease);
|
||||
}
|
||||
.md-icon-button svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
.md-icon-button .state-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease);
|
||||
}
|
||||
.md-icon-button:hover .state-layer {
|
||||
opacity: 0.08;
|
||||
}
|
||||
.md-icon-button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.md-icon-button:active .state-layer {
|
||||
opacity: 0.12;
|
||||
}
|
||||
.md-icon-button.active {
|
||||
background: var(--md-secondary-container, var(--accent-soft));
|
||||
color: var(--md-on-secondary-container, var(--accent));
|
||||
}
|
||||
.history-popover {
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
right: 36px;
|
||||
z-index: 20;
|
||||
width: min(320px, calc(100vw - 32px));
|
||||
max-height: 320px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: var(--shadow);
|
||||
transform-origin: top right;
|
||||
animation: popover-in var(--duration) var(--ease);
|
||||
}
|
||||
@keyframes popover-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.86) translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
.history-head button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-empty {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
color: var(--text-faint);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.history-list {
|
||||
max-height: 250px;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.history-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.history-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
padding: var(--space-3);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.history-item:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.history-item span {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: var(--text-faint);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.history-item p {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-4);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.messages::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.placeholder {
|
||||
margin: 0;
|
||||
color: var(--text-faint);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
.message {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.message.user {
|
||||
text-align: right;
|
||||
}
|
||||
.role {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-faint);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
.message p {
|
||||
display: inline-block;
|
||||
max-width: 82%;
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message.user p {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
max-height: 44px;
|
||||
resize: none;
|
||||
overflow: auto;
|
||||
scrollbar-width: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 22px;
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
}
|
||||
.composer textarea::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.composer button {
|
||||
align-self: flex-end;
|
||||
min-width: 64px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.composer button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.caret {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1.1em;
|
||||
margin-left: 1px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--accent);
|
||||
animation: blink 1s steps(2, start) infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,659 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import {
|
||||
getChatAiConfig,
|
||||
getOpenAiConfig,
|
||||
getPinned,
|
||||
getShortcutConfig,
|
||||
setChatAiConfig,
|
||||
setOpenAiConfig,
|
||||
setPinned,
|
||||
setShortcutConfig,
|
||||
setShortcutRecording,
|
||||
type ChatAiConfig,
|
||||
type OpenAiConfig,
|
||||
type ShortcutConfig,
|
||||
} from "../api/tauri";
|
||||
import {
|
||||
applyShortcutRecord,
|
||||
clearShortcutField,
|
||||
recordShortcutFromEvent,
|
||||
type ShortcutField,
|
||||
} from "../shortcuts/shortcutRecorder";
|
||||
import { enable, disable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let isPinned = $state(false);
|
||||
|
||||
let cfg = $state<OpenAiConfig>({
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key: "",
|
||||
model: "gpt-4o-mini",
|
||||
translate_prompt:
|
||||
"你是一个专业翻译引擎。将用户输入从 {source} 翻译成 {target}。只输出译文,不要解释、不要引号、不要附加任何说明。",
|
||||
});
|
||||
let chatCfg = $state<ChatAiConfig>({
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key: "",
|
||||
model: "gpt-4o-mini",
|
||||
chat_prompt: "",
|
||||
});
|
||||
let shortcutCfg = $state<ShortcutConfig>({
|
||||
toggle: "CmdOrCtrl+Shift+Space",
|
||||
ocr: "CmdOrCtrl+Shift+S",
|
||||
ai_dialog: "",
|
||||
selection_translate: "",
|
||||
selection_ai_dialog: "",
|
||||
});
|
||||
type Section = "translate" | "chat" | "shortcuts" | "general";
|
||||
let activeSection = $state<Section>("translate");
|
||||
|
||||
const sections: { id: Section; label: string }[] = [
|
||||
{ id: "translate", label: "AI 翻译" },
|
||||
{ id: "chat", label: "AI 对话" },
|
||||
{ id: "shortcuts", label: "快捷键" },
|
||||
{ id: "general", label: "通用" },
|
||||
];
|
||||
|
||||
let autostart = $state(false);
|
||||
let saved = $state(false);
|
||||
let chatSaved = $state(false);
|
||||
let shortcutSaved = $state(false);
|
||||
let shortcutError = $state<string | null>(null);
|
||||
let generalError = $state<string | null>(null);
|
||||
let recordingShortcut = $state<ShortcutField | null>(null);
|
||||
const shortcutButtons: Partial<Record<ShortcutField, HTMLButtonElement>> = {};
|
||||
|
||||
const shortcutLabels: Record<ShortcutField, string> = {
|
||||
toggle: "呼出翻译",
|
||||
ocr: "截图 OCR",
|
||||
ai_dialog: "呼出 AI 对话",
|
||||
selection_translate: "划词翻译",
|
||||
selection_ai_dialog: "划词 AI 对话",
|
||||
};
|
||||
|
||||
const shortcutFields: { field: ShortcutField; hint: string }[] = [
|
||||
{ field: "toggle", hint: "呼出或收起翻译窗口" },
|
||||
{ field: "ocr", hint: "框选屏幕区域并 OCR 翻译" },
|
||||
{ field: "ai_dialog", hint: "呼出 AI 对话窗口" },
|
||||
{ field: "selection_translate", hint: "捕获选中文本并直接翻译" },
|
||||
{ field: "selection_ai_dialog", hint: "捕获选中文本并发送给 AI" },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
cfg = await getOpenAiConfig();
|
||||
chatCfg = await getChatAiConfig();
|
||||
shortcutCfg = await getShortcutConfig();
|
||||
getPinned().then((pinned) => (isPinned = pinned));
|
||||
try {
|
||||
autostart = await isEnabled();
|
||||
} catch {
|
||||
autostart = false;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (recordingShortcut) {
|
||||
void setShortcutRecording(false);
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
await setOpenAiConfig($state.snapshot(cfg));
|
||||
cfg = await getOpenAiConfig();
|
||||
saved = true;
|
||||
setTimeout(() => (saved = false), 1400);
|
||||
}
|
||||
|
||||
async function saveChat() {
|
||||
await setChatAiConfig($state.snapshot(chatCfg));
|
||||
chatCfg = await getChatAiConfig();
|
||||
chatSaved = true;
|
||||
setTimeout(() => (chatSaved = false), 1400);
|
||||
}
|
||||
|
||||
async function startShortcutRecording(field: ShortcutField) {
|
||||
recordingShortcut = field;
|
||||
shortcutSaved = false;
|
||||
shortcutError = null;
|
||||
|
||||
try {
|
||||
await setShortcutRecording(true);
|
||||
} catch {
|
||||
shortcutError = "启动快捷键录制失败,请重试。";
|
||||
recordingShortcut = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopShortcutRecording() {
|
||||
const field = recordingShortcut;
|
||||
recordingShortcut = null;
|
||||
|
||||
try {
|
||||
await setShortcutRecording(false);
|
||||
} catch {
|
||||
shortcutError = "结束快捷键录制失败,请重新打开设置页。";
|
||||
}
|
||||
|
||||
if (field) {
|
||||
shortcutButtons[field]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleShortcutKeydown(event: KeyboardEvent) {
|
||||
if (!recordingShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const result = recordShortcutFromEvent(event);
|
||||
|
||||
if (result.status === "cancelled") {
|
||||
stopShortcutRecording();
|
||||
shortcutError = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "pending") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "invalid") {
|
||||
shortcutError = result.message;
|
||||
return;
|
||||
}
|
||||
|
||||
shortcutCfg = applyShortcutRecord(shortcutCfg, recordingShortcut, result);
|
||||
shortcutError = null;
|
||||
stopShortcutRecording();
|
||||
}
|
||||
|
||||
function clearShortcut(field: ShortcutField) {
|
||||
shortcutCfg = clearShortcutField(shortcutCfg, field);
|
||||
shortcutSaved = false;
|
||||
shortcutError = null;
|
||||
shortcutButtons[field]?.focus();
|
||||
}
|
||||
|
||||
async function saveShortcuts() {
|
||||
shortcutError = null;
|
||||
|
||||
if (recordingShortcut) {
|
||||
shortcutError = "请先完成当前快捷键录制,或按 Esc 取消。";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setShortcutConfig($state.snapshot(shortcutCfg));
|
||||
shortcutSaved = true;
|
||||
setTimeout(() => (shortcutSaved = false), 1400);
|
||||
} catch (e) {
|
||||
shortcutError = typeof e === "string" ? e : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePinned() {
|
||||
isPinned = await setPinned(!isPinned);
|
||||
}
|
||||
|
||||
async function toggleAutostart() {
|
||||
const next = !autostart;
|
||||
generalError = null;
|
||||
try {
|
||||
if (next) {
|
||||
await enable();
|
||||
} else {
|
||||
await disable();
|
||||
}
|
||||
autostart = next;
|
||||
} catch {
|
||||
generalError = "切换开机自启失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleShortcutKeydown} />
|
||||
|
||||
<section class="settings">
|
||||
<header class="toolbar" data-tauri-drag-region>
|
||||
<h2 class="title">设置</h2>
|
||||
<div class="toolbar-actions">
|
||||
<button
|
||||
class="md-icon-button pin"
|
||||
class:active={isPinned}
|
||||
onclick={togglePinned}
|
||||
aria-pressed={isPinned}
|
||||
aria-label={isPinned ? "取消固定窗口" : "固定窗口"}
|
||||
title={isPinned ? "取消固定窗口" : "固定窗口"}
|
||||
>
|
||||
<span class="state-layer"></span>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14 4.5c0-.83.67-1.5 1.5-1.5S17 3.67 17 4.5v6.15l2.27 2.27c.47.47.14 1.28-.53 1.28H13v5.55c0 .41-.34.75-.75.75s-.75-.34-.75-.75V14.2H5.76c-.67 0-1-.81-.53-1.28L7.5 10.65V4.5C7.5 3.67 8.17 3 9 3s1.5.67 1.5 1.5v5.55c0 .2-.08.39-.22.53L8.36 12.5h7.28l-1.92-1.92a.75.75 0 0 1-.22-.53V4.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="close" onclick={onClose} aria-label="返回">完成</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<nav class="sidebar" aria-label="设置分类">
|
||||
{#each sections as sec}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={activeSection === sec.id}
|
||||
onclick={() => (activeSection = sec.id)}
|
||||
>{sec.label}</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="panel">
|
||||
{#if activeSection === "translate"}
|
||||
<div class="group">
|
||||
<span class="group-title">AI 翻译配置(OpenAI 兼容)</span>
|
||||
<label class="field">
|
||||
<span>API 地址</span>
|
||||
<input type="text" bind:value={cfg.base_url} placeholder="https://api.openai.com/v1" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>API Key</span>
|
||||
<input type="password" bind:value={cfg.api_key} placeholder="sk-…" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>模型</span>
|
||||
<input type="text" bind:value={cfg.model} placeholder="gpt-4o-mini" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>翻译提示词</span>
|
||||
<textarea bind:value={cfg.translate_prompt} placeholder="使用 {source} 和 {target} 作为语言占位符" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<p class="hint">仅用于翻译页右侧 AI 译文;AI 对话页不会附加这个提示词。</p>
|
||||
<button class="save" onclick={save}>{saved ? "已保存" : "保存"}</button>
|
||||
</div>
|
||||
|
||||
{:else if activeSection === "chat"}
|
||||
<div class="group">
|
||||
<span class="group-title">AI 对话配置(OpenAI 兼容)</span>
|
||||
<label class="field">
|
||||
<span>API 地址</span>
|
||||
<input type="text" bind:value={chatCfg.base_url} placeholder="https://api.openai.com/v1" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>API Key</span>
|
||||
<input type="password" bind:value={chatCfg.api_key} placeholder="sk-…" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>模型</span>
|
||||
<input type="text" bind:value={chatCfg.model} placeholder="gpt-4o-mini" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>自定义提示词</span>
|
||||
<textarea bind:value={chatCfg.chat_prompt} placeholder="可选,作为 system 提示词附加在对话最前;留空则不附加" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<p class="hint">仅用于顶部 AI 对话模式;不会使用翻译提示词。提示词留空即关闭。</p>
|
||||
<button class="save" onclick={saveChat}>{chatSaved ? "已保存" : "保存"}</button>
|
||||
</div>
|
||||
|
||||
{:else if activeSection === "shortcuts"}
|
||||
<div class="group">
|
||||
<span class="group-title">快捷键</span>
|
||||
{#each shortcutFields as { field, hint } (field)}
|
||||
<div class="field">
|
||||
<span>{shortcutLabels[field]}</span>
|
||||
<div class="shortcut-row">
|
||||
<button
|
||||
bind:this={shortcutButtons[field]}
|
||||
type="button"
|
||||
class="shortcut-recorder"
|
||||
class:recording={recordingShortcut === field}
|
||||
aria-label={recordingShortcut === field
|
||||
? "正在录制,按 Esc 取消"
|
||||
: `当前为 ${shortcutCfg[field] || "未设置"},点击录制`}
|
||||
aria-pressed={recordingShortcut === field}
|
||||
onclick={() => startShortcutRecording(field)}
|
||||
>
|
||||
<span class="shortcut-value">
|
||||
{recordingShortcut === field
|
||||
? "按下新的组合键…"
|
||||
: shortcutCfg[field] || "未设置"}
|
||||
</span>
|
||||
<span class="shortcut-action">
|
||||
{recordingShortcut === field ? "Esc 取消" : "点击录制"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shortcut-clear"
|
||||
disabled={!shortcutCfg[field] || recordingShortcut === field}
|
||||
onclick={() => clearShortcut(field)}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">{hint}</p>
|
||||
</div>
|
||||
{/each}
|
||||
<p class="shortcut-status" aria-live="polite">
|
||||
{recordingShortcut ? `正在录制${shortcutLabels[recordingShortcut]}快捷键,按下包含修饰键的组合键。` : "录制或清空后需点击“保存快捷键”才会生效。"}
|
||||
</p>
|
||||
{#if shortcutError}
|
||||
<p class="error" role="alert">{shortcutError}</p>
|
||||
{/if}
|
||||
<button class="save" onclick={saveShortcuts}>{shortcutSaved ? "已生效" : "保存快捷键"}</button>
|
||||
<p class="hint">每个快捷键都可单独设置或清空(留空即禁用),必须包含 Cmd/Ctrl/Option 修饰键。「呼出翻译」与「划词翻译」、「呼出 AI 对话」与「划词 AI 对话」可设为同一组合:有选中文本时执行划词,否则按呼出处理。示例:<kbd>CmdOrCtrl+Shift+Space</kbd>、<kbd>Option+Space</kbd>。</p>
|
||||
</div>
|
||||
|
||||
{:else if activeSection === "general"}
|
||||
<div class="group">
|
||||
<span class="group-title">通用</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" checked={autostart} onchange={toggleAutostart} />
|
||||
<span>开机自动启动</span>
|
||||
</label>
|
||||
<p class="hint">截图 OCR 需要"屏幕录制"权限。划词捕获优先使用"辅助功能"权限,未授权时会回退到临时复制并尽量恢复剪贴板。</p>
|
||||
{#if generalError}
|
||||
<p class="error" role="alert">{generalError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-window);
|
||||
overflow: hidden;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4) var(--space-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--md-surface-container, var(--surface-sunken));
|
||||
}
|
||||
.md-icon-button {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--md-on-surface-variant, var(--text-muted));
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
color var(--duration-fast) var(--ease),
|
||||
background var(--duration-fast) var(--ease),
|
||||
transform var(--duration-fast) var(--ease);
|
||||
}
|
||||
.md-icon-button svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
.md-icon-button .state-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease);
|
||||
}
|
||||
.md-icon-button:hover .state-layer {
|
||||
opacity: 0.08;
|
||||
}
|
||||
.md-icon-button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.md-icon-button:active .state-layer {
|
||||
opacity: 0.12;
|
||||
}
|
||||
.md-icon-button.pin.active {
|
||||
background: var(--md-secondary-container, var(--accent-soft));
|
||||
color: var(--md-on-secondary-container, var(--accent));
|
||||
box-shadow: inset 0 0 0 1px oklch(70% 0.16 266 / 0.14);
|
||||
}
|
||||
.close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.layout {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-right: 1px solid var(--border);
|
||||
gap: 2px;
|
||||
}
|
||||
.nav-item {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease);
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.panel {
|
||||
overflow-y: auto;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.group-title {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.field span {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.field input,
|
||||
.field textarea {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
font-size: var(--text-base);
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
transition: border-color var(--duration-fast) var(--ease);
|
||||
}
|
||||
.field textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.shortcut-recorder {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-base);
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease),
|
||||
background var(--duration-fast) var(--ease);
|
||||
}
|
||||
.shortcut-recorder:hover,
|
||||
.shortcut-recorder:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.shortcut-recorder.recording {
|
||||
background: var(--surface-sunken);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.shortcut-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.shortcut-action {
|
||||
flex: none;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.shortcut-clear {
|
||||
flex: none;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-sm);
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease),
|
||||
color var(--duration-fast) var(--ease),
|
||||
background var(--duration-fast) var(--ease);
|
||||
}
|
||||
.shortcut-clear:hover:not(:disabled),
|
||||
.shortcut-clear:focus-visible {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.shortcut-clear:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.shortcut-status {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.4;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.save {
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
padding: 7px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) var(--ease);
|
||||
}
|
||||
.save:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
kbd {
|
||||
font-family: inherit;
|
||||
font-size: var(--text-xs);
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { mount } from "svelte";
|
||||
import "./styles/global.css";
|
||||
import App from "./App.svelte";
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById("app")!,
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,52 @@
|
||||
@import "tailwindcss";
|
||||
@import "./tokens.css";
|
||||
|
||||
/* 透明背景 —— 让 macOS 毛玻璃透出来 */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
|
||||
"Helvetica Neue", sans-serif;
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 全局聚焦态 —— 设计过的焦点环,非浏览器默认 */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* 滚动条 —— 克制 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/* ─────────────────────────────────────────────
|
||||
设计令牌 — 克制、现代、有层次
|
||||
暖灰中性 + 单一靛蓝强调色,避免模板感
|
||||
───────────────────────────────────────────── */
|
||||
:root {
|
||||
/* 表面 —— 半透明,配合毛玻璃 */
|
||||
--surface: oklch(99% 0.004 280 / 0.72);
|
||||
--surface-raised: oklch(100% 0 0 / 0.55);
|
||||
--surface-sunken: oklch(96% 0.004 280 / 0.6);
|
||||
--border: oklch(80% 0.008 280 / 0.5);
|
||||
--border-strong: oklch(70% 0.01 280 / 0.6);
|
||||
|
||||
/* 文本 */
|
||||
--text: oklch(22% 0.01 280);
|
||||
--text-muted: oklch(52% 0.012 280);
|
||||
--text-faint: oklch(65% 0.01 280);
|
||||
|
||||
/* 强调色 —— 靛蓝 */
|
||||
--accent: oklch(58% 0.19 268);
|
||||
--accent-hover: oklch(52% 0.2 268);
|
||||
--accent-soft: oklch(58% 0.19 268 / 0.12);
|
||||
--accent-text: oklch(98% 0 0);
|
||||
|
||||
/* 状态 */
|
||||
--danger: oklch(58% 0.2 25);
|
||||
--danger-soft: oklch(58% 0.2 25 / 0.12);
|
||||
|
||||
/* 字号 —— 流式缩放 */
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.9375rem;
|
||||
--text-lg: 1.0625rem;
|
||||
--text-xl: 1.375rem;
|
||||
|
||||
/* 间距节奏 */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 0.5rem;
|
||||
--radius: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-window: 1.25rem;
|
||||
|
||||
/* 动效 */
|
||||
--duration-fast: 120ms;
|
||||
--duration: 200ms;
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.06);
|
||||
--shadow: 0 4px 16px oklch(0% 0 0 / 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--surface: oklch(24% 0.01 280 / 0.72);
|
||||
--surface-raised: oklch(30% 0.012 280 / 0.55);
|
||||
--surface-sunken: oklch(20% 0.01 280 / 0.6);
|
||||
--border: oklch(40% 0.012 280 / 0.5);
|
||||
--border-strong: oklch(50% 0.014 280 / 0.6);
|
||||
|
||||
--text: oklch(94% 0.004 280);
|
||||
--text-muted: oklch(70% 0.01 280);
|
||||
--text-faint: oklch(56% 0.01 280);
|
||||
|
||||
--accent: oklch(70% 0.16 266);
|
||||
--accent-hover: oklch(76% 0.16 266);
|
||||
--accent-soft: oklch(70% 0.16 266 / 0.16);
|
||||
--accent-text: oklch(18% 0.02 266);
|
||||
|
||||
--shadow: 0 8px 28px oklch(0% 0 0 / 0.4);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["svelte", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), tailwindcss()],
|
||||
|
||||
// prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
});
|
||||