diff --git a/.cursor/rules/architecture.mdc b/.cursor/rules/architecture.mdc index dc5682b..5885af1 100644 --- a/.cursor/rules/architecture.mdc +++ b/.cursor/rules/architecture.mdc @@ -1,5 +1,5 @@ --- -description: LiveLingo architecture boundaries — ViewModel / UI / Platform separation +description: LiveLingo architecture boundaries — state in C#, the page only draws, platform code behind interfaces alwaysApply: true --- @@ -7,31 +7,36 @@ alwaysApply: true ## Core Principle -UI 不测试逻辑,逻辑不依赖 UI。 +UI 不测试逻辑,逻辑不依赖 UI。一个功能只由一个服务或组件实现;不为兼容旧行为堆叠分支。 -## ViewModel Layer +## State and logic (C#) -- ViewModel **禁止** 引用 `Avalonia.*` 命名空间 -- ViewModel **禁止** 使用 `Dispatcher`、`Dispatcher.UIThread` 或任何线程调度 API -- ViewModel 只依赖接口 (`ISettingsService`, `ITranslationPipeline`, `ITextInjector`, `IModelManager` 等) -- ViewModel 使用 `CommunityToolkit.Mvvm` (`ObservableObject`, `RelayCommand`) -- 需要通知 UI 的行为通过事件 (`event Action? RequestClose`) 或属性变更实现 +- `ComposerViewModel`、`SettingsViewModel` 和 Core 持有全部状态与逻辑 +- ViewModel **禁止** 引用 `Avalonia.*`,禁止使用 `Dispatcher` 或任何线程调度 API +- ViewModel 只依赖接口(`ISettingsService`、`IModelManager`、`IVoiceInput`、`ITextInjector` 等) +- ViewModel 使用 `CommunityToolkit.Mvvm`;需要通知界面时用事件或属性变更 -## View Layer (Avalonia) +## The page (Web UI) -- View 只负责绑定和布局 -- View 中 `code-behind` 仅处理 Avalonia 特有交互 (窗口定位、焦点、动画) -- Dispatcher 调用只允许出现在 View 或 `App.axaml.cs` 中 +- 窗口是包着 WebView 的薄壳;页面(`Web/Assets`,Preact + htm,无 Node 构建)只绘制整份快照,只发送封闭集合的意图 +- 页面**不持有逻辑**:是否可用、校验、同意、记忆都在 C# 决定 +- 窗口代码只处理 Avalonia 特有的事:定位、焦点、停放;`Dispatcher` 只出现在窗口或 `App.axaml.cs` -## Platform Layer +## Platform layer -- 平台差异通过接口抽象 (`IPlatformServices`, `IClipboardService`, `ITextInjector`) -- 具体实现放在 `Platform/Windows/` 或 `Platform/macOS/` -- 禁止在 ViewModel 中直接使用 P/Invoke 或平台 API +- 平台差异通过接口抽象(`IPlatformServices`、`IClipboardService`、`ITextInjector`、`IWindowTracker`) +- 实现放在 `Platform/Windows/` 或 `Platform/macOS/` +- 禁止在 ViewModel 中使用 P/Invoke 或平台 API +- 产品只支持 Windows x64 与 macOS arm64(llama.cpp 运行时的固定版本) + +## Privacy + +- 默认语音和文本不离开本机;云端调用必须由用户按功能显式开启,且只发文本 +- 凭据只进系统密钥存储,不写入代码、日志或设置文件 ## Testing -- ViewModel 测试使用 NSubstitute mock 所有依赖 -- Stryker 变异测试 **不测试** Avalonia UI 项目(源生成器不兼容) -- Stryker 只测试 `LiveLingo.Core` 项目 -- 代码覆盖率通过 `test.runsettings` 排除平台实现和 View 层 +- ViewModel 测试使用 NSubstitute 替换全部依赖;测试不碰真实磁盘、网络或界面框架 +- 页面由 `LiveLingo.Web.Tests`(Playwright,WebKit + Chromium)用黄金快照测试;夹具由 `Desktop.Tests` 用 `UPDATE_FIXTURES=1` 生成,且必须与时区、操作系统无关 +- Stryker 只测试 `LiveLingo.Core` 的纯逻辑模块(见 `tests/LiveLingo.Core.Tests/stryker-config.json`) +- 代码覆盖率通过 `test.runsettings` 排除平台实现和窗口壳层 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c56517..b92079c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,9 @@ permissions: contents: read jobs: + # The product ships for Windows x64 and macOS arm64 (the only pinned llama.cpp runtimes), so its tests run on one of them. test: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -29,7 +30,19 @@ jobs: - name: Restore tools run: dotnet tool restore - - name: Test + Coverage + Mutation + - name: Build run: | chmod +x build.sh - ./build.sh Test + ./build.sh Build + + # The web tests drive WebKit and Chromium. + - uses: actions/cache@v4 + with: + path: ~/Library/Caches/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('tests/LiveLingo.Web.Tests/*.csproj') }} + + - name: Install browsers for the web tests + run: pwsh tests/LiveLingo.Web.Tests/bin/Release/net10.0/playwright.ps1 install webkit chromium + + - name: Test + Coverage + Mutation + run: ./build.sh Test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75e95d8..dd0c7bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,10 +52,10 @@ jobs: - name: Compile run: dotnet build LiveLingo.slnx -c Release --nologo - # Stage 2: Run tests + # Stage 2: Run tests (on macOS: the product ships for Windows x64 and macOS arm64, the pinned llama.cpp runtimes) test: needs: compile - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 @@ -75,10 +75,22 @@ jobs: - name: Restore tools run: dotnet tool restore - - name: Test + - name: Build run: | chmod +x build.sh - ./build.sh Test + ./build.sh Build + + # The web tests drive WebKit and Chromium. + - uses: actions/cache@v4 + with: + path: ~/Library/Caches/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('tests/LiveLingo.Web.Tests/*.csproj') }} + + - name: Install browsers for the web tests + run: pwsh tests/LiveLingo.Web.Tests/bin/Release/net10.0/playwright.ps1 install webkit chromium + + - name: Test + run: ./build.sh Test # Stage 3: Build platform-specific installers pack: @@ -123,9 +135,10 @@ jobs: shell: bash run: | chmod +x build.sh - ./build.sh PackMac --skip Test --Runtime ${{ matrix.runtime }} --Version ${{ needs.compile.outputs.version }} + ./build.sh Pack --skip Test --Runtime ${{ matrix.runtime }} --Version ${{ needs.compile.outputs.version }} - - name: Upload installers + # The installers and what installed applications update from: the full package and its feed (releases..json). + - name: Upload release files uses: actions/upload-artifact@v4 with: name: installers-${{ matrix.runtime }} @@ -133,15 +146,18 @@ jobs: releases/*.exe releases/*.msi releases/*.pkg + releases/*.nupkg + releases/releases.*.json + releases/RELEASES* if-no-files-found: error - # Stage 4: Create GitHub Release with only installer files + # Stage 4: Create the GitHub Release with the installers and the update feeds release: needs: [compile, pack] runs-on: ubuntu-latest steps: - - name: Download all installer artifacts + - name: Download all release files uses: actions/download-artifact@v4 with: path: installers @@ -151,13 +167,31 @@ jobs: - name: List release files run: ls -la installers/ + # An application only updates from a release whose feed names a package that is in it, so a release without one is not published. + - name: Check the update feeds + run: | + for channel in win osx; do + feed="installers/releases.${channel}.json" + test -f "$feed" || { echo "Missing $feed"; exit 1; } + jq -e --arg v "${{ needs.compile.outputs.version }}" \ + '[.Assets[] | select(.Version == $v and .Type == "Full")] | length == 1' "$feed" > /dev/null \ + || { echo "$feed has no full package for ${{ needs.compile.outputs.version }}"; exit 1; } + for file in $(jq -r '.Assets[].FileName' "$feed"); do + test -f "installers/$file" || { echo "$feed names $file, which was not built"; exit 1; } + done + done + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.compile.outputs.tag }} name: LiveLingo ${{ needs.compile.outputs.tag }} + prerelease: ${{ contains(needs.compile.outputs.version, '-') }} files: | installers/*.exe installers/*.msi installers/*.pkg + installers/*.nupkg + installers/releases.*.json + installers/RELEASES* generate_release_notes: true diff --git a/.gitignore b/.gitignore index 1ea5314..5e309f9 100644 --- a/.gitignore +++ b/.gitignore @@ -428,3 +428,13 @@ stryker-run.log # Nuke temp .nuke/temp/ .DS_Store + +# Local agent scratch space +.superpowers/ + +# POC data (audio corpora, results, models are not committed) +poc/**/corpus*/ +poc/**/results/ +poc/**/models/ +*.wav +*.gguf diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 42df900..0365fd2 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -28,11 +28,9 @@ "Clean", "Mutate", "Pack", - "PackMac", "PackMsi", - "ProbeStt", "ProbeTranslation", - "ProbeTranslationAll", + "ProbeVoice", "Publish", "Restore", "Run", @@ -128,46 +126,18 @@ "description": "Minimum line coverage percentage", "format": "int32" }, - "EnableSttProbe": { - "type": "boolean", - "description": "Enable sherpa-onnx STT regression probe target" - }, - "EnableTranslationProbe": { - "type": "boolean", - "description": "Enable translation regression probe target" - }, "MutationThreshold": { "type": "integer", "description": "Minimum mutation score percentage", "format": "int32" }, - "ProbeCases": { + "ProbeCorpus": { "type": "string", - "description": "Batch probe cases: source=>expected;source=>expected" - }, - "ProbeExpectedContains": { - "type": "string", - "description": "Expected substring in translation result" + "description": "Directory with scenarios.json and audio/.wav used by ProbeVoice (see poc/voice-dictation/corpus)" }, "ProbeModelPath": { "type": "string", - "description": "Model path used by translation probe" - }, - "ProbeSourceText": { - "type": "string", - "description": "Source text used by translation probe" - }, - "ProbeSttExpected": { - "type": "string", - "description": "Expected substring in the STT probe transcription result" - }, - "ProbeSttLang": { - "type": "string", - "description": "Optional language hint (ISO-639-1) for the STT probe, e.g. en / zh / ja" - }, - "ProbeWavPath": { - "type": "string", - "description": "Path to a 16 kHz mono wav file used by the STT probe" + "description": "Model storage directory used by end-to-end probes (defaults to the app's model directory)" }, "Runtime": { "type": "string", diff --git a/.superpowers/brainstorm/19181-1774361145/.events b/.superpowers/brainstorm/19181-1774361145/.events deleted file mode 100644 index a096443..0000000 --- a/.superpowers/brainstorm/19181-1774361145/.events +++ /dev/null @@ -1,7 +0,0 @@ -{"type":"click","text":"✓\n \n This direction looks right\n Keep this structure and move to runtime / model-routing details.","choice":"yes","id":null,"timestamp":1774361387742} -{"type":"click","text":"✓\n \n This direction looks right\n Keep this structure and move to runtime / model-routing details.","choice":"yes","id":null,"timestamp":1774361395025} -{"type":"click","text":"✓\n \n This direction looks right\n Keep this structure and move to runtime / model-routing details.","choice":"yes","id":null,"timestamp":1774361395343} -{"type":"click","text":"↺\n \n Adjust boundaries\n Keep A, but change some layer boundaries before we continue.","choice":"adjust","id":null,"timestamp":1774361396426} -{"type":"click","text":"✓\n \n This direction looks right\n Keep this structure and move to runtime / model-routing details.","choice":"yes","id":null,"timestamp":1774361397310} -{"type":"click","text":"↺\n \n Adjust boundaries\n Keep A, but change some layer boundaries before we continue.","choice":"adjust","id":null,"timestamp":1774361399033} -{"type":"click","text":"✓\n \n This direction looks right\n Keep this structure and move to runtime / model-routing details.","choice":"yes","id":null,"timestamp":1774361399571} diff --git a/.superpowers/brainstorm/19181-1774361145/.server-stopped b/.superpowers/brainstorm/19181-1774361145/.server-stopped deleted file mode 100644 index 243a2ad..0000000 --- a/.superpowers/brainstorm/19181-1774361145/.server-stopped +++ /dev/null @@ -1 +0,0 @@ -{"reason":"idle timeout","timestamp":1774363546214} diff --git a/.superpowers/brainstorm/19181-1774361145/.server.pid b/.superpowers/brainstorm/19181-1774361145/.server.pid deleted file mode 100644 index 76fa3d0..0000000 --- a/.superpowers/brainstorm/19181-1774361145/.server.pid +++ /dev/null @@ -1 +0,0 @@ -19193 diff --git a/.superpowers/brainstorm/19181-1774361145/architecture-options.html b/.superpowers/brainstorm/19181-1774361145/architecture-options.html deleted file mode 100644 index c0470f6..0000000 --- a/.superpowers/brainstorm/19181-1774361145/architecture-options.html +++ /dev/null @@ -1,44 +0,0 @@ - -

LiveLingo 重组方向:3 个总体架构方案

-

我推荐 A:能力平台 + Provider 适配层。它最适合“多模型后端优先,并为未来云端/插件/多前端留口子”。

- -
-
-
-
Recommended
-

A. Capability Platform + Provider Adapters

-

把“翻译、后处理、STT、注入”等能力定义为稳定接口;本地/云端模型都实现 Provider 适配层;编排层只依赖 capability,不依赖具体模型。

-
-

优点

  • 天然支持 Marian / llama.cpp / 云 API 并存
  • 可逐步插件化,不必一开始做重插件系统
  • 方便未来接 Desktop / CLI / 其他前端
-

代价

  • 前期抽象设计要求高
  • 需要重画 Core / Application / Host 边界
-
-
-
- -
-
-

B. 分层单体重整

-

保留现有项目主形态,只把 App / ViewModel / Engine 逻辑拆干净,先不引入 provider registry 和统一能力模型。

-
-

优点

  • 落地快
  • 迁移风险较低
  • 适合近期只修当前问题
-

代价

  • 多模型后端扩展会再次碰边界
  • 云端模型支持会比较别扭
-
-
-
- -
-
-

C. 先做完整插件宿主

-

从第一天就做插件发现、清单、隔离、版本协商,让所有模型和能力都通过插件装配。

-
-

优点

  • 扩展性最强
  • 第三方生态空间最大
-

代价

  • 明显过重
  • 会拖慢主链路重构
  • 当前阶段 YAGNI 风险高
-
-
-
-
- -
-

我建议

-

选 A。 先把系统做成“平台化内核 + 内建 providers”,外部插件机制先预留协议,不在第一阶段完全做满。

-
diff --git a/.superpowers/brainstorm/19181-1774361145/architecture-section-1.html b/.superpowers/brainstorm/19181-1774361145/architecture-section-1.html deleted file mode 100644 index 4ebd32f..0000000 --- a/.superpowers/brainstorm/19181-1774361145/architecture-section-1.html +++ /dev/null @@ -1,86 +0,0 @@ - -

推荐目标架构(Section 1: System Architecture)

-

核心思想:前端只消费用例接口;能力编排只依赖 capability;模型后端通过 providers 注册;本地与云端统一到同一套执行协议。

- -
-
Layered Platform Architecture
-
-
-
-
Frontends
-
-
Desktop
-
CLI
-
Future Extension
-
-
- -
-
Host / Shell
-
-
Tray
-
Overlay
-
Permissions
-
Updates
-
-
- -
-
Application / Use Cases
-
-
TranslateText
-
PolishText
-
SpeechSession
-
ModelSetup
-
-
- -
-
Capability Runtime
-
-
Capability Router
-
Execution Policy
-
Fallback Policy
-
Metrics
-
-
- -
-
Provider Registry
-
-
Translation Providers
-
Text Processing Providers
-
STT / VAD Providers
-
Cloud Adapters
-
-
- -
-
Infrastructure
-
-
Model Catalog
-
Model Manager
-
Secrets / Profiles
-
Storage
-
-
-
-
-
- -
-
-
✓
-
-

This direction looks right

-

Keep this structure and move to runtime / model-routing details.

-
-
-
-
↺
-
-

Adjust boundaries

-

Keep A, but change some layer boundaries before we continue.

-
-
-
diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e23e3c..14ae263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,105 @@ All notable changes to LiveLingo are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Speech is ready when you are. The speech models are now loaded in the background a few seconds after the app + starts (Settings → Voice → *Get speech ready at startup*, on by default), after they are downloaded, and whenever the + composer opens, and they stay in memory for 30 idle minutes. Starting dictation after that takes milliseconds instead + of 3–5 seconds. The cleanup or translation model of the output the composer starts with is loaded when it opens, and + when you switch output, so the first sentence no longer waits for it (the first cleanup took about 10 seconds). + While the speech models are still loading, the empty composer says so. The preloaded speech models take about 1.5 GB + of memory; see `docs/superpowers/specs/2026-11-02-model-warmup-design.md`. +- Automatic updates work. Releases now carry the Velopack update packages and feeds + (`releases.win.json`, `releases.osx.json`) next to the installers, and the release is not published unless each feed + names a package that is in it. Before, only installers were uploaded, so no installed application could find an update. +- The installed app downloads a newer version in the background and offers *Restart to Update* in the tray menu; + quitting installs it too. The macOS installer is now built by Velopack (it replaces the hand-built `.pkg`, which the + updater could not manage) and carries the app icon. The `.msi` does not update itself. + +### Fixed + +- Installed apps could never find an update: every settings file of 1.x and 2.0.0 stores the placeholder update address + `https://github.com/user/LiveLingo`, which the new default did not replace. It is now replaced on load; an address you + set yourself is kept, and an empty one turns updates off (it used to be filled in again at every start). +- The Velopack install and update hooks are handled first thing in `Main`, before the single-instance lock, so a running + instance can no longer turn the installer or updater away. +- The default update URL pointed at a placeholder repository; it is now `https://github.com/AGIBuild/LiveLingo`. + +### Removed + +- Code that nothing in 2.0 uses: model deletion, disk-usage and storage-relocation operations of `IModelManager`, the + stub model manager and null download coordinator, Hugging Face repository listing and quantization choosing (and the + `LiveLingo.HfGguf.Cli` tool that used them), unused native declarations, and tests that only checked record equality. +- The 1.x product design, proposals, roadmap and demo images, the WebView feasibility spike (`tools/WebViewSpike`) and + stray scratch files. + +## [2.0.0] - 2026-11-01 + +Rewrite around a single composer window (branch `feat/composer-v3`). The 1.x translator overlay, its cloud and Ollama +providers, wizard and model catalog were removed; see the design and plan under `docs/superpowers/`. + +### Added + +- Composer: summon with a global hotkey, type, paste or speak, choose *Clean up*, *Translate* or *Original*, press + Enter to write into the app you came from. Banners replace dialogs for missing models, permissions and silence. +- Voice dictation with live text (X-ASR), settled text (Qwen3-ASR) and a fidelity-checked cleanup (Qwen3.5 4B). +- Translation with Hy-MT2 on a pinned `llama-server`; glossary of keep-as-written terms and fixed translations. +- Import a WAV recording as a timeline; export SRT in the chosen output. +- Optional cloud translation and cleanup through an OpenAI-compatible endpoint, per feature, text only, off by default; + API key in the system secret store. +- Recent drafts (memory only), side-by-side layout for transcripts and whole documents, accessible names and live + regions. +- Every summon starts with an empty composer. A draft that was closed without being written is offered as one click + ("Continue last draft") in the empty state, and stays in the recent drafts menu; it is never put back by itself. +- The microphone no longer closes when the user pauses: dictation goes on until it is stopped, and only 5 minutes of + complete silence turn it off, with a notice that what was heard is kept. +- The composer remembers, per target application, the output (*Clean up* or *Translate*) and the translation direction + last chosen there, and begins with them the next time. A one-line note says where a remembered choice came from and + offers "Restore defaults"; *Original*, cloud switches and the microphone are never remembered. Stored in a separate + `preferences.json` (app identifier, two choices, last export format; at most 50 apps, no expiry). Settings can turn + it off and list, forget or clear the remembered apps, and add a default output and "start dictating when summoned". +- The composer can be moved by dragging its header, and it always stays inside the working area of its screen: the + window is re-placed whenever it grows, and the page scrolls inside its panel instead of growing past the screen. +- Settings: hotkey, commit key (Enter or Ctrl+Enter), interface language, theme (light, dark, follow system), reduce + motion, microphone, translation direction, glossary list, cloud model, translator tier, removal of models from + earlier versions. Changes apply at once; there is no save button. +- Settings migration from schema 2; `THIRD_PARTY_NOTICES.md`. +- Settings: a switch to turn sentence cleanup off (the composer then offers only *Translate* and *Original* and starts on + *Original*), and a status dialog (version, system, switch states) that never contains text, terms, addresses or keys. + +### Changed + +- The composer and the settings are rendered by the system web view (WKWebView on macOS, WebView2 on Windows) inside + thin Avalonia windows. State and logic stay in C#; the page, built from embedded Preact + htm sources without a Node + build, draws whole snapshots and sends closed-set intents. The Avalonia XAML views, custom controls and theme were + removed. The composer window is kept parked off-screen between uses instead of hidden. +- The note under a sentence whose cleanup was refused ("original wording kept") follows the interface language. + +- Dictation is a toggle: `⌘D` (`Ctrl+D`) or a click on the microphone starts it and the same key stops it. Hold-to-talk + is gone (nobody should have to keep the mouse button pressed), and dictation no longer stops when the window loses + focus. The composer's other actions have shortcuts as well (output `⌘1`–`⌘3`, swap language `⌘L`, copy `⇧⌘C`, new + draft `⌘N`, recent drafts `⌘R`, import `⌘O`, export `⌘S`, settings `⌘,`); they are defined once in C# and shown next + to the menu items. + +- The interface follows the v3 prototype (`docs/superpowers/prototypes/livelingo-ux-v3/`): destination, privacy and + output chips, per-sentence edit and "use original", microphone, light and dark themes, + system font. +- All packages are on their latest stable versions; `xunit.v3` stays on 3.2.2 because 4.x only supports Microsoft + Testing Platform. +- Model catalog pins revisions and SHA256 hashes and shows each model's license. +- macOS bundle declares `NSMicrophoneUsageDescription` and the audio-input entitlement. + +### Known limits + +- Validated on an M3 Pro only; M2/16 GB and Windows runtime behaviour are unverified. +- Recording import reads uncompressed WAV only. +- The web UI has not been exercised in a real window on Windows, and WebView2 runtime distribution for Windows 10 is + not decided. There is no recognition-language option (the speech engine has none) and recordings are never kept. +- The composer window is not blurred (alpha glass only); the prototype's backdrop blur is not reproduced. + ## [1.4.0] - 2026-04-25 ### Changed @@ -34,7 +133,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Velopack NotInstalledException in dev mode**: `CheckForUpdateAsync` now returns early when `UpdateManager.IsInstalled` is false. -## [Unreleased] +## [1.4.1 – 1.4.2] - 2026-04-30 + +Notes written while these versions were in progress; they belong to the 1.x line, which 2.0 replaces. The voice-input arc — replacing the legacy Whisper engine with a sherpa-onnx-based pipeline, surfacing model routing in the UI, and reclaiming disk used by the retired @@ -207,5 +308,6 @@ process-wide task with shared progress and dedup across every UI surface. See `git log v0.1.4` for the pre-CHANGELOG history. -[Unreleased]: https://github.com/user/LiveLingo/compare/v0.1.4...HEAD -[0.1.4]: https://github.com/user/LiveLingo/releases/tag/v0.1.4 +[2.0.0]: https://github.com/AGIBuild/LiveLingo/releases/tag/v2.0.0 +[1.4.0]: https://github.com/AGIBuild/LiveLingo/releases/tag/v1.4.0 +[0.1.4]: https://github.com/AGIBuild/LiveLingo/releases/tag/v0.1.4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 647f2b9..d488bf2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ Thanks for your interest in contributing! This guide covers everything you need ## Quick Start ```bash -git clone https://github.com/user/LiveLingo.git +git clone https://github.com/AGIBuild/LiveLingo.git cd LiveLingo # Restore CLI tools (Velopack, WiX, Stryker) @@ -41,28 +41,32 @@ dotnet run --project src/LiveLingo.Desktop ``` LiveLingo/ ├── src/ -│ ├── LiveLingo.Core/ # Pure C# — translation pipeline, AI engines, models -│ │ ├── Engines/ # LLamaSharp (Gemma 4) + MarianMT ONNX engines -│ │ ├── Models/ # Model registry + download / install / cleanup -│ │ │ # collaborators (incl. ObsoleteModelCleaner) -│ │ ├── Processing/ # Post-processing (summarize, optimize, colloquialize) -│ │ ├── Settings/ # User settings model + JSON persistence -│ │ ├── Speech/ # sherpa-onnx STT engines (Cohere / SenseVoice), -│ │ │ # SpeechModelRouting, Silero VAD -│ │ └── Translation/ # Translation pipeline orchestration +│ ├── LiveLingo.Core/ # Pure C# — composer domain, inference, models +│ │ ├── Drafting/ Dictation/ # The draft, dictation session, fidelity gate +│ │ ├── Cleanup/ Translation/ # Sentence cleanup and translation schedulers over llama-server +│ │ ├── Inference/ Cloud/ # llama-server host, local and opt-in cloud chat clients +│ │ ├── Speech/Native/ # sherpa-onnx adapters (excluded from unit coverage) +│ │ ├── Transcription/ # WAV import and SRT export +│ │ └── Models/ # Catalog, download / install / cleanup collaborators │ │ -│ └── LiveLingo.Desktop/ # Avalonia 11 UI application +│ ├── LiveLingo.HfGguf/ # Hugging Face resolve URLs and the resumable downloader +│ │ +│ └── LiveLingo.Desktop/ # Avalonia shell + web UI +│ ├── Composer/ Settings/ # View models, texts and the thin windows around the pages +│ ├── Web/ # Bridge, view-state snapshots, intents, and Assets/ (the page: +│ │ # Preact + htm sources and CSS, embedded; no Node build) │ ├── Platform/ │ │ ├── Windows/ # Win32 hotkeys, SendInput text injection │ │ └── macOS/ # CGEventTap hotkeys, Cocoa text injection -│ ├── Views/ # AXAML windows + code-behind -│ ├── ViewModels/ # CommunityToolkit.Mvvm — pure C#, no Avalonia refs -│ ├── Services/ # Localization, update service -│ └── Styles/ # AppTheme, adaptive color system +│ └── Services/ # Settings, localization, update service │ +├── docs/superpowers/ # Design specs, plans, UX prototypes (see below) +├── poc/ # Validated POCs (sources only; corpora are not committed) ├── tests/ │ ├── LiveLingo.Core.Tests/ # Unit tests for Core -│ └── LiveLingo.Desktop.Tests/ # Unit tests for ViewModels +│ ├── LiveLingo.Desktop.Tests/ # Unit tests for Desktop services, view models and view states +│ └── LiveLingo.Web.Tests/ # Playwright tests of the page on WebKit and Chromium +│ # (golden fixtures are written by Desktop.Tests with UPDATE_FIXTURES=1) │ ├── build/ # Nuke build automation │ ├── BuildTask.cs # All build targets @@ -88,14 +92,14 @@ This is the single most important rule. It ensures ViewModels are fully testable - **No Avalonia references** — ViewModels must never import `Avalonia.*` namespaces - **No threading** — No `Dispatcher`, `Dispatcher.UIThread`, or thread scheduling APIs -- **Interface-only dependencies** — `ISettingsService`, `ITranslationPipeline`, `ITextInjector`, `IModelManager`, etc. +- **Interface-only dependencies** — `ISettingsService`, `IModelManager`, `ITextInjector`, `IVoiceInput`, etc. - **CommunityToolkit.Mvvm** — Use `ObservableObject`, `RelayCommand`, `[ObservableProperty]` - **UI communication** — Via events (`event Action? RequestClose`) or property change notifications ### View Layer -- Views handle **binding and layout only** -- Code-behind is limited to Avalonia-specific interactions: window positioning, focus, animation, drag +- The windows are thin Avalonia shells around a web view; the page draws snapshots and **does not hold logic** +- Window code is limited to Avalonia-specific interactions: window positioning, focus, parking - `Dispatcher` calls are only allowed in Views or `App.axaml.cs` ### Platform Layer @@ -118,55 +122,41 @@ LiveLingo uses [Nuke](https://nuke.build/) for build automation. All targets are ./build.sh Run # Launch in Debug mode ./build.sh Mutate # Run mutation testing only -# End-to-end probes (real model download + inference; not part of the unit-test gate) -./build.sh ProbeTranslation # Real Gemma 4 translation probe -./build.sh ProbeStt # Real sherpa-onnx STT probe (uses bundled wav) -./build.sh ProbeStt --ProbeWavPath ~/sample.wav \ - --ProbeSttLang en \ - --ProbeSttExpected "hello" # Custom wav + language + assertion - # Packaging ./build.sh Publish --Runtime osx-arm64 # Publish for macOS ./build.ps1 Publish --Runtime win-x64 # Publish for Windows -./build.ps1 Pack --Runtime win-x64 --Version 1.0.0 # Velopack .exe installer -./build.ps1 PackMsi --Runtime win-x64 --Version 1.0.0 # WiX .msi installer -./build.sh PackMac --Runtime osx-arm64 --Version 1.0.0 # macOS .pkg installer +./build.sh Pack --Runtime osx-arm64 --Version 1.0.0 # macOS: .pkg installer + update package and feed +./build.ps1 Pack --Runtime win-x64 --Version 1.0.0 # Windows: Setup.exe installer + update package and feed +./build.ps1 PackMsi --Runtime win-x64 --Version 1.0.0 # WiX .msi installer (does not update itself) ``` -Use `--Configuration Release` for release builds. On CI, configuration defaults to Release automatically. +End-to-end probes download the pinned models (SHA256-verified) and use real inference; they are not part of the unit-test gate: -### `ProbeStt` operational notes +```bash +./build.sh ProbeTranslation # runtime + translator on llama-server +./build.sh ProbeVoice --ProbeCorpus poc/voice-dictation/corpus # speech models + dictation + cleanup on recorded audio +``` -`ProbeStt` exercises the full sherpa-onnx pipeline on the host machine: -download → tar.bz2 extract → `OfflineRecognizer` init → real wav decode → -substring assertion. It is intentionally kept off the unit-test gate because -it requires network and disk for the model archive (~1.6 GB for Cohere -Transcribe). +Use `--Configuration Release` for release builds. On CI, configuration defaults to Release automatically. -| Parameter | Default | Purpose | -|-----------|---------|---------| -| `--ProbeWavPath` | `/test_wavs/en.wav` (bundled with the archive) | 16 kHz mono PCM16 wav file to transcribe | -| `--ProbeSttLang` | `""` (no hint) | Optional ISO-639-1 language hint | -| `--ProbeSttExpected` | `""` (no assertion) | Substring that must appear in the transcript | -| `--ProbeModelPath` | `%LocalAppData%/LiveLingo/models` | Where to cache the downloaded bundle | +### Obsolete model cleanup -The probe reuses the model cache between runs — the first invocation downloads -~1.6 GB, subsequent invocations reuse the extracted archive. +Settings lists model directories that are not in `ModelCatalog` (for example the 1.x Gemma, Marian and Qwen +processors) with their total size, and removes them through `IModelManager.CleanObsoleteModelsAsync` only when the user +presses *Remove old models*. Nothing is deleted silently (design spec, S7). -### Obsolete model cleanup +### Settings migration -`ObsoleteModelRegistry` (see `LiveLingo.Core/Models/`) lists model IDs that -are no longer shipped. On startup, `App.axaml.cs` fires -`IModelManager.CleanObsoleteModelsAsync` to delete the corresponding -directories and log the disk reclaimed. When retiring a model, add its ID to -`ObsoleteModelRegistry.Ids`; existing installs will sweep it on next launch. +`SettingsMigration` reads a schema 2 file (1.x) and keeps the hotkey, interface language, update settings and the +advanced paths and levels. Any other schema version starts from defaults. A new schema version must add its own +migration instead of resetting the file. --- ## Model downloads -Model downloads are a **process-global concern**: the Setup Wizard, the -Settings → Models cards, the Overlay STT status line and any background +Model downloads are a **process-global concern**: the first-run banner, the +settings model cards, the composer status line and any background "ensure model before use" call must all observe the same lifecycle so that - a download started from one surface is visible on every other surface, and @@ -187,8 +177,7 @@ is the single owner of that lifecycle. ``` This applies to every ViewModel, Service or background coordinator that - wants to *report* a download to the user — including the Overlay's STT - status line, the Setup Wizard, and any future Settings affordances. + wants to *report* a download to the user. 2. **`IModelManager.EnsureModelAsync` is a low-level primitive.** It is marked `[EditorBrowsable(Advanced)]` and the XML doc points new callers @@ -201,7 +190,6 @@ is the single owner of that lifecycle. `IModelDownloadCoordinator.StateChanged` must implement `IDisposable` (or already does) and unsubscribe in `Dispose` — otherwise the coordinator keeps the subscriber alive for the lifetime of the process. - `SetupWizardViewModel` and `OverlayViewModel` are the canonical examples. 4. **Tests use `InProcessModelDownloadCoordinator` over a mocked `IModelManager`** for integration-style tests, and mock the coordinator @@ -209,8 +197,7 @@ is the single owner of that lifecycle. `Raise.Event>(...)`) for ViewModel unit tests that want to drive specific lifecycle events. -For tests / design-time scenarios that genuinely don't need a real -coordinator, use `NullModelDownloadCoordinator.Instance`. +For tests that don't need a real coordinator, substitute `IModelDownloadCoordinator`. --- @@ -222,16 +209,15 @@ The `Test` target enforces three quality gates. **All must pass for CI to succee | Metric | Threshold | Measured by | |--------|-----------|-------------| -| Line coverage | ≥ **96%** | XPlat Code Coverage (Cobertura) | -| Branch coverage | ≥ **92%** | XPlat Code Coverage (Cobertura) | +| Line coverage | ≥ **80%** | XPlat Code Coverage (Cobertura) | +| Branch coverage | ≥ **65%** | XPlat Code Coverage (Cobertura) | **What's excluded from coverage** (configured in `test.runsettings`): - Platform implementations (`Platform.Windows.*`, `Platform.macOS.*`) -- View layer (`Views.*`, `Controls.*`) +- Window shells and the web view host (`ComposerWindow`, `SettingsWindow`, `WebSurface`, `StorageFileDialogs`) - Infrastructure (`Program`, `App`, `VelopackUpdateService`) -- AI model hosts that require GPU (`LlamaModelHost`, `MarianOnnxEngine`) -- sherpa-onnx engines (`SherpaCohereTranscribeEngine`, `SherpaSenseVoiceTranscribeEngine`) - — covered by the dedicated `ProbeStt` Nuke target instead of unit-test mocks +- AI model hosts and native adapters (`Inference.LlamaServerHost*`, `Speech.Native.*`) + — covered by end-to-end Nuke probe targets instead of unit-test mocks ### 2. Mutation Testing @@ -239,7 +225,9 @@ The `Test` target enforces three quality gates. **All must pass for CI to succee |--------|-----------|------| | Mutation score | ≥ **80%** | [Stryker.NET](https://stryker-mutator.io/) | -Stryker only targets `LiveLingo.Core` (source generators in Avalonia projects are incompatible). +Stryker only targets the pure-logic modules of `LiveLingo.Core` +(`Drafting`, `Translation`, `Dictation`, `Cleanup`; see `tests/LiveLingo.Core.Tests/stryker-config.json`). +Source generators in Avalonia projects are incompatible with it. ### 3. Test Conventions @@ -256,13 +244,34 @@ The CI pipeline (`.github/workflows/release.yml`) runs on tag push (`v*`) with f ``` compile → test → pack → release │ │ │ │ - │ │ │ └─ Create GitHub Release with installers - │ │ └─ Matrix build: win-x64 (.exe + .msi) / osx-arm64 (.pkg) - │ └─ Unit tests + coverage + mutation gates - └─ Build verification (all platforms) + │ │ │ └─ Check the update feeds, then create the GitHub Release + │ │ └─ Matrix build: win-x64 (Setup.exe + .msi) / osx-arm64 (.pkg), each with its update package and feed + │ └─ Unit tests + coverage gates (on macOS, where the web tests run both browsers) + └─ Build verification ``` -Only final installer artifacts (`.exe`, `.msi`, `.pkg`) are published — no source archives or debug files. +To release: push a tag `v` (or run *Create Tag*; a version with a suffix such as `2.1.0-rc.1` is published as a +pre-release). The version is the tag; nothing in the source needs editing. + +### Automatic updates + +Installed applications update themselves from the GitHub releases of the repository +(`Update.UpdateUrl` in the settings, default `https://github.com/AGIBuild/LiveLingo`) through +[Velopack](https://velopack.io/): + +- Every release carries, per platform, the full package (`LiveLingo--full.nupkg` on Windows, + `LiveLingo--osx-full.nupkg` on macOS) and its feed (`releases.win.json` / `releases.osx.json`). The channels + (`win`, `osx`) are Velopack's defaults for the platforms, so the feeds do not collide. The *release* job refuses to + publish a release whose feed does not name a package that is in it. +- The application checks at start and every `Update.CheckIntervalHours`, downloads a newer version in the background, + and then offers *Restart to Update* in the tray menu; quitting also installs it. Pre-releases are not offered. +- Only an application installed by the Setup.exe or the .pkg can update itself. A development build and the .msi cannot; + they skip the check. +- A release without a feed (the 1.x releases) is skipped. The application reads the latest 10 releases. +- Installers are not signed, so Windows SmartScreen and macOS Gatekeeper warn on the first install; updates run from the + installed application. Signing (`--signAppIdentity`, `--notaryProfile`) is not set up. + +Only the installers, the update packages and the feeds are published — no source archives or debug files. --- @@ -279,18 +288,17 @@ Only final installer artifacts (`.exe`, `.msi`, `.pkg`) are published — no sou - Tests for new logic (ViewModels, Core services) - No Avalonia references leaked into ViewModels - Coverage and mutation thresholds maintained -- Follows existing code style (Inter font, dark theme conventions) +- Follows the v3 prototype (`docs/superpowers/prototypes/livelingo-ux-v3/`): design tokens in `Web/Assets/styles/`, system font, light and dark themes +- UI changes keep the contract: state lives in C#; the page only draws a snapshot and sends closed-set intents --- ## Adding a New Language -1. Add the language code to the supported list in `LiveLingo.Core` -2. Ensure the AI model supports it. Gemma 4 26B-A4B MoE covers all major - languages; for voice input, check that the chosen STT bundle (Cohere - Transcribe 14-Lang, or SenseVoice CJK+EN) handles the source language. -3. Add UI translations in `Resources/i18n/` if adding a new UI locale -4. Add tests for the new language pair +Only Chinese ↔ English is validated (see `poc/text-translation/REPORT.md` and the +voice design spec). Adding a language requires a new evaluation run on a +representative corpus before it is exposed in the UI; UI locales live in +`Resources/i18n/`. --- diff --git a/LiveLingo.slnx b/LiveLingo.slnx index 441cb5d..5435113 100644 --- a/LiveLingo.slnx +++ b/LiveLingo.slnx @@ -3,15 +3,11 @@ - - - - - + diff --git a/README.md b/README.md index bb82f70..15657a3 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,91 @@ -
- -# 🌐 LiveLingo - -**Your words, any language, zero cloud. Instantly.** - -A privacy-first, AI-powered desktop translator that lives in your menu bar. -Press a hotkey → type → get your translation → send it. All offline. All local. All yours. - -![LiveLingo Demo](docs/demo/demo.gif) - -[![.NET](https://img.shields.io/badge/.NET_10-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/) -[![Avalonia](https://img.shields.io/badge/Avalonia_11-7B2BFC?logo=data:image/svg+xml;base64,&logoColor=white)](https://avaloniaui.net/) -[![Platform](https://img.shields.io/badge/Windows_%7C_macOS-0078D4?logo=windows&logoColor=white)](/) -[![License](https://img.shields.io/badge/license-MIT-green.svg)](/) - -
- ---- - -## Why LiveLingo? - -You're chatting with a colleague in Tokyo. Mid-conversation, you need to reply in Japanese — but your Japanese is… *creative* at best. You could open a browser, navigate to some translation site, copy-paste, wait for the response, copy-paste again… - -**Or you could just press `Ctrl+Alt+T`.** - -LiveLingo pops up as a sleek floating overlay — right where you're working. Type your message, get an instant AI translation, and hit **Send**. The translated text lands directly into the app you were using. No tab-switching. No copy-paste gymnastics. No data leaving your machine. - -> 💡 Think of it as having a personal interpreter sitting inside your keyboard. - ---- - -## ✨ Features - -### 🔒 100% Offline & Private - -Your text — and your voice — never leaves your computer. LiveLingo runs local AI -models for both translation and speech recognition: [Gemma 4 26B-A4B -MoE](https://huggingface.co/google) (Mixture-of-Experts, ~26 B-class quality at -~4 B-class latency) for translation, and [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) -(Cohere Transcribe 14-Lang or SenseVoice CJK-tuned) for speech-to-text. -No API keys, no cloud services, no "we may use your data to improve our products" surprises. - -### ⚡ Ridiculously Fast - -Translation in **~300ms**. The overlay appears instantly with a global hotkey. Type, translate, send — all in one fluid motion. - -### 🌍 10 Languages - -Chinese, English, Japanese, Korean, French, German, Spanish, Russian, Arabic, and Portuguese. Mix and match source and target languages freely. - -### 🎯 Smart Text Injection - -Two modes to fit your workflow: - -- **Paste Only** — drops the translation into your clipboard -- **Paste & Send** — pastes AND hits Enter for you (perfect for chat apps) - -### 🎙️ Voice Input - -Talk to LiveLingo instead of typing. Powered by sherpa-onnx with two -interchangeable engines: - -- **Accuracy First** — Cohere Transcribe (14 languages, top of the Open ASR - Leaderboard, ~1.6 GB) -- **Multilingual First** — SenseVoice Small (中 / 粤 / 英 / 日 / 韩, on-model - language detection, ~228 MB) - -Switch engines anytime from *Settings → Speech* — the active model card shows -exactly what will run, with a one-click jump to the *Models* tab when a -download is missing. - -### 🪟 Beautiful Floating Overlay - -A frosted-glass overlay that floats above everything. Drag it, resize it, adjust its transparency from 10% to fully opaque. The text automatically adapts contrast based on your opacity setting — readable on any desktop background. - -### 🖥️ Cross-Platform - -Native experience on both **Windows** and **macOS**. Platform-specific hotkey hooks, text injection, and UI polish on each. - -### 🔄 Auto-Update - -Built-in update system via [Velopack](https://velopack.io/) keeps you on the latest version without lifting a finger. - ---- - -## 📸 Screenshots - - - - - - - - - - -
- -**Translation Overlay** -*Floating, resizable, with adaptive transparency* - - - - - -**Long Text Translation** -*Handles paragraphs with ease* - - - -
- -**Settings — General** -*Custom hotkeys, opacity, injection mode* - - - - - -**Settings — AI Models** -*One-click download and management* - - - -
- ---- - -## 🚀 Getting Started - -### Install - -Download the latest release for your platform: - -| Platform | Format | Download | -|----------|--------|----------| -| Windows | `.exe` (auto-update) | [Releases](../../releases) | -| Windows | `.msi` (traditional) | [Releases](../../releases) | -| macOS | `.pkg` | [Releases](../../releases) | - -### First Launch - -1. **Download the AI models** — LiveLingo will prompt you on first run. - Translation needs Gemma 4 26B-A4B MoE (~17 GB; falls back to Gemma 4 E4B - ~5.4 GB on low-RAM devices). Voice input is optional — pull either - Cohere Transcribe (~1.6 GB) or SenseVoice Small (~228 MB) from - *Settings → Models* when you're ready. -2. **Set your hotkey** — Default is `Ctrl+Alt+T` (Windows) or `Option+Cmd+T` - (macOS). Customize it anytime. -3. **Start translating** — Press the hotkey anywhere, type or speak your - text, and go. - -> **macOS users:** Grant Accessibility and Input Monitoring permissions when -> prompted. LiveLingo needs these to register global hotkeys and inject text. - ---- - -## 🤝 Contributing - -Contributions are welcome! Whether it's a bug fix, new language support, or a feature idea — check the [Contributing Guide](CONTRIBUTING.md) to get started. - ---- - -
- -**Stop copy-pasting between translator tabs.** -**Start LiveLingo-ing.** 🚀 - -
+# LiveLingo + +Voice dictation and translation that stay on your computer. Press a hotkey in any app, speak or type, and the finished +text is written back into the input box you were using. + +LiveLingo 2.0 is a rewrite around one small window, the *composer*. The 1.x translator overlay is gone; its last +release is tag `v1.4.2`. + +![Composer prototype](docs/superpowers/prototypes/livelingo-ux-v3/overview.png) + +## How it works + +1. **Summon** with a global hotkey (default `Ctrl+Alt+T`). The composer opens above the input box you were in. +2. **Say it or type it.** Press `⌘D` (`Ctrl+D` on Windows) or click the microphone to start talking, and press it again to + stop; there is nothing to hold. You can also paste text or import a WAV recording. The other actions have shortcuts + too, shown next to them in the menus: `⌘1` / `⌘2` / `⌘3` pick the output, `⌘L` swaps the language, `⇧⌘C` copies, + `⌘N` starts a new draft, `⌘R` recent drafts, `⌘O` import, `⌘S` export, `⌘,` settings. +3. **Pick what is written**, with one control: *Clean up* (fillers, repetitions and homophone errors removed, meaning + untouched), *Translate to English / Chinese*, or *Original*. Your original words stay visible and can be restored for + any sentence. +4. **Press Enter.** The result goes into the app you came from. If that window is gone, nothing is typed anywhere + else; copy still works. + +Cleanup is checked by a fidelity gate: a rewrite that adds facts, drops negations or numbers, or drifts from what you +said is rejected and that sentence keeps your words. Cleanup can be switched off in Settings; the composer then offers +only *Translate* and *Original*. + +Imported recordings become a read-only timeline that goes through the same cleanup and translation, and can be +exported as SRT. + +## Privacy + +- Speech recognition, cleanup and translation run locally. Audio never leaves the machine. The speech models are + loaded in the background when the app starts, which takes about 1.5 GB of memory until they have been idle for 30 + minutes; turn that off in *Settings → Voice* to load them only when you summon the composer. +- Cloud models are **off by default**. In *Settings → Privacy and cloud* you can switch translation and cleanup to your own + OpenAI-compatible endpoint (for example Microsoft Foundry) separately. Only the text of that step is sent; the + endpoint must be `https`; the API key is kept in the system secret store, not in a file. A cloud failure is shown, + never silently retried on the local model. +- Models are downloaded only after you agree, from their original hosts, and verified against pinned SHA256 hashes. +- Recent drafts are kept in memory only and are gone when the app exits. +- The installed app looks for updates on the GitHub releases of this repository (at start and every few hours) and + downloads a newer version in the background. Nothing you wrote or said is part of that request. Set + `Update.UpdateUrl` to an empty string in `settings.json` to turn it off. +- The output and translation direction last chosen for an app are remembered for that app (identifier and the two + choices only, in `preferences.json` on this device; never titles, text or recordings). Turn it off, or forget one app + or all of them, in Settings. + +## Models + +| Role | Model | Size | +|---|---|---| +| Live recognition | X-ASR 480 ms streaming (zh-en) | ~130 MB | +| Settled recognition and recording import | Qwen3-ASR 0.6B | ~880 MB | +| Speech boundaries | Silero VAD | <1 MB | +| Cleanup | Qwen3.5 4B Q4_K_M | ~2.7 GB | +| Translation (balanced) / (quality) | Hy-MT2 1.8B / 7B Q4_K_M | ~1.1 GB / ~4.6 GB | +| Runtime | llama.cpp b11980 | ~16–24 MB | + +Licenses are listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + +## Requirements and status + +- Target hardware: Apple M2 or newer with 16 GB, or an equivalent Windows 11 PC. macOS 12+ and Windows 10/11. +- macOS needs Accessibility (to type into other apps) and Microphone permission. +- The interface is drawn by the system web view: WKWebView on macOS, WebView2 on Windows (Windows 11 includes it; how the + runtime reaches Windows 10 is not decided yet). +- Developed and measured on an M3 Pro (36 GB). **Not yet verified:** an M2/16 GB machine (memory with all models + resident, latency), anything running on Windows beyond a successful build and bundled native libraries, and a real + Foundry/OpenAI endpoint. +- Recording import reads uncompressed WAV only. m4a/mp3 need a platform decoder that is not built yet. +- The project license is not chosen yet; there is no `LICENSE` file. + +## Build and test + +```bash +dotnet tool restore +dotnet run --project src/LiveLingo.Desktop # run +./build.sh Test # unit tests with coverage gates +./build.sh ProbeVoice --ProbeCorpus poc/voice-dictation/corpus # end-to-end voice probe (downloads models) +``` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the project layout, build targets and rules. + +## Design + +- [Composer design](docs/superpowers/specs/2026-10-31-livelingo-composer-design.md) +- [Implementation plan and progress](docs/superpowers/plans/2026-10-31-composer-implementation.md) +- [UI prototype v3](docs/superpowers/prototypes/livelingo-ux-v3/README.md) (open `index.html` in a browser) +- Evidence behind the choices: [voice dictation design](docs/superpowers/specs/2026-10-29-voice-dictation-design.md), + [translation POC report](poc/text-translation/REPORT.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..89aa0b0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,48 @@ +# Third-party notices + +LiveLingo 2.0 ships a small set of native libraries and downloads open models on the user's request. This file +records what each component is, who owns it and under which license it is used. Licenses were read from the upstream +repositories and model cards on 2026-10-31; re-check them when a pinned revision changes (the pins live in +`src/LiveLingo.Core/Models/ModelCatalog.cs`, which also shows each model's license in the download banner data). + +This is an engineering inventory, not legal advice. + +## Models (downloaded from their original hosts after the user agrees; never bundled) + +| Model | Role | Source of the pinned file | License | +|---|---|---|---| +| Hy-MT2 1.8B / 7B, Q4_K_M | Translation | `tencent/Hy-MT2-1.8B-GGUF`, `tencent/Hy-MT2-7B-GGUF` (Hugging Face) | Apache-2.0 (model card and `LICENSE.txt`) | +| Qwen3.5 4B, Q4_K_M | Text cleanup | `unsloth/Qwen3.5-4B-GGUF`, derived from `Qwen/Qwen3.5-4B` | Apache-2.0 (model card links the Qwen license) | +| X-ASR 480 ms streaming zh-en, int8 | Live recognition | sherpa-onnx `asr-models` release; upstream `Gilgamesh-J/X-ASR` | Apache-2.0 (upstream `LICENSE`) | +| Qwen3-ASR 0.6B, int8 | Second pass and recording import | sherpa-onnx `asr-models` release; upstream `Qwen/Qwen3-ASR-0.6B` | Apache-2.0 (model card) | +| Silero VAD | Speech boundaries | sherpa-onnx `asr-models` release; upstream `snakers4/silero-vad` | MIT (upstream `LICENSE`) | + +Every download is verified against a pinned SHA256 before it is used. + +## Runtimes and libraries shipped with the app + +| Component | Use | License | +|---|---|---| +| llama.cpp b11980 (`llama-server`) | Runs the translation and cleanup models; downloaded on demand | MIT | +| sherpa-onnx 2.0.1 (`org.k2fsa.sherpa.onnx`) with ONNX Runtime | Speech recognition | Apache-2.0 (sherpa-onnx), MIT (ONNX Runtime) | +| PortAudio via PortAudioSharp2 | Microphone capture | MIT | +| Avalonia 12 preview | UI | MIT | +| Preact 10.29.8 (`dist/preact.min.js`, sha256 `25aa00978fef96e4fd0020cc723a41c3b1a1e81c9c087eff26a77e07a4867e8e`) | Renders the composer and settings in the web view; vendored in `src/LiveLingo.Desktop/Web/Assets/vendor` | MIT | +| htm 3.1.1 (`dist/htm.js`, sha256 `80e39afe20fd61183412eda89efa10532d57945e6364642aceacd50eb2384b4b`) | Template-literal views for Preact; vendored next to it | Apache-2.0 | +| CommunityToolkit.Mvvm | View models | MIT | +| Velopack | Updates and installers | MIT | +| Serilog | Logging | Apache-2.0 | +| SharpCompress | Archive extraction | MIT | + +## Project license + +The repository has no `LICENSE` file yet, although the 1.x README displayed an MIT badge. The owner must choose and add +the license before a release. Nothing above restricts that choice: all components are permissive and require only that +their notices are kept, which this file does for the shipped binaries (include it in installers). + +## Open points + +- The Apache-2.0 components require a copy of the license text and any NOTICE files in distributions. The installers + do not include them yet. +- Model licenses are those declared on the model cards. A later model revision may change them; the pinned commit + hashes prevent that from happening silently. diff --git a/build/BuildTask.cs b/build/BuildTask.cs index 0b8c09f..facec36 100644 --- a/build/BuildTask.cs +++ b/build/BuildTask.cs @@ -38,32 +38,11 @@ class BuildTask : NukeBuild [Parameter("Minimum mutation score percentage")] readonly int MutationThreshold = 80; - [Parameter("Enable translation regression probe target")] - readonly bool EnableTranslationProbe = true; - - [Parameter("Model path used by translation probe")] + [Parameter("Model storage directory used by end-to-end probes (defaults to the app's model directory)")] readonly string ProbeModelPath = string.Empty; - [Parameter("Source text used by translation probe")] - readonly string ProbeSourceText = "你好"; - - [Parameter("Expected substring in translation result")] - readonly string ProbeExpectedContains = "hello"; - - [Parameter("Batch probe cases: source=>expected;source=>expected")] - readonly string ProbeCases = "你好=>hello;谢谢=>thank;早上好=>morning"; - - [Parameter("Enable sherpa-onnx STT regression probe target")] - readonly bool EnableSttProbe = true; - - [Parameter("Path to a 16 kHz mono wav file used by the STT probe")] - readonly string ProbeWavPath = string.Empty; - - [Parameter("Optional language hint (ISO-639-1) for the STT probe, e.g. en / zh / ja")] - readonly string ProbeSttLang = string.Empty; - - [Parameter("Expected substring in the STT probe transcription result")] - readonly string ProbeSttExpected = string.Empty; + [Parameter("Directory with scenarios.json and audio/.wav used by ProbeVoice (see poc/voice-dictation/corpus)")] + readonly string ProbeCorpus = string.Empty; AbsolutePath SourceDir => RootDirectory / "src"; AbsolutePath TestsDir => RootDirectory / "tests"; @@ -226,99 +205,45 @@ private static void RunProcessLogger(OutputType type, string output) .SetProcessLogger(RunProcessLogger)); }); + // End-to-end probe: real download (SHA256-verified), real llama-server, real translation. Not part of the unit-test gate. Target ProbeTranslation => _ => _ .DependsOn(Build) - .OnlyWhenDynamic(() => EnableTranslationProbe) - .Executes(() => - { - var probeProject = TestsDir / "LiveLingo.Core.Tests" / "LiveLingo.Core.Tests.csproj"; - var modelPath = string.IsNullOrWhiteSpace(ProbeModelPath) - ? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo", - "models") - : ProbeModelPath; - var env = new Dictionary - { - ["LIVELINGO_ENABLE_TRANSLATION_PROBE"] = "1", - ["LIVELINGO_PROBE_SOURCE_TEXT"] = ProbeSourceText, - ["LIVELINGO_PROBE_EXPECTED_CONTAINS"] = ProbeExpectedContains, - ["LIVELINGO_PROBE_MODEL_PATH"] = modelPath - }.AsReadOnly(); - - Log.Information("Running translation probe with source text: {Text}", ProbeSourceText); - Log.Information("Probe model path: {ModelPath}", modelPath); - - DotNet( - $"test \"{probeProject}\" " + - $"--configuration {Configuration} " + - $"--no-build " + - $"--filter \"FullyQualifiedName~MarianTranslationProbeTests\" " + - $"--nologo", - environmentVariables: env); - }); - - Target ProbeTranslationAll => _ => _ - .DependsOn(Build) - .OnlyWhenDynamic(() => EnableTranslationProbe) .Executes(() => { - var probeProject = TestsDir / "LiveLingo.Core.Tests" / "LiveLingo.Core.Tests.csproj"; - var modelPath = string.IsNullOrWhiteSpace(ProbeModelPath) - ? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo", - "models") - : ProbeModelPath; var env = new Dictionary { ["LIVELINGO_ENABLE_TRANSLATION_PROBE"] = "1", - ["LIVELINGO_PROBE_CASES"] = ProbeCases, - ["LIVELINGO_PROBE_MODEL_PATH"] = modelPath + ["LIVELINGO_PROBE_MODEL_PATH"] = ProbeModelPath }.AsReadOnly(); - Log.Information("Running translation batch probe with cases: {Cases}", ProbeCases); - Log.Information("Probe model path: {ModelPath}", modelPath); - DotNet( - $"test \"{probeProject}\" " + + $"test \"{TestsDir / "LiveLingo.Core.Tests" / "LiveLingo.Core.Tests.csproj"}\" " + $"--configuration {Configuration} " + $"--no-build " + - $"--filter \"FullyQualifiedName~Translate_ZhToEn_BatchCases_ProducesExpectedText\" " + + $"--filter \"FullyQualifiedName~TranslationProbeTests\" " + + $"--logger \"console;verbosity=detailed\" " + $"--nologo", environmentVariables: env); }); - Target ProbeStt => _ => _ + // End-to-end probe of the voice chain: speech models, dictation session and cleanup on recorded audio. + Target ProbeVoice => _ => _ .DependsOn(Build) - .OnlyWhenDynamic(() => EnableSttProbe) + .Requires(() => ProbeCorpus) .Executes(() => { - var probeProject = TestsDir / "LiveLingo.Core.Tests" / "LiveLingo.Core.Tests.csproj"; - var modelPath = string.IsNullOrWhiteSpace(ProbeModelPath) - ? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo", - "models") - : ProbeModelPath; - var env = new Dictionary { - ["LIVELINGO_ENABLE_STT_PROBE"] = "1", - ["LIVELINGO_PROBE_WAV_PATH"] = ProbeWavPath, - ["LIVELINGO_PROBE_LANG"] = ProbeSttLang, - ["LIVELINGO_PROBE_EXPECTED_CONTAINS"] = ProbeSttExpected, - ["LIVELINGO_PROBE_MODEL_PATH"] = modelPath + ["LIVELINGO_ENABLE_VOICE_PROBE"] = "1", + ["LIVELINGO_PROBE_CORPUS"] = ProbeCorpus, + ["LIVELINGO_PROBE_MODEL_PATH"] = ProbeModelPath }.AsReadOnly(); - Log.Information("Running STT probe with wav: {Wav}", ProbeWavPath); - Log.Information("Probe model path: {ModelPath}", modelPath); - DotNet( - $"test \"{probeProject}\" " + + $"test \"{TestsDir / "LiveLingo.Core.Tests" / "LiveLingo.Core.Tests.csproj"}\" " + $"--configuration {Configuration} " + $"--no-build " + - $"--filter \"FullyQualifiedName~SherpaSttProbeTests\" " + + $"--filter \"FullyQualifiedName~VoiceProbeTests\" " + $"--logger \"console;verbosity=detailed\" " + $"--nologo", environmentVariables: env); @@ -348,14 +273,7 @@ double RunMutationTesting() $"stryker " + $"--project LiveLingo.Core.csproj " + $"--break-at {MutationThreshold} " + - $"--reporter progress --reporter json " + - $"--mutate \"!**/Engines/MarianOnnxEngine*\" " + - $"--mutate \"!**/Processing/QwenModelHost.cs\" " + - $"--mutate \"!**/Processing/QwenTextProcessor.cs\" " + - $"--mutate \"!**/Processing/SummarizeProcessor.cs\" " + - $"--mutate \"!**/Processing/OptimizeProcessor.cs\" " + - $"--mutate \"!**/Processing/ColloquializeProcessor.cs\" " + - $"--mutate \"!**/ServiceCollectionExtensions.cs\"", + $"--reporter progress --reporter json", workingDirectory: coreTestDir); var reportFile = coreTestDir.GlobFiles("**/StrykerOutput/**/mutation-report.json") @@ -384,43 +302,60 @@ double RunMutationTesting() return total > 0 ? detected * 100.0 / total : 0; } + // One Velopack package per platform: the installer, the full package, and the feed (releases..json) that + // installed applications read to update themselves. The feed and the package must be released together. Target Pack => _ => _ .DependsOn(Publish) .Executes(() => { DotNetToolRestore(); - var mainExe = Runtime.StartsWith("win") ? "LiveLingo.Desktop.exe" : "LiveLingo.Desktop"; + var windows = Runtime.StartsWith("win"); + var mainExe = windows ? "LiveLingo.Desktop.exe" : "LiveLingo.Desktop"; + var icon = windows + ? SourceDir / "LiveLingo.Desktop" / "Assets" / "app-icon.ico" + : RootDirectory / "build" / "macos" / "app-icon.icns"; var tempDir = RootDirectory / ".nuke" / "temp" / "vpk"; tempDir.CreateDirectory(); - var vpkEnv = new Dictionary - { - ["DOTNET_ROLL_FORWARD"] = "LatestMajor", - ["TEMP"] = tempDir, - ["TMP"] = tempDir - }.AsReadOnly(); - - DotNet( + var arguments = $"vpk pack " + $"--packId LiveLingo " + + $"--packTitle LiveLingo " + + $"--packAuthors AGIBuild " + $"--packVersion {Version} " + $"--packDir {PublishDir / Runtime} " + $"--mainExe {mainExe} " + - $"--outputDir {ReleasesDir}", - environmentVariables: vpkEnv); + $"--icon {icon} " + + $"--outputDir {ReleasesDir} " + + $"--noPortable"; - if (Runtime.StartsWith("win")) + if (!windows) { - var setupExe = ReleasesDir.GlobFiles("*Setup*.exe").FirstOrDefault(); - if (setupExe is not null) - { - var renamed = ReleasesDir / $"LiveLingo-{Version}-{Runtime}-Setup.exe"; - File.Move(setupExe, renamed, overwrite: true); - Log.Information("Renamed installer: {Path}", renamed); - } + // The bundle version has no pre-release suffix; the update feed carries the full semantic version. + var bundleVersion = Version.Split('-')[0]; + var plist = tempDir / "Info.plist"; + File.WriteAllText(plist, File.ReadAllText(RootDirectory / "build" / "macos" / "Info.plist").Replace("__VERSION__", bundleVersion)); + arguments += $" --plist {plist}"; } + + // Replaces the process environment, so the rest of it (PATH: vpk calls cp, codesign) is carried over. + var vpkEnv = EnvironmentInfo.Variables + .ToDictionary(variable => variable.Key, variable => variable.Value); + vpkEnv["DOTNET_ROLL_FORWARD"] = "LatestMajor"; + vpkEnv["TEMP"] = tempDir; + vpkEnv["TMP"] = tempDir; + + DotNet(arguments, environmentVariables: vpkEnv.AsReadOnly()); + + // The installer keeps the file name of earlier releases. + var installer = ReleasesDir.GlobFiles(windows ? "*Setup.exe" : "*Setup.pkg").Single(); + var renamed = ReleasesDir / (windows + ? $"LiveLingo-{Version}-{Runtime}-Setup.exe" + : $"LiveLingo-{Version}-{Runtime}.pkg"); + File.Move(installer, renamed, overwrite: true); + Log.Information("Installer: {Path}", renamed); }); Target PackMsi => _ => _ @@ -444,85 +379,6 @@ double RunMutationTesting() Log.Information("Windows MSI created: {Path}", msiOutput); }); - Target PackMac => _ => _ - .DependsOn(Publish) - .Requires(() => Runtime.StartsWith("osx")) - .Executes(() => - { - var macosDir = RootDirectory / "build" / "macos"; - var appBundle = PublishDir / "LiveLingo.app"; - var contentsDir = appBundle / "Contents"; - var macOsDir = contentsDir / "MacOS"; - var resourcesDir = contentsDir / "Resources"; - - appBundle.DeleteDirectory(); - macOsDir.CreateDirectory(); - resourcesDir.CreateDirectory(); - - var infoPlist = File.ReadAllText(macosDir / "Info.plist"); - infoPlist = infoPlist.Replace("__VERSION__", Version); - File.WriteAllText(contentsDir / "Info.plist", infoPlist); - - File.Copy(macosDir / "entitlements.plist", contentsDir / "entitlements.plist", overwrite: true); - - var publishedFiles = PublishDir / Runtime; - foreach (var file in Directory.GetFiles(publishedFiles, "*", SearchOption.AllDirectories)) - { - var relative = Path.GetRelativePath(publishedFiles, file); - var dest = macOsDir / relative; - Directory.CreateDirectory(Path.GetDirectoryName(dest)!); - File.Copy(file, dest, overwrite: true); - } - - var mainExe = macOsDir / "LiveLingo.Desktop"; - if (File.Exists(mainExe)) - Chmod(mainExe, "755"); - - var svgIcon = RootDirectory / "src" / "LiveLingo.Desktop" / "Assets" / "app-icon.svg"; - if (File.Exists(svgIcon)) - File.Copy(svgIcon, resourcesDir / "app-icon.svg", overwrite: true); - - ReleasesDir.CreateDirectory(); - - var componentPkg = PublishDir / "LiveLingo-component.pkg"; - RunProcess("pkgbuild", - $"--root \"{appBundle}\" " + - $"--identifier com.livelingo.app " + - $"--version {Version} " + - $"--install-location /Applications/LiveLingo.app " + - $"\"{componentPkg}\""); - - var finalPkg = ReleasesDir / $"LiveLingo-{Version}-{Runtime}.pkg"; - RunProcess("productbuild", - $"--package \"{componentPkg}\" " + - $"\"{finalPkg}\""); - - componentPkg.DeleteFile(); - Log.Information("macOS PKG created: {Path}", finalPkg); - }); - - static void Chmod(string path, string mode) - { - RunProcess("chmod", $"{mode} \"{path}\""); - } - - static void RunProcess(string tool, string arguments) - { - var psi = new System.Diagnostics.ProcessStartInfo(tool, arguments) - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - using var proc = System.Diagnostics.Process.Start(psi)!; - proc.WaitForExit(); - if (proc.ExitCode != 0) - { - var stderr = proc.StandardError.ReadToEnd(); - throw new Exception($"{tool} failed (exit {proc.ExitCode}): {stderr}"); - } - } - static bool IsCoverageClassIncluded(string packageName, string className) { if (packageName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase)) diff --git a/build/_build.csproj b/build/_build.csproj index 13cecb3..99de4a3 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -15,8 +15,9 @@ - - + + +
diff --git a/build/macos/Info.plist b/build/macos/Info.plist index 330066c..e223228 100644 --- a/build/macos/Info.plist +++ b/build/macos/Info.plist @@ -16,6 +16,8 @@ APPL CFBundleExecutable LiveLingo.Desktop + CFBundleIconFile + app-icon LSMinimumSystemVersion 12.0 NSHighResolutionCapable @@ -24,5 +26,7 @@ NSPrincipalClass NSApplication + NSMicrophoneUsageDescription + LiveLingo listens to your voice only while you dictate. Audio is recognized on this Mac and is never sent anywhere. diff --git a/build/macos/app-icon.icns b/build/macos/app-icon.icns new file mode 100644 index 0000000..68a8758 Binary files /dev/null and b/build/macos/app-icon.icns differ diff --git a/build/macos/entitlements.plist b/build/macos/entitlements.plist deleted file mode 100644 index b5d079c..0000000 --- a/build/macos/entitlements.plist +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.automation.apple-events - - com.apple.security.device.audio-input - - com.apple.security.personal-information.addressbook - - - diff --git a/docs/demo/demo.gif b/docs/demo/demo.gif deleted file mode 100644 index 4beb9bd..0000000 Binary files a/docs/demo/demo.gif and /dev/null differ diff --git a/docs/demo/demo.mov b/docs/demo/demo.mov deleted file mode 100644 index ab008d5..0000000 Binary files a/docs/demo/demo.mov and /dev/null differ diff --git a/docs/demo/settings-AI-models-download.jpg b/docs/demo/settings-AI-models-download.jpg deleted file mode 100644 index c3cbc6f..0000000 Binary files a/docs/demo/settings-AI-models-download.jpg and /dev/null differ diff --git a/docs/demo/settings-general.jpg b/docs/demo/settings-general.jpg deleted file mode 100644 index b10b310..0000000 Binary files a/docs/demo/settings-general.jpg and /dev/null differ diff --git a/docs/demo/transalate-window.jpg b/docs/demo/transalate-window.jpg deleted file mode 100644 index ac1a96c..0000000 Binary files a/docs/demo/transalate-window.jpg and /dev/null differ diff --git a/docs/demo/translate-long-text.jpg b/docs/demo/translate-long-text.jpg deleted file mode 100644 index 1e45a4c..0000000 Binary files a/docs/demo/translate-long-text.jpg and /dev/null differ diff --git a/docs/icons/app-a-v3-wave.svg b/docs/icons/app-a-v3-wave.svg deleted file mode 100644 index 6f91f4e..0000000 --- a/docs/icons/app-a-v3-wave.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - LIVE - - - - En - - - diff --git a/docs/icons/tray-a-v5-compact.svg b/docs/icons/tray-a-v5-compact.svg deleted file mode 100644 index 547cbb2..0000000 --- a/docs/icons/tray-a-v5-compact.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - LIVE - - - - En - - - diff --git a/docs/poc-design.md b/docs/poc-design.md deleted file mode 100644 index ddf27cb..0000000 --- a/docs/poc-design.md +++ /dev/null @@ -1,240 +0,0 @@ -# LiveLingo PoC 技术方案 - -> 本文档记录 PoC 阶段验证通过的技术方案,供正式产品开发参考。 - -## 1. 产品目标 - -为多语言团队提供桌面端实时翻译输入工具。用户在 Slack 等 IM 工具中按下全局快捷键,弹出翻译输入框,输入母语后自动翻译并注入到目标应用的输入框中。 - -## 2. 技术选型 - -| 项目 | 选型 | 理由 | -|------|------|------| -| 运行时 | .NET 10 | 最新 LTS,P/Invoke + LibraryImport 性能好 | -| UI 框架 | Avalonia 11.x | 跨平台桌面 UI,支持无边框透明窗口 | -| MVVM | CommunityToolkit.Mvvm | 源码生成器驱动,零反射开销 | -| 翻译引擎 | PoC 阶段为 stub | 正式版计划使用 ONNX Runtime 本地推理 | - -## 3. 架构概览 - -``` -┌─────────────────────────────────────────────┐ -│ App.axaml.cs │ -│ ┌───────────────┐ ┌────────────────────┐ │ -│ │ GlobalKeyboard│ │ WindowTracker │ │ -│ │ Hook │──│ (捕获目标窗口信息) │ │ -│ └───────┬───────┘ └────────┬───────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌───────────────────────────────────────┐ │ -│ │ OverlayWindow + VM │ │ -│ │ (浮动输入框,翻译预览,模式切换) │ │ -│ └───────────────┬───────────────────────┘ │ -│ │ Ctrl+Enter │ -│ ▼ │ -│ ┌───────────────────────────────────────┐ │ -│ │ TextInjector │ │ -│ │ S1: SendInput Ctrl+V │ │ -│ │ S2: WM_CHAR → Chrome renderer │ │ -│ └───────────────────────────────────────┘ │ -└─────────────────────────────────────────────┘ -``` - -## 4. 核心机制 - -### 4.1 全局快捷键 - -使用 `WH_KEYBOARD_LL` 低级键盘钩子实现全局快捷键检测(当前为 Ctrl+Alt+T)。 - -- 通过 `SetWindowsHookExW` 安装钩子 -- 回调中检测 `VK_T` + `GetAsyncKeyState(VK_CONTROL)` + `GetAsyncKeyState(VK_MENU)` -- 触发后通过 `Dispatcher.UIThread.Post` 回到 UI 线程 - -**正式版注意**:macOS 需用 `CGEventTap`,Linux 需用 `XGrabKey` 或 libinput。 - -### 4.2 目标窗口识别 - -`WindowTracker.GetForegroundWindowInfo()` 在快捷键触发时调用,此时目标应用仍是前台窗口。 - -捕获内容: -- 主窗口句柄(`GetForegroundWindow`) -- 进程名、窗口位置/大小 -- **输入子窗口句柄**(关键) - -#### 输入子窗口查找策略 - -``` -FindInputChild(mainWindow, threadId) - │ - ├─ 策略 1:FindChromeRenderer(递归搜索) - │ └─ 遍历子窗口树,查找 class = "Chrome_RenderWidgetHostHWND" - │ └─ 适用于所有 Electron/Chromium 应用(Slack, Teams, Discord, VS Code) - │ - └─ 策略 2:GetGUIThreadInfo - └─ 获取 hwndFocus,适用于原生 Win32 应用(Notepad 等) -``` - -**关键发现**:Electron 应用的 Win32 焦点(`GUITHREADINFO.hwndFocus`)停留在主窗口上,实际输入由 `Chrome_RenderWidgetHostHWND` 子窗口处理。必须使用 `FindWindowExW` 递归搜索。 - -Slack 的窗口结构验证结果: -``` -Slack (hwnd=0x3C069A) - ├─ 0x1300A0 [Chrome_RenderWidgetHostHWND] ← 注入目标 - └─ 0x86017E [Intermediate D3D Window] -``` - -### 4.3 浮动输入框(Overlay) - -| 特性 | 实现方式 | -|------|----------| -| 无边框透明 | `SystemDecorations="None"` + `TransparencyLevelHint="Transparent"` | -| 始终置顶 | `Topmost="True"` | -| 可拖动 | 顶部 `DragHandle` Border + `BeginMoveDrag` | -| 焦点劫持 | `ForceActivateWindows()` 使用 `AttachThreadInput` + `SetForegroundWindow` | -| 快捷键拦截 | `AddHandler(KeyDownEvent, handler, RoutingStrategies.Tunnel)` | - -**焦点劫持细节**:由于 overlay 需要从其他应用手中抢夺前台焦点,简单的 `Activate()` 不够。需要: -1. `AttachThreadInput` 将当前线程与前台线程合并输入状态 -2. `BringWindowToTop` + `SetForegroundWindow` 强制切换 -3. `DispatcherTimer.RunOnce(150ms)` 延迟后再 `TextBox.Focus()` - -**Tunnel 路由**:`TextBox(AcceptsReturn=True)` 会在 Bubble 阶段吞掉 Enter 事件。使用 Tunnel 路由在 TextBox 之前拦截 Ctrl+Enter 和 Esc。 - -### 4.4 文本注入 - -`TextInjector.InjectText` 实现双策略自动降级: - -#### 策略 1:SendInput Ctrl+V(主线) - -``` -ReleaseAllModifiers() // 释放物理按键状态 - → SetClipboardText() // 写入剪贴板 - → ForceForeground(target) // 切回目标窗口 - → Thread.Sleep(500) // 等待前台切换完成 - → SendInput(Ctrl+V) // 模拟键盘粘贴 - → [可选] SendInput(Enter) // 模拟回车发送 -``` - -**前提条件**: -- 调用进程必须是当前前台进程(或刚刚是前台) -- 目标进程与调用进程处于相同完整性级别(UIPI 限制) -- 在 Avalonia GUI 流程中自然满足(overlay 就是前台窗口) - -**验证结果**:`SendInput returned 4, err=0`,Slack 处于前台时完全可用。 - -#### 策略 2:WM_CHAR 逐字符发送(回退) - -``` -foreach (char ch in text) - PostMessageW(chromeRenderer, WM_CHAR, ch, 0) -→ [可选] PostMessageW(WM_KEYDOWN/WM_KEYUP, VK_RETURN) -``` - -当 SendInput 因 UIPI 被拒绝时自动降级到此策略。 - -**优势**: -- `PostMessage` 直接寻址目标 HWND,不依赖前台状态 -- 不需要剪贴板(但 PoC 中仍先写剪贴板以供策略 1 使用) -- Chromium 的 `Chrome_RenderWidgetHostHWND` 处理 `WM_CHAR` 消息 - -**限制**: -- 不保证对所有 Chromium 版本有效 -- BMP 以外的 Unicode 字符(emoji 等)需要代理对处理 - -### 4.5 注入模式配置 - -| 模式 | 行为 | 快捷键 | -|------|------|--------| -| Paste & Send | 粘贴翻译文本 + 自动按 Enter 发送 | Ctrl+Enter | -| Paste Only | 仅粘贴翻译文本到光标位置 | Ctrl+Enter | - -模式通过 overlay 底部按钮切换,选择在 overlay 实例间通过 `static` 字段持久保持。 - -### 4.6 前台窗口管理 - -Windows 严格限制 `SetForegroundWindow` 的调用权限。`ForceForeground` 的完整流程: - -```csharp -AllowSetForegroundWindow(ASFW_ANY) -ShowWindow(hwnd, SW_SHOW) -BringWindowToTop(hwnd) -AttachThreadInput(myThread, fgThread, true) // 合并输入状态 -SetForegroundWindow(hwnd) // 切换前台 -AttachThreadInput(myThread, fgThread, false) // 分离 -``` - -## 5. Win32 API 清单 - -| API | 用途 | -|-----|------| -| `SetWindowsHookExW(WH_KEYBOARD_LL)` | 全局键盘钩子 | -| `GetForegroundWindow` | 获取前台窗口 | -| `GetWindowThreadProcessId` | 获取窗口的线程和进程 ID | -| `GetWindowRect` | 获取窗口位置大小 | -| `FindWindowExW` | 遍历子窗口树 | -| `GetClassNameW` | 获取窗口类名(用于识别 Chrome renderer) | -| `GetGUIThreadInfo` | 获取线程的焦点窗口 | -| `SetForegroundWindow` | 切换前台窗口 | -| `BringWindowToTop` | 提升窗口 Z 序 | -| `AttachThreadInput` | 合并/分离线程输入状态 | -| `AllowSetForegroundWindow` | 授予前台切换权限 | -| `SendInput` | 注入键盘事件到系统输入队列 | -| `PostMessageW` | 直接向指定窗口投递消息 | -| `GetAsyncKeyState` | 查询物理按键状态 | -| `OpenClipboard / SetClipboardData` | 剪贴板操作 | -| `GlobalAlloc / GlobalLock` | 剪贴板内存管理 | - -## 6. 已知限制与正式版改进方向 - -| 限制 | 改进方向 | -|------|----------| -| 仅支持 Windows | macOS: Accessibility API + CGEventTap; Linux: AT-SPI + XTest | -| 翻译为 stub | 集成 ONNX Runtime 本地推理(如 NLLB-200、MarianMT) | -| 注入使用 Thread.Sleep 阻塞 UI 线程 | 改为 async + TaskCompletionSource,或独立注入线程 | -| 模式配置仅内存持久 | 持久化到本地配置文件(JSON / SQLite) | -| 快捷键硬编码 | 用户可配置快捷键 | -| 单向翻译(母语→英语) | 自动检测语言,双向翻译 | -| 目标语言固定英语 | 可选目标语言 | -| 无错误恢复 | SendInput 失败时弹出通知,提示以管理员运行 | -| Chrome renderer 子窗口只取第一个 | 多 renderer 场景需结合窗口可见性/大小判断 | - -## 7. 项目结构 - -``` -LiveLingo/ -├── LiveLingo.slnx -├── docs/ -│ └── poc-design.md ← 本文档 -└── src/ - └── LiveLingo.App/ - ├── LiveLingo.App.csproj - ├── Program.cs # 入口,支持 --test-inject / --diag-window / --test-slack - ├── App.axaml / .cs # 应用生命周期,全局快捷键注册,overlay 管理 - ├── app.manifest - ├── Views/ - │ ├── MainWindow.axaml / .cs # 主窗口(显示快捷键提示) - │ └── OverlayWindow.axaml / .cs # 浮动翻译输入框 - ├── ViewModels/ - │ └── OverlayViewModel.cs # 输入/翻译/模式 状态管理 - └── Services/Platform/Windows/ - ├── NativeMethods.cs # P/Invoke 声明(LibraryImport) - ├── GlobalKeyboardHook.cs # WH_KEYBOARD_LL 全局键盘钩子 - ├── WindowTracker.cs # 前台窗口信息 + Chrome renderer 查找 - ├── TextInjector.cs # 双策略文本注入 - ├── InjectionTest.cs # 交互式注入测试(Notepad/Slack) - ├── SlackAutoTest.cs # 非交互式 Slack 注入测试 - └── WindowDiagnostic.cs # 窗口树诊断工具 -``` - -## 8. CLI 诊断工具 - -```bash -# 交互式注入测试(支持 Notepad / Slack / 前台窗口) -dotnet run -- --test-inject - -# 窗口树诊断(不指定进程则 5 秒内点击目标窗口) -dotnet run -- --diag-window Slack - -# 非交互式 Slack 三策略注入测试(WM_CHAR / SendInput / WM_PASTE) -dotnet run -- --test-slack -``` diff --git a/docs/product-design.md b/docs/product-design.md deleted file mode 100644 index 1bcbb56..0000000 --- a/docs/product-design.md +++ /dev/null @@ -1,526 +0,0 @@ -# LiveLingo 正式产品技术设计 - -> 基于 PoC 验证结果(见 poc-design.md),面向正式产品的架构设计。 - -## 1. 产品定位 - -桌面端实时翻译输入工具。用户在 Slack/Teams/Discord 等 IM 中按全局快捷键唤出翻译浮窗, -输入母语后实时翻译,支持 AI 驱动的总结、优化、口语化后处理,最终注入目标应用。 - -## 2. 技术决策 - -| 项 | 选型 | 理由 | -|----|------|------| -| 运行时 | .NET 10 | LTS,LibraryImport 性能好,跨平台 | -| UI | Avalonia 11.x | 跨平台桌面 UI,无边框透明窗口 | -| MVVM | CommunityToolkit.Mvvm | 源码生成器,零反射 | -| 翻译引擎 | MarianMT + ONNX Runtime | 每语言对 ~30MB,推理 <200ms | -| 后处理 LLM | Qwen2.5-1.5B + LLamaSharp | GGUF Q4 ~1GB,口语化/润色/总结 | -| 语言检测 | FastText (ONNX) 或 CLD3 | <1ms,自动识别源语言 | -| 架构预留 | ITranslationEngine 抽象 | v2 可替换为 CTranslate2 / Bergamot | - -## 3. 分层架构 - -``` -┌─────────────────────────────────────────────────┐ -│ LiveLingo.App (Avalonia) │ -│ UI · 生命周期 · DI 组装 · 用户配置 │ -│ │ -│ ┌─────────────────────────────────────────────┐ │ -│ │ Platform (内部抽象) │ │ -│ │ IPlatformServices │ │ -│ │ ├── IHotkeyService │ │ -│ │ ├── IWindowTracker │ │ -│ │ ├── ITextInjector │ │ -│ │ └── IClipboardService │ │ -│ │ │ │ -│ │ ┌──────────────┐ ┌──────────────────────┐ │ │ -│ │ │ Windows │ │ macOS │ │ │ -│ │ │ WH_KEYBOARD_LL│ │ CGEventTap │ │ │ -│ │ │ SendInput │ │ AXUIElement │ │ │ -│ │ │ FindWindowEx │ │ NSWorkspace │ │ │ -│ │ └──────────────┘ └──────────────────────┘ │ │ -│ └─────────────────────────────────────────────┘ │ -├─────────────────────────────────────────────────┤ -│ LiveLingo.Core (可复用) │ -│ │ -│ ITranslationPipeline │ -│ ├── ILanguageDetector │ -│ ├── ITranslationEngine (MarianMT/ONNX) │ -│ └── ITextProcessor[] (Qwen/LLamaSharp) │ -│ │ -│ IModelManager │ -│ ├── 模型下载 / 缓存 / 版本管理 │ -│ └── 按需加载 / 卸载 │ -└─────────────────────────────────────────────────┘ -``` - -### 3.1 层职责 - -| 层 | 项目 | 职责 | 可复用 | -|----|------|------|--------| -| App | LiveLingo.App | Avalonia UI、DI 组装、平台分发、配置持久化 | 否 | -| Platform | LiveLingo.App 内部 | 全局快捷键、窗口识别、文本注入、剪贴板 | 否 | -| Core | LiveLingo.Core | 翻译管线、AI 模型管理、语言检测、后处理 | **是** | - -### 3.2 依赖方向 - -``` -App ──依赖──▶ Core -App ──内含──▶ Platform (接口 + 实现) -Core ──不依赖──▶ App / Platform -``` - -Core 对外零依赖(除 ONNX Runtime / LLamaSharp 等推理库), -其他产品引用 Core 即可使用完整的翻译管线。 - -## 4. 翻译管线(Pipeline) - -### 4.1 管线数据流 - -``` -用户输入 (母语) - │ - ▼ -┌───────────────────┐ -│ 语言检测 │ FastText / CLD3 (<1ms) -│ auto → "zh" │ -└─────────┬─────────┘ - ▼ -┌───────────────────┐ -│ 机器翻译 │ MarianMT via ONNX Runtime -│ zh → en │ ~100-200ms/句 -└─────────┬─────────┘ - ▼ -┌───────────────────┐ ← 用户可选开关 -│ 后处理器链 │ Qwen2.5-1.5B via LLamaSharp -│ ┌─────────────┐ │ -│ │ Summarize │ │ 缩短冗长内容 -│ ├─────────────┤ │ -│ │ Optimize │ │ 语法润色 -│ ├─────────────┤ │ -│ │ Colloquial │ │ 口语化(聊天风格) -│ └─────────────┘ │ -└─────────┬─────────┘ - ▼ - TranslationResult -``` - -### 4.2 实时状态机 - -文本变化即触发翻译。前一次未完成则取消,使用最新内容重新翻译。 - -``` - ┌──────────────────────────┐ - │ IDLE │ - └────────────┬─────────────┘ - │ 文本变化 (debounce) - ▼ - 文本再次变化 ┌──────────────────────────┐ - (cancel+restart) ──▶│ TRANSLATING │ MarianMT - │ preview = "翻译中..." │ - └────────────┬─────────────┘ - │ 翻译完成 - ▼ - 文本再次变化 ┌──────────────────────────┐ - (cancel+restart) ──▶│ POST-PROCESSING │ Qwen (如果启用) - │ preview = 原始翻译结果 │ - └────────────┬─────────────┘ - │ 后处理完成 - ▼ - ┌──────────────────────────┐ - │ READY │ - │ preview = 最终润色结果 │ - └──────────────────────────┘ - -任何阶段均可 Ctrl+Enter 注入当前 preview 文本(所见即所得)。 -``` - -### 4.3 取消机制 - -```csharp -// ViewModel 中的核心逻辑 -partial void OnSourceTextChanged(string value) -{ - _pipelineCts?.Cancel(); - _pipelineCts = new CancellationTokenSource(); - _ = RunPipelineAsync(value, _pipelineCts.Token); -} - -async Task RunPipelineAsync(string text, CancellationToken ct) -{ - // Stage 1: MarianMT 翻译 - TranslatedText = await _pipeline.TranslateAsync(text, ct); - ct.ThrowIfCancellationRequested(); - - // Stage 2: Qwen 后处理 (如果启用) - if (_options.PostProcessingEnabled) - TranslatedText = await _pipeline.PostProcessAsync(TranslatedText, _options, ct); -} -``` - -## 5. Core 公开 API - -### 5.1 管线接口 - -```csharp -public interface ITranslationPipeline -{ - Task ProcessAsync( - TranslationRequest request, CancellationToken ct = default); -} - -public record TranslationRequest( - string SourceText, - string? SourceLanguage, // null = 自动检测 - string TargetLanguage, - ProcessingOptions? PostProcessing // null = 不后处理 -); - -public record ProcessingOptions( - bool Summarize = false, - bool Optimize = false, - bool Colloquialize = false -); - -public record TranslationResult( - string Text, // 最终文本 - string DetectedSourceLanguage, - string RawTranslation, // MarianMT 原始翻译(后处理前) - TimeSpan TranslationDuration, - TimeSpan? PostProcessingDuration -); -``` - -### 5.2 引擎接口(架构预留替换) - -```csharp -public interface ITranslationEngine -{ - Task TranslateAsync( - string text, string sourceLang, string targetLang, - CancellationToken ct = default); - - IReadOnlyList SupportedLanguagePairs { get; } -} - -public interface ITextProcessor -{ - string Name { get; } // "summarize" / "optimize" / "colloquialize" - - Task ProcessAsync( - string text, string language, - CancellationToken ct = default); -} - -public interface ILanguageDetector -{ - Task DetectAsync( - string text, CancellationToken ct = default); -} -``` - -### 5.3 模型管理 - -```csharp -public interface IModelManager -{ - Task EnsureModelAsync( - ModelDescriptor descriptor, - IProgress? progress = null, - CancellationToken ct = default); - - IReadOnlyList ListInstalled(); - Task DeleteModelAsync(string modelId, CancellationToken ct = default); - long GetTotalDiskUsage(); -} - -public record ModelDescriptor( - string Id, // "marian-zh-en" - string DisplayName, // "Chinese → English" - string DownloadUrl, - long SizeBytes, - ModelType Type // Translation / PostProcessing / LanguageDetection -); -``` - -### 5.4 DI 注册 - -```csharp -// 其他产品使用 Core -services.AddLiveLingoCore(options => -{ - options.ModelStoragePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo", "models"); - options.DefaultTargetLanguage = "en"; - options.PostProcessing.Enabled = true; - options.PostProcessing.DefaultMode = ProcessingMode.Colloquialize; -}); - -// LiveLingo.App 额外注册平台服务 -if (OperatingSystem.IsWindows()) - services.AddSingleton(); -else if (OperatingSystem.IsMacOS()) - services.AddSingleton(); -``` - -## 6. 模型规格 - -### 6.1 MarianMT (翻译) - -| 项 | 值 | -|----|----| -| 来源 | Helsinki-NLP/opus-mt-{src}-{tgt} (HuggingFace) | -| 格式 | ONNX | -| 大小 | ~30MB/语言对 | -| 推理框架 | ONNX Runtime (Microsoft.ML.OnnxRuntime) | -| 推理速度 | <200ms/句 (CPU) | -| 分词 | SentencePiece (随模型分发) | -| 量化 | INT8 可用 | - -### 6.2 Qwen2.5-1.5B (后处理) - -| 项 | 值 | -|----|----| -| 来源 | Qwen/Qwen2.5-1.5B-Instruct (HuggingFace) | -| 格式 | GGUF (Q4_K_M 量化) | -| 大小 | ~1GB | -| 推理框架 | LLamaSharp (llama.cpp .NET 绑定) | -| 推理速度 | ~10 tok/s (CPU),~50 tok/s (GPU) | -| 内存占用 | ~2GB 运行时 | - -### 6.3 后处理 Prompt 策略 - -``` -[Summarize] -System: You are a text editor. Shorten the following text while keeping -its core meaning. Output only the shortened text, nothing else. - -[Optimize] -System: You are a grammar editor. Fix grammar and improve clarity of the -following text. Keep the same meaning and tone. Output only the improved -text, nothing else. - -[Colloquialize] -System: You are a casual chat editor. Rewrite the following text in a -relaxed, conversational tone suitable for workplace chat (Slack/Teams). -Keep it natural and friendly. Output only the rewritten text, nothing else. -``` - -## 7. 平台层设计(App 内部) - -### 7.1 接口定义 - -```csharp -public interface IPlatformServices -{ - IHotkeyService Hotkey { get; } - IWindowTracker WindowTracker { get; } - ITextInjector TextInjector { get; } - IClipboardService Clipboard { get; } -} - -public interface IHotkeyService : IDisposable -{ - event Action HotkeyTriggered; - void Register(HotkeyBinding binding); - void Unregister(HotkeyBinding binding); -} - -public interface IWindowTracker -{ - TargetWindowInfo? GetForegroundWindowInfo(); -} - -public interface ITextInjector -{ - Task InjectAsync(TargetWindowInfo target, string text, bool autoSend); -} - -public interface IClipboardService -{ - Task SetTextAsync(string text); - Task GetTextAsync(); -} -``` - -### 7.2 平台实现对照 - -| 接口方法 | Windows 实现 | macOS 实现 | -|----------|-------------|------------| -| Hotkey.Register | WH_KEYBOARD_LL 全局钩子 | CGEventTap | -| WindowTracker.GetForeground | GetForegroundWindow + FindChromeRenderer | NSWorkspace.frontmostApplication | -| TextInjector.Inject (策略1) | SendInput Ctrl+V | AXUIElement.setValue | -| TextInjector.Inject (策略2) | WM_CHAR → Chrome renderer | AXUIElement.setValue (无需备选) | -| Clipboard.SetText | OpenClipboard + SetClipboardData | NSPasteboard | - -### 7.3 macOS 权限 - -| 权限 | 用途 | 配置位置 | -|------|------|----------| -| Accessibility | AXUIElement 读写 UI 元素 | Info.plist + 系统设置 | -| Input Monitoring | CGEventTap 全局键盘事件 | 系统设置 → 隐私与安全 | - -首次启动需要引导用户授权,提供清晰的引导 UI。 - -## 8. 项目结构 - -``` -LiveLingo/ -├── src/ -│ ├── LiveLingo.Core/ # 核心业务(可复用 NuGet 包) -│ │ ├── LiveLingo.Core.csproj -│ │ ├── ServiceCollectionExtensions.cs # AddLiveLingoCore() -│ │ ├── CoreOptions.cs -│ │ ├── Translation/ -│ │ │ ├── ITranslationPipeline.cs -│ │ │ ├── TranslationPipeline.cs -│ │ │ ├── TranslationRequest.cs -│ │ │ └── TranslationResult.cs -│ │ ├── Engines/ -│ │ │ ├── ITranslationEngine.cs -│ │ │ ├── MarianOnnxEngine.cs # MarianMT + ONNX Runtime -│ │ │ └── SentencePieceTokenizer.cs -│ │ ├── Processing/ -│ │ │ ├── ITextProcessor.cs -│ │ │ ├── ProcessingOptions.cs -│ │ │ ├── QwenProcessor.cs # Qwen + LLamaSharp -│ │ │ └── Prompts/ -│ │ │ ├── SummarizePrompt.cs -│ │ │ ├── OptimizePrompt.cs -│ │ │ └── ColloquializePrompt.cs -│ │ ├── LanguageDetection/ -│ │ │ ├── ILanguageDetector.cs -│ │ │ └── FastTextDetector.cs -│ │ └── Models/ -│ │ ├── IModelManager.cs -│ │ ├── ModelManager.cs -│ │ ├── ModelDescriptor.cs -│ │ └── ModelDownloadProgress.cs -│ │ -│ └── LiveLingo.App/ # Avalonia 桌面应用 -│ ├── LiveLingo.App.csproj -│ ├── Program.cs -│ ├── App.axaml / .cs # DI 组装 -│ ├── Configuration/ -│ │ ├── AppSettings.cs -│ │ └── SettingsStore.cs # JSON 持久化 -│ ├── ViewModels/ -│ │ ├── MainWindowViewModel.cs -│ │ ├── OverlayViewModel.cs -│ │ └── ModelSetupViewModel.cs # 首次模型下载引导 -│ ├── Views/ -│ │ ├── MainWindow.axaml / .cs -│ │ ├── OverlayWindow.axaml / .cs -│ │ └── ModelSetupView.axaml / .cs -│ └── Platform/ -│ ├── IPlatformServices.cs -│ ├── IHotkeyService.cs -│ ├── IWindowTracker.cs -│ ├── ITextInjector.cs -│ ├── IClipboardService.cs -│ ├── TargetWindowInfo.cs -│ ├── Windows/ -│ │ ├── WindowsPlatformServices.cs -│ │ ├── Win32HotkeyService.cs -│ │ ├── Win32WindowTracker.cs -│ │ ├── Win32TextInjector.cs -│ │ ├── Win32ClipboardService.cs -│ │ └── NativeMethods.cs -│ └── macOS/ -│ ├── MacPlatformServices.cs -│ ├── MacHotkeyService.cs -│ ├── MacWindowTracker.cs -│ └── MacTextInjector.cs -│ -├── tests/ -│ ├── LiveLingo.Core.Tests/ -│ │ ├── Translation/ -│ │ │ └── TranslationPipelineTests.cs -│ │ ├── Engines/ -│ │ │ └── MarianOnnxEngineTests.cs -│ │ └── Processing/ -│ │ └── QwenProcessorTests.cs -│ └── LiveLingo.App.Tests/ -│ └── ViewModels/ -│ └── OverlayViewModelTests.cs -│ -├── docs/ -│ ├── poc-design.md # PoC 技术记录 -│ └── product-design.md # 本文档 -│ -└── models/ # 本地模型存储 (gitignored) - └── .gitkeep -``` - -## 9. NuGet 依赖 - -### LiveLingo.Core - -| 包 | 用途 | -|----|------| -| Microsoft.ML.OnnxRuntime | MarianMT 推理 | -| LLamaSharp | Qwen 推理 (llama.cpp 绑定) | -| LLamaSharp.Backend.Cpu | CPU 推理后端 | -| Microsoft.Extensions.DependencyInjection.Abstractions | DI 注册 | -| Microsoft.Extensions.Options | 配置模型 | -| Microsoft.Extensions.Logging.Abstractions | 日志抽象 | - -### LiveLingo.App - -| 包 | 用途 | -|----|------| -| Avalonia | UI 框架 | -| Avalonia.Desktop | 桌面平台支持 | -| Avalonia.Themes.Fluent | Fluent 主题 | -| CommunityToolkit.Mvvm | MVVM 基础设施 | -| System.Text.Json | 配置持久化 | - -## 10. 首次运行体验 - -``` -┌──────────────────────────────────────────┐ -│ Welcome to LiveLingo │ -│ │ -│ Translation models need to be │ -│ downloaded for first use. │ -│ │ -│ ┌────────────────────────────────────┐ │ -│ │ ☑ Chinese → English (MarianMT) │ │ -│ │ 30 MB │ │ -│ ├────────────────────────────────────┤ │ -│ │ ☑ AI Polish (Qwen2.5-1.5B) │ │ -│ │ 1.0 GB │ │ -│ ├────────────────────────────────────┤ │ -│ │ ☐ Japanese → English (MarianMT) │ │ -│ │ 28 MB │ │ -│ └────────────────────────────────────┘ │ -│ │ -│ Total: 1.03 GB │ -│ │ -│ [████████████░░░░░░░░] 62% 640 MB │ -│ │ -│ [ Download ] │ -└──────────────────────────────────────────┘ -``` - -## 11. 注入模式(延续 PoC) - -| 模式 | 行为 | -|------|------| -| Paste Only | 翻译文本粘贴到目标输入框光标位置 | -| Paste & Send | 粘贴后自动按 Enter 发送 | - -模式通过 overlay 底部按钮切换。选择持久化到配置文件。 - -## 12. 后续迭代方向 - -| 版本 | 里程碑 | -|------|--------| -| v1.0 | Core + Windows 平台 + MarianMT + Qwen 后处理 | -| v1.1 | macOS 平台支持 | -| v1.2 | 用户可配置快捷键、多语言对管理 | -| v2.0 | CTranslate2 替换 ONNX(翻译性能优化) | -| v2.1 | 流式翻译预览(边翻译边显示) | -| v2.2 | 插件系统(自定义后处理器) | diff --git a/docs/proposals/P1-foundation.md b/docs/proposals/P1-foundation.md deleted file mode 100644 index 94b2bab..0000000 --- a/docs/proposals/P1-foundation.md +++ /dev/null @@ -1,134 +0,0 @@ -# P1: Foundation & Architecture - -> 将 PoC 单项目结构重构为正式多项目架构,定义所有接口,迁移现有实现。 - -## 目标 - -- 建立 LiveLingo.Core + LiveLingo.App 多项目结构 -- 定义 Core 层全部公开接口(翻译管线、引擎、后处理、语言检测、模型管理) -- 定义 App 层平台抽象接口(快捷键、窗口追踪、文本注入、剪贴板) -- 将 PoC Windows 代码迁移到平台接口实现 -- 引入 DI 容器,替代 static 类直接调用 -- 保留 stub 翻译,确保功能与 PoC 完全一致 - -## 不做 - -- 不引入任何 AI/ML 依赖 -- 不改变 Windows 注入行为 -- 不添加新功能 - -## 交付内容 - -### 1. Solution 结构 - -``` -LiveLingo.slnx -├── src/ -│ ├── LiveLingo.Core/ -│ │ ├── LiveLingo.Core.csproj (netstandard2.1 或 net10.0) -│ │ ├── ServiceCollectionExtensions.cs -│ │ ├── CoreOptions.cs -│ │ ├── Translation/ -│ │ │ ├── ITranslationPipeline.cs -│ │ │ ├── TranslationPipeline.cs (stub: 直通或 [EN] prefix) -│ │ │ ├── TranslationRequest.cs -│ │ │ └── TranslationResult.cs -│ │ ├── Engines/ -│ │ │ ├── ITranslationEngine.cs -│ │ │ └── StubTranslationEngine.cs -│ │ ├── Processing/ -│ │ │ ├── ITextProcessor.cs -│ │ │ └── ProcessingOptions.cs -│ │ ├── LanguageDetection/ -│ │ │ └── ILanguageDetector.cs -│ │ └── Models/ -│ │ ├── IModelManager.cs -│ │ ├── ModelDescriptor.cs -│ │ └── ModelDownloadProgress.cs -│ │ -│ └── LiveLingo.App/ -│ ├── LiveLingo.App.csproj (引用 LiveLingo.Core) -│ ├── Program.cs -│ ├── App.axaml.cs (DI 组装) -│ ├── Views/ -│ ├── ViewModels/ -│ │ └── OverlayViewModel.cs (注入 ITranslationPipeline) -│ └── Platform/ -│ ├── IPlatformServices.cs -│ ├── IHotkeyService.cs -│ ├── IWindowTracker.cs -│ ├── ITextInjector.cs -│ ├── IClipboardService.cs -│ ├── TargetWindowInfo.cs -│ └── Windows/ -│ ├── WindowsPlatformServices.cs -│ ├── Win32HotkeyService.cs (← GlobalKeyboardHook.cs) -│ ├── Win32WindowTracker.cs (← WindowTracker.cs) -│ ├── Win32TextInjector.cs (← TextInjector.cs) -│ ├── Win32ClipboardService.cs (← 从 TextInjector 提取) -│ └── NativeMethods.cs -``` - -### 2. Core 公开接口 - -见 product-design.md 第 5 节。此阶段只需定义接口 + stub 实现。 - -### 3. DI 注册 - -```csharp -// App.axaml.cs OnFrameworkInitializationCompleted -var services = new ServiceCollection(); - -services.AddLiveLingoCore(opt => -{ - opt.ModelStoragePath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo", "models"); -}); - -if (OperatingSystem.IsWindows()) - services.AddSingleton(); - -_serviceProvider = services.BuildServiceProvider(); -``` - -### 4. ViewModel 改造 - -OverlayViewModel 当前直接 `new` 和调用 static 方法。改为构造函数注入: - -``` -Before: OverlayViewModel(TargetWindowInfo target) - 内部直接调用 TextInjector.InjectText(...) - -After: OverlayViewModel(TargetWindowInfo target, - ITranslationPipeline pipeline) - 注入由 OverlayWindow 传入 -``` - -### 5. PoC 代码迁移映射 - -| PoC 文件 | 迁移到 | 变化 | -|----------|--------|------| -| GlobalKeyboardHook.cs | Platform/Windows/Win32HotkeyService.cs | 实现 IHotkeyService | -| WindowTracker.cs | Platform/Windows/Win32WindowTracker.cs | 实现 IWindowTracker | -| TextInjector.cs | Platform/Windows/Win32TextInjector.cs | 实现 ITextInjector (async) | -| NativeMethods.cs | Platform/Windows/NativeMethods.cs | 不变 | -| InjectionTest.cs | 保留或移至 tests/ | 诊断工具 | -| SlackAutoTest.cs | 保留或移至 tests/ | 诊断工具 | -| WindowDiagnostic.cs | 保留或移至 tests/ | 诊断工具 | - -### 6. 诊断工具处理 - -PoC 的三个 CLI 诊断工具(--test-inject, --diag-window, --test-slack): -- 保留在 App 项目中作为开发模式功能 -- 或迁移到独立的 test/tools 项目 - -## 验收标准 - -- [ ] `dotnet build` 编译通过 -- [ ] 运行应用,Ctrl+Alt+T 呼出 overlay -- [ ] 输入文字,显示 `[EN] xxx` stub 翻译 -- [ ] Ctrl+Enter 注入到 Slack/Notepad(与 PoC 行为一致) -- [ ] OverlayViewModel 通过构造函数接收 ITranslationPipeline -- [ ] 无任何 static 类直接调用(NativeMethods 除外) -- [ ] LiveLingo.Core 项目无 Avalonia / 平台依赖 diff --git a/docs/proposals/P2-translation-core.md b/docs/proposals/P2-translation-core.md deleted file mode 100644 index e9e862c..0000000 --- a/docs/proposals/P2-translation-core.md +++ /dev/null @@ -1,196 +0,0 @@ -# P2: Translation Core - -> 实现模型管理、MarianMT 翻译引擎、语言检测、翻译管线,替换 stub 为真实翻译。 - -## 前置依赖 - -- P1 完成(Core 接口已定义,DI 已就位) - -## 目标 - -- 实现 ModelManager:模型下载(带进度)、本地缓存、版本管理 -- 实现 MarianMT 翻译引擎:ONNX Runtime 推理 + SentencePiece 分词 -- 实现语言检测器:自动识别源语言 -- 组装 TranslationPipeline:语言检测 → 翻译,cancel-and-restart -- 基础模型下载 UI -- 替换 stub,用户首次体验到真实翻译 - -## 不做 - -- 不实现 Qwen 后处理(P3) -- 不实现 macOS 平台(P4) -- 不实现持久化配置(P5) - -## 交付内容 - -### 1. 模型管理 - -```csharp -// ModelManager 职责 -public class ModelManager : IModelManager -{ - // 检查模型是否已下载 - // 从 HuggingFace 下载模型文件(断点续传) - // 报告下载进度 - // 管理本地缓存目录 - // 列出已安装模型 - // 删除模型释放空间 - // 计算磁盘占用 -} -``` - -模型存储结构: -``` -{LocalAppData}/LiveLingo/models/ -├── marian-zh-en/ -│ ├── model.onnx -│ ├── source.spm (SentencePiece source vocab) -│ ├── target.spm (SentencePiece target vocab) -│ └── manifest.json (版本、大小、来源) -├── marian-ja-en/ -│ └── ... -└── fasttext-lid/ - └── lid.176.ftz (语言检测模型) -``` - -### 2. MarianMT 翻译引擎 - -``` -输入文本 - │ - ▼ -┌──────────────────────────┐ -│ SentencePiece Encode │ source.spm -│ "你好世界" → [token_ids] │ -└────────────┬─────────────┘ - ▼ -┌──────────────────────────┐ -│ ONNX Runtime Inference │ model.onnx -│ [src_ids] → [tgt_ids] │ -└────────────┬─────────────┘ - ▼ -┌──────────────────────────┐ -│ SentencePiece Decode │ target.spm -│ [tgt_ids] → "Hello world"│ -└──────────────────────────┘ -``` - -关键技术点: -- ONNX 模型的 beam search 解码需要自行实现或使用 ONNX Runtime Extensions -- SentencePiece 分词器需要 .NET 绑定(SentencePieceSharp 或自建 P/Invoke) -- 模型加载应延迟到首次翻译时(lazy loading) -- InferenceSession 线程安全,可复用 - -### 3. 语言检测 - -使用 FastText 的 lid.176.ftz 模型(~1MB): -- 支持 176 种语言 -- 推理 <1ms -- 确定源语言后自动选择对应的 MarianMT 模型 - -### 4. 翻译管线 - -```csharp -public class TranslationPipeline : ITranslationPipeline -{ - public async Task ProcessAsync( - TranslationRequest request, CancellationToken ct) - { - // 1. 语言检测(如果未指定源语言) - var srcLang = request.SourceLanguage - ?? (await _detector.DetectAsync(request.SourceText, ct)).Language; - - ct.ThrowIfCancellationRequested(); - - // 2. 确保模型已下载 - var modelId = $"marian-{srcLang}-{request.TargetLanguage}"; - await _modelManager.EnsureModelAsync( - _registry.Get(modelId), progress: null, ct); - - // 3. 翻译 - var sw = Stopwatch.StartNew(); - var translated = await _engine.TranslateAsync( - request.SourceText, srcLang, request.TargetLanguage, ct); - var translationTime = sw.Elapsed; - - ct.ThrowIfCancellationRequested(); - - // 4. 后处理(P3 实现,此阶段跳过) - // ... - - return new TranslationResult( - Text: translated, - DetectedSourceLanguage: srcLang, - RawTranslation: translated, - TranslationDuration: translationTime, - PostProcessingDuration: null); - } -} -``` - -### 5. ViewModel 集成 - -```csharp -// OverlayViewModel — cancel-and-restart 模式 -private CancellationTokenSource? _pipelineCts; - -partial void OnSourceTextChanged(string value) -{ - _pipelineCts?.Cancel(); - _pipelineCts = new CancellationTokenSource(); - _ = RunPipelineAsync(value, _pipelineCts.Token); -} - -private async Task RunPipelineAsync(string text, CancellationToken ct) -{ - if (string.IsNullOrWhiteSpace(text)) - { - TranslatedText = string.Empty; - return; - } - - try - { - StatusText = "Translating..."; - var result = await _pipeline.ProcessAsync( - new TranslationRequest(text, null, _targetLanguage, null), ct); - TranslatedText = result.Text; - StatusText = $"Translated ({result.TranslationDuration.TotalMilliseconds:0}ms)"; - } - catch (OperationCanceledException) { } - catch (Exception ex) - { - StatusText = $"Error: {ex.Message}"; - } -} -``` - -### 6. 基础模型下载 UI - -首次启动检测模型缺失时,显示简单下载对话框: -- 显示需要下载的模型列表和大小 -- 下载进度条 -- 下载完成后自动进入正常模式 - -不需要复杂的多语言对选择(P5 做)。v1 默认只下载 zh→en + FastText。 - -## 新增 NuGet 依赖 - -| 包 | 项目 | 用途 | -|----|------|------| -| Microsoft.ML.OnnxRuntime | Core | MarianMT + FastText 推理 | -| Microsoft.Extensions.DependencyInjection | Core | DI 基础设施 | -| Microsoft.Extensions.Options | Core | 配置模型 | -| Microsoft.Extensions.Logging.Abstractions | Core | 日志 | - -SentencePiece 分词器:评估 SentencePieceSharp 或自建 native 绑定。 - -## 验收标准 - -- [ ] 首次启动自动下载 MarianMT zh→en 模型 + FastText 模型 -- [ ] 输入中文,200ms 内显示英文翻译 -- [ ] 快速连续输入时,前一次翻译被取消,只显示最新结果 -- [ ] 输入日文/韩文,语言自动检测正确(但翻译失败 — 模型未下载,显示错误信息) -- [ ] Ctrl+Enter 注入真实翻译结果到 Slack -- [ ] 模型文件缓存在 LocalAppData,二次启动无需下载 -- [ ] Core 项目可独立编译,无 Avalonia 依赖 diff --git a/docs/proposals/P3-ai-postprocessing.md b/docs/proposals/P3-ai-postprocessing.md deleted file mode 100644 index 313af54..0000000 --- a/docs/proposals/P3-ai-postprocessing.md +++ /dev/null @@ -1,185 +0,0 @@ -# P3: AI Post-processing - -> 集成 Qwen2.5-1.5B 本地 LLM,实现总结、优化、口语化三种后处理模式。 - -## 前置依赖 - -- P2 完成(TranslationPipeline + ModelManager 已工作) - -## 目标 - -- 集成 LLamaSharp + Qwen2.5-1.5B (GGUF Q4) -- 实现三种 ITextProcessor:Summarize / Optimize / Colloquialize -- 管线增加后处理阶段(翻译完成后自动执行) -- Overlay UI 增加后处理模式选择 -- Cancel-and-restart 覆盖后处理阶段 - -## 不做 - -- 不实现 GPU 加速(CPU 先行,后续优化) -- 不实现自定义 prompt(使用预设 prompt) -- 不实现流式输出到 preview(完整结果后一次更新) - -## 交付内容 - -### 1. QwenProcessor 实现 - -``` -ITextProcessor - ├── SummarizeProcessor → 缩短内容,保留核心意思 - ├── OptimizeProcessor → 语法修正,表达润色 - └── ColloquializeProcessor → 口语化,适合 IM 聊天 -``` - -三者共享同一个 Qwen 模型实例,仅 system prompt 不同。 - -```csharp -internal class QwenProcessor : ITextProcessor -{ - private readonly LLamaWeights _model; // 共享模型权重 - private readonly string _systemPrompt; // 按模式不同 - - public async Task ProcessAsync( - string text, string language, CancellationToken ct) - { - using var context = _model.CreateContext(new ModelParams { ... }); - var executor = new InstructExecutor(context); - - var result = new StringBuilder(); - await foreach (var token in executor.InferAsync( - $"{_systemPrompt}\n\nText: {text}", ct)) - { - result.Append(token); - ct.ThrowIfCancellationRequested(); - } - - return result.ToString().Trim(); - } -} -``` - -### 2. Prompt 模板 - -``` -[Summarize] -System: You are a concise editor. Shorten the following translated text -while preserving its core meaning. Remove redundancy. Output only the -shortened text, nothing else. - -[Optimize] -System: You are a professional editor. Improve the grammar, clarity, and -natural flow of the following translated text. Keep the original meaning -and tone. Output only the improved text, nothing else. - -[Colloquialize] -System: You are a casual chat writer. Rewrite the following translated -text in a relaxed, friendly tone suitable for workplace chat apps like -Slack or Teams. Keep it natural and brief. Output only the rewritten -text, nothing else. -``` - -### 3. 管线两阶段流转 - -``` -用户输入变化 - │ - ▼ -┌─────────────────────────────────────────┐ -│ Stage 1: MarianMT 翻译 │ -│ preview 更新为原始翻译 │ -│ StatusText = "Translated (150ms)" │ -└─────────────┬───────────────────────────┘ - │ (如果后处理已启用) - ▼ -┌─────────────────────────────────────────┐ -│ Stage 2: Qwen 后处理 │ -│ preview 更新为润色结果 │ -│ StatusText = "Polished (1.2s)" │ -└─────────────────────────────────────────┘ -``` - -如果后处理未启用,Stage 2 跳过。用户在任何阶段按 Ctrl+Enter 都注入当前 preview。 - -### 4. 模型生命周期 - -``` -App 启动 - │ - ├─ Qwen 模型不预加载(1GB 太大) - │ - ▼ -用户首次启用后处理 - │ - ├─ ModelManager.EnsureModelAsync("qwen2.5-1.5b-q4") - │ ├─ 已下载 → 跳过 - │ └─ 未下载 → 下载对话框 (1GB) - │ - ├─ 加载模型到内存 (~2GB RAM, 耗时 3-5s) - │ └─ 首次加载显示 loading 状态 - │ - └─ 后续调用复用已加载模型 - └─ 5 分钟无调用 → 卸载释放内存 -``` - -### 5. Overlay UI 变化 - -在 overlay 底部状态栏增加后处理模式选择: - -``` -┌─────────────────────────────────────────────┐ -│ [输入框] │ -│ [翻译预览] │ -├─────────────────────────────────────────────┤ -│ Ctrl+Enter paste & send │ -│ [Paste & Send] [Off ▾] Target:EN │ -│ ├── Off │ -│ ├── Summarize │ -│ ├── Optimize │ -│ └── Colloquial │ -└─────────────────────────────────────────────┘ -``` - -### 6. Cancel 覆盖范围 - -```csharp -async Task RunPipelineAsync(string text, CancellationToken ct) -{ - // Stage 1 - StatusText = "Translating..."; - var result = await _pipeline.ProcessAsync( - new TranslationRequest(text, null, _targetLang, null), ct); - TranslatedText = result.Text; - - ct.ThrowIfCancellationRequested(); // ← 在此取消时不跑 Stage 2 - - // Stage 2 (如果启用) - if (_postProcessMode != ProcessingMode.Off) - { - StatusText = "Polishing..."; - TranslatedText = await _pipeline.PostProcessAsync( - result.Text, _postProcessMode, ct); - } - - StatusText = "Ready"; -} -``` - -用户在 Stage 2 进行中打字 → cancel → 从 Stage 1 重新开始。 - -## 新增 NuGet 依赖 - -| 包 | 项目 | 用途 | -|----|------|------| -| LLamaSharp | Core | Qwen 推理 | -| LLamaSharp.Backend.Cpu | Core | CPU 推理后端 | - -## 验收标准 - -- [ ] 选择 "Colloquial" 模式后,翻译结果被口语化改写 -- [ ] 选择 "Summarize" 模式后,长文本翻译被缩短 -- [ ] 选择 "Optimize" 模式后,翻译语法被润色 -- [ ] 选择 "Off" 时,只显示 MarianMT 原始翻译 -- [ ] 翻译完成后 1-3 秒内后处理结果出现 -- [ ] 快速打字时,前一次后处理被正确取消 -- [ ] 5 分钟无操作后 Qwen 模型自动卸载 -- [ ] Ctrl+Enter 注入的是 preview 中显示的文本 diff --git a/docs/proposals/P4-macos-platform.md b/docs/proposals/P4-macos-platform.md deleted file mode 100644 index 362aa70..0000000 --- a/docs/proposals/P4-macos-platform.md +++ /dev/null @@ -1,139 +0,0 @@ -# P4: macOS Platform - -> 实现 macOS 平台的全部平台服务,使 LiveLingo 在 Mac 上完整可用。 - -## 前置依赖 - -- P1 完成(平台接口已定义) -- 可与 P2/P3 并行开发 - -## 目标 - -- 实现 IHotkeyService:CGEventTap 全局键盘事件 -- 实现 IWindowTracker:NSWorkspace 前台应用识别 -- 实现 ITextInjector:Accessibility API (AXUIElement) 文本注入 -- 实现 IClipboardService:NSPasteboard -- 权限引导 UI(Accessibility + Input Monitoring) - -## 不做 - -- 不实现 App Store 分发(sandbox 限制太多) -- 不实现 Linux 平台 - -## 交付内容 - -### 1. macOS 平台服务映射 - -| 接口 | macOS 实现 | 技术 | -|------|-----------|------| -| IHotkeyService | MacHotkeyService | CGEventTap (Core Graphics) | -| IWindowTracker | MacWindowTracker | NSWorkspace.frontmostApplication | -| ITextInjector | MacTextInjector | AXUIElement.setValue | -| IClipboardService | MacClipboardService | NSPasteboard.generalPasteboard | - -### 2. macOS 与 Windows 关键差异 - -``` -Windows 注入流程: - FindChromeRenderer ──▶ SendInput Ctrl+V ──▶ 文本出现在输入框 - (需要找到 Chrome 子窗口) (需要前台权限) - -macOS 注入流程: - AXUIElementCopyAttributeValue ──▶ AXUIElementSetAttributeValue - (Accessibility API 直接操作 UI 元素的 value 属性) - 无需 Chrome 子窗口 hack,无需前台权限 - 但需要用户授予 Accessibility 权限 -``` - -macOS 方案更优雅但权限更严格。 - -### 3. 权限管理 - -``` -首次启动 - │ - ▼ -┌─────────────────────────────────────────┐ -│ LiveLingo needs permissions │ -│ │ -│ ☐ Accessibility │ -│ Required to inject text into apps │ -│ [Open System Settings] │ -│ │ -│ ☐ Input Monitoring │ -│ Required for global hotkey │ -│ [Open System Settings] │ -│ │ -│ Status: Waiting for permissions... │ -└─────────────────────────────────────────┘ -``` - -权限检测 API: -- `AXIsProcessTrusted()` — Accessibility 权限 -- `CGPreflightListenEventAccess()` — Input Monitoring 权限 - -### 4. .NET macOS 互操作 - -macOS native API 调用方式: -- **ObjCRuntime** (.NET 内建):访问 NSWorkspace、NSPasteboard -- **P/Invoke**:调用 Core Graphics (CGEventTap)、Accessibility (AXUIElement) -- 或考虑使用 **MonoMac / MacCatalyst** 绑定 - -关键 P/Invoke: -```csharp -// Core Graphics - 全局键盘事件 -[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] -static extern IntPtr CGEventTapCreate(...); - -// Accessibility - UI 元素操作 -[DllImport("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")] -static extern int AXUIElementSetAttributeValue(IntPtr element, IntPtr attribute, IntPtr value); - -// 权限检测 -[DllImport("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")] -static extern bool AXIsProcessTrusted(); -``` - -### 5. 文本注入策略 - -macOS 注入比 Windows 简单——Accessibility API 支持直接设置 UI 元素值: - -``` -1. 获取前台应用的 focused UI element - AXUIElementCopyAttributeValue(app, kAXFocusedUIElementAttribute) - -2. 获取当前文本值 - AXUIElementCopyAttributeValue(element, kAXValueAttribute) - -3. 在光标位置插入翻译文本 - AXUIElementSetAttributeValue(element, kAXValueAttribute, newValue) - -4. [可选] 模拟 Enter 键发送 - CGEventPost(kCGHIDEventTap, enterKeyEvent) -``` - -对 Electron 应用(Slack macOS)同样有效,因为 Chromium 实现了 Accessibility API。 - -### 6. 项目结构 - -``` -LiveLingo.App/ -└── Platform/ - └── macOS/ - ├── MacPlatformServices.cs - ├── MacHotkeyService.cs - ├── MacWindowTracker.cs - ├── MacTextInjector.cs - ├── MacClipboardService.cs - ├── MacPermissionChecker.cs - └── NativeMethods.macOS.cs -``` - -## 验收标准 - -- [ ] macOS 上 Ctrl+Alt+T (或 Cmd+Option+T) 呼出 overlay -- [ ] overlay 正确显示在前台应用上方 -- [ ] 输入框获取焦点,可正常输入 -- [ ] Ctrl+Enter 将翻译文本注入到 Slack macOS 输入框 -- [ ] 缺少权限时显示引导 UI -- [ ] 授权后无需重启应用即可使用 diff --git a/docs/proposals/P5-settings-polish.md b/docs/proposals/P5-settings-polish.md deleted file mode 100644 index def6dbd..0000000 --- a/docs/proposals/P5-settings-polish.md +++ /dev/null @@ -1,143 +0,0 @@ -# P5: Settings & Polish - -> 实现用户配置持久化、首次运行引导、可配置快捷键、多语言对管理。 - -## 前置依赖 - -- P3 完成(全部翻译功能就位) -- P4 完成或并行(macOS 快捷键配置需要平台适配) - -## 目标 - -- 用户配置 JSON 持久化(语言对、后处理模式、注入模式、快捷键) -- 首次运行引导流程(模型选择 + 下载 + 权限) -- 可配置全局快捷键 -- 多语言对管理 UI(添加/删除语言对) -- 主窗口设置面板 - -## 不做 - -- 不实现云同步配置 -- 不实现用户账户系统 -- 不实现自动更新 - -## 交付内容 - -### 1. 配置模型 - -```csharp -public class AppSettings -{ - public string TargetLanguage { get; set; } = "en"; - public List InstalledLanguagePairs { get; set; } = ["zh-en"]; - public HotkeyConfig Hotkey { get; set; } = new(); - public InjectionMode InjectionMode { get; set; } = InjectionMode.PasteAndSend; - public ProcessingMode PostProcessingMode { get; set; } = ProcessingMode.Off; - public string ModelStoragePath { get; set; } = ""; // 默认 LocalAppData -} - -public class HotkeyConfig -{ - public bool Ctrl { get; set; } = true; - public bool Alt { get; set; } = true; - public bool Shift { get; set; } = false; - public string Key { get; set; } = "T"; -} -``` - -存储路径:`{LocalAppData}/LiveLingo/settings.json` - -### 2. 首次运行引导 - -``` -Step 1: Welcome Step 2: Language Setup -┌──────────────────────┐ ┌──────────────────────┐ -│ Welcome to LiveLingo │ │ Select languages │ -│ │ │ │ -│ Real-time translation│ │ Your language: │ -│ for your chat apps. │ │ [Chinese ▾] │ -│ │ │ │ -│ [Next →] │ │ Translate to: │ -│ │ │ [English ▾] │ -└──────────────────────┘ │ │ - │ ☐ Enable AI polish │ - │ │ - │ [← Back] [Next →] │ - └──────────────────────┘ - -Step 3: Download Step 4: Ready -┌──────────────────────┐ ┌──────────────────────┐ -│ Downloading models │ │ You're all set! │ -│ │ │ │ -│ ☑ Chinese→English │ │ Press Ctrl+Alt+T │ -│ [████████░░] 80% │ │ in any app to start │ -│ │ │ translating. │ -│ ☑ AI Polish (Qwen) │ │ │ -│ [██░░░░░░░░] 20% │ │ [Start →] │ -│ │ │ │ -└──────────────────────┘ └──────────────────────┘ -``` - -macOS 额外步骤:权限授权引导(插入在 Step 1 和 Step 2 之间)。 - -### 3. 主窗口设置面板 - -当前主窗口只显示快捷键提示。扩展为设置面板: - -``` -┌─────────────────────────────────────────┐ -│ LiveLingo [─] [□] [×]│ -├─────────────────────────────────────────┤ -│ │ -│ Hotkey: [Ctrl+Alt+T] [Change...] │ -│ │ -│ ─── Languages ───────────────────── │ -│ ☑ Chinese → English [30 MB] │ -│ ☐ Japanese → English [28 MB] [Add] │ -│ ☐ Korean → English [32 MB] [Add] │ -│ │ -│ ─── AI Polish ───────────────────── │ -│ Model: Qwen2.5-1.5B [1.0 GB] │ -│ Status: Loaded ✓ │ -│ Default: [Colloquial ▾] │ -│ │ -│ ─── Injection ───────────────────── │ -│ Default mode: [Paste & Send ▾] │ -│ │ -│ Storage: 1.06 GB used │ -│ [Open model folder] [Clear cache] │ -│ │ -└─────────────────────────────────────────┘ -``` - -### 4. 快捷键配置 - -快捷键录制组件: -- 用户点击 [Change...],进入录制模式 -- 按下新组合键(如 Ctrl+Shift+Space),显示实时预览 -- Enter 确认,Esc 取消 -- 更新全局键盘钩子注册 - -需要平台适配: -- Windows: 修改 `GlobalKeyboardHook` 回调中的按键检测 -- macOS: 修改 `CGEventTap` 的按键匹配 - -### 5. 多语言对管理 - -用户可在设置中添加/删除语言对: -- 添加时触发 ModelManager 下载对应 MarianMT 模型 -- 删除时清理本地模型文件 -- overlay 中语言选择跟随设置 - -可用语言对从预定义注册表获取(不是所有 MarianMT 模型都质量可用)。 - -## 验收标准 - -- [ ] 首次启动显示引导流程 -- [ ] 引导中选择的语言对自动下载 -- [ ] 设置修改后立即持久化到 JSON 文件 -- [ ] 重启应用后配置保持 -- [ ] 可成功更改全局快捷键并立即生效 -- [ ] 可添加新语言对并下载对应模型 -- [ ] 可删除语言对并释放磁盘空间 -- [ ] 主窗口显示模型状态和磁盘占用 diff --git a/docs/proposals/specs/P1-foundation-spec.md b/docs/proposals/specs/P1-foundation-spec.md deleted file mode 100644 index 8975ba6..0000000 --- a/docs/proposals/specs/P1-foundation-spec.md +++ /dev/null @@ -1,572 +0,0 @@ -# P1 Spec: Foundation & Architecture - -## 1. 项目与 Solution 结构 - -### 1.1 Solution 文件 - -```xml - - - - - - - - - - -``` - -### 1.2 LiveLingo.Core.csproj - -```xml - - - net10.0 - enable - enable - - - - - - - -``` - -P1 阶段 Core 不引入 ONNX Runtime / LLamaSharp(P2/P3 才加)。 - -### 1.3 LiveLingo.App.csproj - -```xml - - - WinExe - net10.0 - enable - true - app.manifest - true - - - - - - - - - - - -``` - -## 2. LiveLingo.Core 公开接口 - -### 2.1 翻译管线 - -```csharp -namespace LiveLingo.Core.Translation; - -public interface ITranslationPipeline -{ - Task ProcessAsync( - TranslationRequest request, - CancellationToken ct = default); -} - -public record TranslationRequest( - string SourceText, - string? SourceLanguage, // null = 自动检测 - string TargetLanguage, - ProcessingOptions? PostProcessing // null = 不后处理 -); - -public record TranslationResult( - string Text, - string DetectedSourceLanguage, - string RawTranslation, - TimeSpan TranslationDuration, - TimeSpan? PostProcessingDuration -); -``` - -### 2.2 翻译引擎 - -```csharp -namespace LiveLingo.Core.Engines; - -public interface ITranslationEngine : IDisposable -{ - Task TranslateAsync( - string text, - string sourceLanguage, - string targetLanguage, - CancellationToken ct = default); - - bool SupportsLanguagePair(string sourceLanguage, string targetLanguage); -} -``` - -### 2.3 文本后处理器 - -```csharp -namespace LiveLingo.Core.Processing; - -public interface ITextProcessor : IDisposable -{ - string Name { get; } - - Task ProcessAsync( - string text, - string language, - CancellationToken ct = default); -} - -public record ProcessingOptions( - bool Summarize = false, - bool Optimize = false, - bool Colloquialize = false -); - -public enum ProcessingMode -{ - Off, - Summarize, - Optimize, - Colloquialize -} -``` - -### 2.4 语言检测 - -```csharp -namespace LiveLingo.Core.LanguageDetection; - -public interface ILanguageDetector : IDisposable -{ - Task DetectAsync( - string text, - CancellationToken ct = default); -} - -public record DetectionResult( - string Language, // ISO 639-1 code: "zh", "en", "ja" - float Confidence // 0.0 - 1.0 -); -``` - -### 2.5 模型管理 - -```csharp -namespace LiveLingo.Core.Models; - -public interface IModelManager -{ - Task EnsureModelAsync( - ModelDescriptor descriptor, - IProgress? progress = null, - CancellationToken ct = default); - - IReadOnlyList ListInstalled(); - - Task DeleteModelAsync(string modelId, CancellationToken ct = default); - - long GetTotalDiskUsage(); -} - -public record ModelDescriptor( - string Id, // "marian-zh-en" - string DisplayName, // "Chinese → English" - string DownloadUrl, // HuggingFace URL - long SizeBytes, - ModelType Type -); - -public enum ModelType -{ - Translation, - PostProcessing, - LanguageDetection -} - -public record InstalledModel( - string Id, - string DisplayName, - string LocalPath, - long SizeBytes, - ModelType Type, - DateTime InstalledAt -); - -public record ModelDownloadProgress( - string ModelId, - long BytesDownloaded, - long TotalBytes -) -{ - public double Percentage => TotalBytes > 0 - ? (double)BytesDownloaded / TotalBytes * 100 - : 0; -} -``` - -### 2.6 DI 注册扩展 - -```csharp -namespace LiveLingo.Core; - -public static class ServiceCollectionExtensions -{ - public static IServiceCollection AddLiveLingoCore( - this IServiceCollection services, - Action? configure = null) - { - var options = new CoreOptions(); - configure?.Invoke(options); - services.AddSingleton(Options.Create(options)); - - services.AddSingleton(); - services.AddSingleton(); - - // P1: stub 实现 - services.AddSingleton(); - services.AddSingleton(); - // 无 ITextProcessor 注册 (P3 才加) - - return services; - } -} - -public class CoreOptions -{ - public string ModelStoragePath { get; set; } = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo", "models"); - - public string DefaultTargetLanguage { get; set; } = "en"; -} -``` - -### 2.7 Stub 实现(P1 阶段临时使用) - -```csharp -// StubTranslationEngine: 直接返回 "[EN] {input}" -// StubLanguageDetector: 始终返回 ("zh", 1.0) -// StubModelManager: EnsureModelAsync 立即返回,ListInstalled 返回空列表 -``` - -## 3. Platform 接口(App 内部) - -### 3.1 聚合接口 - -```csharp -namespace LiveLingo.App.Platform; - -public interface IPlatformServices : IDisposable -{ - IHotkeyService Hotkey { get; } - IWindowTracker WindowTracker { get; } - ITextInjector TextInjector { get; } - IClipboardService Clipboard { get; } -} -``` - -### 3.2 快捷键服务 - -```csharp -namespace LiveLingo.App.Platform; - -public interface IHotkeyService : IDisposable -{ - event Action? HotkeyTriggered; - void Register(HotkeyBinding binding); - void Unregister(string hotkeyId); -} - -public record HotkeyBinding( - string Id, - KeyModifiers Modifiers, // Ctrl, Alt, Shift, Meta - string Key // "T", "Space", etc. -); - -public record HotkeyEventArgs(string HotkeyId); - -[Flags] -public enum KeyModifiers -{ - None = 0, - Ctrl = 1, - Alt = 2, - Shift = 4, - Meta = 8 // Win / Cmd -} -``` - -### 3.3 窗口追踪 - -```csharp -namespace LiveLingo.App.Platform; - -public interface IWindowTracker -{ - TargetWindowInfo? GetForegroundWindowInfo(); -} - -public record TargetWindowInfo( - nint Handle, // 主窗口句柄 (Windows HWND / macOS window number) - nint InputChildHandle, // 输入子窗口 (Windows Chrome renderer / macOS unused) - string ProcessName, - string Title, - int Left, int Top, int Width, int Height -); -``` - -### 3.4 文本注入 - -```csharp -namespace LiveLingo.App.Platform; - -public interface ITextInjector -{ - Task InjectAsync( - TargetWindowInfo target, - string text, - bool autoSend, - CancellationToken ct = default); -} -``` - -注意:PoC 中 `TextInjector.InjectText` 是同步阻塞(Thread.Sleep)。 -P1 将其包装为 async 接口,内部仍可用 Task.Run + 同步调用实现。 -后续优化为真正的 async。 - -### 3.5 剪贴板服务 - -```csharp -namespace LiveLingo.App.Platform; - -public interface IClipboardService -{ - Task SetTextAsync(string text, CancellationToken ct = default); - Task GetTextAsync(CancellationToken ct = default); -} -``` - -从 TextInjector 中提取剪贴板操作到独立服务。 -TextInjector 通过 IClipboardService 设置剪贴板,然后执行注入。 - -## 4. Windows 平台实现迁移 - -### 4.1 文件迁移映射 - -| PoC 源文件 | P1 目标文件 | 实现接口 | -|-----------|------------|----------| -| Services/Platform/Windows/GlobalKeyboardHook.cs | Platform/Windows/Win32HotkeyService.cs | IHotkeyService | -| Services/Platform/Windows/WindowTracker.cs | Platform/Windows/Win32WindowTracker.cs | IWindowTracker | -| Services/Platform/Windows/TextInjector.cs | Platform/Windows/Win32TextInjector.cs | ITextInjector | -| (从 TextInjector 提取) | Platform/Windows/Win32ClipboardService.cs | IClipboardService | -| Services/Platform/Windows/NativeMethods.cs | Platform/Windows/NativeMethods.cs | (不变) | - -### 4.2 WindowsPlatformServices - -```csharp -namespace LiveLingo.App.Platform.Windows; - -internal class WindowsPlatformServices : IPlatformServices -{ - public IHotkeyService Hotkey { get; } - public IWindowTracker WindowTracker { get; } - public ITextInjector TextInjector { get; } - public IClipboardService Clipboard { get; } - - public WindowsPlatformServices() - { - Clipboard = new Win32ClipboardService(); - Hotkey = new Win32HotkeyService(); - WindowTracker = new Win32WindowTracker(); - TextInjector = new Win32TextInjector(Clipboard); - } - - public void Dispose() - { - Hotkey.Dispose(); - } -} -``` - -## 5. DI 组装(App.axaml.cs) - -```csharp -public partial class App : Application -{ - private ServiceProvider? _serviceProvider; - - public override void OnFrameworkInitializationCompleted() - { - var services = new ServiceCollection(); - - services.AddLiveLingoCore(); - - if (OperatingSystem.IsWindows()) - services.AddSingleton(); - // macOS: P4 - - _serviceProvider = services.BuildServiceProvider(); - - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.MainWindow = new MainWindow(); - desktop.ShutdownMode = ShutdownMode.OnMainWindowClose; - - var platform = _serviceProvider.GetRequiredService(); - platform.Hotkey.Register(new HotkeyBinding("overlay", KeyModifiers.Ctrl | KeyModifiers.Alt, "T")); - platform.Hotkey.HotkeyTriggered += args => - Dispatcher.UIThread.Post(() => ShowOverlay(platform)); - } - - base.OnFrameworkInitializationCompleted(); - } - - private void ShowOverlay(IPlatformServices platform) - { - var target = platform.WindowTracker.GetForegroundWindowInfo(); - if (target is null) return; - - // 跳过自身窗口 - // ... - - var pipeline = _serviceProvider!.GetRequiredService(); - var vm = new OverlayViewModel(target, pipeline, platform.TextInjector); - var overlay = new OverlayWindow(vm); - // 定位、显示... - } -} -``` - -## 6. OverlayViewModel 改造 - -```csharp -public partial class OverlayViewModel : ObservableObject -{ - private readonly TargetWindowInfo _target; - private readonly ITranslationPipeline _pipeline; - private readonly ITextInjector _injector; - private CancellationTokenSource? _pipelineCts; - - // 构造函数注入取代 static 调用 - public OverlayViewModel( - TargetWindowInfo target, - ITranslationPipeline pipeline, - ITextInjector injector) - { - _target = target; - _pipeline = pipeline; - _injector = injector; - } - - partial void OnSourceTextChanged(string value) - { - _pipelineCts?.Cancel(); - _pipelineCts = new CancellationTokenSource(); - _ = RunPipelineAsync(value, _pipelineCts.Token); - } - - private async Task RunPipelineAsync(string text, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(text)) - { - TranslatedText = string.Empty; - return; - } - - try - { - var result = await _pipeline.ProcessAsync( - new TranslationRequest(text, null, "en", null), ct); - TranslatedText = result.Text; - } - catch (OperationCanceledException) { } - } - - // 注入调用改为通过 ITextInjector - public async Task InjectAsync(bool autoSend) - { - if (string.IsNullOrWhiteSpace(TranslatedText)) return; - await _injector.InjectAsync(_target, TranslatedText, autoSend); - } -} -``` - -## 7. 诊断工具处理 - -PoC 的三个 CLI 工具保留在 App 项目内,仅在 DEBUG 配置下编译: - -```csharp -#if DEBUG -if (args.Contains("--test-inject")) { InjectionTest.Run(); return; } -if (args.Contains("--diag-window")) { WindowDiagnostic.Run(...); return; } -if (args.Contains("--test-slack")) { SlackAutoTest.Run(); return; } -#endif -``` - -## 8. 目录结构(最终) - -``` -src/ -├── LiveLingo.Core/ -│ ├── LiveLingo.Core.csproj -│ ├── CoreOptions.cs -│ ├── ServiceCollectionExtensions.cs -│ ├── Translation/ -│ │ ├── ITranslationPipeline.cs -│ │ ├── TranslationPipeline.cs -│ │ ├── TranslationRequest.cs -│ │ └── TranslationResult.cs -│ ├── Engines/ -│ │ ├── ITranslationEngine.cs -│ │ └── StubTranslationEngine.cs -│ ├── Processing/ -│ │ ├── ITextProcessor.cs -│ │ └── ProcessingOptions.cs -│ ├── LanguageDetection/ -│ │ ├── ILanguageDetector.cs -│ │ ├── DetectionResult.cs -│ │ └── StubLanguageDetector.cs -│ └── Models/ -│ ├── IModelManager.cs -│ ├── ModelDescriptor.cs -│ ├── InstalledModel.cs -│ ├── ModelDownloadProgress.cs -│ └── StubModelManager.cs -│ -└── LiveLingo.App/ - ├── LiveLingo.App.csproj - ├── Program.cs - ├── App.axaml - ├── App.axaml.cs - ├── app.manifest - ├── Views/ - │ ├── MainWindow.axaml / .cs - │ └── OverlayWindow.axaml / .cs - ├── ViewModels/ - │ └── OverlayViewModel.cs - └── Platform/ - ├── IPlatformServices.cs - ├── IHotkeyService.cs - ├── IWindowTracker.cs - ├── ITextInjector.cs - ├── IClipboardService.cs - ├── TargetWindowInfo.cs - ├── HotkeyBinding.cs - └── Windows/ - ├── WindowsPlatformServices.cs - ├── Win32HotkeyService.cs - ├── Win32WindowTracker.cs - ├── Win32TextInjector.cs - ├── Win32ClipboardService.cs - ├── NativeMethods.cs - └── Diagnostics/ (#if DEBUG) - ├── InjectionTest.cs - ├── SlackAutoTest.cs - └── WindowDiagnostic.cs -``` diff --git a/docs/proposals/specs/P2-translation-core-spec.md b/docs/proposals/specs/P2-translation-core-spec.md deleted file mode 100644 index 72822c4..0000000 --- a/docs/proposals/specs/P2-translation-core-spec.md +++ /dev/null @@ -1,604 +0,0 @@ -# P2 Spec: Translation Core - -## 1. ModelManager - -### 1.1 职责 - -- 从远程(HuggingFace)下载模型文件到本地 -- 支持断点续传(HTTP Range header) -- 报告下载进度 -- 管理本地模型目录(列出、删除、计算磁盘占用) -- 校验模型完整性(文件大小或 SHA256) - -### 1.2 接口实现 - -```csharp -namespace LiveLingo.Core.Models; - -public class ModelManager : IModelManager -{ - private readonly CoreOptions _options; - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - - public ModelManager( - IOptions options, - ILogger logger, - HttpClient httpClient) - { - _options = options.Value; - _logger = logger; - _httpClient = httpClient; - } - - public async Task EnsureModelAsync( - ModelDescriptor descriptor, - IProgress? progress, - CancellationToken ct) - { - var modelDir = GetModelDirectory(descriptor.Id); - - if (IsModelInstalled(descriptor.Id)) - { - _logger.LogDebug("Model {Id} already installed", descriptor.Id); - return; - } - - Directory.CreateDirectory(modelDir); - await DownloadModelAsync(descriptor, modelDir, progress, ct); - WriteManifest(descriptor, modelDir); - } - - // ... -} -``` - -### 1.3 本地存储结构 - -``` -{ModelStoragePath}/ -├── marian-zh-en/ -│ ├── manifest.json // 模型元数据 -│ ├── model.onnx // ONNX 翻译模型 -│ ├── source.spm // SentencePiece 源语言词表 -│ ├── target.spm // SentencePiece 目标语言词表 -│ └── vocab.json // 可选的词汇映射 -│ -├── fasttext-lid/ -│ ├── manifest.json -│ └── lid.176.ftz // FastText 语言检测模型 -│ -└── qwen2.5-1.5b-q4/ // P3 才使用 - ├── manifest.json - └── qwen2.5-1.5b-instruct-q4_k_m.gguf -``` - -### 1.4 manifest.json 格式 - -```json -{ - "id": "marian-zh-en", - "displayName": "Chinese → English", - "type": "Translation", - "version": "1.0.0", - "sizeBytes": 31457280, - "sha256": "abc123...", - "installedAt": "2026-03-05T12:00:00Z", - "files": [ - { "name": "model.onnx", "sizeBytes": 28000000 }, - { "name": "source.spm", "sizeBytes": 800000 }, - { "name": "target.spm", "sizeBytes": 800000 } - ] -} -``` - -### 1.5 模型注册表 - -预定义可用模型(硬编码,后续可改为远程 registry): - -```csharp -public static class ModelRegistry -{ - public static readonly ModelDescriptor MarianZhEn = new( - Id: "marian-zh-en", - DisplayName: "Chinese → English", - DownloadUrl: "https://huggingface.co/Helsinki-NLP/opus-mt-zh-en/resolve/main/", - SizeBytes: 31_457_280, - Type: ModelType.Translation - ); - - public static readonly ModelDescriptor MarianJaEn = new( - Id: "marian-ja-en", - DisplayName: "Japanese → English", - DownloadUrl: "https://huggingface.co/Helsinki-NLP/opus-mt-ja-en/resolve/main/", - SizeBytes: 29_360_128, - Type: ModelType.Translation - ); - - public static readonly ModelDescriptor FastTextLid = new( - Id: "fasttext-lid", - DisplayName: "Language Detection", - DownloadUrl: "https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz", - SizeBytes: 917_734, - Type: ModelType.LanguageDetection - ); - - public static readonly ModelDescriptor Qwen25_15B = new( - Id: "qwen2.5-1.5b-q4", - DisplayName: "AI Polish (Qwen2.5-1.5B)", - DownloadUrl: "https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf", - SizeBytes: 1_073_741_824, - Type: ModelType.PostProcessing - ); - - public static IReadOnlyList All => [MarianZhEn, MarianJaEn, FastTextLid, Qwen25_15B]; - public static IReadOnlyList TranslationModels => [MarianZhEn, MarianJaEn]; -} -``` - -### 1.6 下载行为 - -- 使用 `HttpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead)` 流式下载 -- 每 100ms 报告一次进度 -- 支持 `Range` header 断点续传(检测临时文件 `.part`) -- 下载完成后原子重命名 `.part` → 正式文件 -- 取消时保留 `.part` 文件以便续传 -- 并发下载保护(同一模型不重复下载) - -### 1.7 错误处理 - -| 错误场景 | 处理 | -|---------|------| -| 网络断开 | 抛出 `ModelDownloadException`,保留 .part | -| 磁盘空间不足 | 下载前检查可用空间,不足时抛出 `InsufficientDiskSpaceException` | -| 文件损坏 | 校验 SHA256,不匹配则删除并抛出 `ModelCorruptException` | -| URL 404 | 抛出 `ModelNotFoundException` | - -## 2. MarianMT 翻译引擎 - -### 2.1 ONNX 模型文件 - -HuggingFace 上的 MarianMT ONNX 模型包含: -- `encoder_model.onnx` — encoder 部分 -- `decoder_model.onnx` 或 `decoder_model_merged.onnx` — decoder 部分 -- `source.spm` — SentencePiece 源语言分词模型 -- `target.spm` — SentencePiece 目标语言分词模型 -- `tokenizer_config.json` — 分词器配置 - -注意:部分 HuggingFace 模型提供合并的单文件 `model.onnx`,部分拆分为 encoder/decoder。 -需要在 ModelRegistry 中标注模型结构类型。 - -### 2.2 推理流程 - -``` -输入: "你好世界" - │ - ▼ -┌────────────────────────────────────────┐ -│ 1. SentencePiece Encode │ -│ source.spm.Encode("你好世界") │ -│ → token_ids: [256, 1024, 8192, ...] │ -│ → attention_mask: [1, 1, 1, ...] │ -└────────────────────┬───────────────────┘ - ▼ -┌────────────────────────────────────────┐ -│ 2. ONNX Encoder │ -│ input: input_ids, attention_mask │ -│ output: encoder_hidden_states │ -└────────────────────┬───────────────────┘ - ▼ -┌────────────────────────────────────────┐ -│ 3. ONNX Decoder (autoregressive) │ -│ 循环生成 token 直到 EOS │ -│ 每步输入: decoder_input_ids, │ -│ encoder_hidden_states │ -│ 每步输出: next_token_logits │ -│ beam_size: 4 (可配置) │ -└────────────────────┬───────────────────┘ - ▼ -┌────────────────────────────────────────┐ -│ 4. SentencePiece Decode │ -│ target.spm.Decode(output_ids) │ -│ → "Hello world" │ -└────────────────────────────────────────┘ -``` - -### 2.3 类设计 - -```csharp -namespace LiveLingo.Core.Engines; - -public class MarianOnnxEngine : ITranslationEngine -{ - private readonly IModelManager _modelManager; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _sessions = new(); - - public async Task TranslateAsync( - string text, string sourceLang, string targetLang, CancellationToken ct) - { - var modelId = $"marian-{sourceLang}-{targetLang}"; - var session = await GetOrLoadSessionAsync(modelId, ct); - - var inputIds = session.Tokenizer.Encode(text); - var result = await session.RunInferenceAsync(inputIds, ct); - return session.Tokenizer.Decode(result); - } - - public bool SupportsLanguagePair(string src, string tgt) - => ModelRegistry.TranslationModels.Any(m => m.Id == $"marian-{src}-{tgt}"); - - private async Task GetOrLoadSessionAsync(string modelId, CancellationToken ct) - { - if (_sessions.TryGetValue(modelId, out var cached)) - return cached; - - var descriptor = ModelRegistry.All.First(m => m.Id == modelId); - await _modelManager.EnsureModelAsync(descriptor, progress: null, ct); - - var modelPath = Path.Combine(_modelManager.GetModelPath(modelId)); - var session = new ModelSession(modelPath); - _sessions.TryAdd(modelId, session); - return session; - } - - public void Dispose() - { - foreach (var session in _sessions.Values) - session.Dispose(); - _sessions.Clear(); - } -} -``` - -### 2.4 ModelSession(内部类) - -```csharp -internal class ModelSession : IDisposable -{ - public SentencePieceTokenizer Tokenizer { get; } - private readonly InferenceSession _encoderSession; - private readonly InferenceSession _decoderSession; - - public ModelSession(string modelDirectory) - { - var sessionOptions = new SessionOptions - { - GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, - InterOpNumThreads = 2, - IntraOpNumThreads = 4 - }; - - _encoderSession = new InferenceSession( - Path.Combine(modelDirectory, "encoder_model.onnx"), sessionOptions); - _decoderSession = new InferenceSession( - Path.Combine(modelDirectory, "decoder_model_merged.onnx"), sessionOptions); - - Tokenizer = new SentencePieceTokenizer( - Path.Combine(modelDirectory, "source.spm"), - Path.Combine(modelDirectory, "target.spm")); - } - - public async Task RunInferenceAsync(int[] inputIds, CancellationToken ct) - { - // Encoder - var encoderOutput = RunEncoder(inputIds); - ct.ThrowIfCancellationRequested(); - - // Decoder (autoregressive beam search) - return await Task.Run(() => BeamSearchDecode(encoderOutput, ct), ct); - } - - // ... -} -``` - -### 2.5 SentencePiece 分词器 - -.NET 生态中 SentencePiece 的选项: - -| 方案 | 特点 | -|------|------| -| SentencePieceSharp (NuGet) | 社区维护,可能不活跃 | -| 自建 P/Invoke 绑定 | 控制力强,需编译 native lib | -| 预处理为 BPE 词表 + 纯 C# 实现 | 无 native 依赖,但兼容性风险 | - -推荐:先评估 SentencePieceSharp,如不可用再自建 P/Invoke。 - -```csharp -public class SentencePieceTokenizer : IDisposable -{ - private readonly IntPtr _sourceProcessor; - private readonly IntPtr _targetProcessor; - - public int[] Encode(string text) - { - // 调用 sp_encode_as_ids - // 添加 BOS/EOS token - } - - public string Decode(int[] ids) - { - // 调用 sp_decode_from_ids - // 去除特殊 token - } -} -``` - -### 2.6 Beam Search 解码 - -简化实现(beam_size=1 即 greedy search 作为起点): - -``` -初始化: decoder_input = [BOS_TOKEN] -循环: - 1. decoder(decoder_input, encoder_output) → logits - 2. next_token = argmax(logits[-1]) - 3. if next_token == EOS_TOKEN: break - 4. decoder_input = append(decoder_input, next_token) - 5. if len(decoder_input) > max_length: break -返回: decoder_input (不含 BOS/EOS) -``` - -后续可优化为 beam_size=4 的标准 beam search。 - -## 3. 语言检测 - -### 3.1 FastText 集成 - -FastText `lid.176.ftz` 模型 (~1MB) 可通过 ONNX 或原生方式推理。 - -方案 A — FastText 原生(推荐): -- 使用 FastText 的二进制格式直接解析 -- 社区有 .NET 绑定:FastText.NetWrapper 或自建 -- 推理极快 (<1ms) - -方案 B — 简化实现: -- 使用 n-gram + 字符分布的简单启发式算法 -- 区分 CJK / Latin / Cyrillic 等脚本 -- 精度较低但零依赖 - -```csharp -namespace LiveLingo.Core.LanguageDetection; - -public class FastTextDetector : ILanguageDetector -{ - private readonly string _modelPath; - private FastTextModel? _model; - - public async Task DetectAsync(string text, CancellationToken ct) - { - _model ??= await Task.Run(() => FastTextModel.Load(_modelPath), ct); - var prediction = _model.Predict(text, k: 1); - return new DetectionResult( - Language: prediction.Label.Replace("__label__", ""), - Confidence: prediction.Score - ); - } -} -``` - -### 3.2 降级策略 - -如果 FastText 模型未下载,使用 Unicode script 启发式检测: - -```csharp -public class ScriptBasedDetector : ILanguageDetector -{ - public Task DetectAsync(string text, CancellationToken ct) - { - // CJK Unified Ideographs → "zh" - // Hiragana/Katakana → "ja" - // Hangul → "ko" - // Cyrillic → "ru" - // Latin (default) → "en" - // 其他 → "unknown" - } -} -``` - -## 4. TranslationPipeline 组装 - -### 4.1 完整实现 - -```csharp -namespace LiveLingo.Core.Translation; - -public class TranslationPipeline : ITranslationPipeline -{ - private readonly ILanguageDetector _detector; - private readonly ITranslationEngine _engine; - private readonly IEnumerable _processors; - private readonly ILogger _logger; - - public TranslationPipeline( - ILanguageDetector detector, - ITranslationEngine engine, - IEnumerable processors, - ILogger logger) - { - _detector = detector; - _engine = engine; - _processors = processors; - _logger = logger; - } - - public async Task ProcessAsync( - TranslationRequest request, CancellationToken ct) - { - // 1. 语言检测 - var srcLang = request.SourceLanguage; - if (string.IsNullOrEmpty(srcLang)) - { - var detection = await _detector.DetectAsync(request.SourceText, ct); - srcLang = detection.Language; - _logger.LogDebug("Detected language: {Lang} ({Conf:P0})", - detection.Language, detection.Confidence); - } - - // 源语言与目标语言相同时直接返回 - if (srcLang == request.TargetLanguage) - { - return new TranslationResult( - request.SourceText, srcLang, request.SourceText, - TimeSpan.Zero, null); - } - - ct.ThrowIfCancellationRequested(); - - // 2. 翻译 - var sw = Stopwatch.StartNew(); - var translated = await _engine.TranslateAsync( - request.SourceText, srcLang, request.TargetLanguage, ct); - var translationDuration = sw.Elapsed; - - ct.ThrowIfCancellationRequested(); - - // 3. 后处理 (P2 阶段: processors 为空,跳过) - var finalText = translated; - TimeSpan? postDuration = null; - - if (request.PostProcessing is { } opts) - { - sw.Restart(); - foreach (var proc in SelectProcessors(opts)) - { - ct.ThrowIfCancellationRequested(); - finalText = await proc.ProcessAsync( - finalText, request.TargetLanguage, ct); - } - postDuration = sw.Elapsed; - } - - return new TranslationResult( - finalText, srcLang, translated, - translationDuration, postDuration); - } - - private IEnumerable SelectProcessors(ProcessingOptions opts) - { - if (opts.Summarize) - yield return _processors.First(p => p.Name == "summarize"); - if (opts.Optimize) - yield return _processors.First(p => p.Name == "optimize"); - if (opts.Colloquialize) - yield return _processors.First(p => p.Name == "colloquialize"); - } -} -``` - -### 4.2 DI 注册(P2 阶段) - -```csharp -public static IServiceCollection AddLiveLingoCore( - this IServiceCollection services, - Action? configure = null) -{ - // options - var options = new CoreOptions(); - configure?.Invoke(options); - services.AddSingleton(Options.Create(options)); - - // infrastructure - services.AddHttpClient(); - - // engines (P2: 真实实现) - services.AddSingleton(); - services.AddSingleton(); - - // pipeline - services.AddSingleton(); - - // processors: P2 不注册, P3 才加 - - return services; -} -``` - -## 5. ViewModel 集成 - -### 5.1 状态显示 - -翻译过程中 OverlayViewModel 的 StatusText 变化: - -``` -IDLE: "Ctrl+Enter paste & send · Esc cancel" -TRANSLATING: "Translating..." -TRANSLATED: "Translated (150ms) · Ctrl+Enter paste & send" -ERROR: "Error: Model not found for ja→en" -``` - -### 5.2 Cancel 行为 - -``` -用户打字 "你" → 触发翻译 #1 -用户打字 "你好" → 取消 #1, 触发翻译 #2 -用户打字 "你好世" → 取消 #2, 触发翻译 #3 -用户停止打字 → 翻译 #3 完成, 显示结果 -``` - -无 debounce(PoC 有 200ms debounce)。 -MarianMT 足够快 (<200ms),每次变化直接触发。 -如果发现性能问题再加 debounce。 - -## 6. 基础模型下载 UI - -### 6.1 触发时机 - -App 启动时检查核心模型是否存在: -- `fasttext-lid` — 语言检测(必需) -- `marian-zh-en` — 默认翻译对(必需) - -如果任一缺失,显示下载对话框后才进入正常模式。 - -### 6.2 UI - -简单的 Window,显示进度: - -``` -┌─────────────────────────────────────────┐ -│ Setting up LiveLingo... │ -│ │ -│ Downloading translation models: │ -│ │ -│ ☑ Language Detection (1 MB) Done │ -│ ◻ Chinese → English (30 MB) ████░ 80% │ -│ │ -│ [Cancel] │ -└─────────────────────────────────────────┘ -``` - -## 7. 新增 NuGet 依赖 - -| 包 | 版本 | 项目 | 用途 | -|----|------|------|------| -| Microsoft.ML.OnnxRuntime | 1.* | Core | MarianMT ONNX 推理 | -| Microsoft.Extensions.Http | 10.* | Core | HttpClient DI | - -SentencePiece 绑定根据评估结果选定(SentencePieceSharp 或自建)。 -FastText 绑定根据评估结果选定。 - -## 8. 测试策略 - -### 8.1 单元测试 - -``` -LiveLingo.Core.Tests/ -├── Models/ -│ └── ModelManagerTests.cs // mock HttpClient, 测试下载/缓存/删除 -├── Engines/ -│ └── MarianOnnxEngineTests.cs // 集成测试, 需要真实模型文件 -├── LanguageDetection/ -│ └── FastTextDetectorTests.cs // 集成测试 -└── Translation/ - └── TranslationPipelineTests.cs // mock engine/detector, 测试编排 -``` - -### 8.2 集成测试 checklist - -- [ ] 下载 MarianMT zh→en 模型到临时目录 -- [ ] 翻译 "你好世界" → 包含 "hello" / "world" (不区分大小写) -- [ ] 检测 "你好" → "zh", 置信度 > 0.8 -- [ ] 检测 "hello" → "en", 置信度 > 0.8 -- [ ] Pipeline 端到端: 中文输入 → 英文输出 -- [ ] 取消: 翻译进行中取消, 不抛非 OperationCanceledException 的异常 diff --git a/docs/proposals/specs/P3-ai-postprocessing-spec.md b/docs/proposals/specs/P3-ai-postprocessing-spec.md deleted file mode 100644 index 5ba5bef..0000000 --- a/docs/proposals/specs/P3-ai-postprocessing-spec.md +++ /dev/null @@ -1,419 +0,0 @@ -# P3 Spec: AI Post-processing - -## 1. LLamaSharp 集成 - -### 1.1 NuGet 包 - -```xml - - - - - - -``` - -### 1.2 模型文件 - -| 项 | 值 | -|----|----| -| 模型 | Qwen2.5-1.5B-Instruct | -| 格式 | GGUF (Q4_K_M 量化) | -| 文件名 | qwen2.5-1.5b-instruct-q4_k_m.gguf | -| 大小 | ~1.0 GB | -| 来源 | https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF | -| 上下文窗口 | 32K tokens (实际使用限制到 2K) | - -### 1.3 模型生命周期管理 - -```csharp -namespace LiveLingo.Core.Processing; - -public class QwenModelHost : IDisposable -{ - private LLamaWeights? _weights; - private readonly SemaphoreSlim _loadLock = new(1, 1); - private Timer? _unloadTimer; - - private readonly string _modelPath; - private readonly ILogger _logger; - - private static readonly TimeSpan UnloadTimeout = TimeSpan.FromMinutes(5); - - /// - /// 获取或加载模型。首次调用会加载模型 (3-5s)。 - /// 5 分钟无调用后自动卸载释放内存。 - /// - public async Task GetWeightsAsync(CancellationToken ct) - { - ResetUnloadTimer(); - - if (_weights is not null) - return _weights; - - await _loadLock.WaitAsync(ct); - try - { - if (_weights is not null) - return _weights; - - _logger.LogInformation("Loading Qwen model from {Path}...", _modelPath); - var parameters = new ModelParams(_modelPath) - { - ContextSize = 2048, - GpuLayerCount = 0, // CPU only (P3 scope) - Threads = Environment.ProcessorCount / 2 - }; - _weights = await Task.Run( - () => LLamaWeights.LoadFromFile(parameters), ct); - _logger.LogInformation("Qwen model loaded"); - return _weights; - } - finally - { - _loadLock.Release(); - } - } - - private void ResetUnloadTimer() - { - _unloadTimer?.Dispose(); - _unloadTimer = new Timer(_ => Unload(), null, UnloadTimeout, Timeout.InfiniteTimeSpan); - } - - private void Unload() - { - _loadLock.Wait(); - try - { - if (_weights is null) return; - _logger.LogInformation("Unloading Qwen model (idle timeout)"); - _weights.Dispose(); - _weights = null; - } - finally - { - _loadLock.Release(); - } - } - - public void Dispose() - { - _unloadTimer?.Dispose(); - _weights?.Dispose(); - } -} -``` - -### 1.4 加载状态通知 - -QwenModelHost 暴露加载状态事件,供 UI 显示: - -```csharp -public enum ModelLoadState { Unloaded, Loading, Loaded } - -public event Action? StateChanged; -``` - -OverlayViewModel 监听此事件更新 StatusText: -- `Loading` → "Loading AI model..." -- `Loaded` → 正常流程 - -## 2. 三种后处理器 - -### 2.1 共通基类 - -```csharp -namespace LiveLingo.Core.Processing; - -public abstract class QwenTextProcessor : ITextProcessor -{ - private readonly QwenModelHost _modelHost; - private readonly ILogger _logger; - - public abstract string Name { get; } - protected abstract string SystemPrompt { get; } - - protected QwenTextProcessor(QwenModelHost modelHost, ILogger logger) - { - _modelHost = modelHost; - _logger = logger; - } - - public async Task ProcessAsync( - string text, string language, CancellationToken ct) - { - var weights = await _modelHost.GetWeightsAsync(ct); - - using var context = weights.CreateContext(new ModelParams(string.Empty) - { - ContextSize = 2048 - }); - - var executor = new InstructExecutor(context); - var inferenceParams = new InferenceParams - { - MaxTokens = 512, - Temperature = 0.3f, - TopP = 0.9f, - AntiPrompts = ["\n\n", "Text:", "Input:"] - }; - - var prompt = BuildPrompt(text); - var result = new StringBuilder(); - - await foreach (var token in executor.InferAsync(prompt, inferenceParams, ct)) - { - result.Append(token); - - // 安全上限 - if (result.Length > text.Length * 3) - break; - } - - var processed = result.ToString().Trim(); - _logger.LogDebug("[{Name}] {InputLen} → {OutputLen} chars", - Name, text.Length, processed.Length); - - return string.IsNullOrWhiteSpace(processed) ? text : processed; - } - - private string BuildPrompt(string text) - { - return $""" - <|im_start|>system - {SystemPrompt}<|im_end|> - <|im_start|>user - {text}<|im_end|> - <|im_start|>assistant - """; - } -} -``` - -### 2.2 SummarizeProcessor - -```csharp -public class SummarizeProcessor : QwenTextProcessor -{ - public override string Name => "summarize"; - - protected override string SystemPrompt => - """ - You are a concise text editor. Your task: - - Shorten the input text while preserving its core meaning - - Remove redundancy and filler words - - Keep the same language as the input - - Output ONLY the shortened text, nothing else - - If the text is already short (under 20 words), return it unchanged - """; -} -``` - -### 2.3 OptimizeProcessor - -```csharp -public class OptimizeProcessor : QwenTextProcessor -{ - public override string Name => "optimize"; - - protected override string SystemPrompt => - """ - You are a professional text editor. Your task: - - Fix any grammar or spelling errors - - Improve clarity and natural flow - - Keep the original meaning and tone - - Keep the same language as the input - - Output ONLY the improved text, nothing else - - If the text is already well-written, return it unchanged - """; -} -``` - -### 2.4 ColloquializeProcessor - -```csharp -public class ColloquializeProcessor : QwenTextProcessor -{ - public override string Name => "colloquialize"; - - protected override string SystemPrompt => - """ - You are a casual chat writer. Your task: - - Rewrite the input in a relaxed, friendly conversational tone - - Make it suitable for workplace chat (Slack, Teams, Discord) - - Keep it natural, brief, and approachable - - Keep the same language as the input - - Output ONLY the rewritten text, nothing else - - Do NOT add greetings or sign-offs unless they were in the original - """; -} -``` - -## 3. DI 注册 - -```csharp -// P3 阶段的 AddLiveLingoCore 扩展 -public static IServiceCollection AddLiveLingoCore( - this IServiceCollection services, - Action? configure = null) -{ - // ... (P2 注册内容) - - // P3: 后处理 - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - return services; -} -``` - -Pipeline 通过 `IEnumerable` 注入所有处理器,按名称选择。 - -## 4. Overlay UI 变化 - -### 4.1 后处理模式选择器 - -在状态栏增加下拉选择(替换 PoC 中的 toggle button): - -``` -┌──────────────────────────────────────────────────────┐ -│ Ctrl+Enter paste & send [Off ▾] [Paste&Send] │ -│ ├─ Off │ -│ ├─ Summarize │ -│ ├─ Optimize │ -│ └─ Colloquial │ -└──────────────────────────────────────────────────────┘ -``` - -### 4.2 OverlayViewModel 变化 - -```csharp -[ObservableProperty] -private ProcessingMode _postProcessMode = ProcessingMode.Off; - -// 上次选择持久保持 -private static ProcessingMode _lastPostProcessMode = ProcessingMode.Off; - -partial void OnPostProcessModeChanged(ProcessingMode value) -{ - _lastPostProcessMode = value; - // 如果已有翻译结果,触发重新后处理 - if (!string.IsNullOrWhiteSpace(TranslatedText)) - RerunPostProcessing(); -} -``` - -### 4.3 Preview 两阶段更新 - -``` -用户打字 → RunPipelineAsync: - │ - ├─ Stage 1: MarianMT - │ TranslatedText = "The meeting is scheduled for next Monday to discuss the proposal." - │ StatusText = "Translated (120ms)" - │ - ├─ (如果 PostProcessMode != Off) - │ StatusText = "Polishing..." - │ - └─ Stage 2: Qwen - TranslatedText = "Let's meet Monday to go over the proposal" - StatusText = "Polished (1.8s)" -``` - -用户在 Stage 1 完成后立即看到原始翻译,然后看到润色版替换上去。 - -## 5. 模型按需下载 - -### 5.1 触发时机 - -用户首次选择非 Off 的后处理模式时: - -``` -用户选择 "Colloquial" - │ - ├─ Qwen 模型已下载? → 正常执行 - │ - └─ 未下载 → 弹出下载确认 - "AI Polish requires Qwen2.5-1.5B model (1.0 GB). Download now?" - [Download] [Cancel] - │ - └─ 下载中显示进度 → 完成后自动执行后处理 -``` - -### 5.2 下载进度集成 - -在 overlay 状态栏或单独弹窗显示下载进度。 -下载完成后自动切换到 loaded 状态。 - -## 6. 推理参数 - -| 参数 | 值 | 说明 | -|------|-----|------| -| max_tokens | 512 | 防止无限生成 | -| temperature | 0.3 | 低随机性,保持忠实 | -| top_p | 0.9 | 核采样 | -| context_size | 2048 | 输入+输出上限 | -| threads | CPU cores / 2 | CPU 推理线程数 | -| anti_prompts | `["\n\n"]` | 遇到双换行停止 | - -### 6.1 输入长度限制 - -- Qwen 上下文 2048 tokens -- System prompt 约 100 tokens -- 保留 512 tokens 给输出 -- 输入文本上限约 1400 tokens (~5000 字符) -- 超过上限时截断并在日志中警告 - -## 7. 错误处理 - -| 场景 | 处理 | -|------|------| -| 模型加载失败 | StatusText 显示错误,后处理降级为 Off | -| 推理超时 (>10s) | 取消推理,返回原始翻译,StatusText 提示 | -| 输出为空/无意义 | 回退到原始翻译 | -| OOM | 捕获异常,卸载模型,降级为 Off,建议用户关闭其他程序 | -| 取消 | OperationCanceledException 静默处理 | - -## 8. 性能基准(预期) - -| 输入长度 | MarianMT | Qwen 后处理 | 总计 | -|----------|----------|-------------|------| -| 10 字 | ~100ms | ~1.0s | ~1.1s | -| 50 字 | ~150ms | ~2.0s | ~2.2s | -| 200 字 | ~250ms | ~4.0s | ~4.3s | - -CPU: 现代 4 核以上。GPU 加速可将 Qwen 提速 5-10x(P3 scope 外)。 - -## 9. 测试 - -### 9.1 单元测试 - -``` -Processing/ -├── QwenTextProcessorTests.cs -│ ├── ProcessAsync_ReturnsNonEmpty -│ ├── ProcessAsync_RespectsCancel -│ ├── ProcessAsync_FallsBackOnEmpty -│ └── ProcessAsync_RespectsMaxLength -│ -├── SummarizeProcessorTests.cs -│ └── ProcessAsync_ShorterThanInput (集成测试, 需要模型) -│ -└── QwenModelHostTests.cs - ├── GetWeightsAsync_LoadsOnce - ├── Unload_AfterTimeout - └── Dispose_ReleasesResources -``` - -### 9.2 集成测试 checklist - -- [ ] Qwen 模型加载成功 (<5s) -- [ ] Colloquialize 输出比输入更口语化(人工验证) -- [ ] Summarize 输出长度 < 输入长度 -- [ ] Optimize 输出语法正确(人工验证) -- [ ] 连续调用复用同一模型实例 -- [ ] 5 分钟无调用后模型自动卸载 -- [ ] 取消不泄漏资源 diff --git a/docs/proposals/specs/P4-macos-platform-spec.md b/docs/proposals/specs/P4-macos-platform-spec.md deleted file mode 100644 index bd3a778..0000000 --- a/docs/proposals/specs/P4-macos-platform-spec.md +++ /dev/null @@ -1,573 +0,0 @@ -# P4 Spec: macOS Platform - -## 1. 前置条件 - -### 1.1 系统要求 - -- macOS 12 Monterey+(Accessibility API 稳定性) -- .NET 10 + Avalonia 11 (macOS arm64 / x64) - -### 1.2 权限要求 - -```xml - -NSAccessibilityUsageDescription -LiveLingo needs Accessibility access to detect text input areas and inject translated text. -``` - -用户必须在 System Settings → Privacy & Security → Accessibility 中授权 LiveLingo。 - -### 1.3 权限检测 - -```csharp -namespace LiveLingo.App.Platform.macOS; - -public static class AccessibilityPermission -{ - [DllImport("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")] - private static extern bool AXIsProcessTrustedWithOptions(IntPtr options); - - [DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")] - private static extern IntPtr CFDictionaryCreate( - IntPtr allocator, IntPtr[] keys, IntPtr[] values, - nint numValues, IntPtr keyCallBacks, IntPtr valueCallBacks); - - public static bool IsGranted() - => AXIsProcessTrustedWithOptions(IntPtr.Zero); - - public static bool RequestAndCheck() - { - // kAXTrustedCheckOptionPrompt = true - // 这会弹出系统授权对话框 - var key = CFStringCreate("AXTrustedCheckOptionPrompt"); - var value = CFBooleanTrue(); - var dict = CFDictionaryCreate(..., [key], [value], 1, ...); - return AXIsProcessTrustedWithOptions(dict); - } -} -``` - -## 2. 全局快捷键 — CGEventTap - -### 2.1 方案对比 - -| 方案 | 优点 | 缺点 | -|------|------|------| -| CGEventTap | 系统级拦截,可消费事件 | 需要 Accessibility 权限 | -| NSEvent.addGlobalMonitorForEvents | 不需要 Accessibility | 不能消费事件,可能与 Slack 冲突 | -| Carbon RegisterEventHotKey | 传统方案,专为全局热键设计 | 已 deprecated | - -选择 CGEventTap:功能最强,且已需要 Accessibility 权限(文本注入也需要)。 - -### 2.2 实现 - -```csharp -namespace LiveLingo.App.Platform.macOS; - -public class CoreGraphicsHotkeyService : IHotkeyService, IDisposable -{ - private IntPtr _eventTap; - private IntPtr _runLoopSource; - private readonly Dictionary _bindings = new(); - private Thread? _tapThread; - - public event Action? HotkeyTriggered; - - public void Register(HotkeyBinding binding) - { - _bindings[binding.Id] = binding; - - if (_eventTap == IntPtr.Zero) - StartEventTap(); - } - - public void Unregister(string hotkeyId) - { - _bindings.Remove(hotkeyId); - if (_bindings.Count == 0) - StopEventTap(); - } - - private void StartEventTap() - { - _tapThread = new Thread(() => - { - _eventTap = CGEventTapCreate( - tap: CGEventTapLocation.CGHIDEventTap, - place: CGEventTapPlacement.HeadInsertEventTap, - options: CGEventTapOptions.ListenOnly, // 不消费事件 - eventsOfInterest: CGEventMask.KeyDown, - callback: TapCallback, - userInfo: IntPtr.Zero - ); - - if (_eventTap == IntPtr.Zero) - throw new PlatformNotSupportedException( - "CGEventTap creation failed. Accessibility permission may be missing."); - - _runLoopSource = CFMachPortCreateRunLoopSource( - IntPtr.Zero, _eventTap, 0); - CFRunLoopAddSource( - CFRunLoopGetCurrent(), _runLoopSource, kCFRunLoopCommonModes); - CGEventTapEnable(_eventTap, true); - CFRunLoopRun(); - }) - { - IsBackground = true, - Name = "CGEventTap" - }; - _tapThread.Start(); - } - - private IntPtr TapCallback( - IntPtr proxy, CGEventType type, IntPtr eventRef, IntPtr userInfo) - { - if (type == CGEventType.KeyDown) - { - var keyCode = CGEventGetIntegerValueField(eventRef, CGEventField.KeyboardEventKeycode); - var flags = CGEventGetFlags(eventRef); - - foreach (var (id, binding) in _bindings) - { - if (MatchesBinding(keyCode, flags, binding)) - { - Avalonia.Threading.Dispatcher.UIThread.Post( - () => HotkeyTriggered?.Invoke(new HotkeyEventArgs(id))); - break; - } - } - } - - return eventRef; // 不修改事件 - } - - private static bool MatchesBinding(long keyCode, CGEventFlags flags, HotkeyBinding binding) - { - if (MacKeyMap.GetKeyCode(binding.Key) != keyCode) - return false; - - var required = CGEventFlags.None; - if (binding.Modifiers.HasFlag(KeyModifiers.Ctrl)) - required |= CGEventFlags.MaskControl; - if (binding.Modifiers.HasFlag(KeyModifiers.Alt)) - required |= CGEventFlags.MaskAlternate; - if (binding.Modifiers.HasFlag(KeyModifiers.Shift)) - required |= CGEventFlags.MaskShift; - if (binding.Modifiers.HasFlag(KeyModifiers.Meta)) - required |= CGEventFlags.MaskCommand; - - return (flags & required) == required; - } - - public void Dispose() - { - StopEventTap(); - } - - private void StopEventTap() - { - if (_eventTap != IntPtr.Zero) - { - CGEventTapEnable(_eventTap, false); - CFRunLoopStop(CFRunLoopGetCurrent()); - CFRelease(_eventTap); - _eventTap = IntPtr.Zero; - } - } -} -``` - -### 2.3 macOS Key Code 映射 - -```csharp -internal static class MacKeyMap -{ - private static readonly Dictionary KeyCodes = new() - { - ["A"] = 0x00, ["B"] = 0x0B, ["C"] = 0x08, ["D"] = 0x02, - ["E"] = 0x0E, ["F"] = 0x03, ["G"] = 0x05, ["H"] = 0x04, - ["I"] = 0x22, ["J"] = 0x26, ["K"] = 0x28, ["L"] = 0x25, - ["M"] = 0x2E, ["N"] = 0x2D, ["O"] = 0x1F, ["P"] = 0x23, - ["Q"] = 0x0C, ["R"] = 0x0F, ["S"] = 0x01, ["T"] = 0x11, - ["U"] = 0x20, ["V"] = 0x09, ["W"] = 0x0D, ["X"] = 0x07, - ["Y"] = 0x10, ["Z"] = 0x06, - ["Space"] = 0x31, ["Return"] = 0x24, ["Tab"] = 0x30, - ["Escape"] = 0x35, - }; - - public static long GetKeyCode(string key) - => KeyCodes.TryGetValue(key, out var code) ? code - : throw new ArgumentException($"Unknown key: {key}"); -} -``` - -## 3. 窗口追踪 — NSWorkspace - -### 3.1 实现 - -```csharp -namespace LiveLingo.App.Platform.macOS; - -public class MacWindowTracker : IWindowTracker -{ - public TargetWindowInfo? GetForegroundWindowInfo() - { - // 1. 获取前台应用 - var frontApp = NSWorkspace.SharedWorkspace.FrontmostApplication; - if (frontApp is null) return null; - - // 2. 通过 CGWindowListCopyWindowInfo 获取窗口详情 - var windowList = CGWindowListCopyWindowInfo( - CGWindowListOption.OptionOnScreenOnly | CGWindowListOption.ExcludeDesktopElements, - kCGNullWindowID); - - var appPid = frontApp.ProcessIdentifier; - - // 3. 遍历找到属于前台应用的第一个窗口 - foreach (var windowInfo in windowList) - { - if (windowInfo.OwnerPID != appPid) continue; - if (windowInfo.Layer != 0) continue; // 只关注标准窗口层级 - - return new TargetWindowInfo( - Handle: (nint)windowInfo.WindowNumber, - InputChildHandle: (nint)windowInfo.WindowNumber, // macOS 不区分子窗口 - ProcessName: frontApp.LocalizedName ?? frontApp.BundleIdentifier ?? "unknown", - Title: windowInfo.Name ?? "", - Left: (int)windowInfo.Bounds.X, - Top: (int)windowInfo.Bounds.Y, - Width: (int)windowInfo.Bounds.Width, - Height: (int)windowInfo.Bounds.Height - ); - } - - return null; - } -} -``` - -### 3.2 P/Invoke 声明 - -```csharp -// CoreGraphics -[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")] -private static extern IntPtr CGWindowListCopyWindowInfo( - CGWindowListOption option, uint relativeToWindow); - -// AppKit 通过 Objective-C runtime -[DllImport("/usr/lib/libobjc.dylib", EntryPoint = "objc_msgSend")] -private static extern IntPtr objc_msgSend(IntPtr receiver, IntPtr selector); -``` - -实际封装会使用 Objective-C runtime interop 或 NativeReference 方式调用 AppKit。 - -## 4. 文本注入 — AXUIElement (Accessibility API) - -### 4.1 注入策略 - -macOS 的注入策略与 Windows 有本质不同: - -| Windows 策略 | macOS 对应 | -|-------------|-----------| -| SendInput (Ctrl+V) | CGEventPost (Cmd+V) | -| WM_CHAR (PostMessage) | AXUIElement.SetValue (直接设值) | -| Chrome_RenderWidgetHostHWND | AXFocusedUIElement (自动定位) | - -**主策略:AXUIElement SetValue** -- macOS Accessibility API 可直接查询并设置 focused element 的 value -- 不需要查找子窗口句柄 -- Electron/Chromium 应用支持 Accessibility(前提是已开启) - -**备选策略:CGEventPost (Cmd+V)** -- 通过剪贴板 + 模拟 Cmd+V 粘贴 -- 与 Windows SendInput 策略对应 - -### 4.2 实现 - -```csharp -namespace LiveLingo.App.Platform.macOS; - -public class MacTextInjector : ITextInjector -{ - private readonly IClipboardService _clipboard; - private readonly ILogger _logger; - - public async Task InjectAsync( - TargetWindowInfo target, string text, bool autoSend, CancellationToken ct) - { - // 策略1: AXUIElement 直接设值 - if (TrySetViaAccessibility(target, text)) - { - _logger.LogDebug("AXUIElement SetValue succeeded"); - if (autoSend) await SimulateReturnKeyAsync(); - return; - } - - // 策略2: 剪贴板 + Cmd+V - _logger.LogDebug("Falling back to clipboard + Cmd+V"); - await _clipboard.SetTextAsync(text, ct); - await SimulatePasteAsync(); - if (autoSend) await SimulateReturnKeyAsync(); - } - - private bool TrySetViaAccessibility(TargetWindowInfo target, string text) - { - // 1. 获取目标应用的 AXUIElement - var appElement = AXUIElementCreateApplication(target.Handle); - - // 2. 获取 focused element - AXUIElementCopyAttributeValue( - appElement, kAXFocusedUIElementAttribute, out var focusedElement); - if (focusedElement == IntPtr.Zero) return false; - - // 3. 获取当前值(用于 append) - AXUIElementCopyAttributeValue( - focusedElement, kAXValueAttribute, out var currentValue); - var current = CFStringToManaged(currentValue) ?? ""; - - // 4. 获取选中范围(光标位置) - AXUIElementCopyAttributeValue( - focusedElement, kAXSelectedTextRangeAttribute, out var rangeValue); - var range = CFRangeFromAXValue(rangeValue); - - // 5. 在光标位置插入文本 - var newValue = current[..range.Location] + text + current[range.Location..]; - var cfString = CFStringCreateWithCString(newValue); - var result = AXUIElementSetAttributeValue( - focusedElement, kAXValueAttribute, cfString); - - // 6. 更新光标位置到插入文本末尾 - var newCursorPos = range.Location + text.Length; - SetCursorPosition(focusedElement, newCursorPos); - - return result == 0; // kAXErrorSuccess - } - - private async Task SimulatePasteAsync() - { - // CGEventPost: Cmd+V - var source = CGEventSourceCreate(CGEventSourceStateID.HIDSystemState); - - var keyDown = CGEventCreateKeyboardEvent(source, /* V */ 0x09, true); - CGEventSetFlags(keyDown, CGEventFlags.MaskCommand); - CGEventPost(CGEventTapLocation.CGHIDEventTap, keyDown); - - await Task.Delay(50); - - var keyUp = CGEventCreateKeyboardEvent(source, 0x09, false); - CGEventSetFlags(keyUp, CGEventFlags.MaskCommand); - CGEventPost(CGEventTapLocation.CGHIDEventTap, keyUp); - - CFRelease(source); - CFRelease(keyDown); - CFRelease(keyUp); - } - - private async Task SimulateReturnKeyAsync() - { - await Task.Delay(100); - - var source = CGEventSourceCreate(CGEventSourceStateID.HIDSystemState); - - var keyDown = CGEventCreateKeyboardEvent(source, /* Return */ 0x24, true); - CGEventPost(CGEventTapLocation.CGHIDEventTap, keyDown); - - await Task.Delay(30); - - var keyUp = CGEventCreateKeyboardEvent(source, 0x24, false); - CGEventPost(CGEventTapLocation.CGHIDEventTap, keyUp); - - CFRelease(source); - CFRelease(keyDown); - CFRelease(keyUp); - } -} -``` - -### 4.3 AXUIElement 关键 API - -```csharp -internal static partial class AccessibilityNative -{ - [DllImport("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")] - public static extern IntPtr AXUIElementCreateApplication(int pid); - - [DllImport("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")] - public static extern int AXUIElementCopyAttributeValue( - IntPtr element, IntPtr attribute, out IntPtr value); - - [DllImport("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")] - public static extern int AXUIElementSetAttributeValue( - IntPtr element, IntPtr attribute, IntPtr value); - - // 常用属性名 - public static IntPtr kAXFocusedUIElementAttribute - => CFStringCreate("AXFocusedUIElement"); - public static IntPtr kAXValueAttribute - => CFStringCreate("AXValue"); - public static IntPtr kAXSelectedTextRangeAttribute - => CFStringCreate("AXSelectedTextRange"); - public static IntPtr kAXRoleAttribute - => CFStringCreate("AXRole"); -} -``` - -### 4.4 Electron/Slack 兼容性 - -Slack Desktop macOS (Electron) 的 Accessibility 支持: - -| 属性 | 支持情况 | -|------|---------| -| AXFocusedUIElement | ✅ 返回 AXTextArea | -| AXValue (读取) | ✅ 返回当前文本 | -| AXValue (设置) | ⚠️ 部分 Electron 版本不支持 | -| AXSelectedTextRange | ✅ 返回选中范围 | - -如果 AXValue 设置不被 Slack 接受(Electron 可能忽略),自动降级到剪贴板策略。 - -## 5. 剪贴板服务 - -```csharp -namespace LiveLingo.App.Platform.macOS; - -public class MacClipboardService : IClipboardService -{ - public Task SetTextAsync(string text, CancellationToken ct) - { - // NSPasteboard - var pasteboard = GetGeneralPasteboard(); - PasteboardClear(pasteboard); - PasteboardSetString(pasteboard, text); - return Task.CompletedTask; - } - - public Task GetTextAsync(CancellationToken ct) - { - var pasteboard = GetGeneralPasteboard(); - var text = PasteboardGetString(pasteboard); - return Task.FromResult(text); - } - - // Objective-C runtime 调用 [NSPasteboard generalPasteboard] - private static IntPtr GetGeneralPasteboard() - { - var cls = objc_getClass("NSPasteboard"); - var sel = sel_registerName("generalPasteboard"); - return objc_msgSend(cls, sel); - } -} -``` - -## 6. MacPlatformServices 聚合 - -```csharp -namespace LiveLingo.App.Platform.macOS; - -internal class MacPlatformServices : IPlatformServices -{ - public IHotkeyService Hotkey { get; } - public IWindowTracker WindowTracker { get; } - public ITextInjector TextInjector { get; } - public IClipboardService Clipboard { get; } - - public MacPlatformServices(ILoggerFactory loggerFactory) - { - Clipboard = new MacClipboardService(); - WindowTracker = new MacWindowTracker(); - Hotkey = new CoreGraphicsHotkeyService(); - TextInjector = new MacTextInjector( - Clipboard, loggerFactory.CreateLogger()); - } - - public void Dispose() - { - Hotkey.Dispose(); - } -} -``` - -DI 注册: - -```csharp -// App.axaml.cs -if (OperatingSystem.IsMacOS()) - services.AddSingleton(); -``` - -## 7. 首次启动权限引导 - -### 7.1 流程 - -``` -App 启动 - │ - ├─ AccessibilityPermission.IsGranted() - │ ├─ true → 正常启动 - │ └─ false → 显示引导窗口 - │ - ▼ 引导窗口: - ┌──────────────────────────────────────────────────────┐ - │ LiveLingo needs Accessibility permission │ - │ │ - │ 1. Click "Open Settings" to open System Settings │ - │ 2. Find LiveLingo in the list │ - │ 3. Toggle it ON │ - │ 4. Click "Verify" to continue │ - │ │ - │ [Open Settings] [Verify] [Quit] │ - └──────────────────────────────────────────────────────┘ - │ - ├─ [Open Settings] → NSWorkspace.open("x-apple.systempreferences:...") - ├─ [Verify] → 重新检测 → 成功则继续,失败则提示 - └─ [Quit] → 退出应用 -``` - -### 7.2 自动重试 - -用户授权后可能需要重启 App 才生效(macOS 限制)。 -如果 `AXIsProcessTrustedWithOptions` 在授权后仍返回 false,提示用户重启。 - -## 8. 目录结构 - -``` -src/LiveLingo.App/ -└── Platform/ - └── macOS/ - ├── MacPlatformServices.cs - ├── CoreGraphicsHotkeyService.cs - ├── MacWindowTracker.cs - ├── MacTextInjector.cs - ├── MacClipboardService.cs - ├── AccessibilityPermission.cs - ├── MacKeyMap.cs - └── Native/ - ├── CoreGraphicsNative.cs // CGEvent* P/Invoke - ├── AccessibilityNative.cs // AXUIElement* P/Invoke - ├── CoreFoundationNative.cs // CF* P/Invoke - └── ObjCRuntime.cs // objc_msgSend, sel_*, class_* -``` - -## 9. 已知限制与风险 - -| 风险 | 缓解措施 | -|------|---------| -| Electron AXValue 设置不生效 | 自动降级到 Cmd+V 策略 | -| CGEventTap 在 sandboxed app 中受限 | 不沙箱发布(DMG 分发) | -| macOS 版本间 Accessibility API 行为差异 | 测试覆盖 macOS 12-15 | -| 用户拒绝 Accessibility 权限 | 优雅降级:仅快捷键不可用时提示 | -| objc_msgSend 签名问题 (arm64) | 使用 [UnmanagedCallersOnly] 和正确的 calling convention | - -## 10. 测试 - -### 10.1 测试 checklist - -- [ ] Accessibility 权限检测返回正确结果 -- [ ] 未授权时显示引导窗口 -- [ ] CGEventTap 捕获 Ctrl+Alt+T -- [ ] CGEventTap 不干扰其他快捷键 -- [ ] 窗口追踪返回 Slack 窗口信息 -- [ ] AXUIElement 策略在 TextEdit 中注入成功 -- [ ] AXUIElement 策略在 Slack 中注入成功(或降级) -- [ ] Cmd+V 策略在 Slack 中注入成功 -- [ ] Return 键模拟在 Slack 中触发发送 -- [ ] 剪贴板操作不丢失用户原有内容(保存/恢复) diff --git a/docs/proposals/specs/P5-settings-polish-spec.md b/docs/proposals/specs/P5-settings-polish-spec.md deleted file mode 100644 index acdd267..0000000 --- a/docs/proposals/specs/P5-settings-polish-spec.md +++ /dev/null @@ -1,779 +0,0 @@ -# P5 Spec: Settings & Polish - -## 1. 配置模型 - -### 1.1 UserSettings - -```csharp -namespace LiveLingo.Core.Configuration; - -public class UserSettings -{ - public HotkeySettings Hotkey { get; set; } = new(); - public TranslationSettings Translation { get; set; } = new(); - public ProcessingSettings Processing { get; set; } = new(); - public UISettings UI { get; set; } = new(); - public AdvancedSettings Advanced { get; set; } = new(); -} - -public class HotkeySettings -{ - public string OverlayHotkey { get; set; } = "Ctrl+Alt+T"; - public string InjectHotkey { get; set; } = "Ctrl+Enter"; -} - -public class TranslationSettings -{ - public string DefaultSourceLanguage { get; set; } = "auto"; - public string DefaultTargetLanguage { get; set; } = "en"; - - /// - /// 用户配置的语言对列表。 - /// "auto→en", "zh→ja", "en→zh" 等。 - /// 在 Overlay 中切换使用。 - /// - public List LanguagePairs { get; set; } = ["auto→en"]; -} - -public class ProcessingSettings -{ - /// - /// 默认后处理模式。用户在 Overlay 中可切换。 - /// - public ProcessingMode DefaultPostProcessMode { get; set; } = ProcessingMode.Off; - - /// - /// 注入模式。PasteOnly 或 PasteAndSend。 - /// - public InjectionMode DefaultInjectionMode { get; set; } = InjectionMode.PasteAndSend; -} - -public class UISettings -{ - /// - /// Overlay 窗口透明度 (0.5 - 1.0) - /// - public double OverlayOpacity { get; set; } = 0.9; - - /// - /// 上次 Overlay 位置 (null = 自动定位到目标窗口上方) - /// - public OverlayPosition? LastOverlayPosition { get; set; } -} - -public class AdvancedSettings -{ - /// - /// 模型存储路径 - /// - public string ModelStoragePath { get; set; } = ""; // 空 = 默认路径 - - /// - /// MarianMT ONNX 推理线程数 (0 = 自动) - /// - public int TranslationThreads { get; set; } = 0; - - /// - /// Qwen 推理线程数 (0 = 自动) - /// - public int LlmThreads { get; set; } = 0; - - /// - /// 翻译 debounce 时间 (ms) - /// - public int TranslationDebounceMs { get; set; } = 0; - - /// - /// 日志级别 - /// - public string LogLevel { get; set; } = "Information"; -} - -public record OverlayPosition(int X, int Y); -``` - -### 1.2 InjectionMode / ProcessingMode 枚举 - -这些已在 P1/P3 中定义,此处确认来源: - -```csharp -// LiveLingo.Core.Processing (P1) -public enum ProcessingMode { Off, Summarize, Optimize, Colloquialize } - -// LiveLingo.App.Platform (P1) -public enum InjectionMode { PasteOnly, PasteAndSend } -``` - -## 2. 配置持久化 - -### 2.1 存储位置 - -| 平台 | 路径 | -|------|------| -| Windows | `%LOCALAPPDATA%\LiveLingo\settings.json` | -| macOS | `~/Library/Application Support/LiveLingo/settings.json` | - -### 2.2 ISettingsService 接口 - -```csharp -namespace LiveLingo.App.Services; - -public interface ISettingsService -{ - UserSettings Current { get; } - event Action? SettingsChanged; - Task LoadAsync(CancellationToken ct = default); - Task SaveAsync(CancellationToken ct = default); - void Update(Action mutate); -} -``` - -### 2.3 JsonSettingsService 实现 - -```csharp -public class JsonSettingsService : ISettingsService -{ - private readonly string _filePath; - private readonly SemaphoreSlim _lock = new(1, 1); - private readonly JsonSerializerOptions _jsonOptions; - private UserSettings _current = new(); - - public UserSettings Current => _current; - public event Action? SettingsChanged; - - public JsonSettingsService() - { - _filePath = Path.Combine(GetSettingsDirectory(), "settings.json"); - _jsonOptions = new JsonSerializerOptions - { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter() } - }; - } - - public async Task LoadAsync(CancellationToken ct) - { - if (!File.Exists(_filePath)) - { - _current = new UserSettings(); - return; - } - - await _lock.WaitAsync(ct); - try - { - var json = await File.ReadAllTextAsync(_filePath, ct); - _current = JsonSerializer.Deserialize(json, _jsonOptions) - ?? new UserSettings(); - } - catch (JsonException) - { - // 配置损坏,使用默认值 - _current = new UserSettings(); - } - finally - { - _lock.Release(); - } - } - - public async Task SaveAsync(CancellationToken ct) - { - await _lock.WaitAsync(ct); - try - { - Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); - var json = JsonSerializer.Serialize(_current, _jsonOptions); - await File.WriteAllTextAsync(_filePath, json, ct); - } - finally - { - _lock.Release(); - } - } - - public void Update(Action mutate) - { - mutate(_current); - SettingsChanged?.Invoke(_current); - _ = SaveAsync(CancellationToken.None); // fire-and-forget - } - - private static string GetSettingsDirectory() - { - if (OperatingSystem.IsMacOS()) - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", "Application Support", "LiveLingo"); - - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LiveLingo"); - } -} -``` - -### 2.4 settings.json 示例 - -```json -{ - "hotkey": { - "overlayHotkey": "Ctrl+Alt+T", - "injectHotkey": "Ctrl+Enter" - }, - "translation": { - "defaultSourceLanguage": "auto", - "defaultTargetLanguage": "en", - "languagePairs": ["auto→en", "zh→ja", "en→zh"] - }, - "processing": { - "defaultPostProcessMode": "Off", - "defaultInjectionMode": "PasteAndSend" - }, - "ui": { - "overlayOpacity": 0.9, - "lastOverlayPosition": { "x": 500, "y": 300 } - }, - "advanced": { - "modelStoragePath": "", - "translationThreads": 0, - "llmThreads": 0, - "translationDebounceMs": 0, - "logLevel": "Information" - } -} -``` - -## 3. 首次运行引导 - -### 3.1 判断逻辑 - -```csharp -bool isFirstRun = !File.Exists(settingsFilePath); -``` - -无需额外标记——settings.json 不存在即为首次运行。 - -### 3.2 引导流程 - -``` -App 启动 - │ - ├─ settings.json 存在 → 正常启动 - │ - └─ settings.json 不存在 → 显示 SetupWizardWindow - │ - ├─ Step 1: 语言选择 - │ ┌──────────────────────────────────────────┐ - │ │ Welcome to LiveLingo! │ - │ │ │ - │ │ What languages do you work with? │ - │ │ │ - │ │ I write in: [Chinese ▾] │ - │ │ Translate to: [English ▾] │ - │ │ │ - │ │ [Next →] │ - │ └──────────────────────────────────────────┘ - │ - ├─ Step 2: 模型下载 - │ ┌──────────────────────────────────────────┐ - │ │ Downloading required models... │ - │ │ │ - │ │ ☑ Language Detection (1 MB) Done │ - │ │ ◻ Chinese → English (30 MB) ███░░ 60% │ - │ │ │ - │ │ [Cancel] │ - │ └──────────────────────────────────────────┘ - │ - ├─ Step 3: 快捷键确认 - │ ┌──────────────────────────────────────────┐ - │ │ Your shortcuts: │ - │ │ │ - │ │ Open overlay: [Ctrl+Alt+T] [Change] │ - │ │ Inject text: [Ctrl+Enter] [Change] │ - │ │ │ - │ │ Default mode: [Paste & Send ▾] │ - │ │ │ - │ │ [← Back] [Finish →] │ - │ └──────────────────────────────────────────┘ - │ - └─ 完成 → 保存 settings.json → 进入正常模式 -``` - -### 3.3 SetupWizardViewModel - -```csharp -namespace LiveLingo.App.ViewModels; - -public partial class SetupWizardViewModel : ObservableObject -{ - private readonly ISettingsService _settings; - private readonly IModelManager _modelManager; - - [ObservableProperty] private int _currentStep = 0; // 0, 1, 2 - [ObservableProperty] private string _sourceLanguage = "zh"; - [ObservableProperty] private string _targetLanguage = "en"; - [ObservableProperty] private string _overlayHotkey = "Ctrl+Alt+T"; - [ObservableProperty] private string _injectHotkey = "Ctrl+Enter"; - [ObservableProperty] private InjectionMode _injectionMode = InjectionMode.PasteAndSend; - - // 下载进度 - [ObservableProperty] private double _downloadProgress; - [ObservableProperty] private string _downloadStatus = ""; - [ObservableProperty] private bool _isDownloading; - - [RelayCommand] - private void Next() - { - if (CurrentStep == 0) - { - CurrentStep = 1; - _ = StartDownloadAsync(); - } - else if (CurrentStep == 1 && !IsDownloading) - { - CurrentStep = 2; - } - } - - [RelayCommand] - private void Back() - { - if (CurrentStep > 0) CurrentStep--; - } - - [RelayCommand] - private async Task FinishAsync() - { - _settings.Update(s => - { - s.Translation.DefaultSourceLanguage = SourceLanguage; - s.Translation.DefaultTargetLanguage = TargetLanguage; - s.Translation.LanguagePairs = [$"{SourceLanguage}→{TargetLanguage}"]; - s.Hotkey.OverlayHotkey = OverlayHotkey; - s.Hotkey.InjectHotkey = InjectHotkey; - s.Processing.DefaultInjectionMode = InjectionMode; - }); - - await _settings.SaveAsync(); - // 关闭向导窗口 → 启动主流程 - } - - private async Task StartDownloadAsync() - { - IsDownloading = true; - var modelsToDownload = new List - { - ModelRegistry.FastTextLid, - ModelRegistry.All.First(m => m.Id == $"marian-{SourceLanguage}-{TargetLanguage}") - }; - - var totalBytes = modelsToDownload.Sum(m => m.SizeBytes); - long downloaded = 0; - - foreach (var model in modelsToDownload) - { - DownloadStatus = $"Downloading {model.DisplayName}..."; - var progress = new Progress(p => - { - var current = downloaded + p.BytesDownloaded; - DownloadProgress = (double)current / totalBytes * 100; - }); - - await _modelManager.EnsureModelAsync(model, progress); - downloaded += model.SizeBytes; - } - - DownloadStatus = "All models ready!"; - IsDownloading = false; - } -} -``` - -## 4. 可配置快捷键 - -### 4.1 快捷键录制控件 - -```csharp -namespace LiveLingo.App.Controls; - -public class HotkeyRecorder : TextBox -{ - public static readonly StyledProperty HotkeyProperty = - AvaloniaProperty.Register(nameof(Hotkey)); - - public string Hotkey - { - get => GetValue(HotkeyProperty); - set => SetValue(HotkeyProperty, value); - } - - private bool _isRecording; - - protected override void OnGotFocus(GotFocusEventArgs e) - { - base.OnGotFocus(e); - _isRecording = true; - Text = "Press key combination..."; - } - - protected override void OnKeyDown(KeyEventArgs e) - { - if (!_isRecording) return; - e.Handled = true; - - // 忽略单独的修饰键 - if (IsModifierKey(e.Key)) return; - - var parts = new List(); - if (e.KeyModifiers.HasFlag(Avalonia.Input.KeyModifiers.Control)) - parts.Add("Ctrl"); - if (e.KeyModifiers.HasFlag(Avalonia.Input.KeyModifiers.Alt)) - parts.Add("Alt"); - if (e.KeyModifiers.HasFlag(Avalonia.Input.KeyModifiers.Shift)) - parts.Add("Shift"); - if (e.KeyModifiers.HasFlag(Avalonia.Input.KeyModifiers.Meta)) - parts.Add(OperatingSystem.IsMacOS() ? "Cmd" : "Win"); - - parts.Add(e.Key.ToString()); - - Hotkey = string.Join("+", parts); - Text = Hotkey; - _isRecording = false; - } - - private static bool IsModifierKey(Key key) => key is - Key.LeftCtrl or Key.RightCtrl or - Key.LeftAlt or Key.RightAlt or - Key.LeftShift or Key.RightShift or - Key.LWin or Key.RWin; -} -``` - -### 4.2 快捷键字符串解析 - -```csharp -namespace LiveLingo.App.Platform; - -public static class HotkeyParser -{ - /// - /// 将 "Ctrl+Alt+T" 解析为 HotkeyBinding - /// - public static HotkeyBinding Parse(string hotkeyId, string hotkeyString) - { - var parts = hotkeyString.Split('+', StringSplitOptions.TrimEntries); - var modifiers = KeyModifiers.None; - string key = ""; - - foreach (var part in parts) - { - switch (part.ToLowerInvariant()) - { - case "ctrl": modifiers |= KeyModifiers.Ctrl; break; - case "alt": modifiers |= KeyModifiers.Alt; break; - case "shift": modifiers |= KeyModifiers.Shift; break; - case "cmd" or "win" or "meta": modifiers |= KeyModifiers.Meta; break; - default: key = part; break; - } - } - - if (string.IsNullOrEmpty(key)) - throw new ArgumentException($"Hotkey string missing key: {hotkeyString}"); - - return new HotkeyBinding(hotkeyId, modifiers, key); - } -} -``` - -### 4.3 快捷键变更热更新 - -```csharp -// App.axaml.cs 中监听 SettingsChanged -settingsService.SettingsChanged += settings => -{ - platform.Hotkey.Unregister("overlay"); - platform.Hotkey.Register( - HotkeyParser.Parse("overlay", settings.Hotkey.OverlayHotkey)); -}; -``` - -## 5. 多语言对管理 - -### 5.1 UI - -在 Settings 窗口中: - -``` -┌────────────────────────────────────────────────┐ -│ Language Pairs │ -│ │ -│ ┌────────────────────────────────────────────┐ │ -│ │ Auto detect → English [Remove] │ │ -│ │ Chinese → Japanese [Remove] │ │ -│ │ English → Chinese [Remove] │ │ -│ └────────────────────────────────────────────┘ │ -│ │ -│ [+ Add pair] │ -│ │ -│ Default pair: [Auto detect → English ▾] │ -└────────────────────────────────────────────────┘ -``` - -### 5.2 Overlay 中切换 - -在 Overlay 状态栏增加语言对切换按钮: - -``` -┌──────────────────────────────────────────────────────┐ -│ [auto→en ▾] [Off ▾] Ctrl+Enter paste & send [Paste&Send] │ -└──────────────────────────────────────────────────────┘ -``` - -点击 `auto→en` 弹出下拉,列出 settings 中配置的所有语言对。 -切换后立即重新翻译当前 SourceText。 - -### 5.3 OverlayViewModel 变化 - -```csharp -[ObservableProperty] -private string _selectedLanguagePair = "auto→en"; - -public IReadOnlyList AvailableLanguagePairs - => _settings.Current.Translation.LanguagePairs; - -partial void OnSelectedLanguagePairChanged(string value) -{ - var parts = value.Split('→'); - _sourceLanguage = parts[0] == "auto" ? null : parts[0]; - _targetLanguage = parts[1]; - - // 重新翻译 - if (!string.IsNullOrWhiteSpace(SourceText)) - { - _pipelineCts?.Cancel(); - _pipelineCts = new CancellationTokenSource(); - _ = RunPipelineAsync(SourceText, _pipelineCts.Token); - } -} -``` - -## 6. 支持的语言列表 - -| ISO 代码 | 显示名 | MarianMT 模型 | -|----------|--------|---------------| -| zh | Chinese | opus-mt-zh-en, opus-mt-en-zh | -| ja | Japanese | opus-mt-ja-en, opus-mt-en-ja | -| ko | Korean | opus-mt-ko-en, opus-mt-en-ko | -| de | German | opus-mt-de-en, opus-mt-en-de | -| fr | French | opus-mt-fr-en, opus-mt-en-fr | -| es | Spanish | opus-mt-es-en, opus-mt-en-es | -| ru | Russian | opus-mt-ru-en, opus-mt-en-ru | -| pt | Portuguese | opus-mt-pt-en, opus-mt-en-pt | -| en | English | (as target) | - -v1.0 优先支持 CJK ↔ English。 -非英语之间的翻译通过 pivot(源→en→目标)实现。 - -## 7. Settings 窗口 - -### 7.1 入口 - -系统托盘图标右键菜单中添加 "Settings" 选项。 - -macOS: 菜单栏图标。 -Windows: 系统托盘图标。 - -### 7.2 Settings 窗口 Tab 结构 - -``` -┌─ General ─┬─ Translation ─┬─ AI ─┬─ Advanced ─┐ -│ │ -│ [General Tab] │ -│ │ -│ Shortcuts: │ -│ Open overlay: [Ctrl+Alt+T] [Record] │ -│ Inject text: [Ctrl+Enter] [Record] │ -│ │ -│ Injection mode: ○ Paste only │ -│ ● Paste & Send │ -│ │ -│ Overlay opacity: ──●──────── 90% │ -│ │ -│ [ ] Start with OS │ -│ │ -│ [Save] [Reset] │ -└────────────────────────────────────────────────┘ -``` - -**Translation Tab:** -- 语言对管理(参见 5.1) -- 默认翻译方向 - -**AI Tab:** -- 默认后处理模式(Off / Summarize / Optimize / Colloquial) -- 已下载模型列表 + 磁盘占用 -- [Delete] 按钮删除不需要的模型 - -**Advanced Tab:** -- 模型存储路径(带文件夹选择按钮) -- 翻译线程数 -- LLM 线程数 -- Debounce 时间 -- 日志级别 -- [Open Log Folder] 按钮 - -### 7.3 SettingsViewModel - -```csharp -public partial class SettingsViewModel : ObservableObject -{ - private readonly ISettingsService _settings; - private readonly IModelManager _modelManager; - - // General - [ObservableProperty] private string _overlayHotkey; - [ObservableProperty] private string _injectHotkey; - [ObservableProperty] private InjectionMode _injectionMode; - [ObservableProperty] private double _overlayOpacity; - - // Translation - [ObservableProperty] private ObservableCollection _languagePairs; - [ObservableProperty] private string _defaultLanguagePair; - - // AI - [ObservableProperty] private ProcessingMode _defaultPostProcessMode; - [ObservableProperty] private ObservableCollection _installedModels; - - // Advanced - [ObservableProperty] private string _modelStoragePath; - [ObservableProperty] private int _translationThreads; - [ObservableProperty] private int _llmThreads; - - [RelayCommand] - private async Task SaveAsync() - { - _settings.Update(s => - { - s.Hotkey.OverlayHotkey = OverlayHotkey; - s.Hotkey.InjectHotkey = InjectHotkey; - s.Processing.DefaultInjectionMode = InjectionMode; - s.UI.OverlayOpacity = OverlayOpacity; - s.Translation.LanguagePairs = LanguagePairs.ToList(); - s.Processing.DefaultPostProcessMode = DefaultPostProcessMode; - s.Advanced.ModelStoragePath = ModelStoragePath; - s.Advanced.TranslationThreads = TranslationThreads; - s.Advanced.LlmThreads = LlmThreads; - }); - } - - [RelayCommand] - private void Reset() - { - // 重置为默认值 - var defaults = new UserSettings(); - OverlayHotkey = defaults.Hotkey.OverlayHotkey; - // ... - } - - [RelayCommand] - private async Task DeleteModelAsync(string modelId) - { - await _modelManager.DeleteModelAsync(modelId); - InstalledModels.Remove(InstalledModels.First(m => m.Id == modelId)); - } -} -``` - -## 8. 系统托盘 / 菜单栏 - -### 8.1 Windows 系统托盘 - -```csharp -// 使用 Avalonia TrayIcon -var trayIcon = new TrayIcon -{ - Icon = new WindowIcon("Assets/tray-icon.ico"), - ToolTipText = "LiveLingo", - Menu = new NativeMenu - { - new NativeMenuItem("Settings") { Command = OpenSettingsCommand }, - new NativeMenuItemSeparator(), - new NativeMenuItem("Quit") { Command = QuitCommand } - } -}; -``` - -### 8.2 macOS 菜单栏 - -Avalonia 在 macOS 上自动支持 TrayIcon 显示在菜单栏。 -无需额外平台代码。 - -## 9. Overlay 位置记忆 - -### 9.1 行为 - -- 首次打开:自动定位到目标窗口上方居中 -- 用户拖动后:记录位置到 `UI.LastOverlayPosition` -- 再次打开:使用记忆位置(如果在屏幕内) - -```csharp -// OverlayWindow.axaml.cs -protected override void OnPositionChanged(PixelPointEventArgs e) -{ - base.OnPositionChanged(e); - if (_isDragging) - { - _settings.Update(s => - s.UI.LastOverlayPosition = new OverlayPosition(Position.X, Position.Y)); - } -} -``` - -## 10. DI 注册 - -```csharp -// P5 阶段完整 DI -public static IServiceCollection AddAppServices(this IServiceCollection services) -{ - services.AddSingleton(); - return services; -} -``` - -## 11. 测试 - -### 11.1 单元测试 - -``` -Configuration/ -├── JsonSettingsServiceTests.cs -│ ├── Load_NoFile_ReturnsDefaults -│ ├── SaveAndLoad_Roundtrip -│ ├── Load_CorruptJson_ReturnsDefaults -│ ├── Update_TriggersChanged -│ └── Update_SavesAsync -│ -├── HotkeyParserTests.cs -│ ├── Parse_CtrlAltT -│ ├── Parse_CmdShiftSpace -│ ├── Parse_SingleKey_Throws -│ └── Parse_EmptyString_Throws -│ -└── SetupWizardViewModelTests.cs - ├── Next_Step0To1_StartsDownload - ├── Finish_SavesSettings - └── Back_DecrementsStep -``` - -### 11.2 集成测试 checklist - -- [ ] 首次启动(删除 settings.json)→ 显示向导 -- [ ] 向导完成 → settings.json 正确生成 -- [ ] 再次启动 → 跳过向导,加载已有配置 -- [ ] 修改快捷键 → 热更新生效 -- [ ] 切换语言对 → 按需下载模型 → 翻译使用新语言对 -- [ ] 删除模型 → 磁盘空间释放 -- [ ] Overlay 位置 → 拖动后记忆,再次打开恢复 -- [ ] settings.json 手动损坏 → 优雅回退到默认值 diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index 94550ff..0000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,97 +0,0 @@ -# LiveLingo v1.0 Roadmap - -> 基于 product-design.md 的实施路线图。 - -## 提案总览 - -| # | 提案 | 交付物 | 预估 | 依赖 | -|---|------|--------|------|------| -| P1 | Foundation & Architecture | 多项目结构 + 接口 + PoC 迁移 | 1 周 | 无 | -| P2 | Translation Core | 模型管理 + MarianMT + 语言检测 + 管线 | 2-3 周 | P1 | -| P3 | AI Post-processing | Qwen + LLamaSharp + 三种后处理器 | 2 周 | P2 | -| P4 | macOS Platform | 全平台服务 macOS 实现 | 2-3 周 | P1 | -| P5 | Settings & Polish | 持久化配置 + 首次引导 + 快捷键配置 | 1-2 周 | P3 | - -## 时间线 - -``` - Week 1 Week 2-4 Week 5-6 Week 7-8 Week 9-10 - ────── ──────────── ──────────── ─────────── ─────────── -主线: [ P1 ] [ P2 ] [ P3 ] [ P5 ] - -并行: [ P4 (macOS) ] - -里程碑: ▲ ▲ ▲ ▲ ▲ - 架构就位 真实翻译可用 AI润色可用 macOS可用 v1.0 发布 -``` - -## 依赖图 - -``` - P1: Foundation ─────────────────────────────────────┐ - │ │ - │ 定义所有接口 + 迁移 PoC │ - │ │ - ├──────────────────────┐ │ - │ │ │ - ▼ ▼ │ - P2: Translation Core P4: macOS Platform │ - │ ModelManager CGEventTap │ - │ MarianMT/ONNX AXUIElement │ - │ FastText NSWorkspace │ - │ Pipeline │ - │ │ - ▼ │ - P3: AI Post-processing │ - │ Qwen/LLamaSharp │ - │ Summarize/Optimize/Colloquialize │ - │ │ - ▼ │ - P5: Settings & Polish ◀────────────────────────────┘ - JSON 持久化 - 首次运行引导 - 可配置快捷键 -``` - -## 各提案详情 - -见 proposals/ 目录下各提案文档: -- [P1: Foundation & Architecture](proposals/P1-foundation.md) -- [P2: Translation Core](proposals/P2-translation-core.md) -- [P3: AI Post-processing](proposals/P3-ai-postprocessing.md) -- [P4: macOS Platform](proposals/P4-macos-platform.md) -- [P5: Settings & Polish](proposals/P5-settings-polish.md) - -## 里程碑定义 - -### M1: 架构就位 (P1 完成) -- [ ] LiveLingo.Core 项目创建,包含所有公开接口 -- [ ] LiveLingo.App 中平台接口定义完成 -- [ ] PoC Windows 代码迁移到平台接口实现 -- [ ] DI 注册工作,stub 翻译仍然可用 -- [ ] 功能与 PoC 完全一致 - -### M2: 真实翻译可用 (P2 完成) -- [ ] ModelManager 可下载/缓存/管理模型 -- [ ] MarianMT 中→英翻译工作 -- [ ] 语言自动检测工作 -- [ ] 实时翻译管线 (文本变化 → 取消 → 重翻) -- [ ] 基础模型下载 UI - -### M3: AI 润色可用 (P3 完成) -- [ ] Qwen2.5-1.5B 本地推理工作 -- [ ] 三种后处理模式可切换 -- [ ] 管线两阶段流转 (翻译 → 润色) -- [ ] 后处理自动跟随翻译结果 - -### M4: macOS 可用 (P4 完成) -- [ ] macOS 全局快捷键工作 -- [ ] macOS 前台窗口识别工作 -- [ ] macOS 文本注入工作 -- [ ] 权限引导 UI - -### M5: v1.0 发布 (P5 完成) -- [ ] 用户配置持久化 -- [ ] 可配置全局快捷键 -- [ ] 多语言对管理 -- [ ] 首次运行引导完善 diff --git a/docs/superpowers/plans/2026-10-29-translation-poc.md b/docs/superpowers/plans/2026-10-29-translation-poc.md new file mode 100644 index 0000000..7fbb95c --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-translation-poc.md @@ -0,0 +1,239 @@ +# 文本翻译与实时转写翻译 POC 验证计划 + +> **状态(2026-10-30):** 文本翻译 POC 已完成多来源语料评测,结果与由此推翻/修正的设计见 [poc/text-translation/REPORT.md](../../../poc/text-translation/REPORT.md) 第 0 节。实时语音翻译尚未开始;产品代码未修改。 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Do not modify product translation code before the POC decision gate. + +**Goal:** 先证明翻译准确、自然、持续输出,并在 Apple M2 及以上、内存 16 GB 及以上的目标配置中流畅运行,再将胜出流程迁入 LiveLingo,最后接入实时语音。 + +**Architecture:** 使用专用翻译模型或小型 NMT,配合有版本的语义单元调度;稳定单元保留,活动尾部可修订。文本 POC 与语音 POC 分阶段,复用语音核心而非重造 ASR。 + +**Tech Stack:** 独立 .NET 10 POC 编排;官方 llama.cpp Metal 运行时与 Hy-MT2 GGUF;公开 OPUS-MT/CTranslate2 为独立 NMT 对照;Foundry 仅作显式授权的可选对照。 + +日期:2026-10-29。状态:**按用户最新指示完成首轮独立 POC;不读取、引用或迁就产品现有翻译实现,不修改产品翻译代码**。已对比 Hy-MT2-1.8B / 7B、公开 NMT、纯文本/结构化契约及全文/单元流程;结果与未通过项见 [首轮报告](../../../poc/text-translation/REPORT.md)。下方完整准入清单仍包含未实施的后续阶段,不因首轮完成而自动勾选。 + +默认范围为中英双向、技术讨论/协作聊天/方案文档。其他语言另列,不把中英结论外推。数值均为待验证门槛,不是性能承诺。 + +**硬件定位:**以 M2/16 GB 实机作为产品性能验证基线,而不是面向最低配置做产品。正常利用 Metal 等可用加速,以准确率、自然度和流畅度共同选择方案,不为模型最小化牺牲质量。CPU-only 是补充对照,不是所有后端的主性能门槛;Windows 以 16 GB 及以上同档设备独立验证。 + +## 1. 从目标出发的独立设计 + +不把现有产品的引擎、路由、提示词、防抖或后处理链当作设计依据;不引用产品项目来建立基线。设计的约束只有保义、自然、持续输出、可修订、隐私和 M2/16 GB 资源目标。 + +对比三个独立方案: + +1. **专用翻译 LLM + 语义单元调度**:术语与语境能力较强,采用正确模板、常驻模型和有限上下文,是首选验证方向。 +2. **小型 encoder-decoder NMT**:资源与吞吐对照,检查技术文本、混说和语境损失,不预设其质量足够。 +3. **远端专用翻译**:可选参考,不作为本地失败后的隐式备选,未授权不调用。 + +独立基线 **B0** 定义为:相同候选模型、相同最终原文,在输入完成后一次性翻译。**B1** 为输入期间处理完整语义单元,**B2** 增加有限的活动尾部预览。这样只比较模型与新流程的差异,不借旧实现来证明新方案优秀。 + +## 2. 调研结论与候选 + +检索日期 2026-10-29;以下能力来自官方资料,**厂家榜单和设备延迟不代替本项目实测**。 + +### 2.1 第一轮只保留三条路径 + +| 路径 | 候选与理由 | 必须验证 / 取舍 | +|---|---|---| +| A:轻量专用翻译 LLM,优先验证 | **Tencent Hy-MT2-1.8B,官方 GGUF Q4_K_M**。2026-05-21 发布;官方提供术语、上下文、格式保留指令;权重许可 Apache-2.0 [S1][S2] | 不是之前的 HY-MT1.5。以 M2/16 GB 的 Metal 路径为主测,CPU-only 作对照;验证量化后术语/否定保真、实际首可见译文、是否输出推理。无默认 system prompt,必须使用本模型模板 | +| B:小型专用 NMT,资源基线 | 官方 OPUS-MT 中英双向权重 + **CTranslate2 CPU int8**,不使用产品导出或解码器 [S4] | 先独立进程 POC。没有 Metal 支持证据,不按“Mac GPU 加速”估计性能;上下文与术语约束能力可能较弱 | +| C:用户授权的远端路径 | **Azure Translator NMT**;另作一条 Foundry 低延迟 LLM 翻译质量对照 [S5] | 关闭云端时不调用;未提供凭据也不影响 A/B 验证。记录 API、部署版本、区域、网络、费用;无凭据则报告“未测”,不能填入预测成绩 | + +**TranslateGemma 4B**(2026-01-15 发布,55 种语言,Gemma 条款、需接受访问条件)作为质量挑战者 [S3]。4B/7B 模型只要在 M2/16 GB 上满足质量、延迟和资源约束,就有资格成为默认,不因参数量较大而预先排除。较大模型是否入选由实测决定,不靠提高产品硬件基线掩盖问题,也不把 30B 级模型直接设为默认。 + +Hy-MT2 官方另外提供 2-bit / 1.25-bit 版本。首轮不采用极端量化:磁盘小不等于运行内存小,也不证明技术术语准确。先 Q4 通过再单独比较。 + +### 2.2 版本与运行约定 + +调研时核实的官方修订,用于下载前复核并生成 manifest: + +- Hy-MT2-1.8B:`9a341cd1b679d3efd23b46e847b01745a71ed792`。 +- Hy-MT2-1.8B-GGUF:`a0c709d9fac510f2c807aa3af52872340dc37a4a`,文件 `Hy-MT2-1.8B-Q4_K_M.gguf`。 +- TranslateGemma:模型元数据为 `gemma` 许可、`manual` 访问;不得绕过访问审批。 + +固定模型 SHA256、运行时 commit、量化、提示模板、上下文大小、线程、采样、缓存模式和 stop token。Hy-MT2 官方推荐包含 temperature 0.7 的配置;将其与确定性配置作开发集对照,不擅自把 `temperature=0` 宣称为官方最优配置。 + +采用非推理模式/正确停止条件;不能用“屏蔽推理显示”冒充“没有花时间推理”。不默认串接润色模型;翻译的自然度首先由译文模型负责。 + +### 2.3 成熟实践借鉴,但不复制高配栈 + +- llama.cpp server 已提供量化 CPU/GPU 推理、会话及缓存能力;先测常驻服务和稳定提示前缀,不逐次冷加载 [S6]。 +- WhisperStreaming 的 LocalAgreement 展示“临时结果与确认前缀分开”的价值;官方已提示改用 SimulStreaming。后者采用更强的流式策略,但其推荐栈包含 Whisper large-v3 与 EuroLLM-9B、GPU,**不能直接视为已满足本产品 M2/16 GB 基线** [S7]。 +- 学习的是质量—延迟—修订成本的联合评估,不是把“ASR 稳定前缀”机械等同于“译文稳定前缀”。中英语序变化使后者不能仅凭原文 append-only 判断。 +- Azure Translator 现行官方文档列出 `2026-06-06` GA,支持 NMT/LLM 模式选择,且相对 v3 有破坏性 schema 变化。POC 单独适配,不能沿用旧响应解析器;普通文本 REST 完成响应不等于 token SSE [S5]。 + +## 3. 推荐流程:稳定单元保留,只处理活动尾部 + +```text +文本变更 / ASR 原文修订 + → 版本号 + IME composition 状态 + → 结构保护(代码、URL、占位符、产品术语) + → 已完成语义单元 + 活动尾部 + → 调度器(每会话一个执行中任务;尾部合并为最新版本) + → 单一选定翻译后端 + → provisional 预览 / final 确认 + → 原文与译文并列,提交仅使用当前版本 +``` + +### 3.1 输入、切分与调度 + +- 中文输入法 composition 中不发起翻译;composition 结束才进入调度。 +- 默认优先在句子或可靠短语完成时翻译,不按单字符调用。活动尾部采用 150/300/500 ms 静默窗、1/2 s 最大等待作 POC 网格,均为候选而非产品参数。 +- 稳定前文不因在结尾新增一个词而重译;修改前文时,定位受影响单元,并使依赖它的后续语境失效。 +- 稳定单元 FIFO,不能因最新输入到达而丢弃;活动尾部 latest-wins,不能累计过期请求。过期结果即使无法立即取消也不能应用。 +- 上下文优先给最近 1–2 个已确认源/译对及命中的小词表;比较零上下文,不发送全部聊天历史。 +- 原文版本、上下文版本、语言对、模型/模板版本、词表版本共同决定缓存键;不能跨模式或用户复用结果。 + +### 3.2 预览与确认不同 + +一个单元最小状态为 `Pending / InFlight / Provisional / Final / Failed / UserOwned`,以 `(sessionId, unitId, sourceRevision, contextRevision)` 标识结果。 + +token 流只更新该单元的 provisional 区域。完成、质量检查通过且版本匹配后才确认。**token 不等于可以提交的稳定译文**;如果小型 NMT 以一句为粒度完成就已足够快,不为显示 token 另加复杂服务。 + +用户改过译文后模型不覆盖;原文随后变化时把该人工译文标为待复核,不偷偷重译。切换语言/模型要形成新版本,杜绝晚到结果串会话。 + +默认不整体闪空译文,不把新增一句变成全文重绘。UI 以单元更新并限频到约 20–30 Hz;其收益必须与推理时间分开计量。 + +### 3.3 质量与失败处理 + +- 保护代码块、API 名、变量、占位符和 URL;注释/说明文字仍需翻译,不能把含英文的整句全部“保护”。 +- 只注入原文命中的术语映射;同时测试无关术语误插入。上下文只读,禁止把提示结构、历史句子抄入译文。 +- 数字/单位/日期允许合法语言格式转换,比较规范化后的值;否定、条件、程度、主体和时态以专门对照题和人工评审验证。 +- 不复用语音整理的 CJK LCS、长度比例或同词删除规则。跨语言重排和句子合并是合法现象。 +- 空输出、拒绝、超时、重复循环、格式破坏、后端错误都形成显式失败;保留原文和最近有效译文并标明过期,禁止把原文当成“翻译成功”。 +- 后端由用户策略明确选定。没有授权不因质量失败或本地慢而上传;不新增本地→Ollama→云端的静默备选链。 + +## 4. POC 验证,不以演示代替评测 + +### 4.1 分开测试模型、调度、真实体验 + +| 层 | 控制变量 | 对照 | +|---|---|---| +| 文本模型 | 同一完整文本、词表、译向 | Hy-MT2-1.8B / 7B;公开 NMT;获授权的其他挑战者 | +| 交互调度 | 固定模型、相同输入事件 | 独立 B0 全文翻译 vs B1 单元调度 vs B2 尾部预览 | +| 端到端文本 UI | 固定模型与调度 | 粘贴、连打、IME、撤销、改前句、手工改译文 | +| 语音级联 | 固定 ASR 事件与翻译模型 | 等全文、稳定句优先、短语尾部预览 | + +模型筛选阶段不启用自动路由、自动重试或多模型润色,以免测到的是另一模型。所有基线和候选固定模型、模板及原文版本。 + +### 4.2 测试集 + +1. **通用参考**:从许可允许本用途的 WMT/其他公开中英平行集抽样,人工核对;公开基准存在训练重叠,只作锚点。 +2. **产品集**:240 个中英双向条目,覆盖技术讨论、日常协作、方案文档,各 80 条;每类两种译向均衡。来源须为自有/获授权文本,参考译文人工审校,不用同一个候选模型生成答案再给自己打分。 +3. **危险变体**:额外至少 60 组否定、例外条件、数字/版本、术语、口语纠正的最小对照;术语与格式正确不能掩盖句意反转。 +4. **事件集**:至少 20 条 30–120 s 的真实或手写输入事件脚本,包含 IME composition、前文修订和取消;另有 10 分钟持续输入 soak。 +5. **语音后续集**:已获许可的真人音频,包含 1–5 分钟技术口述及目标译文。已有中文转写字幕只能评 ASR,**不能直接当翻译参考**。不用 TTS 替代人声,也不把拼接朗读当自然长口述。 + +按文档/说话人划分开发集与隐藏测试集。词表、上下文和调度阈值只在开发集调整;冻结后跑隐藏集,不能用同一批挑出赢家又宣称无偏验收。 + +### 4.3 质量指标 + +- 主评:盲评 adequacy(保义)/ fluency(自然度),记录 MQM 风格的漏译、增译、错义、术语、格式错误;关键错误逐例审阅。 +- 两名双语评审独立标注关键集、分歧复核;无足够评审时明确标注“单人探索”,不伪造一致率。 +- 机器指标:SacreBLEU chrF++、BLEU 的完整 signature;COMET 可离线辅助,固定模型/许可,不装进产品运行链路 [S8]。 +- 对中英混说/代码标识符分别报告术语精确率和召回率、无关词误插入、占位符/代码恢复正确率、数字值保留率。 +- 通过率同时报告“被门禁拒绝但其实正确”的比例,防止靠拒绝全部结果达到零错误。 +- 报每类和每译向,不只报平均分;显著性采用按文档分组的配对重采样,不能从一次小样本测试推断显著。 + +### 4.4 速度与资源指标 + +每次请求保存同一单调时钟上的时间点:输入事件、composition 结束、入队、开始执行、模型就绪、首可见预览、首确认单元、结束/取消、最后 UI 更新。 + +由此分离防抖、排队、加载、prefill、decode、网络及渲染;首 token、首可见译文、首确认译文和总完成时间必须分别报告。 + +- 热请求同一条目至少 5 次;冷启动至少 3 次;随机顺序运行并单列缓存命中与未命中,不把重复缓存计作模型吞吐。 +- p50/p95、10 分钟队列趋势、过期应用次数、取消后释放耗时、回撤字符数/修订次数、重译字符量÷新增字符量。 +- 记录 RSS/峰值、整机内存压力/换页、CPU 线程、GPU、功耗条件、模型驻留和线程设置。 +- 主基线:**Apple M2、16 GB 内存实机**;另测 M2 以上、16 GB 以上配置,M3 Pro/36 GB 作为已有高配参照。macOS 使用正常可用的 Metal 加速;限制线程或只跑 CPU 不能冒充 M2/16 GB 实测。 +- Windows:独立验证 16 GB 及以上同档配置,记录具体 CPU/GPU 和可用加速路径;不以 8 GB、低端四核或无独显 CPU-only 设备作为产品验收目标。 +- 云端单列 RTT、地区、限流、失联、费用。未获得资源/凭据的格子留空,不估成绩。 + +### 4.5 初始准入门槛 + +下列是 POC 目标;未通过则诚实记录失败,不为选中某个模型临时放宽: + +| 项目 | 门槛 | +|---|---| +| 严重语义错误 | 隐藏关键集不得出现新增的否定/条件反转、数字值改变、越权执行原文指令;零观察不等于零风险 | +| 一般质量 | 相对 B0 严重/主要错误不增加,自然度不退化;得分与置信区间同时展示 | +| 术语与结构 | 受保护标识符、占位符恢复 100%;词表命中目标 ≥98%,无关词插入 0;按样本量说明不确定性 | +| M2/16 GB 热短句 | 正常加速路径下,20–60 中文字符 / 10–40 英文词,首可见结果 p95 ≤1.5 s,最终结果 p95 ≤4 s;含调度等待、不含首次下载 | +| 相对交互速度 | 同设备同事件集的首确认单元 p95 比 B0 改善 ≥30%,并且质量不退化 | +| 本地资源 | 在 M2/16 GB 上记录文本翻译 UI + 后端的峰值内存、Metal 分配和整机压力;与编辑器及后续 ASR 同时运行时不得持续积压或持续换页。不沿用 8 GB 设备推导的 3 GiB/4 线程硬限制,具体预算按实机结果冻结 | +| 持续性 | 10 分钟无持续积压;最终只处理剩余尾部;所有已过期结果应用次数为 0 | + +若没有 M2/16 GB 实机,只能通过“功能/当前设备 POC”,不能进入“M2/16 GB 基线性能已验证”的产品准入;更高配置机器的成绩不能替代基线实测。 + +## 5. 第二阶段:实时转写翻译 + +文本 POC 胜出后,优先采用 **ASR → 有版本的稳定原文单元 → 翻译**,并列展示原文和译文。复用 [语音识别设计](../specs/2026-10-29-voice-dictation-design.md)的时间轴、原文恢复、用户优先和尾部提交。 + +不默认等待整理模型:比较二遍原文直接翻译与整理稿翻译,确定是否值得增加延迟及改写风险;两个文本来源不能同时悄悄争写同一译文。 + +分三种策略对照: + +- S0:录完后翻译,作质量/等待基线。 +- S1:稳定句/可靠短语翻译,先保证连续性和可追溯,作为推荐起点。 +- S2:活动尾部有限前瞻的 provisional 翻译,必要时修订尾部;原文稳定不意味着译文前缀一定稳定,只有完整语义单元通过判定才确认。 + +句末否定、中文长修饰语、英文从句和口语自纠是专门挑战题。比较译文质量、首确认延迟、句尾到稳定译文延迟、字幕回撤、ASR 修订引发的重译成本及共享 CPU/GPU 竞争。 + +先用人工转写驱动相同事件测 MT 上限,再用实际 ASR 结果测级联损失;不能把所有错译归给 MT,也不能把 ASR CER 当翻译质量。实时模拟必须 1×,加速回放只用于离线正确性。 + +SimulEval 的 AL/LAAL 等时延思想可参考,但中英 tokenization 和长度偏差要声明;产品主要报告用户可观察的原句结束→译文确认时间与修订量,不只报一个平均延迟 [S9]。 + +Azure Speech 可同时给出 interim/final 转写与译文,作为**独立云端授权**的可选端到端对照;这会上传音频,不等同于只授权文本翻译 [S10]。大 GPU 直接语音翻译方案不列默认。 + +## 6. 分阶段执行清单 + +本节是完整 POC 与产品准入清单;首轮的实际覆盖见报告。基线为独立 B0,不含相对旧产品提速的实测结论。 + +### P0:固定当前基线,先测证据 + +- [ ] 在独立 `poc/text-translation/` 建立回放命令和最小测试窗口;不改 `src/LiveLingo.*`。 +- [ ] 从零实现独立 B0,不引用任何产品翻译项目;记录模型、参数、输入版本和阶段时间。 +- [ ] 创建 schema、去敏/许可清单、开发/隐藏分组;先以最小原文→参考译文样例测试评测器能检出漏译、增译、格式与数值错误。 +- [ ] 完成独立模型冷/热与事件回放基线;先测首 token、首可见译文、总完成,不虚报相对旧产品提速。 + +### P1:筛模型,不叠加调度改动 + +- [ ] 使用官方模板接入 Hy-MT2 Q4,独立进程跑相同文本;先验证特殊 token、停止、词表、上下文只读。 +- [ ] 使用公开 OPUS 权重运行 CTranslate2 CPU int8;记录 tokenizer、转换版本和许可,不使用产品 ONNX 实现。 +- [ ] 无云授权时只跑本地;有授权时单列 Translator NMT / Foundry LLM。取得模型访问许可后对比 TranslateGemma 4B,质量有需要时扩展至可满足 M2/16 GB 约束的 7B 模型。 +- [ ] 开发集选配置、冻结 manifest、隐藏集评分;按质量、延迟、内存 Pareto 选择,不用一个综合平均分掩盖硬约束。 + +### P2:验证增量调度与可用界面 + +- [ ] 新增 POC 的 `TranslationSession` 与修订事件,不复制产品的整个 provider/模型管理体系。 +- [ ] 先写能失败的回放测试:在末尾增加一句不重译前文、编辑前句使依赖后文失效、旧结果不得应用、IME 不触发、手改不被覆盖、失败不伪装成功。 +- [ ] 用可控延迟/乱序完成的假后端证明状态不变量,再连真实胜出模型;冷加载/推理期间不断输入也要可取消或明确排队。 +- [ ] 在相同模型上比较 B0 与增量策略,跑 M2/16 GB 基线性能、更高配置对照及 10 分钟 soak;提供原文/译文、临时/确认状态、有效配置与错误日志。 + +### P3:语音接入 POC + +- [ ] 回放 `~/VoicePoc/recordings/` 或其他获授权真人录音,补人工转写和人工译文,不再下载受限数据冒充商业验收。 +- [ ] POC 只适配现有 `DictationSession` 快照/原文事件;比较 S0/S1/S2 和是否等待整理。 +- [ ] 双流水线共用设备预算,验证连续 5–30 分钟、否定后置、修订、取消、目标语言切换和提交时剩余尾部。 + +### P4:设计收敛后迁入产品 + +- [ ] 交付 `report.md`、逐例结果、时延分布、资源曲线、失败清单和固定模型/运行时清单。 +- [ ] 用户通过 POC 窗口盲测,确认质量和体验;未达标保留当前产品默认。 +- [ ] 只把胜出模型适配、单一会话调度和必要 UI 状态迁入产品,淘汰被替换路径;不把试验用备选链全部搬进去。 +- [ ] Windows/macOS 回归、隐私出口验证、迁移/撤回开关验证后才改默认。 + +建议产物布局:POC 的编排与评测放 `poc/text-translation/`,模型放用户 cache,测试原文/参考译文及结果默认放 `~/VoicePoc/translation-eval/`。仓库只保留脚本、脱敏且获授权的最小测试样例与报告;秘密、真实工作文本和录音不进 Git。 + +## 7. 官方来源 + +- [S1: Hy-MT2 官方仓库、2026-05-21 发布记录与指令模板](https://github.com/Tencent-Hunyuan/Hy-MT2) +- [S2: 官方 1.8B GGUF](https://huggingface.co/tencent/Hy-MT2-1.8B-GGUF);[1.8B Apache-2.0 许可原文](https://huggingface.co/tencent/Hy-MT2-1.8B/blob/main/LICENSE.txt) +- [S3: Google TranslateGemma 发布说明,2026-01-15](https://blog.google/innovation-and-ai/technology/developers-tools/translategemma/);[模型访问及条款](https://huggingface.co/google/translategemma-4b-it) +- [S4: CTranslate2 项目](https://github.com/OpenNMT/CTranslate2);[硬件支持](https://opennmt.net/CTranslate2/hardware_support.html) +- [S5: Azure Translator 概览与 2026-06-06 变更](https://learn.microsoft.com/en-us/azure/ai-services/translator/text-translation/overview);[Translate 请求契约](https://learn.microsoft.com/en-us/azure/ai-services/translator/text-translation/2026-06-06/translate-api) +- [S6: llama.cpp server 官方文档](https://github.com/ggml-org/llama.cpp/tree/master/tools/server) +- [S7: WhisperStreaming 的替代说明](https://github.com/ufal/whisper_streaming);[SimulStreaming 流式方案及硬件要求](https://github.com/ufal/SimulStreaming) +- [S8: SacreBLEU 与 chrF++、配对显著性](https://github.com/mjpost/sacrebleu);[COMET、模型许可及解释性评估](https://github.com/Unbabel/COMET) +- [S9: SimulEval 同传评测框架](https://github.com/facebookresearch/SimulEval) +- [S10: Azure Speech 实时转写翻译与 interim/final 输出](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-translation) diff --git a/docs/superpowers/plans/2026-10-31-composer-implementation.md b/docs/superpowers/plans/2026-10-31-composer-implementation.md new file mode 100644 index 0000000..75ecb2a --- /dev/null +++ b/docs/superpowers/plans/2026-10-31-composer-implementation.md @@ -0,0 +1,193 @@ +# LiveLingo Composer 实施计划 + +设计见 [composer 设计](../specs/2026-10-31-livelingo-composer-design.md)。**接手后续工作请从 [Web UI 迁移与发布交接计划](./2026-11-01-web-ui-and-release-handoff.md) 开始**;本文件保留 M0–M4 的实施记录与实测数据。本计划按优先级拆分;**M0、M1 给出可直接执行的步骤**,M2 起在前一个里程碑验收后再细化(其前提取决于 M1 的实测结果,如 S1)。 + +规则:每个任务先写失败的测试再实现;每步结束运行 `dotnet build LiveLingo.slnx` 与相关测试;每个任务一个本地提交,英文提交信息,带 `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>`;不推送。 + +## 总览 + +| 里程碑 | 级别 | 范围 | 验收 | +|---|---|---|---| +| M0 地基 | P0 | 分支、基线提交、删除旧实现、门禁配置 | 解决方案可构建;保留模块测试全绿;覆盖 / 变异配置指向新模块 | +| M1 打字翻译 | P0 | Draft 域、文本单元、翻译管线、固定运行时与目录、Composer 与回写 | 离线:唤起 → 输入 / 粘贴 → 译文 → 回写;目标失效不乱写;真实模型探针通过 | +| M2 语音 | P1 | 麦克风、Sherpa 2.0.1、DictationSession 接入 Draft、整理与保真、听写交互 | POC 评测语料不退化;录音中逐句出译文 | +| M3 完整性 | P2 | 转录导入 / 时间线 / SRT、术语、草稿恢复、云端(Foundry / OpenAI 兼容)、宽屏、托盘、无障碍、Windows 对齐 | 逐项验收见任务卡 | +| M4 发布 | P3 | M2/16 GB 与 Windows 16 GB 验证、安装包与签名、设置迁移、旧模型清理、文档 | 开放问题 S1–S7 全部关闭或有明确结论 | + +## 进度与偏差(随实施更新) + +| 任务 | 状态 | 说明 | +|---|---|---| +| M0(0.1–0.3) | 完成 | 基线提交;旧实现已删除;覆盖与变异配置已指向新模块 | +| 1.1 Draft 域 | 完成 | 23 → 28 个测试;`Restore`(草稿恢复)推迟到 M3 | +| 1.2 文本单元 | 完成 | 唯一的 `TextUnits`(拆分、整段判定、拼接) | +| 1.3 保护与提示 | 完成 | `ProtectedText`、`TranslationPrompt` | +| 1.4 ChatClient | 完成 | `OpenAiChatClient`、`IChatCompletionClient`(本地与云端共用) | +| 1.5 调度 | 完成 | `TranslationScheduler` + `DraftSession` | +| 1.6 目录与校验 | 完成 | 每个资产必带 SHA256;新增 tar.gz / zip;`ModelCatalog` 固定版本 | +| 1.7 LlamaServerHost | 完成 | 按角色实例、回环端口、闲置卸载;真实模型端到端探针通过(macOS arm64,1.8B:首字约 200 ms,整句约 400 ms) | +| 1.8 ComposerViewModel | 完成 | 19 个测试;无 Avalonia 依赖 | +| 1.9 视图与接线 | 完成 | `ComposerWindow`、`IWindowTracker.IsAlive`、热键 → 浮窗;Windows 的 `IsAlive` 未在实机验证 | +| 1.10 验收 | 部分 | `ProbeTranslation` 已跑通;**macOS 手工验收与 Windows 验证未做** | + +**相对原计划的偏差(均有意为之)** + +1. **文本拼接**:听写追加的句子之间不再强制 `\n`(原型 `draft.js` 如此),而是沿用 POC 的同行拼接规则(只在两侧都是 ASCII 时加空格)。原因:向聊天框写入 `\n` 可能直接发送消息。 +2. **前文上下文**:评测未测出收益,`TranslationRequest.Context` 保留但调度器不使用;复测有收益再接入。 +3. **术语表**:M1 不接入;`TranslationRequest.Terms` 与提示词已支持,设置界面与失效逻辑在 M3。 +4. **Windows 运行时**:登记 CPU 版(`win-cpu-x64`)。Vulkan 版待 Windows 实机评估(S3)。 +5. **镜像回退**:删除了旧代码里“Hugging Face 不可达时自动改走 hf-mirror.com”的隐式回退;只用用户显式配置的镜像。 +6. **已知风险**:应用崩溃(非正常退出)会留下孤儿 llama-server 进程;需要在 M1 验收中确认并加启动时清理或父进程监控。 + +## M2 进度(随实施更新) + +| 任务 | 状态 | 说明 | +|---|---|---| +| 2.3 DictationSession → Draft | 完成 | 17 个行为测试(POC 的音频侧用例逐条移植);分句复用唯一的 `TextUnits`(新增 `;`、`…`、连续终结符);识别器由会话持有并释放 | +| 2.4 CleanupScheduler + FidelityGate | 完成 | 门禁 23 个测试原样移植;调度含“后文等待 2 s”、服务级失败整体失败、用户编辑优先 | +| 2.4 整理改走 llama-server(S2) | **完成,结论:可用** | 117 句对照:门禁一致 109/117(93%);双方都通过时文本逐字相同 80/98(82%,差异多为空格与标点);拒绝 14 vs POC 16;均值 649 ms/句(M3 Pro)。过程中发现并修复:连接复用导致 5/117 请求失败(现每次请求 `Connection: close`) | +| 2.1 麦克风 / 权限 | 部分 | `IMicrophone`(PortAudio,唯一实现)与 `IMicrophonePermission`(macOS AVFoundation / Windows 注册表)已建;**未在真机上验证采集与权限提示** | +| 2.2 Sherpa 适配器(S4) | **完成(macOS)** | X-ASR 流式、Qwen3-ASR、Silero 迁入 Core;带 SHA256 的目录实测通过(真实 tar.bz2 下载、校验、解压)。用 POC 语料经产品管线回放:同一段困难合成语音(terms-basic)原文 CER 0.737 vs POC 0.729,错写几乎一致;其余 5 段与 POC 行为一致(口头语与重复被整理、停顿切开的句子被缝合)。模型加载 3.4–6.3 s;停止后整理收尾 1.5–4.2 s | +| 2.5 交互与横幅 | 完成 | 点按 / 按住麦克风(350 ms)、录音中实时文字、编辑锁定、Enter 先停止再排队写入、下载同意、权限 / 无设备 / “没有声音”(输入连续 5 分钟静音才关闭麦克风;停顿不会结束录音)横幅,以及“N 句保留原话(整理未通过保真检查)”提示,均有测试;结果框与横幅文字可选中复制(完成后整段为一个可选块) | +| 2.6 回放对照 | 部分 | `ProbeVoice` 可重复运行;**尚未与 POC 做逐指标回归对照**(只做了上面的文本级对照);真实语料(非合成)未重跑 | + +## M3 进度(随实施更新) + +| 任务 | 状态 | 说明 | +|---|---|---| +| 设置 v1 | 完成 | 托盘 → 设置:唤起快捷键(校验,改后立即重新注册)、界面语言、翻译模型档位(均衡 1.8B / 质量 7B)、术语表。设置窗口改的是副本,保存才生效 | +| 术语表 | 完成 | `Glossary`:每行一个;只写词=原样保留(接入二遍识别热词、整理、保真门禁);`中文=English`=固定译法(接入翻译提示词,按目标语言自动反向)。每次唤起时读取当前设置 | +| 云端(可选) | 完成(未连真实端点验证) | 设置里“翻译/整理使用云端”两个独立开关,默认关;只发送文字,不发送音频;端点必须 https(回环地址除外),API 密钥存系统密钥库、不写设置文件;`RoutedChatClient` 每次调用读取路由,云端失败直接报错、不回退本地;选云端的角色不再需要本地模型。语音识别始终在本机。需用真实 Foundry 部署验证 | +| 草稿恢复与最近草稿(内存版) | 完成 | `RecentDrafts`:窗口关闭时记下原话(最多 5 条、新的在前、同文去重);上次未写入的草稿**不再自动恢复**:每次唤起都是空白,空白状态下提供一键“继续上次草稿”(只在上次没写入时出现);头部“最近”菜单可恢复任一条(仅在空闲且为空时可点)并可清除。只放内存,不落盘,退出应用即清除;导入的转录不记录。落盘保存需要隐私设置,暂不做 | +| 转录导入 / SRT | 完成(仅 WAV) | `WavReader`(PCM 8/16/24/32、float、扩展格式,任意声道与采样率→16 kHz 单声道)→ Silero VAD 按停顿切分(0.5 s)→ Qwen3-ASR 二遍识别;单段超过 12 s 时在其后 4 s 内最安静处切开(真实探针发现:Qwen3-ASR 对 36–43 s 的片段会截断并输出乱码)。结果进 `Draft` 的逐句单元,与语音共用整理、翻译、保真;时间线只读;导出 SRT 用当前输出(整理 / 译文 / 原话)。M3 Pro 上 81 s 录音 7.3–10.5 s 转完(约 8–11 倍实时)。**不支持 m4a / mp3 等压缩格式**(需要各平台解码器,见开放问题) | +| 宽屏 | 完成 | 转录与整段文本自动切为左右对照(980 px),其余堆叠(680 px);结果区最高 320 px 内部滚动 | +| 历史(云端同步等) | 不做 | 原型只有内存中的最近 5 份草稿,已由上面的“最近草稿”覆盖 | +| 设置迁移(schema 2 → 3) | 完成 | `SettingsMigration`:只保留热键、界面语言、更新设置、高级路径与级别;其余按新设计丢弃,云端保持关闭;其它版本从默认开始。顺带修复:`schemaVersion` 不是数字时会抛 `InvalidOperationException` 使启动崩溃 | +| 旧模型清理(S7) | 完成 | 设置里列出不在目录中的模型目录(数量与大小),用户点“删除旧模型”才删除,不静默删除 | +| 无障碍 | 完成(基础) | 无可见文字的控件有可访问名称(麦克风、互换方向、原话输入框、设置里的输入框);状态行为礼貌播报、横幅为立即播报;全部文字可选可复制;键盘:Enter 写入、Esc 逐级退出、麦克风 Space/Enter。**未用读屏软件实测** | +| Windows 对齐 | 构建级 | `dotnet publish -r win-x64 --self-contained` 成功,产物含 `portaudio.dll`、`onnxruntime.dll`、`sherpa-onnx-c-api.dll`;Windows 平台服务实现了全部接口(含 `IsAlive`、麦克风与权限);llama.cpp 已登记 `win-x64` CPU 包(`llama-server.exe`)。**没有在 Windows 上运行过任何东西** | +| macOS 打包 | 修复 | `Info.plist` 缺 `NSMicrophoneUsageDescription`、entitlements 的 audio-input 为 false,打包后的应用无法打开麦克风;已补上。未重新打包签名验证 | +| 许可审计(S5) | 完成 | 逐项核对上游许可文件与模型卡:Hy-MT2、Qwen3.5、X-ASR、Qwen3-ASR、sherpa-onnx 均 Apache-2.0;llama.cpp、Silero VAD 为 MIT。模型只在用户同意后从原站下载,不随应用分发。写入 `THIRD_PARTY_NOTICES.md` 并随产物复制。**仓库没有 `LICENSE` 文件(1.x README 的 MIT 徽章无依据),需要负责人决定**;安装包尚未附带 Apache-2.0 许可全文 | + +## M4 进度(随实施更新) + +| 项目 | 状态 | 说明 | +|---|---|---| +| S1 内存(开发机数据) | 有数据,**目标机未测** | `ResidentMemoryProbeTests`(`LIVELINGO_ENABLE_VOICE_PROBE=1`,仅 macOS):识别(实时 + 二遍 + VAD)、整理 4B、翻译全部常驻并各跑过一次请求后,合计常驻内存(M3 Pro 36 GB):**均衡档 6.6 GB**(应用进程含语音模型 1.8 GB、整理 llama-server 3.1 GB、翻译 1.8B 1.7 GB);**质量档 10.5 GB**(翻译 7B 的 llama-server 5.8 GB)。结论:均衡档在 16 GB 机器上有余量,可继续作默认;质量档加上系统与其他应用已很紧,不应自动选用。M2/16 GB 上的换页、首字与整句延迟仍需实机 | +| S3 Windows 实机 | 未做 | 需要 Windows 16 GB 机器:PortAudio 采集、热键、注入、CPU / Vulkan 速度 | +| S5 许可 | 完成 | 见上 | +| S7 旧模型 | 完成 | 见上 | +| 安装包与签名 | 部分 | `./build.sh PackMac --Runtime osx-arm64` 成功:`.pkg` 内的 `Info.plist` 含麦克风说明,`libportaudio` / `libonnxruntime` / `libsherpa-onnx-c-api` 与 `THIRD_PARTY_NOTICES.md` 都在包内。**未签名、未公证、未安装运行**(需要证书与公证账号);Windows 的 `Pack` / `PackMsi` 未在本机验证 | +| CI 门禁 | 通过 | `./build.sh Test`:Core 445、Desktop 359、HfGguf 9 全绿;行覆盖 86.2%、分支覆盖 81.6%(升级 coverlet 10 后分母变大,仍高于门槛 80 / 65)。`./build.sh Mutate`:变异得分 81.99%(门槛 80%,升级包之前测得,界面重写后只改了 Desktop,不在变异范围内)。修复了变异目标从未能运行的问题(Nuke 传 `--break-at 80` 而配置 `low: 60`;命令行 `--mutate` 还覆盖了配置里的范围),现在范围与阈值只由 `stryker-config.json` 决定 | +| 包升级 | 完成 | Avalonia 12.1.4、Microsoft.Extensions 10.0.14(`Http.Resilience` 10.11.0)、CommunityToolkit.Mvvm 8.4.2、Serilog 4.4.0 / Sinks.File 7.0.0、Velopack 1.2.161、测试包与工具(vpk、stryker 5、wix 7)全部最新稳定版。`xunit.v3` 留在 3.2.2:4.x 只支持 Microsoft Testing Platform,.NET 10 SDK 的 `dotnet test` 无法运行。移除未使用的 `Avalonia.Diagnostics` 与 `Avalonia.Fonts.Inter`(界面改用系统字体,与原型一致)。`./build.sh Build`、`Test`、`Run` 均 0 警告 | +| 原型一致性重做 | 完成(实机待验) | 按 `livelingo-ux-v3` 逐项重做:设计令牌(浅 / 深色、跟随系统)、浮窗头部(去向、隐私、输出药丸、更多、关闭)、横幅与行内操作、句子悬停工具与行内编辑、麦克风点按 / 按住、保留 Esc 逐级退出、三个菜单、最近草稿与导出对话框、设置即改即存。用无头 Skia 渲染截图(浅色 / 深色 / 听写 / 横幅 / 宽屏 / 设置四页)与原型截图逐张对照,图标、开关、按钮已修正 | +| 文档 | 完成 | README 改写为 2.0;CHANGELOG 增加 2.0.0 草案;CONTRIBUTING 更新旧模型清理与设置迁移 | +| 真实语料对照 POC | 部分 | 语音:6 个场景回放通过(M2);录音导入在 81 s 合成长录音上跑通,识别有同音错字(合成语音所致),未做 CER 对照;没有真实人声语料 | + +## M5 进度:Web UI 与发布移交(随实施更新,见 `2026-11-01-web-ui-and-release-handoff.md`) + +| 项目 | 状态 | 说明 | +|---|---|---| +| W0–W5 | 完成(实机待验) | Composer 与设置都由 `NativeWebView` 承载的页面渲染;状态与逻辑在 C#,页面只画快照、发封闭集合的意图;旧 Avalonia XAML、自绘控件与主题已删除。**GUI 启动、WKWebView 里的原生下拉菜单、热键录制、焦点归还需在 macOS 上手工确认;`./build.sh Run` 未在无界面环境运行** | +| R9 原型缺口 | 部分完成 | 已做:整理开关(关闭时不创建 `CleanupScheduler`、不提供“整理”输出)、运行状态对话框(不含正文 / 术语 / 地址 / 密钥)、保真检查拒绝原因按界面语言显示(`GateRejection` 取代英文句子)。**不做**:识别语言(语音引擎没有语言参数,做了就是无效入口)、保留录音(现在没有任何落盘录音的能力,需先做隐私设计) | +| R11 门禁复测 | 完成 | `./build.sh Test`:HfGguf 9、Core 445、Desktop 564、Web(Playwright,WebKit + Chromium)234 全绿;行覆盖 91.6%、分支覆盖 87.1%。`./build.sh Mutate`:变异得分 82.4%(门槛 80%)。README、CHANGELOG、CONTRIBUTING 已更新(CONTRIBUTING 里 96 / 92 的覆盖率门槛是过期写法,实际门槛 80 / 65) | +| R1–R8、R10 | 未做 | 需要真机、账号、证书或负责人决定 | + +## M0 地基 + +### Task 0.1 分支与基线提交 +- [ ] `git switch -c feat/composer-v3` +- [ ] `.gitignore` 追加:`poc/**/corpus*/`、`poc/**/results/`、`poc/**/models/`、`*.wav`、`*.gguf`、`poc/**/bin/`、`poc/**/obj/` +- [ ] `git status --short` 确认待提交内容只有文档、原型与 POC 源码;`du -sh` 检查暂存区无大文件 +- [ ] 提交:`docs: add voice and translation POCs, UX prototypes and composer design` + +### Task 0.2 删除旧实现 +按设计第 2 节清单分批删除,每批后构建: +- [ ] 删除 `tools/TranslationBenchmark`、`tests/LiveLingo.TranslationBenchmark.Tests`,并从 `LiveLingo.slnx` 与 Nuke 引用中移除 +- [ ] 删除 Core:`Engines/`、`LanguageDetection/`、旧 `Translation/`、旧 `Speech/`、`Processing/` 的处理器与 Qwen 宿主、`Models` 中的路由 / 选择 / 回退 / 遥测 / Ollama / OpenAI provider、`TranslationChatClient`;同步删除对应测试 +- [ ] 删除 Desktop:`Views`、`ViewModels`、`Services/Speech`、`Services/Cloud`、两套音频采集与 `IAudioCaptureService`;同步删除对应测试 +- [ ] 将 `App.axaml.cs` 缩减为:托盘 + 热键 + 空白 `ComposerWindow` 占位(后续 Task 1.9 接线) +- [ ] 移除不再使用的包引用(Lingua、Marian 用到的 OnnxRuntime / Tokenizers、旧 Sherpa 版本) +- [ ] 验证:`dotnet build LiveLingo.slnx` 0 警告 0 错误;`dotnet test LiveLingo.slnx` 全绿 +- [ ] 提交:`refactor: remove legacy engines, routing and overlay UI`(可按批次拆分提交) + +### Task 0.3 门禁配置 +- [ ] `tests/LiveLingo.Core.Tests/stryker-config.json` 的 `mutate` 改为 `Drafting/**`、`Translation/**`、`Dictation/**`、`Cleanup/**` +- [ ] `test.runsettings` 排除 `Inference/LlamaServerHost*`、`Speech/Native/**`、`Platform/**`、Views 与 Program / App +- [ ] 更新 `README.md` 与 `CONTRIBUTING.md` 中被删除的概念,保留已有的架构规则 +- [ ] 提交:`build: retarget coverage and mutation gates to the new core modules` + +## M1 打字翻译(P0) + +### Task 1.1 Draft 领域模型(`Core/Drafting`) +对应 `draft.js` 与 `draft.test.cjs` 的 21 个用例,逐条移植为 xunit 测试(文件 `DraftTests.cs`),**先红后绿**。 +- [ ] 测试:翻译与整理互相独立;人工编辑不被晚到结果覆盖;单句可改用原话;整理被拒时展示原因并保留原话;切换目标语言只使该语言的译文失效;录音中与所选输出未完成时禁止提交;翻译失败不显示成功;显式回退原话后取消晚到整理;目标丢失阻止写入但可复制;无目标时返回复制动作;提交快照不可变且晚到任务无法改写;空的人工译文视为不可用;`Reset` 使所有旧任务失效;源文变更后调和(未变单元保留结果与编辑) +- [ ] 实现:`Draft`、`DraftUnit`、`UnitView`、`UnitStatus`、`OutputKind`、`CommitResult`(`Text` / `Action` / `Reason`)、`JobTicket(epoch, unitId, revision)` +- [ ] 命令:`dotnet test tests/LiveLingo.Core.Tests --filter "FullyQualifiedName~Drafting"` +- [ ] 提交:`feat(drafting): add draft aggregate with ticketed results and frozen commit snapshot` + +### Task 1.2 文本单元(`Core/Drafting/TextUnits`) +唯一的分句与路径判定。以 `poc/text-translation` 的 `Units` 测试与 `draft.js` 的 `splitSentences` / `routeFor` 为特征测试。 +- [ ] 测试:中英句末标点;省略号与数字小数点不断句;行与缩进保留;`Join` 逆运算恒等;代码围栏、≥3 行结构化行、超过 400 字符判为整段 +- [ ] 实现 `TextUnits.Split`、`TextUnits.Join`、`TextUnits.RouteFor` +- [ ] 提交:`feat(drafting): add unified text unit segmentation and route detection` + +### Task 1.3 保护与提示(`Core/Translation`) +- [ ] 从 `poc/text-translation` 搬迁 `ProtectedText` 与 `Prompt` 及其测试;纯文本提示,无 JSON / 引号契约 +- [ ] 提交:`feat(translation): add protected literals and plain-text prompt` + +### Task 1.4 ChatClient(`Core/Inference`) +- [ ] 测试(`HttpMessageHandler` 桩):流式增量、取消、非 2xx 的错误类型、拒绝非回环本地地址 +- [ ] 实现单一 OpenAI 兼容客户端;基地址、凭据由配置注入 +- [ ] 提交:`feat(inference): add OpenAI-compatible streaming chat client` + +### Task 1.5 TranslationScheduler +- [ ] 测试:新编辑使旧任务取消并丢弃结果;依赖键(目标语言 / 前文)变化触发重译;失败成为单元状态;`Draft` 拒收过期票据 +- [ ] 实现:观察 `Draft`,最新优先,单一串行队列;整段路径一次请求 +- [ ] 提交:`feat(translation): schedule latest-wins translation over the draft` + +### Task 1.6 目录与完整性校验 +- [ ] `ModelDescriptor` / `ModelAsset` 增加 `Revision`、`Sha256`、`SizeBytes`、`License` +- [ ] 下载完成后校验;失败删除文件并抛出明确错误(测试覆盖) +- [ ] 目录登记:llama-server b11980(macOS arm64、Windows x64)、Hy-MT2-1.8B、Hy-MT2-7B;哈希取自 `poc/text-translation/scripts/fetch-models.sh` 所下载的真实文件并核对 +- [ ] 提交:`feat(models): pin revisions and verify SHA256 on install` + +### Task 1.7 LlamaServerHost +- [ ] 测试(进程接口桩):按角色启动与复用;端口随机且仅回环;就绪探测超时有界;闲置超时卸载;崩溃后下一请求重启;没有隐藏重试 +- [ ] 实现并从目录取得固定版本的可执行文件;替换旧 `LlamaServerProcessManager` 与 `NativeRuntimeUpdater` +- [ ] 提交:`feat(inference): host pinned llama-server instances per role` + +### Task 1.8 ComposerViewModel +以 `livelingo-ux-v3` 的行为为准,纯 C#(不引用 Avalonia)。 +- [ ] 测试:Enter 写入 / 处理中排队 / Shift+Enter / Esc 逐级 / 输入法组合中忽略;输出胶囊与语言互换;横幅状态;目标失效只允许复制;排队写入的取消条件(Esc、编辑、输出变更、失败);二次 Enter 无操作 +- [ ] 实现 `ComposerViewModel` 与子模型(头部、原话、结果、页脚) +- [ ] 提交:`feat(composer): add composer view model` + +### Task 1.9 视图与应用接线 +- [ ] 新增 `IWindowTracker.IsAlive`(两个平台实现 + 测试) +- [ ] `ComposerWindow`:样式令牌取自原型 `style.css`;锚定目标窗口底部居中;动画可关;绑定与布局,不含逻辑 +- [ ] `App.axaml.cs`:热键 → 捕获目标 → 显示窗口;Enter → `ITextInjector.InjectAsync(target, text, autoSend: false)` +- [ ] 提交:`feat(desktop): show composer at the target window and write back` + +### Task 1.10 端到端验证(M1 验收) +- [ ] Nuke 目标 `ProbeTranslate`:启动真实 llama-server,翻译一段固定文本并断言非空、无提示词泄漏 +- [ ] 手工(macOS):TextEdit、浏览器输入框、VS Code、终端;断网状态下完整走通;记录结果到 `docs/superpowers/plans/m1-acceptance.md` +- [ ] 记录 S1 的数据点(开发机为 M3 Pro 36 GB,不能代替 M2/16 GB;结论写明) +- [ ] 提交:`test: add translation probe and M1 acceptance record` + +## M2 语音(P1,细化时机:M1 验收后) + +| 任务 | 内容 | +|---|---| +| 2.1 | `IMicrophone`(PortAudio)+ `IMicrophonePermission`(macOS / Windows),替换已删除的采集 | +| 2.2 | Sherpa 适配器(2.0.1):流式识别、二遍、VAD;放入 `Speech/Native` | +| 2.3 | 迁移 `DictationSession` 与 FidelityGate 到 `Core/Dictation`,写入 `Draft`;搬迁 58 个测试 | +| 2.4 | `CleanupScheduler` + 保真门禁;整理改走 llama-server(S2:与 POC 对照) | +| 2.5 | 麦克风点按 / 按住、横幅(无声音、权限、下载)、录音中禁用单元编辑 | +| 2.6 | 回放对照:POC 语料上 CER 与门禁拒绝率不退化;录音中逐句出译文 | + +## M3 完整性(P2) +转录导入 / 时间线 / SRT 导出;术语表;草稿恢复;云端(授权开关 + 凭据入密钥库 + 一个 OpenAI 兼容客户端);宽屏对照;托盘;无障碍(键盘、读屏、对比度);Windows 对齐。 + +## M4 发布(P3) +M2/16 GB 与 Windows 16 GB 实测(S1、S3);安装包与签名;设置 schema 3 迁移;旧模型清理提示(S7);许可审计(S5);文档。 diff --git a/docs/superpowers/plans/2026-11-01-web-ui-and-release-handoff.md b/docs/superpowers/plans/2026-11-01-web-ui-and-release-handoff.md new file mode 100644 index 0000000..a7f33e0 --- /dev/null +++ b/docs/superpowers/plans/2026-11-01-web-ui-and-release-handoff.md @@ -0,0 +1,201 @@ +# LiveLingo 2.0 · Web UI 迁移与发布 · 交接计划 + +> **给接手的 Agent:** 先读第 0、2 节,再从第 4 节的 **W0** 开始,按顺序做。每个任务先写失败的测试再实现,一个任务一个本地提交。用 `- [ ]` 跟踪步骤。 + +**目标:** 把 Composer 与设置的界面从 Avalonia XAML 换成 Web(Avalonia 外壳 + `NativeWebView`),然后完成真机验证与发布。 + +**架构:** 状态与业务留在 C#(`ComposerViewModel`、`SettingsViewModel`、Core);Web 只渲染 C# 推来的快照并回传“意图”。设计与契约见 [Web UI 外壳设计](../specs/2026-11-01-web-ui-design.md)。 + +**技术栈:** .NET 10、Avalonia 12.1.4、Avalonia.Controls.WebView 12.1.1、Preact 10 + htm(无 Node、无构建步骤)、Playwright .NET、xUnit、Nuke。 + +--- + +## 0. 文档与代码在哪里 + +| 要找什么 | 位置 | +|---|---| +| 产品设计(需求、架构、决策 D1–D13、开放问题 S1–S7) | [specs/2026-10-31-livelingo-composer-design.md](../specs/2026-10-31-livelingo-composer-design.md) | +| **Web UI 设计(本轮新增,待确认)** | [specs/2026-11-01-web-ui-design.md](../specs/2026-11-01-web-ui-design.md) | +| 已完成的实施计划与实测记录(M0–M4 进度表,含数据) | [plans/2026-10-31-composer-implementation.md](./2026-10-31-composer-implementation.md) | +| 界面规格(视觉、交互、28 个场景、状态模型) | [prototypes/livelingo-ux-v3/](../prototypes/livelingo-ux-v3/):`style.css`、`index.html`、`app.js`、`draft.js`、`browser.test.cjs`、`*.png` | +| 语音与翻译的评测、POC | `docs/superpowers/specs/2026-10-29-voice-dictation-design.md`、`plans/2026-10-29-translation-poc.md`、`poc/` | +| Web 可行性验证程序 | 已完成并移除(`tools/WebViewSpike`,结论见 `specs/2026-11-01-web-ui-design.md` §3;需要时从 git 历史找回) | +| 业务核心 | `src/LiveLingo.Core`:`Drafting`、`Translation`、`Dictation`、`Cleanup`、`Transcription`、`Inference`、`Models`、`Cloud` | +| 桌面外壳 | `src/LiveLingo.Desktop`:`Composer/`、`Settings/`、`Platform/{macOS,Windows}`、`Services/`、`App.axaml.cs` | +| 构建与门禁 | `build/BuildTask.cs`(Nuke)、`test.runsettings`、`tests/LiveLingo.Core.Tests/stryker-config.json`、`.github/workflows/ci.yml` | + +## 1. 当前状态(分支 `feat/composer-v3`,仅本地提交,未推送) + +| 项 | 状态 | +|---|---| +| 提交 | `c76e407` 包升级;`7b5b375` Avalonia 版界面按 v3 重做(最新);之前 `bab55ce` 及更早的 M0–M4 | +| 功能 | 打字 / 语音 → 整理(保真检查)/ 翻译 → 回写;录音导入(仅 WAV)与 SRT;术语表;云端(可选,未连真实端点);最近草稿;设置迁移;旧模型清理;无障碍基础 | +| 测试 | Core 445、Desktop 359、HfGguf 9 全绿;行覆盖 86.2%、分支 81.6%;变异得分 81.99%(界面重写前测得) | +| 构建 | `./build.sh Build`、`Test`、`Run` 均 0 警告;NuGet 全部为最新稳定版(`xunit.v3` 留 3.2.2,见 §2) | +| 界面 | Avalonia 实现,已与原型逐图对照;**无背景模糊**;设置页多了云端三行;没有的设置:识别语言、保留录音、整理方式、诊断 | +| **未验证** | 真机整条流程(热键、真实麦克风、写入 Cursor / Slack / 浏览器 / 终端);Windows 任何运行;M2 / 16 GB 联合内存与延迟;真实云端;读屏;真实人声语料的 CER 回归 | +| 本轮新增 | Web UI 设计 + 可行性验证程序(macOS 上桥与 CSP 通过;透明、焦点、输入法、Windows 未验证 → W0) | + +## 2. 规则与陷阱(必须遵守) + +规则: +- 代码、注释、提交信息一律英文;回复用户用中文、简明。 +- **不要用脚本改文件**(python / sed / awk / 重定向)。用编辑 / 创建工具。批量机械替换需先告知用户。 +- 同一功能只有一个实现;不写防御性回退分支、不为兼容堆 `if-else`;云端必须用户显式开启,无隐式降级;凭据不得硬编码、不入日志与设置文件。 +- 本地提交,英文信息,结尾带 `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>`;**不推送**。 +- 验证基线:Apple **M2 及以上、16 GB 以上**;不要拿最低配设备当目标。 +- 不引入 OpenSpec;沿用 `docs/superpowers` 的设计 / 计划约定(决策 D7)。 +- 完成声明前必须贴出验证命令的真实输出;**0 警告**(`./build.sh Build`、`Test`、`Run`)。 + +陷阱: +- `xunit.v3` 固定 3.2.2:4.x 只支持 Microsoft Testing Platform,.NET 10 SDK 的 `dotnet test` 跑不了。 +- Agent 进程没有 macOS“辅助功能”与“屏幕录制”权限:热键注册会警告,`screencapture` 失败。真机流程与肉眼检查要请用户在自己的终端里做;视觉回归用 Playwright 截图。 +- `create` 工具不能覆盖已有文件;改文件用 `edit`,需要重写就先 `rm` 再 `create`(分两次调用,不要并行)。 +- 并行调用时,同批的 `build` / `test` 会读到编辑前的旧文件;编辑完成后再构建。 +- Avalonia 12 API:焦点重写用 `FocusChangedEventArgs`;`TextBox.PlaceholderText`;`InvokeScript` 的脚本不能返回 Promise(用 `void (...)` 包裹)。 +- 无头测试:`TestApp.Initialize` 里要先引用 `typeof(App).Assembly`,否则找不到编译好的 XAML。 +- 不能用 `pkill`,只能 `kill `。 +- 本地化:`zh-CN.json` 与 `en-US.json` 的键必须一致(`LocalizationServiceTests.ShellKeys_AreCoveredByBothLanguages` 校验)。 +- 变异测试(`./build.sh Mutate`)约 9–10 分钟,范围只由 `stryker-config.json` 决定。 + +常用命令: + +```bash +./build.sh Build # 构建,必须 0 警告 +./build.sh Test # 全部测试 + 覆盖率门禁(行 ≥ 80%、分支 ≥ 65%) +./build.sh Run # 运行应用(须在用户终端才有辅助功能权限) +./build.sh Mutate # 变异测试,门槛 80% +./build.sh ProbeTranslation # 真实模型翻译探针 +LIVELINGO_ENABLE_VOICE_PROBE=1 ./build.sh ProbeVoice # 语音探针(仅 macOS) +``` + +## 3. 路线图 + +| 级别 | 任务 | 说明 | 依赖 | +|---|---|---|---| +| **P0** | **W0** 可行性关口 | 透明 / 焦点 / 输入法 / 预热 / 内存,macOS 与 Windows | 无 | +| P0 | W1 Web 工程脚手架 | Preact + htm + 令牌 + `WebPage` 拼页 + 嵌入 + Playwright .NET 烟雾测试 | W0 通过 | +| P0 | W2 契约与宿主 | `WebSurface`、`WebBridge`、DTO、意图、Schema → TS、安全策略 | W1 | +| P0 | W3 Composer 视图 | 原型标记移植、状态渲染、Playwright 场景与截图 | W2 | +| P0 | W4 Composer 窗口替换 | 外壳窗口、定位 / 尺寸 / 焦点 / 预热,删除旧 XAML 实现 | W3 | +| P0 | W5 设置迁移 | Web 版设置,删除旧设置窗口与控件 | W4 | +| P1 | R1 macOS 真机整条流程 | S6:各类目标应用回填、恰好一次、剪贴板恢复 | W4 | +| P1 | R2 M2 / 16 GB 联合内存与延迟 | S1,含 WebView 占用 | W4 | +| P1 | R3 Windows 16 GB 实机 | S3,含 WebView2 透明 | W4 | +| P1 | R4 质量回归 | 真实人声语料:CER、保真拒绝率对 POC | 无 | +| P1 | R9 原型缺口 | 识别语言 / 整理方式 / 保留录音 / 诊断设置;原话保留原因本地化 | W5 | +| P2 | R5 云端真实验证 | Foundry 部署 | 无 | +| P2 | R6 读屏实测 | VoiceOver / Narrator | W4 | +| P2 | R8 压缩音频导入 | m4a / mp3 解码方案 | 无 | +| P2 | R10 背景模糊 | D21 复议 | R3 | +| **P3** | R7 发布 | 签名与公证、Windows 安装包与 WebView2、LICENSE、许可全文 | R1–R3 | +| P3 | R11 门禁复测 | 删除旧界面后重测覆盖率与变异得分,更新文档 | W5 | + +迁移期间**不删除**旧 Avalonia 界面,直到 W4 / W5 的验收通过;删除与替换在同一提交完成,最终不保留两套界面。 + +## 4. 任务卡(W 系列) + +### W0:可行性关口(先做,半天到一天) + +**文件:** `tools/WebViewSpike/`(已完成并移除,可从 git 历史找回);结果写回 `specs/2026-11-01-web-ui-design.md` §3 表格。 + +- [ ] **步骤 1:** 在用户终端运行 `dotnet run --project tools/WebViewSpike -- --auto`,确认 4 项 PASS(macOS 已通过;在 Windows 上再跑一次)。 +- [ ] **步骤 2(S8a、S8b、S8c,需要肉眼,请用户配合):** 不带参数运行。检查:窗口四周只有圆角面板与阴影、没有白底或黑角(浅色与深色壁纸各看一次);窗口出现后 `textarea` 已有焦点可直接输入;用系统拼音输入“缓存穿透”,候选窗位置正确,回车确认候选时 C# 侧收到的 `enter` 消息带 `composing:true`。 +- [ ] **步骤 3(S8d、S8e):** 在 spike 里加“隐藏 / 再显示”与计时,测热键到首帧可输入的 p95(目标 ≤ 150 ms);用活动监视器 / 任务管理器记录 WebView 相关进程内存。 +- [ ] **步骤 4(S8f):** 在 Windows 上若透明不成立,设置 `WindowsWebView2EnvironmentRequestedEventArgs.ExperimentalOffscreen = true` 再测;记录结论。 +- [ ] **步骤 5(S8g):** 试 `WebViewAdapterType.Headless` 是否能让 C# 测试在无界面环境运行。 +- [ ] **步骤 6:** 把每项的结论(通过 / 失败 / 采用的退路)写进设计文档 §3,提交:`docs: record the web shell feasibility results`。**任一项失败且退路也不成立 → 停止,向负责人汇报,保留现有 Avalonia 界面。** + +验收:S8a–S8f 均有记录;目标机型 M2 或更新、16 GB。 + +### W1:Web 工程脚手架 + +**文件(无 Node,设计 §7 / D20):** 新建 `src/LiveLingo.Desktop/Web/{WebPage.cs,Assets/page.html,Assets/styles/*,Assets/vendor/*,Assets/app/*}`、`tests/LiveLingo.Web.Tests/*`;修改 `LiveLingo.Desktop.csproj`(嵌入 `Web/Assets/**`,`InternalsVisibleTo` 加 `LiveLingo.Web.Tests`)、`LiveLingo.slnx`(加入测试项目)、`THIRD_PARTY_NOTICES.md`(preact 的 MIT、htm 的 Apache-2.0,及版本与 SHA-256)、`.github/workflows/ci.yml`(无需 Node;仅在需要时安装 Playwright 浏览器依赖)。 + +- [ ] **步骤 1(先写失败测试):** `WebPage.Build(nonce)` 返回含 nonce 的单页;页面带 CSP meta(设计 §6.2);预算 ≤ 400 KB;自有源文件无禁用 API;WebKit 与 Chromium 中页面加载后 `LL` 已定义、渲染出注入的快照、零控制台错误、零网络请求。 +- [ ] **步骤 2:** 固定版本放入 `vendor/`:`preact@10.29.8`(`dist/preact.min.js`)、`htm@3.1.1`(`dist/htm.js`),记录 SHA-256,许可写入 `THIRD_PARTY_NOTICES.md`。 +- [ ] **步骤 3:** 把原型 `style.css` 拆为 `styles/tokens.css`(令牌,名称不变)与 `styles/base.css`;`page.html` 带 `__NONCE__`、`__STYLES__`、`__SCRIPTS__` 占位。 +- [ ] **步骤 4:** `WebPage.cs` 按固定顺序拼接嵌入资源;`app/` 下只放最小占位(`99-main.js`:读取入口并渲染“最新快照的 rev”),W3 再替换。 +- [ ] **步骤 5:** 新测试项目接入 `LiveLingo.slnx`;夹具里首次使用时安装 Playwright 浏览器。 +- [ ] **步骤 6:** 运行 `./build.sh Build`(0 警告)与 `./build.sh Test`,提交:`build: add the web UI assets, page builder and browser tests`。 + +验收:`./build.sh Build` 与 `Test` 在没有 Node 的机器上成功;`WebPage.Build` 产出单页字符串;WebKit 与 Chromium 烟雾用例通过;禁用 API 扫描与预算测试通过。 + +### W2:契约与宿主 + +**文件:** 新建 `src/LiveLingo.Desktop/Web/{WebSurface.cs,WebBridge.cs,WebIntent.cs,ComposerViewState.cs,SettingsViewState.cs,ComposerIntents.cs,SettingsIntents.cs}`;测试 `tests/LiveLingo.Desktop.Tests/Web/*` 与快照黄金文件 `tests/LiveLingo.Web.Tests/Fixtures/*.json`。 + +- [ ] **步骤 1(先写失败测试):** `ComposerViewState.From(vm, texts)` 对原型的每种状态给出期望 DTO(空、听写中、翻译完成、整段宽屏、各横幅、编辑中、写入排队、已写入、目标丢失、仅复制)。 +- [ ] **步骤 2:** `WebIntent` 封闭联合与解析:未知名、缺字段、超长、错误类型一律拒绝并记不含正文的日志;测试覆盖。 +- [ ] **步骤 3:** `WebBridge`:16 ms 合并推送、`rev` 单调递增、`source{text,rev}` 规则(设计 §5.3)、`strings` 在 `ready` 与语言变化时发送;用假 `IWebHost` 测。 +- [ ] **步骤 4:** `WebSurface`:承载 `NativeWebView`;`NavigateToString` + nonce 模板;取消一切导航与新窗口;环境设置(macOS 非持久存储;Windows 私有模式 + 临时目录);所有 `InvokeScript` 用 `void (...)`。 +- [ ] **步骤 5:** `ComposerIntents` / `SettingsIntents`:把意图映射到现有 VM 命令;`saveKey` 只经一次消息并立即写密钥库;测试断言快照与日志里没有密钥。 +- [ ] **步骤 6:** 快照黄金文件:把步骤 1 的各状态序列化为 `Fixtures/*.json` 并逐字比对(设计 §4.3);Web 场景测试复用同一批文件。 +- [ ] **步骤 7:** `./build.sh Test` 全绿且 0 警告;提交:`feat(web): add the bridge, the intents and the view state`。 + +验收:新增 C# 代码行 / 分支覆盖不低于现有门禁;快照黄金文件与 C# 一致;日志里没有任何正文或密钥。 + +### W3:Composer 视图(Web) + +**文件:** `src/LiveLingo.Desktop/Web/Assets/app/{00-store,10-bridge,20-composer-*}.js`、`tests/LiveLingo.Web.Tests/*`。 + +- [ ] **步骤 1(先写失败测试):** 把原型 `browser.test.cjs` 的场景改写为“`Fixtures` 快照序列 → 断言 DOM 与发出的意图(交给 C# `WebIntent` 解析)”,Playwright .NET 跑 WebKit 与 Chromium。 +- [ ] **步骤 2:** 移植原型标记与样式:头部(目的地 / 隐私 / 输出胶囊 / 更多 / 关闭)、横幅、原话区(编辑 / 实时)、结果行(骨架、警告条、悬停工具、行内编辑)、页脚(麦克风 / 状态 / 复制 / 写入)、三个菜单、最近草稿与导出对话框、Toast。 +- [ ] **步骤 3:** 键盘语义(设计 §5):Enter 写入(按设置也可 Ctrl+Enter)、Shift+Enter 换行、Esc 逐级、输入法组合中不触发(WebKit 里确认候选的回车是 `isComposing:false` 且 `keyCode:229`,必须同时判 `e.isComposing || e.keyCode === 229`,W3 用 WebKit 项目加用例)、麦克风 Space / Enter;麦克风只发 `micDown` / `micUp`,时序由 C# 决定;`pointer capture`。 +- [ ] **步骤 4:** `source{text,rev}` 规则与光标保持;`ResizeObserver` 上报高度。 +- [ ] **步骤 5:** 视觉回归(对原型 PNG 的像素差阈值)、axe 零违规、无小于 12 px 文字、零控制台错误、零网络请求;深浅色与减少动画。 +- [ ] **步骤 6:** `./build.sh Test` 全绿;提交:`feat(web): render the composer from view-state snapshots`。 + +验收:28 个原型场景全部对应一个通过的用例;截图与原型差异在阈值内。没有 JS 行覆盖率门禁(设计 §7,已知上限)。 + +### W4:Composer 窗口替换 + +**文件:** 修改 `Composer/ComposerWindow.*`(改为薄外壳)、`App.axaml.cs`;删除旧 XAML 视图与 `ComposerDialogs.cs`、`Controls/Icon.cs`、相关窗口测试;新增外壳测试。 + +- [ ] **步骤 1:** 外壳窗口:透明、置顶、无边框、不在任务栏;承载 `WebSurface`;沿用 `ComposerPlacement`;宽度由 `layout.wide` 决定,高度来自 `size` 意图。 +- [ ] **步骤 2:** 激活与焦点:显示后 `web.Focus()` 并发 `focus{source}`;`Deactivated` → `VM.WindowDeactivatedAsync()`;关闭后焦点还给目标应用(回填仍走现有 `ITextInjector`)。 +- [ ] **步骤 3:** 预热:应用启动后创建窗口并加载页面,之后**不要 `Hide()`**(W0 实测 `Hide()` 再 `Show()` 有 330–650 ms 尾部),而是保持显示、透明、移出屏幕(“停放”);唤起时移回原位 + `reset` + 新状态;测 p95(W0 的 park 数据:首帧 p95 116 ms)。 +- [ ] **步骤 4:** 文件选择器与导出对话框的“选择文件”仍在 C#(`StorageProvider`);其余对话框在 Web。 +- [ ] **步骤 5:** 删除旧实现与测试,更新 `App.axaml.cs`;`./build.sh Test` 全绿、`Run` 0 警告。 +- [ ] **步骤 6:** 在用户终端完成 **macOS 手工清单**:唤起 → 打字 → 回车写入;语音听写(点按与按住);翻译;导入录音;各横幅;深浅色;减少动画。提交:`feat(desktop): host the composer in a web view`。 + +验收:S8 全部通过;手工清单每项勾选;旧界面代码全部删除。 + +### W5:设置迁移 + +**文件:** `src/LiveLingo.Desktop/Web/Assets/app/30-settings-*.js`;`Settings/SettingsWindow` 改为薄外壳;删除 `SettingsWindow.axaml(.cs)`、`Controls/HotkeyRecorder.cs`、多余的 `AppTheme.axaml` 样式及其测试。 + +- [x] **步骤 1:** 四组设置(通用 / 语音与翻译 / 隐私与云端 / 模型与关于),即改即存;云端开关由 C# 决定是否弹同意(同意对话框在 Web 内)。 +- [x] **步骤 2:** 热键录制:Web 采集 `event.code` + 修饰键,发 `setting{summonHotkey}`,由 C# `HotkeyParser` 校验并重新注册。 +- [x] **步骤 3:** 密钥:`saveKey` 一次性意图;界面只显示“已保存”与“忘记密钥”。 +- [x] **步骤 4:** 测试与删除,同 W4;提交:`feat(settings): render the settings in the web view`。 + +验收:设置所有行为与现有 `SettingsViewModelTests` 一致;旧设置界面代码全部删除。 + +## 5. 其余任务卡(R 系列,摘要) + +| 编号 | 做什么 | 完成标准 | +|---|---|---| +| R1 | 在原生输入框、Electron(Cursor / Slack)、浏览器、终端中逐类回填;中途切走窗口;目标关闭 | 恰好一次、不乱写;用户剪贴板被恢复;失败有横幅与复制兜底;记录进计划的进度表 | +| R2 | M2 / 16 GB:语音 + 整理 + 翻译 + WebView 常驻的峰值 RSS 与首字 / 整句延迟,均衡档与质量档 | 数据写入进度表;决定默认档位(D13);质量档是否禁用 | +| R3 | Windows 16 GB:PortAudio、热键、注入、`IsAlive`、llama.cpp CPU 与 Vulkan 速度、WebView2 透明 | S3 关闭;`win-x64` 的 `Pack` / `PackMsi` 实际安装运行 | +| R4 | 用真实人声(会议、与 Agent 对话、技术术语)重跑 POC 回放;CER、保真拒绝率对 POC | 不退化;语料不入库 | +| R5 | 用真实 Foundry 部署跑云端翻译 / 整理 | 失败直接报错、不降级;密钥只在密钥库 | +| R6 | VoiceOver 与 Narrator 走完整条流程 | 横幅与状态播报正确;焦点顺序合理 | +| R8 | 设计 m4a / mp3 解码(各平台系统解码器 vs 打包 ffmpeg) | 先出方案与许可评估再实现 | +| R9 | 补原型里有但未实现的设置(识别语言、整理方式、保留录音、诊断);“已保留原话”的原因改为按界面语言显示 | 每项有 VM 支持与测试;不新增无效入口 | +| R10 | 评估系统 Acrylic / Mica(圆角伪影) | 有结论再决定 | +| R7 | macOS 签名与公证;Windows 安装包含 WebView2 运行时(Evergreen Bootstrapper);随包附 Apache-2.0 许可全文;`LICENSE` 文件 | 干净机器安装可用 | +| R11 | 删除旧界面后重测覆盖率与变异得分,更新 README / CHANGELOG / 进度表 | 门禁仍达标 | + +## 6. 需要负责人决定 + +1. ~~确认采用 Web UI 方案,以及 Node 进入构建工具链~~ **已决定(2026-11-01):采用 Web UI;不引入 Node,Web 源文件作为嵌入资源,Preact + htm 随源码提交(D20)。** +2. 仓库没有 `LICENSE`(1.x README 的 MIT 徽章无依据):选哪种许可。 +3. Windows 10 的 WebView2 运行时:随安装包带 Bootstrapper,还是首次运行检测。 +4. 默认翻译档位在 M2 / 16 GB 数据出来后确认(D13)。 +5. 原型中“写入并发送”已取消(D12);是否需要作为显式可选项加回。 + +## 7. 通用验收 + +每个任务结束:`./build.sh Build`(0 警告)→ `./build.sh Test`(全绿,覆盖率门禁)→ 本地提交(英文信息 + Co-authored-by)。里程碑结束:在用户终端 `./build.sh Run`,对照手工清单操作,并把结果与数据写入 [实施计划](./2026-10-31-composer-implementation.md) 的进度表。 diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/README.md b/docs/superpowers/prototypes/livelingo-ux-v3/README.md new file mode 100644 index 0000000..5eee31f --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux-v3/README.md @@ -0,0 +1,68 @@ +# LiveLingo 产品原型 v3 + +直接用浏览器打开 [index.html](./index.html),无需服务器。这是交互设计演示,不是产品实现:使用预设语料,不访问麦克风、模型、网络,也不写入真实应用。上一版见 [v2](../livelingo-ux/README.md)。 + +![整体](./overview.png) + +## v2 有哪些问题(实测) + +在同一台机器、1480px 宽下量化 v2 浮窗(待命 / 听写 / 审阅三种状态): + +| 指标 | v2 | v3 | +|---|---|---| +| 静止时可点控件 | 18 / 21 / 27 | **8 / 7 / 8** | +| 小于 12px 的文字片段 | 15 / 20 / 31(最小 9px) | **0 / 0 / 0** | +| 字号种类 | 9 / 7 / 7 | **3 / 4 / 4** | +| 浮窗高度 px | 463 / 424 / 528 | **234 / 258 / 416** | + +具体问题: + +1. **同一件事拆成四个控件。** “写什么”被分散在模式页签(听写 / 文字 / 转录)、翻译开关、方向下拉、输出页签(整理稿 / 译文 / 识别原文)里,用户要先理解它们的关系。 +2. **每句常驻噪音。** 每句都带编号行、状态文字和三个下划线链接,三句话就是 9 个文字链接,正文反而淹没。 +3. **字太小。** 页脚提示、元信息多为 9–11px,低于可读下限。 +4. **页脚三层。** 操作条、按钮行、两行小字提示叠在一起,每次呼出都要先“读一遍界面”。 +5. **异常靠弹窗。** 没声音、没权限、模型下载中都弹出对话框,打断本来还能继续打字的人。 +6. **回车遇到“处理中”只提示**,用户要等一下再按一次。 +7. **定位靠固定偏移。** 浮窗与输入框的间距随内容高度需要手调。 +8. **页面冗余。** “状态全景”只是把场景再列一遍;设置分 6 页,每页都有大段说明。 + +## v3 做了什么 + +| 主题 | 做法 | +|---|---| +| 一块输入区 | 上面是“原话”(说的或写的,可编辑),下面是“将写入”。说话和打字共用;不再有模式页签。 | +| 一个输出胶囊 | 右上角胶囊选 **整理 / 翻译成 X / 原话**,并可互换语言方向。 | +| 句子操作悬停出现 | 编辑、改用原话只在悬停或键盘聚焦时出现;触屏常显。听写中不显示,避免列表在录音时被冻结。 | +| 单行页脚 | 麦克风 · 状态 · 复制 · 写入。键盘提示只在空草稿时显示一次。 | +| 麦克风一个控件 | 点按 = 免按住听写,再点结束;按住超过 350 ms = 按住说话,松开结束;键盘 Enter / Space 也可操作。去掉了暂停按钮:结束后再点即续录追加,效果相同。 | +| 回车排队 | 处理中按回车显示“完成后写入”,完成后自动写入;再按回车不取消,Esc、任何编辑、切换输出、失败或点按钮才取消。 | +| 异常用横幅 | 没声音、没权限、模型下载、处理很慢都是浮窗内横幅,打字照常可用。 | +| 失败不伪装 | 演示语料之外的文本没有译文,行内明确提示并提供重试 / 手动输入;整理在语料外保持原文。 | +| 自适应布局 | 浮窗锚定在输入框上方向上生长;整段文本或转录自动展开为左右对照,窄屏自动堆叠。 | +| 现代视觉 | 毛玻璃浮窗、统一圆角、单一强调色、骨架屏代替“处理中”文字、SVG 图标、浅色 / 深色 / 减少动画、macOS 与 Windows 样式。 | +| 更少页面 | 体验 / 设置 / 设计说明三页;设置 4 组:通用、语音与翻译、隐私与云端、模型与关于。能力没有减少,只是归并并压缩文案。 | + +![实时译文](./translation-live.png) +![整段技术文档](./long-document.png) +![深色](./dark.png) +![设置](./settings.png) +![Windows](./windows.png) ![手机宽度](./mobile.png) + +## 验证 + +```bash +node --test draft.test.cjs # 21 个状态模型测试,无第三方依赖 +NODE_PATH=<含 playwright 的 node_modules> node browser.test.cjs # 28 个 Chromium 场景,并重新生成截图 +``` + +浏览器场景覆盖:录音中可见译文且不可提交;点按 / 按住麦克风;打字后回车排队写入;语料外文本不伪造译文;悬停工具、编辑与“改用原话”;整理被拒;整段路径与自动宽屏;目标失效、无输入框、转录时间轴;四种横幅;菜单键盘操作;Esc、全局快捷键、历史;输入法与 Shift+Enter;设置、独立云端授权、术语规则;浅色 / 深色 WCAG AA 对比度(设计令牌);全页无 12px 以下文字;Windows 样式;390px 无横向溢出且浮窗不遮挡输入框;零脚本异常、零网络请求。 + +**独立复核发现并修复的三个缺陷**(均有回归测试,并经“撤销修复→变红→恢复→变绿”验证):慢速模式下“改用原话”后切回整理会永远转圈;在“设置”页按 Enter / Esc 会作用于看不见的浮窗;编辑某句时再改原文,列表冻结且编辑被静默丢弃。 + +## 边界与未验证 + +- 这是视觉与交互设计,不证明识别 / 翻译质量或延迟;“更好的体验”需要真人任务验证:首次完成率、误回填率、修改成本、长口述恢复。 +- 对比度只验证了设计令牌,没有逐一验证叠在毛玻璃之上的实际合成色。 +- 按住 350 ms 的阈值是起点,需用户测试;OS 全局热键、跨应用光标捕获与回填、Windows 实机、M2 / 16 GB 上语音 + 整理 + 翻译的联合调度都未验证。 +- 术语表只做界面演示,预设译文不会应用术语。系统声音、说话人分离、语音合成没有已验证方案,不提供入口。 +- 保真检查只能提示风险,不能证明语义正确;原话始终可见、可回退。 diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/app.js b/docs/superpowers/prototypes/livelingo-ux-v3/app.js new file mode 100644 index 0000000..74103ef --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux-v3/app.js @@ -0,0 +1,650 @@ +(function () { + "use strict"; + const { Draft, splitSentences, routeFor } = window.LiveLingoDraft; + const $ = id => document.getElementById(id); + const esc = text => String(text ?? "").replace(/[&<>"']/g, ch => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]); + const icon = name => ``; + + /* ---------- preset corpus: the prototype runs no model ---------- */ + const ZH_EN = [ + { raw: "嗯,这个 PR 先不要合并,等 CI 跑完再说。", clean: "这个 PR 先不要合并,等 CI 跑完再说。", translation: "Do not merge this PR yet. Wait for CI to finish." }, + { raw: "Redis 的超时设置为 30 秒,不是 30 分钟。", clean: "Redis 的超时设置为 30 秒,不是 30 分钟。", translation: "Set the Redis timeout to 30 seconds, not 30 minutes.", gate: { candidate: "Redis 的超时设置为 30 分钟。", reason: "整理稿把「30 秒」改成了「30 分钟」,已保留原话" } }, + { raw: "就是,保持 `CacheService` 的 API 不变,只修改错误处理。", clean: "保持 `CacheService` 的 API 不变,只修改错误处理。", translation: "Keep the API of `CacheService` unchanged. Only modify error handling." }, + { raw: "今天主要讨论缓存穿透的处理方案。", clean: "今天主要讨论缓存穿透的处理方案。", translation: "Today we mainly discuss how to handle cache penetration." }, + { raw: "会后请小王整理纪要,并同步给所有人。", clean: "会后请小王整理纪要,并同步给所有人。", translation: "After the meeting, please ask Xiao Wang to write the minutes and share them with everyone." } + ].map(item => ({ ...item, dir: "zh-en" })); + const EN_ZH = [ + { raw: "Please do not merge this PR until CI finishes.", clean: "Please do not merge this PR until CI finishes.", translation: "在 CI 跑完前,请不要合并这个 PR。" }, + { raw: "Set the Redis timeout to 30 seconds, not 30 minutes.", clean: "Set the Redis timeout to 30 seconds, not 30 minutes.", translation: "将 Redis 的超时设为 30 秒,不是 30 分钟。" }, + { raw: "Keep the API of `CacheService` unchanged.", clean: "Keep the API of `CacheService` unchanged.", translation: "保持 `CacheService` 的 API 不变。" } + ].map(item => ({ ...item, dir: "en-zh" })); + const DOC_RAW = '# 部署约束\n\n- 不要更改 `CacheService` 的 API。\n- 超时为 30 秒,日志不包含凭据。\n\n```json\n{\n "timeout": 30,\n "api_key": "${API_KEY}"\n}\n```\n'; + const DOC = { raw: DOC_RAW, clean: DOC_RAW, dir: "zh-en", translation: '# Deployment constraints\n\n- Do not change the API of `CacheService`.\n- The timeout is 30 seconds. Logs must not contain credentials.\n\n```json\n{\n "timeout": 30,\n "api_key": "${API_KEY}"\n}\n```\n' }; + const FIXTURES = [...ZH_EN, ...EN_ZH, DOC]; + const SAMPLES = { "zh-en": ZH_EN, "en-zh": EN_ZH }; + const LANG = { "zh-en": { src: "中文", dst: "English" }, "en-zh": { src: "English", dst: "中文" } }; + const NO_MODEL = "演示语料之外的文本没有译文(原型未接入模型)"; + const REASONS = { recording: "请先结束录音。", empty: "草稿是空的。", processing: "选定的内容还在处理中。", unavailable: "有句子没有可用的结果:重试、手动输入,或把输出改为「原话」。", "target-lost": "原输入框已失效,请重新选择目标或复制。" }; + + /* ---------- state ---------- */ + const draft = new Draft("agent"); + const ui = { + direction: "zh-en", editing: null, editValue: "", wide: false, forceWhole: false, armed: false, timeline: false, fileName: "", + slow: false, gate: false, handsFree: false, composing: false, cloud: { translation: false, cleanup: false }, cleanup: "on", + notice: null, micBlock: null, history: [], secs: 0, tail: "", theme: "auto", binding: "Ctrl+Alt+T", commitKey: "enter", platform: "mac" + }; + const rec = { id: 0, timer: null, index: 0 }; + const inflight = new Set(); + const terms = [{ source: "缓存穿透", target: "cache penetration" }, { source: "CacheService", target: "CacheService" }]; + let syncTimer = null, toastTimer = null, lastStruct = "", lastData = "", lastPointerUp = 0; + + /* ---------- helpers ---------- */ + const fixtureFor = raw => FIXTURES.find(item => item.raw === raw); + const mmss = total => `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; + const stamp = total => `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; + const SYMBOLS = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘", cmd: "⌘" }; + const keyLabel = binding => { + const parts = binding.split("+").map(part => part.trim()).filter(Boolean); + return ui.platform === "mac" ? parts.map(part => SYMBOLS[part.toLowerCase()] ?? part.toUpperCase()).join("") : parts.join("+"); + }; + function matches(event, binding) { + const parts = binding.toLowerCase().split("+").map(part => part.trim()).filter(Boolean); + if (!parts.length) return false; + const key = parts.at(-1), need = new Set(parts.slice(0, -1)); + const has = { ctrl: event.ctrlKey, alt: event.altKey, shift: event.shiftKey, meta: event.metaKey }; + const keyMatch = event.key.toLowerCase() === key || event.code.toLowerCase() === `key${key}` || event.code.toLowerCase() === key; + return keyMatch && Object.keys(has).every(mod => need.has(mod) === has[mod]); + } + const commitKeyLabel = () => ui.commitKey === "enter" ? "↵" : ui.platform === "mac" ? "⌃↵" : "Ctrl+↵"; + const isWide = () => innerWidth >= 900 && (ui.wide || draft.route === "whole" || ui.timeline); + const popoverOpen = () => document.querySelector(".menu:popover-open"); + const dialogOpen = () => $("dlg").open; + + function toast(message) { + $("toast").textContent = message; + $("toast").hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { $("toast").hidden = true; }, 3600); + } + function dialog(title, body) { + $("dlg-title").textContent = title; + $("dlg-body").innerHTML = body; + if (!$("dlg").open) $("dlg").showModal(); + } + function showPage(name) { + document.querySelectorAll(".page").forEach(page => { page.hidden = page.id !== `page-${name}`; }); + document.querySelectorAll(".tab").forEach(tab => { if (tab.dataset.page === name) tab.setAttribute("aria-current", "page"); else tab.removeAttribute("aria-current"); }); + } + function showSettings(section = "general") { + showPage("settings"); + document.querySelectorAll("[data-panel]").forEach(panel => { panel.hidden = panel.dataset.panel !== section; }); + document.querySelectorAll("[data-sec]").forEach(button => button.classList.toggle("active", button.dataset.sec === section)); + } + function applyTheme() { + const theme = ui.theme === "auto" ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : ui.theme; + document.documentElement.dataset.theme = theme; + } + + /* ---------- processing simulation ---------- */ + function run(unit, kind) { + const key = `${unit.id}:${kind}:${draft.translationRevision}`; + if (ui.slow || inflight.has(key)) return; + inflight.add(key); + const ticket = draft.ticketFor(unit); + const fixture = fixtureFor(unit.raw); + let text, warning = ""; + if (kind === "clean") { + if (ui.cleanup === "off") text = unit.raw; + else if (ui.gate && fixture?.gate) { text = fixture.gate.candidate; warning = fixture.gate.reason; } + else text = fixture ? fixture.clean : unit.raw; + } else text = fixture && fixture.dir === ui.direction ? fixture.translation : null; + setTimeout(() => { + inflight.delete(key); + if (text == null) draft.fail(ticket, kind, NO_MODEL); else draft.complete(ticket, kind, text, warning); + render(); + }, kind === "clean" ? 380 : 650); + } + function ensure() { + const kind = draft.output; + if (kind === "raw") return; + draft.units.forEach(unit => { if (unit[kind] === null && !unit[kind + "Error"]) run(unit, kind); }); + } + + /* ---------- source <-> draft ---------- */ + function autosize() { + const field = $("src"); + if (field.hidden) return; + field.style.height = "auto"; + field.style.height = `${Math.min(field.scrollHeight, 180)}px`; + } + function setSource() { + $("src").value = draft.units.map(unit => unit.raw).join(draft.route === "whole" ? "" : "\n"); + autosize(); + } + function syncFromSource() { + syncTimer = null; + if (draft.snapshot || draft.recording || ui.timeline) return; + const text = $("src").value; + const route = ui.forceWhole ? "whole" : routeFor(text); + draft.syncSource(route === "whole" ? (text.trim() ? [text] : []) : splitSentences(text), route); + ensure(); + render(); + } + function flushSource() { + if (syncTimer === null) return; + clearTimeout(syncTimer); + syncFromSource(); + } + + /* ---------- recording ---------- */ + function startRecording(free) { + if (draft.recording) return; + if (ui.micBlock) { toast(ui.micBlock === "permission" ? "麦克风权限未开启 · 仍可直接打字" : "语音模型还在下载 · 现在可以直接打字"); $("src").focus(); return; } + if (draft.snapshot) fresh(); + if (ui.editing !== null) saveEdit(); + flushSource(); + ui.notice = null; + draft.recording = true; + ui.handsFree = free; + ui.secs = 0; + ui.tail = ""; + const run = ++rec.id; + const samples = SAMPLES[ui.direction]; + let position = 0, pause = 0, ticks = 0; + rec.timer = setInterval(() => { + if (run !== rec.id) return; + if (++ticks % 10 === 0) ui.secs++; + if (pause > 0) { pause--; render(); return; } + const sample = samples[rec.index % samples.length]; + position = Math.min(position + 3, sample.raw.length); + ui.tail = sample.raw.slice(0, position); + if (position === sample.raw.length) { + draft.append(sample.raw); + rec.index++; position = 0; ui.tail = ""; pause = 10; + ensure(); + } + render(); + }, 100); + render(); + } + function stopRecording() { + if (!draft.recording) return; + clearInterval(rec.timer); + rec.id++; + const tail = ui.tail.trim(); + draft.recording = false; + ui.handsFree = false; + ui.tail = ""; + if (tail) draft.append(tail); + if (!ui.timeline) setSource(); + ensure(); + render(); + } + + /* ---------- draft lifecycle ---------- */ + function saveHistory() { + if (!draft.units.length) return; + ui.history = [{ time: new Date().toLocaleTimeString(), units: structuredClone(draft.units), output: draft.output, route: draft.route, direction: ui.direction, timeline: ui.timeline, fileName: ui.fileName }, ...ui.history].slice(0, 5); + } + function fresh() { + clearInterval(rec.timer); rec.id++; rec.index = 0; + clearTimeout(syncTimer); syncTimer = null; + saveHistory(); + inflight.clear(); + draft.reset(draft.target); + Object.assign(ui, { editing: null, armed: false, timeline: false, fileName: "", slow: false, gate: false, handsFree: false, tail: "", notice: null }); + draft.output = "clean"; + $("src").value = ""; + autosize(); + $("composer").hidden = false; + lastStruct = ""; + render(); + } + function restore(index) { + const entry = ui.history[index]; + if (!entry) return; + clearInterval(rec.timer); rec.id++; + inflight.clear(); + draft.reset(draft.target); + draft.restore(structuredClone(entry.units), entry.output, entry.route); + Object.assign(ui, { direction: entry.direction, timeline: entry.timeline, fileName: entry.fileName, slow: false, editing: null, armed: false, notice: null }); + if (!ui.timeline) setSource(); + $("composer").hidden = false; + lastStruct = ""; + ensure(); + render(); + } + function setOutput(kind) { + if (draft.snapshot) { toast("已写入的内容已冻结 · 新草稿后可继续"); return; } + draft.output = kind; + ui.editing = null; + ui.armed = false; + ensure(); + render(); + } + function swapDirection() { + if (draft.snapshot) return; + ui.direction = ui.direction === "zh-en" ? "en-zh" : "zh-en"; + draft.invalidateTranslations(); + ensure(); + render(); + } + function loadTranscript(name, fromPicker) { + fresh(); + ui.timeline = true; + ui.fileName = name; + ZH_EN.forEach(item => draft.append(item.raw)); + ensure(); + render(); + if (fromPicker) toast("已载入示例转录(不会读取所选文件的音频)"); + } + function showComposer() { + showPage("play"); + if (draft.snapshot) fresh(); + $("composer").hidden = false; + render(); + if (draft.units.length === 0 || !(draft.recording || ui.timeline)) $("src").focus(); else $("mic").focus(); + } + function hideComposer() { + if (draft.recording) stopRecording(); + flushSource(); + ui.armed = false; + saveHistory(); + $("composer").hidden = true; + $("target-text").focus(); + } + + /* ---------- edit / commit ---------- */ + function startEdit(id) { + const unit = draft.units.find(item => item.id === id); + if (!unit || draft.snapshot) return; + ui.editing = id; + ui.editValue = unit[draft.output] ?? ""; + ui.armed = false; + lastStruct = ""; + render(); + $(`edit-${id}`)?.focus(); + } + function saveEdit() { + if (ui.editing === null) return; + draft.edit(draft.indexOf(ui.editing), draft.output, $(`edit-${ui.editing}`).value); + ui.editing = null; + lastStruct = ""; + render(); + toast("已保存 · 之后的模型结果不会覆盖这一版"); + } + function cancelEdit() { ui.editing = null; lastStruct = ""; render(); } + function rowAction(action, id) { + const index = draft.indexOf(id); + if (index < 0 || draft.snapshot) return; + if (action === "edit") startEdit(id); + if (action === "raw") { draft.useRaw(index); ui.armed = false; lastStruct = ""; render(); } + if (action === "retry") { + const unit = draft.units[index]; + unit[draft.output + "Error"] = ""; unit[draft.output] = null; + ensure(); render(); + } + if (action === "save") saveEdit(); + if (action === "cancel") cancelEdit(); + } + function commit(fromKey) { + if (draft.snapshot) return; + if (ui.armed) { if (!fromKey) { ui.armed = false; render(); toast("已取消自动写入"); } return; } + if (ui.editing !== null) saveEdit(); + flushSource(); + const ready = draft.readiness(); + if (!ready.ok && ready.reason === "processing") { ui.armed = true; render(); return; } + if (!ready.ok) { toast(REASONS[ready.reason]); return; } + submit(); + } + function submit() { + const result = draft.requestCommit(); + if (!result.ok) return; + saveHistory(); + ui.armed = false; + if (result.action === "insert") { + const field = $("target-text"); + field.value += (field.value ? "\n" : "") + result.text; + $("composer").hidden = true; + toast(`已写入${result.target === "notes" ? "笔记" : " Agent"} · 未发送`); + field.focus(); + } else copyText(result.text); + render(); + } + async function copyText(text) { + try { await navigator.clipboard.writeText(text); toast("已复制"); } + catch { dialog("请手动复制", `

浏览器未允许写入剪贴板,内容没有丢失。

${esc(text)}
`); } + } + function copyCurrent() { + const ready = draft.recording ? { ok: false, reason: "recording" } : draft.contentReadiness(); + if (!ready.ok) { toast(REASONS[ready.reason]); return; } + copyText(draft.text()); + } + function download(format) { + const ready = draft.recording ? { ok: false } : draft.contentReadiness(); + if (!ready.ok) { toast("选定的内容未完成,不能导出半截结果"); return; } + const srt = draft.units.map((unit, i) => `${i + 1}\n00:${stamp(i * 3)},000 --> 00:${stamp(i * 3 + 3)},000\n${unit[draft.output] ?? ""}\n`).join("\n"); + const body = format === "srt" ? srt : format === "md" ? `# LiveLingo 草稿(原型示例)\n\n${draft.text()}` : draft.text(); + const url = URL.createObjectURL(new Blob([body], { type: "text/plain;charset=utf-8" })); + const link = document.createElement("a"); + link.href = url; link.download = `livelingo-prototype.${format}`; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast(format === "srt" ? "已导出 SRT · 时间为示例,非真实对齐" : "已导出"); + } + + /* ---------- rendering ---------- */ + function unitHTML(unit, index) { + const kind = draft.output, state = draft.state(unit, kind), owned = unit[kind + "Owned"], editing = ui.editing === unit.id; + const warn = kind === "clean" && unit.warning; + let body; + if (editing) body = `
`; + else if (state === "pending") body = `处理中`; + else if (state === "error") body = `

${esc(unit[kind + "Error"])}

`; + else if (state === "empty") body = `

(空)

`; + else body = `

${esc(unit[kind])}

${warn ? `

${esc(unit.warning)}

` : owned ? `

已手动修改

` : ""}`; + const tools = draft.snapshot || draft.recording || editing ? "" : `
${kind === "clean" ? `` : ""}
`; + return `
${body}${tools}
`; + } + function bannerState() { + if (draft.targetLost) return { tone: "bad", text: "原输入框已失效,草稿已保留。为避免写进错误的窗口,不会自动写入。", actions: [["重新选择", "retarget"], ["复制", "copy"]] }; + return ui.notice; + } + function statusText() { + if (draft.snapshot) return "已写入"; + if (draft.recording) return ui.handsFree ? `正在听 · ${mmss(ui.secs)} · 点击结束` : `按住说话 · ${mmss(ui.secs)} · 松开结束`; + if (ui.armed) return "处理完成后自动写入 · Esc 取消"; + if (!draft.units.length) return ""; + const ready = draft.contentReadiness(); + if (ready.reason === "processing") return draft.output === "translation" ? "正在翻译…" : "正在整理…"; + if (ready.reason === "unavailable") return "有句子未完成 · 可重试或改用原话"; + const where = ui.cloud.translation || ui.cloud.cleanup ? "含云端处理" : "本地处理"; + return `${draft.units.length} 句 · ${where}`; + } + function render() { + const lang = LANG[ui.direction], output = draft.output; + const composer = $("composer"); + composer.classList.toggle("wide", isWide()); + composer.classList.toggle("has-res", draft.units.length > 0 && output !== "raw"); + + // header + const dest = $("dest"); + dest.className = `chip dest${draft.targetLost ? " bad" : draft.target === null ? " warn" : ""}`; + dest.innerHTML = draft.targetLost ? `${icon("alert")}原输入框已失效` : draft.target === null ? "没有输入框 · 仅复制" : `${icon("target")}${draft.target === "notes" ? "笔记" : "Agent"}`; + const cloud = [ui.cloud.translation && "翻译", ui.cloud.cleanup && "整理"].filter(Boolean); + $("privacy").className = `chip privacy ${cloud.length ? "warn" : "ok"}`; + $("privacy").innerHTML = `${cloud.length ? `云端 · ${cloud.join("、")}` : "本地"}`; + $("privacy").setAttribute("aria-label", cloud.length ? `云端处理:${cloud.join("、")}` : "全部本地处理"); + const pillLabel = { clean: `${lang.src} · 整理`, translation: `→ ${lang.dst}`, raw: "原话" }[output]; + $("out-pill").innerHTML = `${output === "translation" ? icon("globe") : ""}${pillLabel}${icon("chevron")}`; + const menuLabels = { clean: `保持${lang.src}(整理)`, translation: `翻译成 ${lang.dst}`, raw: "原话(不整理)" }; + document.querySelectorAll("[data-out]").forEach(item => { + item.setAttribute("aria-checked", String(item.dataset.out === output)); + if (item.dataset.out !== "raw") item.textContent = menuLabels[item.dataset.out]; + }); + document.querySelector('[data-act="swap"]').textContent = `语言:${lang.src} → ${lang.dst} ⇄ 互换`; + document.querySelectorAll("[data-target]").forEach(item => item.setAttribute("aria-checked", String((item.dataset.target === "none" ? null : item.dataset.target) === draft.target))); + document.querySelector('[data-act="wide"]').setAttribute("aria-checked", String(ui.wide)); + document.querySelector('[data-act="whole"]').setAttribute("aria-checked", String(ui.forceWhole)); + document.querySelectorAll(".k-summon").forEach(el => { el.textContent = keyLabel(ui.binding); }); + document.querySelectorAll(".k-commit").forEach(el => { el.textContent = commitKeyLabel(); }); + $("menubar-title").textContent = draft.target === "notes" ? "Notes" : "Cursor"; + $("target-title").textContent = draft.target === "notes" ? "Notes — 新笔记" : "Cursor — Agent"; + + // banner + const banner = bannerState(); + $("banner").hidden = !banner; + if (banner) { + $("banner").className = `banner ${banner.tone}`; + $("banner").innerHTML = `${icon(banner.tone === "info" ? "info" : "alert")}

${esc(banner.text)}

${banner.progress ? `` : ""}${(banner.actions || []).map(([label, act]) => ``).join("")}`; + } + + // source + const live = draft.recording || ui.timeline; + $("src").hidden = live; + $("src-live").hidden = !live; + if (!live) autosize(); + $("src").readOnly = !!draft.snapshot; + if (live) { + const lines = draft.units.map((unit, i) => `
${ui.timeline ? `` : ""}${esc(unit.raw)}
`); + if (ui.tail) lines.push(`
${esc(ui.tail)}
`); + $("src-live").innerHTML = lines.join(""); + $("src-live").scrollTop = $("src-live").scrollHeight; + } + $("src-label").textContent = ui.timeline ? `转录 · ${ui.fileName}` : draft.recording ? "正在听…" : "原话"; + $("route-chip").hidden = draft.route !== "whole"; + $("hint").hidden = draft.units.length > 0 || draft.recording || $("src").value.trim() !== ""; + + // result rows (skipped while nothing changed, so hover and focus are never disturbed) + const showRes = draft.units.length > 0 && output !== "raw"; + $("res").hidden = !showRes; + $("res-label").textContent = output === "translation" ? `将写入 · ${lang.dst}` : "将写入"; + const struct = JSON.stringify([output, ui.editing, draft.snapshot !== null, draft.recording]); + const data = JSON.stringify(draft.units.map(unit => [unit.id, unit.clean, unit.translation, unit.cleanError, unit.translationError, unit.warning, unit.cleanOwned, unit.translationOwned])); + // While a sentence is being edited, late results must not rebuild the DOM and steal the caret. + if (showRes && (struct !== lastStruct || (data !== lastData && ui.editing === null))) { + const body = $("rows"); + const wasBottom = $("composer").querySelector(".c-body").scrollTop; + body.innerHTML = draft.units.map(unitHTML).join(""); + lastStruct = struct; lastData = data; + $("composer").querySelector(".c-body").scrollTop = draft.recording ? 1e6 : wasBottom; + } + + // footer + const mic = $("mic"); + mic.classList.toggle("recording", draft.recording); + mic.setAttribute("aria-label", draft.recording ? "结束听写" : "开始听写,长按为按住说话"); + const status = $("status"); + status.textContent = statusText(); + const ready = draft.contentReadiness(); + status.className = `status${!draft.recording && draft.units.length && ready.reason === "unavailable" ? " warn" : ""}`; + $("copy").disabled = draft.recording || !ready.ok; + const commitButton = $("commit"); + const live2 = draft.readiness(); + commitButton.disabled = !!draft.snapshot || (!ui.armed && !live2.ok && live2.reason !== "processing"); + commitButton.innerHTML = ui.armed ? `完成后写入` : draft.snapshot ? "已写入" : `${draft.target === null ? "复制" : `写入 ${draft.target === "notes" ? "笔记" : "Agent"}`} ${commitKeyLabel()}`; + commitButton.setAttribute("aria-label", ui.armed ? "取消完成后自动写入" : `${draft.target === null ? "复制" : "写入"}`); + + if (ui.armed) { + const state = draft.readiness(); + if (state.ok) { ui.armed = false; submit(); } + else if (state.reason !== "processing") { ui.armed = false; toast(REASONS[state.reason]); render(); } + } + } + + /* ---------- scenarios ---------- */ + function scenario(name) { + showPage("play"); + ui.micBlock = null; + fresh(); + draft.retarget(name === "no-target" ? null : "agent"); + ui.direction = "zh-en"; + if (name === "pair") startRecording(true); + else if (name === "translate") { draft.output = "translation"; startRecording(true); } + else if (name === "document") { draft.output = "translation"; $("src").value = DOC_RAW; autosize(); syncFromSource(); } + else if (name === "meeting") loadTranscript("会议录音(示例).m4a"); + else { + ZH_EN.slice(0, 3).forEach(item => draft.append(item.raw)); + setSource(); + if (name === "gate") ui.gate = true; + if (name === "slow") { ui.slow = true; ui.notice = { tone: "info", text: "处理比平时慢。你可以继续等待,或改用原话。", actions: [["改用原话", "use-raw"], ["继续等待", "keep-waiting"]] }; } + if (name === "failure") draft.output = "translation"; + if (name === "lost") draft.targetLost = true; + if (name === "no-audio") ui.notice = { tone: "warn", text: "没有检测到声音,已停止录音,已有内容保留。", actions: [["检查输入设备", "mic-settings"], ["知道了", "dismiss"]] }; + if (name === "permission") { ui.micBlock = "permission"; ui.notice = { tone: "warn", text: "麦克风权限未开启 · 仍可直接打字。授权后无需重启(示意)。", actions: [["打开系统设置", "open-os"], ["知道了", "dismiss"]] }; } + if (name === "download") { ui.micBlock = "download"; ui.notice = { tone: "info", text: "语音模型下载中 · 现在可以直接打字。", progress: 46, actions: [["后台下载", "dismiss"]] }; } + ensure(); + if (name === "failure") { + draft.invalidateTranslations(); + draft.units.forEach((unit, i) => draft.fail(draft.ticket(i), "translation", "示例连接超时 · 原话和整理稿未丢失")); + } + render(); + } + } + + /* ---------- events ---------- */ + document.addEventListener("click", event => { + const el = event.target.closest("button, [data-sec]"); + if (!el) return; + if (el.classList.contains("tab")) showPage(el.dataset.page); + if (el.dataset.scenario) scenario(el.dataset.scenario); + if (el.dataset.sec) showSettings(el.dataset.sec); + if (el.dataset.row) rowAction(el.dataset.row, Number(el.dataset.id)); + if (el.dataset.banner) bannerAction(el.dataset.banner); + if (el.dataset.out) { setOutput(el.dataset.out); el.closest("[popover]")?.hidePopover(); } + if (el.dataset.target) { draft.retarget(el.dataset.target === "none" ? null : el.dataset.target); render(); el.closest("[popover]")?.hidePopover(); } + if (el.dataset.platform) { + ui.platform = el.dataset.platform; + document.documentElement.dataset.platform = ui.platform; + document.querySelectorAll("[data-platform]").forEach(b => { if (b.getAttribute("role") === "radio") b.setAttribute("aria-checked", String(b.dataset.platform === ui.platform)); }); + render(); + } + if (el.dataset.act) { menuAction(el.dataset.act); const pop = el.closest("[popover]"); if (pop && !["wide", "whole"].includes(el.dataset.act)) pop.hidePopover(); } + if (el.dataset.restore !== undefined) { $("dlg").close(); restore(Number(el.dataset.restore)); } + if (el.dataset.export) { $("dlg").close(); download(el.dataset.export); } + if (el.dataset.dlg === "close") $("dlg").close(); + if (el.dataset.dlg === "clipboard") { $("dlg").close(); copyCurrent(); } + }); + function bannerAction(action) { + if (action === "retarget") $("dest").click(); + if (action === "copy") copyCurrent(); + if (action === "dismiss") { ui.notice = null; render(); } + if (action === "use-raw") { ui.notice = null; ui.slow = false; setOutput("raw"); } + if (action === "keep-waiting") { ui.notice = null; ui.slow = false; inflight.clear(); ensure(); render(); } + if (action === "mic-settings") { ui.notice = null; showSettings("voice"); } + if (action === "open-os") toast("(演示)将打开系统隐私设置"); + } + function menuAction(act) { + if (act === "summon") showComposer(); + if (act === "hide") hideComposer(); + if (act === "settings") showSettings("general"); + if (act === "new") { fresh(); $("src").focus(); } + if (act === "swap") swapDirection(); + if (act === "import") $("file").click(); + if (act === "wide") { ui.wide = !ui.wide; render(); } + if (act === "whole") { ui.forceWhole = !ui.forceWhole; if (!draft.recording && !ui.timeline) { syncFromSource(); } render(); } + if (act === "history") dialog("最近草稿", ui.history.length ? ui.history.map((entry, i) => `
${esc(entry.time)} · ${esc(entry.units[0]?.raw.slice(0, 36))}
`).join("") : "

还没有草稿。关闭或写入后会出现在这里;刷新页面会清除。

"); + if (act === "export") dialog("导出当前内容", `

导出当前选定的内容。SRT 使用示例时间,不代表真实音频对齐。

`); + } + $("summon").addEventListener("click", showComposer); + $("close").addEventListener("click", hideComposer); + $("commit").addEventListener("click", () => commit(false)); + $("copy").addEventListener("click", copyCurrent); + $("dlg-close").addEventListener("click", () => $("dlg").close()); + $("file").addEventListener("change", () => { const file = $("file").files[0]; if (file) loadTranscript(file.name, true); $("file").value = ""; }); + $("edge").addEventListener("change", () => { if ($("edge").value) scenario($("edge").value); $("edge").selectedIndex = 0; }); + $("theme-btn").addEventListener("click", () => { ui.theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; $("theme").value = ui.theme; applyTheme(); }); + + // microphone: tap = hands-free, press and hold = push-to-talk + const mic = $("mic"); + let pressed = null; + mic.addEventListener("pointerdown", event => { + if (event.button !== 0) return; + pressed = { at: performance.now(), wasRecording: draft.recording }; + if (!draft.recording) startRecording(false); + mic.setPointerCapture(event.pointerId); + }); + mic.addEventListener("pointerup", () => { + if (!pressed) return; + const { at, wasRecording } = pressed; + pressed = null; + lastPointerUp = performance.now(); + if (wasRecording) stopRecording(); + else if (!draft.recording) return; + else if (performance.now() - at >= 350) stopRecording(); + else { ui.handsFree = true; render(); } + }); + mic.addEventListener("pointercancel", () => { pressed = null; if (draft.recording && !ui.handsFree) stopRecording(); }); + mic.addEventListener("click", () => { + if (performance.now() - lastPointerUp < 400) return; + if (draft.recording) stopRecording(); else startRecording(true); + }); + + // source input + const src = $("src"); + src.addEventListener("input", () => { + if (ui.editing !== null) saveEdit(); + autosize(); + ui.armed = false; + clearTimeout(syncTimer); + syncTimer = null; + if (!ui.composing) syncTimer = setTimeout(syncFromSource, 400); + render(); + }); + src.addEventListener("compositionstart", () => { ui.composing = true; }); + src.addEventListener("compositionend", () => { ui.composing = false; clearTimeout(syncTimer); syncTimer = setTimeout(syncFromSource, 400); }); + + // menus + document.querySelectorAll(".menu").forEach(menu => menu.addEventListener("toggle", event => { + if (event.newState !== "open") return; + const anchor = document.querySelector(`[popovertarget="${menu.id}"]`); + const rect = anchor.getBoundingClientRect(), width = menu.offsetWidth, height = menu.offsetHeight; + let top = rect.bottom + 6; + if (top + height > innerHeight - 8) top = Math.max(8, rect.top - height - 6); + menu.style.left = `${Math.min(Math.max(8, rect.right - width), innerWidth - width - 8)}px`; + menu.style.top = `${top}px`; + (menu.querySelector('[aria-checked="true"]') || menu.querySelector("button"))?.focus(); + })); + document.addEventListener("keydown", event => { + const menu = popoverOpen(); + if (!menu || !["ArrowDown", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + const items = [...menu.querySelectorAll("button")]; + const next = items.indexOf(document.activeElement) + (event.key === "ArrowDown" ? 1 : -1); + items[(next + items.length) % items.length].focus(); + }, true); + + // keyboard + document.addEventListener("keydown", event => { + if (event.isComposing || ui.composing) return; + if (matches(event, ui.binding)) { event.preventDefault(); showComposer(); return; } + if (dialogOpen() || popoverOpen() || $("page-play").hidden || $("composer").hidden) return; + const target = event.target; + if (event.key === "Escape") { + event.preventDefault(); + if (ui.editing !== null) cancelEdit(); + else if (ui.armed) { ui.armed = false; render(); } + else if (draft.recording) stopRecording(); + else hideComposer(); + return; + } + if (event.key !== "Enter" || event.shiftKey) return; + if (target.classList?.contains("edit")) { event.preventDefault(); saveEdit(); return; } + if (!(target === src || target === document.body || target === mic)) return; + const chord = ui.commitKey === "ctrl-enter" ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey && !event.altKey; + if (chord) { event.preventDefault(); commit(true); } + }); + $("rows").addEventListener("input", event => { if (event.target.classList.contains("edit")) ui.editValue = event.target.value; }); + window.addEventListener("blur", () => { if (draft.recording && !ui.handsFree) stopRecording(); }); + window.addEventListener("resize", render); + + // settings + $("summon-binding").addEventListener("input", () => { ui.binding = $("summon-binding").value; render(); }); + $("commit-binding").addEventListener("change", () => { ui.commitKey = $("commit-binding").value; render(); }); + $("theme").addEventListener("change", () => { ui.theme = $("theme").value; applyTheme(); }); + $("reduce-motion").addEventListener("change", () => { document.documentElement.dataset.motion = $("reduce-motion").checked ? "off" : ""; }); + $("cleanup").addEventListener("change", () => { ui.cleanup = $("cleanup").value; }); + $("direction-setting").addEventListener("change", () => { if ($("direction-setting").value !== ui.direction) swapDirection(); }); + $("clear-history").addEventListener("click", () => { ui.history = []; toast("最近草稿已清除"); }); + $("show-diagnostics").addEventListener("click", () => dialog("本次状态(不含正文)", `
${esc(JSON.stringify({ output: draft.output, route: draft.route, recording: draft.recording, target: draft.target, targetLost: draft.targetLost, units: draft.units.length, submitted: !!draft.snapshot, cloud: ui.cloud }, null, 2))}
`)); + ["cloud-translation", "cloud-cleanup"].forEach(id => $(id).addEventListener("change", () => { + const kind = id === "cloud-translation" ? "translation" : "cleanup"; + if (!$(id).checked) { ui.cloud[kind] = false; render(); return; } + $(id).checked = false; + dialog(`同意云端${kind === "translation" ? "翻译" : "整理"}?`, `

开启后,相应文本会发送到你配置的服务商;语音不会上传。本演示不发送任何请求。

`); + $("allow-cloud").addEventListener("click", () => { ui.cloud[kind] = true; $(id).checked = true; $("dlg").close(); render(); }); + })); + function renderTerms() { + $("terms-list").innerHTML = terms.map((term, i) => `
${esc(term.source)}${esc(term.target)}
`).join(""); + } + $("terms-list").addEventListener("click", event => { const del = event.target.closest("[data-term]"); if (del) { terms.splice(Number(del.dataset.term), 1); renderTerms(); } }); + $("term-form").addEventListener("submit", event => { + event.preventDefault(); + const source = $("term-source").value.trim(), target = $("term-target").value.trim(); + if (!source || !target || terms.some(term => term.source.toLowerCase() === source.toLowerCase())) { toast("原词不能为空,也不能重复"); return; } + terms.push({ source, target }); event.target.reset(); renderTerms(); + toast("已添加 · 原型的预设译文不会应用术语表"); + }); + + matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { if (ui.theme === "auto") applyTheme(); }); + applyTheme(); renderTerms(); autosize(); render(); +})(); diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/browser.test.cjs b/docs/superpowers/prototypes/livelingo-ux-v3/browser.test.cjs new file mode 100644 index 0000000..9355ae6 --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux-v3/browser.test.cjs @@ -0,0 +1,431 @@ +const assert = require("node:assert/strict"); +const { pathToFileURL } = require("node:url"); +const path = require("node:path"); +const { chromium } = require("playwright"); + +const root = __dirname; +const results = []; +async function check(name, fn) { + await fn(); + results.push(name); + console.log("PASS", name); +} + +// Visible, at-rest interactive controls and sub-12px text inside a root element. +const measure = (page, selector) => page.evaluate(sel => { + const scope = document.querySelector(sel); + const visible = el => { + const box = el.getBoundingClientRect(); + if (!box.width || !box.height) return false; + for (let node = el; node && node !== document.body; node = node.parentElement) { + const style = getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false; + } + return true; + }; + const controls = [...scope.querySelectorAll("button, input, select, textarea, summary, a[href]")].filter(visible); + const small = []; + const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); + for (let node; (node = walker.nextNode());) { + if (!node.textContent.trim() || !visible(node.parentElement)) continue; + const size = parseFloat(getComputedStyle(node.parentElement).fontSize); + if (size < 12) small.push(`${size}px "${node.textContent.trim().slice(0, 20)}"`); + } + const sizes = new Set(); + scope.querySelectorAll("*").forEach(el => { if (visible(el) && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) sizes.add(getComputedStyle(el).fontSize); }); + return { controls: controls.length, small, fontSizes: sizes.size, height: Math.round(scope.getBoundingClientRect().height) }; +}, selector); + +// Screenshots are design deliverables: wait for the entrance animation and remove leftovers of earlier steps. +async function shot(page, file, selector) { + await page.waitForFunction(() => !document.getAnimations().some(a => a.effect?.target?.id === "composer")); + await page.evaluate(() => { document.querySelector("#toast").hidden = true; document.querySelector("#target-text").value = ""; }); + const target = selector ? page.locator(selector) : page; + if (selector) await page.evaluate(() => { document.querySelector(".top").style.position = "static"; }); + await target.screenshot({ path: path.join(root, file) }); + if (selector) await page.evaluate(() => { document.querySelector(".top").style.position = ""; }); +} + +const luminance = ([r, g, b]) => { const f = c => { c /= 255; return c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4; }; return .2126 * f(r) + .7152 * f(g) + .0722 * f(b); }; +const ratio = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + .05) / (lo + .05); }; + +(async () => { + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ viewport: { width: 1480, height: 1000 }, permissions: ["clipboard-read", "clipboard-write"] }); + const page = await context.newPage(); + const errors = [], network = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("request", request => { if (/^https?:/.test(request.url())) network.push(request.url()); }); + await page.goto(pathToFileURL(path.join(root, "index.html")).href); + const composer = page.locator("#composer"); + const metrics = {}; + + await check("loads offline with the composer ready and no script errors", async () => { + assert.equal(await composer.isVisible(), true); + assert.deepEqual(errors, []); + assert.deepEqual(network, []); + await page.waitForFunction(() => getComputedStyle(document.querySelector("#composer")).opacity === "1" && !document.getAnimations().some(a => a.effect?.target?.id === "composer")); + metrics.idle = await measure(page, "#composer"); + await shot(page, "overview.png"); + }); + + await check("tap on the microphone is hands-free; translation is visible before recording stops; commit is blocked", async () => { + await page.locator('.scenario[data-scenario="translate"]').click(); + await page.waitForFunction(() => document.querySelector(".row-text")?.textContent.includes("Do not merge this PR")); + assert.match(await page.locator("#status").innerText(), /正在听/); + assert.equal(await page.locator("#commit").isDisabled(), true); + assert.equal(await page.locator("#copy").isDisabled(), true); + metrics.live = await measure(page, "#composer"); + await shot(page, "translation-live.png"); + await page.locator("#mic").click(); + assert.equal(await page.locator("#mic").getAttribute("class").then(c => c.includes("recording")), false); + }); + + await check("Enter writes the selected English output into the original input without sending", async () => { + await page.locator("#src").focus(); + await page.keyboard.press("Enter"); + await page.waitForFunction(() => document.querySelector("#target-text").value.includes("Do not merge this PR")); + assert.equal(await composer.isHidden(), true); + assert.match(await page.locator("#toast").innerText(), /未发送/); + }); + + await check("press-and-hold records only while held; a quick tap keeps recording hands-free", async () => { + await page.locator("#summon").click(); + const box = await page.locator("#mic").boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.waitForTimeout(600); + assert.match(await page.locator("#status").innerText(), /按住说话/); + await page.mouse.up(); + assert.doesNotMatch(await page.locator("#status").innerText(), /正在听|按住说话/); + await page.locator("#mic").click(); + await page.waitForTimeout(500); + assert.match(await page.locator("#status").innerText(), /正在听/); + await page.locator("#mic").click(); + }); + + await check("typing flows into reviewed output; Enter during processing queues and writes automatically", async () => { + await page.locator("#more").click(); + await page.locator(`#menu-more [data-act="new"]`).click(); + await page.locator("#target-text").fill(""); + await page.locator("#src").focus(); + await page.keyboard.type("这个 PR 先不要合并,等 CI 跑完再说。"); + await page.keyboard.press("Enter"); + assert.match(await page.locator("#commit").innerText(), /完成后写入/); + await page.keyboard.press("Enter"); + assert.match(await page.locator("#commit").innerText(), /完成后写入/, "a second Enter must not cancel the queue"); + await page.waitForFunction(() => document.querySelector("#target-text").value.includes("这个 PR 先不要合并")); + assert.equal(await composer.isHidden(), true); + }); + + await check("text outside the demo corpus has no fake translation and offers a way out", async () => { + await page.locator("#summon").click(); + await page.locator("#more").click(); await page.locator(`#menu-more [data-act="new"]`).click(); + await page.locator("#src").fill("随便写点别的内容。"); + await page.locator("#out-pill").click(); + await page.locator('[data-out="translation"]').click(); + await page.waitForFunction(() => document.querySelector(".row-error")); + assert.match(await page.locator(".row-error").innerText(), /未接入模型/); + assert.equal(await page.locator("#commit").isDisabled(), true); + await page.locator("#out-pill").click(); + await page.locator('[data-out="raw"]').click(); + assert.equal(await page.locator("#commit").isEnabled(), true); + }); + + await check("sentence tools appear on hover; edits and 'use original' are kept as the draft grows", async () => { + await page.locator("#more").click(); await page.locator(`#menu-more [data-act="new"]`).click(); + await page.locator("#src").fill("嗯,这个 PR 先不要合并,等 CI 跑完再说。"); + await page.waitForFunction(() => document.querySelector(".row-text")); + const unit = page.locator(".unit").first(); + assert.equal(await unit.locator(".row-tools").evaluate(el => getComputedStyle(el).opacity), "0"); + await unit.hover(); + await page.waitForFunction(() => getComputedStyle(document.querySelector(".row-tools")).opacity === "1"); + await unit.locator('[data-row="raw"]').click(); + assert.match(await page.locator(".row-text").first().innerText(), /^嗯,/); + await page.locator(".unit").first().hover(); + await page.locator(".unit").first().locator('[data-row="edit"]').click(); + await page.locator(".edit").fill("请先不要合并这个 PR。"); + await page.keyboard.press("Enter"); + assert.match(await page.locator(".unit").first().innerText(), /已手动修改/); + await page.locator("#src").fill("嗯,这个 PR 先不要合并,等 CI 跑完再说。\nRedis 的超时设置为 30 秒,不是 30 分钟。"); + await page.waitForFunction(() => document.querySelectorAll(".unit .row-text").length === 2); + assert.equal(await page.locator(".unit .row-text").first().innerText(), "请先不要合并这个 PR。"); + }); + + await check("a rejected cleanup shows its reason and keeps the original wording", async () => { + await page.locator("#edge").selectOption("gate"); + await page.waitForFunction(() => document.querySelector(".row-note")?.textContent.includes("30 秒")); + assert.equal(await page.locator(".unit").nth(1).locator(".row-text").innerText(), "Redis 的超时设置为 30 秒,不是 30 分钟。"); + metrics.review = await measure(page, "#composer"); + }); + + await check("pasted technical text uses the whole-text path, keeps code/layout and widens automatically", async () => { + await page.locator('.scenario[data-scenario="document"]').click(); + await page.waitForFunction(() => document.querySelector(".row-text")?.textContent.startsWith("# Deployment constraints")); + assert.equal(await page.locator("#route-chip").isVisible(), true); + assert.match(await composer.getAttribute("class"), /wide/); + const text = await page.locator(".row-text").innerText(); + assert.match(text, /"api_key": "\$\{API_KEY\}"/); + assert.match(text, /\n "timeout": 30,/); + assert.ok((await composer.boundingBox()).width > 900); + await shot(page, "long-document.png"); + }); + + await check("target loss blocks writing, still allows copy, and requires an explicit new target", async () => { + await page.locator("#edge").selectOption("lost"); + await page.waitForFunction(() => document.querySelectorAll(".unit .row-text").length === 3); + assert.match(await page.locator("#banner").innerText(), /不会自动写入/); + assert.equal(await page.locator("#commit").isDisabled(), true); + assert.equal(await page.locator("#copy").isEnabled(), true); + await page.locator('[data-banner="retarget"]').click(); + await page.locator('[data-target="notes"]').click(); + assert.equal(await page.locator("#commit").isEnabled(), true); + await page.locator("#commit").click(); + assert.match(await page.locator("#toast").innerText(), /笔记/); + }); + + await check("no input target turns the action into copy", async () => { + await page.locator("#edge").selectOption("no-target"); + await page.waitForFunction(() => document.querySelectorAll(".unit .row-text").length === 3); + assert.match(await page.locator("#commit").innerText(), /复制/); + await page.locator("#commit").click(); + await page.waitForFunction(() => /已复制/.test(document.querySelector("#toast").textContent) || document.querySelector("#dlg").open); + }); + + await check("meeting transcript shows a timeline and an imported file does not read audio", async () => { + await page.locator('.scenario[data-scenario="meeting"]').click(); + assert.match(await page.locator(".src-live time").nth(1).innerText(), /00:03/); + assert.match(await composer.getAttribute("class"), /wide/); + await page.locator("#more").click(); + const chooser = page.waitForEvent("filechooser"); + await page.locator(`#menu-more [data-act="import"]`).click(); + await (await chooser).setFiles({ name: "weekly.wav", mimeType: "audio/wav", buffer: Buffer.alloc(44) }); + assert.match(await page.locator("#src-label").innerText(), /weekly\.wav/); + assert.match(await page.locator("#toast").innerText(), /不会读取/); + }); + + await check("banners replace modals: slow, no audio, permission and download keep typing available", async () => { + await page.locator("#edge").selectOption("slow"); + assert.match(await page.locator("#banner").innerText(), /处理比平时慢/); + await page.locator('[data-banner="use-raw"]').click(); + assert.equal(await page.locator("#commit").isEnabled(), true); + for (const name of ["no-audio", "permission", "download"]) { + await page.locator("#edge").selectOption(name); + assert.equal(await page.locator("#banner").isVisible(), true, name); + assert.equal(await page.locator("#dlg").evaluate(el => el.open), false, `${name} must not open a modal`); + } + await page.locator("#mic").click(); + assert.match(await page.locator("#toast").innerText(), /下载|直接打字/); + }); + + await check("output menu is keyboard operable and language direction can be swapped", async () => { + await page.locator('.scenario[data-scenario="pair"]').click(); + await page.locator("#mic").click(); + await page.locator("#out-pill").focus(); + await page.keyboard.press("Enter"); + await page.waitForFunction(() => document.querySelector("#menu-output").contains(document.activeElement)); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + assert.match(await page.locator("#out-pill").innerText(), /English/); + await page.locator("#out-pill").click(); + await page.locator('[data-act="swap"]').click(); + assert.match(await page.locator("#out-pill").innerText(), /中文/); + }); + + await check("leaving slow mode via 'use original' does not strand later cleanup jobs", async () => { + await page.locator("#edge").selectOption("slow"); + await page.locator('[data-banner="use-raw"]').click(); + await page.locator("#out-pill").click(); + await page.locator('[data-out="clean"]').click(); + await page.waitForFunction(() => !document.querySelector(".skel") && document.querySelectorAll(".unit .row-text").length === 3, null, { timeout: 3000 }); + }); + + await check("keys pressed on another tab never act on the hidden composer", async () => { + await page.locator('.scenario[data-scenario="pair"]').click(); + await page.locator("#mic").click(); + await page.locator("#target-text").fill(""); + await page.locator('[data-page="settings"]').click(); + await page.locator("#page-settings h1").click(); + await page.keyboard.press("Enter"); + await page.keyboard.press("Escape"); + await page.locator('[data-page="play"]').click(); + assert.equal(await composer.isVisible(), true); + assert.equal(await page.locator("#target-text").inputValue(), ""); + assert.equal(await page.locator("#commit").innerText().then(t => /已写入/.test(t)), false); + }); + + await check("editing the source while a sentence editor is open keeps the list truthful and never loses the edit silently", async () => { + await page.locator("#edge").selectOption("gate"); + await page.waitForFunction(() => document.querySelectorAll(".unit .row-text").length === 3); + await page.locator(".unit").first().hover(); + await page.locator(".unit").first().locator('[data-row="edit"]').click(); + await page.locator(".edit").fill("请先不要合并这个 PR。"); + const original = await page.locator("#src").inputValue(); + await page.locator("#src").fill(original + "\n今天主要讨论缓存穿透的处理方案。"); + await page.waitForFunction(() => document.querySelectorAll(".unit .row-text").length === 4, null, { timeout: 3000 }); + assert.equal(await page.locator(".edit").count(), 0); + assert.equal(await page.locator(".unit .row-text").first().innerText(), "请先不要合并这个 PR。"); + assert.match(await page.locator(".unit").first().innerText(), /已手动修改/); + assert.ok(original.length > 0); + }); + + await check("sentence editing is unavailable while dictating, so rows never freeze mid-recording", async () => { + await page.locator("#mic").click(); + await page.waitForFunction(() => document.querySelectorAll(".unit .row-text").length >= 1); + assert.equal(await page.locator(".row-tools").count(), 0); + await page.locator("#mic").click(); + await page.waitForFunction(() => document.querySelectorAll(".row-tools").length >= 1); + }); + + await check("Esc hides and keeps the draft; the global shortcut and history bring it back", async () => { + await page.locator("#src").focus(); + await page.keyboard.press("Escape"); + assert.equal(await composer.isHidden(), true); + await page.keyboard.press("Control+Alt+KeyT"); + assert.equal(await composer.isVisible(), true); + await page.locator("#more").click(); await page.locator(`#menu-more [data-act="history"]`).click(); + assert.ok(await page.locator("[data-restore]").count() >= 1); + await page.locator("[data-restore]").first().click(); + assert.equal(await composer.isVisible(), true); + }); + + await check("IME confirmation and Shift+Enter never write; Ctrl+Enter mode requires the chord", async () => { + await page.locator("#more").click(); await page.locator(`#menu-more [data-act="new"]`).click(); + await page.locator("#target-text").fill(""); + await page.locator("#src").fill("这个 PR 先不要合并,等 CI 跑完再说。"); + await page.waitForFunction(() => document.querySelector(".row-text")); + await page.locator("#src").evaluate(el => el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true }))); + await page.locator("#src").press("Shift+Enter"); + assert.equal(await page.locator("#target-text").inputValue(), ""); + await page.locator('[data-page="settings"]').click(); + await page.locator("#commit-binding").selectOption("ctrl-enter"); + await page.locator('[data-page="play"]').click(); + await page.locator("#src").focus(); + await page.keyboard.press("Enter"); + assert.equal(await page.locator("#target-text").inputValue(), ""); + await page.keyboard.press("Control+Enter"); + await page.waitForFunction(() => document.querySelector("#target-text").value.includes("这个 PR")); + }); + + await check("settings: four sections, separate cloud consent, glossary rules", async () => { + await page.locator('[data-page="settings"]').click(); + for (const section of ["general", "voice", "privacy", "models"]) { + await page.locator(`[data-sec="${section}"]`).click(); + assert.equal(await page.locator(`[data-panel="${section}"]`).isVisible(), true); + } + await page.locator('[data-sec="privacy"]').click(); + await page.locator("#cloud-translation").click(); + assert.equal(await page.locator("#cloud-translation").isChecked(), false); + await page.locator("#allow-cloud").click(); + assert.equal(await page.locator("#cloud-cleanup").isChecked(), false); + await page.locator('[data-sec="voice"]').click(); + await page.locator("#term-source").fill("缓存穿透"); + await page.locator("#term-target").fill("duplicate"); + await page.locator('#term-form button[type="submit"]').click(); + assert.match(await page.locator("#toast").innerText(), /不能为空,也不能重复/); + await page.locator("#term-source").fill("幂等键"); + await page.locator("#term-target").fill("idempotency key"); + await page.locator('#term-form button[type="submit"]').click(); + assert.match(await page.locator("#terms-list").innerText(), /idempotency key/); + await shot(page, "settings.png", "#page-settings"); + await page.locator('[data-page="play"]').click(); + assert.match(await page.locator("#privacy").innerText(), /云端 · 翻译/); + await page.locator('[data-page="settings"]').click(); + await page.locator('[data-sec="privacy"]').click(); + await page.locator("#cloud-translation").click(); + assert.equal(await page.locator("#cloud-translation").isChecked(), false); + await page.locator('[data-page="play"]').click(); + assert.match(await page.locator("#privacy").innerText(), /本地/); + }); + + await check("light and dark themes meet WCAG AA contrast for the design tokens", async () => { + for (const theme of ["light", "dark"]) { + await page.evaluate(value => { document.documentElement.dataset.theme = value; }, theme); + const pairs = await page.evaluate(() => { + const probe = document.createElement("span"); document.body.append(probe); + const read = name => { probe.style.color = `var(${name})`; const c = getComputedStyle(probe).color.match(/[\d.]+/g).map(Number); return { rgb: c.slice(0, 3), alpha: c[3] ?? 1 }; }; + const blend = (top, base) => top.rgb.map((v, i) => Math.round(v * top.alpha + base.rgb[i] * (1 - top.alpha))); + const panel = read("--panel"), result = {}; + const on = (fg, bg) => ({ fg: read(fg).rgb, bg: blend(read(bg), panel) }); + result["text/panel"] = on("--text", "--panel"); result["muted/panel"] = on("--muted", "--panel"); result["faint/panel"] = on("--faint", "--panel"); + result["accent-ink/accent-soft"] = on("--accent-ink", "--accent-soft"); result["warn/warn-bg"] = on("--warn", "--warn-bg"); + result["danger/danger-bg"] = on("--danger", "--danger-bg"); result["info/info-bg"] = on("--info", "--info-bg"); result["ok/panel"] = on("--ok", "--panel"); + result["white/accent"] = { fg: [255, 255, 255], bg: read("--accent").rgb }; + probe.remove(); + return result; + }); + for (const [name, { fg, bg }] of Object.entries(pairs)) assert.ok(ratio(fg, bg) >= 4.5, `${theme} ${name} contrast ${ratio(fg, bg).toFixed(2)}`); + } + await page.evaluate(() => { document.documentElement.dataset.theme = "light"; }); + }); + + await check("no text below 12px anywhere on the three pages", async () => { + for (const name of ["play", "settings", "notes"]) { + await page.locator(`[data-page="${name}"]`).click(); + const result = await measure(page, `#page-${name}`); + assert.deepEqual(result.small, [], name); + } + }); + + await check("Windows variant moves the tray to the taskbar and uses Ctrl labels", async () => { + await page.locator('[data-page="play"]').click(); + await page.locator('[data-platform="win"]').click(); + assert.match(await page.locator(".rail .k-summon").innerText(), /Ctrl\+Alt\+T/); + const bar = await page.locator(".menubar").boundingBox(), win = await page.locator(".app-window").boundingBox(); + assert.ok(bar.y > win.y, "taskbar below the app window"); + await page.locator("#tray-btn").click(); + assert.equal(await page.locator("#tray-menu").isVisible(), true); + await page.keyboard.press("Escape"); + await shot(page, "windows.png"); + await page.locator('[data-platform="mac"]').click(); + }); + + await check("dark theme composer stays legible", async () => { + await page.locator("#theme-btn").click(); + await page.locator('.scenario[data-scenario="translate"]').click(); + await page.waitForFunction(() => document.querySelector(".row-text")); + await shot(page, "dark.png"); + await page.locator("#mic").click(); + await page.locator("#theme-btn").click(); + }); + + await check("390px viewport: no horizontal overflow and the composer never covers the target input", async () => { + await page.setViewportSize({ width: 390, height: 900 }); + for (const name of ["settings", "notes", "play"]) { + await page.locator(`[data-page="${name}"]`).click(); + const [scrollWidth, innerWidth] = await page.evaluate(() => [document.documentElement.scrollWidth, innerWidth]); + assert.ok(scrollWidth <= innerWidth + 1, `${name} overflows`); + } + for (const name of ["pair", "document", "meeting"]) { + await page.locator(`.scenario[data-scenario="${name}"]`).click(); + if (name === "pair") await page.locator("#mic").click(); + const box = await composer.boundingBox(), field = await page.locator(".field").boundingBox(), desk = await page.locator("#desktop").boundingBox(); + assert.ok(box.y + box.height <= field.y, `${name}: composer covers the input`); + assert.ok(box.y >= desk.y + 30, `${name}: composer is clipped at the top`); + assert.ok(box.x >= 0 && box.x + box.width <= 390, `${name}: composer outside viewport`); + } + await page.waitForFunction(() => document.querySelectorAll(".unit .row-text").length === 5); + await shot(page, "mobile.png", "#desktop"); + await page.setViewportSize({ width: 1480, height: 1000 }); + }); + + await check("design budget: few controls at rest and no text below 12px", async () => { + // A lower bound guards against measuring a half-rendered (animating) composer. + assert.ok(metrics.idle.controls >= 6 && metrics.idle.controls <= 9, `idle controls ${metrics.idle.controls}`); + assert.ok(metrics.live.controls >= 6 && metrics.live.controls <= 9, `live controls ${metrics.live.controls}`); + assert.ok(metrics.review.controls >= 6 && metrics.review.controls <= 10, `review controls ${metrics.review.controls}`); + for (const [state, value] of Object.entries(metrics)) { assert.deepEqual(value.small, [], state); assert.ok(value.fontSizes <= 6, `${state} font sizes ${value.fontSizes}`); } + }); + + await check("runtime stays error-free and fully offline", async () => { + assert.deepEqual(errors, []); + assert.deepEqual(network, []); + }); + + console.log("METRICS", JSON.stringify(metrics)); + console.log(`VERIFIED ${results.length} browser scenarios`); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/dark.png b/docs/superpowers/prototypes/livelingo-ux-v3/dark.png new file mode 100644 index 0000000..b1f7aef Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux-v3/dark.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/draft.js b/docs/superpowers/prototypes/livelingo-ux-v3/draft.js new file mode 100644 index 0000000..62ac077 --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux-v3/draft.js @@ -0,0 +1,147 @@ +// Prototype state model. It only simulates the draft rules of the product design; it runs no speech or translation. +(function (root) { + "use strict"; + + // ponytail: naive splitter (abbreviations like "e.g. Redis" split early); the product uses its own sentence segmenter. + const SENTENCE_BREAK = /\n+|(?<=[。!?!?][”"」』))]*)(?![”"」』))])\s*|(?<=[a-z0-9)\]”"']\.)\s+(?=[A-Z"“'(\[])/; + function splitSentences(text) { + return text.split(SENTENCE_BREAK).map(part => part?.trim()).filter(Boolean); + } + + function routeFor(text) { + const structured = text.split("\n").filter(line => /^\s*(#{1,6}\s|[-*]\s|\d+\.\s|[{}\[\]<]|```)/.test(line)).length >= 3; + return text.includes("```") || structured || text.length > 400 ? "whole" : "units"; + } + + class Draft { + constructor(target) { this.seq = 0; this.epoch = 0; this.reset(target); } + + reset(target) { + this.epoch++; + this.translationRevision = 0; + this.units = []; + this.output = "clean"; + this.route = "units"; + this.target = target; + this.targetLost = false; + this.recording = false; + this.snapshot = null; + } + + newUnit(raw) { + return { id: ++this.seq, raw, clean: null, translation: null, cleanOwned: false, translationOwned: false, cleanError: "", translationError: "", warning: "" }; + } + ticketFor(unit) { return { epoch: this.epoch, id: unit.id, translationRevision: this.translationRevision }; } + ticket(index) { return this.ticketFor(this.units[index]); } + find(ticket) { return ticket.epoch === this.epoch ? this.units.find(unit => unit.id === ticket.id) : undefined; } + indexOf(id) { return this.units.findIndex(unit => unit.id === id); } + + append(raw) { + if (this.snapshot) throw new Error("Start a new draft before adding text."); + const unit = this.newUnit(raw); + this.units.push(unit); + return this.ticketFor(unit); + } + + // Reconciles the units with the sentences of the source text. Units whose raw text is unchanged keep their results and + // manual edits; only new sentences return tickets for processing. + syncSource(sentences, route) { + if (this.snapshot) throw new Error("Submitted text is frozen."); + const pool = new Map(); + for (const unit of this.units) { + if (!pool.has(unit.raw)) pool.set(unit.raw, []); + pool.get(unit.raw).push(unit); + } + const created = []; + this.units = sentences.map(raw => { + const reused = pool.get(raw)?.shift(); + if (reused) return reused; + const unit = this.newUnit(raw); + created.push(unit); + return unit; + }); + this.route = route; + return created.map(unit => this.ticketFor(unit)); + } + + restore(units, output, route) { + this.units = units; + this.output = output; + this.route = route; + this.seq = Math.max(this.seq, ...units.map(unit => unit.id)); + } + + complete(ticket, kind, text, warning = "") { + const unit = this.snapshot ? undefined : this.find(ticket); + if (!unit || unit[kind + "Owned"]) return false; + if (kind === "translation" && ticket.translationRevision !== this.translationRevision) return false; + unit[kind] = warning ? unit.raw : text; + unit[kind + "Error"] = ""; + if (warning) unit.warning = warning; + return true; + } + + fail(ticket, kind, reason) { + if (!this.complete(ticket, kind, null)) return false; + this.find(ticket)[kind + "Error"] = reason; + return true; + } + + edit(index, kind, text) { + if (this.snapshot) throw new Error("Submitted text is frozen."); + const unit = this.units[index]; + unit[kind] = text; + unit[kind + "Owned"] = true; + unit[kind + "Error"] = ""; + } + useRaw(index) { this.edit(index, "clean", this.units[index].raw); } + + invalidateTranslations() { + if (this.snapshot) return; + this.translationRevision++; + this.units.forEach(unit => { unit.translation = null; unit.translationOwned = false; unit.translationError = ""; }); + } + + retarget(target) { this.target = target; this.targetLost = false; } + + state(unit, kind) { + if (kind === "raw") return "ready"; + if (unit[kind + "Error"]) return "error"; + if (unit[kind] === null) return "pending"; + return unit[kind].trim() ? "ready" : "empty"; + } + + text() { + return this.units.map(unit => unit[this.output] ?? "").join(this.route === "whole" ? "" : "\n"); + } + + // Content readiness is shared by copy, export and insertion; insertion additionally needs a live target and no recording. + contentReadiness() { + if (!this.units.length) return { ok: false, reason: "empty" }; + if (this.output !== "raw") { + const states = this.units.map(unit => this.state(unit, this.output)); + if (states.some(state => state === "error" || state === "empty")) return { ok: false, reason: "unavailable" }; + if (states.includes("pending")) return { ok: false, reason: "processing" }; + } + return this.text().trim() ? { ok: true } : { ok: false, reason: "empty" }; + } + + readiness() { + if (this.targetLost) return { ok: false, reason: "target-lost" }; + if (this.recording) return { ok: false, reason: "recording" }; + return this.contentReadiness(); + } + + requestCommit() { + if (this.snapshot) return this.snapshot; + const ready = this.readiness(); + if (!ready.ok) return ready; + this.snapshot = Object.freeze({ ok: true, text: this.text(), action: this.target === null ? "copy" : "insert", target: this.target }); + return this.snapshot; + } + } + + const api = { Draft, splitSentences, routeFor }; + if (typeof module !== "undefined") module.exports = api; + else root.LiveLingoDraft = api; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/draft.test.cjs b/docs/superpowers/prototypes/livelingo-ux-v3/draft.test.cjs new file mode 100644 index 0000000..bfe1044 --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux-v3/draft.test.cjs @@ -0,0 +1,193 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { Draft, splitSentences, routeFor } = require("./draft.js"); + +function ready() { + const draft = new Draft("agent"); + const ticket = draft.append("这个 PR 先不要合并。"); + draft.complete(ticket, "clean", "这个 PR 先不要合并。"); + draft.complete(ticket, "translation", "Do not merge this PR yet."); + return { draft, ticket }; +} + +test("translation is independent of cleanup and selects the actual output language", () => { + const draft = new Draft("agent"); + const ticket = draft.append("先不要合并。"); + draft.output = "translation"; + draft.complete(ticket, "translation", "Do not merge yet."); + assert.equal(draft.requestCommit().text, "Do not merge yet."); +}); + +test("manual cleanup edits are not overwritten by late model results", () => { + const { draft, ticket } = ready(); + draft.edit(0, "clean", "请先不要合并这个 PR。"); + assert.equal(draft.complete(ticket, "clean", "错误的迟到结果"), false); + assert.equal(draft.units[0].clean, "请先不要合并这个 PR。"); + assert.equal(draft.units[0].raw, "这个 PR 先不要合并。"); +}); + +test("a sentence can use raw text without reverting other sentences", () => { + const { draft, ticket } = ready(); + const second = draft.append("嗯,等 CI 跑完。"); + draft.complete(second, "clean", "等 CI 跑完。"); + draft.useRaw(0); + assert.equal(draft.complete(ticket, "clean", "不要保留原文"), false); + assert.equal(draft.text(), "这个 PR 先不要合并。\n等 CI 跑完。"); +}); + +test("a rejected cleanup exposes its reason and safely retains the original", () => { + const draft = new Draft("agent"); + const ticket = draft.append("超时为 30 秒。"); + draft.complete(ticket, "clean", "超时为 30 分钟。", "候选改动了单位"); + assert.equal(draft.units[0].clean, "超时为 30 秒。"); + assert.equal(draft.units[0].warning, "候选改动了单位"); + assert.equal(draft.requestCommit().text, "超时为 30 秒。"); +}); + +test("switching the target language invalidates only translations", () => { + const { draft, ticket } = ready(); + draft.invalidateTranslations(); + assert.equal(draft.units[0].clean, "这个 PR 先不要合并。"); + assert.equal(draft.complete(ticket, "translation", "wrong language"), false); + draft.output = "translation"; + assert.equal(draft.requestCommit().reason, "processing"); +}); + +test("submission is blocked during recording and while selected output is pending", () => { + const draft = new Draft("agent"); + draft.append("尚未完成的句子。"); + draft.recording = true; + assert.equal(draft.requestCommit().reason, "recording"); + draft.recording = false; + assert.equal(draft.requestCommit().reason, "processing"); +}); + +test("a failed translation is not presented as a successful translation", () => { + const { draft, ticket } = ready(); + draft.invalidateTranslations(); + draft.fail(draft.ticket(0), "translation", "连接超时"); + draft.output = "translation"; + assert.equal(draft.requestCommit().reason, "unavailable"); + assert.equal(draft.units[0].translationError, "连接超时"); + assert.equal(draft.complete(ticket, "translation", "late"), false); +}); + +test("explicit raw fallback cancels late cleanup and permits submission", () => { + const draft = new Draft("agent"); + const ticket = draft.append("不要改动接口。"); + draft.useRaw(0); + assert.equal(draft.complete(ticket, "clean", "改动接口。"), false); + assert.equal(draft.requestCommit().text, "不要改动接口。"); +}); + +test("target loss blocks insertion but still allows copying; rebinding is explicit", () => { + const { draft } = ready(); + draft.targetLost = true; + assert.equal(draft.requestCommit().reason, "target-lost"); + assert.equal(draft.contentReadiness().ok, true); + draft.retarget("notes"); + assert.equal(draft.requestCommit().target, "notes"); +}); + +test("no input target yields a copy action rather than injecting into another app", () => { + const { draft } = ready(); + draft.retarget(null); + assert.equal(draft.requestCommit().action, "copy"); +}); + +test("an immutable submitted snapshot is returned once and late tasks cannot rewrite it", () => { + const { draft, ticket } = ready(); + const first = draft.requestCommit(); + assert.equal(first.ok, true); + assert.equal(draft.requestCommit(), first); + assert.equal(draft.complete(ticket, "clean", "晚到改写"), false); + assert.equal(first.text, "这个 PR 先不要合并。"); +}); + +test("empty manual translations are explicitly unavailable for submission", () => { + const { draft } = ready(); + draft.edit(0, "translation", ""); + draft.output = "translation"; + assert.equal(draft.requestCommit().reason, "unavailable"); +}); + +test("reset invalidates every job from the previous session", () => { + const { draft, ticket } = ready(); + draft.reset("agent"); + assert.equal(draft.units.length, 0); + assert.equal(draft.complete(ticket, "clean", "previous session"), false); + assert.equal(draft.requestCommit().reason, "empty"); +}); + +test("source reconcile keeps manual edits and finished translations of unchanged sentences", () => { + const { draft } = ready(); + draft.edit(0, "clean", "我手动修改的句子。"); + const created = draft.syncSource(["这个 PR 先不要合并。", "再检查缓存。"], "units"); + assert.equal(created.length, 1); + assert.equal(draft.units[0].clean, "我手动修改的句子。"); + assert.equal(draft.units[0].translation, "Do not merge this PR yet."); + assert.equal(draft.units[1].raw, "再检查缓存。"); +}); + +test("a late result for a removed sentence cannot land on another sentence that reuses its index", () => { + const draft = new Draft("agent"); + const [first] = draft.syncSource(["旧句子。"], "units"); + draft.syncSource(["新句子。"], "units"); + assert.equal(draft.complete(first, "clean", "旧结果"), false); + assert.equal(draft.units[0].clean, null); +}); + +test("duplicate sentences each keep their own unit", () => { + const draft = new Draft("agent"); + draft.syncSource(["好的。", "好的。"], "units"); + draft.edit(1, "clean", "好。"); + draft.syncSource(["好的。", "好的。", "继续。"], "units"); + assert.equal(draft.units.length, 3); + assert.equal(draft.units[0].clean, null); + assert.equal(draft.units[1].raw, "好的。"); +}); + +test("replacing the whole source with a different document invalidates old jobs and keeps exact layout", () => { + const { draft, ticket } = ready(); + const raw = ' {\n "message": "你好",\n "timeout": 30\n }\n'; + draft.syncSource([raw], "whole"); + assert.equal(draft.complete(ticket, "translation", "stale"), false); + draft.output = "raw"; + assert.equal(draft.route, "whole"); + assert.equal(draft.text(), raw); +}); + +test("editing source text never revalidates a lost input target", () => { + const { draft } = ready(); + draft.targetLost = true; + draft.syncSource(["新的输入文本。"], "units"); + draft.output = "raw"; + assert.equal(draft.requestCommit().reason, "target-lost"); +}); + +test("restoring a draft keeps unit ids unique for later jobs", () => { + const { draft } = ready(); + const saved = structuredClone(draft.units); + draft.reset("agent"); + draft.restore(saved, "translation", "units"); + const next = draft.append("新追加。"); + assert.equal(new Set(draft.units.map(unit => unit.id)).size, 2); + assert.equal(draft.complete(next, "clean", "新追加。"), true); + assert.equal(draft.output, "translation"); +}); + +test("splitSentences handles Chinese, English, quotes, decimals and newlines", () => { + assert.deepEqual(splitSentences("先不要合并。等 CI 跑完!好吗?"), ["先不要合并。", "等 CI 跑完!", "好吗?"]); + assert.deepEqual(splitSentences("他说:“好。”然后走了。"), ["他说:“好。”", "然后走了。"]); + assert.deepEqual(splitSentences("Set the timeout to 3.5 seconds. Then restart."), ["Set the timeout to 3.5 seconds.", "Then restart."]); + assert.deepEqual(splitSentences("第一行\n\n第二行\n"), ["第一行", "第二行"]); + assert.deepEqual(splitSentences(" "), []); +}); + +test("routeFor chooses whole-text for code, structured or long text, and units for short speech", () => { + assert.equal(routeFor("这个 PR 先不要合并。"), "units"); + assert.equal(routeFor("看这个:\n```js\nconst a = 1;\n```"), "whole"); + assert.equal(routeFor("# 标题\n- 一\n- 二\n- 三"), "whole"); + assert.equal(routeFor("字".repeat(401)), "whole"); + assert.equal(routeFor("第一句。\n第二句。\n第三句。"), "units"); +}); diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/index.html b/docs/superpowers/prototypes/livelingo-ux-v3/index.html new file mode 100644 index 0000000..aa9461d --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux-v3/index.html @@ -0,0 +1,242 @@ + + + + + + + LiveLingo · 产品原型 v3 + + + + + + + +
+ LiveLingo原型 v3 + +
+
+ + +
+ +
+
+ +
+
+
+
+ +
+
Cursor — Agent
+
+

我们继续讨论缓存和错误处理。

+

代码已读完。告诉我你的边界条件,我先给出修改方案,不直接改业务代码。

+
+
+
+
+ + + + + + +
+ +
+
+
原话
+ + +

按住麦克风说话 · ↵ 写入 · ⇧↵ 换行 · Esc 隐藏

+
+ +
+
+ +

+ + +
+
+
+ + + 回填只写入文字,不会自动发送 +
+
+
+ +
+ + +
+
+ + + + +
+ + + + + + + +

+ + diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/long-document.png b/docs/superpowers/prototypes/livelingo-ux-v3/long-document.png new file mode 100644 index 0000000..be5e771 Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux-v3/long-document.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/mobile.png b/docs/superpowers/prototypes/livelingo-ux-v3/mobile.png new file mode 100644 index 0000000..3df6a0a Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux-v3/mobile.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/overview.png b/docs/superpowers/prototypes/livelingo-ux-v3/overview.png new file mode 100644 index 0000000..4f35430 Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux-v3/overview.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/settings.png b/docs/superpowers/prototypes/livelingo-ux-v3/settings.png new file mode 100644 index 0000000..17b9ef8 Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux-v3/settings.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/style.css b/docs/superpowers/prototypes/livelingo-ux-v3/style.css new file mode 100644 index 0000000..fbce319 --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux-v3/style.css @@ -0,0 +1,238 @@ +:root { + color-scheme: light; + --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI Variable", "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; + --bg: #eef0f4; --panel: #ffffff; --win: #fbfbfd; --text: #15181e; --muted: #555d6b; --faint: #667085; + --line: rgba(15, 20, 30, .1); --line-strong: rgba(15, 20, 30, .18); + --accent: #4358d8; --accent-ink: #3347c4; --accent-soft: #eef0ff; + --ok: #17794f; --warn: #8a5a00; --warn-bg: #fff4d9; --danger: #b3382c; --danger-bg: #fdeceb; --info: #2a4bb0; --info-bg: #eaf1ff; + --glass: rgba(255, 255, 255, .82); --shadow: 0 1px 0 rgba(255, 255, 255, .7) inset, 0 24px 64px rgba(20, 30, 60, .22), 0 2px 8px rgba(20, 30, 60, .08); + --hover: rgba(15, 20, 30, .06); + --wall: radial-gradient(900px 500px at 15% -10%, #cfd8ff 0, transparent 60%), radial-gradient(800px 500px at 100% 100%, #d9f0e4 0, transparent 60%), #e7eaf2; +} +html[data-theme="dark"] { + color-scheme: dark; + --bg: #0d0f13; --panel: #171a20; --win: #1b1e25; --text: #eceef3; --muted: #a8b0be; --faint: #8f98a8; + --line: rgba(255, 255, 255, .12); --line-strong: rgba(255, 255, 255, .22); + --accent: #4a5ee0; --accent-ink: #aeb8ff; --accent-soft: rgba(110, 125, 255, .14); + --ok: #5ad19a; --warn: #f2c46b; --warn-bg: rgba(242, 196, 107, .14); --danger: #ff8d82; --danger-bg: rgba(255, 141, 130, .14); --info: #a9bcff; --info-bg: rgba(110, 140, 255, .14); + --glass: rgba(30, 33, 41, .8); --shadow: 0 1px 0 rgba(255, 255, 255, .08) inset, 0 24px 64px rgba(0, 0, 0, .55), 0 2px 8px rgba(0, 0, 0, .3); + --hover: rgba(255, 255, 255, .08); + --wall: radial-gradient(900px 500px at 15% -10%, #2c3470 0, transparent 60%), radial-gradient(800px 500px at 100% 100%, #17433a 0, transparent 60%), #10131a; +} +* { box-sizing: border-box; } +html { background: var(--bg); } +body { margin: 0; font: 14px/1.55 var(--font); color: var(--text); background: var(--bg); -webkit-font-smoothing: antialiased; } +h1, h2, p { margin: 0; } +h1 { font-size: 28px; letter-spacing: -.02em; line-height: 1.25; } +h2 { font-size: 15px; font-weight: 650; } +button, input, select, textarea { font: inherit; color: inherit; } +button { border: 0; background: none; cursor: pointer; padding: 0; } +button:disabled { cursor: not-allowed; opacity: .5; } +input, select, textarea { background: var(--panel); border: 1px solid var(--line-strong); border-radius: 10px; padding: 7px 10px; } +input[type=checkbox] { appearance: none; width: 40px; height: 24px; padding: 0; border-radius: 999px; position: relative; background: var(--line-strong); border: 0; cursor: pointer; transition: background .15s; } +input[type=checkbox]::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: transform .15s; } +input[type=checkbox]:checked { background: var(--accent); } +input[type=checkbox]:checked::after { transform: translateX(16px); } +:focus-visible { outline: 2px solid var(--accent-ink); outline-offset: 2px; } +[hidden] { display: none !important; } +kbd { font: 12px/1.3 var(--mono); padding: 1px 6px; border-radius: 6px; border: 1px solid var(--line-strong); color: var(--muted); background: transparent; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.sprite { position: absolute; width: 0; height: 0; } +.ic { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; } + +/* page chrome */ +.top { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 20px; height: 56px; padding: 0 max(20px, 4vw); background: color-mix(in srgb, var(--bg) 86%, transparent); backdrop-filter: blur(16px); border-bottom: 1px solid var(--line); } +.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; color: var(--text); text-decoration: none; } +.logo { display: inline-grid; place-items: center; width: 28px; height: 28px; border-radius: 9px; background: linear-gradient(135deg, #5b6cf0, #3a9bd9); color: #fff; font: 700 15px/1 Georgia, serif; } +.logo.sm { width: 20px; height: 20px; border-radius: 6px; font-size: 12px; } +.tag { font-size: 12px; color: var(--faint); font-weight: 500; } +nav { display: flex; gap: 4px; margin-left: 12px; } +.tab { padding: 6px 12px; border-radius: 9px; color: var(--muted); white-space: nowrap; } +.tab:hover { background: var(--hover); } +.tab[aria-current] { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(0, 0, 0, .08); } +.top-tools { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.seg { display: flex; padding: 2px; border-radius: 10px; background: var(--hover); } +.seg button { padding: 3px 10px; border-radius: 8px; color: var(--muted); font-size: 13px; } +.seg button[aria-checked=true] { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(0, 0, 0, .1); } +.icon-btn { display: inline-grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; color: var(--muted); } +.icon-btn:hover:not(:disabled) { background: var(--hover); color: var(--text); } +main { max-width: 1440px; margin: 0 auto; padding: 28px max(20px, 4vw) 64px; } +.lead { color: var(--muted); margin: 6px 0 24px; } +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 11px; background: var(--hover); font-weight: 550; white-space: nowrap; } +.btn:hover:not(:disabled) { background: var(--line); } +.btn.primary { background: var(--accent); color: #fff; } +.btn.primary:hover:not(:disabled) { filter: brightness(1.1); background: var(--accent); } +.btn.primary kbd { border-color: rgba(255, 255, 255, .4); color: rgba(255, 255, 255, .92); } +.btn.block { width: 100%; } +.chip { display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 999px; background: var(--hover); color: var(--muted); font-size: 12px; white-space: nowrap; } +.chip.mini { height: 22px; padding: 0 8px; } +.chip.ok { color: var(--ok); } .chip.warn { color: var(--warn); background: var(--warn-bg); } .chip.bad { color: var(--danger); background: var(--danger-bg); } +.dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +button.chip:hover { background: var(--line); } + +/* simulated desktop */ +.play-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; align-items: start; } +.desktop { position: relative; height: 720px; border-radius: 24px; overflow: hidden; background: var(--wall); border: 1px solid var(--line); display: flex; flex-direction: column; } +.menubar { display: flex; align-items: center; justify-content: space-between; height: 32px; padding: 0 14px; font-size: 13px; background: color-mix(in srgb, var(--panel) 45%, transparent); backdrop-filter: blur(14px); flex: none; } +.menubar-title { font-weight: 650; } +.tray { display: grid; place-items: center; width: 28px; height: 24px; border-radius: 7px; } +.tray:hover { background: var(--hover); } +html[data-platform=win] .menubar { order: 2; height: 44px; justify-content: flex-end; border-top: 1px solid var(--line); } +html[data-platform=win] .menubar-title { display: none; } +html[data-platform=win] .desktop { border-radius: 14px; } +html[data-platform=win] .app-window { border-radius: 8px; } +.app-window { flex: 1; min-height: 0; margin: 20px 32px 24px; border-radius: 14px; background: var(--win); border: 1px solid var(--line); box-shadow: 0 20px 50px rgba(20, 30, 60, .14); display: flex; flex-direction: column; } +.titlebar { display: flex; align-items: center; gap: 14px; height: 40px; padding: 0 14px; font-size: 13px; color: var(--muted); border-bottom: 1px solid var(--line); flex: none; } +.dots { display: flex; gap: 6px; } .dots i { width: 10px; height: 10px; border-radius: 50%; background: var(--line-strong); } +html[data-platform=win] .dots { display: none; } +.chat { flex: 1; min-height: 0; padding: 22px 26px; display: flex; flex-direction: column; gap: 14px; overflow: hidden; } +.bubble { max-width: 72%; padding: 10px 14px; border-radius: 16px; font-size: 14px; } +.bubble.me { align-self: flex-end; background: var(--accent-soft); } +.bubble.ai { align-self: flex-start; background: var(--hover); } +.dock { position: relative; padding: 0 22px 20px; flex: none; } +.field { border: 1px solid var(--line-strong); border-radius: 16px; padding: 10px 14px 8px; background: var(--panel); } +.field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); } +.field textarea { display: block; width: 100%; border: 0; padding: 2px 0; resize: none; background: transparent; outline: none; min-height: 44px; } +.field-note { font-size: 12px; color: var(--faint); } + +/* composer */ +.composer { position: absolute; left: 50%; bottom: calc(100% + 10px); translate: -50% 0; width: min(620px, calc(100% - 12px)); max-height: 470px; display: flex; flex-direction: column; background: var(--glass); backdrop-filter: blur(30px) saturate(1.6); border: 1px solid var(--line); border-radius: 22px; box-shadow: var(--shadow); animation: rise .18s cubic-bezier(.2, .8, .3, 1); z-index: 3; transition: width .2s ease; } +.composer[hidden] { display: none; } +.composer.wide { width: min(940px, calc(100% - 12px)); } +@keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.98); } } +.c-head { display: flex; align-items: center; gap: 6px; padding: 10px 10px 6px 12px; flex: none; } +.dest { color: var(--text); font-weight: 550; } +.spacer { flex: 1; } +.pill { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px 0 12px; border-radius: 999px; background: var(--accent-soft); color: var(--accent-ink); font-size: 13px; font-weight: 600; } +.pill:hover { filter: brightness(.96); } +.banner { display: flex; align-items: center; gap: 10px; margin: 2px 12px 6px; padding: 9px 12px; border-radius: 12px; font-size: 13px; flex: none; } +.banner.warn { background: var(--warn-bg); color: var(--warn); } .banner.bad { background: var(--danger-bg); color: var(--danger); } .banner.info { background: var(--info-bg); color: var(--info); } +.banner p { flex: 1; } .banner .actions { display: flex; gap: 4px; flex-wrap: wrap; } +.banner .actions button { padding: 2px 9px; border-radius: 8px; font-weight: 650; text-decoration: underline; text-underline-offset: 3px; } +.banner .actions button:hover { background: rgba(127, 127, 127, .16); } +.banner progress { width: 90px; height: 6px; accent-color: currentColor; } +.c-body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; padding-bottom: 4px; } +.pane { padding: 4px 18px 6px; } +.pane-label { display: flex; align-items: center; gap: 8px; min-height: 22px; font-size: 12px; color: var(--faint); font-weight: 600; } +.src textarea { display: block; width: 100%; border: 0; background: transparent; padding: 2px 0; resize: none; outline: none; font-size: 16px; line-height: 1.6; max-height: 180px; } +.src-live { font-size: 16px; line-height: 1.6; max-height: 200px; overflow-y: auto; } +.src-live .line { display: flex; gap: 10px; } +.src-live time { font: 12px/2.1 var(--mono); color: var(--faint); flex: none; width: 42px; } +.src-live .tail { color: var(--faint); } +.caret { display: inline-block; width: 2px; height: 1.1em; margin-left: 2px; vertical-align: -2px; background: var(--accent-ink); animation: blink 1s steps(1) infinite; } +@keyframes blink { 50% { opacity: 0; } } +.hint { font-size: 12px; color: var(--faint); padding: 4px 0 6px; } +.has-res .src textarea, .has-res .src-live { font-size: 14px; color: var(--muted); } +.res { margin: 2px 12px 8px; padding: 6px 6px 4px; border-radius: 16px; background: var(--accent-soft); } +.res .pane-label { padding: 0 8px; color: var(--accent-ink); } +.rows { display: flex; flex-direction: column; } +.unit { position: relative; padding: 9px 10px 9px 14px; border-radius: 12px; } +.unit + .unit { border-top: 1px solid var(--line); border-radius: 0; } +.unit::before { content: ""; position: absolute; left: 3px; top: 12px; bottom: 12px; width: 3px; border-radius: 3px; background: transparent; } +.unit.warn::before { background: var(--warn); } .unit.owned::before { background: var(--accent); } +.row-text { font-size: 16px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; padding-right: 64px; } +.row-note { font-size: 12px; color: var(--warn); margin-top: 2px; } +.row-note.owned { color: var(--accent-ink); } +.row-error { font-size: 14px; color: var(--danger); } +.row-empty { color: var(--faint); } +.inline-actions { display: flex; gap: 6px; margin-top: 4px; } +.inline-actions button, .edit-actions button { padding: 2px 10px; border-radius: 8px; background: var(--hover); font-size: 13px; font-weight: 550; } +.inline-actions button:hover, .edit-actions button:hover { background: var(--line); } +.row-tools { position: absolute; right: 4px; top: 4px; display: flex; opacity: 0; transition: opacity .12s; } +.unit:hover .row-tools, .unit:focus-within .row-tools { opacity: 1; } +@media (hover: none) { .row-tools { opacity: 1; } } +.row-tools .icon-btn { width: 30px; height: 30px; } +.edit { display: block; width: 100%; font-size: 16px; line-height: 1.6; resize: vertical; min-height: 64px; } +.edit-actions { display: flex; gap: 6px; margin-top: 6px; } +.skel { height: 14px; border-radius: 7px; margin: 7px 0; background: linear-gradient(90deg, var(--line) 25%, var(--line-strong) 50%, var(--line) 75%); background-size: 200% 100%; animation: shimmer 1.3s linear infinite; } +.skel.short { width: 55%; } +@keyframes shimmer { to { background-position: -200% 0; } } +.c-foot { display: flex; align-items: center; gap: 8px; padding: 8px 12px 12px; flex: none; } +.mic { position: relative; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; background: var(--accent); color: #fff; flex: none; transition: transform .1s, background .15s; touch-action: none; } +.mic:hover { filter: brightness(1.1); } .mic:active { transform: scale(.94); } +.mic .bars { display: none; align-items: center; gap: 3px; height: 20px; } +.mic .bars i { width: 3px; height: 6px; border-radius: 2px; background: #fff; animation: bar .9s ease-in-out infinite; } +.mic .bars i:nth-child(2) { animation-delay: .12s; } .mic .bars i:nth-child(3) { animation-delay: .24s; } .mic .bars i:nth-child(4) { animation-delay: .36s; } .mic .bars i:nth-child(5) { animation-delay: .48s; } +@keyframes bar { 50% { height: 18px; } } +.mic.recording { background: #d93a2f; animation: ring 1.8s ease-out infinite; } +.mic.recording .mic-ic { display: none; } .mic.recording .bars { display: flex; } +@keyframes ring { 0% { box-shadow: 0 0 0 0 rgba(217, 58, 47, .45); } 100% { box-shadow: 0 0 0 14px rgba(217, 58, 47, 0); } } +.status { flex: 1; min-width: 0; font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.status.bad { color: var(--danger); } .status.warn { color: var(--warn); } +.c-foot .btn { height: 40px; padding: 0 16px; border-radius: 14px; } +.spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, .4); border-top-color: #fff; animation: spin .8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.composer.wide .c-body { display: grid; grid-template-columns: 1fr 1fr; align-items: start; overflow-y: auto; } +.composer.wide .src { border-right: 1px solid var(--line); min-height: 100%; } +.composer.wide .res { margin: 6px 12px 8px 8px; } +.composer.wide .src textarea { max-height: 320px; min-height: 200px; } +.composer.wide .src-live { max-height: 320px; } +.toast { position: absolute; left: 50%; bottom: 18px; translate: -50% 0; z-index: 6; max-width: calc(100% - 40px); padding: 10px 16px; border-radius: 12px; background: #1d2129; color: #f2f4f8; font-size: 13px; box-shadow: 0 10px 30px rgba(0, 0, 0, .3); } +html[data-theme=dark] .toast { background: #eceef3; color: #15181e; } + +/* menus & dialog */ +.menu { inset: auto; margin: 0; min-width: 232px; padding: 6px; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); color: var(--text); box-shadow: 0 16px 48px rgba(20, 30, 60, .24); } +.menu button { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 8px 10px; border-radius: 9px; text-align: left; font-size: 14px; } +.menu button:hover, .menu button:focus-visible { background: var(--hover); outline: none; } +.menu button[aria-checked=true]::after { content: "✓"; color: var(--accent-ink); font-weight: 700; } +.menu hr { border: 0; border-top: 1px solid var(--line); margin: 5px 4px; } +dialog { width: min(560px, 92vw); padding: 22px; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); color: var(--text); box-shadow: 0 30px 80px rgba(0, 0, 0, .35); } +dialog::backdrop { background: rgba(10, 14, 24, .45); backdrop-filter: blur(3px); } +dialog > header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } +dialog p { color: var(--muted); } +dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 300px; overflow: auto; padding: 12px; border-radius: 10px; background: var(--hover); font: 13px/1.6 var(--mono); } +.dlg-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; } +.hist { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--line); } + +/* rail */ +.rail { display: flex; flex-direction: column; gap: 8px; } +.rail h2 { margin: 14px 0 2px; font-size: 13px; color: var(--faint); font-weight: 600; } +.scenario { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 11px 14px; border-radius: 14px; text-align: left; background: var(--panel); border: 1px solid var(--line); } +.scenario:hover { border-color: var(--accent); } +.scenario span { font-size: 13px; color: var(--muted); } +.field-label { margin-top: 12px; font-size: 13px; color: var(--faint); font-weight: 600; } +.rail-note { margin-top: 10px; font-size: 12px; color: var(--faint); } + +/* settings & notes */ +.settings { display: grid; grid-template-columns: 200px minmax(0, 1fr); border: 1px solid var(--line); border-radius: 20px; overflow: hidden; background: var(--panel); max-width: 920px; } +.settings-nav { display: flex; flex-direction: column; gap: 2px; padding: 14px 10px; background: var(--hover); } +.settings-nav button { padding: 8px 12px; border-radius: 9px; text-align: left; color: var(--muted); } +.settings-nav button.active { background: var(--panel); color: var(--text); box-shadow: 0 1px 2px rgba(0, 0, 0, .08); font-weight: 600; } +.settings-body { padding: 8px 28px 24px; } +.settings-body .row { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 0; border-top: 1px solid var(--line); } +.settings-body .row:first-child { border-top: 0; } +.settings-body label, .settings-body b { font-weight: 600; } +.settings-body .row p { font-size: 13px; color: var(--muted); } +.settings-body input:not([type=checkbox]), .settings-body select { min-width: 200px; max-width: 260px; } +.chips-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 16px 0; } +.terms { padding-top: 12px; border-top: 1px solid var(--line); } +.term-form { display: flex; gap: 10px; align-items: end; margin: 10px 0; } +.term-form label { display: grid; gap: 4px; flex: 1; font-size: 13px; color: var(--muted); } +.term-form input { min-width: 0 !important; max-width: none !important; width: 100%; } +.term-row { display: flex; gap: 10px; align-items: center; padding: 9px 0; border-top: 1px solid var(--line); } +.term-row button { margin-left: auto; color: var(--danger); padding: 2px 8px; border-radius: 8px; } +.notes-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 14px; } +.card { padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: var(--panel); } +.card h2 { margin-bottom: 8px; } .card p, .card li { color: var(--muted); } +.wide-card { margin-top: 14px; max-width: 920px; } +.wide-card .note { margin-top: 10px; font-size: 13px; color: var(--faint); } +.wide-card ul { padding-left: 18px; display: grid; gap: 6px; margin: 0; } +table { width: 100%; border-collapse: collapse; margin-top: 6px; } +th, td { text-align: left; padding: 10px 8px; border-top: 1px solid var(--line); } +th { color: var(--faint); font-weight: 600; border-top: 0; } + +[data-motion=off] *, [data-motion=off] *::before, [data-motion=off] *::after { animation: none !important; transition: none !important; } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } +@media (max-width: 1100px) { .play-grid { grid-template-columns: 1fr; } .rail { display: grid; grid-template-columns: repeat(2, 1fr); } .rail > .btn, .rail > h2, .rail > .field-label, .rail > select, .rail > .rail-note { grid-column: 1 / -1; } .notes-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 720px) { + main { padding-top: 18px; } h1 { font-size: 24px; } + .top { gap: 8px; padding: 0 12px; } .tag, .brand span:nth-child(2) { display: none; } nav { margin-left: 0; } .tab { padding: 6px 8px; } + .seg { display: none; } + .desktop { height: 780px; } .app-window { margin: 12px 8px 14px; } .dock { padding: 0 10px 12px; } .chat { padding: 14px; } .bubble { max-width: 92%; } + .composer { width: calc(100% - 4px); max-height: 560px; } .composer.wide { width: calc(100% - 4px); } .composer.wide .c-body { display: flex; } .composer.wide .src { border-right: 0; } + .c-head { gap: 4px; padding-inline: 8px; } .privacy.ok .lbl { display: none; } .privacy.ok { padding: 0 8px; } .pane { padding-inline: 14px; } .c-foot { gap: 6px; padding: 8px 10px 12px; } + .status { display: none; } + .settings { grid-template-columns: 1fr; } .settings-nav { flex-direction: row; overflow-x: auto; } .settings-body { padding: 4px 16px 20px; } .settings-body .row { flex-wrap: wrap; } .settings-body input:not([type=checkbox]), .settings-body select { max-width: none; width: 100%; } + .notes-grid, .rail { grid-template-columns: 1fr; } .term-form { flex-wrap: wrap; } +} diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/translation-live.png b/docs/superpowers/prototypes/livelingo-ux-v3/translation-live.png new file mode 100644 index 0000000..a51b2f0 Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux-v3/translation-live.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux-v3/windows.png b/docs/superpowers/prototypes/livelingo-ux-v3/windows.png new file mode 100644 index 0000000..ec2b7bc Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux-v3/windows.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux/README.md b/docs/superpowers/prototypes/livelingo-ux/README.md new file mode 100644 index 0000000..b131df6 --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux/README.md @@ -0,0 +1,77 @@ +# LiveLingo 产品原型 v2 + +直接用浏览器打开 [index.html](./index.html),无需启动服务器。这是完整交互设计演示,不是产品实现;不访问麦克风、不运行模型、不连接云端、不写入真实应用。 + +## 如何体验 + +1. 右侧选择“和 Agent 讨论”:示例开始听写,点麦克风结束。逐句编辑后可继续录音;Enter 回填示例输入框,不发送。 +2. 选择“说中文,写英文”:录音未结束时译文已出现。展开原文对照不会改变英文输出目标。 +3. 选择“翻译技术文档”:预设 Markdown 使用整段路径,保留代码与占位符。也可自行输入或粘贴文本。 +4. 选择“会议转录”:展开长文工作台,体验暂停、续录、文件选择和导出。 +5. 展开“不顺利的情况”:体验整理拒绝、处理未完成、翻译失败、目标失效、没有输入框、无声音、缺权限和模型准备。 + +**自由输入的边界**:只有预设语料有模拟整理与译文。任意其他文本会明确显示“原型没有运行模型”,不会伪造翻译成功;可手动编辑译文,或明确选择原文回填。 + +## 本轮设计取舍 + +| 主题 | v2 决策 | +|---|---| +| 核心形态 | 短输入使用轻浮窗;同一浮窗可展开为长文工作台,共用一份草稿 | +| 全局呼出 | 继承现有配置;现有默认 Ctrl+Alt+T。原型不注册 OS 热键 | +| 录音 | 点击免按住 / 按住按钮 / 暂停续录均有明确入口;双击热键不是唯一入口 | +| 回填 | 显式选择整理稿、译文或识别原文。原目标失效必须重新绑定,不猜新目标 | +| 发送 | 回填与发送分离,不自动发送,不承诺跨应用的统一撤销行为 | +| 快捷键 | 默认 Enter 回填,可选现有 Ctrl+Enter;Shift+Enter 换行,IME 确认优先,Tab 保留焦点导航 | +| 翻译来源 | 二遍识别原文直接进入翻译;不强制等待整理,不把整理稿冒充翻译来源 | +| 处理路径 | 输入中使用完整句路径;粘贴已完成文本使用整段路径。输入来源决定,用户可显式切换 | +| 人工修订 | 逐句编辑与“这一句用原文”;人工工作稿不被迟到结果覆盖,识别记录只读 | +| 未完成或失败 | 不回填、复制或导出半截结果。明确选原文才使用原文,不静默降级 | +| 隐私 | 默认本地;云端翻译、云端整理、录音留存独立授权。原型没有凭据输入与网络调用 | +| 草稿 | 原型内存保存最近 5 份,刷新清空;正式产品默认不落盘,持久保存另行授权 | + +未采纳上轮审查中的“零毫秒呼出”“自动写入后 Esc 撤销”“双击热键消除所有延迟”等未经验证承诺。保真门禁只提示风险,不能证明语义完全正确;原型不提供一键采纳已被判定改错单位的候选。 + +## 能力覆盖 + +- 听写、实时尾部、二遍原文、整理、逐句编辑、追加、暂停续录。 +- 中文 ↔ 英文实时翻译、文本输入、完成文本整段翻译。 +- 文件选择、转录时间轴、TXT / Markdown / SRT 导出。SRT 时间是示例,不是真实音频对齐。 +- 托盘、最近草稿、失效目标重新绑定、无目标复制。 +- 六个设置页:通用快捷键、语音转录、整理翻译术语、隐私云端、性能模型、关于诊断。 +- macOS / Windows 键位展示、浅色 / 深色、减少动画、窄视口、键盘焦点与中文输入法保护。 + +**产品验证尚未完成**:语音与翻译联合资源调度、M2/16 GB 目标机表现、Windows 实机、全局按键手势、跨应用光标捕获与回填、长录音资源上限、真实文件任务及音频对齐。系统声音、说话人分离、TTS 没有已验证设计,不制造可用入口。 + +## 设计图 + +- [整体交互](./overview.png) +- [录音中的实时译文](./translation-live.png) +- [技术文档工作台](./long-document.png) +- [设置与术语](./settings.png) + +## 验证 + +原型行为测试不依赖第三方包: + +```bash +node --test prototype.test.cjs +``` + +浏览器检查使用 Playwright(需在测试环境中安装 Playwright 与 Chromium,不向产品添加依赖): + +```bash +node browser.test.cjs +``` + +如果 Playwright 在独立目录安装,可设置 `NODE_PATH` 为该目录的 `node_modules` 后执行。浏览器检查会重新生成以上设计截图。 + +已验证 **18 个草稿行为测试 + 20 个 Chromium 浏览器场景**,包括录音中可见译文、人工编辑不覆盖、单句回原文、输入法、回填键、目标失效、无目标复制、处理未完成、草稿恢复、转录续录、托盘、六个设置页、独立云端授权和窄视口布局。浏览器测试确认零脚本异常、零 HTTP/HTTPS 请求;窄视口浮窗不遮住原输入框且不裁切顶部。 + +复核发现并修复的边界问题有回归测试:修改原文不得恢复失效目标;恢复草稿同时恢复翻译方向;开始录音不得丢弃尚未触发的文本任务。任务恢复、快捷键回填和原输入框遮挡问题也已实际验证。 + +这些结果证明原型可操作,不证明真实识别质量、模型速度或“世界最佳体验”。下一步用真人任务评估首次完成率、误回填率、修订成本和长口述恢复,再决定产品实现。 + +## 依据 + +- [语音设计与证据边界](../../specs/2026-10-29-voice-dictation-design.md) +- [翻译语料评测与双路径结论](../../../../poc/text-translation/REPORT.md) diff --git a/docs/superpowers/prototypes/livelingo-ux/app.js b/docs/superpowers/prototypes/livelingo-ux/app.js new file mode 100644 index 0000000..81ae5f4 --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux/app.js @@ -0,0 +1,582 @@ +(function () { + "use strict"; + + // This state model belongs only to the interactive prototype; it does not run the product pipeline. + class Draft { + constructor(target) { this.epoch = 0; this.reset(target); } + reset(target) { + this.epoch++; + this.translationRevision = 0; + this.units = []; + this.output = "clean"; + this.route = "units"; + this.target = target; + this.targetLost = false; + this.recording = false; + this.snapshot = null; + } + ticket(index) { return { epoch: this.epoch, index, translationRevision: this.translationRevision }; } + append(raw) { + if (this.snapshot) throw new Error("Start a new draft before adding text."); + this.units.push({ raw, clean: null, translation: null, cleanOwned: false, translationOwned: false, cleanError: "", translationError: "", warning: "" }); + return this.ticket(this.units.length - 1); + } + replaceInput(raw, route) { + const output = this.output; + const targetLost = this.targetLost; + this.reset(this.target); + this.output = output; + this.targetLost = targetLost; + this.route = route; + return raw.trim() ? this.append(raw) : null; + } + complete(ticket, kind, text, warning = "") { + const unit = this.units[ticket.index]; + if (this.snapshot || ticket.epoch !== this.epoch || !unit || unit[kind + "Owned"]) return false; + if (kind === "translation" && ticket.translationRevision !== this.translationRevision) return false; + unit[kind] = warning ? unit.raw : text; + unit[kind + "Error"] = ""; + if (warning) unit.warning = warning; + return true; + } + fail(ticket, kind, reason) { + if (!this.complete(ticket, kind, null)) return false; + this.units[ticket.index][kind + "Error"] = reason; + return true; + } + edit(index, kind, text) { + if (this.snapshot) throw new Error("Submitted text is frozen."); + const unit = this.units[index]; + unit[kind] = text; + unit[kind + "Owned"] = true; + unit[kind + "Error"] = ""; + } + useRaw(index) { this.edit(index, "clean", this.units[index].raw); } + invalidateTranslations() { + if (this.snapshot) return; + this.translationRevision++; + this.units.forEach(unit => { unit.translation = null; unit.translationOwned = false; unit.translationError = ""; }); + } + retarget(target) { this.target = target; this.targetLost = false; } + text() { + return this.units.map(unit => unit[this.output] ?? "").join(this.route === "whole" ? "" : "\n"); + } + contentReadiness() { + if (!this.units.length) return { ok: false, reason: "empty" }; + if (this.output !== "raw") { + if (this.units.some(unit => unit[this.output + "Error"] || unit[this.output + "Owned"] && !unit[this.output]?.trim())) return { ok: false, reason: "unavailable" }; + if (this.units.some(unit => unit[this.output] === null)) return { ok: false, reason: "processing" }; + } + if (!this.text().trim()) return { ok: false, reason: "empty" }; + return { ok: true }; + } + readiness() { + if (this.targetLost) return { ok: false, reason: "target-lost" }; + if (this.recording) return { ok: false, reason: "recording" }; + return this.contentReadiness(); + } + requestCommit() { + if (this.snapshot) return this.snapshot; + const ready = this.readiness(); + if (!ready.ok) return ready; + this.snapshot = Object.freeze({ ok: true, text: this.text(), action: this.target === null ? "copy" : "insert", target: this.target }); + return this.snapshot; + } + } + if (typeof module !== "undefined") module.exports = { Draft }; + if (typeof document === "undefined") return; + + const $ = id => document.getElementById(id); + const escape = text => String(text ?? "").replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); + const SAMPLES = { + "zh-en": [ + { raw: "嗯,这个 PR 先不要合并,等 CI 跑完再说。", clean: "这个 PR 先不要合并,等 CI 跑完再说。", translation: "Do not merge this PR yet. Wait for CI to finish." }, + { raw: "Redis 的超时设置为 30 秒,不是 30 分钟。", clean: "Redis 的超时设置为 30 秒,不是 30 分钟。", translation: "Set the Redis timeout to 30 seconds, not 30 minutes." }, + { raw: "就是,保持 `CacheService` 的 API 不变,只修改错误处理。", clean: "保持 `CacheService` 的 API 不变,只修改错误处理。", translation: "Keep the API of `CacheService` unchanged. Only modify error handling." } + ], + "en-zh": [ + { raw: "Please do not merge this PR until CI finishes.", clean: "Please do not merge this PR until CI finishes.", translation: "在 CI 跑完前,请不要合并这个 PR。" }, + { raw: "Set the Redis timeout to 30 seconds, not 30 minutes.", clean: "Set the Redis timeout to 30 seconds, not 30 minutes.", translation: "将 Redis 的超时设为 30 秒,不是 30 分钟。" }, + { raw: "Keep the API of `CacheService` unchanged.", clean: "Keep the API of `CacheService` unchanged.", translation: "保持 `CacheService` 的 API 不变。" } + ] + }; + const DOCUMENT = { + raw: '# 部署约束\n\n- 不要更改 `CacheService` 的 API。\n- 超时为 30 秒,日志不包含凭据。\n\n```json\n{\n "timeout": 30,\n "api_key": "${API_KEY}"\n}\n```\n', + clean: '# 部署约束\n\n- 不要更改 `CacheService` 的 API。\n- 超时为 30 秒,日志不包含凭据。\n\n```json\n{\n "timeout": 30,\n "api_key": "${API_KEY}"\n}\n```\n', + translation: '# Deployment constraints\n\n- Do not change the API of `CacheService`.\n- The timeout is 30 seconds. Logs must not contain credentials.\n\n```json\n{\n "timeout": 30,\n "api_key": "${API_KEY}"\n}\n```\n' + }; + const STATES = [ + ["01", "轻量待命", "捕获原输入目标,打字或点麦克风开始。", "pair", "回填目标始终可见"], + ["02", "连续听写", "灰色实时尾部与已稳定原文分区。", "pair", "按住 / 免按住都有入口"], + ["03", "真正的边说边译", "录音尚未结束,已完成句的译文已出现。", "translate", "开启翻译即选择译文输出"], + ["04", "整段技术文本", "完成文本按整段处理,不拆坏代码和版式。", "document", "格式检查不是正确性保证"], + ["05", "长录音转录", "展开时间轴,暂停、续录、选择文件与导出。", "meeting", "文件流程仅示例演示"], + ["06", "整理被拒绝", "候选改动单位,工作稿保留原文,可自行编辑。", "gate", "不把危险候选当默认答案"], + ["07", "正在处理最后一句", "未完成的选定版本不可回填;可明确改用原文。", "slow", "不提交半截译文"], + ["08", "翻译失败", "原文和整理稿仍在,失败原因及出口可见。", "failure", "不静默替换成成功形状的原文"], + ["09", "原目标失效", "草稿保留,明确选择新目标后才能回填。", "lost", "不写进新的前台窗口"], + ["10", "没有输入目标", "独立草稿 / 翻译阅读场景,主动作变为复制。", "no-target", "不伪装成可注入输入框"], + ["11", "无声音 / 设备断开", "停下采集并保留已有内容,给出检查入口。", "no-audio", "不无声地结束会话"], + ["12", "缺权限 / 模型未就绪", "能力分别提示,纯文本编辑仍然可用。", "permission", "不笼统承诺无需重启"] + ]; + const draft = new Draft("agent"); + let mode = "voice", expanded = false, composing = false, sourceTimer = null; + let recordTimer = null, recordSession = 0, recordIndex = 0, liveText = "", paused = false; + let editor = null, history = [], announcementTimer = null, slow = false, fixtureWarning = false; + const terms = [{ source: "缓存穿透", target: "cache penetration" }, { source: "CacheService", target: "CacheService" }]; + const reasons = { "recording": "请先结束录音,再确认回填。", "empty": "草稿为空。", "processing": "选定版本仍在处理,不会回填半截结果。", "unavailable": "选定版本不可用。请编辑、重新请求,或明确选择原文。", "target-lost": "原输入目标已失效。请重新绑定,或复制草稿。" }; + + function announce(text) { + $("announcement").textContent = text; + $("announcement").hidden = false; + clearTimeout(announcementTimer); + announcementTimer = setTimeout(() => { $("announcement").hidden = true; }, 5000); + } + function showPage(page) { + document.querySelectorAll(".page").forEach(panel => { panel.hidden = panel.id !== page; }); + document.querySelectorAll("[data-page]").forEach(button => { + button.classList.toggle("active", button.dataset.page === page); + if (button.dataset.page === page) button.setAttribute("aria-current", "page"); else button.removeAttribute("aria-current"); + }); + } + function modal(title, content) { + $("modal-title").textContent = title; + $("modal-body").innerHTML = content; + if (!$("modal").open) $("modal").showModal(); + } + function saveHistory() { + if (!draft.units.length) return; + const entry = { time: new Date().toLocaleTimeString(), units: structuredClone(draft.units), output: draft.output, route: draft.route, direction: $("direction").value }; + history = [entry, ...history].slice(0, 5); + } + function stopRecording() { + clearInterval(recordTimer); + recordSession++; + const pendingTail = liveText; + draft.recording = false; + paused = false; + liveText = ""; + if (pendingTail.trim()) { + const ticket = draft.append(pendingTail); + draft.complete(ticket, "clean", pendingTail); + draft.units[ticket.index].warning = "仅有实时尾部,示例未提供二遍结果;已显式保留原文。"; + draft.fail(ticket, "translation", "尾部不在预设翻译语料中。请补充完整句,或编辑译文。"); + } + render(); + } + function fresh() { + clearInterval(recordTimer); + clearTimeout(sourceTimer); + sourceTimer = null; + recordSession++; + saveHistory(); + draft.reset(draft.target); + draft.output = $("translate-on").checked ? "translation" : $("cleanup").value === "off" ? "raw" : "clean"; + liveText = ""; paused = false; slow = false; editor = null; fixtureWarning = false; + $("source-input").value = ""; + $("route").value = "units"; + $("composer").hidden = false; + $("toast").hidden = true; + render(); + } + function fixtureFor(raw) { + return [...SAMPLES["zh-en"], ...SAMPLES["en-zh"], DOCUMENT].find(item => item.raw === raw); + } + function schedule(ticket, kind) { + const raw = draft.units[ticket.index]?.raw; + const fixture = fixtureFor(raw); + const targetEnglish = $("direction").value === "zh-en"; + let text = kind === "clean" ? fixture?.clean : fixture?.translation; + if (kind === "translation" && fixture === DOCUMENT && !targetEnglish) text = null; + const fixtureEnglish = fixture && SAMPLES["en-zh"].includes(fixture); + if (kind === "translation" && fixture && fixture !== DOCUMENT && fixtureEnglish === targetEnglish) { + text = null; + } + if (slow) return; + setTimeout(() => { + if (text == null) { + draft.fail(ticket, kind, "此文本不在预设语料中;原型没有运行模型。可手动编辑或改用原文。"); + } else { + const warning = kind === "clean" && fixtureWarning && raw.includes("30 秒") ? "候选把 30 秒改成 30 分钟,已拒绝并保留原文。" : ""; + draft.complete(ticket, kind, text, warning); + } + render(); + }, kind === "clean" ? 380 : 650); + } + function process(ticket) { + if (!ticket) return; + if ($("cleanup").value === "off") draft.useRaw(ticket.index); else schedule(ticket, "clean"); + if ($("translate-on").checked) schedule(ticket, "translation"); + } + function flushSource() { + if (sourceTimer === null) return; + clearTimeout(sourceTimer); + sourceTimer = null; + process(draft.units.length ? draft.ticket(draft.units.length - 1) : null); + } + function loadSamples() { + SAMPLES[$("direction").value].forEach(fixture => process(draft.append(fixture.raw))); + render(); + } + function startRecording(hold = false) { + if (draft.snapshot) fresh(); + if (draft.recording) return; + flushSource(); + draft.recording = true; + paused = false; + $("composer").hidden = false; + const run = ++recordSession; + const samples = SAMPLES[$("direction").value]; + let position = 0; + let delayTicks = 0; + $("session-status").dataset.gesture = hold ? "按住" : "免按住"; + recordTimer = setInterval(() => { + if (run !== recordSession || paused) return; + if (delayTicks) { delayTicks--; return; } + const sample = samples[recordIndex % samples.length]; + position = Math.min(position + 3, sample.raw.length); + liveText = sample.raw.slice(0, position); + if (position === sample.raw.length) { + process(draft.append(sample.raw)); + recordIndex++; + position = 0; + liveText = ""; + delayTicks = 10; + } + render(); + }, 100); + render(); + } + function updateText(route = draft.route) { + if (composing) return; + clearTimeout(sourceTimer); + clearInterval(recordTimer); + recordSession++; + const source = $("source-input").value; + const ticket = draft.replaceInput(source, route); + editor = null; + $("route").value = route; + render(); + sourceTimer = setTimeout(() => { sourceTimer = null; process(ticket); render(); }, 450); + } + function setOutput(output) { + if (draft.snapshot) { announce("已回填的版本已冻结;开始新草稿后可继续修改。"); return; } + draft.output = output; + if (output === "translation") { + $("translate-on").checked = true; + draft.units.forEach((unit, index) => { if (unit.translation === null && !unit.translationError) schedule(draft.ticket(index), "translation"); }); + } + editor = null; + render(); + } + function currentValue(unit) { return unit[draft.output]; } + function sentenceMarkup(unit, index) { + const kind = draft.output; + const text = currentValue(unit); + const pending = kind !== "raw" && text === null && !unit[kind + "Error"]; + const error = unit[kind + "Error"]; + const label = unit[kind + "Owned"] ? "已手动修改" : error ? "结果不可用" : pending ? "处理中" : unit.warning && kind === "clean" ? "已用原文" : kind === "raw" ? "识别原文" : "已完成"; + const time = mode === "transcript" ? `${String(Math.floor(index * 3 / 60)).padStart(2, "0")}:${String(index * 3 % 60).padStart(2, "0")} · 示例时间` : `#${index + 1}`; + const editing = editor === index; + return `
+
${time}${label}
+ ${editing ? `
` : `

${escape(error || (pending ? "正在完成当前句…" : text))}

`} + ${unit.warning && kind === "clean" ? `

${escape(unit.warning)}

` : ""} + ${kind === "translation" ? `

翻译来源 · 识别原文:${escape(unit.raw)}

` : ""} + ${kind !== "raw" ? `
查看识别原文

${escape(unit.raw)}

` : ""} + ${!draft.snapshot && !editing ? `
${kind !== "raw" ? `` : ""}${kind === "clean" ? `` : ""}${error ? `` : ""}
` : ""} +
`; + } + function peekCommit() { + // Rendering must not freeze a draft; only an explicit submission may create its snapshot. + if (draft.snapshot) return { ok: false, reason: "submitted" }; + return draft.readiness(); + } + function render() { + $("empty-draft").hidden = draft.units.length > 0 || draft.recording; + if (!document.activeElement?.classList.contains("sentence-editor")) { + $("sentences").innerHTML = draft.units.map(sentenceMarkup).join(""); + } + $("live-tail").hidden = !liveText; + $("live-tail").textContent = "实时识别 · " + liveText; + $("source-panel").hidden = !(mode === "text" || expanded); + $("transcript-tools").hidden = mode !== "transcript"; + $("composer").classList.toggle("expanded", expanded); + $("record").classList.toggle("recording", draft.recording); + $("record").setAttribute("aria-label", draft.recording ? "结束录音" : "开始免按住听写"); + $("pause").hidden = !draft.recording; + $("pause").textContent = paused ? "继续" : "暂停"; + $("hold-record").disabled = draft.recording && $("session-status").dataset.gesture !== "按住"; + const dest = draft.target === null ? "无输入目标 · 复制" : draft.target === "notes" ? "回填 · 笔记" : "回填 · Agent"; + $("destination").textContent = draft.targetLost ? "原目标已失效" : dest; + $("target-title").textContent = draft.target === "notes" ? "Notes / 新目标(明确选择)" : "VS Code / Agent"; + $("route-label").textContent = draft.route === "whole" ? "完成文本 · 整段路径" : "输入中 · 完整句路径"; + $("source-input").readOnly = mode !== "text" || draft.snapshot !== null || draft.recording; + document.querySelector('label[for="source-input"]').textContent = mode === "text" ? "原文 · 可编辑" : "识别原文 · 只读记录"; + if (mode !== "text") $("source-input").value = draft.units.map(unit => unit.raw).join("\n"); + const cloud = []; + if ($("cloud-translation").checked) cloud.push("翻译"); + if ($("cloud-cleanup").checked) cloud.push("整理"); + $("privacy-state").className = "privacy " + (cloud.length ? "cloud" : "local"); + $("privacy-state").textContent = cloud.length ? `${cloud.join(" / ")}云端 · 演示` : "全部本地 · 演示"; + document.querySelectorAll("[data-output]").forEach(button => { + button.classList.toggle("active", button.dataset.output === draft.output); + button.setAttribute("aria-pressed", String(button.dataset.output === draft.output)); + }); + const ready = peekCommit(); + $("commit").disabled = !ready.ok; + $("commit").textContent = draft.snapshot ? "已完成回填" : draft.target === null ? "复制当前版本" : `写入${draft.target === "notes" ? "笔记" : " Agent"}`; + const key = document.createElement("kbd"); + key.className = "commit-key"; + key.textContent = $("commit-binding").value === "ctrl-enter" ? ($("platform").value === "mac" ? "⌃↵" : "Ctrl↵") : "↵"; + $("commit").append(key); + const kindLabel = { clean: "整理稿", raw: "识别原文", translation: $("direction").value === "zh-en" ? "英文译文" : "中文译文" }[draft.output]; + $("output-hint").textContent = `回填版本:${kindLabel}。原文对照不改变输出目标。`; + $("session-status").textContent = draft.recording ? `${paused ? "已暂停" : "正在录音"} · ${$("session-status").dataset.gesture} · 演示` : draft.snapshot ? "已冻结提交快照" : ready.reason === "processing" && !ready.ok ? "最后一段处理中 · 不会回填半截结果" : ready.reason === "unavailable" && !ready.ok ? "结果不可用 · 原文仍在" : `${draft.units.length} 个单元 · 可继续追加口述`; + const notices = []; + if (draft.targetLost) notices.push('原输入框已失效,草稿未丢失。'); + if (draft.output === "translation" && draft.units.some(unit => unit.translationError)) notices.push("译文不可用,不会自动改成原文输出。你可以编辑译文,或明确选择识别原文。"); + if (cloud.length) notices.push(`${escape(cloud.join(" / "))}云端已单独授权。此原型没有网络调用;正式产品须展示真实服务商与发送范围。`); + $("warning").hidden = notices.length === 0; + $("warning").innerHTML = notices.join("
"); + } + function changeMode(next) { + mode = next; + document.querySelectorAll("[data-mode]").forEach(button => { button.classList.toggle("active", button.dataset.mode === mode); button.setAttribute("aria-pressed", String(button.dataset.mode === mode)); }); + if (mode === "text") $("source-input").value = draft.units.map(unit => unit.raw).join("\n"); + render(); + } + function scenario(name) { + showPage("experience"); + fresh(); + draft.retarget(name === "no-target" ? null : "agent"); + changeMode(["document"].includes(name) ? "text" : name === "meeting" ? "transcript" : "voice"); + expanded = name === "meeting" || name === "document"; + if (name === "translate" || name === "document" || name === "failure" || name === "slow") { + $("translate-on").checked = true; + draft.output = "translation"; + } else { + $("translate-on").checked = false; + draft.output = $("cleanup").value === "off" ? "raw" : "clean"; + } + if (name === "pair" || name === "translate") startRecording(); + else if (name === "document") { + $("direction").value = "zh-en"; + $("source-input").value = DOCUMENT.raw; + updateText("whole"); + } else if (name === "meeting" || name === "lost" || name === "no-target" || name === "gate") { + fixtureWarning = name === "gate"; + loadSamples(); + if (name === "lost") draft.targetLost = true; + } else if (name === "slow") { slow = true; loadSamples(); } + else if (name === "failure") { + loadSamples(); + draft.invalidateTranslations(); + draft.units.forEach((_, index) => draft.fail(draft.ticket(index), "translation", "示例连接超时。原文和整理稿未丢失。")); + } else if (name === "no-audio") { + loadSamples(); + modal("没有检测到声音", '

已停止录音,已有草稿保留。请检查输入设备、系统音量及权限,再继续录音。

'); + } else if (name === "permission") { + modal("权限分开申请,不阻塞打字", '

麦克风用于听写;辅助功能用于回填;macOS 输入监控权限视热键实现决定。拒绝麦克风仍可打字,拒绝回填权限仍可复制。

授权后重新检测状态;不能承诺所有运行环境都无需重启。

'); + } else if (name === "download") { + modal("模型尚未就绪", '

识别 / 整理 / 翻译按能力显示准备状态。纯文本编辑不被阻塞,模型下载必须另行授权。

下载失败、校验失败、磁盘不足都应明确显示;这里不执行真实下载。

'); + } + render(); + } + function submit() { + if (draft.snapshot) return; + if (editor !== null) { announce("请先保存或取消当前句的编辑。"); return; } + const result = draft.requestCommit(); + if (!result.ok) { announce(reasons[result.reason]); return; } + saveHistory(); + if (result.action === "insert") { + const field = $("target-text"); + field.value += (field.value ? "\n" : "") + result.text; + $("composer").hidden = true; + $("toast").textContent = "已模拟回填到捕获的输入框 · 未发送。真实跨应用回填与撤销仍需验证。"; + $("toast").hidden = false; + field.focus(); + } else { + modal("当前版本 · 可复制", `

没有输入框,不尝试回填到其他窗口。以下为模拟复制结果;点击下方按钮可使用浏览器剪贴板。

${escape(result.text)}
`); + } + render(); + } + async function copy() { + if (draft.recording) { announce(reasons.recording); return; } + if (editor !== null) { announce("请先保存或取消当前句的编辑。"); return; } + const ready = draft.contentReadiness(); + if (!ready.ok) { announce(reasons[ready.reason]); return; } + const text = draft.text(); + try { + await navigator.clipboard.writeText(text); + announce("当前选定版本已复制。"); + } catch (error) { + modal("浏览器未允许剪贴板写入", `

${escape(error.message)}。可以选择下面的文字手动复制;草稿没有丢失。

${escape(text)}
`); + } + } + function download(format) { + const ready = draft.recording ? { ok: false } : draft.contentReadiness(); + if (!ready.ok && !draft.snapshot) { announce("选定版本未完成,不能导出半截结果。请改用原文或完成编辑。"); return; } + const text = draft.snapshot?.text ?? draft.text(); + const stamp = ms => `00:${String(Math.floor(ms / 60000)).padStart(2, "0")}:${String(Math.floor(ms / 1000) % 60).padStart(2, "0")},${String(ms % 1000).padStart(3, "0")}`; + const contents = format === "srt" ? draft.units.map((unit, index) => `${index + 1}\n${stamp(index * 3000)} --> ${stamp((index + 1) * 3000)}\n${unit[draft.output] ?? ""}\n`).join("\n") : format === "md" ? "# LiveLingo 草稿(原型示例)\n\n" + text : text; + const url = URL.createObjectURL(new Blob([contents], { type: "text/plain;charset=utf-8" })); + const link = document.createElement("a"); + link.href = url; link.download = `livelingo-prototype.${format}`; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + announce(format === "srt" ? "已导出 SRT;时间来自演示单元,不是真实音频对齐。" : "已导出当前版本。"); + } + function settingsPage(name) { + showPage("settings"); + document.querySelectorAll("[data-settings-panel]").forEach(panel => { panel.hidden = panel.dataset.settingsPanel !== name; }); + document.querySelectorAll("[data-settings]").forEach(button => button.classList.toggle("active", button.dataset.settings === name)); + } + function renderTerms() { + $("terms-list").innerHTML = terms.map((term, index) => `
${escape(term.source)}→${escape(term.target)}
`).join(""); + } + function unitAction(action, index) { + const unit = draft.units[index]; + if (!unit || draft.snapshot) return; + if (action === "raw") { draft.useRaw(index); render(); } + if (action === "edit") { + editor = index; render(); + $(`sentence-edit-${index}`).focus(); + } + if (action === "save") { + const input = $(`sentence-edit-${index}`); + draft.edit(index, draft.output, input.value); + input.blur(); editor = null; render(); + announce("已保存人工修改,后续模型结果不会覆盖这一版。"); + } + if (action === "cancel") { $(`sentence-edit-${index}`).blur(); editor = null; render(); } + if (action === "retry") { + slow = false; unit[draft.output + "Error"] = ""; unit[draft.output] = null; + schedule(draft.ticket(index), draft.output); render(); + } + } + document.addEventListener("click", event => { + const button = event.target.closest("button"); + if (!button) return; + if (button.dataset.page) showPage(button.dataset.page); + if (button.dataset.scenario) scenario(button.dataset.scenario); + if (button.dataset.mode) changeMode(button.dataset.mode); + if (button.dataset.output) setOutput(button.dataset.output); + if (button.dataset.settings) settingsPage(button.dataset.settings); + if (button.dataset.trayAction) { + $("tray-menu").hidePopover(); + const action = button.dataset.trayAction; + if (action === "summon") $("summon").click(); + if (action === "history") $("history").click(); + if (action === "translation") setOutput(draft.output === "translation" ? "clean" : "translation"); + if (action === "settings") settingsPage("general"); + if (action === "diagnostics") $("show-diagnostics").click(); + if (action === "hide") $("close-composer").click(); + } + if (button.dataset.unitAction) unitAction(button.dataset.unitAction, Number(button.dataset.index)); + if (button.dataset.action === "retarget") modal("明确重新绑定目标", '

选择新目标才允许写入;不会自动采用新的前台窗口。

'); + if (button.dataset.action === "copy-draft") copy(); + if (button.dataset.deleteTerm !== undefined) { terms.splice(Number(button.dataset.deleteTerm), 1); renderTerms(); } + const dialogAction = button.dataset.dialogAction; + if (dialogAction) { + $("modal").close(); + if (dialogAction === "speech-settings") settingsPage("speech"); + if (dialogAction === "models-settings") settingsPage("models"); + if (dialogAction === "text-mode") changeMode("text"); + if (dialogAction === "bind-notes") { draft.retarget("notes"); render(); } + if (dialogAction === "clipboard") copy(); + } + if (button.dataset.export) { $("modal").close(); download(button.dataset.export); } + if (button.dataset.restore !== undefined) { + const entry = history[Number(button.dataset.restore)]; + clearInterval(recordTimer); recordSession++; + draft.reset(draft.target); + draft.units = structuredClone(entry.units); draft.output = entry.output; draft.route = entry.route; + $("direction").value = entry.direction; + $("translate-on").checked = draft.output === "translation"; $("source-input").value = draft.units.map(unit => unit.raw).join("\n"); $("route").value = draft.route; + slow = false; + draft.units.forEach((unit, index) => { + const ticket = draft.ticket(index); + if (unit.clean === null && !unit.cleanError && !unit.cleanOwned) schedule(ticket, "clean"); + if ($("translate-on").checked && unit.translation === null && !unit.translationError && !unit.translationOwned) schedule(ticket, "translation"); + }); + $("modal").close(); $("composer").hidden = false; liveText = ""; render(); + } + }); + $("summon").addEventListener("click", () => { showPage("experience"); $("composer").hidden = false; if (draft.snapshot) fresh(); render(); if (mode === "text") $("source-input").focus(); else $("record").focus(); }); + $("close-composer").addEventListener("click", () => { if (draft.recording) stopRecording(); saveHistory(); $("composer").hidden = true; announce("已隐藏。草稿保留在本次原型内存中,重新呼出即可继续。"); }); + $("record").addEventListener("click", () => { if (draft.recording) stopRecording(); else startRecording(); }); + $("hold-record").addEventListener("pointerdown", event => { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); startRecording(true); }); + $("hold-record").addEventListener("pointerup", () => { if (draft.recording) stopRecording(); }); + $("hold-record").addEventListener("pointercancel", () => { if (draft.recording) stopRecording(); }); + $("hold-record").addEventListener("keydown", event => { if ((event.key === " " || event.key === "Enter") && !event.repeat) { event.preventDefault(); startRecording(true); } }); + $("hold-record").addEventListener("keyup", event => { if (event.key === " " || event.key === "Enter") { event.preventDefault(); stopRecording(); } }); + $("pause").addEventListener("click", () => { paused = !paused; render(); }); + $("reset").addEventListener("click", fresh); + $("expand").addEventListener("click", () => { expanded = !expanded; if (expanded) $("source-input").value = draft.units.map(unit => unit.raw).join("\n"); $("expand").setAttribute("aria-label", expanded ? "收起长文工作台" : "展开长文工作台"); render(); }); + $("source-input").addEventListener("input", () => updateText("units")); + $("source-input").addEventListener("compositionstart", () => { composing = true; }); + $("source-input").addEventListener("compositionend", () => { composing = false; updateText("units"); }); + $("source-input").addEventListener("paste", () => { setTimeout(() => updateText("whole"), 0); }); + $("route").addEventListener("change", () => updateText($("route").value)); + $("text-sample").addEventListener("click", () => { fresh(); changeMode("text"); $("source-input").value = SAMPLES[$("direction").value][0].raw; updateText("units"); }); + $("paste-sample").addEventListener("click", () => { $("direction").value = "zh-en"; $("source-input").value = DOCUMENT.raw; updateText("whole"); }); + $("transcript-demo").addEventListener("click", () => { fresh(); changeMode("transcript"); expanded = true; loadSamples(); }); + $("audio-file").addEventListener("change", () => { const file = $("audio-file").files[0]; $("file-note").textContent = file ? `已选择 ${file.name};不会读取音频内容。请载入会议示例体验流程。` : "未选择文件。"; }); + $("translate-on").addEventListener("change", () => setOutput($("translate-on").checked ? "translation" : $("cleanup").value === "off" ? "raw" : "clean")); + $("direction").addEventListener("change", () => { draft.invalidateTranslations(); if ($("translate-on").checked) draft.units.forEach((_, index) => schedule(draft.ticket(index), "translation")); render(); }); + $("use-raw-all").addEventListener("click", () => { if (draft.snapshot) return; slow = false; draft.units.forEach((_, index) => draft.useRaw(index)); setOutput("raw"); announce("已明确改用原文;晚到的整理结果不会覆盖原文工作稿。"); }); + $("copy").addEventListener("click", copy); + $("commit").addEventListener("click", submit); + $("history").addEventListener("click", () => { + modal("最近草稿 · 仅本次原型内存", history.length ? history.map((entry, index) => `
${escape(entry.time)} · ${escape(entry.units[0]?.raw.slice(0, 45))}
`).join("") : "

暂无最近草稿。关闭或回填后可以在这里找回;刷新页面会清除。

"); + }); + $("export").addEventListener("click", () => modal("导出当前选定版本", '

TXT 与 Markdown 保留当前文本。SRT 只使用演示单元的示例时间,不代表真实对齐;结构化整段不适合字幕格式。

')); + $("modal-close").addEventListener("click", () => $("modal").close()); + $("clear-history").addEventListener("click", () => { history = []; announce("本次原型的最近草稿已清除。"); }); + $("theme").addEventListener("change", () => document.body.classList.toggle("dark", $("theme").value === "dark")); + $("reduce-motion").addEventListener("change", () => document.body.classList.toggle("reduce-motion", $("reduce-motion").checked)); + $("commit-binding").addEventListener("change", render); + $("summon-binding").addEventListener("input", () => { document.querySelectorAll(".summon-key").forEach(key => { key.textContent = $("summon-binding").value || "未设置"; }); }); + $("platform").addEventListener("change", () => { $("desktop-platform").textContent = $("platform").value === "mac" ? "macOS" : "Windows"; document.querySelectorAll(".summon-key").forEach(key => { key.textContent = $("platform").value === "mac" && $("summon-binding").value === "Ctrl+Alt+T" ? "⌃⌥T" : $("summon-binding").value; }); render(); }); + $("cleanup").addEventListener("change", () => { if (!$("translate-on").checked) setOutput($("cleanup").value === "off" ? "raw" : "clean"); }); + ["cloud-translation", "cloud-cleanup"].forEach(id => $(id).addEventListener("change", () => { + if ($(id).checked) { + $(id).checked = false; + const label = id === "cloud-translation" ? "翻译" : "整理"; + modal(`单独授权云端${label}`, `

正式产品会把${label}所需的原文发送至指定服务商,语音上传须另行授权。本次仅演示授权界面,不发送任何请求。

`); + $("allow-cloud").addEventListener("click", () => { $(id).checked = true; $("modal").close(); render(); }); + } else render(); + })); + $("show-diagnostics").addEventListener("click", () => modal("本次原型状态 · 不包含正文", `
${escape(JSON.stringify({ mode, route: draft.route, output: draft.output, recording: draft.recording, target: draft.target, targetLost: draft.targetLost, units: draft.units.length, submitted: !!draft.snapshot }, null, 2))}
`)); + $("term-form").addEventListener("submit", event => { + event.preventDefault(); + const source = $("term-source").value.trim(), target = $("term-target").value.trim(); + if (!source || !target) { announce("原词和目标表达都不能为空。"); return; } + if (terms.some(term => term.source.toLowerCase() === source.toLowerCase())) { announce("此原词已有词条,请先删除旧词条再添加。"); return; } + terms.push({ source, target }); event.target.reset(); renderTerms(); + announce("词条已加入本次原型。示例译文是预设的,不会伪装成模型已应用这个词条。"); + }); + document.addEventListener("keydown", event => { + if (event.isComposing || composing || $("modal").open) return; + const editing = event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement; + if (event.key === "Escape" && !draft.snapshot) { + if (editor !== null) { document.activeElement.blur(); editor = null; render(); return; } + if (draft.recording) stopRecording(); + saveHistory(); $("composer").hidden = true; return; + } + if (event.key === "Enter" && !event.shiftKey && !$("composer").hidden && !draft.recording) { + if (event.target.id === "source-input" || event.target.id === "record" || event.target === document.body) { + const shortcutMatches = $("commit-binding").value === "ctrl-enter" ? event.ctrlKey : !event.ctrlKey && !event.metaKey && !event.altKey; + if (shortcutMatches) { + event.preventDefault(); + flushSource(); + submit(); + } + } + } + if (!editing && event.ctrlKey && event.altKey && event.key.toLowerCase() === "t") { event.preventDefault(); $("summon").click(); } + }); + window.addEventListener("blur", () => { if (draft.recording && $("session-status").dataset.gesture === "按住") stopRecording(); }); + $("state-grid").innerHTML = STATES.map(([number, title, description, name, hint]) => ``).join(""); + renderTerms(); render(); +})(); diff --git a/docs/superpowers/prototypes/livelingo-ux/browser.test.cjs b/docs/superpowers/prototypes/livelingo-ux/browser.test.cjs new file mode 100644 index 0000000..54b1557 --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux/browser.test.cjs @@ -0,0 +1,248 @@ +const { chromium } = require("playwright"); +const assert = require("node:assert/strict"); +const { pathToFileURL } = require("node:url"); +const path = require("node:path"); + +const root = __dirname; +const results = []; +async function check(name, fn) { + await fn(); + results.push(name); + console.log("PASS", name); +} + +(async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ viewport: { width: 1480, height: 1100 } }); + const errors = []; + const network = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("request", request => { if (/^https?:/.test(request.url())) network.push(request.url()); }); + await page.goto(pathToFileURL(path.join(root, "index.html")).href); + await check("offline assets load without script errors", async () => { + assert.equal(await page.locator("#session-status").innerText(), "0 个单元 · 可继续追加口述"); + assert.deepEqual(errors, []); + assert.deepEqual(network, []); + }); + await page.screenshot({ path: path.join(root, "overview.png"), fullPage: true }); + await check("translations appear during recording before stop, with submission blocked", async () => { + await page.locator('.scenario[data-scenario="translate"]').click(); + await page.waitForFunction(() => document.querySelector(".sentence-text")?.textContent.includes("Do not merge this PR")); + assert.match(await page.locator("#session-status").innerText(), /正在录音/); + assert.equal(await page.locator("#commit").isEnabled(), false); + await page.locator("#copy").click(); + assert.match(await page.locator("#announcement").innerText(), /请先结束录音/); + await page.screenshot({ path: path.join(root, "translation-live.png"), fullPage: true }); + await page.locator("#record").click(); + }); + await check("manual sentence edit survives subsequent dictation", async () => { + await page.locator('.scenario[data-scenario="pair"]').click(); + await page.waitForFunction(() => document.querySelector('[data-unit="0"] .sentence-meta')?.textContent.includes("已完成")); + await page.locator("#record").click(); + await page.locator('[data-unit-action="edit"]').first().click(); + await page.locator(".sentence-editor").fill("这是我人工修改的句子。"); + await page.locator('[data-unit-action="save"]').click(); + await page.locator("#record").click(); + await page.waitForFunction(() => document.querySelectorAll(".sentence").length >= 2); + await page.locator("#record").click(); + assert.equal(await page.locator('[data-unit="0"] .sentence-text').innerText(), "这是我人工修改的句子。"); + assert.match(await page.locator('[data-unit="0"] .sentence-meta').innerText(), /已手动修改/); + }); + await check("gate rejection retains raw text and offers sentence-level editing", async () => { + await page.locator(".edge-cases summary").click(); + await page.locator('.edge-cases [data-scenario="gate"]').click(); + await page.waitForFunction(() => document.querySelector(".warning-inline")?.textContent.includes("已拒绝")); + assert.match(await page.locator(".warning-inline").innerText(), /30 秒.*30 分钟/); + assert.equal(await page.locator('[data-unit="1"] .sentence-text').innerText(), "Redis 的超时设置为 30 秒,不是 30 分钟。"); + await page.locator('[data-unit="1"] [data-unit-action="raw"]').click(); + }); + await check("paste chooses whole-text route and preserves literal code/indentation in the fixture", async () => { + await page.locator('.scenario[data-scenario="document"]').click(); + await page.waitForFunction(() => document.querySelector(".sentence-text")?.textContent.startsWith("# Deployment constraints")); + assert.equal(await page.locator("#route").inputValue(), "whole"); + const translated = await page.locator(".sentence-text").innerText(); + assert.match(translated, /"api_key": "\$\{API_KEY\}"/); + assert.match(translated, /\n "timeout": 30,/); + await page.locator("#announcement").evaluate(el => { el.hidden = true; }); + await page.screenshot({ path: path.join(root, "long-document.png"), fullPage: true }); + await page.locator("#source-input").evaluate(el => { + el.value = "# 自定义完成文本\n\n`keep_me`"; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("paste", { bubbles: true })); + }); + await page.waitForFunction(() => document.querySelector("#route").value === "whole"); + await page.waitForFunction(() => document.querySelector(".sentence-text")?.textContent.includes("原型没有运行模型")); + assert.equal(await page.locator("#commit").isEnabled(), false); + await page.locator('[data-output="raw"]').click(); + assert.equal(await page.locator(".sentence-text").innerText(), "# 自定义完成文本\n\n`keep_me`"); + }); + await check("IME confirmation cannot submit; Shift+Enter remains a newline", async () => { + await page.locator("#source-input").focus(); + const before = await page.locator("#target-text").inputValue(); + await page.locator("#source-input").evaluate(el => { + el.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true })); + }); + assert.equal(await page.locator("#target-text").inputValue(), before); + await page.locator("#source-input").press("End"); + await page.locator("#source-input").press("Shift+Enter"); + assert.match(await page.locator("#source-input").inputValue(), /\n$/); + }); + await check("Tab moves keyboard focus rather than changing output version", async () => { + const output = await page.locator('[data-output].active').getAttribute("data-output"); + await page.locator("#translate-on").focus(); + await page.keyboard.press("Tab"); + assert.equal(await page.locator("#direction").evaluate(el => el === document.activeElement), true); + assert.equal(await page.locator('[data-output].active').getAttribute("data-output"), output); + }); + await check("target loss prevents submission until explicitly rebound", async () => { + await page.locator('.edge-cases [data-scenario="lost"]').click(); + await page.waitForFunction(() => document.querySelectorAll(".sentence-meta").length === 3 && [...document.querySelectorAll(".sentence-meta")].every(el => !el.textContent.includes("处理中"))); + assert.equal(await page.locator("#commit").isEnabled(), false); + await page.locator('[data-action="retarget"]').click(); + await page.locator('[data-dialog-action="bind-notes"]').click(); + assert.match(await page.locator("#destination").innerText(), /笔记/); + assert.equal(await page.locator("#commit").isEnabled(), true); + await page.locator("#commit").click(); + assert.match(await page.locator("#target-text").inputValue(), /Redis/); + assert.match(await page.locator("#toast").innerText(), /未发送/); + }); + await check("editing raw input cannot bypass lost-target confirmation", async () => { + await page.locator('.edge-cases [data-scenario="lost"]').click(); + await page.locator('[data-mode="text"]').click(); + await page.locator("#source-input").fill("重新输入的文字。"); + await page.locator('[data-output="raw"]').click(); + assert.match(await page.locator("#destination").innerText(), /失效/); + assert.equal(await page.locator("#commit").isEnabled(), false); + }); + await check("no target exposes copy instead of OS injection", async () => { + await page.locator('.edge-cases [data-scenario="no-target"]').click(); + await page.waitForFunction(() => !document.querySelector("#commit").disabled); + assert.match(await page.locator("#commit").innerText(), /复制/); + await page.locator("#commit").click(); + assert.equal(await page.locator("#modal-title").innerText(), "当前版本 · 可复制"); + await page.locator("#modal-close").click(); + }); + await check("unknown translation never silently falls back to source", async () => { + await page.locator('.edge-cases [data-scenario="failure"]').click(); + assert.equal(await page.locator("#commit").isEnabled(), false); + assert.match(await page.locator("#warning").innerText(), /不会自动改成原文输出/); + await page.locator("#use-raw-all").click(); + assert.equal(await page.locator('[data-output="raw"]').getAttribute("aria-pressed"), "true"); + assert.equal(await page.locator("#commit").isEnabled(), true); + }); + await check("history restores its original language direction together with the translated text", async () => { + await page.locator("#direction").selectOption("zh-en"); + await page.locator('.scenario[data-scenario="meeting"]').click(); + await page.locator("#translate-on").check(); + await page.waitForFunction(() => !document.querySelector("#commit").disabled); + await page.locator("#close-composer").click(); + await page.locator("#summon").click(); + await page.locator("#direction").selectOption("en-zh"); + await page.locator("#history").click(); + await page.locator('[data-restore="0"]').click(); + assert.equal(await page.locator("#direction").inputValue(), "zh-en"); + assert.match(await page.locator("#output-hint").innerText(), /英文译文/); + assert.match(await page.locator(".sentence-text").first().innerText(), /Do not merge/); + }); + await check("dictation flushes pending typed-text processing instead of stranding it", async () => { + await page.locator('.scenario[data-scenario="document"]').click(); + await page.locator("#text-sample").click(); + await page.locator("#record").click(); + await page.waitForFunction(() => document.querySelectorAll(".sentence").length >= 2); + await page.locator("#record").click(); + await page.waitForFunction(() => document.querySelector('[data-unit="0"] .sentence-meta')?.textContent.includes("已完成"), null, { timeout: 2500 }); + assert.match(await page.locator('[data-unit="0"] .sentence-text').innerText(), /Do not merge/); + }); + await check("restored pending drafts restart valid jobs rather than waiting forever", async () => { + await page.locator('.edge-cases [data-scenario="slow"]').click(); + await page.locator("#close-composer").click(); + await page.locator("#summon").click(); + await page.locator("#reset").click(); + await page.locator("#history").click(); + await page.locator('[data-restore="0"]').click(); + await page.waitForFunction(() => !document.querySelector("#commit").disabled, null, { timeout: 2500 }); + assert.match(await page.locator(".sentence-text").first().innerText(), /Do not merge/); + }); + await check("Enter after microphone-stop submits the reviewed version rather than restarting recording", async () => { + await page.locator("#record").focus(); + await page.keyboard.press("Enter"); + assert.match(await page.locator("#target-text").inputValue(), /Do not merge/); + assert.equal(await page.locator("#composer").isHidden(), true); + }); + await check("transcription permits pause/continuation and shows read-only source evidence", async () => { + await page.locator('.scenario[data-scenario="meeting"]').click(); + await page.waitForFunction(() => !document.querySelector("#commit").disabled); + assert.equal(await page.locator("#source-input").getAttribute("readonly"), ""); + await page.locator("#audio-file").setInputFiles({ name: "review-audio.wav", mimeType: "audio/wav", buffer: Buffer.alloc(44) }); + assert.match(await page.locator("#file-note").innerText(), /review-audio\.wav.*不会读取音频/); + await page.locator("#record").click(); + await page.locator("#pause").click(); + assert.match(await page.locator("#session-status").innerText(), /已暂停/); + await page.locator("#pause").click(); + await page.waitForFunction(() => document.querySelectorAll(".sentence").length >= 4); + await page.locator("#record").click(); + assert.match(await page.locator(".sentence-meta").first().innerText(), /示例时间/); + }); + await check("tray exposes summon, draft recovery, and the settings center", async () => { + await page.locator('[popovertarget="tray-menu"]').click(); + await page.locator('[data-tray-action="settings"]').click(); + assert.equal(await page.locator('[data-settings-panel="general"]').isVisible(), true); + await page.locator('[data-page="experience"]').click(); + await page.locator('[popovertarget="tray-menu"]').click(); + await page.locator('[data-tray-action="summon"]').click(); + assert.equal(await page.locator("#composer").isVisible(), true); + }); + await check("all six settings pages are reachable and cloud authorizations are separate", async () => { + await page.locator('[data-page="settings"]').click(); + for (const name of ["general", "speech", "language", "privacy", "models", "about"]) { + await page.locator(`[data-settings="${name}"]`).click(); + assert.equal(await page.locator(`[data-settings-panel="${name}"]`).isVisible(), true); + } + await page.locator('[data-settings="privacy"]').click(); + await page.locator("#cloud-translation").click(); + assert.equal(await page.locator("#cloud-translation").isChecked(), false); + await page.locator("#allow-cloud").click(); + assert.equal(await page.locator("#cloud-translation").isChecked(), true); + assert.equal(await page.locator("#cloud-cleanup").isChecked(), false); + await page.locator('[data-settings="language"]').click(); + await page.locator("#term-source").fill("原型测试词"); + await page.locator("#term-target").fill("prototype test term"); + await page.locator('#term-form button[type="submit"]').click(); + assert.match(await page.locator("#terms-list").innerText(), /prototype test term/); + await page.locator("#announcement").evaluate(el => { el.hidden = true; }); + await page.screenshot({ path: path.join(root, "settings.png"), fullPage: true }); + }); + await check("Windows labels, dark theme, and 390px viewport do not horizontally overflow", async () => { + await page.locator("#platform").selectOption("win"); + assert.match(await page.locator(".summon-key").innerText(), /Ctrl/); + await page.locator('[data-settings="general"]').click(); + await page.locator("#theme").selectOption("dark"); + await page.locator("#reduce-motion").check(); + assert.match(await page.locator("body").getAttribute("class"), /dark.*reduce-motion/); + await page.setViewportSize({ width: 390, height: 844 }); + for (const name of ["experience", "states", "settings", "design"]) { + await page.locator(`[data-page="${name}"]`).click(); + const dimensions = await page.evaluate(() => [document.documentElement.scrollWidth, innerWidth]); + assert.ok(dimensions[0] <= dimensions[1] + 1, `${name} overflow: ${dimensions}`); + } + await page.locator('[data-page="experience"]').click(); + for (const name of ["pair", "document", "meeting"]) { + await page.locator(`.scenario[data-scenario="${name}"]`).click(); + const composer = await page.locator("#composer").boundingBox(); + const target = await page.locator(".target-input").boundingBox(); + const desktop = await page.locator("#desktop").boundingBox(); + assert.ok(composer.y + composer.height <= target.y, `${name}: composer covers the original input`); + assert.ok(composer.y >= desktop.y + 29, `${name}: composer clipped above desktop`); + if (name === "pair") await page.locator("#record").click(); + } + }); + await check("runtime remains error-free and completely offline after all scenarios", async () => { + assert.deepEqual(errors, []); + assert.deepEqual(network, []); + }); + console.log(`VERIFIED ${results.length} browser scenarios`); + } finally { + await browser.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/docs/superpowers/prototypes/livelingo-ux/index.html b/docs/superpowers/prototypes/livelingo-ux/index.html new file mode 100644 index 0000000..e0c5788 --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux/index.html @@ -0,0 +1,197 @@ + + + + + + + LiveLingo · 随处表达 / 产品原型 v2 + + + + + +
+
+
+

A SMALL SPACE FOR BIG IDEAS

思路不用停,表达不用切换。

从任何输入框唤起。说话、打字或转录,再把你确认的那一版写回去。

+ +
+
设计演示 不访问麦克风、不调用模型、不连接云端、不向真实应用写入。识别与译文使用预设语料;所有时间均为演示。
+
+
+
macOS本地工作台 · 前台应用示意
+
+

LiveLingo 本地处理 · 原型

+ + + +
+ + + +
+
+
VS Code / Agent输入目标已捕获
+
+

我们继续讨论缓存和错误处理。

+

代码已读完。告诉我你的边界条件,我会先给出修改方案,不直接改业务代码。

+
+
全局唤起 → 审阅 → 快捷键回填
+
+
+
+
LiveLingo回填 · Agent
+
本地处理
+
+
+
+ + + +
+ + +
+ +
+ +
+
准备写入
+

把脑海里的想法说出来。

点击麦克风免按住听写,或按住「按住说话」。
松开只结束录音,不自动回填,也不自动发送。

+
+ +
+
+ +
+ + + + 就绪 · 按 Tab 正常移动焦点 + +
+
+ + +
+
回填只写入当前选定的版本,不执行原应用的发送动作。Shift↵ 换行 · Esc 隐藏
+
+ +
+ +
+
+ + + +
+
+ + + diff --git a/docs/superpowers/prototypes/livelingo-ux/long-document.png b/docs/superpowers/prototypes/livelingo-ux/long-document.png new file mode 100644 index 0000000..e5b1ae2 Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux/long-document.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux/overview.png b/docs/superpowers/prototypes/livelingo-ux/overview.png new file mode 100644 index 0000000..8b971da Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux/overview.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux/prototype.test.cjs b/docs/superpowers/prototypes/livelingo-ux/prototype.test.cjs new file mode 100644 index 0000000..367b3fc --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux/prototype.test.cjs @@ -0,0 +1,162 @@ +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { existsSync } = require("node:fs"); +const path = require("node:path"); + +const available = existsSync(path.join(__dirname, "app.js")); +test("the interrupted prototype has a persistent behavior implementation", () => { + assert.ok(available, "The prototype behavior file has not been rebuilt."); +}); +const Draft = available ? require("./app.js").Draft : null; +const check = (name, fn) => test(name, { skip: !available }, fn); + +function ready() { + const draft = new Draft("agent"); + const ticket = draft.append("这个 PR 先不要合并。"); + draft.complete(ticket, "clean", "这个 PR 先不要合并。"); + draft.complete(ticket, "translation", "Do not merge this PR yet."); + return { draft, ticket }; +} + +check("translation is independent of cleanup and selects the actual output language", () => { + const draft = new Draft("agent"); + const ticket = draft.append("先不要合并。"); + draft.output = "translation"; + draft.complete(ticket, "translation", "Do not merge yet."); + assert.equal(draft.requestCommit().text, "Do not merge yet."); +}); + +check("manual cleanup edits are not overwritten by late model results", () => { + const { draft, ticket } = ready(); + draft.edit(0, "clean", "请先不要合并这个 PR。"); + assert.equal(draft.complete(ticket, "clean", "错误的迟到结果"), false); + assert.equal(draft.units[0].clean, "请先不要合并这个 PR。"); + assert.equal(draft.units[0].raw, "这个 PR 先不要合并。"); +}); + +check("a sentence can use raw text without reverting other sentences", () => { + const { draft, ticket } = ready(); + const second = draft.append("嗯,等 CI 跑完。"); + draft.complete(second, "clean", "等 CI 跑完。"); + draft.useRaw(0); + assert.equal(draft.complete(ticket, "clean", "不要保留原文"), false); + assert.equal(draft.text(), "这个 PR 先不要合并。\n等 CI 跑完。"); +}); + +check("a rejected cleanup exposes its reason and safely retains the original", () => { + const draft = new Draft("agent"); + const ticket = draft.append("超时为 30 秒。"); + draft.complete(ticket, "clean", "超时为 30 分钟。", "候选改动了单位"); + assert.equal(draft.units[0].clean, "超时为 30 秒。"); + assert.equal(draft.units[0].warning, "候选改动了单位"); + assert.equal(draft.requestCommit().text, "超时为 30 秒。"); +}); + +check("replacing the source invalidates old jobs and preserves exact layout", () => { + const { draft, ticket } = ready(); + const raw = ' {\n "message": "你好",\n "timeout": 30\n }\n'; + draft.replaceInput(raw, "whole"); + assert.equal(draft.complete(ticket, "translation", "stale"), false); + draft.output = "raw"; + assert.equal(draft.route, "whole"); + assert.equal(draft.text(), raw); +}); + +check("appending more dictation retains existing manual edits and completed translations", () => { + const { draft } = ready(); + draft.edit(0, "clean", "我手动修改的句子。"); + const second = draft.append("再检查缓存。"); + draft.complete(second, "clean", "再检查缓存。"); + assert.equal(draft.units[0].translation, "Do not merge this PR yet."); + assert.equal(draft.text(), "我手动修改的句子。\n再检查缓存。"); +}); + +check("switching the target language invalidates only translations", () => { + const { draft, ticket } = ready(); + draft.invalidateTranslations(); + assert.equal(draft.units[0].clean, "这个 PR 先不要合并。"); + assert.equal(draft.complete(ticket, "translation", "wrong language"), false); + draft.output = "translation"; + assert.equal(draft.requestCommit().reason, "processing"); +}); + +check("submission is blocked during recording and while selected output is pending", () => { + const draft = new Draft("agent"); + draft.append("尚未完成的句子。"); + draft.recording = true; + assert.equal(draft.requestCommit().reason, "recording"); + draft.recording = false; + assert.equal(draft.requestCommit().reason, "processing"); +}); + +check("a failed translation is not presented as a successful translation", () => { + const { draft, ticket } = ready(); + draft.invalidateTranslations(); + draft.fail(draft.ticket(0), "translation", "连接超时"); + draft.output = "translation"; + assert.equal(draft.requestCommit().reason, "unavailable"); + assert.equal(draft.units[0].translationError, "连接超时"); + assert.equal(draft.complete(ticket, "translation", "late"), false); +}); + +check("explicit raw fallback cancels late cleanup and permits submission", () => { + const draft = new Draft("agent"); + const ticket = draft.append("不要改动接口。"); + draft.useRaw(0); + assert.equal(draft.complete(ticket, "clean", "改动接口。"), false); + assert.equal(draft.requestCommit().text, "不要改动接口。"); +}); + +check("target loss blocks insertion; retargeting requires an explicit new destination", () => { + const { draft } = ready(); + draft.targetLost = true; + assert.equal(draft.requestCommit().reason, "target-lost"); + draft.retarget("notes"); + assert.equal(draft.requestCommit().target, "notes"); +}); + +check("editing source text does not silently revalidate a lost input target", () => { + const { draft } = ready(); + draft.targetLost = true; + draft.replaceInput("新的输入文本。", "units"); + draft.output = "raw"; + assert.equal(draft.targetLost, true); + assert.equal(draft.requestCommit().reason, "target-lost"); +}); + +check("no input target yields a copy action rather than injecting into another app", () => { + const { draft } = ready(); + draft.retarget(null); + assert.equal(draft.requestCommit().action, "copy"); +}); + +check("an immutable submitted snapshot is returned once and late tasks cannot rewrite it", () => { + const { draft, ticket } = ready(); + const first = draft.requestCommit(); + assert.equal(first.ok, true); + assert.equal(draft.requestCommit(), first); + assert.equal(draft.complete(ticket, "clean", "晚到改写"), false); + assert.equal(first.text, "这个 PR 先不要合并。"); +}); + +check("source-language cleanup edits do not silently rewrite raw-based translations", () => { + const { draft } = ready(); + draft.edit(0, "clean", "不同的手动文稿。"); + draft.output = "translation"; + assert.equal(draft.text(), "Do not merge this PR yet."); +}); + +check("empty manual translations are explicitly unavailable for submission", () => { + const { draft } = ready(); + draft.edit(0, "translation", ""); + draft.output = "translation"; + assert.equal(draft.requestCommit().reason, "unavailable"); +}); + +check("reset invalidates every job from the previous session", () => { + const { draft, ticket } = ready(); + draft.reset("agent"); + assert.equal(draft.units.length, 0); + assert.equal(draft.complete(ticket, "clean", "previous session"), false); + assert.equal(draft.requestCommit().reason, "empty"); +}); diff --git a/docs/superpowers/prototypes/livelingo-ux/settings.png b/docs/superpowers/prototypes/livelingo-ux/settings.png new file mode 100644 index 0000000..f436edb Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux/settings.png differ diff --git a/docs/superpowers/prototypes/livelingo-ux/style.css b/docs/superpowers/prototypes/livelingo-ux/style.css new file mode 100644 index 0000000..bbf176b --- /dev/null +++ b/docs/superpowers/prototypes/livelingo-ux/style.css @@ -0,0 +1,243 @@ +:root { + color-scheme: light; + --paper: #f6f5f1; --surface: #fffefa; --ink: #25302e; --muted: #67716e; + --line: #dedfd7; --soft: #eeeee7; --accent: #136c56; --tint: #e8f1eb; + --amber: #80551d; --amber-bg: #fbf0dc; --red: #993e32; --red-bg: #faebe7; + --shadow: 0 24px 70px #24382c24, 0 3px 10px #24382c12; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--paper); color: var(--ink); font-size: 14px; line-height: 1.6; } +button, input, textarea, select { font: inherit; } +button, select { cursor: pointer; } +button { border: 0; color: inherit; background: transparent; } +input, textarea, select { color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 8px; } +input, select { padding: 7px 10px; } +textarea { resize: vertical; padding: 12px; width: 100%; line-height: 1.7; } +button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 3px solid #a46a0c; outline-offset: 3px; } +button:disabled { opacity: .5; cursor: not-allowed; } +[hidden] { display: none !important; } +h1, h2, h3, p { margin: 0; } +h1 { font-size: clamp(25px, 2.8vw, 37px); font-weight: 600; letter-spacing: -1.1px; line-height: 1.35; } +h2 { font-size: 18px; font-weight: 600; } +h3 { font-size: 15px; } +p + p { margin-top: 9px; } +label { cursor: pointer; } +kbd { border: 1px solid currentColor; opacity: .75; font: 11px ui-monospace, monospace; padding: 2px 5px; border-radius: 4px; margin-left: 8px; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } +.site-header { min-height: 76px; padding: 14px 4vw; display: flex; align-items: center; gap: 28px; border-bottom: 1px solid var(--line); background: var(--paper); } +.brand { text-decoration: none; display: flex; align-items: center; gap: 10px; font-size: 18px; font-weight: 650; color: var(--ink); white-space: nowrap; } +.logo, .small-logo { display: inline-grid; place-items: center; background: var(--accent); color: #fff; border-radius: 10px; width: 34px; height: 34px; font-family: Georgia, serif; font-size: 25px; } +.small-logo { width: 25px; height: 25px; border-radius: 7px; font-size: 18px; } +.version { margin-left: 10px; color: var(--muted); font: 9px ui-monospace, monospace; letter-spacing: 1px; } +.site-header nav { display: flex; gap: 8px; margin-left: auto; } +.nav-button { padding: 8px 12px; border-radius: 8px; color: var(--muted); } +.nav-button.active, .nav-button:hover { background: var(--soft); color: var(--ink); } +.platform-control { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); } +main { max-width: 1480px; margin: 0 auto; padding: 32px 4vw 80px; } +.page-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 26px; } +.eyebrow { font: 10px ui-monospace, monospace; letter-spacing: 1.8px; color: var(--accent); margin-bottom: 12px; } +.lead { color: var(--muted); margin-top: 12px; } +.primary, .secondary { display: inline-flex; gap: 6px; align-items: center; justify-content: center; border-radius: 9px; padding: 9px 13px; line-height: 1.4; font-weight: 550; white-space: nowrap; } +.primary { color: #fff; background: var(--accent); } +.secondary { background: var(--surface); border: 1px solid var(--line); } +.primary:hover:not(:disabled) { filter: brightness(1.1); } +.secondary:hover, .icon-button:hover, .text-button:hover { background: var(--soft); } +.text-button { padding: 5px 7px; color: var(--muted); font-size: 12px; border-radius: 5px; } +.icon-button { border-radius: 6px; width: 30px; height: 30px; font-size: 22px; padding: 0; } +.simulation-notice { color: var(--muted); font-size: 12px; padding: 9px 0 17px; display: flex; align-items: baseline; gap: 10px; } +.badge { font: 10px ui-monospace, monospace; color: var(--accent); white-space: nowrap; border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; } +.experience-grid { display: grid; grid-template-columns: minmax(0, 1fr) 250px; gap: 24px; align-items: start; } +.desktop { background: #dfe5dc; border: 1px solid #cdd5c9; border-radius: 16px; min-height: 780px; position: relative; padding: 46px 20px 20px; overflow: hidden; } +.desktop::before { content: ""; pointer-events: none; position: absolute; inset: 0; background: radial-gradient(circle at 20% 0, #f6ecd480, transparent 50%), radial-gradient(circle at 80% 95%, #aacbb865, transparent 50%); } +.desktop:has(.composer.expanded) { min-height: 1000px; } +.desktop:has(.composer.expanded) .target-app { min-height: 900px; } +.desktop-top { position: absolute; top: 0; left: 0; right: 0; height: 29px; display: flex; justify-content: space-between; align-items: center; padding: 0 17px; background: #f5f6ef99; color: #536350; font: 10px ui-monospace, monospace; } +.tray-button { font-size: 10px; padding: 3px 6px; } +#tray-menu { position: fixed; top: 32px; right: 26px; left: auto; bottom: auto; margin: 0; width: 260px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); padding: 10px; box-shadow: var(--shadow); } +#tray-menu p { padding: 5px 7px 10px; font-size: 12px; font-weight: 600; } +#tray-menu p span { display: block; font-size: 10px; font-weight: 400; color: var(--muted); } +#tray-menu button { display: block; text-align: left; width: 100%; border-radius: 5px; padding: 8px; font-size: 12px; } +#tray-menu button:hover { background: var(--soft); } +#tray-menu small { float: right; color: var(--muted); font-size: 10px; } +#tray-menu hr { border: 0; border-top: 1px solid var(--line); } +.target-app { position: relative; margin: 0 auto; border: 1px solid #d4dbd1; border-radius: 11px; background: #f8f9f4; min-height: 680px; display: flex; flex-direction: column; box-shadow: 0 12px 40px #34412c13; } +.target-app > header { display: flex; align-items: center; padding: 12px 16px; gap: 15px; border-bottom: 1px solid #e0e4da; color: #6a7563; font-size: 11px; } +.window-dots { display: flex; gap: 5px; } +.window-dots i { height: 7px; width: 7px; border-radius: 50%; background: #c4cbc0; } +.target-privacy { margin-left: auto; font-size: 10px; } +.agent-conversation { padding: 23px 24px; display: flex; flex-direction: column; gap: 19px; color: #485244; } +.message { max-width: 85%; font-size: 12px; line-height: 1.7; } +.message.user { align-self: flex-end; background: #e8ebdf; padding: 10px 15px; border-radius: 10px; } +.message.agent { display: flex; gap: 12px; align-items: start; } +.agent-avatar { flex: 0 0 24px; height: 24px; border-radius: 7px; display: grid; place-items: center; background: #d6e0d0; } +.target-input { margin: auto 20px 20px; padding: 10px 12px 7px; border: 1px solid #ced7c6; background: #fcfdf9; border-radius: 11px; color: #64725d; } +.target-input label { font-size: 10px; } +.target-input textarea { font-size: 12px; color: #3d4b36; background: transparent; border: 0; padding: 3px 0; min-height: 55px; } +.target-input-tip { display: block; font-size: 9px; color: #65775c; } +.composer { position: absolute; bottom: 200px; left: 50%; transform: translateX(-50%); z-index: 1; width: min(670px, 95%); max-height: calc(100% - 250px); display: flex; flex-direction: column; margin: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 15px; box-shadow: var(--shadow); overflow: hidden; } +.composer > :not(.draft-layout) { flex-shrink: 0; } +.composer-header { padding: 13px 15px 10px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); } +.composer-name, .header-actions { display: flex; align-items: center; gap: 8px; } +.composer-name b { font-size: 12px; } +.destination { font-size: 10px; color: var(--muted); border-left: 1px solid var(--line); padding-left: 9px; } +.header-actions { margin-left: auto; } +.privacy { border-radius: 20px; font-size: 10px; padding: 3px 7px; white-space: nowrap; } +.privacy.local { color: var(--accent); background: var(--tint); } +.privacy.cloud { color: var(--amber); background: var(--amber-bg); } +.intent-bar { padding: 11px 15px; display: flex; gap: 10px; align-items: center; } +.mode-buttons { display: flex; background: var(--soft); padding: 3px; border-radius: 8px; } +.mode-buttons button { font-size: 11px; padding: 4px 10px; border-radius: 6px; } +.mode-buttons .active { background: var(--surface); box-shadow: 0 1px 4px #25302e12; } +.translation-switch { display: flex; gap: 7px; align-items: center; margin-left: auto; font-size: 11px; } +input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; } +.direction select { max-width: 150px; font-size: 10px; padding: 4px 6px; } +.draft-layout { display: grid; min-height: 0; overflow-y: auto; } +.source-panel { margin: 0 15px 12px; padding-top: 4px; } +.source-panel textarea { font-size: 13px; min-height: 96px; background: var(--paper); white-space: pre-wrap; } +.panel-label { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--muted); font-size: 10px; padding: 2px 0 8px; } +.source-actions { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; padding-top: 5px; font-size: 10px; color: var(--muted); } +.source-actions label { margin-left: auto; } +.source-actions select { padding: 3px; font-size: 10px; } +.output-panel { padding: 0 15px 12px; } +.output-selector { display: flex; align-items: center; gap: 2px; } +.output-selector button { font-size: 10px; padding: 4px 7px; border-radius: 5px; color: var(--muted); } +.output-selector .active { background: var(--tint); color: var(--accent); } +.empty-draft { text-align: center; padding: 18px 0 27px; } +.empty-draft h2 { font-size: 17px; font-weight: 550; margin-bottom: 8px; } +.empty-draft p { font-size: 12px; color: var(--muted); line-height: 1.8; } +.empty-symbol { display: block; color: var(--accent); font-size: 36px; line-height: 1.4; } +.sentences { max-height: 260px; overflow-y: auto; } +.sentence { position: relative; border-top: 1px solid var(--line); padding: 11px 2px; } +.sentence:first-child { border-top: 0; } +.sentence-meta { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 9px; margin-bottom: 4px; } +.sentence-meta .owner { color: var(--accent); } +.sentence-text { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.75; } +.sentence-text.pending { color: var(--muted); font-style: italic; } +.sentence .warning-inline { color: var(--amber); font-size: 11px; margin-top: 4px; } +.sentence .source-reference { color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; margin-top: 6px; } +.sentence-actions { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 5px; } +.sentence-actions button, .sentence summary { padding: 1px 5px; font-size: 10px; color: var(--muted); text-decoration: underline; text-underline-offset: 3px; } +.sentence details { margin-top: 6px; } +.sentence details p { color: var(--muted); font-size: 12px; padding: 8px; border-radius: 6px; background: var(--paper); white-space: pre-wrap; } +.sentence-editor { font-size: 13px; margin-top: 6px; } +.live-tail { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; color: var(--muted); padding-top: 12px; border-top: 1px dashed var(--line); } +.live-tail::after { content: "│"; color: var(--accent); animation: blink 1s step-end infinite; } +.recording-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 11px 15px; background: var(--paper); font-size: 10px; } +.record-button { height: 30px; width: 30px; border-radius: 50%; background: var(--accent); color: #fff; display: grid; place-items: center; } +.mic-shape { width: 8px; height: 13px; border-radius: 4px; border: 1.5px solid #fff; display: block; position: relative; } +.mic-shape::after { content: ""; position: absolute; left: -4px; right: -4px; bottom: -5px; height: 9px; border: 1.5px solid #fff; border-top: 0; border-radius: 0 0 7px 7px; } +.record-button.recording { background: var(--red); animation: pulse 2s infinite; } +.record-button.recording .mic-shape { width: 10px; height: 10px; border: 0; border-radius: 2px; background: #fff; } +.record-button.recording .mic-shape::after { display: none; } +.recording-bar .secondary { padding: 5px 9px; font-size: 10px; } +#use-raw-all { margin-left: auto; } +.composer-footer { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--line); padding: 10px 14px; } +.footer-left { margin-right: auto; display: flex; gap: 2px; } +.composer-footer > button { font-size: 12px; } +.shortcut-footnote { display: flex; justify-content: space-between; gap: 8px; padding: 0 15px 9px; color: var(--muted); font-size: 9px; } +.warning { padding: 9px 12px; background: var(--amber-bg); border-radius: 7px; color: var(--amber); margin: 0 15px 10px; font-size: 11px; } +.warning button { text-decoration: underline; margin-left: 7px; } +.composer.expanded { width: 97%; } +.composer.expanded .draft-layout { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.composer.expanded .source-panel { border-right: 1px solid var(--line); padding-right: 15px; margin-right: 0; } +.composer.expanded .source-panel textarea { min-height: 245px; } +.composer.expanded .sentences { max-height: 350px; } +.transcript-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 0 15px 12px; font-size: 10px; color: var(--muted); } +.transcript-tools .secondary { font-size: 10px; padding: 5px 8px; } +.file-button { border: 1px solid var(--line); border-radius: 6px; padding: 5px 8px; } +.file-button input { max-width: 180px; font-size: 10px; padding: 0; border: 0; } +.toast { position: absolute; z-index: 5; bottom: 15px; left: 5%; right: 5%; background: var(--ink); color: var(--surface); padding: 11px 14px; border-radius: 10px; box-shadow: var(--shadow); font-size: 12px; } +.scenario-sidebar { padding-top: 5px; } +.scenario-sidebar h2 { font-size: 17px; line-height: 1.6; margin-bottom: 17px; } +.scenario { display: flex; align-items: center; gap: 12px; text-align: left; width: 100%; padding: 16px 0; border-top: 1px solid var(--line); } +.scenario-number { font: 10px ui-monospace, monospace; color: var(--accent); } +.scenario b { font-size: 13px; font-weight: 550; } +.scenario small { display: block; color: var(--muted); font-size: 11px; margin-top: 3px; } +.scenario > span:last-child { margin-left: auto; color: var(--muted); } +.scenario:hover b { color: var(--accent); } +.edge-cases { margin-top: 15px; font-size: 12px; border-bottom: 1px solid var(--line); padding-bottom: 15px; } +summary { cursor: pointer; } +.edge-cases div { display: grid; gap: 6px; padding-top: 10px; } +.edge-cases button { text-align: left; color: var(--muted); font-size: 11px; padding: 6px; background: var(--soft); border-radius: 5px; } +.sidebar-note { margin-top: 25px; color: var(--muted); font-size: 11px; } +.sidebar-note b { color: var(--ink); font-size: 13px; } +.sidebar-note p { margin-top: 9px; } +.flow-strip { display: flex; gap: 15px; flex-wrap: wrap; align-items: center; font-size: 12px; color: var(--muted); margin: 35px 0; } +.flow-strip span { padding: 9px 15px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; } +.state-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.state-card { padding: 22px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; text-align: left; } +.state-card:hover { border-color: var(--accent); } +.state-card > span { display: block; color: var(--accent); font: 10px ui-monospace, monospace; margin-bottom: 16px; } +.state-card h2 { font-size: 17px; margin-bottom: 8px; } +.state-card p { color: var(--muted); font-size: 12px; min-height: 42px; } +.state-card small { display: block; border-top: 1px solid var(--line); padding-top: 14px; margin-top: 20px; color: var(--accent); } +.settings-layout { display: grid; grid-template-columns: 230px minmax(0, 1fr); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); overflow: hidden; } +.settings-nav { padding: 22px 15px; background: var(--soft); display: flex; flex-direction: column; gap: 7px; } +.settings-nav button { text-align: left; padding: 10px 12px; border-radius: 7px; font-size: 13px; color: var(--muted); } +.settings-nav button.active { background: var(--surface); color: var(--ink); } +.settings-nav-note { margin: auto 12px 0; padding-top: 65px; color: var(--muted); font-size: 11px; } +.settings-content { padding: 32px; max-width: 1000px; } +.section-intro { color: var(--muted); font-size: 12px; margin-top: 6px; margin-bottom: 24px; } +.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 25px; padding: 19px 0; border-top: 1px solid var(--line); } +.setting-row label, .setting-row b { font-size: 13px; font-weight: 550; } +.setting-row p { color: var(--muted); font-size: 12px; max-width: 500px; margin-top: 5px; } +.setting-row input:not([type=checkbox]), .setting-row select { max-width: 250px; font-size: 12px; } +.status-pill { padding: 4px 9px; border-radius: 6px; background: var(--tint); color: var(--accent); font-size: 10px; white-space: nowrap; } +.callout { padding: 15px; border: 1px solid var(--line); border-radius: 9px; background: var(--paper); font-size: 12px; color: var(--muted); margin-top: 18px; } +.stacked-label { display: grid; gap: 8px; margin-top: 18px; font-size: 12px; } +.model-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 25px; } +.model-cards article { padding: 16px 12px; border: 1px solid var(--line); border-radius: 9px; } +.model-cards span, .model-cards p { color: var(--muted); font-size: 10px; } +.model-cards h3 { margin: 6px 0; font-size: 13px; } +.terms-heading { margin: 25px 0 15px; } +.terms-heading span { font-size: 11px; color: var(--muted); } +.term-form { display: flex; gap: 10px; align-items: end; } +.term-form label { flex: 1; display: grid; gap: 5px; font-size: 11px; } +.term-form input { width: 100%; } +.term-form button { font-size: 12px; } +.term-row { display: flex; gap: 10px; align-items: center; border-bottom: 1px solid var(--line); padding: 12px 0; font-size: 12px; } +.term-row button { margin-left: auto; } +.design-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } +.design-card { padding: 24px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); } +.design-card span { font: 10px ui-monospace, monospace; color: var(--accent); } +.design-card h2 { font-size: 18px; margin: 13px 0 9px; } +.design-card p { font-size: 12px; color: var(--muted); } +.scope-card { padding: 25px; border: 1px solid var(--line); border-radius: 12px; margin-top: 25px; background: var(--surface); overflow-x: auto; } +table { border-collapse: collapse; width: 100%; font-size: 12px; margin-top: 18px; } +th, td { text-align: left; padding: 13px 12px; border-top: 1px solid var(--line); vertical-align: top; } +th { color: var(--muted); font-weight: 500; } +.design-bottom { margin-top: 25px; font-size: 12px; color: var(--muted); max-width: 850px; } +.design-bottom h2 { color: var(--ink); margin-bottom: 9px; } +dialog { max-width: min(620px, 90vw); width: 580px; border: 1px solid var(--line); border-radius: 15px; padding: 24px; box-shadow: var(--shadow); background: var(--surface); color: var(--ink); } +dialog::backdrop { background: #25302e55; backdrop-filter: blur(3px); } +dialog > header { display: flex; gap: 20px; justify-content: space-between; align-items: center; margin-bottom: 18px; } +dialog p { color: var(--muted); font-size: 12px; } +dialog .dialog-actions { display: flex; gap: 10px; justify-content: end; margin-top: 18px; flex-wrap: wrap; } +dialog pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 350px; overflow-y: auto; background: var(--paper); padding: 15px; border-radius: 8px; font-size: 12px; } +.history-row { display: flex; gap: 10px; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--line); font-size: 12px; } +.global-announcement { position: fixed; z-index: 100; bottom: 22px; left: 50%; transform: translateX(-50%); max-width: 90vw; background: var(--ink); color: var(--surface); padding: 12px 20px; border-radius: 10px; box-shadow: var(--shadow); font-size: 12px; } +@keyframes blink { 50% { opacity: .3; } } +@keyframes pulse { 50% { box-shadow: 0 0 0 5px #993e3218; } } +body.dark { color-scheme: dark; --paper: #18211e; --surface: #202b26; --ink: #ebeee4; --muted: #b0bcb3; --line: #3b4b42; --soft: #27382e; --accent: #258165; --tint: #294839; --amber: #e5bd73; --amber-bg: #463b26; --red: #ec9d90; --red-bg: #49302a; } +body.dark .privacy.local, body.dark .status-pill, body.dark .eyebrow, body.dark .owner, body.dark .state-card > span, body.dark .state-card small, body.dark .design-card > span, body.dark .scenario-number, body.dark .output-selector .active { color: #a2debf; } +body.reduce-motion *, body.reduce-motion *::after { animation: none !important; } +@media (prefers-reduced-motion: reduce) { *, *::after { animation: none !important; } } +@media (max-width: 1180px) { .version { display: none; } .experience-grid { grid-template-columns: minmax(0, 1fr) 215px; gap: 17px; } .site-header { gap: 16px; } .nav-button { padding: 8px; } } +@media (max-width: 950px) { .experience-grid { grid-template-columns: 1fr; } .scenario-sidebar { display: grid; grid-template-columns: 1fr 1fr; gap: 0 20px; } .scenario-sidebar > .eyebrow, .scenario-sidebar > h2, .sidebar-note { grid-column: 1 / -1; } .site-header { flex-wrap: wrap; } .site-header nav { margin-left: auto; } .platform-control { margin-left: auto; } .state-grid, .design-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 650px) { + main { padding: 23px 15px 50px; } .site-header { padding: 13px 15px; gap: 10px; } .brand { font-size: 16px; } .logo { width: 27px; height: 27px; font-size: 22px; } + .site-header nav { order: 3; width: 100%; justify-content: space-between; } .nav-button { font-size: 12px; } .platform-control select { font-size: 11px; padding: 4px; } + .page-heading { align-items: start; flex-direction: column; gap: 17px; } .simulation-notice { font-size: 10px; } .lead { font-size: 12px; } + .desktop { padding: 38px 8px 10px; min-height: 840px; } .desktop-top { font-size: 8px; padding: 0 10px; } .target-app { min-height: 730px; } + .target-app > header { padding: 10px; font-size: 9px; gap: 10px; } .target-privacy { display: none; } .agent-conversation { padding: 15px 14px; } .message { max-width: 95%; font-size: 10px; } + .target-input { margin: auto 10px 15px; } .composer { width: 98%; bottom: 220px; max-height: calc(100% - 270px); } .composer-header { padding: 10px; } .destination { font-size: 8px; padding-left: 6px; } .composer-name { gap: 6px; } .header-actions { gap: 3px; } .privacy { font-size: 8px; } .icon-button { width: 25px; height: 25px; font-size: 20px; } + .intent-bar { padding: 9px 10px; gap: 5px; flex-wrap: wrap; } .mode-buttons button { padding: 4px 8px; } .direction select { max-width: 140px; } + .recording-bar { padding: 9px 10px; gap: 5px; } #session-status { max-width: 170px; } .composer-footer { padding: 9px; gap: 5px; flex-wrap: wrap; } .footer-left { width: 100%; } .composer-footer > button { flex: 1; } .shortcut-footnote { font-size: 8px; padding: 0 9px 9px; flex-wrap: wrap; } + .desktop:has(.composer.expanded) { min-height: 1150px; } + .desktop:has(.composer.expanded) .target-app { min-height: 1040px; } + .composer.expanded { width: 99%; } .composer.expanded .draft-layout { grid-template-columns: 1fr; } .composer.expanded .source-panel { border: 0; margin-right: 15px; padding-right: 0; } .composer.expanded .source-panel textarea { min-height: 90px; } + .settings-layout { grid-template-columns: 1fr; } .settings-nav { flex-direction: row; flex-wrap: wrap; padding: 10px; gap: 3px; } .settings-nav button { font-size: 11px; padding: 7px; } .settings-nav-note { display: none; } .settings-content { padding: 20px 15px; } + .setting-row { gap: 12px; flex-wrap: wrap; } .setting-row > div { width: 100%; } .setting-row p { font-size: 11px; } .term-form { flex-wrap: wrap; } .term-form label { min-width: 110px; } + .state-grid, .design-grid { grid-template-columns: 1fr; } .model-cards { grid-template-columns: 1fr; } .scope-card { padding: 15px; } table { min-width: 600px; } +} diff --git a/docs/superpowers/prototypes/livelingo-ux/translation-live.png b/docs/superpowers/prototypes/livelingo-ux/translation-live.png new file mode 100644 index 0000000..3ce6578 Binary files /dev/null and b/docs/superpowers/prototypes/livelingo-ux/translation-live.png differ diff --git a/docs/superpowers/specs/2026-10-29-voice-dictation-design.md b/docs/superpowers/specs/2026-10-29-voice-dictation-design.md new file mode 100644 index 0000000..3bdfdbb --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-voice-dictation-design.md @@ -0,0 +1,218 @@ +# 中文语音输入:已验证架构与产品迁移设计 + +日期:2026-10-29。状态:**POC 核心已验证,产品迁移尚未实施**。 + +本文固化的是处理流程、不变量和验证方法,不是“某个模型永远最好”的结论。依据为 [POC 说明](../../../poc/voice-dictation/README.md)、当前工作区实现与本机评测产物;用户已在测试应用中手工确认效果良好。没有证据表明其已达到微信或豆包的准确率。 + +## 1. 范围与结论 + +- 产品支持 Windows、macOS;本轮实际验证平台只有 macOS arm64、M3 Pro、36 GB。 +- 产品性能验证基线为 **Apple M2 及以上、内存 16 GB 及以上**,以 M2/16 GB 实机验证目标范围的起点;不以最低配置设备定义产品目标。Windows 使用 16 GB 及以上的同档配置独立验证,不将其性能直接等同于 M2。 +- 使用 .NET 10、Avalonia,保留“任意位置唤出草稿、确认后写回原输入位置”的产品交互。 +- 核心采用 **持续流式识别 + 片段二遍识别 + 逐句整理 + 保真判定 + 显式提交**。 +- 用户始终能查看实时识别、二遍原文和整理结果;整理结果不能覆盖原文记录。 +- 默认本地处理。云端必须显式授权;模型下载、联网翻译和上传录音是不同的权限。 +- POC 测试窗口已支持麦克风和录音留存;全局快捷键、目标输入框写入及 Windows 行为不在本轮验证范围。 + +## 2. 必须保留的架构 + +```text +麦克风:16 kHz、单声道、40 ms 一帧 + │ + ├── 用户授权的录音存储:WAV + 参考文本 + 处理记录 + │ + └── 音频时间轴 + ├── 持续流式 ASR ─────────── 实时文本 + └── VAD:提出切分候选,不拥有“哪些声音值得识别”的决定权 + │ + 会话层决定硬/软切分 + │ + 相同音频范围的二遍 ASR + │ + 按片段顺序归并原文 + │ + 完整句 + 未完成尾部 + │ + 逐句整理,前文只读 + │ + 保真判定 ── 不通过:保留原文并标明原因 + │ + 可审阅草稿 ── 用户确认 ── 提交快照 +``` + +职责只实现一次: + +| 职责 | POC 中唯一归属 | 产品中的边界 | +|---|---|---| +| 录音与格式转换 | [MicrophoneRecorder](../../../poc/voice-dictation/src/Dictation.Audio/MicrophoneRecorder.cs) | 平台适配层,不包含断句或模型选择 | +| 会话时间轴、断句调度、顺序归并、提交 | [DictationSession](../../../poc/voice-dictation/src/Dictation.Core/DictationSession.cs) | 平台无关核心;UI 和回放工具共用 | +| VAD、实时与二遍模型 | [Sherpa 适配器](../../../poc/voice-dictation/src/Dictation.Asr.Sherpa) | 实现现有识别接口,不创建第二套会话 | +| 目标句整理 | [LlamaCleaner](../../../poc/voice-dictation/src/Dictation.Cleanup.Llama/LlamaCleaner.cs) | 只生成候选文本,不直接更新 UI | +| 候选文本是否可以应用 | [FidelityGate](../../../poc/voice-dictation/src/Dictation.Core/FidelityGate.cs) | 保真规则集中维护,不散落到提示词和 UI | +| 模型键与默认参数 | [ModelCatalog](../../../poc/voice-dictation/src/Dictation.Models/ModelCatalog.cs) | 迁移时并入产品现有模型目录,不长期保留平行目录 | +| 草稿展示、用户修改、输出确认 | [测试窗口](../../../poc/voice-dictation/src/Dictation.TestApp) | UI 只消费状态和发命令 | + +### 2.1 连续识别,而不是“逐段重新开始听” + +短暂停顿不能重置实时流。每个呼吸都重新创建流会丢失上下文,损坏后续识别。当前硬停顿或软切分才关闭并重建实时流。 + +本轮 X-ASR 的回放记录没有发生可见回撤,这是观测结果,不是所有模型的契约。产品必须允许临时区域修订;不能因为本轮为零就规定所有实时模型都 append-only。 + +### 2.2 VAD 管切分,不管内容 + +远场真人音频曾出现:实时 ASR 识别了整句,VAD 只检出其中很短的一段。若二遍只读取 VAD 段,正确实时文本会被残缺原文覆盖。 + +因此区分: + +1. **流消费边界**:实际送入实时识别器的样本范围。 +2. **解码边界**:二遍读取的音频范围,可以基于首个出字证据和末尾证据去除静音。 +3. **VAD 语音边界**:用于切分和测量,不等于解码边界。 + +POC 用首个实时文本前的余量、VAD 和最后出字位置共同确定解码范围。迁移时必须独立保存上述边界:尾部裁剪不能使下一片段的消费边界倒退;padding 不能跨越已经消费的上段而重复解码。要用带样本索引的单词边界测试证明无丢字、无重字,不能只比较总音频长度。 + +### 2.3 两类切分,不能等价处理 + +- **硬切分**:VAD 不在语音中,且距最近检测段结束达到硬停顿;当前尾部可作为句子结束。 +- **软切分**:一句过长,在下一处候选停顿关闭声学片段,但不宣称语义结束;文本尾部继续衔接。 +- `12 s` 是软切分的触发条件,**不是严格最大片段时长**。没有可用停顿时仍可能更长。 +- 流式 X-ASR 不保证在片段结束时补句号。边界来自二遍结果、句子分割器和会话的硬切分语义,而不是等待流式标点。 + +### 2.4 二遍原文与整理分离 + +二遍结果按片段顺序归并,不能按返回先后拼接。原文完成断句后,整理可在用户继续说话时运行,不等整段口述结束。 + +单个整理任务只获得: + +- 当前目标句; +- 最多两句只读前文; +- 后文是否仍在继续的 `Continues` 标记; +- 本次会话术语表。 + +不传入后文正文,避免把后文抄入当前句。前文仍可能泄漏,所以提示词之外还要执行保真检查。不得为了减少上下文泄漏而丢掉所有必要语境;上下文数量需要对照评测。 + +## 3. 草稿状态与所有权 + +保留三条证据链: + +- `LiveText`:实时模型最终输出,供诊断。 +- `RawText`:二遍归并的原文,不包含整理改写。 +- `Text`:经过保真判定或用户修改后的工作稿。 + +句子状态由核心维护:`Pending → InFlight → Cleaned / Rejected`;无整理或超时保留为 `Raw`;用户修改后为 `UserOwned`。 + +核心不变量: + +1. 音频采集不等待模型推理。 +2. 已归并的片段顺序不变;临时识别尾部和已归并原文分区展示。 +3. 整理任务不能修改目标句以外的文本。 +4. 用户修改优先,晚到的模型结果不得覆盖用户文本。 +5. 被拒候选及拒绝原因可审计,不能伪装成成功整理。 +6. 同一会话 `CommitAsync` 返回同一任务结果。**这不等于 OS 文本注入已实现 exactly-once**。 + +## 4. 当前默认配置与取舍 + +这些是工作区的已测起点,不是硬编码的永恒阈值。 + +| 项 | 当前值 | 取舍 | +|---|---|---| +| 实时 ASR | X-ASR zh/en,480 ms,int8 | 首字反馈优先;160/960 ms 尚未完整对照 | +| 二遍 ASR | Qwen3-ASR-0.6B,int8 | 真人口音样本上综合较好;不是每种任务最优 | +| 整理 | Qwen3.5-4B,Q4_K_M | 本机可逐句运行;M2/16 GB 基线尚需实测 | +| VAD | Silero,阈值 0.35,最短静音 250 ms | 三档阈值对照后的折中;并非所有数据集上单调改善 | +| 硬停顿 | 800 ms | 避免每次换气都断流 | +| 软切分触发 | 12,000 ms | 控制片段增长,但还需迁移时补无停顿策略 | +| 文本尾部强制成句 | 90 字符 | 防止无标点内容无限积累,不保证语义完整 | +| 整理尾句 holdback | 2,000 ms | 有后续内容即提前处理,否则有界等待 | +| 前文 | 2 句 | 小上下文降低成本;不得输出前文 | +| 首次出字回看余量 / 尾部证据余量 | 3,000 / 300 ms | 经验启发式,不是模型对齐精度保证 | +| 二遍音频 padding | 200 ms | 不得造成跨片段重复 | +| 二遍异常长度 | `字母数字数 > 音频秒数 × 15 + 10` | 拦已观察到的重复循环;不是普适语速上限 | +| 提交总等待预算 | 15,000 ms | 超时要明确标记,不再无期限等待 | + +默认依赖为 sherpa-onnx 2.0.1、LLamaSharp 0.30.0;模型文件清单见 [下载脚本](../../../poc/voice-dictation/scripts/fetch-models.sh)。迁移应固定模型修订、文件哈希和运行时版本,不能只固定文件名。 + +备选项: + +- X-ASR offline:速度较低开销,在会议、网络视频和中英样本上有竞争力;但口音集弱于 Qwen3。 +- Fun-ASR-Nano:部分口音优秀,但曾陷入重复解码,标点偏少;保留为对照,不根据单个合成集晋升默认。 +- SenseVoice:可作小型基线,目前样本上未体现更好的综合质量。 +- 整段录完后统一识别、整理:实现简单,但不能持续提供整理结果,长口述等待随文本增长;不作主架构。 + +## 5. 保真策略的边界 + +当前门禁检查数字丢失/新增、英文术语变化、长度比例、CJK 字符对齐、无法解释的删除、多行提示词回显,以及引号/括号净平衡变化。允许解释为语气词、重复、自我纠正或词表替换的删除。 + +**不能据此宣称“不新增事实、不丢信息”已被证明:** + +- 单字替换、否定词删除、主体或条件变化仍可能漏过。 +- 数字子串检查不等于数值、单位、日期和数量级相等。 +- 净平衡不等于引号/括号严格嵌套正确。 +- 长度阈值可能误拒正确的去口头语处理。 +- 它针对同语言整理,**不能直接用于跨语言翻译**。 + +因此原文回看、拒绝原因和人工确认是架构要求,不是临时调试功能。默认保守,不用 LLM 的“自评正确”替代判定。 + +## 6. 证据及其适用范围 + +### 6.1 真人音频:识别精度 + +依据本机 `poc/voice-dictation/results/final-<数据集>-<模型>.json`。每个数据集内为逐片段 CER 的算术平均,忽略标点、空白和大小写;不是整套语料的 pooled CER。各列样本、说话人分布不同,不能直接比较难度或宣称统计显著。 + +| 二遍模型 | KeSpeech 23 条 | 会议 20 条 | 网络视频 10 条 | ASCEND 中英 8 条 | +|---|---:|---:|---:|---:| +| Qwen3-ASR-0.6B | 8.1% | 9.9% | 10.1% | 17.7% | +| X-ASR offline | 15.3% | 9.8% | 9.5% | 13.5% | +| Fun-ASR-Nano | 9.3% | 10.3% | 15.3% | 15.2% | +| SenseVoice | 13.6% | 11.2% | 13.2% | 14.6% | +| 仅实时流 | 24.2% | 12.8% | 12.6% | 15.8% | + +识别器筛选主要使用 `--pace 4 --cleaner none`,它测识别文本,不证明实时运行延迟。小样本只支持当前默认选择,不支持“Qwen3 在中英混说最好”的结论;该列中它甚至弱于仅实时流。新增词表密集真人语料后必须重评。 + +### 6.2 实时性与整理 + +- 旧版 X-ASR 二遍配置的 322 s 合成 soak:56 句、音频积压最高 40 ms、提交等待约 1.4 s。**不能把这个延迟归给最新 Qwen3 默认配置,也不能由此推出长录音内存有界。** +- 最新 Qwen3 二遍 + 整理的真人回放:网络视频 10 条,平均提交等待约 2.9 s、最高 5.9 s;ASCEND 8 条,约 2.0 s、最高 2.9 s。 +- 两套真人整理共 208 句,59 句有改写、14 句被门禁拒绝。门禁拦截了翻译原文、上下文泄漏和凭空补词等问题;之后还补了引号平衡检查,因此这些数量不是所有最终规则的最新接受率。 +- 整理删除口头语会让“对口述逐字稿的 CER”变差,故整理质量以保义盲评为主,不能追求最终 CER 越低越好。 +- 截至本次固化,最近完整验证为 58 个核心测试、1 个真实模型 UI 测试通过。存在一次未复现的会话测试失败记录,不能写成“从未失败”。 + +### 6.3 数据授权与复现 + +录音在用户目录 `~/VoicePoc/recordings/`,真人参考集在 `~/VoicePoc/reference-corpus/`,不提交到 Git;合成音频只作工程回归。复现命令和数据构建见 [POC 说明](../../../poc/voice-dictation/README.md)。 + +必须纠正旧记录中的授权简写: + +- [KeSpeech 官方许可](https://github.com/tzyll/KeSpeech/blob/main/dataset_license.md)明确限制非商业用途、改编及分发。历史结果只作探索记录,不能作为商业产品准入依据;继续用于本产品前须取得许可或更换数据。 +- [WenetSpeech 官方仓库](https://github.com/wenet-e2e/WenetSpeech)要求阅读许可并申请下载;镜像可访问不代表已获得全部使用权,迁移评测前核对授权。官方也记录了 TEST_NET 标签错误。 +- [ASCEND 数据卡](https://huggingface.co/datasets/CAiRE/ASCEND)标注 CC-BY-SA-4.0;使用和衍生分发须遵守署名及相同方式共享等条款。 +- 可复现清单还须保存原始 utterance ID、说话人/会话 ID、来源修订和音频哈希。当前导入脚本生成的拼接片段不是自然连续口述,不能用其推导长程语境能力。 + +## 7. 迁移顺序与验收门 + +### M1:移植核心,不改产品交互 + +把已验证的会话、接口和状态模型接入现有 Core;替换旧语音处理路径,而不是同时维护两套断句、整理或模型服务。测试窗口和回放工具仍引用同一个核心。 + +先补齐 POC 尚未完成的可靠性: + +- 无界音频/推理队列改为可度量的容量与处理预算;不得静默丢采集帧,不能跟上时停止并明确保留录音。 +- 音频缓存采用保留必要上下文的窗口,录音写盘离开实时音频回调;测内存而不只测积压。 +- 提交超时后冻结结果;取消并等待所有 worker/native 调用安全退出,杜绝迟到二遍结果改写提交快照、释放后继续推理。 +- 把识别/整理失败变成结构化错误和可见状态;现有异常后保留实时原文是明确降级,不得无声掩盖。 +- 术语表、模型版本及有效参数进入每次录音记录;权限撤销、磁盘满、崩溃残留录音有明确处理。 + +### M2:接入平台交互 + +平台层实现全局快捷键、按住/锁定录音、目标输入位置捕获与重新校验。用户确认才写入;目标失效保留草稿,禁止发送到新前台窗口。文本注入幂等需单独验证。 + +### M3:跨平台与目标配置验证后发布 + +至少覆盖 macOS M2/16 GB 基线和更高配置,以及 Windows 16 GB 及以上的同档设备。macOS 正常使用 Metal 等可用硬件加速;CPU-only 仅作补充诊断,不作为全产品性能目标。测试真人近讲/远讲、中英技术术语、否定/数字、自我纠正、5–30 分钟口述、取消/超时和用户编辑竞态。 + +迁移准入不是“所有测试绿”一项:同一授权语料对照不退化、首字与整理滞后可接受、积压不持续增长、内存有界、原文可恢复、隐私出口可审计。没有 M2/16 GB 实测前,不把 M3 Pro/36 GB 的体验外推到产品验证基线。 + +## 8. 后续翻译接口 + +后续实时转写翻译消费**有版本的二遍原文单元**,不强制等待整理完成。原文、整理稿、译文分别保留;默认翻译忠实原文,不能把“清理 → 翻译 → 再润色”串成每句话必经的三次生成。 + +先完成 [文本翻译及实时转写翻译 POC 计划](../plans/2026-10-29-translation-poc.md),证明文本翻译的质量、调度和 M2/16 GB 目标配置下的性能,再接语音事件。语音识别的保真原则保留,但跨语言语义重排与增量提交需要独立验证。 diff --git a/docs/superpowers/specs/2026-10-31-livelingo-composer-design.md b/docs/superpowers/specs/2026-10-31-livelingo-composer-design.md new file mode 100644 index 0000000..76d2e6b --- /dev/null +++ b/docs/superpowers/specs/2026-10-31-livelingo-composer-design.md @@ -0,0 +1,206 @@ +# LiveLingo 2.0 · Composer:完整设计与实施路线 + +日期:2026-10-31。状态:**设计已确认方向(原型 v3),实现从本文开始**。 + +依据:[语音识别设计](./2026-10-29-voice-dictation-design.md)、[翻译评测报告](../../../poc/text-translation/REPORT.md)、[交互原型 v3](../prototypes/livelingo-ux-v3/README.md)。凡是“已验证”都指向这些文档中的实测;凡是“未验证”都列在第 11 节,不当作结论。 + +## 1. 目标与不做什么 + +**目标**:Windows + macOS 桌面工具。全局热键在任意输入框旁唤起浮窗;说话、打字或粘贴;实时看到识别结果;按需整理 / 翻译;用户确认后写回原输入框。默认本地,云端显式开启。 + +**验证基线**:Apple M2 及以上、16 GB 及以上;Windows 使用同档 16 GB 设备独立验证。不以最低配置定义目标。 + +**不做(本阶段)**:自动发送(原型已去掉“写入并发送”,见 D12);系统声音捕获、说话人分离、语音合成(无已验证方案);除中英外的语言(POC 只验证了中英);插入符精确定位(见 11 节 S6)。 + +**工程规则(来自需求)**:代码、注释、commit 用英文;同一功能只由一个组件实现;不写防御性备选分支与“只修局部”的补丁式设计;不为兼容性堆 if-else;凭据不得硬编码。 + +## 2. 现有实现的处置 + +现有 Core / Desktop 约 2 万行,围绕 Gemma 4 26B(约 17 GB)+ Marian + 多级回退路由。依据: + +- 与基线冲突:26B 模型加载失败才回退到 E4B; +- 违反规则:`ModelSelectionPolicy`(489 行)、`FallbackTranslationInvoker`(468 行)、`HybridTranslationEngine` 是多级备选链;`Translation/` 内有两个断句器; +- 下载无完整性校验(`src/LiveLingo.Core` 中没有任何 SHA256 校验),llama.cpp 运行时每次解析 `releases/latest` 后下载并执行(`NativeRuntimeUpdater.cs:58`)。 + +| 范围 | 处置 | 理由 | +|---|---|---| +| `Core/Engines`、`LanguageDetection`、旧 `Translation`(含 `Segmentation`)、旧 `Speech`、`Processing` 中的处理器与 Qwen 宿主 | **删除** | 被新管线完整替代;方向由用户显式选择,不需要语言检测(连带移除 Lingua、Marian 的 OnnxRuntime / Tokenizers 依赖) | +| `Core/Models` 中的路由、选择、回退、遥测、Ollama / OpenAI 兼容 provider、`TranslationChatClient` | **删除** | 回退链;云端改用新的单一 OpenAI 兼容客户端(D2) | +| `Core/Models/Downloads`、`Installations`、`ModelManager`、下载协调器、`ModelDescriptor` | **保留并扩展** | 断点续传、解压、磁盘守卫已可用;新增 SHA256 与固定修订 | +| `Processing/LlamaServerProcessManager`、`NativeRuntimeUpdater` | **重写** 为 `Inference/LlamaServerHost` | 现为单实例、未固定版本;新设计需要按角色多实例、固定版本、哈希校验 | +| `Desktop/Platform` 热键、文本注入、窗口跟踪、剪贴板、密钥库(Windows / macOS) | **保留**,增加 `IsAlive` | 难度高、已在两个平台使用 | +| `Desktop/Platform` 两套音频采集(Mac 429 行、Win 244 行)、`IAudioCaptureService` | **删除**,改用单一 PortAudio 实现 | 一个功能一个实现;POC 已用 PortAudio 验证 macOS(Windows 见 S3) | +| `Desktop/Views`、`ViewModels`(含九个 Settings 协作者)、`Services/Speech`、`Cloud`、`LocalModelRuntimePresentation`、`TranslationTraceRow` | **删除** | 由 Composer 与精简设置替代 | +| 本地化(`LocalizationService` + `i18n/*.json`)、更新(Velopack)、`Build`(Nuke / WiX / pkg)、`HotkeyRecorder` | **保留** | 与功能无关的基础设施 | +| `tools/TranslationBenchmark` 及其测试 | **删除** | POC 语料评测(`poc/text-translation/scripts`)取代 | +| `src/LiveLingo.HfGguf` | **保留** | 仅负责 HF 解析 URL,职责单一 | +| `poc/` | **源码入库,数据不入库** | 语料与结果共 4.8 GB,含非商业许可数据;忽略 `corpus*/`、`results/`、`*.wav` | + +旧版以 tag `v1.4.2` 保留,不再维护两套。 + +## 3. 总体架构 + +```text + ┌────────────────── LiveLingo.Desktop (Avalonia 12) ──────────────────┐ + 全局热键 ───────▶│ App / Tray ─▶ ComposerWindow ◀──▶ ComposerViewModel (纯 C#,无 Avalonia) │ + │ │ │ │ + │ Platform(热键/窗口/注入/剪贴板/密钥/麦克风) │ + └─────────────────────┼──────────────────────┼─────────────────────────┘ + ▼ ▼ + ┌──────────────────────────────── LiveLingo.Core(逻辑,无 UI 依赖)────────────────────────────┐ + │ Drafting Draft · DraftUnit · TextUnits · Readiness · CommitSnapshot ← 唯一的草稿模型 │ + │ Dictation DictationSession(音频→原文单元)· FidelityGate · 识别器/VAD 契约 │ + │ Cleanup CleanupScheduler · ITextCleaner │ + │ Translation TranslationScheduler · ProtectedText · TranslationPrompt · ITranslator │ + │ Inference LlamaServerHost(固定版本、按角色多实例)· ChatClient(OpenAI 兼容,本地 / 云端共用)│ + │ Speech Sherpa 适配器(实时 / 二遍 / VAD) │ + │ Models 固定修订目录 · 下载 / 校验 / 安装(复用并扩展) │ + └────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +**一份草稿,三条视图**(原型已验证交互):`Draft` 按顺序保存 `DraftUnit`。每个单元有 `Raw`(识别 / 输入原文)、`Clean`、`Translation` 三个独立结果,各自状态为 `Pending / Ready / Error / Empty`,另有 `Owned`(用户修改过)。输出选择 `Clean | Translation | Raw` 决定写入哪一份。 + +### 3.1 单元与任务(唯一实现) + +| 职责 | 唯一组件 | 说明 | +|---|---|---| +| 草稿状态、就绪判定、提交快照 | `Drafting.Draft` | 取代 POC `Dictation.Core.Draft`、TranslationPoc `TranslationSession` 内的 `Entry`、原型 `draft.js` | +| 断句与处理路径 | `Drafting.TextUnits` | 取代 POC 的 `SentenceSplitter` 与 `Units`、旧 Core 的两套分句。支持完整句、行与缩进保留、受保护字面量、尾部强制成句;`RouteFor(text)` 判定“按句 / 整段” | +| 音频到原文单元 | `Dictation.DictationSession` | 持续流式识别 + 片段二遍;只产出原文单元与未成句尾部 | +| 整理 | `Cleanup.CleanupScheduler` + `FidelityGate` | 观察 `Draft`,对需要整理的单元排队;前文、`Continues` 由草稿推出 | +| 翻译 | `Translation.TranslationScheduler` | 观察 `Draft`,对需要翻译的单元排队;依赖键包含目标语言、命中术语、前文 | +| 本地 / 云端模型调用 | `Inference.ChatClient` | 本地 llama-server 与云端端点同一协议、同一客户端 | + +调度器不持有状态副本:任务以 `(epoch, unitId, translationRevision)` 作为票据,结果回写时由 `Draft` 校验;**过期、被删除或被用户修改的单元一律拒收**(原型 21 个模型测试与 28 个浏览器场景覆盖了这些不变量)。 + +### 3.2 关键不变量 + +1. 音频采集不等待模型推理;已归并的单元顺序不变。 +2. 用户修改(`Owned`)优先,晚到结果不得覆盖。 +3. 整理 / 翻译任务只改自己的那一个单元的那一个视图。 +4. 失败是单元状态,不是静默降级:翻译失败不会自动换成原文;整理被保真门禁拒绝时保留原话并显示原因。 +5. 回填只使用提交瞬间的**冻结快照**;录音中、选定输出未完成、目标失效时不可回填(目标失效仍可复制)。 +6. 回填前重新校验目标仍存在;不向新的前台窗口注入。 +7. 一个会话的 `Commit` 返回同一结果;**这不等于操作系统注入具备恰好一次语义**(验证见 S6)。 + +### 3.3 并发与错误 + +- `Draft` 非线程安全,由 `DraftSession` 以单一串行队列驱动;事件在锁外触发;UI 只在 `App` / View 层切回 UI 线程(沿用仓库规则)。 +- 本地推理由进程隔离:llama-server 崩溃只表现为该角色的任务失败与状态变化,下一个用户请求按需重新启动,没有隐藏重试与回退链。 +- 取消:Esc、新草稿、编辑源文都会使对应 `epoch` / `revision` 失效,已发出的 HTTP 请求被取消。 + +## 4. 推理运行时与模型 + +**单一本地运行时:llama.cpp `llama-server`,固定到 b11980**(POC 翻译评测所用版本)。每个角色独立进程,监听 `127.0.0.1` 随机端口,拒绝非回环地址;可执行文件与权重按 SHA256 校验后才启动。 + +| 角色 | 默认模型 | 说明 | +|---|---|---| +| 翻译 | Hy-MT2-7B Q4_K_M(质量)/ 1.8B(速度) | 评测:7B 显著优于 1.8B(MetricX 差 0.56),1.8B 首个可见译文约 93 ms | +| 整理 | Qwen3.5-4B Q4_K_M | 语音 POC 所用;改用 llama-server 后需验证等价(S2) | +| 实时识别 | X-ASR 480 ms 流式 int8 | sherpa-onnx 2.0.1 | +| 二遍识别 | Qwen3-ASR-0.6B int8 | sherpa-onnx | +| 切分 | Silero VAD 0.35 | sherpa-onnx | + +**内存与档位**:16 GB 机器同时驻留识别、整理、翻译三类模型的总占用**未经联合实测**(S1)。因此设计成两个档位,在安装时按物理内存预选,用户可改: + +- 均衡:翻译 1.8B,整理 4B(16 GB 默认,直到 S1 证明 7B 可行); +- 质量:翻译 7B; +- 按需驻留:整理与翻译模型在对应输出首次使用时才加载,闲置超时卸载;语音模型常驻在内存并闲置卸载。何时预热、留多久见 [模型预热设计](./2026-11-02-model-warmup-design.md)(取代原设想的单一 `ModelResidencyPolicy`:“何时”由 `ModelWarmup` 决定,“留多久”由 `ResidentModel` / `LlamaServerHost` 决定)。 + +**哈希与修订**:模型、llama.cpp 压缩包、sherpa 模型包的 URL、修订、大小、SHA256 写入目录;下载器校验失败即删除并报错。许可信息随目录显示(S5)。 + +## 5. 音频与语音管线 + +沿用已验证的 `DictationSession` 行为,**搬迁而不是重写**: + +- 采集:单一 PortAudio 实现,16 kHz 单声道 40 ms 帧;权限状态由各平台的小型 `IMicrophonePermission` 提供(macOS TCC,Windows 隐私设置)。录音文件保存仅在用户开启时发生,并落在用户目录。 +- 切分:硬停顿 800 ms、软切分 12 s、90 字尾部强制成句、二遍音频 padding 200 ms 等参数保持 POC 值(设计文档 §4)。 +- 迁移前提:`DictationSession` 从自带的句子模型改为写入统一的 `Draft`;POC 的 58 个核心测试作为特征测试一并搬迁,并用 POC 回放工具在同一授权语料上对照输出(不得退化)。 + +## 6. 翻译管线(已验证结论) + +- 纯文本提示,原文原样放在指令之后;**不使用 JSON 契约与引号包裹**(评测:前者在 12%–19% 条目失控,后者把 `\n`、`\"` 抄进译文)。 +- 单元保留前后空白,`Join` 原样恢复,版式通过率从 26% 提升到 78%。 +- **处理路径由输入来源决定,不靠内容嗅探推断结构**:打字 / 听写走完整句单元(说完等待 1.5–2 s);粘贴的完成文本走整段(质量更好、结构化通过率 94%)。`RouteFor` 只负责“是否像已完成的长文本”,用户可显式覆盖。 +- 受保护字面量(代码、URL、占位符)保留可逆保护;**评测未能证明它有收益**,保留的理由只剩首轮个例,需用代码密集语料复测(开放问题,不阻塞)。 +- 前文上下文(两组)同样**未测出收益**;M1 不实现(提示词已支持 `Context`),复测有收益再接入。 + +## 7. 桌面应用 + +### 7.1 Composer(原型 v3 的实现) + +> 界面技术的变更提案(Avalonia 外壳 + Web 渲染)见 [Web UI 外壳设计](./2026-11-01-web-ui-design.md),确认后以它为准;下面的行为与状态语义不变。 + +- `ComposerWindow`:置顶、透明、无任务栏图标、无边框;锚定在捕获到的目标窗口底部居中,向上生长;整段文本或转录自动展开为左右对照;窄屏堆叠。 +- `ComposerViewModel`(纯 C#):持有 `DraftSession`;暴露头部(目标、隐私、输出胶囊)、原话区、结果区单元列表、横幅、页脚(麦克风 / 状态 / 复制 / 写入)。命令与快捷键语义以原型测试为准:Enter 写入,处理中按 Enter 排队,Shift+Enter 换行,Esc 逐级退出,输入法组合中不触发。 +- 麦克风:点按免按住,按住超过 350 ms 为按住说话;键盘 Enter / Space 可操作。 +- 异常一律横幅:没声音、没权限、模型下载中、处理很慢、目标失效。 + +### 7.2 设置 + +四组:通用(热键、回填键、外观、动画)、语音与翻译(麦克风、识别语言、整理、方向、术语、保留录音)、隐私与云端(分项授权、服务商、草稿)、模型与关于(档位、状态)。设置模型整体重建,`SchemaVersion = 3`;从旧版只迁移热键与界面语言。 + +### 7.3 首次运行 + +不用向导。直接可打字;缺的模型以横幅显示下载进度并且**需要用户显式同意**;下载完成前仍可使用不依赖它的能力。 + +### 7.4 平台层 + +保留现有热键、窗口跟踪、注入(剪贴板 + 粘贴键)、剪贴板、密钥库。新增:`IWindowTracker.IsAlive(target)`;单一 `IMicrophone`(PortAudio)与 `IMicrophonePermission`。注入后恢复用户剪贴板的行为在 P0 验证。 + +## 8. 隐私与云端 + +- 默认没有任何出站网络请求。出站只有三类,各自独立授权:模型 / 运行时下载、云端翻译、云端整理(语音不上传)。 +- 云端后端是一个 OpenAI 兼容端点(可对接 Microsoft Foundry);凭据存系统密钥库,界面只显示“已配置”,不写入日志与设置文件。云端是用户的显式选择,不作为本地失败的隐式降级。 +- 日志不含正文与录音;诊断只含状态与计数。 + +## 9. 质量与门禁 + +- 保持现有 CI 门禁:行覆盖 ≥ 80%、分支覆盖达标、变异得分 ≥ 80%。**变异范围随新代码调整**:原先只覆盖 `Translation/**`,现改为 `Drafting/**`、`Translation/**`(调度、提示、保护)、`Dictation/**`(会话、门禁)、`Cleanup/**`;适配器(Sherpa、PortAudio、llama 进程)与 View 层沿用覆盖率排除并以 Nuke 的 Probe 目标做端到端验证。 +- 测试:xunit v3 + NSubstitute;ViewModel 无 Avalonia 依赖,可直接测试;Avalonia Headless 只测 View 绑定。 +- 行为基线:原型 `draft.test.cjs` 的 21 个用例逐条移植为 C# 测试;浏览器场景作为 Composer 验收清单。 +- 回放基线:POC 的语音与翻译评测脚本不入主构建,作为发布前对照(语料不入库)。 + +## 10. 优先级与里程碑 + +| 级别 | 含义 | 内容 | +|---|---|---| +| **P0** | 没有它就没有产品 | M0 清理与地基;M1 “打字 → 翻译 → 写回”整条链路 | +| **P1** | 核心价值 | M2 语音:采集、识别、整理、保真、听写交互 | +| **P2** | 完整性 | M3 转录与导出、术语、草稿恢复、云端、宽屏与托盘、无障碍 | +| **P3** | 可发布 | M4 目标机验证(M2/16 GB、Windows 16 GB)、安装包、迁移、文档 | + +- **M0 地基**:分支与基线提交;`.gitignore` 排除语料;删除第 2 节所列旧实现,使解决方案重新可构建、保留模块的测试通过;调整变异与覆盖配置。 +- **M1 打字翻译(P0)**:`Drafting`(含 `TextUnits`、就绪与快照)→ `ProtectedText` / `TranslationPrompt` → `ChatClient` → 固定修订目录与 SHA256 校验 → `LlamaServerHost` → `TranslationScheduler` → `ComposerViewModel` → Composer 视图与应用接线 → 真实模型端到端探针。**验收**:离线状态下,在任意应用的输入框旁唤起,打字或粘贴,译文写回,目标失效不乱写;全部新逻辑达到门禁。 +- **M2 语音(P1)**:`IMicrophone` → Sherpa 适配器(2.0.1)→ `DictationSession` 接入 `Draft` → 整理与保真 → 麦克风交互与横幅 → 回放对照 POC。**验收**:POC 评测语料上不退化;翻译在录音中逐句出现。 +- **M3 完整性(P2)**、**M4 发布(P3)**:任务卡见实施计划。 + +## 11. 开放问题与验证关口 + +| 编号 | 问题 | 时机 | 验证方法 | +|---|---|---|---| +| S1 | 16 GB 上识别 + 整理 + 翻译同时驻留的内存与延迟 | M1 末,**决定默认档位** | M2 / 16 GB 实机;记录峰值 RSS、首字与整句延迟 | +| S2 | 整理改走 llama-server 是否与 POC(LLamaSharp)等价 | M2 开始 | POC 回放工具同语料对照门禁拒绝率与 CER | +| S3 | Windows:PortAudio 采集、热键、注入、llama.cpp CPU / Vulkan 速度 | M2–M4 | 16 GB Windows 实机 | +| S4 | sherpa-onnx 1.12.38 → 2.0.1 的回归 | M2 | 回放对照 | +| S5 | 所有模型与运行时的许可,是否允许按用户授权下载并展示 | M1 | 逐项核对许可文本,目录显示许可 | +| S6 | 目标窗口内光标位置获取、跨应用注入的可靠性与恰好一次 | M1–M3 | 原生输入框、Electron、浏览器、终端逐类实测;取不到时锚定窗口底部 | +| S7 | 1.4.2 用户磁盘上约 17 GB 的旧模型如何清理 | M4 | 显式提示后清理,不静默删除 | + +## 12. 关键决策与备选 + +| 编号 | 决策 | 备选 | 取舍 | +|---|---|---|---| +| D1 | 在分支上重写领域层,复用基础设施 | 在旧结构上渐进改造 | 旧结构以多级回退为核心,渐进改造要同时维护两套;旧版保留在 tag | +| D2 | 本地 + 云端共用一个 OpenAI 兼容客户端 | 为每种服务商各写适配器 | 协议相同;少一套适配器与回退路由 | +| D3 | 只用 llama-server(进程隔离) | LLamaSharp 进程内;两者混用 | 一种运行时、可固定可校验、崩溃不拖垮 UI;代价是整理需验证等价(S2) | +| D4 | 单一 PortAudio 采集 | 保留各平台原生采集 | 一个实现、帧格式与 POC 一致;Windows 待验证(S3) | +| D5 | 统一草稿模型 | 各管线保留自己的状态 | 同一概念只实现一次;代价是迁移 `DictationSession` 需特征测试保护 | +| D6 | 纯文本提示,无 JSON 契约 | JSON / 引号包裹 | 评测推翻了后两者 | +| D7 | 不引入 OpenSpec | OpenSpec 管理变更提案 | 仓库已有 `docs/superpowers` 的设计与计划约定;再加一层工具只增加维护成本,价值不足 | +| D8 | 设置重建(schema 3),只迁移热键与语言 | 兼容全部旧字段 | 旧字段大多对应被删除的路由与引擎;兼容会堆积无用分支 | +| D9 | 没有隐式云端降级 | 本地失败自动走云端 | 隐私要求与“一个功能一个路径” | +| D10 | 浮窗锚定目标窗口,不依赖插入符 | 精确插入符定位 | 各应用差异大,先保证稳定;S6 再评估 | +| D11 | 变异范围随新模块调整 | 沿用原范围 | 原范围会让新核心逻辑不受门禁约束 | +| D12 | 取消“写入并发送” | 保留 | 原型决定回填只写入;误发代价高于多按一次键。若用户需要,作为 P2 的显式可选项重新加入 | +| D13 | 默认均衡档(1.8B 翻译) | 默认 7B | 7B 的联合内存未经验证(S1);评测显示 1.8B 差距可测但可用 | diff --git a/docs/superpowers/specs/2026-11-01-preferences-design.md b/docs/superpowers/specs/2026-11-01-preferences-design.md new file mode 100644 index 0000000..788c036 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-preferences-design.md @@ -0,0 +1,144 @@ +# LiveLingo 2.0 · 用户偏好设计:什么要记住,什么走默认 + +状态:**已确认,已实施(真机未验证)**。已确认的决定:①“记住每个应用的上次选择”**默认开**;②**不设失效时间**(记忆只受 50 个应用的上限约束,最久未用的先淘汰);③“唤起后自动听写”**先做全局开关**,按应用覆盖留待后续。相关:[Composer 设计](./2026-10-31-livelingo-composer-design.md) §7.2、[Web UI 设计](./2026-11-01-web-ui-design.md)。 + +## 1. 要解决的问题 + +现在每次唤起,浮窗都回到出厂状态:输出总是“整理”,翻译方向总是设置里的默认值。在 Slack 里用户刚选了“翻译成 English”,下次唤起又回到“整理”,要再选一次;而在 Cursor 里和 Agent 讨论时,他要的是“整理”(保持中文)。**同一个人在不同应用里的需求不同,同一应用里的需求又相当稳定。** 反过来,也不能什么都记:把“原话”记住,会让用户悄悄绕过整理;把麦克风记住成“自动开启”,会让用户在没意识到的时候开着录音。 + +目标:用户在每个场景里**第一次选择之后就不用再选**,并且**不会因为记住了什么而出错或泄露**。 + +## 2. 原则:一个选择要不要被记住 + +按顺序问四个问题,全部通过才记住。 + +| # | 问题 | 不通过的例子 | +|---|---|---| +| 1 | 它描述的是**去向**(谁会读、什么语言)而不是**这份内容**?去向是稳定的,内容是一次性的 | 整段处理、宽屏对照、仅复制:只对这一份草稿成立 | +| 2 | 它是用户的**主动选择**,而不是系统因故障做的切换? | 处理太慢时点“改用原话”;模型缺失时的回落 | +| 3 | 记错了,**代价小且看得见**? | 悄悄打开录音、悄悄开启云端:看不见,代价是隐私 | +| 4 | 它**会降低保护**(保真检查、隐私默认)吗?降低保护的选择不跨次保留 | “原话(不整理)”:跳过整理与保真 | + +因此得到四种“归属”: + +| 归属 | 谁改变它 | 例子 | +|---|---|---| +| **设置(默认)** | 用户在设置里主动改;不会自动变 | 唤起快捷键、回填键、界面语言、主题、减少动画、麦克风设备、翻译档位、术语表、云端开关与密钥、**默认输出**、**默认翻译方向**、**唤起后自动听写** | +| **记忆(每个应用)** | 用户在浮窗里主动选择后自动记录,可忘记 | 该应用的输出(整理 / 翻译)与翻译方向 | +| **推导(由情境得出)** | 不存储,每次由当前情境算出 | 无输入框 → 仅复制;整段文本 → 整段路径与宽屏;导入的转录 → 时间线;浮窗位置与宽度 → 贴着目标窗口、限制在屏幕内 | +| **本次(只在这份草稿内)** | 用户临时切换,唤起后丢弃 | “原话”、整段处理开关、宽屏开关、仅复制开关、浮窗被拖到的位置、听写开关状态、草稿文字 | + +## 3. 逐项分类 + +### 3.1 浮窗里的选择 + +| 项 | 归属 | 理由 | +|---|---|---| +| 输出:整理 | 默认(设置“默认输出”,出厂为整理) | 是起点,不需要记 | +| 输出:翻译 | **记忆(每应用)** | 去向稳定:给海外同事的 Slack 总是英文;通过原则 1–4 | +| 输出:原话 | **本次** | 跳过整理与保真(原则 4);用户显式用时再选一次,成本是一个键 `⌘3` | +| 翻译方向(English ↔ 中文) | **记忆(每应用)**,随“翻译”一起记 | 方向属于去向;新应用用设置里的默认方向 | +| 仅复制 | 推导;用户手动切换为**本次** | 有没有输入框是情境,不是偏好 | +| 整段处理、宽屏对照 | 推导;手动切换为**本次** | 属于内容 | +| 听写开 / 关 | **本次**;是否“唤起即听写”见 3.2 | 麦克风是隐私敏感操作,不从行为里学习 | +| 草稿文字 | **本次**;上次未写入的草稿只“提供”不“恢复”(已实现) | | +| 浮窗位置 | **本次**;每次唤起重新贴着输入框 | 拖动是为了躲开遮挡,不是长期偏好;可选“固定位置”见 §8 | +| 系统因故障引起的切换(慢 → 原话、模型缺失) | **不记** | 原则 2 | + +### 3.2 设置里的项 + +| 项 | 归属 | 说明 | +|---|---|---| +| 唤起快捷键、回填键、界面语言、主题、减少动画 | 设置 | 一次性配置,任何自动记忆都会让它“莫名其妙地变” | +| 麦克风设备 | 设置 | 蓝牙耳机会改变系统默认;设备缺失时报横幅,不静默换设备(已实现) | +| 云端翻译 / 整理、端点、模型、密钥 | 设置,**永不记忆** | 扩大数据出境范围必须是显式同意,不能从“上次”继承到新场景 | +| 翻译档位、术语表 | 设置 | 术语按应用分组留作后续(§9) | +| **默认输出**(新增) | 设置 | 整理 / 翻译 / 原话;没有记忆的应用以它为起点。出厂为整理 | +| **唤起后自动开始听写**(新增,出厂关闭) | 设置;可以按应用覆盖(在 §6 的应用列表里) | 想要“一按热键就说话”的用户显式打开;不做“学习式”触发 | +| 导出格式 | **记忆(全局)** | 与应用无关:导出对话框把上次用的格式标出并默认聚焦。文件夹不记——系统文件对话框自己会记 | +| 识别语言、整理强度(后续功能) | 设置为默认;**可按应用覆盖** | 写技术聊天与写文档对“整理强度”要求不同 | + +## 4. 场景如何区分 + +**键 = 目标应用的稳定标识**,不用窗口标题(标题带页面与文档名,属于内容,隐私风险大且不稳定)。 + +- macOS:bundle id(如 `com.microsoft.VSCode`),而不是显示名;Windows:可执行文件名(小写)。**需要扩展 `IWindowTracker`**:现在 `TargetWindowInfo` 只有 `ProcessName`(显示名),不够稳定。 +- 没有输入框(仅复制):用固定键 `∅`,也有自己的记忆(用户常把它当翻译器用)。 +- 三个主要场景自然落在不同应用上:与 Agent 讨论(Cursor、终端、VS Code)、协作沟通(Slack、微信、Teams)、文档(Word、Notion、Obsidian)。**不需要用户建“场景”**:应用就是场景。 +- 浏览器、Electron 这类“一个应用里有多个场景”的宿主是已知薄弱点:键仍是应用,但**可见**(§5),错了只需一个键改正,也可以在设置的应用列表里忘记。 + +## 5. 使用时的规则 + +唤起时,起点按以下顺序取第一个有效值: + +``` +本次(只存在于草稿内) > 该应用的记忆(仍然可用) > 设置里的默认 > 出厂默认 +``` + +“仍然可用”:记忆里的输出必须是当前提供的输出(例如关闭了整理就没有“整理”)、云端或模型状态允许;**不满足就忽略这一条记忆,不报错**(它不是错误,是已失效的偏好)。 + +写入记忆的时机:**只在用户主动选择时**——输出药丸菜单、`⌘1` / `⌘2`、互换语言 `⌘L`。系统的切换(“改用原话”等)通过另一条路径,不触发记录。成功写入后刷新这条记忆的“最近使用”时间。 + +**记忆必须看得见**:当起点来自记忆且不同于默认时,空白状态的提示下面显示一行: + +> 已沿用 Slack 上次的选择:翻译成 English · **恢复默认** + +输出药丸本来就显示当前状态;“恢复默认”删除这条记忆并回到默认。输出菜单底部再加一行说明“选择会为 Slack 记住”,让这件事不是隐含的。 + +## 6. 设置里怎么管 + +- **通用**:开关“记住每个应用的上次选择”(默认开)。说明:只记应用标识与输出、方向;不记标题、文字、录音;仅在本机。 +- **语音与翻译**新增:“默认输出”“默认翻译方向”(已有)“唤起后自动开始听写”。 +- **隐私与云端**新增:“应用偏好”列表——每行:应用名 · 输出 · 方向 · 上次使用时间 · 忘记;顶部“清除全部”。同一个表将来承载“按应用覆盖”的项(自动听写、整理强度、术语分组),所以**记忆与按应用的配置是同一个概念,不分两套**。用户在这里手工改一行,等价于“为该应用预设”。 + +## 7. 存储与生命周期 + +- **单独的文件** `preferences.json`(`SchemaVersion = 1`),不放进 `settings.json`:设置是用户写的配置,会被导出和迁移;记忆是自动产生的、可随时丢弃的。设置导出不含它。 +- 内容:`apps`(键 → 显示名、输出、翻译方向、最近使用时间)、`exportFormat`(上次导出的格式)。不含标题、文字、录音、密钥。 +- 上限 50 个应用,淘汰最久未用的;**没有失效时间**(已确认):记忆一直有效,直到用户在设置里忘记它、清除全部,或被更新的应用挤出。 +- 原子写入(先写临时文件再替换),防抖;读失败(损坏)时从空开始并记一条不含内容的日志——这是“偏好丢了”,不是错误。 +- 只在本机,不同步、不进日志与诊断、不进云端调用。 + +## 8. 架构 + +遵守“同一功能一个实现”: + +- `ComposerPreferences`(唯一服务,接口 `IComposerPreferences`):`ComposerStart Resolve(AppKey? app)`、`void Remember(AppKey? app, OutputChoice choice)`、`Forget(AppKey)`、`Clear()`、`Entries`。纯逻辑,时间由 `TimeProvider` 注入,存储由 `IPreferencesStore`(JSON)实现,便于测试。 +- `AppKey`:由平台层给出的稳定标识(`IWindowTracker` 增加 `AppKey`,macOS 用 bundle id,Windows 用小写 exe 名)。 +- `ComposerViewModel`:构造时接收 `ComposerStart`(起点的输出与方向),不知道记忆存在;新增 `ChooseOutput(output)` / `ChooseTarget(language)` 作为**用户选择入口**,设置值并触发事件 `ChoiceMade`;横幅等系统路径直接设 `Output`,不触发。Web 意图 `output` / `swapLanguage` 走 `Choose*`。 +- `App.SummonAsync`:`Resolve(app)` → 构造 VM;订阅 `ChoiceMade` → `Remember`;写入成功时 `Touch`。 +- 快照增加 `Source.Remembered`(来源应用名与“恢复默认”)供提示行使用;`restoreDefault` 为新的封闭意图。 + +## 9. 不做什么(以及为什么) + +| 不做 | 理由 | +|---|---| +| 全局“上次用的输出” | 一个应用里的选择会污染另一个应用(在 Slack 翻译后,到 Cursor 变成写英文) | +| 从行为里学习自动听写 | 麦克风是隐私敏感操作,必须显式 | +| 记住“原话” | 它绕过整理与保真;用户要用时 `⌘3` 即可 | +| 按窗口标题 / 网页 / 文档记忆 | 标题是内容,隐私风险大,且不稳定 | +| 同步到其他设备 | 应用标识是个人活动痕迹,默认只在本机 | +| 预置“应用类别”的默认(如终端默认保守整理) | 现在没有这些功能可调;等整理强度等功能存在,且有数据证明有必要再加 | +| 让用户建命名的“场景 / 档案” | 应用已经是场景;多一层概念增加学习成本,用 §6 的应用列表已能覆盖 | + +## 10. 开放问题与验证 + +| 编号 | 问题 | 结论 | 验证 | +|---|---|---|---| +| P1 | “记住每个应用的上次选择”默认开还是关 | **已确认:开**(只记应用标识与两个枚举,本机,可一键清除) | 隐私页明确披露 | +| P2 | 失效时间 | **已确认:不设失效时间** | 用户测试:被记错(沿用了不想要的翻译)的次数;若浏览器类显著偏高,再考虑按域名而不是失效 | +| P3 | 浮窗位置要不要“固定”选项 | 暂不做;若用户反馈每次要拖,再加“固定位置”(菜单里的开关,每块屏幕一份) | 观察拖动频率 | +| P4 | “唤起后自动听写”是否按应用可覆盖 | **已确认:先做全局开关**;按应用覆盖留待应用表扩展时 | 第一期只有全局开关 | +| P5 | 目标应用标识在 Windows 上用 exe 名是否够区分 | 对 UWP / 多个托管同一进程的应用可能不够 | Windows 实机核对;不够再换 AUMID | +| P6 | 提示行与菜单说明会不会干扰 | 只在“来自记忆且不同于默认”时出现 | 用户测试 | + +## 11. 实施拆分与进度 + +| 级别 | 任务 | 验收 | 状态 | +|---|---|---|---| +| P0 | `AppKey`(平台层)+ `ComposerPreferences` + `IPreferencesStore` + VM 的 `Choose*` 与 `ChoiceMade`;唤起时应用起点;“默认输出”设置 | 单测覆盖:优先级、不可用时忽略、系统切换不记录、LRU;重新唤起沿用 | 已完成 | +| P0 | 提示行与“恢复默认”;输出菜单说明 | Playwright 场景(两个引擎)与快照夹具 | 已完成 | +| P1 | 设置:开关、应用偏好列表、清除全部;隐私披露 | 设置页场景;删除后立即生效 | 已完成 | +| P1 | 导出格式的全局记忆 | 导出对话框标出并聚焦上次的格式 | 已完成 | +| P2 | “唤起后自动听写”(全局开关) | 默认关;打开后唤起即进入“正在听”,权限 / 模型缺失走既有横幅 | 已完成(真机未验证) | +| P3 | 按应用覆盖:自动听写、整理强度、识别语言、术语分组(随对应功能一起) | 同一个应用表 | 未开始 | diff --git a/docs/superpowers/specs/2026-11-01-web-ui-design.md b/docs/superpowers/specs/2026-11-01-web-ui-design.md new file mode 100644 index 0000000..c35bc40 --- /dev/null +++ b/docs/superpowers/specs/2026-11-01-web-ui-design.md @@ -0,0 +1,220 @@ +# LiveLingo 2.0 · Web UI 外壳设计 + +状态:**负责人已确认采用(2026-11-01,W0 在 macOS 通过)**。前置:[Composer 设计](./2026-10-31-livelingo-composer-design.md)、[原型 v3](../prototypes/livelingo-ux-v3/README.md)。实施见 [交接计划](../plans/2026-11-01-web-ui-and-release-handoff.md)。 + +## 1. 结论 + +**保留 Avalonia 作为桌面外壳,界面(浮窗与设置)改用 Web 技术绘制,托管在 Avalonia 12 的 `NativeWebView` 里(macOS:WKWebView;Windows:WebView2)。** 状态与业务仍全部在 C#(`ComposerViewModel`、`SettingsViewModel`、Core),Web 只是状态的渲染层与输入采集层。 + +理由: + +1. **原型就是 Web。** `prototypes/livelingo-ux-v3` 的 `style.css`(238 行)、`index.html`、28 个 Playwright 场景已经定义了视觉与交互。现在用 XAML 重画一遍,需要逐项对照截图才能一致(上一轮就是这样做的),仍有差距(无背景模糊、悬停、动画、字体渲染)。直接复用 CSS 令牌与标记,一致性由浏览器保证。 +2. **中文输入法。** 听写工具的使用者会在原话框里打中文。WKWebView / WebView2 的输入法候选窗定位、组合态与回车确认由系统 Web 引擎处理,比 Avalonia `TextBox` 成熟;原型也有“输入法组合中不触发回车”的用例。 +3. **迭代成本。** 布局、状态样式、动画、深浅色在 CSS 中改动是分钟级;有热重载;可以用 Playwright(WebKit 与 Chromium 两个引擎)自动截图对比。 +4. **无障碍。** 原生 ARIA、读屏与键盘焦点模型,由引擎映射到 VoiceOver / Narrator。 +5. **不引入第二个运行时。** 所有能力(热键、窗口跟踪、注入、麦克风、模型、llama-server)已在 .NET;外壳不换。 + +## 2. 备选与取舍 + +**不随包带浏览器内核。** `NativeWebView` 使用系统自带的引擎:macOS 的 WKWebView(随系统)、Windows 的 WebView2(Windows 11 预装;Windows 10 可能缺,见 §11)。应用只增加约 1.5 MB 的 NuGet 包。代价是引擎版本由用户系统决定,因此: + +- Web 代码的特性基线以 WebKit(macOS 最低支持版本自带的 Safari 内核)与 Chromium(WebView2)的**交集**为准;用到较新的 CSS / JS 特性(`:has()`、`backdrop-filter`、容器查询等)前,先在两个引擎的 Playwright 项目里验证。 +- 前端测试同时跑 WebKit 与 Chromium(§7),分别对应 macOS 与 Windows。 + +| 方案 | 做法 | 结论 | 原因 | +|---|---|---|---| +| **A(推荐)Avalonia 外壳 + NativeWebView** | 一个透明无边框 Avalonia 窗口承载 WebView;JSON 消息桥 | **采用** | 重用全部 C#;MIT 许可,随 Avalonia 12 开源(Avalonia.Controls.WebView 12.1.1,net10.0);不带 Chromium | +| B 继续 Avalonia XAML | 现状 | 否决 | 视觉一致性靠手工对照;输入法与文本渲染不如 Web;每次改版都要改 XAML 与代码后置(`ComposerWindow.axaml.cs` 495 行都是“状态→控件”映射) | +| C Tauri 2 外壳 + .NET 边车 | Rust 壳 + 系统 WebView,.NET 作子进程 | 否决 | 要用 Rust 重写热键、窗口跟踪、注入、托盘,并设计进程间协议;“同一功能一个实现”被破坏 | +| D Electron + .NET 边车 | 同上,带 Chromium | 否决 | 额外约 150 MB 与一份 Chromium 常驻内存,与模型争 16 GB;同样要重写平台层 | +| E Photino / 直接嵌 WebView2 与 WKWebView | 轻壳 | 否决 | 要自己做透明窗口、置顶、非激活、托盘;Avalonia 已有且已验证 | +| F 本地 HTTP 服务 + 系统浏览器窗口 | Kestrel + 浏览器 | 否决 | 开出监听端口,窗口不可控,无法做透明浮窗与回填焦点 | + +## 3. 已验证与未验证 + +在 macOS(Apple Silicon,Avalonia 12.1.4 + Avalonia.Controls.WebView 12.1.1)上运行验证程序 `tools/WebViewSpike`(`--auto`;已完成并移除,可从 git 历史找回): + +| 项 | 结果 | +|---|---| +| 适配器 | `WkWebView`,场景 `NativeControlHost`,无需额外安装 | +| `NavigateToString(html, baseUri)` 加载本地页面 | ✅ | +| C# → JS:`InvokeScript` 返回基本类型 | ✅;**返回 Promise 会抛 `NSErrorException`**,脚本必须用 `void (...)` 包裹 | +| JS → C#:页面里的 `invokeCSharpAction(string)` → `WebMessageReceived` | ✅(只传字符串,用 JSON) | +| CSP 生效(`securitypolicyviolation` 触发) | ✅ | + +**未验证,必须由 S8 关口确认,任何一项失败都回到第 8 节的退路:** + +| 编号 | 问题 | 通过标准 | +|---|---|---| +| S8a | 透明无边框窗口里,WebView 背景是否真透明,圆角与阴影不出白底 / 黑角(macOS、Windows) | 肉眼:窗口外侧只有面板的圆角与阴影;浅色与深色桌面壁纸上都一样 | +| S8b | 焦点:热键唤起后窗口激活,`textarea` 自动获得焦点并可直接打字;失焦(`Deactivated`)事件仍到达 C#;关闭后焦点回到原应用,回填成功 | 手工 10 次,均成功 | +| S8c | 中文输入法:拼音输入、候选窗位置、回车确认候选不触发“写入” | macOS 系统拼音与 Windows 微软拼音各测 | +| S8d | 预热:窗口隐藏时保持 WebView 存活,再唤起的延迟 | 热键到首帧可输入 p95 ≤ 150 ms(M2 级别);冷启动首次 ≤ 600 ms | +| S8e | 内存与进程:WebView 常驻增加的内存 | macOS ≤ 120 MB,Windows ≤ 200 MB(含渲染进程);联合内存仍在 S1 的 16 GB 余量内 | +| S8f | Windows 透明:WebView2 作为子窗口时分层窗口是否合成 alpha;不行则用 `WindowsWebView2EnvironmentRequestedEventArgs.ExperimentalOffscreen = true`(由 Avalonia 合成,场景 `OffscreenRenderer`) | 同 S8a | +| S8g | `AdapterType.Headless` 是否能让 C# 侧集成测试在无界面环境跑 | 能则加入 CI;不能则桥的测试用假 `IWebHost` | + +### W0 实测记录(2026-11-01,macOS 通过;Windows 推迟到 R3) + +状态:**macOS 全部通过,负责人已确认采用 Avalonia + WebView 方案。** 环境:Apple M3 Pro / 36 GB(高于基线 M2 / 16 GB,绝对数偏乐观,用来判断趋势),macOS,Avalonia 12.1.4,WkWebView。Agent 进程无屏幕录制与辅助功能权限,肉眼项与 S8d 最终数据由用户在自己的终端取得。命令:`dotnet run --project tools/WebViewSpike -- [--auto | --bench [--park] [--hide-ms=N] | --headless]`。 + +| 编号 | 状态 | 结论与数据 | +|---|---|---| +| 桥 / CSP(`--auto`) | ✅ macOS | 4 项 PASS;Windows 未跑 | +| S8a 透明 | ✅ macOS | 用户肉眼确认:浅色与深色壁纸上都没有白底或黑角 | +| S8b 焦点 | ✅ macOS | 用户确认:窗口出现后不点击即可直接打字;用户终端实测 `activeElement = textarea`、`hasFocus = true`。“关闭后焦点回到原应用并回填”属于 W4 步骤 2 | +| S8c 输入法 | ✅ macOS 系统拼音(需一处特判) | 用户实测日志:组合中的按键 `isComposing:true`、`keyCode:229`;候选窗正常,能上屏中文。**但确认候选的回车到达时 `compositionend` 已先触发,该 `keydown` 是 `key:"Enter"`、`keyCode:229`、`isComposing:false`**;用户随后有意按的回车是 `keyCode:13`。只判 `isComposing` 会把确认候选误当成“写入”。**结论:Web 的回车处理必须同时忽略 `e.isComposing` 与 `e.keyCode === 229`(WebKit 的已知行为),只在这一处实现;W3 用 WebKit 项目加用例。** Windows 微软拼音未测 | +| S8d 预热延迟 | ✅ macOS 采用“常驻 + 停放”(`Hide()` 不采用) | **用户终端实测(`--bench --park --hide-ms=2000`,n = 30):冷启动首帧 24 ms;“脚本已执行、textarea 已聚焦”4–10 ms;首帧 p50 = 42 ms、p95 = 116 ms、max = 119 ms(≤ 150 ms);结束时 `activeElement = textarea`、`hasFocus = true`。** 结论:W4 的预热窗口不要 `Hide()`,而是保持显示、透明、移出屏幕,唤起时移回。下面是 Agent 进程里较早的数据(应用不在前台、屏幕状态不明),尾部偏高,保留作对照,真实热键流程在 R2 复测。早期数据(首帧 = `requestAnimationFrame` 回调):**`Hide()` 再 `Show()`**,n = 30:隐藏 0.4 s 时 p50 = 44 ms、p95 = 610 ms(4 次落在 323–650 ms);隐藏 2 s 时 p50 = 58 ms、p95 = 618 ms(约 1/3 落在 360–650 ms)。**窗口常驻、只置透明并移出屏幕(`--park`)**:隐藏 0.4 s 时 p50 = 29 ms、p95 = 34 ms;隐藏 2 s 时 p50 = 44 ms、p95 = 358 ms(2/30 落在 358–613 ms)。尾部集中在 330 ms 与 650 ms 附近,像 WebKit 对不可见页面的节流恢复。`Hide()` 的路径没有在用户终端复测,因为已决定不用 | +| S8e 内存 | ✅ macOS | 新增 WebKit 进程(用户终端实测):WebContent 31 MB、GPU 25 MB、Networking 10 MB,合计约 66 MB(≤ 120 MB)。应用进程 184 MB 是整个 Avalonia 运行时,不能归给 WebView | +| S8f Windows 透明 | ⏳ 推迟到 R3 | 负责人决定:现在没有 Windows 机器,W0 按 macOS 通过;Windows 的 `--auto`、透明(含 `ExperimentalOffscreen` 退路)、微软拼音与 WebView2 内存在 R3 实测。若 R3 失败,按 §8 退路处理 | +| S8g Headless | ❌ 不可用,退路成立 | `Avalonia.Headless` 平台下 `NativeWebView` 不创建适配器(无 `AdapterCreated`,`InvokeScript` 抛“Unable to invoke script before any page was loaded”);`AdapterPreference = [Headless]` 同样无效。`Headless` 适配器本身只是桩,由调用方提供 `ScriptEngine` 与 `HttpHandler`,不会运行页面。结论:桥的 C# 测试用假 `IWebHost`(§7 已规定) | + +## 4. 架构 + +``` +┌────────────────────────── Avalonia 外壳(C#)─────────────────────────┐ +│ ComposerWindow(透明、置顶、无边框;位置、激活、失焦、尺寸) │ +│ └ WebSurface ── NativeWebView ── 页面(WebPage 拼装,Preact) │ +│ │ ▲ │ +│ intents │ │ state / strings / toast / focus │ +│ ▼ │ │ +│ WebBridge(JSON、校验、合并推送) │ +│ ├ ComposerIntents ──► ComposerViewModel ──► DraftSession / Core │ +│ └ ComposerViewState ◄── ComposerViewModel + ComposerTexts(本地化) │ +│ SettingsWindow(同一 WebSurface,另一入口)…… SettingsViewModel │ +│ 平台层(热键、窗口跟踪、注入、麦克风、密钥库)与托盘:不变 │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +原则: + +1. **单向数据流。** Web → C#:只发“意图”(用户做了什么)。C# → Web:只发“整份状态快照”。Web 不计算业务状态:没有草稿状态机、没有调度、没有计时(原型的 `draft.js` 与模拟管线**不进产品**,只留在原型目录)。 +2. **一份本地化来源。** `Resources/i18n/*.json` 仍是唯一来源。静态文案(标签、提示、可访问名)作为一份 `strings` 在语言确定时发给 Web 一次;带参数的文案(状态行、横幅、行内说明)由 `ComposerTexts` 在 C# 格式化后放进快照。Web 里没有 i18n 库、没有文案常量。 +3. **一份契约来源。** 消息类型只在 C# 里定义(`record` + `System.Text.Json` 多态)。Web 不手写类型,也没有 TypeScript(D20);漂移由两道测试发现,而不是靠编译:① **快照黄金文件**:C# 测试把 `ComposerViewState.From(...)` 的各状态序列化成 `tests/LiveLingo.Web.Tests/Fixtures/*.json` 并与已提交文件逐字比对(不一致即失败);Web 场景测试把**同一批文件**喂给页面。② **意图用真解析器验**:Web 场景测试截获页面发出的消息,交给 C# 的 `WebIntent` 解析器,解析失败即失败。 +4. **视图映射可测。** 今天 `ComposerWindow.axaml.cs` 里“状态→控件”的 495 行不可测;它变成纯函数 `ComposerViewState.From(vm, texts)`,由 C# 单测覆盖。 + +## 5. 契约(v1) + +所有消息是 JSON 字符串,带 `"v":1`。 + +### 5.1 Web → C#(意图,封闭集合) + +C# 用多态反序列化为封闭的 `WebIntent` 联合;未知 `name`、缺字段、超长(正文 > 1 MiB)、类型不符一律丢弃并记一条不含正文的日志。意图不返回数据,结果通过下一份状态快照到达。 + +| 域 | 意图(`name`;字段) | 对应 VM | +|---|---|---| +| 生命周期 | `ready`;`size{width,height}`(整数 px,CSS 像素,含 18 px 阴影边距) | 首次推送;窗口高度 | +| 原话 | `source{text,rev}` | `SourceText`(见 5.3) | +| 麦克风 | `micDown`;`micUp` | `PressMicAsync` / `ReleaseMicAsync`(350 ms 阈值仍由 C# 计时) | +| 输出 | `output{value:"clean"\|"translate"\|"original"}`;`swapLanguage` | `Output`;`SwapLanguage` | +| 写入 | `commit{fromKey}`;`copy`;`cancel`(Esc 逐级);`hide` | `CommitAsync`;`Copy`;`Cancel`;`Hide` | +| 行 | `retry{i}`;`edit{i}`;`editText{text}`;`saveEdit`;`cancelEdit`;`useOriginal{i}` | 同名命令 | +| 草稿 | `newDraft`;`restoreRecent{index}`;`clearRecent`;`import`;`export{format}` | C# 负责文件选择器与对话框 | +| 布局 | `wide{on}`;`whole{on}`;`copyOnly{on}` | `ForceWide`、`ForceWhole`、`CopyOnly` | +| 横幅 | `banner{action}` | `RunBannerActionAsync` | +| 导航 | `openSettings{section}` | `OpenSettings` | +| 设置 | `setting{key,value}`;`addTerm{source,target}`;`removeTerm{index}`;`cloudToggle{step,on}`(C# 决定是否弹同意);`saveKey{value}`;`forgetKey`;`clearRecent`;`removeLegacyModels` | `SettingsViewModel` | + +W2 落地时的补充:`micToggle`(键盘 Space/Enter 切换听写,对应 `ToggleListeningCommand`,与旧窗口行为一致);`size` 范围 1–4000,`index/i` 范围 0–9999,`openSettings.section` 只允许 `general|voice|privacy|models`;枚举只接受 camelCase 字符串;`v` 必须为 1;多余字段同样拒绝;拒绝原因只记类别(`too large`/`JsonException`/`version`/`invalid`),不回显消息内容。 + +秘钥规则:`saveKey` 的值只在这一条消息里出现,C# 立即写入密钥库;快照里只有 `hasStoredKey`;日志不得记录消息体。 + +### 5.2 C# → Web + +| 消息 | 内容 | 时机 | +|---|---|---| +| `strings` | `{locale, dict}`:所有静态文案 | 页面 `ready` 后、语言变更时 | +| `composer` | `ComposerViewState`(整份) | 每次 VM 变化;C# 侧用 16 ms 定时器合并,保证最多 60 次/s | +| `settings` | `SettingsViewState`(整份) | 同上 | +| `toast` | `{text}` | `ToastRaised` | +| `focus` | `{target:"source"\|"mic"\|"edit"}` | 页面启动后;横幅操作后 | + +没有 `reset`:窗口停放(预热)时页面被重新加载一次,下次唤起得到的是全新页面,所以瞬时状态(悬停、菜单、对话框、`sentRev`)不需要清理。窗口在页面首次上报 `size`(说明首份快照已画好)后才移回并显示。 + +信封统一为 `{"v":1,"type":"…","data":…}`;另有 `openMenu{id:"target"}`(`RetargetRequested` 要求打开目标菜单)。静态文案键见 `ComposerTexts.WebStrings()`。 + +`ComposerViewState` 字段(以 `src/LiveLingo.Desktop/Web/ComposerViewState.cs` 与 `tests/LiveLingo.Web.Tests/Fixtures/*.json` 为准):`rev`;`dest{text,kind:"target"|"copyOnly"|"lost"}`;`privacy{text,cloud,aria}`;`output{value,pill,globe,menu[{id,label,checked,enabled}],swap}`;`banner{kind,tone,message,progress,actions[{id,label}]}|null`;`source{mode:"edit"|"live",text,sourceRev,editable,label,routeChip,hint,lines[{time,text,tail}]}`;`result{visible,label,rows[{i,state:"pending"|"ready"|"failed"|"empty",text,note,time,warn,owned,editing,canEdit,canUseOriginal,useOriginalAria,editAria}],editText}`(失败行的错误文案就在 `text`);`footer{mic{recording,aria},status{text,tone:"muted"|"warn"},copyEnabled,commit{label,enabled,armed,written,aria}}`;`layout{wide}`;`options{animations,commitKey:"enter"|"ctrlEnter",commitKeyCap,newlineKeyCap}`;`menus{target[],more[]}`(菜单项 id:`target|copyOnly`;`new|recent|import|export|wide|whole|settings`);`dialogs{recent[{index,label}],canRestore,export{txt,markdown,srt}}`。`editText` 只在某句开始编辑时被页面采用一次。`sourceRev` 起始为 1(新页面发出的 `rev` 从 0 起算,因此能显示已恢复的草稿);模型侧改动文本(语音、导入、恢复)时自增,页面发出的文本不会自增。黄金文件里的键帽固定为 macOS 值。 + +### 5.3 原话框的双向同步 + +用户在 Web 的 `textarea` 里打字时,Web 拥有该文本;C# 在语音、导入、新草稿时才改写文本。每份 `source{text,rev}` 意图带递增 `rev`;快照里的 `source.sourceRev` 是 C# 已接受的最新 `rev`。Web 仅当 `sourceRev` 大于自己最后发送的 `rev` 时才用快照文本覆盖 `textarea`(并保持光标)。这样输入法组合与快速打字不会被回显打断。 + +### 5.4 窗口尺寸 + +C# 控制宽度(窄 620 / 宽 940 + 36 px 边距,来自 `layout.wide`);Web 用 `ResizeObserver` 在 `requestAnimationFrame` 里上报内容高度,C# 设置 `Window.Height` 并按 `ComposerPlacement` 重新定位(向上生长,锚定目标窗口)。**所有弹出菜单与对话框都在面板内**(原型本就如此),因此窗口高度总是包含它们;不依赖窗口外的弹出层。 + +## 6. 安全 + +1. 页面只来自内嵌资源(`NavigateToString`,单文件 `index.html`);`NavigationStarting` 一律取消,`NewWindowRequested` 一律取消。 +2. CSP:`default-src 'none'; script-src 'nonce-…'; style-src 'nonce-…'; style-src-attr 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; base-uri 'none'; form-action 'none'`。nonce 由 C# 在每次加载时写入模板。`style-src-attr 'unsafe-inline'` 仅放行 style 属性:WebKit 原生 `