From 9332169acfb28f646db5f99a251a7fc8a779ce39 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Mon, 19 Oct 2026 01:15:03 +0800 Subject: [PATCH] Add speech recognition, archive improvements and release skill updates --- .acecode/skills/acecode-release/SKILL.md | 139 +++++-- .../skills/acecode-release/agents/openai.yaml | 4 +- .../sync_github_release_to_aupdate.ps1 | 41 +- .../scripts/test_quick_validation.ps1 | 258 +++++++++++++ .../test_sync_github_release_to_aupdate.ps1 | 51 ++- .../skills/acecode-frontend-style/SKILL.md | 16 +- AGENTS.md | 1 + cmake/macos/ACECodeDesktopInfo.plist.in | 2 + docs/daemon-api.md | 58 +++ docs/speech-recognition.md | 48 +++ .../changes/add-speech-recognition/design.md | 61 +++ .../add-speech-recognition/proposal.md | 25 ++ .../specs/speech-recognition/spec.md | 82 ++++ .../changes/add-speech-recognition/tasks.md | 25 ++ .../design.md | 11 +- .../proposal.md | 1 + .../specs/session-archive/spec.md | 22 +- .../improve-archived-session-browser/tasks.md | 9 + src/adapters/speech/speech_client.cpp | 138 +++++++ src/adapters/speech/speech_client.hpp | 34 ++ src/apps/desktop/linux_desktop.cpp | 24 ++ src/apps/desktop/linux_desktop.hpp | 1 + src/apps/desktop/web_host.cpp | 2 +- .../web/routes/routes_speech_recognition.cpp | 151 ++++++++ src/apps/web/server.cpp | 1 + src/apps/web/server_impl.hpp | 5 + src/base/config/config.cpp | 47 +-- src/base/config/config.hpp | 19 + src/base/config/image_generation_config.cpp | 28 ++ src/base/config/image_generation_config.hpp | 7 + src/base/config/speech_recognition_config.cpp | 99 +++++ src/base/utils/http_url_validation.hpp | 5 +- src/layers.tsv | 1 + .../config/speech_recognition_config_test.cpp | 72 ++++ tests/speech/speech_client_test.cpp | 140 +++++++ tests/web/web_server_smoke_test.cpp | 46 +++ web/public/vs-icons/Microphone.svg | 1 + web/scripts/i18n-en-overrides.mjs | 67 ++++ .../test-archived-session-pagination.mjs | 138 +++++++ web/scripts/test-session-archive.mjs | 297 ++++++++++++++ web/scripts/test-speech-recognition.mjs | 364 ++++++++++++++++++ web/src/components/ConsoleDock.jsx | 2 +- web/src/components/Icon.jsx | 1 + web/src/components/InputBar.jsx | 39 +- web/src/components/SettingsPage.jsx | 73 +++- web/src/components/Sidebar.jsx | 95 ++++- web/src/components/SpeechMicrophoneButton.jsx | 76 ++++ .../components/SpeechRecognitionSettings.jsx | 126 ++++++ .../components/SpeechRecordingControls.jsx | 37 ++ web/src/components/Toast.jsx | 122 ++++-- web/src/i18n/sourceCatalog.generated.js | 122 ++++++ web/src/lib/api.js | 11 +- web/src/lib/api.test.js | 27 ++ web/src/lib/appShortcuts.js | 5 +- web/src/lib/appShortcuts.test.js | 4 + web/src/lib/archivedSessions.js | 21 + web/src/lib/archivedSessions.test.js | 54 +++ .../composerEditabilityArchitecture.test.js | 2 +- web/src/lib/icons/controls.js | 1 + web/src/lib/runTests.js | 1 + web/src/lib/settingsSearch.js | 1 + web/src/lib/shortcutBindings.js | 1 + web/src/lib/sidebarWorkspaceRefresh.test.js | 64 ++- web/src/lib/speechAudio.js | 101 +++++ web/src/lib/speechDevices.js | 68 ++++ web/src/lib/speechRecognition.js | 163 ++++++++ web/src/lib/speechRecognition.test.js | 293 ++++++++++++++ web/src/lib/speechSession.js | 82 ++++ web/src/lib/trailingUserMessageRetry.test.js | 6 +- web/src/lib/useAppShortcuts.js | 8 +- web/src/lib/useSpeechRecognition.js | 129 +++++++ 71 files changed, 4131 insertions(+), 145 deletions(-) create mode 100644 .acecode/skills/acecode-release/scripts/test_quick_validation.ps1 create mode 100644 docs/speech-recognition.md create mode 100644 openspec/changes/add-speech-recognition/design.md create mode 100644 openspec/changes/add-speech-recognition/proposal.md create mode 100644 openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md create mode 100644 openspec/changes/add-speech-recognition/tasks.md create mode 100644 src/adapters/speech/speech_client.cpp create mode 100644 src/adapters/speech/speech_client.hpp create mode 100644 src/apps/web/routes/routes_speech_recognition.cpp create mode 100644 src/base/config/image_generation_config.cpp create mode 100644 src/base/config/image_generation_config.hpp create mode 100644 src/base/config/speech_recognition_config.cpp create mode 100644 tests/config/speech_recognition_config_test.cpp create mode 100644 tests/speech/speech_client_test.cpp create mode 100644 web/public/vs-icons/Microphone.svg create mode 100644 web/scripts/test-archived-session-pagination.mjs create mode 100644 web/scripts/test-session-archive.mjs create mode 100644 web/scripts/test-speech-recognition.mjs create mode 100644 web/src/components/SpeechMicrophoneButton.jsx create mode 100644 web/src/components/SpeechRecognitionSettings.jsx create mode 100644 web/src/components/SpeechRecordingControls.jsx create mode 100644 web/src/lib/speechAudio.js create mode 100644 web/src/lib/speechDevices.js create mode 100644 web/src/lib/speechRecognition.js create mode 100644 web/src/lib/speechRecognition.test.js create mode 100644 web/src/lib/speechSession.js create mode 100644 web/src/lib/useSpeechRecognition.js diff --git a/.acecode/skills/acecode-release/SKILL.md b/.acecode/skills/acecode-release/SKILL.md index c6c814d61..6c31a86ac 100644 --- a/.acecode/skills/acecode-release/SKILL.md +++ b/.acecode/skills/acecode-release/SKILL.md @@ -1,56 +1,72 @@ --- name: acecode-release -description: Release ACECode or publish a fast Windows prerelease validation package. Use when asked to publish an ACECode version, update the Windows self-upgrade server, create a Jenkins Green pre package, build and verify updater packages, commit release code, create an annotated Git version tag, or push release commits/tags. -platforms: [windows] -compatibility: ACECode skill system -metadata: - tags: [release, packaging, windows] +description: Release ACECode by committing and pushing all pending code, merging completed worktrees from the last two days, pushing the integrated result, and publishing one verified stable version. Also supports GitHub-to-aupdate mirroring and fast Windows prerelease validation packages. Use when asked to release ACECode, integrate completed worktrees for a release, repair the update server, or publish a Jenkins Green pre package. --- # ACECode Release ## Purpose -Release ACECode in a traceable way: version files, tests, Git commit, annotated tag, Windows updater zip, `aceupdate.json`, and HTTP verification must all agree on the same version. +默认正式发布顺序:提交所有未提交代码并 push → 合并近两天已完成的 worktree → 再次 push → 最终统一发布一个版本。发布请求即授权完成这整条流程,无需逐步重复询问是否提交、推送、合并或发版;用户明确指定的范围、版本和例外优先。仅修改本 skill、检查发布状态或同步已有版本的镜像,不触发这条发布流程。明确要求快速 Windows 验证包时,使用下方独立的快速流程。 -Use `scripts/publish_acecode_release.ps1` for the mechanical work whenever possible. Resolve that script relative to this skill directory, not a Codex or other-agent path. +Release ACECode in a traceable way: version files, tests, Git commit, annotated tag, GitHub Release assets, all stable updater ZIPs, both macOS PKG installers, `aceupdate.json`, and public HTTP verification must agree on the same version. A full stable release is not complete while required updater ZIPs or either macOS PKG exist only on GitHub or are missing. + +Use `scripts/publish_acecode_release.ps1` for the mechanical work whenever possible. Resolve scripts relative to this skill directory. The repository scripts retain build-directory selection, build locking, Windows Computer Use and WhatsApp payload checks, and exact public manifest verification. Agent Browser is integrated into `acecode-desktop.exe`. Release packages must not contain legacy `ace-browser-host` or `ace-browser-bridge` artifacts, even when stale files remain in the build tree. ## Required Inputs -For a stable release, require a numeric semantic version such as `0.2.2` and treat the Git tag as `v`. +For a stable release, use a numeric semantic version such as `0.2.2` and treat the Git tag as `v`. Honor an explicit version. Otherwise inspect fetched tags, source version files, and the update manifest, choose the next unused patch version above the latest stable version (or the higher unreleased numeric core already prepared for this release), and state it before publishing without requiring a separate version-selection round trip. For a quick validation package, omit the version to auto-select the next `x.y.z-pre.N`, or provide that exact form explicitly. -When publishing a stable release to `aupdate`, also require a concise customer-facing upgrade tip. +When publishing a stable release to `aupdate`, also require a concise customer-facing upgrade tip. Derive it from the complete integrated changes unless the user supplies one. The tip may contain Chinese text and multiple lines; pass it with `-UpgradeTip`. Runs with `-NoPublish` do not require a tip. Default repository and update service assumptions: -- Repo root: current working directory, normally the ACECode checkout +- Repo root: current working directory, normally `N:\Users\shao\acecode` +- GitHub repository: `tmoonlight/acecode` - Update server folder: `J:\jenkins_green\aupdate` - Update HTTP base URL: `http://2017studio.imwork.net:82/aupdate/` - Windows update target: `windows-x64` -## Quick Windows Validation Package +## Quick Windows Validation Package (Speed First) + +When the user asks to "发快速版本", publish a quick validation package, a pre package, or a Jenkins Green Windows test package, treat that request as authorization for this short, speed-first path: + +1. Finish the intended source change on the canonical `master` checkout, commit it, and push `master`. If there is no source change, reuse the current `HEAD` instead of creating an empty commit. Inspect only the concise status needed to avoid committing generated output, secrets, or unrelated work; do not perform a broad worktree/reflog audit for an ordinary quick-version request. +2. Immediately before any Web or C++ compilation, find running processes whose exact process names are `acecode`, `acecode-desktop`, or `acecode-daemon` and force-stop them without asking for a second confirmation. This standing authorization applies specifically to quick-version requests. Do not use substring matching and do not stop compilers, shells, Codex, or unrelated processes. +3. Build in the canonical checkout and reuse its configured build directory and incremental caches. Do not create an isolated worktree merely because an ACECode binary is locked; stop the ACECode process and build in place. Rebuild `web\dist` first only when Web source/build inputs changed or the embedded assets are missing. +4. Run `scripts/publish_acecode_release.ps1 -QuickValidation` to compile only `acecode` and `acecode-desktop`, copy the Windows x64 zip into `J:\jenkins_green\aupdate`, update `aceupdate.json`, and complete the script's package/HTTP checks. +5. Report the commit, prerelease version, package path, public URL, size, and SHA256. Do not add slow duplicate validation after the script succeeds unless seed/packaging behavior changed or the build/publish output indicates a problem. -When the user asks for a quick validation package, a pre package, or a Jenkins Green Windows test package, use `-QuickValidation`. This is an internal packaging path, not a release: +The quick-package boundary remains narrow: -- Build the current working tree, including intended uncommitted changes. -- Build only `acecode` and `acecode-desktop`; skip unit-test compilation and execution for speed. - Publish only `acecode--windows-x64.zip` to `J:\jenkins_green\aupdate` and update `aceupdate.json` with one `windows-x64` package entry. -- Do not create or update a browser-extension, Linux, macOS, npm, GitHub Release, Git commit, Git tag, or Git push artifact. +- Do not create or update a browser-extension, Linux, macOS, npm, GitHub Release, release commit, or Git tag. The source commit and `master` push in step 1 happen before `-QuickValidation`; the script itself must still receive no `-Push`, `-StageFiles`, or commit option. - Temporarily inject the full `x.y.z-pre.N` executable version while keeping CMake's numeric version valid, leave `vcpkg.json` untouched, then restore `CMakeLists.txt` and `src\version.hpp.in` even if the run fails. - Use a new prerelease number for each tester-visible update. When `-Version` is omitted, derive the next version from the highest stable version and prior numeric `pre.N` records in `aceupdate.json`. - Default the note to an internal prerelease message; pass `-UpgradeTip` only when a more specific test description is useful. +Use exact-name process termination before compiling: + +```powershell +$acecodeProcesses = Get-Process -Name acecode,acecode-desktop,acecode-daemon -ErrorAction SilentlyContinue +if ($acecodeProcesses) { + $acecodeProcesses | Stop-Process -Force + $acecodeProcesses | Wait-Process -Timeout 10 -ErrorAction SilentlyContinue +} +``` + Run: ```powershell powershell -NoProfile -ExecutionPolicy Bypass ` -File .acecode\skills\acecode-release\scripts\publish_acecode_release.ps1 ` -QuickValidation ` - -Repo . + -Repo N:\Users\shao\acecode ` + -Configuration MinSizeRel ``` To validate release arguments, the selected build directory, and the dynamically @@ -90,21 +106,64 @@ Report the upgrade semantics explicitly: seed reconciliation happens on the next ## Stable Release Workflow -1. Reconcile the real local `master` before changing versions or creating a tag. A request to release ACECode implicitly authorizes committing and pushing the usable work on local `master`, plus the release commit and tag, unless the user explicitly asks for a local-only or no-push release. - - Run `git fetch origin`, `git status --short --untracked-files=all`, `git rev-list --left-right --count origin/master...master`, and `git log --oneline origin/master..master` in the canonical `master` worktree. - - Inventory `git worktree list --porcelain` and recent local commits with `git log --all --not --remotes`. Use reflogs when a rebase or detached worktree may have hidden a commit. When old hashes are pre-rebase versions, prove patch equivalence with `git range-diff` or `git cherry` instead of applying them twice. - - Treat all non-generated, non-secret tracked and untracked changes in the `master` checkout, and every commit on local `master` that is absent from `origin/master`, as intended release content. Commit it, integrate the latest `origin/master`, and push it before tagging. Never silently package an older clean commit while `master` has dirty or outgoing work. - - Inspect recent dirty or unmerged worktrees so an intended feature is not omitted merely because it was developed outside the canonical checkout. Include clearly intended release work; warn when ownership is genuinely ambiguous rather than silently excluding it. - - Exclude generated build outputs, caches, diagnostics, and secrets. Stop and warn only when a feature is unusable, conflicts cannot be resolved safely, validation fails, or committing would expose secrets or generated junk. A usable dirty feature is work to finish and include, not a reason to preserve it outside the release. -2. If publishing a stable release after a successful prerelease or packaging-fix validation, verify the validated commit is on the release branch before versioning: +1. **盘点并固定本轮范围。** 在发布开始时记录当前时间,以用户时区(默认 `Asia/Taipei`)的最近 48 小时作为“近两天”;后续操作沿用这个截止窗口。 + - 在 canonical `master` 中执行 `git fetch origin --tags`、`git status --short --untracked-files=all`、`git rev-list --left-right --count origin/master...master` 和 `git log --oneline origin/master..master`,同时用 `git worktree list --porcelain` 盘点整个仓库的 worktree,并逐个检查分支、HEAD、未提交文件和未推送提交。 + - 候选包括近 48 小时创建、发生开发活动或完成的 worktree;较早创建但近两天完成的也纳入。通过关联聊天/任务的完成记录、OpenSpec 任务与验收记录、Git 日志和 reflog 核实时间与状态。提交时间或文件夹修改时间只能用于发现候选,不能单独证明已完成。 + - “已完成”必须有任务完成及适用验证的依据,且没有仍在写入该 worktree 的活动任务。已完成但尚未 commit 的代码同样属于本次范围。记录每个候选的路径、分支、HEAD、完成依据及纳入/已合并/未完成/排除原因;无法确认的明确报告,不能假定完成或静默遗漏。 + - 遵守仓库明确保留的 ref/worktree:src 分层搬迁记录中的九个旧 ref/worktree 继续保留原状,迁移另行安排,不因最近有活动自动纳入。其他正在开发且未完成的 worktree 保留并列明。 +2. **提交所有未提交代码并先 push。** 默认把当前检出、canonical `master` 和本轮已完成 worktree 中的全部代码、测试、文档、配置变更作为发布内容,包括已跟踪文件的修改/删除及应纳入版本控制的新文件;不按是否由本聊天产生筛选。当前检出若仍有活动任务,先等其完成再取稳定快照。 + - 审查差异并排除密钥、个人运行配置、会话数据、缓存、构建产物和临时诊断文件;正常维护的仓库资源不因由生成器产生就自动排除。按明确文件清单暂存,完成适用检查,提交到各自所属分支并 push。没有改动时不创建空提交;已有但未推送的提交也要推送。 + - 工作区中的可用改动需要完成、验证并纳入,不能以“保留他人改动”为由丢在发布之外;不得提交其他任务仍在写入的中间状态。暂存、提交前重新核对差异,有并发变化时重新检查对应内容。 + - 同步最新远端再正常推送,不 force-push。`master` 受保护时,通过必要的临时集成分支和 PR 完成提交入主线,等待所需检查及合入,再同步 canonical `master`;这也适用于后续 worktree 集成和版本提交。除此之外复用现有检出。 +3. **合并近两天已完成的 worktree。** 在第一轮提交与 push 成功后,按依赖顺序把候选中的已完成分支逐个合入 canonical `master`。第 2 步已纳入的当前检出若位于其他分支,其发布提交也必须合入,不受两天窗口排除。先用 `git merge-base --is-ancestor` 判断是否已包含;rebase、squash 或 cherry-pick 导致哈希变化时,用 `git range-diff`、`git cherry` 或实际差异核验补丁等价,避免重复应用。 + - 审查每个分支的完整新增提交及其依赖,解决冲突时保留双方有效行为;旧布局分支遵守仓库的路径迁移和 `migrate_branch.py --check` 要求。常规冲突自行解决并验证,无法判断产品意图时才说明具体阻塞并询问。 + - 合并后运行适用的定向检查,记录来源 HEAD 和集成提交;不删除 worktree 或分支,不为每个 worktree 单独发版。已纳入候选若验证失败或合并受阻,修复后继续,不能略过后宣称全部合并。 +4. **再次 push 集成结果。** 所有候选处理完成后,运行合并后整体的适用验证;Web 改动至少执行 `pnpm test` 和 `pnpm build`,并执行 `git diff --check`。重新 fetch 并处理远端并发提交,通过正常 push 或受保护分支 PR 将最终集成内容同步到 `origin/master`。确认 `master` 与 `origin/master` 无分歧、本轮纳入的工作区无待提交代码、来源提交已包含或有补丁等价证据,才进入版本步骤。 +5. If publishing a stable release after a successful prerelease or packaging-fix validation, verify the validated commit is on the release branch before versioning: - Use `git merge-base --is-ancestor HEAD`. - If it fails, do not release from `master` yet; fast-forward/merge the validated branch first, then re-check. - This prevents a branch-only prerelease such as a package-size experiment from being skipped by a later stable release. -3. Before packaging, prove the release `HEAD` descends from the reconciled `master` content and the latest `origin/master`. Run the applicable feature tests, complete the required seed-upgrade validation above when relevant, and confirm the canonical `master` worktree is clean. Seed fixes validated only from a dirty quick package must be committed on this release `HEAD` before tagging. -4. Write a short user-visible upgrade tip that explains features and fixes in product language, then run the release script with the version, `-UpgradeTip`, and explicit `-StageFiles` for any remaining release-code changes beyond `CMakeLists.txt` and `vcpkg.json`. +6. Before packaging, prove the release `HEAD` descends from the reconciled `master` content and the latest `origin/master`. Complete the required seed-upgrade validation above when relevant, and confirm the canonical `master` worktree is clean. Seed fixes validated only from a dirty quick package must be committed on this release `HEAD` before tagging. +7. **最终统一发布一个版本。** Write a short user-visible upgrade tip covering the complete integrated features and fixes, then run the release script with the chosen version, `-UpgradeTip`, and explicit `-StageFiles` for any remaining release-code changes beyond `CMakeLists.txt` and `vcpkg.json`. - If the release changes packaging, include `.github\workflows\package.yml` and any release script/skill edits in the intended file list or keep them committed before publishing. -5. Use `-Push` by default for an ACECode release request. Omit it only when the user explicitly requests a local-only or no-push release. -6. After the script finishes, verify `master` and `origin/master` have no divergence, `git log origin/master..master` is empty, the release tag points to the final included commit, and all expected GitHub/update-server assets came from that tag. Then report the commit/tag, package paths, package sizes, SHA256 values, and verification commands. Also state that Agent Browser is integrated into Desktop and the updater zip contains no legacy browser host or extension artifact. + - If `master` is protected, prepare and merge the version-file commit through its required PR before invoking the publishing script from synchronized `master`; do not tag an unmerged PR head. + - Use `-Push` by default for an ACECode release request. Omit it only when the user explicitly requests a local-only or no-push release. Create one final stable version and tag after integration; do not publish intermediate worktree versions. +8. After the tag is pushed, stay on the task while GitHub Actions builds and publishes the complete Release. The default `-Push` path in `publish_acecode_release.ps1` waits for those assets and invokes `scripts/sync_github_release_to_aupdate.ps1`; do not interrupt or bypass that gate merely because the local Windows package already exists. +9. After the script finishes, verify `master` and `origin/master` have no divergence, `git log origin/master..master` is empty, the release tag points to the final included commit, and all expected GitHub/update-server assets came from that tag. Report the committed/pushed changes, merged and already-included worktrees, any exclusions with reasons, the final commit/tag, package paths, package sizes, SHA256 values, and verification commands. Also state that Agent Browser is integrated into Desktop and the updater ZIPs contain no legacy browser host or extension artifact. + +## Stable GitHub-to-aupdate Gate (Required) + +Every pushed stable release must mirror these exact GitHub Release assets before it can be called complete: + +- `acecode-windows-x64.zip` -> manifest target `windows-x64` +- `acecode-windows-arm64.zip` -> manifest target `windows-arm64` +- `ACECode--macos-x64-update.zip` -> manifest target `macos-x64` +- `ACECode--macos-arm64-update.zip` -> manifest target `macos-arm64` +- `acecode--linux-x64-update.zip` -> manifest target `linux-x64-updater-v1` +- `acecode--linux-arm64-update.zip` -> manifest target `linux-arm64-updater-v1` +- `ACECode--macos-x64.pkg` -> installer download, alias `ACECode-macos-x64.pkg` +- `ACECode--macos-arm64.pkg` -> installer download, alias `ACECode-macos-arm64.pkg` + +The mirror helper waits for the non-draft stable GitHub Release and all eight assets, verifies GitHub metadata plus `SHA256SUMS.txt`, inspects package structure/version/permissions, writes versioned files and latest aliases atomically, updates one six-package stable manifest record, and downloads every public versioned file and alias to recheck HTTP MIME, size, and SHA256. Either missing macOS PKG or any missing updater ZIP is a hard failure before files or `aceupdate.json` change. This is required for every full stable release even if GitHub CI permits releases without optional Installer credentials; restore those credentials and obtain both trusted PKGs before declaring the full release complete. + +PKGs are separate manual installer downloads and must never be added to `aceupdate.json` package entries or replace a macOS update ZIP. Require `.pkg` to be served as `application/vnd.apple.installer+xml`. The mirror checks the flat installer XAR header and release hashes; macOS CI remains responsible for signing, notarization, and installation checks. Quick Windows validation keeps its existing Windows-only scope. + +The helper preserves a semantically newer Windows-only prerelease as manifest `latest` while inserting the stable release below it. Platform selection scans compatible release records, so macOS and Linux clients can still select the new stable package. + +If the GitHub Release already exists and only the mirror is missing, do not rebuild, retag, or touch the source checkout. Run: + +```powershell +$upgradeTip = @' +1. Customer-facing feature or fix. +'@ + +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .acecode\skills\acecode-release\scripts\sync_github_release_to_aupdate.ps1 ` + -Version 0.9.6 ` + -UpgradeTip $upgradeTip +``` + +If the helper times out or fails validation, the release is partial. Preserve the published tag/assets, fix the specific GitHub or server problem, and rerun the helper for the same version; do not create another tag just to retry mirroring. Typical command: @@ -117,7 +176,7 @@ $upgradeTip = @' powershell -NoProfile -ExecutionPolicy Bypass ` -File .acecode\skills\acecode-release\scripts\publish_acecode_release.ps1 ` -Version 0.2.2 ` - -Repo . ` + -Repo N:\Users\shao\acecode ` -UpgradeTip $upgradeTip ` -StageFiles main.cpp,src\upgrade\http.cpp,src\upgrade\http.hpp,src\upgrade\upgrade.cpp,tests\upgrade\upgrade_http_test.cpp ` -Push @@ -144,32 +203,34 @@ In stable release mode, the script: - Requires a non-empty `-UpgradeTip` before any publishing side effects. - Updates `aceupdate.json`, preserving older release records, putting the new release first, and writing the trimmed tip to `releases[0].notes`. - Verifies the manifest tip and zip over HTTP, including `Accept: application/zip`. +- For a pushed published stable release, waits for the complete GitHub Release and then runs `sync_github_release_to_aupdate.ps1`. It reports full cross-platform completion only after all six updater targets, both macOS PKGs, versioned/latest files, manifest entries, and public downloads verify. In quick validation mode, the script: - Auto-selects the next numeric `pre.N` version unless `-Version x.y.z-pre.N` is provided. - Rejects stable versions, non-Windows targets, `-Push`, `-NoPublish`, `-StageFiles`, and `-CommitMessage`. -- Accepts a dirty working tree by design, reports its paths, and never stages or commits it. +- Technically accepts a dirty working tree for rollback compatibility, but the standard speed-first workflow commits and pushes intended source changes before invoking it; the script itself never stages or commits them. - Builds only the two Windows runtime targets and verifies `acecode.exe --version` reports the full prerelease version. - Writes only the Windows zip plus the required `aceupdate.json` and `web.config` update-server metadata. - Preserves existing release records while inserting a prerelease record containing only `windows-x64`. - Verifies the Windows package and manifest over HTTP, then restores all temporarily changed source-version files. -After changing this release tooling, run `scripts\test_quick_validation.ps1` for the isolated package/rollback regression test. +After changing the release scripts, run both `scripts\test_quick_validation.ps1` and `scripts\test_sync_github_release_to_aupdate.ps1`. The latter must prove that a complete fixture publishes six updater targets plus both PKGs and their aliases, that PKGs stay outside the updater manifest, and that missing or invalid PKGs and a missing macOS updater fail before deployment without changing `aceupdate.json`. After any edit to this skill, run the skill creator's `quick_validate.py` against this skill folder. ## Guardrails - Do not run `git reset --hard` or revert unrelated files. -- For stable releases, do not package from a dirty working tree unless the user knowingly accepts `-AllowDirtyBuild`. Quick validation intentionally packages the current working tree without committing it. -- Do not call a release complete while the canonical `master` worktree has usable uncommitted changes or local `master` has commits absent from `origin/master`. -- Do not interpret preservation of dirty work as release correctness. For a release request, finish, validate, commit, integrate, and push usable `master` work by default. +- For stable releases, do not package from a dirty working tree unless the user knowingly accepts `-AllowDirtyBuild`. For a quick-version request, commit and push intended source changes first, then package that clean `HEAD`. +- Do not call a release complete while an included checkout has pending release code, local `master` has commits absent from `origin/master`, or an eligible completed worktree from the fixed two-day window remains unintegrated without an explicit user exclusion. +- For a full release, commit and push all pending release code first, merge the completed worktrees, push the integrated result, and only then publish one version. The release script does not discover, commit, push, or merge worktree changes for you; complete those steps before invoking it. - Do not reuse an existing tag unless the user explicitly requests manual tag repair; the script fails on existing tags. - Do not publish a generic package sentence as the upgrade tip; use short customer-facing feature and fix language. - Keep `aceupdate.json` version, zip filename, SHA256, package size, and `acecode.exe --version` consistent; stable releases must also match the Git tag. -- Never turn quick validation into a partial release: do not add `-Push`, create a tag, publish GitHub assets, or mirror non-Windows packages. +- Never call a pushed stable release complete after only the local Windows package or GitHub upload succeeds. Require all six stable updater targets in `aceupdate.json`, both macOS PKG installers, all versioned and latest-alias files in `aupdate`, and successful public body hash verification. A DMG, PKG, or `.tar.gz` does not substitute for a macOS `*-update.zip`. +- Never turn quick validation into a partial release: do not pass `-Push` to the quick-validation script, create a tag, publish GitHub assets, or mirror non-Windows packages. Pushing the ordinary source commit from step 1 is separate and required. - Remember that publishing a prerelease to the shared Jenkins Green manifest makes it visible to clients using that update service. Use the next `pre.N`, not an existing version, when testers must receive another update. -- Inspect the reported dirty paths before quick packaging and exclude secrets or private machine artifacts from compiled resources and packaged asset folders. -- Treat a quick package built from dirty files as ephemeral manual-test evidence, not as proof that a later stable `HEAD` contains the validated code. Commit and integrate the exact changes, then rerun the applicable validation before the stable release. +- Confirm the source checkout is clean after the step-1 commit. If the quick-validation script still reports dirty paths, stop and exclude secrets, generated output, private machine artifacts, or unrelated work before packaging. +- Do not intentionally publish a quick package from dirty source files. Commit and push the exact intended changes first so the packaged quick version is traceable to `HEAD`. - Remember that users upgrading from older binaries see the old updater UI during the first upgrade; new UI changes are visible after the upgraded binary is installed. - Never infer that existing users receive a new seeded Skill, expert, or hook merely because it is present under `share\acecode\seed` in the package. Prove the old-marker and equal-marker upgrade paths with the final packaged executable, and state the required restart behavior in the handoff. - Treat successful tagged or committed prerelease points as validation evidence, not as automatically released code. Before a stable release, prove the validated prerelease or package-fix commit is an ancestor of the stable release commit. @@ -179,4 +240,8 @@ After changing this release tooling, run `scripts\test_quick_validation.ps1` for - macOS packages must strip the desktop app executable and bundled `acecode-daemon` after creating dSYM artifacts. - `acecode-desktop` must not link the broad `acecode_testable` object set or FTXUI; use focused desktop/native support targets so the desktop shell does not pull agent/TUI/web/server/provider code. - CI should enforce package size budgets for release assets and fail on regressions. Calibrate budgets from the last known-good release assets, then update intentionally with evidence when size growth is expected. -- When deploying non-Windows packages to `J:\jenkins_green\aupdate`, keep in mind the current self-updater extracts zip packages only. Publish `.tar.gz` files and latest aliases as downloadable assets, but do not add them to `aceupdate.json` `packages` until Unix/macOS updater extraction is implemented and tested. +- Keep Unix `.tar.gz` archives and latest aliases as downloadable assets only; never put a `.tar.gz` URL in `aceupdate.json`, because the self-updater consumes ZIP packages. +- Before publishing Linux self-update entries, read `docs/linux-self-update.md` from the release commit and prove that the release contains the `manifest_target_for_platform` capability mapping. Publish only verified `acecode--linux-{x64,arm64}-update.zip` assets under manifest targets `linux-{x64,arm64}-updater-v1`. +- Never publish a Linux updater ZIP under legacy manifest targets `linux-x64` or `linux-arm64`. ACECode versions through v0.8.8 can extract those files without restoring executable permissions, so matching them to a ZIP can leave `acecode` non-executable. Those clients require one integrity-verified manual bootstrap to a release that supports the versioned target. +- For each Linux updater ZIP, verify the exact version, SHA256, byte size, one top-level directory, executable modes for `acecode` and `acecode-desktop`, the required `models.dev` files, and absence of retired `ace-browser-*` artifacts before mirroring it and updating the manifest. After publication, fetch the manifest and every Linux ZIP through the public update URL and repeat the hash, size, ZIP-layout, permission, and version checks. +- macOS updater entries remain governed by their existing tested ZIP/AppBundle path; do not infer Linux support from macOS packaging or vice versa. diff --git a/.acecode/skills/acecode-release/agents/openai.yaml b/.acecode/skills/acecode-release/agents/openai.yaml index 6f2b232f2..feb6b6571 100644 --- a/.acecode/skills/acecode-release/agents/openai.yaml +++ b/.acecode/skills/acecode-release/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "ACECode Release" - short_description: "Release ACECode or a Windows prerelease validation package." - default_prompt: "Use /acecode-release to publish a stable ACECode release or a Jenkins Green Windows prerelease validation package." + short_description: "Commit, merge recent completed worktrees, and release ACECode." + default_prompt: "Use $acecode-release to commit and push all pending code, merge completed worktrees from the last two days, push the integrated result, and publish one stable ACECode version with full GitHub and aupdate verification." diff --git a/.acecode/skills/acecode-release/scripts/sync_github_release_to_aupdate.ps1 b/.acecode/skills/acecode-release/scripts/sync_github_release_to_aupdate.ps1 index 928c90af0..11dc3b932 100644 --- a/.acecode/skills/acecode-release/scripts/sync_github_release_to_aupdate.ps1 +++ b/.acecode/skills/acecode-release/scripts/sync_github_release_to_aupdate.ps1 @@ -298,14 +298,21 @@ function Test-ArchiveContents { $required += 'acecode-computer-use.exe' } } elseif ($Definition.Kind -eq 'macos') { + $compactMacLayout = [version]$ReleaseVersion -ge [version]'1.0.4' + $resourceRoot = if ($compactMacLayout) { 'ACECode.app/Contents/Resources/share/acecode' } else { 'share/acecode' } $required = @( 'ACECode.app/Contents/MacOS/ACECode', 'ACECode.app/Contents/MacOS/acecode-daemon', 'ACECode.app/Contents/Info.plist', 'acecode', - 'share/acecode/models_dev/api.json', - 'share/acecode/models_dev/MANIFEST.json' + "$resourceRoot/models_dev/api.json", + "$resourceRoot/models_dev/MANIFEST.json" ) + if ($compactMacLayout) { + $required += @('ACECode.app/Contents/MacOS/acecode-cli', + "$resourceRoot/models_dev/LICENSE", "$resourceRoot/seed/seed.version", + "$resourceRoot/seed/MANIFEST.json") + } } elseif ($Definition.Kind -eq 'linux') { $root = "acecode-linux-$($Definition.Arch)" $required = @( @@ -334,6 +341,36 @@ function Test-ArchiveContents { } if ($Definition.Kind -eq 'macos') { + if ($compactMacLayout) { + foreach ($name in $byName.Keys) { + if ($name -match '^(share|channels)(/|$)|^acecode-computer-use(/|$)') { + throw "$($Definition.Source) contains duplicated root resources: $name" + } + } + if ($byName['acecode'].Length -gt 262144) { + throw "$($Definition.Source) CLI launcher exceeds 256 KiB." + } + $launcherHashes = @() + foreach ($name in @('acecode', 'ACECode.app/Contents/MacOS/acecode-cli')) { + $stream = $byName[$name].Open() + $digest = [System.Security.Cryptography.SHA256]::Create() + try { + $launcherHashes += [BitConverter]::ToString($digest.ComputeHash($stream)) + } finally { + $digest.Dispose() + $stream.Dispose() + } + } + if ($launcherHashes[0] -cne $launcherHashes[1]) { + throw "$($Definition.Source) root CLI launcher differs from signed app launcher." + } + foreach ($name in @('acecode', 'ACECode.app/Contents/MacOS/acecode-cli', + 'ACECode.app/Contents/MacOS/ACECode', 'ACECode.app/Contents/MacOS/acecode-daemon')) { + if (((Get-ZipEntryMode -Entry $byName[$name]) -band 0x49) -ne 0x49) { + throw "$($Definition.Source) does not preserve executable mode for $name." + } + } + } $stream = $byName['ACECode.app/Contents/Info.plist'].Open() try { $reader = New-Object System.IO.StreamReader($stream) diff --git a/.acecode/skills/acecode-release/scripts/test_quick_validation.ps1 b/.acecode/skills/acecode-release/scripts/test_quick_validation.ps1 new file mode 100644 index 000000000..7a1c85490 --- /dev/null +++ b/.acecode/skills/acecode-release/scripts/test_quick_validation.ps1 @@ -0,0 +1,258 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +function Write-Utf8NoBom { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Text + ) + + $parent = Split-Path -Parent $Path + if ($parent) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Text, $encoding) +} + +function Assert-True { + param( + [Parameter(Mandatory = $true)][bool]$Condition, + [Parameter(Mandatory = $true)][string]$Message + ) + + if (-not $Condition) { + throw $Message + } +} + +function Invoke-Native { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments + ) + + & $FilePath @Arguments | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Command failed ($LASTEXITCODE): $FilePath $($Arguments -join ' ')" + } +} + +$testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ( + 'acecode-release-quick-test-' + [guid]::NewGuid().ToString('N')) +$repo = Join-Path $testRoot 'repo' +$updateDir = Join-Path $testRoot 'aupdate' +$releaseScript = Join-Path $PSScriptRoot 'publish_acecode_release.ps1' + +try { + New-Item -ItemType Directory -Force -Path (Join-Path $repo 'build\Release') | Out-Null + + Write-Utf8NoBom -Path (Join-Path $repo 'CMakeLists.txt') -Text @' +cmake_minimum_required(VERSION 3.20) +project(acecode VERSION 0.8.6 LANGUAGES C CXX) +'@ + Write-Utf8NoBom -Path (Join-Path $repo 'vcpkg.json') -Text @' +{ + "name": "acecode", + "version-semver": "0.8.6" +} +'@ + Write-Utf8NoBom -Path (Join-Path $repo 'src\version.hpp.in') -Text @' +#pragma once +#define ACECODE_VERSION_MAJOR @acecode_VERSION_MAJOR@ +#define ACECODE_VERSION_MINOR @acecode_VERSION_MINOR@ +#define ACECODE_VERSION_PATCH @acecode_VERSION_PATCH@ +#define ACECODE_VERSION "@acecode_VERSION@" +'@ + Write-Utf8NoBom -Path (Join-Path $repo 'assets\models_dev\api.json') -Text "{}`n" + Write-Utf8NoBom -Path (Join-Path $repo 'assets\seed\MANIFEST.json') -Text "{}`n" + foreach ($asset in @('bridge.mjs', 'protocol.mjs', 'package.json', 'package-lock.json')) { + Write-Utf8NoBom -Path (Join-Path $repo "assets\channels\whatsapp\$asset") -Text "{}`n" + } + Write-Utf8NoBom -Path (Join-Path $repo '.gitignore') -Text "build/`n" + Write-Utf8NoBom -Path (Join-Path $updateDir 'aceupdate.json') -Text @' +{ + "schema_version": 1, + "latest": "0.8.7-pre.2", + "releases": [ + { + "version": "0.8.7-pre.2", + "published_at": "2026-08-01T00:00:00Z", + "notes": "Prior validation package.", + "packages": [ + { + "target": "windows-x64", + "file": "acecode-0.8.7-pre.2-windows-x64.zip", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size": 1 + } + ] + }, + { + "version": "0.8.6", + "published_at": "2026-07-31T00:00:00Z", + "notes": "Stable package.", + "packages": [] + } + ] +} +'@ + + $fakeExe = Join-Path $repo 'build\Release\acecode.exe' + $source = @' +using System; +using System.IO; +using System.Text.RegularExpressions; +public static class QuickValidationFakeProgram +{ + public static void Main(string[] args) + { + string forcedVersion = Environment.GetEnvironmentVariable("ACECODE_RELEASE_TEST_FORCE_VERSION"); + if (!String.IsNullOrEmpty(forcedVersion)) + { + Console.WriteLine("acecode v" + forcedVersion); + return; + } + + string repo = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..")); + string template = File.ReadAllText(Path.Combine(repo, "src", "version.hpp.in")); + string cmake = File.ReadAllText(Path.Combine(repo, "CMakeLists.txt")); + Match fullVersion = Regex.Match(template, "#define ACECODE_VERSION \\\"([^\\\"]+)\\\""); + Match numericVersion = Regex.Match(cmake, "project\\(acecode VERSION ([0-9]+\\.[0-9]+\\.[0-9]+) LANGUAGES C CXX\\)"); + if (!fullVersion.Success || !numericVersion.Success || + !fullVersion.Groups[1].Value.StartsWith(numericVersion.Groups[1].Value + "-", StringComparison.Ordinal)) + { + Console.WriteLine("acecode vinvalid-test-version"); + return; + } + Console.WriteLine("acecode v" + fullVersion.Groups[1].Value); + } +} +'@ + Add-Type -TypeDefinition $source -Language CSharp -OutputType ConsoleApplication -OutputAssembly $fakeExe + Copy-Item -LiteralPath $fakeExe -Destination (Join-Path $repo 'build\Release\acecode-desktop.exe') + Copy-Item -LiteralPath $fakeExe -Destination (Join-Path $repo 'build\Release\acecode-computer-use.exe') + # Deliberately leave a stale legacy host in the build tree. Packaging must + # follow the current Agent Browser layout and never pick it up. + Copy-Item -LiteralPath $fakeExe -Destination (Join-Path $repo 'build\Release\ace-browser-host.exe') + + Invoke-Native git -C $repo init -q + Invoke-Native git -C $repo config user.name 'ACECode Release Test' + Invoke-Native git -C $repo config user.email 'acecode-release-test@example.invalid' + Invoke-Native git -C $repo config core.autocrlf false + Invoke-Native git -C $repo add -- CMakeLists.txt vcpkg.json src/version.hpp.in assets/models_dev/api.json assets/seed/MANIFEST.json assets/channels/whatsapp .gitignore + Invoke-Native git -C $repo commit -q -m 'Create quick validation fixture' + + $sourcePaths = @( + (Join-Path $repo 'CMakeLists.txt'), + (Join-Path $repo 'vcpkg.json'), + (Join-Path $repo 'src\version.hpp.in') + ) + $beforeHashes = @{} + foreach ($path in $sourcePaths) { + $beforeHashes[$path] = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + } + $headBefore = (& git -C $repo rev-parse HEAD).Trim() + + $pushWasBlocked = $false + try { + & $releaseScript ` + -QuickValidation ` + -Version '0.8.7-pre.3' ` + -Repo $repo -BuildDir 'build' ` + -UpdateDir $updateDir ` + -RemoteBaseUrl '' ` + -SkipBuild ` + -Push + } catch { + $pushWasBlocked = $_.Exception.Message -like 'Quick validation never pushes*' + } + Assert-True $pushWasBlocked 'Quick validation did not block -Push.' + + & $releaseScript ` + -QuickValidation ` + -Repo $repo -BuildDir 'build' ` + -UpdateDir $updateDir ` + -RemoteBaseUrl '' ` + -SkipBuild + + $expectedVersion = '0.8.7-pre.3' + $zipPath = Join-Path $updateDir "acecode-$expectedVersion-windows-x64.zip" + Assert-True (Test-Path -LiteralPath $zipPath) 'Quick validation Windows zip was not created.' + Assert-True (-not (Test-Path -LiteralPath (Join-Path $updateDir "ace-browser-bridge-$expectedVersion.zip"))) 'Quick validation unexpectedly created a browser extension zip.' + + $manifest = ([System.IO.File]::ReadAllText((Join-Path $updateDir 'aceupdate.json'))) | ConvertFrom-Json + Assert-True ($manifest.latest -eq $expectedVersion) 'Quick validation manifest latest version is incorrect.' + $release = @($manifest.releases) | Select-Object -First 1 + Assert-True ($release.version -eq $expectedVersion) 'Quick validation release version is incorrect.' + Assert-True (@($release.packages).Count -eq 1) 'Quick validation must publish exactly one package entry.' + Assert-True ($release.packages[0].target -eq 'windows-x64') 'Quick validation package target is not windows-x64.' + Assert-True (@($manifest.releases | Where-Object { $_.version -eq '0.8.7-pre.2' }).Count -eq 1) 'Quick validation did not preserve the prior prerelease record.' + Assert-True (@($manifest.releases | Where-Object { $_.version -eq '0.8.6' }).Count -eq 1) 'Quick validation did not preserve the stable release record.' + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + try { + $entries = @($archive.Entries | ForEach-Object { $_.FullName }) + foreach ($required in @( + 'acecode.exe', + 'acecode-desktop.exe', + 'acecode-computer-use.exe', + 'channels/whatsapp/bridge.mjs', + 'channels/whatsapp/protocol.mjs', + 'channels/whatsapp/package.json', + 'channels/whatsapp/package-lock.json', + 'share/acecode/models_dev/api.json', + 'share/acecode/seed/MANIFEST.json')) { + Assert-True ($entries -contains $required) "Quick validation zip is missing $required." + } + Assert-True (-not (@($entries | Where-Object { $_ -like 'ace-browser-*' }).Count)) 'Quick validation zip contains a legacy browser artifact.' + } finally { + $archive.Dispose() + } + + foreach ($path in $sourcePaths) { + $afterHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + Assert-True ($afterHash -eq $beforeHashes[$path]) "Quick validation did not restore $path." + } + $status = @(& git -C $repo status --porcelain) + Assert-True ($status.Count -eq 0) 'Quick validation left tracked or untracked source changes in the fixture repo.' + $headAfter = (& git -C $repo rev-parse HEAD).Trim() + Assert-True ($headAfter -eq $headBefore) 'Quick validation created a commit.' + $tags = @(& git -C $repo tag --list) + Assert-True ($tags.Count -eq 0) 'Quick validation created a Git tag.' + + $failureWasCaught = $false + try { + $env:ACECODE_RELEASE_TEST_FORCE_VERSION = '0.0.0' + try { + & $releaseScript ` + -QuickValidation ` + -Version '0.8.7-pre.4' ` + -Repo $repo -BuildDir 'build' ` + -UpdateDir (Join-Path $testRoot 'failed-aupdate') ` + -RemoteBaseUrl '' ` + -SkipBuild + } finally { + Remove-Item Env:ACECODE_RELEASE_TEST_FORCE_VERSION -ErrorAction SilentlyContinue + } + } catch { + $failureWasCaught = $_.Exception.Message -like "Built executable reports*" + } + Assert-True $failureWasCaught 'Quick validation did not fail on an executable version mismatch.' + foreach ($path in $sourcePaths) { + $afterFailureHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + Assert-True ($afterFailureHash -eq $beforeHashes[$path]) "Failed quick validation did not restore $path." + } + + Write-Host 'Quick validation test passed.' +} finally { + $resolvedTestRoot = [System.IO.Path]::GetFullPath($testRoot) + $tempPrefix = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar + if ($resolvedTestRoot.StartsWith($tempPrefix, [System.StringComparison]::OrdinalIgnoreCase) -and + (Test-Path -LiteralPath $resolvedTestRoot)) { + Remove-Item -LiteralPath $resolvedTestRoot -Recurse -Force + } +} diff --git a/.acecode/skills/acecode-release/scripts/test_sync_github_release_to_aupdate.ps1 b/.acecode/skills/acecode-release/scripts/test_sync_github_release_to_aupdate.ps1 index a8f3da1d0..64f2b018b 100644 --- a/.acecode/skills/acecode-release/scripts/test_sync_github_release_to_aupdate.ps1 +++ b/.acecode/skills/acecode-release/scripts/test_sync_github_release_to_aupdate.ps1 @@ -109,9 +109,15 @@ function New-FixtureAssets { 'ACECode.app/Contents/MacOS/acecode-daemon' = "mac-$arch-daemon" 'ACECode.app/Contents/Info.plist' = $plist 'acecode' = "mac-$arch-cli" - 'share/acecode/models_dev/api.json' = '{}' - 'share/acecode/models_dev/MANIFEST.json' = '{}' - }) + 'ACECode.app/Contents/MacOS/acecode-cli' = "mac-$arch-cli" + 'ACECode.app/Contents/Resources/share/acecode/models_dev/api.json' = '{}' + 'ACECode.app/Contents/Resources/share/acecode/models_dev/MANIFEST.json' = '{}' + 'ACECode.app/Contents/Resources/share/acecode/models_dev/LICENSE' = 'fixture' + 'ACECode.app/Contents/Resources/share/acecode/seed/seed.version' = '2026-10-10.1' + 'ACECode.app/Contents/Resources/share/acecode/seed/MANIFEST.json' = '{}' + }) ` + -ExecutableEntries @('acecode', 'ACECode.app/Contents/MacOS/acecode-cli', + 'ACECode.app/Contents/MacOS/ACECode', 'ACECode.app/Contents/MacOS/acecode-daemon') # Minimal XAR header fixture; macOS CI owns signing/notarization checks. [System.IO.File]::WriteAllBytes( @@ -345,7 +351,9 @@ try { foreach ($scenario in @('missing-updater', 'missing-x64-pkg', 'missing-arm64-pkg', 'tampered-pkg', 'invalid-pkg', 'missing-pkg-checksum', 'missing-pkg-mime', - 'missing-windows-helper', 'empty-windows-helper')) { + 'missing-windows-helper', 'empty-windows-helper', + 'duplicated-mac-resources', 'different-mac-launcher', 'oversized-mac-launcher', + 'missing-mac-resource', 'nonexecutable-mac-launcher')) { $negativeAssets = Join-Path $testRoot "$scenario-assets" Copy-Item -LiteralPath $assets -Destination $negativeAssets -Recurse $negativeUpdateDir = Join-Path $testRoot "$scenario-aupdate" @@ -373,6 +381,41 @@ try { } else { 'empty Computer Use runtime' } } switch ($scenario) { + { $_ -in @('duplicated-mac-resources', 'different-mac-launcher', 'oversized-mac-launcher', + 'missing-mac-resource', 'nonexecutable-mac-launcher') } { + $macName = "ACECode-$version-macos-arm64-update.zip" + $macPath = Join-Path $negativeAssets $macName + $archive = [System.IO.Compression.ZipFile]::Open($macPath, [System.IO.Compression.ZipArchiveMode]::Update) + try { + switch ($scenario) { + 'duplicated-mac-resources' { + $null = $archive.CreateEntry('share/acecode/models_dev/api.json') + $expectedError = 'contains duplicated root resources' + } + 'missing-mac-resource' { + $archive.GetEntry('ACECode.app/Contents/Resources/share/acecode/seed/seed.version').Delete() + $expectedError = 'is missing required entry' + } + 'nonexecutable-mac-launcher' { + $archive.GetEntry('acecode').ExternalAttributes = 0 + $expectedError = 'does not preserve executable mode' + } + default { + $archive.GetEntry('acecode').Delete() + $entry = $archive.CreateEntry('acecode') + $stream = $entry.Open() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes($(if ($scenario -eq 'oversized-mac-launcher') { 'x' * 262145 } else { 'different' })) + $stream.Write($bytes, 0, $bytes.Length) + } finally { $stream.Dispose() } + $expectedError = if ($scenario -eq 'oversized-mac-launcher') { 'exceeds 256 KiB' } else { 'differs from signed app launcher' } + } + } + } finally { $archive.Dispose() } + $newSha = (Get-FileHash -LiteralPath $macPath -Algorithm SHA256).Hash.ToLowerInvariant() + $checksums = [IO.File]::ReadAllText($checksumPath) -replace ('(?m)^[0-9a-f]{64} ' + [regex]::Escape($macName) + '$'), "$newSha $macName" + Write-Utf8NoBom -Path $checksumPath -Text $checksums + } 'missing-updater' { $missingName = "ACECode-$version-macos-arm64-update.zip" [System.IO.File]::Delete((Join-Path $negativeAssets $missingName)) diff --git a/.agents/skills/acecode-frontend-style/SKILL.md b/.agents/skills/acecode-frontend-style/SKILL.md index 43cca9ff2..3a843eb7f 100644 --- a/.agents/skills/acecode-frontend-style/SKILL.md +++ b/.agents/skills/acecode-frontend-style/SKILL.md @@ -187,7 +187,16 @@ section 内不同子分组之间: ## 9. 主操作按钮 / 次按钮 -主按钮(保存 / 提交 / 新增): +### 设置页即时保存(必须遵守) + +- 设置页和配置展开区不放独立“保存”“应用”“取消更改”按钮;仅用户明确要求时允许例外。 +- 开关、下拉和单选变更后立即自动保存;文本和数字输入在失焦或按 Enter 时自动保存,避免把输入中的半成品逐字提交。 +- 收起、切换分组、关闭设置及窗口失焦时补齐有效更改;保存队列归连接级 store 所有,不能随着组件卸载丢失。 +- 串行提交并保留请求期间的新编辑;重新打开先等待队列,防止旧 GET 覆盖新设置。输入无效或保存失败时保留草稿,显示必要错误与重试入口,不弹逐字段保存成功提示。 +- “测试连接”等按钮保留其业务用途,并在执行前等待当前更改保存成功;不能兼作隐含的唯一保存入口。 +- 工具行沿用相邻行的结构;图标框带 `data-settings-surface`。必须在真实 `.ace-settings-content` 中验证布局和自动保存,不能只验孤立卡片。 + +以下主按钮模板用于创建、执行等显式业务动作,不用于设置页保存: ```jsx ``` @@ -259,7 +268,8 @@ section 内不同子分组之间: - [ ] 多选一用 § 4.1 row-radio,多选卡用 § 5 grid - [ ] input 走 § 6.0 / § 6.1 模板 - [ ] 状态指示走 § 8 绿点 -- [ ] 主按钮 § 9.0,行内次按钮 § 9.1 +- [ ] 设置页遵守 § 9 即时保存,不设独立保存按钮;失焦、Enter、收起、离开及失败重试已验证 +- [ ] 显式业务动作的主/次按钮使用 § 9 模板 - [ ] 颜色全部走 token,**不要 hex / `border-gray-X` / `bg-gray-X`** - [ ] 亮 / 暗主题不要写双 className,token 自带 - [ ] 常规 UI 继承 `font-sans`;仅代码和 diff 使用 `font-mono`;不新增第三套字体栈 diff --git a/AGENTS.md b/AGENTS.md index d872f8462..dc8de4051 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Canonical root docs are [README.md](README.md), [README_CN.md](README_CN.md), [A - `acecode_testable` is an INTERFACE aggregate of the production static libraries. All TUI implementations belong to `acecode_tui`; do not add testable subset lists. Executable entry points and the desktop WebView shell stay outside the aggregate. See `cmake/acecode_layer_libraries.cmake` for source ownership. - Daemon/API work usually touches [src/apps/daemon/](src/apps/daemon), [src/apps/web/](src/apps/web), and [src/domain/session/](src/domain/session). Update [docs/daemon-api.md](docs/daemon-api.md) when protocol behavior changes. - React/Vite/Tailwind frontend work stays under [web/src/](web/src). Do not edit generated build output directly; regenerate it with the web build. +- **设置页即时保存规则**:设置页及其配置展开区不设置独立的“保存”“应用”或“取消更改”按钮,除非用户明确要求例外。开关、下拉及单选选项变更后立即持久化;文本和数字输入在失焦或按 Enter 后自动保存,收起配置、切换设置分组和关闭设置时补齐未保存的有效更改。自动保存请求必须串行,保留请求期间的新编辑;失败保留草稿并提供重试,不得假装保存成功。测试连接等操作应先等待自动保存完成。完整设置页中验证交互,不能只用孤立组件截图验收。实现细则见 [.agents/skills/acecode-frontend-style/SKILL.md](.agents/skills/acecode-frontend-style/SKILL.md)。 - 新增弹框默认规则:有关闭按钮时,点击弹框外部不得关闭(使用 `Modal` 时显式设置 `dismissOnBackdrop={false}`);没有关闭按钮时,默认允许点击外部关闭。用户特殊指定的行为优先(例如设置对话框)。此规则面向今后的新增弹框,不追溯修改其他既有弹框;2026-10-07 指定的消息通道连接向导与管理弹框按此规则调整。 - Avoid modifying vendored or submodule trees such as [external/](external), `hermes-agent/`, or `claudecodehaha/` unless the task explicitly targets them. diff --git a/cmake/macos/ACECodeDesktopInfo.plist.in b/cmake/macos/ACECodeDesktopInfo.plist.in index 8ba85d92e..8689b7fa8 100644 --- a/cmake/macos/ACECodeDesktopInfo.plist.in +++ b/cmake/macos/ACECodeDesktopInfo.plist.in @@ -38,6 +38,8 @@ NSScreenCaptureUsageDescription ACECode Computer Use captures the windows you ask the agent to operate. + NSMicrophoneUsageDescription + ACECode records audio when you start dictation and sends it to your configured speech recognition service. NSHumanReadableCopyright ${MACOSX_BUNDLE_COPYRIGHT} diff --git a/docs/daemon-api.md b/docs/daemon-api.md index e76a43936..cd36cf219 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -395,6 +395,12 @@ Development `--static-dir` resources continue to be read from disk on each reque | PUT | `/api/config/custom-instructions` | write custom instructions | | GET | `/api/config/connectors` | read connector settings | | GET | `/api/config/image-generation` | read sanitized image generation settings | +| GET | `/api/config/speech-recognition` | read authenticated speech settings, including the editable key | +| PUT | `/api/config/speech-recognition` | save speech settings and cancel active recognition | +| POST | `/api/config/speech-recognition/test` | test a draft configuration with one second of silent WAV | +| GET | `/api/speech-recognition` | read speech availability without connection details or credentials | +| POST | `/api/speech-recognition/transcriptions/` | recognize a bounded raw PCM16 WAV body | +| DELETE | `/api/speech-recognition/transcriptions/` | cancel a recognition request | | GET | `/api/config/computer-use` | read computer control availability and pointer appearance | | PUT | `/api/config/computer-use` | partially update the switch or pointer appearance | | POST | `/api/config/computer-use/permissions` | explicitly request one macOS system permission | @@ -4075,6 +4081,58 @@ Window observations and PNG attachment metadata include a `cursor` object: screenshot's pixels, including when the native window image was resized. See [Computer Use](computer-use.md) for native capabilities and platform limits. +### Speech recognition settings and dictation + +All speech endpoints use normal API authentication/CORS and `Cache-Control: no-store`. +`GET /api/config/speech-recognition` returns `enabled` (default false), `protocol` +(`sensevoice`, `openai`, or `whisper_cpp`), `base_url`, `model`, `api_key`, +`language` (default `auto`), `timeout_ms` (default 120000), +`max_recording_seconds` (default 60), `use_proxy` (default false), `configured`, +and `generation`. The key is returned for the authenticated password editor; +never log or cache this response. The availability endpoint returns only +`enabled`, `configured`, both time limits, and `generation`. + +`PUT` accepts partial updates. An omitted key is preserved; an empty key clears +it. Both HTTP and HTTPS service URLs are accepted, including custom ports +1–65535; credentials, whitespace, query strings and fragments are rejected. +Timeout must be 5000–600000 ms and maximum recording length 1–300 seconds. +An empty URL is a valid incomplete draft. OpenAI-compatible configuration also +requires a model ID before the microphone becomes available. Invalid input +returns 400 `SPEECH_INVALID_CONFIG`; failed persistence returns 500 `PERSIST_FAILED` +without changing live state. Successful writes merge the disk configuration, +increment the generation, cancel active transcription and return the new snapshot. + +The settings UI has no separate Save button. Toggles and selects save immediately; +text/number inputs save on blur or Enter, with remaining edits flushed when +collapsing or leaving settings. Writes are serialized per connection and preserve +newer edits; failures retain the draft and offer retry. The connection-test button +waits for successful automatic saving before testing the current configuration. + +`POST /api/config/speech-recognition/test` accepts a partial settings object, +overlays it on saved settings without saving/enabling them, and transcribes one +second of silence. Its timeout is capped at 30 seconds. Success is `{"ok":true}`; +an empty transcript is a valid test result. No recognition runs while merely +loading or saving settings. + +`POST /api/speech-recognition/transcriptions/` takes an `audio/wav` body: +canonical 44-byte WAV header, 16 kHz mono PCM16, bounded by the configured +duration and 20 MiB. The ID must contain 1–64 ASCII letters, digits, `_` or `-`. +At most one dictation is active per daemon. Success returns +`{"status":"ok","text":"..."}` or `{"status":"no_speech","text":""}`. +`DELETE` is idempotent and returns `{"cancelled":true}`. Cancellation and daemon +shutdown stop forwarding and suppress late transcripts. A configuration change +invalidates requests admitted under an earlier generation. + +Errors include 409 `SPEECH_NOT_CONFIGURED` / `SPEECH_CANCELLED`, 429 `SPEECH_BUSY`, +413/422 `SPEECH_INVALID_AUDIO`, 503 `SPEECH_NOT_READY`, 504 `SPEECH_TIMEOUT`, and +502 `SPEECH_CONNECTION_FAILED`, `SPEECH_AUTH_FAILED`, `SPEECH_INVALID_RESPONSE` +or `SPEECH_UPSTREAM_FAILED`. Upstream bodies and credentials are never echoed. +The backend forwards in-memory multipart audio to SenseVoice +`/api/transcriptions` (`audio`), OpenAI-compatible `/v1/audio/transcriptions` +(`file`, `model`) or whisper.cpp `/inference` (`file`). HTTP services work +without certificates; browser microphone permission separately requires a +secure context. See [Speech recognition](speech-recognition.md). + ### Image generation settings `GET /api/config/image-generation` returns `enabled`, `source` (`inline` or diff --git a/docs/speech-recognition.md b/docs/speech-recognition.md new file mode 100644 index 000000000..b6a5ed6cc --- /dev/null +++ b/docs/speech-recognition.md @@ -0,0 +1,48 @@ +# 语音识别 + +ACECode 的 Desktop 和 Web 输入框支持听写。识别由独立服务完成,ACECode 不包含 Python、模型或推理运行时,也不会自动下载模型。 + +## 配置 + +进入 **设置 → 工具 → 语音识别 → 配置**,选择协议、填写服务地址,再打开开关。配置自动保存,没有独立保存按钮:开关和选项即时保存,文本和数字输入在失焦或按 Enter 后保存,收起或离开设置也会补齐更改。失败保留输入并提供重试。未配置或关闭时,输入框不显示麦克风。 + +「测试连接」会先等待当前更改保存成功,再提交一秒静音 WAV 验证服务,不使用麦克风。 + +| 协议 | 服务地址示例 | 模型配置 | 实际请求 | +| --- | --- | --- | --- | +| SenseVoice | `http://192.168.1.50:8765` | 在独立服务中加载 SenseVoice 模型 | `/api/transcriptions`,multipart 字段 `audio` | +| OpenAI 兼容 | `http://192.168.1.50:8000/v1` | 填写服务提供的模型 ID,例如 `whisper-1` | `/v1/audio/transcriptions`,字段 `file`、`model` | +| whisper.cpp | `http://192.168.1.50:8080` | 在 whisper.cpp 服务启动参数中选择模型 | `/inference`,字段 `file` | + +这里的 SenseVoice 协议对应独立 Python 服务约定:返回 `{"status":"ok","text":"识别文字"}`,静音返回 `{"status":"no_speech","text":""}`。其他 SenseVoice 服务若采用 OpenAI 兼容接口,应选择对应协议。服务地址填接口前缀,不要填写完整识别路径;OpenAI 兼容协议会在没有 `/v1` 后缀时自动补上。 + +API Key 可选,填写后通过 `Authorization: Bearer …` 发送。高级设置可调整最长录音(1–300 秒,默认 60 秒)、请求超时(5–600 秒,默认 120 秒),以及是否使用 ACECode 代理。内网默认直连。界面不提供语言选项,默认自动识别;SenseVoice 的语言由服务端控制。 + +## 内网 HTTP 和端口 + +识别服务可以使用普通 HTTP,不需要 HTTPS 证书。端口直接写在服务地址中,可以修改;例如 Mac mini 上服务监听 `0.0.0.0:9876`,ACECode 填 `http://Mac-mini的内网IP:9876`。允许该端口入站,并确认 ACECode 后端所在电脑能够访问它。 + +音频先提交给 ACECode 后端,再由后端调用识别服务,因此浏览器无需直接连接 Mac mini,也无需给识别服务配置浏览器 CORS。 + +**麦克风权限取决于打开 ACECode 的页面环境。** Desktop 的本机页面、浏览器的 `http://localhost:端口` 或 HTTPS 页面可以请求麦克风;直接在浏览器打开另一台电脑的 `http://内网IP:端口` 通常无法录音。没有证书时可在各电脑使用 ACECode Desktop 或本机 Web,再连接同一个 HTTP 识别服务。系统和浏览器仍需允许麦克风权限。 + +浏览器限制参见 [MDN getUserMedia 安全上下文说明](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#privacy_and_security);whisper.cpp 的服务端参数和接口见[官方服务文档](https://github.com/ggml-org/whisper.cpp/blob/master/examples/server/README.md)。 + +## 输入框操作 + +- 点击麦克风开始录音,底部显示实际音量波形。 +- 悬停提示“右键配置麦克风”;右键可选择当前电脑的实际音频输入设备,选项即时保存。首次获取完整设备列表可能需要麦克风授权,菜单获取设备名称后立即释放临时音频流。 +- 默认 `Ctrl+D` 开始/停止录音,停止后识别回填。在 **设置 → 快捷键 → 开始/停止录音** 可修改;快捷键录入、其他编辑区域和弹窗不会误触录音。 +- 右键菜单的“按住录音”默认关闭。开启后,按住麦克风或录音快捷键开始,松开即停止并识别;尚未获得权限就松开时取消等待。设备选择与按住偏好仅保存在当前电脑的浏览器/桌面环境中。 +- 点击停止方块:结束录音并把识别文字插入原光标位置,保留已有草稿和附件标签。 +- 点击发送箭头或按发送快捷键:识别后发送合并后的完整草稿一次。 +- 点击取消或按 Escape:丢弃本次录音,保留原草稿。 +- 达到录音时长上限时自动停止并回填,不自动发送。 + +开始、等待权限、识别和取消使用同一工具栏空间,不改变输入框外框尺寸;识别文本新增导致的正常文本换行仍会扩展编辑区。 + +识别失败或没有识别到语音时不会发送消息。切换会话、关闭输入框或改变配置会取消本次听写,迟到结果不会插入其他会话。音频使用内存中的单声道 16 kHz PCM16 WAV,不写入 ACECode 的会话历史或临时音频文件;独立识别服务的存储行为由其自身配置决定。 + +## 验证边界 + +浏览器自动化使用模拟麦克风验证真实 WebAudio 采集、WAV 编码和输入框交互;不能代替真实设备授权验证。macOS 的麦克风用途声明和 Linux WebKit 的本机音频权限处理已接入,仍需在对应系统进行真实设备验收。 diff --git a/openspec/changes/add-speech-recognition/design.md b/openspec/changes/add-speech-recognition/design.md new file mode 100644 index 000000000..e18243692 --- /dev/null +++ b/openspec/changes/add-speech-recognition/design.md @@ -0,0 +1,61 @@ +## Context + +共享输入框为 InputBar + RichComposer + ComposerSessionControls,Slate 已提供跟随编辑的异步 rangeRef 插入机制。设置工具页以独立可折叠配置卡组织。配置保存沿用 mutate_config,HTTP 客户端沿用 cpr。现有工作区有归档相关脏文件,必须保留。 + +## Goals / Non-Goals + +- 完整交付设置、录音、后端转发、文本回填及 HTTP 内网使用。 +- 支持 SenseVoice 自定义接口、OpenAI 兼容音频接口、whisper.cpp;单个当前生效配置可以切换协议和模型。 +- 不捆绑或启动模型服务,不实现流式逐字识别、TTS 或 TUI 麦克风录音。 +- 不通过关闭浏览器安全设置来支持远程 HTTP 页面麦克风;Desktop/localhost 采集与内网 HTTP 识别相互独立。 + +## Decisions + +1. 配置 speech_recognition 默认 disabled,协议 sensevoice,服务地址空;可选 API Key,OpenAI 协议必须填写模型。地址允许 HTTP、HTTPS 和显式端口,保留 TLS 正常验证,不影响其他模块原有 HTTPS 策略。 +2. 设置卡位于工具页,不设独立保存按钮;开关/选项立即自动保存,文本和数字输入在失焦、Enter、收起或离开时提交。连接级 store 串行排空保存队列,保留请求期间的新编辑,重新打开等待保存队列后再读取。失败保留草稿并提供重试;测试连接等待自动保存成功。当前输入框订阅不含密钥的可用性信息并立即更新。 +3. Web Audio 采集单声道 PCM,停止后重采样并编码 16 kHz PCM16 WAV;波形取真实音频能量。默认录音上限 60 秒、请求超时 120 秒,设置中可调整;服务仍可施加自己的限制。 +4. 前端仅向已鉴权的 ACECode API 上传 WAV。后端按协议映射 multipart 字段和路径,使用有界响应、明确错误码、禁止重定向和请求取消。音频仅驻留请求内存,不落盘或写日志。 +5. 输入框以会话身份和请求代次约束录音与结果,复用 Slate 异步插入位置;开始录音时保留草稿和标签,取消不回滚用户自行编辑的内容。停止不提交,发送成功后走现有提交/排队逻辑,识别失败和空结果不提交。 +6. 同时只允许一份客户端录音;后端限制并发转写避免堆积。取消接口以请求 ID 标识活动调用。外部识别服务是否真正中止推理由服务自己决定,ACECode 不接受已取消结果。 +7. Mac Desktop 添加麦克风用途说明;检查各 Desktop WebView 权限入口,保持仅主页面用户触发采集。Windows 本地编译和浏览器验证作为当前可执行验收,不将其宣称为 Mac/Linux 真机通过。 + +## Risks / Trade-offs + +- 麦克风设备属于浏览器所在电脑,本地偏好与后端识别服务配置分离;使用共享连接无关的本机偏好 store。设备列表仅在显式右键菜单时请求,必要时申请麦克风权限以获取真实设备名称,临时授权流立即释放。 +- 默认 Ctrl+D 通过现有快捷键目录、偏好和事件分发接入;仅可用输入框接管。按住模式默认关闭,开启后鼠标/快捷键松开即停止;授权未完成就松开时取消,避免迟到授权后继续采集。 +- 原工具栏保持布局占位,录音栏绝对定位覆盖其区域,使用相同内边距和高度,不再用不同高度的组件替换布局。 + +- 自定义 SenseVoice 不接收按请求选择模型;该协议使用服务端已加载模型,模型输入仅对需要它的协议开放。 +- HTTP 内网地址由用户配置;麦克风安全上下文由客户端环境决定,配置端口不能解除远程 HTTP 页面的限制。 +- 多会话、权限请求和网络响应可能乱序,状态机测试覆盖取消、迟到响应、停止/发送重复点击及配置变化。 +- 真实测试使用独立临时测试配置/服务,避免修改用户现有模型或运行中会话。 + +## Validation + +配置解析/持久化、HTTP 参数映射与错误解析的 C++ 测试;录音状态机、WAV、配置同步的 JS 测试;pnpm test/build;Windows 定向构建和后端 HTTP 实测;浏览器检查主题、入口可见性、录音/停止/取消/发送及草稿隔离。最终审查原有脏改动仍保留。 + +### 当前验收证据(2026-10-18) + +- `pnpm test`、`pnpm build` 通过;包含英文文案覆盖、图标资源一致性和旧 WebView 正则兼容检查。 +- `web/scripts/test-speech-recognition.mjs` 使用真实 InputBar/RichComposer 和 Chromium 模拟麦克风:验证 16 kHz WAV、主题、窄屏、新会话布局、停止回填、空草稿及已有草稿发送、Enter 发送、取消、错误、静音、会话切换、设置变更和文件/Skill 标签保留。识别 API 在该浏览器测试中为受控替身。 +- `SpeechRecognitionConfig.*:SpeechClient.*:SpeechRecognitionSmoke.*:ConfigMutation.*:ImageGenerationSettings.*` 最终 19 项通过,另 1 项外部模型测试单独执行;实际本机 HTTP 覆盖三个协议的 multipart、配置持久化、无密钥可用性视图及取消。 +- Windows `acecode`、`acecode-desktop` 构建通过,独立输出在 `build/speech-validation/Release/`,CLI `--version` 成功;前端构建后已重新运行 CMake 配置。验证构建结束后已恢复生成项目的默认输出目录,运行中的旧程序未覆盖。 +- `SpeechClient.ExternalSenseVoiceModel` 另行连接本机独立 SenseVoice CPU 服务,通过 HTTP 上传由模型公开样例生成的 PCM16 WAV,识别得到“开放时间早上9点至下午5点。”,用例通过;该服务与模型均未打包进 ACECode,测试结束服务正常停止。这是 Windows CPU 联调,不是内网 Mac mini 实测。 +- 分层检查将 tracked 与 untracked 文件一起纳入,严格规则零问题;OpenSpec 严格校验和 `git diff --check` 通过。 +- Sidebar、Toast、归档原有测试逐文件与修改前快照一致;SettingsPage、API 和翻译目录的归档并行改动保留。 +- 未做 macOS/Linux 真机编译、系统麦克风授权或实际说话验收。Windows Chromium 模拟设备验收不能替代这些项目;运行中的旧 ACECode 实例尚未切换。 + +### 设置行对齐修正 + +完整设置页的 `.ace-settings-content` 会把未标记的 bordered flex 容器变为无侧边距的分隔行。语音行此前漏掉行边框 class,图标容器又漏掉 `data-settings-surface`,导致正文缩进 14 px、图标框消失且错误出现短分隔线。现复用相邻图像生成行的行样式、图标表面标记及配置按钮尺寸。 + +浏览器验收已改为挂载真实 SettingsPage 工具页。修正前几何断言准确复现错位;修正后 1440/390 px、明暗主题下,图标、标题、配置按钮、开关的横坐标均与相邻工具一致,图标边框/圆角和行分隔线一致。原有录音交互检查保留。并发负载下首轮完整测试出现 Agent Browser 就绪断言及一次录音发送等待超时,单项及完整测试、浏览器顺序重跑均通过;本次未修改识别或提交逻辑。 + +### 自动保存与麦克风操作增补验收 + +- 工作区 AGENTS.md 与前端样式规范明确设置页自动保存、无独立保存按钮;语音设置验证失焦、Enter、收起、离开后重开、在途编辑串行保存、错误保留/重试和连接测试等待保存。 +- 回归进一步确认此前识别发送超时来自渲染状态落后于编辑器引用:提交现在以当前结构化草稿同时判断可发送状态和构造消息,空草稿识别发送已通过。 +- 浏览器验证实际设备枚举、选中状态和 getUserMedia 的 exact deviceId,快捷键页修改 Ctrl+D 后即时生效,按住鼠标/快捷键松开、窗口失焦自动停止,迟到权限释放且不上传;弹窗/终端/其他编辑区域维持上下文边界。终端保留 Ctrl+D 的原输入行为。 +- 普通/首页、明暗主题和 390 px 窄屏的录音前后 x/y/width/height 差均小于 1 CSS px,菜单不含语言项。原工具栏持续占位,录音层覆盖显示。 +- 最终 pnpm test、pnpm build、完整浏览器交互、OpenSpec 严格校验及 diff 检查通过。浏览器脚本通过真实按钮打开快捷键设置,避免测试驱动直接插入设置页时破坏聚焦编辑器的提交阶段;异步回填也不会再抢走弹窗焦点。 +- 这些增补已更新源码和 Web 构建;先前 Windows 验证二进制未包含本轮增补,当前未替换运行中的 Desktop,也未追加 Mac/Linux 真机验收。 diff --git a/openspec/changes/add-speech-recognition/proposal.md b/openspec/changes/add-speech-recognition/proposal.md new file mode 100644 index 000000000..a48f8d0f4 --- /dev/null +++ b/openspec/changes/add-speech-recognition/proposal.md @@ -0,0 +1,25 @@ +## Why + +用户已有独立部署的 SenseVoice 服务,需要在 ACECode 输入框直接听写,并能切换到其他语音识别接口。公司内网服务没有 HTTPS 证书,服务地址和端口必须可配置,客户端不捆绑 Python、模型或启动识别进程。 + +## What Changes + +- 在设置的“工具”页增加“语音识别”,配置启用状态、协议、服务地址、模型、可选密钥和录音/请求限制。 +- 支持现有 SenseVoice HTTP 接口、OpenAI 兼容转写接口和 whisper.cpp 接口,由 ACECode 后端转发音频。 +- 已启用且配置完整时在发送按钮左侧显示听写按钮;录音时显示取消、实际音量波形、停止、发送。 +- 停止仅插入可编辑文字;发送在成功识别后提交完整草稿;取消、失败、切换会话不能破坏草稿或回填到其他会话。 +- 支持 HTTP 内网服务及自定义端口;区分识别服务传输与浏览器麦克风的安全上下文要求。 + +## Capabilities + +### New Capabilities + +- `speech-recognition`: 配置独立识别服务、录音生命周期、协议适配、草稿回填和错误处理。 + +### Modified Capabilities + +无。 + +## Impact + +涉及 base/config 持久化、adapters 下语音客户端、web 鉴权接口、设置工具页和共享 InputBar/RichComposer。沿用 cpr、React、Slate 和 Web Audio,不新增模型或 Python 运行依赖。更新 daemon API 文档、定向 C++/JS 测试与浏览器交互验证。保留当前检出中的其他未提交改动。 diff --git a/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md b/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md new file mode 100644 index 000000000..d2c9b8902 --- /dev/null +++ b/openspec/changes/add-speech-recognition/specs/speech-recognition/spec.md @@ -0,0 +1,82 @@ +## Purpose + +让用户在 ACECode 的现有输入框中录音并使用自行部署或配置的语音识别服务生成可编辑草稿,保持主题、编辑器内容和会话隔离,同时支持无 HTTPS 证书的内网识别服务与可变端口。 + +## ADDED Requirements + +### Requirement: 识别服务配置与入口可见性 +系统 SHALL 在设置“工具”中提供语音识别开关、协议、服务地址、模型和可选密钥配置,默认关闭。只有启用且配置完整时 SHALL 显示麦克风按钮,保存后当前输入框立即更新。 + +#### Scenario: 尚未配置或关闭 +- **WHEN** 用户未填写服务地址或关闭语音识别 +- **THEN** 新会话和已有会话输入框均不显示麦克风按钮 + +#### Scenario: 配置后立即启用 +- **WHEN** 用户保存完整的识别服务配置并启用 +- **THEN** 当前输入框无需切换会话即可显示听写按钮 + +#### Scenario: 设置自动保存 +- **WHEN** 用户修改语音设置的开关或选项,或完成文本/数字编辑并失焦、按 Enter、收起或离开设置 +- **THEN** 配置自动持久化,不提供独立保存按钮;请求串行执行且不覆盖更新的编辑,失败保留草稿并允许重试,测试连接等待自动保存成功后执行 + +### Requirement: 可配置协议及 HTTP 内网传输 +系统 SHALL 支持现有 SenseVoice、OpenAI 兼容转写和 whisper.cpp 接口,经 ACECode 后端转发音频;SHALL 接受 HTTP/HTTPS 服务地址和自定义端口,不捆绑模型、Python 或启动外部服务。 + +#### Scenario: 内网无证书服务 +- **WHEN** 用户配置 http://192.168.1.50:9876 的 SenseVoice 服务 +- **THEN** 已获麦克风权限的客户端可识别音频,识别服务不需要证书或浏览器跨域配置 + +#### Scenario: 更换协议 +- **WHEN** 用户配置另一已支持协议及其地址、模型 +- **THEN** 听写交互保持一致,音频按所选协议提交 + +### Requirement: 录音交互 +系统 SHALL 在普通输入框中显示听写按钮;录音期间底部显示取消、真实音量波形、停止和发送,沿用 ACECode 主题。停止 SHALL 仅识别并插入草稿;发送 SHALL 在识别成功后提交合并草稿;失败或无语音 SHALL 不自动发送。 + +#### Scenario: 停止后编辑 +- **WHEN** 用户停止录音且服务返回文本 +- **THEN** 文本插入录音开始时的位置并保持可编辑,现有文件和技能标签保留 + +#### Scenario: 录音并发送 +- **WHEN** 用户点击录音栏发送且识别成功 +- **THEN** 原草稿与识别文字通过现有提交或排队行为发送一次 + +### Requirement: 取消与会话隔离 +系统 SHALL 在取消、关闭输入框、切换会话或关闭语音功能时释放录音资源并丢弃迟到结果。识别请求 SHALL 有时长、大小和超时限制;不记录音频或转写正文日志。 + +#### Scenario: 权限回调迟到 +- **WHEN** 用户取消后麦克风权限请求才成功 +- **THEN** 立即停止获得的轨道,不录音、不上传、不修改草稿 + +#### Scenario: 切换会话 +- **WHEN** A 会话识别未完成时切换到 B +- **THEN** 停止 A 的录音或取消请求,结果不会写入 B 或替换 A 的草稿 + +### Requirement: 麦克风环境错误可恢复 +系统 SHALL 区分识别服务器连接错误和客户端麦克风权限/安全上下文错误,并保留用户草稿。不得声称普通远程 HTTP 网页绕过了浏览器麦克风限制。 + +#### Scenario: 远程 HTTP 网页 +- **WHEN** 浏览器因 ACECode 页面不处于安全上下文而拒绝麦克风 +- **THEN** 提示使用 Desktop、localhost 或安全页面入口;服务端 HTTP 地址无需更改 + +### Requirement: 麦克风设备菜单与录音快捷键 +系统 SHALL 在麦克风按钮 hover 时提示“右键配置麦克风”,右键打开使用实际浏览器音频输入设备的菜单,包含设备选中标记和“按住录音”开关,不包含语言项。设备和按住偏好 SHALL 即时保存在本机,采集 SHALL 使用所选设备。 + +#### Scenario: 选择麦克风 +- **WHEN** 用户右键麦克风并选择一个音频输入设备 +- **THEN** 下次录音使用该设备,菜单显示选中状态,设备不可用时提示重新选择且保留草稿 + +#### Scenario: 可配置快捷键 +- **WHEN** 用户按 Ctrl+D 或在快捷键设置中自定义的录音快捷键 +- **THEN** 在可用输入框开始录音,再按停止并回填;快捷键录入、弹窗及其他编辑区域不会误触录音 + +#### Scenario: 按住录音 +- **WHEN** 用户启用按住录音并按住麦克风或录音快捷键 +- **THEN** 按下开始,松开停止并识别;若授权尚未完成则取消等待,窗口失焦或会话切换不会继续隐式录音 + +### Requirement: 录音切换保持输入框布局 +系统 SHALL 保持开始录音前后输入框及底部工具栏的布局尺寸,适配普通输入框、首页、主题和窄屏,不因替换控件使周围内容跳动。 + +#### Scenario: 开始与取消录音 +- **WHEN** 用户开始或取消录音且草稿内容没有改变 +- **THEN** 输入框边界及周围内容位置保持不变 diff --git a/openspec/changes/add-speech-recognition/tasks.md b/openspec/changes/add-speech-recognition/tasks.md new file mode 100644 index 000000000..ba1dbee6b --- /dev/null +++ b/openspec/changes/add-speech-recognition/tasks.md @@ -0,0 +1,25 @@ +## 1. 配置与服务适配 +- [x] 1.1 实现默认关闭的识别配置、HTTP/端口校验、读写和设置接口;以配置及 handler 测试验证省略密钥、错误字段和往返保存。 +- [x] 1.2 实现三个识别协议、连接测试、转写及取消接口,并更新 daemon API 文档;以协议/错误/取消测试和本地 HTTP 请求验证。 + +## 2. 设置与输入框 +- [x] 2.1 工具页增加语音识别卡和设置搜索入口,配置保存实时更新入口;以 JS 配置测试和浏览器操作验证。 +- [x] 2.2 实现录音状态机、WAV 编码、实际波形、取消/停止/发送及草稿插入;以生命周期与音频 JS 测试验证迟到权限、取消和重复提交。 +- [x] 2.3 适配主输入框和新会话主题、焦点及麦克风环境错误,检查 Desktop 权限需求并更新使用说明;以浏览器交互及平台静态检查验证。 + +## 3. 集成验收 +- [x] 3.1 运行 pnpm test/build、OpenSpec 严格校验及 Windows 定向构建/测试,记录结果。 +- [x] 3.2 完成浏览器和真实 HTTP 识别联调,审查取消/切换/设置变化、亮暗主题及原有脏改动;明确未执行的平台验收。 + +## 4. 设置页布局修正 +- [x] 4.1 在完整设置工具页复现语音行错位,复用相邻工具行的边框及图标表面标记,验证图标、标题、配置按钮和开关在明暗主题与窄屏下对齐。 + +## 5. 设置页即时保存 +- [x] 5.1 移除独立保存按钮,接入失焦、Enter、收起和离开保存,开关/选项立即保存;串行队列保留新编辑,错误可重试,测试连接等待保存成功。 +- [x] 5.2 将即时保存规则加入 AGENTS.md 和工作区前端样式规范;同步文档并通过配置队列、完整设置页交互及 Web 测试/构建。 +- [x] 5.3 修正回归发现的发送时序问题:识别文字已写入编辑器但 React 尚未刷新时,提交根据当前草稿判断可发送状态;验证空草稿识别后发送和原有提交守卫。 + +## 6. 麦克风菜单、快捷键及稳定布局 +- [x] 6.1 麦克风 hover 提示“右键配置麦克风”;右键菜单列出实际音频输入设备、选中标记和按住录音开关,不含语言项,设备偏好在本机保存并用于实际采集。 +- [x] 6.2 默认 Ctrl+D 开始/停止录音并接入快捷键设置、冲突校验及上下文守卫;按住模式松开即停止,迟到授权、窗口失焦和切换会话不残留录音。 +- [x] 6.3 录音工具栏覆盖原工具栏且保持原布局尺寸;验证普通/首页、明暗、窄屏切换时边界不跳动及菜单、快捷键和录音完整流程。 diff --git a/openspec/changes/improve-archived-session-browser/design.md b/openspec/changes/improve-archived-session-browser/design.md index 0a47980af..75746852d 100644 --- a/openspec/changes/improve-archived-session-browser/design.md +++ b/openspec/changes/improve-archived-session-browser/design.md @@ -23,8 +23,17 @@ - [Filtering could accidentally retain hidden destructive targets] -> Use visible results for selected targets and test selection pruning, no matches, and combined filters. - [Shared settings CSS removes generic card borders] -> Use semantic list elements for group frames and marked composite controls for filter borders; inspect actual desktop, narrow, and dark renderings. - [New Chinese labels can fail localization gates] -> Add English overrides, regenerate the catalog, and review generated changes. -- [Archives with many sessions remain fully loaded] -> This preserves the current loading contract; memoize local transformations without adding server pagination. +- [归档很多时一次性挂载导致卡顿] -> 完整元数据仍用于搜索、排序、工作区选项和总数;DOM 按 100 条增量挂载,不新增服务端分页协议。 ## Migration Plan No data migration is needed. Ship the frontend bundle through the normal build. Reverting these frontend changes restores the previous archive presentation without altering stored sessions. + +## 2026-10-18 归档列表分批显示 + +- 在完整过滤、分组和排序之后,按累计行数切出首批 100 条,跨工作区共用同一个额度;组标题保留完整匹配数量,未加载的空组不挂载。 +- 在设置内容滚动容器内观察列表末尾,接近底部追加 100 条;观察器不可用时用滚动/尺寸事件兜底。每次观察周期只允许追加一批,卸载时清理观察器/事件,尾批加载完移除哨兵。 +- 搜索、工作区或排序变化时重置首批并回到列表顶部,避免旧分页额度导致短暂全量渲染;选择状态只保留新显示范围内的会话。追加批次不自动勾选新行。 +- 全选及批量操作仅作用于已显示的行;取消归档/删除后保持当前加载额度并补齐缺口,外部撤销触发的数据刷新沿用已有生命周期保护。 +- 初始接口仍获取完整元数据,网络/磁盘全量扫描延迟不是本次前端分批显示解决的范围;无工作区请求增加已有的 `scope=no-workspace`,避免读取随后会被前端丢弃的兼容工作区元数据。 +- 用 100/101 边界、跨组 205 条、超过 2,000 条的实际组件浏览器夹具验证挂载数、滚动、搜索、排序、选择及刷新;比较修改前后的 DOM 数量。 diff --git a/openspec/changes/improve-archived-session-browser/proposal.md b/openspec/changes/improve-archived-session-browser/proposal.md index 7cdb9b622..1f009e530 100644 --- a/openspec/changes/improve-archived-session-browser/proposal.md +++ b/openspec/changes/improve-archived-session-browser/proposal.md @@ -9,6 +9,7 @@ The archive settings page is a flat list with its batch actions below every sess - 工作区分组和筛选选项沿用侧边栏工作区顺序;最近/最早更新时间只决定各工作区内部的会话顺序。 - Move select all, unarchive selected sessions, and delete selected sessions into the upper-right header; restrict selection and batch operations to current filtered results. - Retain single-session actions, localized relative timestamps, shared deletion confirmation, and partial-failure retry behavior. +- 超过 100 条时只挂载首批 100 条,滚动到列表底部每批追加 100 条;搜索和工作区筛选仍覆盖完整归档元数据。 ## Capabilities diff --git a/openspec/changes/improve-archived-session-browser/specs/session-archive/spec.md b/openspec/changes/improve-archived-session-browser/specs/session-archive/spec.md index acfc3f45b..0f42bf884 100644 --- a/openspec/changes/improve-archived-session-browser/specs/session-archive/spec.md +++ b/openspec/changes/improve-archived-session-browser/specs/session-archive/spec.md @@ -46,7 +46,7 @@ The archive view SHALL show select all/deselect all, unarchive selected sessions - **THEN** only visible matching sessions MUST be selected - **AND** batch restore and deletion MUST act only on the current selection - **AND** changing filters MUST remove selections for sessions no longer visible -- **AND** sorting MUST preserve selected sessions +- **AND** sorting MUST preserve selected sessions that remain in the displayed batch #### Scenario: No available selection - **WHEN** no current results are selected or an operation is running @@ -61,3 +61,23 @@ The archive view SHALL show select all/deselect all, unarchive selected sessions - **WHEN** the user deletes one session or the selected sessions - **THEN** the existing shared confirmation dialog MUST appear before permanent deletion - **AND** cancelling MUST leave the archive unchanged + +### Requirement: 大量归档会话分批显示 +归档界面 SHALL 在完整搜索、工作区筛选和组内排序后,首批最多显示 100 条;不足或等于 100 条时 SHALL 一次显示全部。底部滚动加载 SHALL 跨工作区累计计数,分组标题仍显示完整匹配数。 + +#### Scenario: 滚动追加下一批 +- **WHEN** 匹配归档超过 100 条且用户滚动接近当前列表底部 +- **THEN** 界面 MUST 追加最多 100 条并保留已显示行和选择状态 +- **AND** 相同观察周期 MUST NOT 重复追加,全部加载后 MUST 停止追加 + +#### Scenario: 搜索和筛选未显示会话 +- **WHEN** 用户修改搜索、工作区或排序条件 +- **THEN** 结果 MUST 基于完整归档元数据重新计算,重置为首批 100 条并回到顶部 +- **AND** 未显示会话的标题 MUST 可被搜索到 +- **AND** 选择范围 MUST 收缩到新显示结果,追加新行 MUST NOT 自动勾选 + +#### Scenario: 删除与刷新保持加载额度 +- **WHEN** 用户取消归档、删除会话或外部归档事件刷新数据 +- **THEN** 界面 MUST 保留当前累计加载额度,并从剩余结果补齐行 +- **AND** 全选和批量操作 MUST 只作用于已显示会话 +- **AND** 关闭页面 MUST 清理所有滚动加载观察器和事件监听 diff --git a/openspec/changes/improve-archived-session-browser/tasks.md b/openspec/changes/improve-archived-session-browser/tasks.md index 1f44e8ea4..e38263ac3 100644 --- a/openspec/changes/improve-archived-session-browser/tasks.md +++ b/openspec/changes/improve-archived-session-browser/tasks.md @@ -21,3 +21,12 @@ Verification: `pnpm test` passed 2,174 cases; `pnpm build` and the regex compati - [x] 4.3 执行定向回归、完整 Web 测试与生产构建、OpenSpec 严格验证和差异检查,并验证实际页面的排序交互。 本次验证:归档助手 15 项、批量操作结构 3 项定向检查通过;`pnpm test` 全部通过(日志包含 3,024 条 `[pass]`);`pnpm build` 与正则兼容性检查、`openspec validate improve-archived-session-browser --strict`、`git diff --check` 通过。Chromium 使用隔离 API 数据验证工作区分组与筛选顺序、组内正反时间排序、选择保留、组合筛选、取消归档后分组顺序以及修改工作区顺序后重新打开页面;1440px 和 430px 页面无横向溢出、无页面异常。没有重新构建或启动 Desktop。 + +## 5. 2026-10-18 超过 100 条时滚动分批显示 + +- [x] 5.1 增加跨组累计分页助手及边界测试,完整过滤/排序后切片,保留组匹配总数。 +- [x] 5.2 接入每批 100 条的滚动追加和兜底,重置筛选分页并限定选择范围,保留现有归档刷新逻辑,收窄无工作区元数据请求。 +- [x] 5.3 用真实组件夹具比较修改前后挂载数量,验证触底追加、筛选、排序、批量操作、外部刷新、窄屏及观察器兜底。 +- [x] 5.4 执行完整 Web 测试/构建、OpenSpec 严格验证及差异检查并记录结果。 + +本次验证:`pnpm test` 通过(3,241 条 `[pass]`,另含 Node test runner 的 3 项测试);`pnpm build` 及 4,541 条正则兼容性检查通过;`openspec validate improve-archived-session-browser --strict`、`git diff --check` 和界面机械检查通过。`web/scripts/test-archived-session-pagination.mjs` 用真实 SettingsPage 与隔离 API 数据验证:2,305 条归档首屏从 2,305 行 / 25,421 个内容区 DOM 元素降到 100 行 / 1,159 个元素;100→200 追加、100/101 尾批、跨组累计、完整标题搜索、筛选/排序重置、未加载行不被选中、部分取消归档失败、补齐行、外部刷新、删除确认/取消、关闭重开、无 IntersectionObserver 兜底及 430px 深色布局均通过,无页面错误。加载时长受 Vite 冷/热缓存影响,不作为产品性能结论。截图保存在系统临时目录 `ace-archive-pagination-XOTpYF`,未重新构建或启动 Desktop。 diff --git a/src/adapters/speech/speech_client.cpp b/src/adapters/speech/speech_client.cpp new file mode 100644 index 000000000..992fb3757 --- /dev/null +++ b/src/adapters/speech/speech_client.cpp @@ -0,0 +1,138 @@ +#include "speech_client.hpp" +#include "network/proxy_resolver.hpp" +#include "utils/abandonable_call.hpp" + +#include +#include +#include +#include + +namespace acecode::speech { +namespace { +Result failure(const char* code, int status = 502) { return {false, {}, code, status}; } + +std::uint32_t read_u32(const std::string& value, std::size_t offset) { + std::uint32_t out = 0; + for (unsigned i = 0; i < 4; ++i) + out |= static_cast(static_cast(value[offset + i])) << (8 * i); + return out; +} +void write_u32(std::string& value, std::size_t offset, std::uint32_t number) { + for (unsigned i = 0; i < 4; ++i) value[offset + i] = static_cast((number >> (8 * i)) & 255); +} + +Result send(const SpeechRecognitionConfig& config, const std::string& audio, + const std::shared_ptr>& cancelled, + const network::ProxyOptions& proxy) { + const auto plan = request_plan(config); + cpr::Session session; + session.SetUrl(cpr::Url{plan.url}); + session.SetTimeout(cpr::Timeout{config.timeout_ms}); + session.SetConnectTimeout(cpr::ConnectTimeout{5000}); + session.SetRedirect(cpr::Redirect{false}); + session.SetSslOptions(network::build_ssl_options(proxy)); + session.SetProxies(proxy.proxies); + session.SetProxyAuth(proxy.auth); + cpr::Header headers{{"Accept", "application/json"}}; + if (!config.api_key.empty()) headers["Authorization"] = "Bearer " + config.api_key; + if (config.protocol == "sensevoice") { + // Compatibility with the original standalone service's Origin check. + const auto start = config.base_url.find("://") + 3; + headers["Origin"] = config.base_url.substr(0, config.base_url.find('/', start)); + } + session.SetHeader(headers); + cpr::Multipart form{}; + form.parts.emplace_back(plan.file_field, + cpr::Buffer{audio.begin(), audio.end(), std::filesystem::path("recording.wav")}, "audio/wav"); + for (const auto& [key, value] : plan.fields) form.parts.emplace_back(key, value); + session.SetMultipart(std::move(form)); + session.SetProgressCallback(cpr::ProgressCallback{[cancelled](cpr::cpr_off_t, cpr::cpr_off_t, + cpr::cpr_off_t, cpr::cpr_off_t, intptr_t) { + return !cancelled->load(); + }}); + std::string body; + session.SetWriteCallback(cpr::WriteCallback{[&body](std::string_view chunk, intptr_t) { + if (body.size() + chunk.size() > 1024 * 1024) return false; + body.append(chunk); + return true; + }}); + const auto response = session.Post(); + if (cancelled->load()) return failure("SPEECH_CANCELLED", 409); + if (response.error.code == cpr::ErrorCode::OPERATION_TIMEDOUT) return failure("SPEECH_TIMEOUT", 504); + if (response.error.code != cpr::ErrorCode::OK) return failure("SPEECH_CONNECTION_FAILED"); + return parse_response(static_cast(response.status_code), body); +} +} // namespace + +RequestPlan request_plan(const SpeechRecognitionConfig& config) { + RequestPlan plan; + auto base = config.base_url; + while (!base.empty() && base.back() == '/') base.pop_back(); + if (config.protocol == "sensevoice") return {base + "/api/transcriptions", "audio", {}}; + plan.file_field = "file"; + if (config.protocol == "whisper_cpp") plan.url = base + "/inference"; + else { + if (base.size() < 3 || base.substr(base.size() - 3) != "/v1") base += "/v1"; + plan.url = base + "/audio/transcriptions"; + plan.fields.emplace_back("model", config.model); + } + plan.fields.emplace_back("response_format", "json"); + if (!config.language.empty() && (config.language != "auto" || config.protocol == "whisper_cpp")) + plan.fields.emplace_back("language", config.language); + return plan; +} + +Result parse_response(int status, const std::string& body) { + if (status == 401 || status == 403) return failure("SPEECH_AUTH_FAILED"); + if (status == 429) return failure("SPEECH_BUSY", 429); + if (status == 503) return failure("SPEECH_NOT_READY", 503); + if (status == 504 || status == 408) return failure("SPEECH_TIMEOUT", 504); + if (status == 413 || status == 415 || status == 422 || status == 400) + return failure("SPEECH_INVALID_AUDIO", 422); + if (status < 200 || status >= 300) return failure("SPEECH_UPSTREAM_FAILED"); + const auto parsed = nlohmann::json::parse(body, nullptr, false); + if (!parsed.is_object() || parsed.contains("error") || !parsed.contains("text") || !parsed["text"].is_string()) + return failure("SPEECH_INVALID_RESPONSE"); + if (parsed.contains("status") && parsed["status"] != "ok" && parsed["status"] != "no_speech") + return failure("SPEECH_INVALID_RESPONSE"); + return {true, parsed.value("status", std::string{}) == "no_speech" ? std::string{} : parsed["text"].get(), {}, 200}; +} + +std::string silent_wav() { + std::string audio(44 + 32000, '\0'); + audio.replace(0, 4, "RIFF"); write_u32(audio, 4, static_cast(audio.size() - 8)); + audio.replace(8, 8, "WAVEfmt "); write_u32(audio, 16, 16); + audio[20] = 1; audio[22] = 1; + write_u32(audio, 24, 16000); write_u32(audio, 28, 32000); + audio[32] = 2; audio[34] = 16; + audio.replace(36, 4, "data"); write_u32(audio, 40, 32000); + return audio; +} + +bool valid_wav(const std::string& audio, int max_seconds) { + if (max_seconds <= 0 || audio.size() <= 44 || audio.size() > kMaxAudioBytes || audio.size() % 2 || + audio.compare(0, 4, "RIFF") || audio.compare(8, 8, "WAVEfmt ") || audio.compare(36, 4, "data")) return false; + return read_u32(audio, 4) == audio.size() - 8 && read_u32(audio, 16) == 16 && + read_u32(audio, 20) == 0x00010001 && read_u32(audio, 24) == 16000 && + read_u32(audio, 28) == 32000 && read_u32(audio, 32) == 0x00100002 && + read_u32(audio, 40) == audio.size() - 44 && + audio.size() - 44 <= static_cast(max_seconds) * 32000; +} + +Result transcribe(SpeechRecognitionConfig config, std::string audio, + std::shared_ptr> cancelled, const std::atomic* shutdown) { + if (!speech_recognition_configured(config)) return failure("SPEECH_NOT_CONFIGURED", 400); + if (!valid_wav(audio, config.max_recording_seconds)) return failure("SPEECH_INVALID_AUDIO", 422); + if (cancelled->load()) return failure("SPEECH_CANCELLED", 409); + auto proxy = config.use_proxy ? network::proxy_options_for(config.base_url) : network::ProxyOptions{}; + if (!config.use_proxy) proxy.proxies = cpr::Proxies{{"http", ""}, {"https", ""}}; + const auto result = run_abandonable( + [config = std::move(config), audio = std::move(audio), cancelled, proxy = std::move(proxy)] { + return send(config, audio, cancelled, proxy); + }, shutdown ? shutdown : cancelled.get()); + if (result) return *result; + cancelled->store(true); + return failure("SPEECH_CANCELLED", 409); +} + +} // namespace acecode::speech diff --git a/src/adapters/speech/speech_client.hpp b/src/adapters/speech/speech_client.hpp new file mode 100644 index 000000000..475105153 --- /dev/null +++ b/src/adapters/speech/speech_client.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "config/config.hpp" +#include +#include +#include +#include +#include + +namespace acecode::speech { + +constexpr std::size_t kMaxAudioBytes = 20 * 1024 * 1024; +struct RequestPlan { + std::string url; + std::string file_field; + std::vector> fields; +}; +struct Result { + bool ok = false; + std::string text; + std::string error; + int status = 502; +}; + +RequestPlan request_plan(const SpeechRecognitionConfig& config); +Result parse_response(int status, const std::string& body); +std::string silent_wav(); +bool valid_wav(const std::string& audio, int max_seconds); +// Cancellation is shared by the request registry and the owned HTTP operation. +Result transcribe(SpeechRecognitionConfig config, std::string audio, + std::shared_ptr> cancelled, + const std::atomic* shutdown = nullptr); + +} // namespace acecode::speech diff --git a/src/apps/desktop/linux_desktop.cpp b/src/apps/desktop/linux_desktop.cpp index c658db3f9..5ae05dd0d 100644 --- a/src/apps/desktop/linux_desktop.cpp +++ b/src/apps/desktop/linux_desktop.cpp @@ -3,9 +3,33 @@ #include "platform/native_ui/strings.hpp" #include +#include namespace acecode::desktop { +void configure_linux_native_services(void* webview_widget) { + set_linux_folder_picker(pick_linux_folder); + if (!webview_widget) return; + // The widget owns this noncapturing callback. Only the local main UI can + // request audio; camera capture and external pages remain denied. + g_signal_connect(webview_widget, "permission-request", G_CALLBACK( + +[](WebKitWebView* view, WebKitPermissionRequest* request, gpointer) -> gboolean { + if (!WEBKIT_IS_USER_MEDIA_PERMISSION_REQUEST(request)) return FALSE; + auto* media = WEBKIT_USER_MEDIA_PERMISSION_REQUEST(request); + const char* uri = webkit_web_view_get_uri(view); + const std::string url = uri ? uri : ""; + const bool local = url.rfind("http://127.0.0.1:", 0) == 0 || + url.rfind("http://localhost:", 0) == 0 || url.rfind("http://[::1]:", 0) == 0; + if (local && webkit_user_media_permission_request_is_for_audio_device(media) && + !webkit_user_media_permission_request_is_for_video_device(media)) { + webkit_permission_request_allow(request); + } else { + webkit_permission_request_deny(request); + } + return TRUE; + }), nullptr); +} + WindowRect linux_active_work_area() { GdkDisplay* display = gdk_display_get_default(); if (!display) return {}; diff --git a/src/apps/desktop/linux_desktop.hpp b/src/apps/desktop/linux_desktop.hpp index 091e30841..6938455ae 100644 --- a/src/apps/desktop/linux_desktop.hpp +++ b/src/apps/desktop/linux_desktop.hpp @@ -9,5 +9,6 @@ namespace acecode::desktop { WindowRect linux_active_work_area(); void center_linux_window(void* window, WindowRect work_area); FolderPickOutcome pick_linux_folder(void* parent); +void configure_linux_native_services(void* webview_widget); } // namespace acecode::desktop diff --git a/src/apps/desktop/web_host.cpp b/src/apps/desktop/web_host.cpp index 445f7fd35..5df1abd10 100644 --- a/src/apps/desktop/web_host.cpp +++ b/src/apps/desktop/web_host.cpp @@ -2272,7 +2272,7 @@ struct WebHost::Impl { #else w = std::make_unique(debug, nullptr); auto native_window = w->window(); - set_linux_folder_picker(pick_linux_folder); + configure_linux_native_services(w->widget().ok() ? w->widget().value() : nullptr); linux_center_on_first_show = true; if (native_window.ok() && native_window.value() && startup_mode == StartupWindowMode::OffscreenUntilReady) { diff --git a/src/apps/web/routes/routes_speech_recognition.cpp b/src/apps/web/routes/routes_speech_recognition.cpp new file mode 100644 index 000000000..b37c01e48 --- /dev/null +++ b/src/apps/web/routes/routes_speech_recognition.cpp @@ -0,0 +1,151 @@ +#include "web/server_impl.hpp" +#include "config/config_mutation.hpp" +#include "speech/speech_client.hpp" + +#include + +namespace acecode::web { +namespace { +using nlohmann::json; + +json availability(const SpeechRecognitionConfig& config, std::uint64_t generation) { + return {{"enabled", config.enabled}, {"configured", speech_recognition_configured(config)}, + {"max_recording_seconds", config.max_recording_seconds}, + {"timeout_ms", config.timeout_ms}, {"generation", generation}}; +} + +bool valid_request_id(const std::string& id) { + return !id.empty() && id.size() <= 64 && std::all_of(id.begin(), id.end(), [](unsigned char ch) { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '_' || ch == '-'; + }); +} +} // namespace + +void WebServer::Impl::register_speech_recognition() { + const auto respond = [this](const crow::request& req, int status, const json& body) { + crow::response response(status); + response.add_header("Content-Type", "application/json"); + response.add_header("Cache-Control", "no-store"); + response.body = body.dump(); + return with_cors(req, std::move(response)); + }; + CROW_ROUTE(app, "/api/config/speech-recognition").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { return cors_preflight(req); }); + CROW_ROUTE(app, "/api/config/speech-recognition/test").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { return cors_preflight(req); }); + CROW_ROUTE(app, "/api/speech-recognition").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { return cors_preflight(req); }); + CROW_ROUTE(app, "/api/speech-recognition/transcriptions/").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req, const std::string&) { return cors_preflight(req); }); + + CROW_ROUTE(app, "/api/speech-recognition").methods(crow::HTTPMethod::GET) + ([this, respond](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.app_config) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + std::shared_lock lock(app_config_mu); + return respond(req, 200, availability(deps.app_config->speech_recognition, speech_config_generation)); + }); + CROW_ROUTE(app, "/api/config/speech-recognition").methods(crow::HTTPMethod::GET) + ([this, respond](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.app_config) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + std::shared_lock lock(app_config_mu); + auto body = speech_recognition_config_json(deps.app_config->speech_recognition); + body.update(availability(deps.app_config->speech_recognition, speech_config_generation)); + return respond(req, 200, body); + }); + CROW_ROUTE(app, "/api/config/speech-recognition").methods(crow::HTTPMethod::PUT) + ([this, respond](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.app_config) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + const auto patch = json::parse(req.body, nullptr, false); + if (!patch.is_object()) return respond(req, 400, {{"error", "BAD_JSON"}}); + std::lock_guard lock(app_config_mu); + const auto result = mutate_config([&](AppConfig& candidate, std::string& error) { + return apply_speech_recognition_config(candidate.speech_recognition, patch, error); + }, deps.config_path, deps.app_config); + if (!result.ok) return respond(req, result.error_kind == ConfigMutationErrorKind::Validation ? 400 : 500, + {{"error", result.error_kind == ConfigMutationErrorKind::Validation ? "SPEECH_INVALID_CONFIG" : "PERSIST_FAILED"}}); + deps.app_config->speech_recognition = result.config.speech_recognition; + ++speech_config_generation; + { + std::lock_guard requests_lock(speech_requests_mu); + for (const auto& [id, cancelled] : speech_requests) cancelled->store(true); + } + auto body = speech_recognition_config_json(deps.app_config->speech_recognition); + body.update(availability(deps.app_config->speech_recognition, speech_config_generation)); + return respond(req, 200, body); + }); + CROW_ROUTE(app, "/api/config/speech-recognition/test").methods(crow::HTTPMethod::POST) + ([this, respond](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.app_config) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + SpeechRecognitionConfig config; + { + std::shared_lock lock(app_config_mu); + config = deps.app_config->speech_recognition; + } + std::string error; + if (!apply_speech_recognition_config(config, json::parse(req.body, nullptr, false), error)) + return respond(req, 400, {{"error", "SPEECH_INVALID_CONFIG"}}); + config.timeout_ms = std::min(config.timeout_ms, 30000); + // Shared only with the owned HTTP operation during this explicit test. + auto cancelled = std::make_shared>(false); + try { + const auto result = speech::transcribe(config, speech::silent_wav(), cancelled, &shutdown_requested); + return result.ok ? respond(req, 200, {{"ok", true}}) + : respond(req, result.status, {{"error", result.error}}); + } catch (...) { return respond(req, 502, {{"error", "SPEECH_CONNECTION_FAILED"}}); } + }); + CROW_ROUTE(app, "/api/speech-recognition/transcriptions/").methods(crow::HTTPMethod::Delete) + ([this, respond](const crow::request& req, const std::string& id) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!valid_request_id(id)) return respond(req, 400, {{"error", "BAD_REQUEST"}}); + std::lock_guard lock(speech_requests_mu); + const auto found = speech_requests.find(id); + if (found != speech_requests.end()) found->second->store(true); + return respond(req, 200, {{"cancelled", true}}); + }); + CROW_ROUTE(app, "/api/speech-recognition/transcriptions/").methods(crow::HTTPMethod::POST) + ([this, respond](const crow::request& req, const std::string& id) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!valid_request_id(id)) return respond(req, 400, {{"error", "BAD_REQUEST"}}); + if (!deps.app_config) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + SpeechRecognitionConfig config; + std::uint64_t generation; + { + std::shared_lock lock(app_config_mu); + config = deps.app_config->speech_recognition; + generation = speech_config_generation; + } + if (!config.enabled || !speech_recognition_configured(config)) + return respond(req, 409, {{"error", "SPEECH_NOT_CONFIGURED"}}); + if (req.body.size() > speech::kMaxAudioBytes) return respond(req, 413, {{"error", "SPEECH_INVALID_AUDIO"}}); + if (!speech::valid_wav(req.body, config.max_recording_seconds)) + return respond(req, 422, {{"error", "SPEECH_INVALID_AUDIO"}}); + // Shared by this handler, cancellation endpoint and owned HTTP operation. + auto cancelled = std::make_shared>(false); + { + std::shared_lock config_lock(app_config_mu); + if (generation != speech_config_generation) + return respond(req, 409, {{"error", "SPEECH_CANCELLED"}}); + std::lock_guard lock(speech_requests_mu); + if (shutdown_requested.load()) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + if (!speech_requests.empty()) return respond(req, 429, {{"error", "SPEECH_BUSY"}}); + speech_requests.emplace(id, cancelled); + } + speech::Result result; + try { result = speech::transcribe(config, req.body, cancelled, &shutdown_requested); } + catch (...) { result.error = "SPEECH_CONNECTION_FAILED"; } + { + std::lock_guard lock(speech_requests_mu); + speech_requests.erase(id); + } + if (cancelled->load()) return respond(req, 409, {{"error", "SPEECH_CANCELLED"}}); + return result.ok ? respond(req, 200, {{"status", result.text.empty() ? "no_speech" : "ok"}, {"text", result.text}}) + : respond(req, result.status, {{"error", result.error}}); + }); +} + +} // namespace acecode::web diff --git a/src/apps/web/server.cpp b/src/apps/web/server.cpp index 91caabdfa..9c5eebea2 100644 --- a/src/apps/web/server.cpp +++ b/src/apps/web/server.cpp @@ -141,6 +141,7 @@ void WebServer::Impl::register_routes() { register_task_suggestions(); register_models(); register_image_generation(); + register_speech_recognition(); register_computer_use(); register_ios_simulator(); register_summary_generation(); diff --git a/src/apps/web/server_impl.hpp b/src/apps/web/server_impl.hpp index fe832668f..1a2ec75d0 100644 --- a/src/apps/web/server_impl.hpp +++ b/src/apps/web/server_impl.hpp @@ -319,6 +319,10 @@ struct WebServer::Impl { void refresh_computer_use_tool_locked(); void refresh_ios_simulator_tool_locked(); std::mutex image_generation_test_mu; + std::mutex speech_requests_mu; + // Cancellation tokens are shared with owned HTTP work, never with the WebServer. + std::unordered_map>> speech_requests; + std::uint64_t speech_config_generation = 0; // guarded by app_config_mu // 串行化 tool-rewrites.json 的读改写(它不在 config.json 里,不受 app_config_mu 管)。 std::mutex tool_rewrites_mu; // 串行化 /rules/*.rules 托管文件的读改写(安全中心 > 命令安全)。 @@ -622,6 +626,7 @@ struct WebServer::Impl { // ----------------------------------------------------------------- void register_routes(); void register_image_generation(); + void register_speech_recognition(); void register_computer_use(); void register_ios_simulator(); void register_summary_generation(); diff --git a/src/base/config/config.cpp b/src/base/config/config.cpp index 649ade81c..4f28ae377 100644 --- a/src/base/config/config.cpp +++ b/src/base/config/config.cpp @@ -6,6 +6,7 @@ #include "mcp_config.hpp" #include "model_provider_registry.hpp" #include "request_headers.hpp" +#include "image_generation_config.hpp" #include "config/vocab/theme_id.hpp" #include "utils/constants.hpp" #include "utils/atomic_file.hpp" @@ -1205,6 +1206,11 @@ static AppConfig load_config_from_path_once( } } + if (j.contains("speech_recognition")) { + std::string error; + if (!apply_speech_recognition_config(cfg.speech_recognition, j["speech_recognition"], error)) + throw std::runtime_error("speech_recognition: " + error); + } // 图像生成段(openspec add-image-generation-tool)。缺省 → 未配置, // 工具不注册。三档模型名允许部分覆盖,未给的沿用默认。 if (j.contains("image_generation") && j["image_generation"].is_object()) { @@ -2552,38 +2558,15 @@ nlohmann::json build_config_json(const AppConfig& cfg) { wsj["timeout_ms"] = cfg.web_search.timeout_ms; if (!wsj.empty()) j["web_search"] = wsj; - ImageGenerationConfig ig_d; - nlohmann::json igj = nlohmann::json::object(); - if (!cfg.image_generation.base_url.empty() && - !utils::is_valid_http_base_url(cfg.image_generation.base_url)) { - throw std::runtime_error( - "image_generation.base_url must be HTTPS (or loopback HTTP) without " - "credentials, query, or fragment"); - } - if (cfg.image_generation.enabled != ig_d.enabled) - igj["enabled"] = cfg.image_generation.enabled; - if (cfg.image_generation.source != ig_d.source) - igj["source"] = cfg.image_generation.source; - if (cfg.image_generation.saved_model_name != ig_d.saved_model_name) - igj["saved_model_name"] = cfg.image_generation.saved_model_name; - if (cfg.image_generation.base_url != ig_d.base_url) - igj["base_url"] = cfg.image_generation.base_url; - if (cfg.image_generation.api_key != ig_d.api_key) - igj["api_key"] = cfg.image_generation.api_key; - { - nlohmann::json mj = nlohmann::json::object(); - if (cfg.image_generation.model_standard != ig_d.model_standard) - mj["standard"] = cfg.image_generation.model_standard; - if (cfg.image_generation.model_high != ig_d.model_high) - mj["high"] = cfg.image_generation.model_high; - if (cfg.image_generation.model_ultra != ig_d.model_ultra) - mj["ultra"] = cfg.image_generation.model_ultra; - if (!mj.empty()) igj["models"] = mj; - } - if (cfg.image_generation.default_quality != ig_d.default_quality) - igj["default_quality"] = cfg.image_generation.default_quality; - if (cfg.image_generation.timeout_ms != ig_d.timeout_ms) - igj["timeout_ms"] = cfg.image_generation.timeout_ms; + auto speech = cfg.speech_recognition; + std::string speech_error; + if (!apply_speech_recognition_config(speech, nlohmann::json::object(), speech_error)) + throw std::runtime_error("refusing to save speech_recognition: " + speech_error); + const auto speech_json = speech_recognition_config_json(speech); + if (speech_json != speech_recognition_config_json(SpeechRecognitionConfig{})) + j["speech_recognition"] = speech_json; + + const auto igj = image_generation_config_to_json(cfg.image_generation); if (!igj.empty()) j["image_generation"] = igj; nlohmann::json lspj = nlohmann::json::object(); diff --git a/src/base/config/config.hpp b/src/base/config/config.hpp index bc4889bad..29fc269ab 100644 --- a/src/base/config/config.hpp +++ b/src/base/config/config.hpp @@ -399,6 +399,24 @@ struct ImageGenerationConfig { int timeout_ms = 180000; }; +// External speech service only; ACECode does not own models or Python workers. +struct SpeechRecognitionConfig { + bool enabled = false; + std::string protocol = "sensevoice"; // sensevoice | openai | whisper_cpp + std::string base_url; + std::string model; + std::string api_key; + std::string language = "auto"; + int timeout_ms = 120000; + int max_recording_seconds = 60; + bool use_proxy = false; // Internal services connect directly by default. +}; + +bool apply_speech_recognition_config(SpeechRecognitionConfig& config, + const nlohmann::json& patch, std::string& error); +nlohmann::json speech_recognition_config_json(const SpeechRecognitionConfig& config); +bool speech_recognition_configured(const SpeechRecognitionConfig& config); + // 单个 LSP server 的 config 条目(openspec add-lsp-service)。 // 名字命中内置 server(clangd / typescript-language-server / pyright / // gopls / rust-analyzer)时按字段覆盖内置定义;新名字 = 纯自定义 server, @@ -627,6 +645,7 @@ struct AppConfig { LspConfig lsp; // LSP 集成(参见 add-lsp-service) WorktreeConfig worktree; // worktree 隔离(enter_worktree / --worktree) ImageGenerationConfig image_generation; // 图像生成工具(参见 add-image-generation-tool) + SpeechRecognitionConfig speech_recognition; // External dictation service ComputerUseConfig computer_use; // Native desktop control, explicitly enabled IosSimulatorConfig ios_simulator; // Xcode 模拟器控制与实时面板(macOS) GitContextConfig git_context; // git 感知(参见 add-git-context) diff --git a/src/base/config/image_generation_config.cpp b/src/base/config/image_generation_config.cpp new file mode 100644 index 000000000..c7a8574fc --- /dev/null +++ b/src/base/config/image_generation_config.cpp @@ -0,0 +1,28 @@ +#include "image_generation_config.hpp" +#include "utils/http_url_validation.hpp" +#include + +namespace acecode { +nlohmann::json image_generation_config_to_json(const ImageGenerationConfig& config) { + ImageGenerationConfig defaults; + nlohmann::json result = nlohmann::json::object(); + if (!config.base_url.empty() && !utils::is_valid_http_base_url(config.base_url)) { + throw std::runtime_error( + "image_generation.base_url must be HTTPS (or loopback HTTP) without " + "credentials, query, or fragment"); + } + if (config.enabled != defaults.enabled) result["enabled"] = config.enabled; + if (config.source != defaults.source) result["source"] = config.source; + if (config.saved_model_name != defaults.saved_model_name) result["saved_model_name"] = config.saved_model_name; + if (config.base_url != defaults.base_url) result["base_url"] = config.base_url; + if (config.api_key != defaults.api_key) result["api_key"] = config.api_key; + nlohmann::json models = nlohmann::json::object(); + if (config.model_standard != defaults.model_standard) models["standard"] = config.model_standard; + if (config.model_high != defaults.model_high) models["high"] = config.model_high; + if (config.model_ultra != defaults.model_ultra) models["ultra"] = config.model_ultra; + if (!models.empty()) result["models"] = std::move(models); + if (config.default_quality != defaults.default_quality) result["default_quality"] = config.default_quality; + if (config.timeout_ms != defaults.timeout_ms) result["timeout_ms"] = config.timeout_ms; + return result; +} +} // namespace acecode diff --git a/src/base/config/image_generation_config.hpp b/src/base/config/image_generation_config.hpp new file mode 100644 index 000000000..ced911739 --- /dev/null +++ b/src/base/config/image_generation_config.hpp @@ -0,0 +1,7 @@ +#pragma once +#include "config.hpp" + +namespace acecode { +// Sparse persisted representation, shared with the main config writer. +nlohmann::json image_generation_config_to_json(const ImageGenerationConfig& config); +} // namespace acecode diff --git a/src/base/config/speech_recognition_config.cpp b/src/base/config/speech_recognition_config.cpp new file mode 100644 index 000000000..59e741ab7 --- /dev/null +++ b/src/base/config/speech_recognition_config.cpp @@ -0,0 +1,99 @@ +#include "config.hpp" +#include "utils/http_url_validation.hpp" + +#include +#include + +namespace acecode { +namespace { + +bool valid_url(const std::string& url) { + if (!utils::is_valid_http_base_url(url, true, true) || url.find('\\') != std::string::npos) return false; + const auto begin = url.find("://") + 3; + const auto end = url.find('/', begin); + const auto authority = url.substr(begin, end == std::string::npos ? end : end - begin); + std::size_t port_at = authority.find(':'); + if (authority.front() == '[') { + const auto close = authority.find(']'); + if (close == std::string::npos || close == 1) return false; + if (close + 1 == authority.size()) return true; + if (authority[close + 1] != ':') return false; + port_at = close + 1; + } + if (port_at == std::string::npos) return true; + if (port_at == 0) return false; + const auto port = authority.substr(port_at + 1); + if (port.empty() || port.size() > 5 || !std::all_of(port.begin(), port.end(), + [](unsigned char ch) { return ch >= '0' && ch <= '9'; })) return false; + const int number = std::stoi(port); + return number > 0 && number <= 65535; +} + +bool clean_text(const std::string& value) { + return std::none_of(value.begin(), value.end(), + [](unsigned char ch) { return ch < 0x20 || ch == 0x7f; }); +} + +} // namespace + +bool apply_speech_recognition_config(SpeechRecognitionConfig& config, + const nlohmann::json& patch, std::string& error) { + error.clear(); + if (!patch.is_object()) { error = "speech_recognition must be an object"; return false; } + auto next = config; + const auto boolean = [&](const char* key, bool& target) { + if (!patch.contains(key)) return true; + if (!patch[key].is_boolean()) { error = std::string(key) + " must be boolean"; return false; } + target = patch[key].get(); + return true; + }; + const auto text = [&](const char* key, std::string& target, std::size_t limit) { + if (!patch.contains(key)) return true; + if (!patch[key].is_string()) { error = std::string(key) + " must be text"; return false; } + const auto value = patch[key].get(); + if (value.size() > limit || !clean_text(value)) { error = std::string(key) + " is invalid"; return false; } + target = value; + return true; + }; + const auto number = [&](const char* key, int& target, int low, int high) { + if (!patch.contains(key)) return true; + if (!patch[key].is_number_integer() || patch[key].get() < low || patch[key].get() > high) { + error = std::string(key) + " is out of range"; return false; + } + target = patch[key].get(); + return true; + }; + if (!boolean("enabled", next.enabled) || !boolean("use_proxy", next.use_proxy) || + !text("protocol", next.protocol, 32) || !text("base_url", next.base_url, 2048) || + !text("model", next.model, 256) || !text("api_key", next.api_key, 16384) || + !text("language", next.language, 32) || + !number("timeout_ms", next.timeout_ms, 5000, 600000) || + !number("max_recording_seconds", next.max_recording_seconds, 1, 300)) return false; + if (next.protocol != "sensevoice" && next.protocol != "openai" && next.protocol != "whisper_cpp") { + error = "unsupported speech protocol"; return false; + } + while (!next.base_url.empty() && next.base_url.back() == '/') next.base_url.pop_back(); + if (!next.base_url.empty() && !valid_url(next.base_url)) { + error = "base_url must be an HTTP/HTTPS service URL with a valid port, without credentials, query or fragment"; + return false; + } + if (next.api_key.find(' ') != std::string::npos) { error = "api_key contains spaces"; return false; } + config = std::move(next); + return true; +} + +nlohmann::json speech_recognition_config_json(const SpeechRecognitionConfig& config) { + return {{"enabled", config.enabled}, {"protocol", config.protocol}, + {"base_url", config.base_url}, {"model", config.model}, {"api_key", config.api_key}, + {"language", config.language}, {"timeout_ms", config.timeout_ms}, + {"max_recording_seconds", config.max_recording_seconds}, {"use_proxy", config.use_proxy}}; +} + +bool speech_recognition_configured(const SpeechRecognitionConfig& config) { + auto copy = config; + std::string error; + return apply_speech_recognition_config(copy, nlohmann::json::object(), error) && + !copy.base_url.empty() && (copy.protocol != "openai" || !copy.model.empty()); +} + +} // namespace acecode diff --git a/src/base/utils/http_url_validation.hpp b/src/base/utils/http_url_validation.hpp index 7f7159122..9e4c3a95c 100644 --- a/src/base/utils/http_url_validation.hpp +++ b/src/base/utils/http_url_validation.hpp @@ -8,7 +8,8 @@ namespace acecode::utils { inline bool is_valid_http_base_url(std::string_view url, - bool allow_loopback_http = true) { + bool allow_loopback_http = true, + bool allow_remote_http = false) { if (url.empty() || url.size() > 2048) return false; for (unsigned char c : url) { if (c <= 0x20 || c == 0x7f) return false; @@ -46,7 +47,7 @@ inline bool is_valid_http_base_url(std::string_view url, authority.rfind("localhost:", 0) == 0 || authority == "127.0.0.1" || authority.rfind("127.0.0.1:", 0) == 0 || authority == "[::1]" || authority.rfind("[::1]:", 0) == 0; - if (!loopback) return false; + if (!loopback && !allow_remote_http) return false; } return true; } diff --git a/src/layers.tsv b/src/layers.tsv index 0fd468a9d..ac7f59ed9 100644 --- a/src/layers.tsv +++ b/src/layers.tsv @@ -34,6 +34,7 @@ module src/adapters/pa/ pa adapters 29 D1 D2 D15 module src/adapters/provider/ provider adapters 30 D1 D2 D15 module src/adapters/tool/ tool adapters 31 D1 D2 D15 module src/adapters/im/ im adapters 32 add-desktop-im-channels: QQ / Telegram transports +module src/adapters/speech/ speech adapters 33 External speech recognition services module src/engine/tool_preamble/ tool_preamble engine 40 D1 D2 D15 module src/engine/prompt/ prompt engine 41 D1 D2 D15 module src/engine/agent/ agent engine 42 D1 D2 D15 diff --git a/tests/config/speech_recognition_config_test.cpp b/tests/config/speech_recognition_config_test.cpp new file mode 100644 index 000000000..2920c0679 --- /dev/null +++ b/tests/config/speech_recognition_config_test.cpp @@ -0,0 +1,72 @@ +#include "config/config.hpp" +#include "utils/http_url_validation.hpp" +#include +#include +#include + +using namespace acecode; +using nlohmann::json; + +TEST(SpeechRecognitionConfig, DefaultsAndHttpPorts) { + SpeechRecognitionConfig config; + EXPECT_FALSE(config.enabled); + EXPECT_FALSE(speech_recognition_configured(config)); + EXPECT_FALSE(config.use_proxy); + std::string error; + ASSERT_TRUE(apply_speech_recognition_config(config, + {{"enabled", true}, {"base_url", "http://192.168.1.50:9876/"}}, error)); + EXPECT_EQ(config.base_url, "http://192.168.1.50:9876"); + EXPECT_TRUE(speech_recognition_configured(config)); + EXPECT_FALSE(utils::is_valid_http_base_url(config.base_url)); // Other consumers retain their HTTPS policy. + ASSERT_TRUE(apply_speech_recognition_config(config, {{"protocol", "openai"}}, error)); + EXPECT_FALSE(speech_recognition_configured(config)); + ASSERT_TRUE(apply_speech_recognition_config(config, {{"model", "local-whisper"}}, error)); + EXPECT_TRUE(speech_recognition_configured(config)); +} + +TEST(SpeechRecognitionConfig, InvalidPatchIsAtomicAndSecretsAreNotEchoed) { + for (const auto& patch : std::vector{ + json::array(), {{"enabled", "yes"}}, {{"protocol", "unknown"}}, + {{"base_url", "http://server:0"}}, {{"base_url", "http://server:65536"}}, + {{"base_url", "http://server:abc"}}, {{"base_url", "file:///tmp"}}, + {{"base_url", "http://secret@server"}}, {{"base_url", "http://server?key=secret"}}, + {{"api_key", "secret\r\nheader"}}, {{"timeout_ms", 1.5}}, + {{"timeout_ms", 9000000000000000000ULL}}, {{"max_recording_seconds", 0}}, + {{"use_proxy", 1}}, + }) { + SpeechRecognitionConfig config; + config.api_key = "saved-secret"; + const auto before = speech_recognition_config_json(config); + std::string error; + EXPECT_FALSE(apply_speech_recognition_config(config, patch, error)); + EXPECT_EQ(speech_recognition_config_json(config), before); + EXPECT_EQ(error.find("secret"), std::string::npos); + } +} + +TEST(SpeechRecognitionConfig, OmittedKeyPreservedAndEmptyKeyCleared) { + SpeechRecognitionConfig config; + config.api_key = "fixture-key"; + std::string error; + ASSERT_TRUE(apply_speech_recognition_config(config, {{"enabled", true}}, error)); + EXPECT_EQ(config.api_key, "fixture-key"); + ASSERT_TRUE(apply_speech_recognition_config(config, {{"api_key", ""}}, error)); + EXPECT_TRUE(config.api_key.empty()); +} + +TEST(SpeechRecognitionConfig, RealConfigFileRoundTrip) { + const auto root = std::filesystem::temp_directory_path() / + ("acecode-speech-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + struct Cleanup { + std::filesystem::path root; + ~Cleanup() { std::error_code ec; std::filesystem::remove_all(root, ec); } + } cleanup{root}; + const auto path = (root / "config.json").string(); + AppConfig config; + config.speech_recognition = {true, "openai", "http://speech.internal:9988/v1", "whisper-local", + "fixture-key", "zh", 45000, 90, false}; + save_config(config, path); + const auto loaded = load_config_from_path(path); + EXPECT_EQ(speech_recognition_config_json(config.speech_recognition), + speech_recognition_config_json(loaded.speech_recognition)); +} diff --git a/tests/speech/speech_client_test.cpp b/tests/speech/speech_client_test.cpp new file mode 100644 index 000000000..45f718a43 --- /dev/null +++ b/tests/speech/speech_client_test.cpp @@ -0,0 +1,140 @@ +#include "speech/speech_client.hpp" +#include "test_support/network/crow_test_server.hpp" +#include "utils/abandonable_call.hpp" +#include +#include +#include +#include +#include +#include +#include + +using namespace acecode; +using namespace acecode::speech; + +TEST(SpeechClient, ProtocolPathsAndFields) { + SpeechRecognitionConfig config; + config.base_url = "http://speech.internal:9876/"; + EXPECT_EQ(request_plan(config).url, "http://speech.internal:9876/api/transcriptions"); + EXPECT_EQ(request_plan(config).file_field, "audio"); + config.protocol = "openai"; + config.model = "whisper-local"; + EXPECT_EQ(request_plan(config).url, "http://speech.internal:9876/v1/audio/transcriptions"); + config.base_url += "v1"; + EXPECT_EQ(request_plan(config).url, "http://speech.internal:9876/v1/audio/transcriptions"); + config.protocol = "whisper_cpp"; + config.base_url = "http://speech.internal:9888"; + EXPECT_EQ(request_plan(config).url, "http://speech.internal:9888/inference"); + EXPECT_EQ(request_plan(config).file_field, "file"); +} + +TEST(SpeechClient, ExternalSenseVoiceModel) { + const char* url = std::getenv("ACECODE_TEST_SPEECH_URL"); + const char* wav = std::getenv("ACECODE_TEST_SPEECH_WAV"); + if (!url || !wav) GTEST_SKIP() << "Set ACECODE_TEST_SPEECH_URL and ACECODE_TEST_SPEECH_WAV for model integration"; + std::ifstream input(wav, std::ios::binary); + ASSERT_TRUE(input.good()); + std::string audio{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + SpeechRecognitionConfig config; + config.base_url = url; + auto cancelled = std::make_shared>(false); // test and request share cancellation + const auto result = transcribe(config, std::move(audio), cancelled); + ASSERT_TRUE(result.ok) << result.error; + EXPECT_FALSE(result.text.empty()); + RecordProperty("transcript", result.text); + if (const char* expected = std::getenv("ACECODE_TEST_SPEECH_EXPECT")) + EXPECT_NE(result.text.find(expected), std::string::npos) << result.text; +} + +TEST(SpeechClient, BoundedWavAndStableErrors) { + const auto audio = silent_wav(); + EXPECT_TRUE(valid_wav(audio, 1)); + EXPECT_FALSE(valid_wav("broken", 60)); + auto broken = audio; + broken[24] = 0; + EXPECT_FALSE(valid_wav(broken, 60)); + EXPECT_FALSE(valid_wav(audio + "extra", 60)); + EXPECT_EQ(parse_response(429, "secret upstream response").error, "SPEECH_BUSY"); + EXPECT_EQ(parse_response(401, "secret upstream response").error, "SPEECH_AUTH_FAILED"); + EXPECT_EQ(parse_response(503, "{}").error, "SPEECH_NOT_READY"); + EXPECT_FALSE(parse_response(200, R"({"error":"secret","text":"ignored"})").ok); + EXPECT_FALSE(parse_response(200, R"({"text":null})").ok); + EXPECT_TRUE(parse_response(200, R"({"status":"no_speech","text":""})").ok); + EXPECT_EQ(parse_response(200, R"({"text":"hello"})").text, "hello"); +} + +TEST(SpeechClient, MultipartUsesMemoryAndSelectedProtocol) { + crow::SimpleApp app; + std::vector> received; + std::mutex mu; + const auto receive = [&](const crow::request& req) { + std::lock_guard lock(mu); + received.emplace_back(req.url, req.body); + EXPECT_EQ(req.get_header_value("Authorization"), "Bearer fixture-key"); + EXPECT_NE(req.get_header_value("Content-Type").find("multipart/form-data"), std::string::npos); + return crow::response(200, R"({"text":"hello"})"); + }; + CROW_ROUTE(app, "/api/transcriptions").methods(crow::HTTPMethod::POST)(receive); + CROW_ROUTE(app, "/v1/audio/transcriptions").methods(crow::HTTPMethod::POST)(receive); + CROW_ROUTE(app, "/inference").methods(crow::HTTPMethod::POST)(receive); + test::CrowTestServer server(app); + for (const auto& protocol : {"sensevoice", "openai", "whisper_cpp"}) { + SpeechRecognitionConfig config; + config.protocol = protocol; + config.base_url = server.http_base(); + config.model = "whisper-local"; + config.api_key = "fixture-key"; + auto cancelled = std::make_shared>(false); // test and request share cancellation + const auto result = transcribe(config, silent_wav(), cancelled); + EXPECT_TRUE(result.ok) << result.error; + EXPECT_EQ(result.text, "hello"); + } + std::lock_guard lock(mu); + ASSERT_EQ(received.size(), 3u); + EXPECT_NE(received[0].second.find("name=\"audio\""), std::string::npos); + EXPECT_NE(received[1].second.find("name=\"file\""), std::string::npos); + EXPECT_NE(received[1].second.find("whisper-local"), std::string::npos); + EXPECT_EQ(received[2].second.find("name=\"model\""), std::string::npos); + for (const auto& item : received) EXPECT_NE(item.second.find(silent_wav()), std::string::npos); +} + +TEST(SpeechClient, CancelledAndShutdownRequestsNeverStart) { + SpeechRecognitionConfig config; + config.base_url = "http://127.0.0.1:1"; + auto cancelled = std::make_shared>(true); // test and request share cancellation + EXPECT_EQ(transcribe(config, silent_wav(), cancelled).error, "SPEECH_CANCELLED"); + cancelled->store(false); + std::atomic shutdown{true}; + EXPECT_EQ(transcribe(config, silent_wav(), cancelled, &shutdown).error, "SPEECH_CANCELLED"); + EXPECT_TRUE(cancelled->load()); +} + +TEST(SpeechClient, InFlightCancellationReturnsBeforeServerFinishes) { + crow::SimpleApp app; + std::promise entered; + auto waiting = entered.get_future(); + std::mutex mu; + std::condition_variable cv; + bool release = false; + CROW_ROUTE(app, "/api/transcriptions").methods(crow::HTTPMethod::POST) + ([&](const crow::request&) { + entered.set_value(); + std::unique_lock lock(mu); + cv.wait_for(lock, std::chrono::seconds(3), [&] { return release; }); + return crow::response(200, R"({"text":"late"})"); + }); + test::CrowTestServer server(app); + SpeechRecognitionConfig config; + config.base_url = server.http_base(); + auto cancelled = std::make_shared>(false); // test and request share cancellation + auto result = std::async(std::launch::async, [config, cancelled] { + return transcribe(config, silent_wav(), cancelled); + }); + EXPECT_EQ(waiting.wait_for(std::chrono::seconds(3)), std::future_status::ready); + cancelled->store(true); + EXPECT_EQ(result.wait_for(std::chrono::milliseconds(500)), std::future_status::ready); + EXPECT_EQ(result.get().error, "SPEECH_CANCELLED"); + { std::lock_guard lock(mu); release = true; } + cv.notify_all(); + EXPECT_TRUE(wait_for_abandoned_work(std::chrono::seconds(3))); +} diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index 20b6a0e00..c05de6482 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -22,6 +22,8 @@ #include "provider/auth/github_auth.hpp" #include "provider/auth/xai_auth.hpp" #include "config/config.hpp" +#include "speech/speech_client.hpp" +#include "test_support/network/crow_test_server.hpp" #include "security/audit_log.hpp" #include "environment/data_dir_migration.hpp" #include "environment/terminal_runtime.hpp" @@ -13739,3 +13741,47 @@ TEST(WebServerHttp, MemoryBuiltinCommandMatchesSharedText) { EXPECT_EQ(seen, acecode::dispatch_memory_command("list", ctx).text); EXPECT_NE(seen.find("[user] prefs"), std::string::npos) << seen; } + +TEST(SpeechRecognitionSmoke, ConfigAvailabilityAndRealHttpForwarding) { + crow::SimpleApp upstream; + std::atomic calls{0}; + CROW_ROUTE(upstream, "/api/transcriptions").methods(crow::HTTPMethod::Post) + ([&calls](const crow::request& req) { + ++calls; + EXPECT_NE(req.body.find("name=\"audio\""), std::string::npos); + EXPECT_NE(req.body.find("RIFF"), std::string::npos); + EXPECT_EQ(req.get_header_value("Authorization"), "Bearer fixture-speech-key"); + return crow::response(200, R"({"status":"ok","text":"fixture speech"})"); + }); + acecode::test::CrowTestServer service(upstream); + WebServerFixture fx; + auto initial = cpr::Get(cpr::Url{fx.url("/api/speech-recognition")}); + ASSERT_EQ(initial.status_code, 200); + EXPECT_FALSE(json::parse(initial.text)["enabled"].get()); + const auto send = [&](const std::string& audio) { + return cpr::Post(cpr::Url{fx.url("/api/speech-recognition/transcriptions/fixture-request")}, + cpr::Header{{"Content-Type", "audio/wav"}}, cpr::Body{audio}); + }; + EXPECT_EQ(send(acecode::speech::silent_wav()).status_code, 409); + const auto saved = cpr::Put(cpr::Url{fx.url("/api/config/speech-recognition")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"enabled", true}, {"protocol", "sensevoice"}, + {"base_url", service.http_base()}, {"api_key", "fixture-speech-key"}}.dump()}); + ASSERT_EQ(saved.status_code, 200) << saved.text; + EXPECT_TRUE(json::parse(saved.text)["configured"].get()); + const auto public_state = cpr::Get(cpr::Url{fx.url("/api/speech-recognition")}); + EXPECT_EQ(public_state.text.find("fixture-speech-key"), std::string::npos); + EXPECT_FALSE(json::parse(public_state.text).contains("api_key")); + EXPECT_EQ(send("invalid audio").status_code, 422); + EXPECT_EQ(calls.load(), 0); + const auto result = send(acecode::speech::silent_wav()); + ASSERT_EQ(result.status_code, 200) << result.text; + EXPECT_EQ(json::parse(result.text)["text"], "fixture speech"); + EXPECT_EQ(calls.load(), 1); + EXPECT_EQ(cpr::Delete(cpr::Url{fx.url("/api/speech-recognition/transcriptions/fixture-request")}).status_code, 200); + const auto disabled = cpr::Put(cpr::Url{fx.url("/api/config/speech-recognition")}, + cpr::Header{{"Content-Type", "application/json"}}, cpr::Body{R"({"enabled":false})"}); + ASSERT_EQ(disabled.status_code, 200); + EXPECT_EQ(json::parse(disabled.text)["api_key"], "fixture-speech-key"); + EXPECT_EQ(send(acecode::speech::silent_wav()).status_code, 409); +} diff --git a/web/public/vs-icons/Microphone.svg b/web/public/vs-icons/Microphone.svg new file mode 100644 index 000000000..5ede3b4fe --- /dev/null +++ b/web/public/vs-icons/Microphone.svg @@ -0,0 +1 @@ + diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 63cf4699b..e5e9d78df 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,69 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + '右键配置麦克风': 'Right-click to configure microphone', + '麦克风配置': 'Microphone settings', + '麦克风': 'Microphone', + '麦克风 {{p0}}': 'Microphone {{p0}}', + '按住录音': 'Hold to record', + '系统默认': 'System default', + '开始/停止录音': 'Start / stop recording', + '语音识别启用后生效,停止后回填文字': 'Available when speech recognition is enabled; stopping inserts the transcript', + '所选麦克风不可用,请右键重新选择。': 'The selected microphone is unavailable. Right-click to choose another.', + '保存语音识别配置失败,请重试。': 'Could not save speech recognition settings. Try again.', + '测试连接': 'Test connection', + '测试中…': 'Testing…', + '服务地址': 'Service URL', + '韩文': 'Korean', + '接口协议': 'API protocol', + '日文': 'Japanese', + '英文': 'English', + '中文': 'Chinese', + '自动': 'Automatic', + 'API Key(可选)': 'API key (optional)', + 'speech voice dictation microphone sensevoice whisper 录音 听写 麦克风 内网 地址 端口': 'speech voice dictation microphone sensevoice whisper recording LAN URL port', + '语音识别': 'Speech recognition', + '启用语音识别': 'Enable speech recognition', + '语音识别连接正常': 'Speech recognition service connected', + '语音接口协议': 'Speech API protocol', + '语音服务地址': 'Speech service URL', + '语音识别模型': 'Speech recognition model', + '语音 API Key': 'Speech API key', + '识别语言': 'Recognition language', + '最长录音(秒)': 'Maximum recording (seconds)', + '最长录音秒数': 'Maximum recording seconds', + '语音请求超时秒数': 'Speech request timeout seconds', + '语音识别使用代理': 'Use proxy for speech recognition', + '使用 ACECode 代理': 'Use ACECode proxy', + '取消听写': 'Cancel dictation', + '正在录音': 'Recording', + '正在请求麦克风权限': 'Requesting microphone access', + '正在识别语音': 'Recognizing speech', + '等待麦克风权限…': 'Waiting for microphone access…', + '正在识别…': 'Recognizing…', + '停止录音并插入文字': 'Stop recording and insert text', + '停止录音': 'Stop recording', + '识别并发送': 'Recognize and send', + '听写': 'Dictate', + '请检查语音识别地址、端口和配置。': 'Check the speech service URL, port, and settings.', + '请先在设置的工具页配置并启用语音识别。': 'Configure and enable speech recognition under Settings → Tools.', + '无法连接语音识别服务,请检查地址、端口和网络。': 'Cannot connect to the speech service. Check its URL, port, and network.', + '语音服务认证失败,请检查 API Key。': 'Speech service authentication failed. Check the API key.', + '语音模型尚未就绪,请稍后重试。': 'The speech model is not ready. Try again later.', + '语音识别正在处理其他录音,请稍后重试。': 'The speech service is processing another recording. Try again later.', + '语音识别服务返回错误,请稍后重试。': 'The speech service returned an error. Try again later.', + '录音格式或时长不符合服务要求,请重新录制。': 'The recording format or duration is unsupported. Record again.', + '语音服务返回的内容无法识别,请检查接口协议。': 'The speech response could not be read. Check the API protocol.', + '语音识别超时,请重试或调整请求超时。': 'Speech recognition timed out. Retry or adjust the request timeout.', + '语音识别超时,请稍后重试。': 'Speech recognition timed out. Try again later.', + '没有识别到语音,请重新录制。': 'No speech was recognized. Record again.', + '当前网页无法使用麦克风,请使用 ACECode Desktop、localhost 或 HTTPS 页面。': 'This page cannot access the microphone. Use ACECode Desktop, localhost, or HTTPS.', + '当前环境不支持麦克风录音,请使用 ACECode Desktop 或支持录音的浏览器。': 'Microphone recording is unavailable. Use ACECode Desktop or a supported browser.', + '麦克风权限被拒绝,请在系统或浏览器中允许访问。': 'Microphone access was denied. Allow access in your system or browser.', + '没有找到可用的麦克风。': 'No microphone was found.', + '无法读取麦克风,请检查设备是否被其他程序占用。': 'Cannot read the microphone. Check whether another app is using it.', + '麦克风已断开,请检查设备后重试。': 'The microphone was disconnected. Check the device and try again.', + '语音识别失败,请重试。': 'Speech recognition failed. Try again.', '采样参数(可选)': 'Sampling parameters (optional)', '作为团队默认值;成员自己的采样设置优先,留空的参数继承模型设置。': 'Team defaults. Each member’s own sampling settings take priority. Blank fields inherit model settings.', '留空的参数继承模型设置;发起子任务时可单独覆盖。': 'Blank fields inherit model settings. Each subtask can override them when started.', @@ -690,6 +753,10 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '工具': 'Tools', '钩子': 'Hooks', '已归档': 'Archived', + '已归档的聊天': 'Chat archived', + '关闭提示': 'Dismiss notification', + '查看': 'View', + '撤销归档失败:': 'Failed to undo archive: ', '已归档会话': 'Archived sessions', '从项目列表移除': 'Archive', '全不选': 'Deselect all', diff --git a/web/scripts/test-archived-session-pagination.mjs b/web/scripts/test-archived-session-pagination.mjs new file mode 100644 index 000000000..254ab7f15 --- /dev/null +++ b/web/scripts/test-archived-session-pagination.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = process.env.ACE_ARCHIVE_ARTIFACTS || await fs.mkdtemp(path.join(os.tmpdir(), 'ace-archive-pagination-')); +await fs.mkdir(output, { recursive: true }); +const fixture = ` +import React,{useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {SettingsPage} from '/src/components/SettingsPage.jsx'; +import {Toaster} from '/src/components/Toast.jsx'; +import {api} from '/src/lib/api.js'; +import '/src/i18n/index.js'; +import '/src/styles/globals.css'; +const workspaces=[{hash:'a',name:'工作区 A',cwd:'/fixture/a'},{hash:'b',name:'工作区 B',cwd:'/fixture/b'}]; +const makeRows=(count,hash)=>Array.from({length:count},(_,i)=>({id:hash+'-'+i,title:'归档 '+hash+' '+i,workspace_hash:hash, + workspaceName:hash==='a'?'工作区 A':'工作区 B',cwd:'/fixture/'+hash, + updated_at:new Date(Date.UTC(2026,9,18,0,0,-i)).toISOString()})); +window.fixture={rows:[...Array.from({length:5},(_,i)=>({id:'task-'+i,title:'任务 '+i,no_workspace:true})),...makeRows(2200,'a'),...makeRows(100,'b')],calls:[],failId:'',loads:0, + refresh(){window.dispatchEvent(new CustomEvent('ace-session-archive-changed',{detail:{source:'fixture'}}));}}; +const remove=async(hash,id)=>{window.fixture.calls.push([hash,id]);if(id===window.fixture.failId)throw Error('fixture failure'); + window.fixture.rows=window.fixture.rows.filter(s=>!(s.id===id&&(!hash||s.workspace_hash===hash)));}; +Object.assign(api,{ + listAllArchivedSessions:async()=>{window.fixture.loads++;return {workspaces,sessions:[...window.fixture.rows]};}, + listWorkspaces:async()=>workspaces,listSessions:async()=>[],getConfig:async()=>({}),listProviders:async()=>[],listModelProfiles:async()=>[], + unarchiveWorkspaceSession:remove,unarchiveSession:id=>remove('',id),purgeArchivedWorkspaceSession:remove,purgeArchivedSession:id=>remove('',id), +}); +function App(){const[open,setOpen]=useState(true);window.fixture.open=setOpen;return
+ {open&&setOpen(false)} office={{}}/>}
;} +createRoot(document.getElementById('root')).render(); +`; +const virtual = path.join(web, '__archive-pagination-fixture.jsx').replaceAll('\\', '/'); +const server = await createServer({ root: web, server: { host: '127.0.0.1', port: 0, open: false, hmr: false }, plugins: [{ + name: 'archive-pagination-fixture', enforce: 'pre', + resolveId(id) { return id === '/__archive-pagination-fixture.jsx' ? virtual : undefined; }, + load(id) { return id.replaceAll('\\', '/') === virtual ? fixture : undefined; }, + configureServer(vite) { vite.middlewares.use(async (req, res, next) => { + if (req.url?.split('?')[0] !== '/archive-pagination-fixture') return next(); + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/archive-pagination-fixture', '
')); + }); }, +}] }); +let browser; +const errors = []; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, ...(process.env.ACE_CHROMIUM_EXECUTABLE ? { executablePath: process.env.ACE_CHROMIUM_EXECUTABLE } : {}) }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' }); + await page.route('**/api/**', route => route.fulfill({ json: {} })); + page.on('pageerror', error => errors.push(error.message)); + const rows = page.locator('.ace-settings-content ul > li'); + const count = async expected => page.waitForFunction(n => document.querySelectorAll('.ace-settings-content ul > li').length === n, expected); + const bottom = async () => page.locator('.ace-settings-content').evaluate(el => { el.scrollTop = el.scrollHeight; }); + const top = async () => page.locator('.ace-settings-content').evaluate(el => { el.scrollTop = 0; }); + const search = page.getByRole('searchbox', { name: '搜索已归档会话' }); + const workspace = page.getByRole('combobox', { name: '选择工作区' }); + const sort = page.getByRole('combobox', { name: '排序方式' }); + const start = Date.now(); + await page.goto(server.resolvedUrls.local[0] + 'archive-pagination-fixture'); + await rows.first().waitFor(); + const metrics = { rows: await rows.count(), elements: await page.locator('.ace-settings-content *').count(), fixtureReadyMs: Date.now() - start }; + if (process.env.ACE_ARCHIVE_BASELINE === '1') { + assert.equal(metrics.rows, 2305); + console.log(JSON.stringify({ baseline: metrics, errors })); + } else { + await count(100); + await page.getByRole('button', { name: '全选', exact: true }).click(); + assert.equal(await page.locator('.ace-settings-content input:checked').count(), 100); + await bottom(); await count(200); + assert.equal(await page.locator('.ace-settings-content input:checked').count(), 100, 'new batch is not auto selected'); + await top(); await search.fill('归档 a 2199'); await count(1); + assert.equal(await page.locator('.ace-settings-content input:checked').count(), 0); + assert.match(await rows.first().innerText(), /a 2199/); + await search.fill(''); await count(100); + await sort.selectOption('oldest'); await count(100); + assert.match(await rows.nth(5).innerText(), /a 2199/); + await workspace.selectOption('b'); await count(100); + assert.equal(await page.locator('[data-archived-load-more]').count(), 0, 'exactly 100 needs no sentinel'); + await workspace.selectOption('a'); await count(100); + await bottom(); await count(200); + await top(); await sort.selectOption('newest'); await count(100); + await page.getByRole('button', { name: '全选', exact: true }).click(); + await page.evaluate(() => { window.fixture.failId = 'a-0'; }); + await page.getByRole('button', { name: '取消选中会话的归档', exact: true }).click(); + await page.waitForFunction(() => window.fixture.calls.length === 100); + await page.waitForFunction(() => document.querySelectorAll('.ace-settings-content input:checked').length === 1); + await count(100); + assert.equal(await page.locator('.ace-settings-content input:checked').count(), 1, 'failed row stays selected'); + assert.equal(await page.evaluate(() => window.fixture.rows.filter(s => s.workspace_hash === 'a').length), 2101); + await bottom(); await count(200); + await page.evaluate(() => { window.fixture.rows=window.fixture.rows.filter(s=>s.id!=='a-100');window.fixture.refresh(); }); + await page.waitForFunction(() => window.fixture.loads === 2); + await count(200); + await top(); await workspace.selectOption('b'); await count(100); + await page.evaluate(() => { window.fixture.rows.push({id:'b-last',title:'尾批唯一会话',workspace_hash:'b'});window.fixture.refresh(); }); + await page.waitForFunction(() => window.fixture.loads === 3); + await bottom(); await count(101); + assert.equal(await page.locator('[data-archived-load-more]').count(), 0); + assert.match(await rows.last().innerText(), /尾批唯一会话/); + // Reset to the first 100 of 101, then verify destructive actions never reach + // the unmounted tail and cancelling the confirmation does not send requests. + await top(); await sort.selectOption('oldest'); await count(100); + const beforeDelete = await page.evaluate(() => window.fixture.calls.length); + await page.getByRole('button', { name: '全选', exact: true }).click(); + await page.getByRole('button', { name: '删除选中会话', exact: true }).click(); + await page.getByRole('button', { name: '取消', exact: true }).click(); + assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeDelete); + await page.getByRole('button', { name: '删除选中会话', exact: true }).click(); + await page.getByRole('button', { name: '彻底删除', exact: true }).click(); + await count(1); + assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeDelete + 100); + assert.match(await rows.first().innerText(), /尾批唯一会话/); + await top(); await workspace.selectOption(''); await count(100); + await page.screenshot({ path: path.join(output, 'archive-pagination-desktop.png') }); + await page.setViewportSize({ width: 430, height: 800 }); + await page.evaluate(() => document.documentElement.setAttribute('data-theme','dark')); + assert.equal(await page.locator('.ace-settings-content').evaluate(el => el.scrollWidth > el.clientWidth), false); + await page.screenshot({ path: path.join(output, 'archive-pagination-narrow.png') }); + // Reopen without IntersectionObserver to exercise the older-WebView fallback. + await page.evaluate(() => { window.fixture.open(false); }); + await page.locator('.ace-settings-content').waitFor({ state: 'detached' }); + await page.evaluate(() => { window.IntersectionObserver=undefined;window.fixture.open(true); }); + await count(100); await bottom(); await count(200); + await top(); await search.fill('no such archive'); await count(0); + await page.getByRole('status').filter({ hasText: '没有匹配的已归档会话' }).waitFor(); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ after: metrics, checked: ['2305-row initial cap','scroll batches','full search','sort reset','100/101 boundary','selection','partial restore failure','backfill','external refresh','delete confirmation and visible-only targets','reopen','observer fallback','narrow dark layout'], output, errors })); + } +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-session-archive.mjs b/web/scripts/test-session-archive.mjs new file mode 100644 index 000000000..5ff14883e --- /dev/null +++ b/web/scripts/test-session-archive.mjs @@ -0,0 +1,297 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = process.env.ACE_ARCHIVE_ARTIFACTS || await fs.mkdtemp(path.join(os.tmpdir(), 'ace-archive-')); +await fs.mkdir(output, { recursive: true }); +const fixture = String.raw` +import React,{useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {Sidebar} from '/src/components/Sidebar.jsx'; +import {Toaster,toast} from '/src/components/Toast.jsx'; +import {SettingsPage} from '/src/components/SettingsPage.jsx'; +import {api} from '/src/lib/api.js'; +import {connection} from '/src/lib/connection.js'; +import '/src/i18n/index.js'; +import '/src/styles/globals.css'; +const workspaces=Array.from({length:9},(_,i)=>({hash:'w'+i,cwd:'/fixture/'+i,name:'项目 '+i,active:i===0})); +const all=workspaces.flatMap(w=>Array.from({length:15},(_,i)=>({id:w.hash+'-'+i,title:'会话 '+w.hash+' '+i,workspace_hash:w.hash,cwd:w.cwd, + created_at:new Date(Date.UTC(2026,9,18,0,0,15-i)).toISOString(),active:false,attention_state:'read',sidebar_key:'session:'+w.hash+'-'+i}))) + .concat(Array.from({length:8},(_,i)=>({id:'task-'+i,title:'任务 '+i,no_workspace:true,workspace_hash:'',active:false,sidebar_key:'session:task-'+i}))); +const archived=new Set(); +const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +window.fixture={archived,archiveCalls:[],undoCalls:[],pageCalls:[],pins:{},delay:180,failArchive:false,failUndo:false,failPage:false,holdNext:false,held:null,sections:[],toast, + release(){this.held?.();this.held=null;},refresh(){window.dispatchEvent(new Event('ace-session-archive-changed'));}}; +Object.assign(api,{ + listWorkspaces:async()=>workspaces, + listSidebarSessions:async(hash,opts={})=>{ + window.fixture.pageCalls.push(hash); + const rows=all.filter(s=>s.workspace_hash===hash&&!archived.has(s.id)); + const limit=opts.limit||rows.length; + const result={sidebar_tree:true,sessions:rows.slice(0,limit).map((s,i)=>({...s,sidebar_rank:i})),total:rows.length,has_more:rows.length>limit,visible_roots:Math.min(limit,rows.length)}; + if(window.fixture.holdNext&&hash==='w0'){window.fixture.holdNext=false;await new Promise(resolve=>window.fixture.held=resolve);} + else await wait(window.fixture.delay); + if(window.fixture.failPage)throw Error('fixture list error'); + return result; + }, + getPinnedSessions:async(hash)=>({session_ids:window.fixture.pins[hash]||[]}),getNoWorkspacePinnedSessions:async()=>({session_ids:[]}),getPinnedSessionOrder:async()=>({items:[]}), + setPinnedSessions:async(hash,ids)=>{await wait(80);window.fixture.pins[hash]=ids;return {session_ids:ids};},setPinnedSessionOrder:async(items)=>({items}), + getOpencodeImportPreview:async()=>({available:false,count:0}), + archiveWorkspaceSession:async(hash,id)=>{window.fixture.archiveCalls.push([hash,id]);await wait(60);if(window.fixture.failArchive)throw Error('fixture archive error');archived.add(id);}, + archiveSession:async(id)=>api.archiveWorkspaceSession('',id), + unarchiveWorkspaceSession:async(hash,id)=>{window.fixture.undoCalls.push([hash,id]);await wait(100);if(window.fixture.failUndo)throw Error('fixture undo error');archived.delete(id);}, + unarchiveSession:async(id)=>api.unarchiveWorkspaceSession('',id), + listAllArchivedSessions:async()=>({workspaces,sessions:all.filter(s=>archived.has(s.id))}), + listSessions:async()=>[],getConfig:async()=>({}),listProviders:async()=>[],listModelProfiles:async()=>[], +}); +for(const name of ['reconfigure','retainSession','releaseSession','subscribeWorkspaceStatus','unsubscribeWorkspaceStatus'])connection[name]=()=>{}; +function App(){const[section,setSection]=useState('');const[active,setActive]=useState(null); + window.fixture.select=id=>setActive({sessionId:id,workspaceHash:id.startsWith('task')?'':'w0',noWorkspace:id.startsWith('task')}); + return
setActive(null)} + onOpenSettingsSection={key=>{window.fixture.sections.push(key);setSection(key);}}/>
+ {section&&setSection('')} office={{}}/>}
;} +createRoot(document.getElementById('root')).render(); +`; +const virtual = path.join(web, '__archive-fixture.jsx').replaceAll('\\', '/'); +const server = await createServer({ root: web, server: { host: '127.0.0.1', port: 0, open: false, hmr: false }, plugins: [{ + name: 'archive-fixture', enforce: 'pre', + resolveId(id) { return id === '/__archive-fixture.jsx' ? virtual : undefined; }, + load(id) { return id.replaceAll('\\', '/') === virtual ? fixture : undefined; }, + configureServer(vite) { vite.middlewares.use(async (req, res, next) => { + if (req.url?.split('?')[0] !== '/archive-fixture') return next(); + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/archive-fixture', '
')); + }); }, +}] }); +let browser; +let page; +const errors = []; +try { + await server.listen(); + browser = await chromium.launch({ headless: true, ...(process.env.ACE_CHROMIUM_EXECUTABLE ? { executablePath: process.env.ACE_CHROMIUM_EXECUTABLE } : {}) }); + page = await browser.newPage({ viewport: { width: 1100, height: 700 }, reducedMotion: 'reduce' }); + // Ancillary settings/navigation fetches are outside this archive fixture. + await page.route('**/api/**', route => route.fulfill({ json: {} })); + page.on('pageerror', error => errors.push(error.message)); + const row = id => page.locator('.ace-sidebar-session-row[data-desktop-session-id="' + id + '"]'); + const rows = () => page.locator('.ace-sidebar-session-row[data-desktop-session-workspace="w0"]'); + const archive = async id => { await row(id).hover(); await row(id).getByRole('button', { name: '归档', exact: true }).click(); }; + await page.goto(server.resolvedUrls.local[0] + 'archive-fixture'); + await row('w0-0').waitFor(); + await page.evaluate(() => { + window.frames = []; + window.recording = true; + const record = () => { + window.frames.push({ count: document.querySelectorAll('.ace-sidebar-session-row[data-desktop-session-workspace="w0"]').length, + scroll: document.querySelector('.ace-sidebar-scroll').scrollTop }); + if (window.recording) requestAnimationFrame(record); + }; + record(); + }); + await archive('w0-0'); + await row('w0-5').waitFor(); + const frames = await page.evaluate(() => { window.recording = false; return window.frames; }); + const counts = [...new Set(frames.map(frame => frame.count))]; + if (process.env.ACE_ARCHIVE_BASELINE === '1') { + console.log(JSON.stringify({ baselineRowCounts: counts, frames: frames.length, errors })); + } else { + assert.deepEqual(counts, [5], 'archive and backfill must commit in one render'); + assert.equal(await rows().count(), 5); + const notice = page.getByRole('status').filter({ has: page.getByRole('button', { name: '撤销', exact: true }) }); + await notice.first().waitFor(); + const color = await notice.first().getByRole('button', { name: '撤销', exact: true }).evaluate(el => { + const probe = document.createElement('span'); probe.style.color = 'var(--ace-accent)'; el.append(probe); + const result = { color: getComputedStyle(el).color, background: getComputedStyle(el).backgroundColor, accent: getComputedStyle(probe).color }; probe.remove(); return result; + }); + assert.equal(color.background, color.accent, 'undo background uses the active theme accent'); + assert.equal(color.color, 'rgb(255, 255, 255)', 'undo has white text'); + assert.deepEqual(await notice.first().getByRole('button').evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label') || button.textContent)), ['查看', '撤销', '关闭提示']); + assert.equal(await notice.first().locator('[data-icon-name="Archive"]').count(), 1); + assert.match(await notice.first().textContent(), /已归档的聊天/); + await page.evaluate(() => fixture.toast({ text: '样式对照', duration: 0, dismissible: true })); + const plainNotice = page.getByRole('status').filter({ hasText: '样式对照' }); + const noticeStyle = el => { + const style = getComputedStyle(el); + const message = getComputedStyle(el.children[1]); + return { font: message.fontFamily, size: message.fontSize, weight: message.fontWeight, lineHeight: message.lineHeight, + radius: style.borderRadius, padding: style.padding, height: el.getBoundingClientRect().height }; + }; + assert.deepEqual(await notice.first().evaluate(noticeStyle), await plainNotice.evaluate(noticeStyle), 'archive keeps the ordinary toast typography, shape and height'); + assert.equal(await notice.first().getByRole('button', { name: '撤销', exact: true }).evaluate(el => getComputedStyle(el).fontSize), await plainNotice.evaluate(el => getComputedStyle(el).fontSize), 'buttons follow the existing toast font-size preference'); + await plainNotice.getByRole('button', { name: '关闭提示', exact: true }).click(); + await page.screenshot({ path: path.join(output, 'archive-toast-light.png') }); + await notice.first().screenshot({ path: path.join(output, 'archive-toast-blue.png') }); + await page.evaluate(() => document.documentElement.style.setProperty('--ace-accent', '#0d0d0d')); + await page.waitForFunction(() => getComputedStyle(document.querySelector('[role="status"] .bg-accent')).backgroundColor === 'rgb(13, 13, 13)'); + assert.equal(await notice.first().getByRole('button', { name: '撤销', exact: true }).evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(13, 13, 13)'); + await notice.first().screenshot({ path: path.join(output, 'archive-toast-black.png') }); + await page.evaluate(() => document.documentElement.style.removeProperty('--ace-accent')); + await archive('w0-1'); + await row('w0-6').waitFor(); + await notice.first().getByRole('button', { name: '撤销', exact: true }).click(); + await row('w0-1').waitFor(); + assert.deepEqual(await page.evaluate(() => fixture.undoCalls), [['w0', 'w0-1']], 'undo binds to its own notice'); + assert.equal(await row('w0-0').count(), 0); + await notice.first().getByRole('button', { name: '查看', exact: true }).click(); + await page.locator('[data-archived-filters]').waitFor(); + assert.deepEqual(await page.evaluate(() => fixture.sections), ['archived']); + await page.screenshot({ path: path.join(output, 'archive-view.png') }); + + const reset = async () => { + await page.reload(); await row('w0-0').waitFor(); + await page.waitForTimeout(250); + }; + await reset(); + await archive('w0-0'); + await row('w0-5').waitFor(); + await notice.first().getByRole('button', { name: '关闭提示', exact: true }).click(); + assert.equal(await notice.count(), 0); + assert.deepEqual(await page.evaluate(() => ({ archived: [...fixture.archived], undoCalls: fixture.undoCalls, sections: fixture.sections })), { archived: ['w0-0'], undoCalls: [], sections: [] }, 'dismiss only hides the notification'); + await reset(); + await archive('w0-0'); + await row('w0-5').waitFor(); + await archive('w0-1'); + await row('w0-6').waitFor(); + await notice.first().getByRole('button', { name: '查看', exact: true }).click(); + await page.locator('[data-archived-filters]').waitFor(); + await page.getByRole('checkbox', { name: '选择会话 会话 w0 0', exact: true }).waitFor(); + await notice.first().getByRole('button', { name: '撤销', exact: true }).click(); + await page.getByRole('checkbox', { name: '选择会话 会话 w0 0', exact: true }).waitFor({ state: 'detached' }); + assert.equal(await page.getByRole('checkbox', { name: '选择会话 会话 w0 1', exact: true }).count(), 1, 'open archive page follows toast undo'); + + await reset(); + await page.evaluate(() => { fixture.failArchive = true; }); + await archive('w0-0'); + await page.getByRole('status').filter({ hasText: '归档失败:' }).waitFor(); + assert.equal(await row('w0-0').count(), 1, 'failed archive retains its row'); + assert.equal(await notice.count(), 0); + await page.evaluate(() => { fixture.failArchive = false; fixture.archiveCalls = []; }); + await row('w0-0').getByRole('button', { name: '归档', exact: true }).evaluate(el => { el.click(); el.click(); }); + await row('w0-5').waitFor(); + assert.deepEqual(await page.evaluate(() => fixture.archiveCalls), [['w0', 'w0-0']], 'duplicate click sends one archive'); + await page.evaluate(() => { fixture.failUndo = true; }); + await notice.first().getByRole('button', { name: '撤销', exact: true }).click(); + await page.getByRole('status').filter({ hasText: '撤销归档失败' }).waitFor(); + assert.equal(await row('w0-0').count(), 0); + await page.evaluate(() => { fixture.failUndo = false; fixture.undoCalls = []; }); + await notice.first().getByRole('button', { name: '撤销', exact: true }).evaluate(el => { el.click(); el.click(); }); + await row('w0-0').waitFor(); + assert.deepEqual(await page.evaluate(() => fixture.undoCalls), [['w0', 'w0-0']], 'failed undo can retry without duplicate submission'); + + await reset(); + await page.evaluate(() => { fixture.holdNext = true; fixture.refresh(); }); + await page.waitForFunction(() => Boolean(fixture.held)); + await archive('w0-0'); + await row('w0-5').waitFor(); + await page.evaluate(() => { + window.resurrected = false; + new MutationObserver(() => { + if (document.querySelector('[data-desktop-session-id="w0-0"]')) window.resurrected = true; + }).observe(document.querySelector('.ace-sidebar-scroll'), { childList: true, subtree: true }); + fixture.release(); + }); + await page.waitForTimeout(650); + assert.equal(await page.evaluate(() => window.resurrected), false, 'old polling response must never resurrect archived rows'); + + await reset(); + await page.evaluate(() => { + for (const id of ['w0-0', 'w0-1']) document.querySelector('[data-desktop-session-id="'+id+'"] button[title="归档"]').click(); + }); + await row('w0-6').waitFor(); + assert.equal(await row('w0-0').count(), 0); + assert.equal(await row('w0-1').count(), 0); + assert.equal(await rows().count(), 5, 'overlapping archive requests keep a full page'); + await notice.last().getByRole('button', { name: '撤销', exact: true }).click(); + await row('w0-0').waitFor(); + assert.equal(await row('w0-1').count(), 0, 'an older toast still restores its original target'); + + await reset(); + await archive('task-0'); + await page.waitForFunction(() => !document.querySelector('[data-desktop-session-id="task-0"]')); + await notice.first().getByRole('button', { name: '撤销', exact: true }).click(); + await row('task-0').waitFor(); + assert.deepEqual(await page.evaluate(() => fixture.undoCalls), [['', 'task-0']], 'no-workspace undo uses the compatibility API'); + + await reset(); + await page.evaluate(() => { fixture.pins.w0 = ['w0-0']; fixture.refresh(); }); + await page.locator('[data-desktop-session-id="w0-0"][data-desktop-session-pinned="true"]').waitFor(); + await page.evaluate(() => { + window.unpinnedFlash = false; + new MutationObserver(() => { + const archivedRow = document.querySelector('[data-desktop-session-id="w0-0"]'); + if (archivedRow && archivedRow.getAttribute('data-desktop-session-pinned') !== 'true') window.unpinnedFlash = true; + }).observe(document.querySelector('.ace-sidebar-scroll'), { childList: true, subtree: true, attributes: true }); + }); + await archive('w0-0'); + await notice.first().waitFor(); + assert.equal(await page.evaluate(() => window.unpinnedFlash), false, 'archived pinned row never flashes in ordinary sessions'); + assert.equal(await rows().count(), 5); + await notice.first().getByRole('button', { name: '撤销', exact: true }).click(); + await row('w0-0').waitFor(); + assert.notEqual(await row('w0-0').getAttribute('data-desktop-session-pinned'), 'true'); + + await reset(); + await page.evaluate(() => { fixture.select('w0-0'); }); + await archive('w0-0'); + await row('w0-5').waitFor(); + assert.equal(await page.locator('[data-desktop-session-id="w0-0"]').count(), 0); + + await reset(); + // Expand all workspaces so the same mutation can be checked while scrolled. + for (let index = 1; index < 9; index += 1) { + await page.locator('[data-desktop-workspace-id="w'+index+'"]').evaluate(el => el.click()); + } + await row('w8-0').waitFor(); + await row('w4-0').hover(); + const scroll = await page.locator('.ace-sidebar-scroll').evaluate(el => el.scrollTop); + await archive('w4-0'); + await row('w4-5').waitFor(); + assert.equal(await page.locator('.ace-sidebar-scroll').evaluate(el => el.scrollTop), scroll, 'archive backfill keeps the scrolled viewport stable'); + + await reset(); + for (const interaction of ['hover', 'focus']) { + await page.evaluate(() => fixture.toast({ text: '计时验证', duration: 150, actions: [{ label: '测试操作', onClick: () => {} }] })); + const timerToast = page.getByRole('status').filter({ hasText: '计时验证' }); + if (interaction === 'hover') await timerToast.hover(); + else await timerToast.getByRole('button').focus(); + await page.waitForTimeout(350); + assert.equal(await timerToast.count(), 1, interaction + ' pauses expiration'); + if (interaction === 'hover') await page.mouse.move(0, 699); + else await timerToast.getByRole('button').evaluate(el => el.blur()); + await timerToast.waitFor({ state: 'detached' }); + } + await page.evaluate(() => fixture.toast({ text: '异步计时验证', duration: 150, actions: [{ label: '执行操作', onClick: () => new Promise(resolve => setTimeout(resolve, 500)) }] })); + const busyToast = page.getByRole('status').filter({ hasText: '异步计时验证' }); + await busyToast.getByRole('button').click(); + await page.mouse.move(0, 699); + await page.waitForTimeout(250); + assert.equal(await busyToast.count(), 1, 'pending action pauses expiration'); + assert.equal(await busyToast.getByRole('button').isDisabled(), true); + await busyToast.waitFor({ state: 'detached' }); + + await archive('w0-0'); + await row('w0-5').waitFor(); + await page.setViewportSize({ width: 360, height: 740 }); + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')); + const bounds = await notice.first().boundingBox(); + assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= 360, 'action toast fits narrow screens'); + assert.equal(await notice.first().getByRole('button', { name: '撤销', exact: true }).isVisible(), true); + assert.equal(await notice.first().getByRole('button', { name: '查看', exact: true }).isVisible(), true); + await page.screenshot({ path: path.join(output, 'archive-toast-dark-narrow.png') }); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ passed: true, counts, color, checks: ['atomic backfill', 'undo target', 'view route', 'duplicate archive', 'failure/retry', 'stale response', 'concurrent archive', 'no-workspace undo', 'active session', 'scroll stability', 'hover/focus/pending timer', 'narrow dark'], output })); + } +} catch (error) { + console.error(JSON.stringify({ errors, notices: await page?.getByRole('status').allTextContents(), state: await page?.evaluate(() => ({ archiveCalls: fixture.archiveCalls, undoCalls: fixture.undoCalls, archived: [...fixture.archived] })) })); + await page?.screenshot({ path: path.join(output, 'failure.png') }); + throw error; +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/scripts/test-speech-recognition.mjs b/web/scripts/test-speech-recognition.mjs new file mode 100644 index 000000000..e4120ea26 --- /dev/null +++ b/web/scripts/test-speech-recognition.mjs @@ -0,0 +1,364 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createServer } from 'vite'; + +const web = fileURLToPath(new URL('../', import.meta.url)); +const { chromium } = await import(process.env.ACE_PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.ACE_PLAYWRIGHT_MODULE)).href : 'playwright'); +const output = process.env.ACE_SPEECH_ARTIFACTS || await fs.mkdtemp(path.join(os.tmpdir(), 'ace-speech-ui-')); +await fs.mkdir(output, { recursive: true }); +const fixture = String.raw` +import React,{useState,useRef} from 'react'; +import {createRoot} from 'react-dom/client'; +import '/src/styles/globals.css'; +import {InputBar} from '/src/components/InputBar.jsx'; +import {SettingsPage} from '/src/components/SettingsPage.jsx'; +import '/src/i18n/index.js'; +import {Toaster} from '/src/components/Toast.jsx'; +import {api} from '/src/lib/api.js'; +import {SPEECH_DEFAULTS,speechAvailabilityStore} from '/src/lib/speechRecognition.js'; +import {composerContentFromText} from '/src/lib/composerContent.js'; +let config={...SPEECH_DEFAULTS,configured:false,generation:0}; +window.fixture={sent:[],uploads:[],cancelled:[],saves:[],tests:[],hold:false,nextText:'识别文字',failure:null, + saved:()=>({...config}),failSave:false,holdSave:false,releaseSave(){this.pendingSave?.();this.pendingSave=null;}, + release(){this.pending?.();this.pending=null;}, + disable(){config={...config,enabled:false,generation:config.generation+1};speechAvailabilityStore(api).publish(config);}}; +Object.assign(api,{ + getSpeechAvailability:async()=>({...config}), + getSpeechRecognition:async()=>({...config}), + setSpeechRecognition:async(next)=>{ + window.fixture.saves.push(next); + if(window.fixture.holdSave)await new Promise(resolve=>window.fixture.pendingSave=resolve); + if(window.fixture.failSave)throw {code:'PERSIST_FAILED'}; + config={...config,...next,generation:config.generation+1,configured:!!next.base_url&&(next.protocol!=='openai'||!!next.model)};return {...config};}, + testSpeechRecognition:async(next)=>{window.fixture.tests.push({draft:next,saved:{...config}});return {ok:true};}, + transcribeSpeech:async(id,audio,options)=>{ + const buffer=await audio.arrayBuffer(); + window.fixture.uploads.push({id,bytes:buffer.byteLength,rate:new DataView(buffer).getUint32(24,true)}); + if(window.fixture.hold)await new Promise(resolve=>window.fixture.pending=resolve); + if(window.fixture.failure)throw {code:window.fixture.failure}; + return {status:window.fixture.nextText?'ok':'no_speech',text:window.fixture.nextText}; + }, + cancelSpeech:async(id)=>window.fixture.cancelled.push(id), + listCommands:async()=>({commands:[]}), + getImageGeneration:async()=>({enabled:false,source:'inline',base_url:'',api_key:'',models:{standard:'',high:'',ultra:''},default_quality:'standard',connections:[]}), + getComputerUse:async()=>({enabled:false,available:true}), +}); +function App(){ + const [value,setValue]=useState('已有草稿 '),[content,setContent]=useState(composerContentFromText('已有草稿 ')); + const [sid,setSid]=useState('session-a'),[width,setWidth]=useState(900),[variant,setVariant]=useState('default'); + const editor=useRef(null); + const [settingsOpen,setSettingsOpen]=useState(true),[settingsNav,setSettingsNav]=useState('tools'); + window.fixture.settings=(open,nav='tools')=>{setSettingsNav(nav);setSettingsOpen(open);}; + window.fixture.state={value,content,sid}; + window.fixture.content=()=>editor.current?.getComposerContent(); + window.fixture.reset=(text)=>editor.current?.replaceText(text); + window.fixture.tags=()=>editor.current?.setComposerContent({version:1,parts:[ + {type:'text',text:'草稿 '},{type:'path',path:'C:/work/sample.js',token:'@"C:/work/sample.js"'}, + {type:'text',text:' '},{type:'skill',name:'review',token:'/review',path:'C:/skills/review/SKILL.md'}, + {type:'text',text:' 结尾'}]}); + window.fixture.switch=()=>{setSid('session-b');setValue('另一会话草稿');setContent(composerContentFromText('另一会话草稿'));}; + window.fixture.layout=(next)=>{if(next.width)setWidth(next.width);if(next.variant)setVariant(next.variant);}; + const model={name:'ACEModel-gpt-6-astra',provider:'openai',model:'fixture'}; + return
+ {settingsOpen&&setSettingsOpen(false)} office={{}}/>} +
+ {setValue(text);if(next)setContent(next);}} onComposerContentChange={setContent} + currentSessionId={sid} cwd="C:/fixture" mainComposer variant={variant} + onSubmit={text=>window.fixture.sent.push(text)} + sessionControls={{model:'ACEModel-gpt-6-astra',modelOptions:[model],selectedModelName:model.name, + onModelChange(){},onRefreshModels(){},permissionMode:'default',reasoningOptions:{label:'极高',items:[]}}}/> +
+
; +} +createRoot(document.getElementById('root')).render(); +`; +const virtual = path.join(web, '__speech-fixture.jsx').replaceAll('\\', '/'); +const server = await createServer({ + root: web, logLevel: 'error', cacheDir: path.join(output, '.vite'), + server: { host: '127.0.0.1', port: 0, open: false, hmr: false }, + plugins: [{ + name: 'speech-fixture', enforce: 'pre', + resolveId(id) { return id === '/__speech-fixture.jsx' ? virtual : undefined; }, + load(id) { return id.replaceAll('\\', '/') === virtual ? fixture : undefined; }, + configureServer(vite) { + vite.middlewares.use(async (req, res, next) => { + if (req.url !== '/speech-fixture') return next(); + res.setHeader('Content-Type', 'text/html'); + res.end(await vite.transformIndexHtml('/speech-fixture', '
')); + }); + }, + }], +}); +let browser, page; +const errors = []; +try { + await server.listen(); + browser = await chromium.launch({ + headless: true, executablePath: process.env.ACE_CHROMIUM_EXECUTABLE || undefined, + args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'], + }); + page = await browser.newPage({ viewport: { width: 1100, height: 850 }, reducedMotion: 'reduce' }); + await page.addInitScript(() => { + const media=navigator.mediaDevices; + if(!media)return; + const capture=media.getUserMedia.bind(media); + window.speechAudioRequests=[]; + media.getUserMedia=async(options)=>{ + window.speechAudioRequests.push(options); + const stream=await capture(options); + if(window.holdMicrophonePermission){ + window.voicePendingStream=stream; + await new Promise(resolve=>window.releaseMicrophonePermission=resolve); + } + return stream; + }; + }); + page.on('pageerror', error => {errors.push(error.message);console.error(error.stack);}); + page.on('console', message => { if (message.type() === 'error') console.error(message.text()); }); + await page.route('**/api/**', route => route.fulfill({ json: {} })); + await page.goto(server.resolvedUrls.local[0] + 'speech-fixture', { timeout: 120000 }); + const editor = page.locator('[data-slate-editor]'); + await editor.waitFor(); + assert.equal(await page.locator('[data-composer-speech]').count(), 0); + const settings = page.locator('[data-speech-settings]'); + await settings.waitFor(); + for (const theme of ['light', 'dark']) { + await page.evaluate(theme => { document.documentElement.dataset.theme=theme; }, theme); + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 1000 }); + const geometry = await page.evaluate(() => { + const speech=document.querySelector('[data-speech-settings]'); + const image=speech.previousElementSibling; + const metrics = (section) => { + const row=section.firstElementChild, icon=row.firstElementChild; + const title=icon.nextElementSibling, button=row.querySelector('button[aria-expanded]'); + const toggle=row.querySelector('[role="switch"]'); + const rect=(node)=>{const r=node.getBoundingClientRect();return {x:r.x,right:r.right,width:r.width,height:r.height};}; + return {row:rect(row),icon:rect(icon),title:rect(title),button:rect(button),toggle:rect(toggle), + iconRadius:getComputedStyle(icon).borderRadius,iconBorder:getComputedStyle(icon).borderLeftWidth, + divider:getComputedStyle(row).borderBottomWidth}; + }; + return {speech:metrics(speech),image:metrics(image)}; + }); + for (const part of ['row','icon','title','button','toggle']) + assert.ok(Math.abs(geometry.speech[part].x-geometry.image[part].x)<1, `${theme}/${width}: ${part} aligns with adjacent tool: ${JSON.stringify(geometry)}`); + assert.equal(geometry.speech.icon.height,geometry.image.icon.height); + assert.equal(geometry.speech.iconRadius,geometry.image.iconRadius); + assert.equal(geometry.speech.iconBorder,geometry.image.iconBorder); + assert.equal(geometry.speech.divider,geometry.image.divider); + await page.locator('.ace-settings-content').screenshot({path:path.join(output,`tools-${theme}-${width}.png`)}); + } + } + await page.setViewportSize({width:1100,height:850}); + await page.evaluate(() => {document.documentElement.dataset.theme='light';}); + await settings.getByRole('button', { name: '配置', exact: true }).click(); + assert.equal(await settings.getByRole('button', {name:'保存',exact:true}).count(),0); + await page.getByLabel('语音服务地址', { exact: true }).fill('http://192.168.1.50:9876'); + await page.getByLabel('语音服务地址', { exact: true }).press('Tab'); + await page.waitForFunction(() => window.fixture.saved().base_url==='http://192.168.1.50:9876'); + assert.equal(await page.locator('[data-composer-speech]').count(), 0, 'configured but disabled'); + await page.getByRole('switch', { name: '启用语音识别' }).click(); + await page.locator('[data-composer-speech]').waitFor(); + await settings.screenshot({ path: path.join(output, 'settings-light.png') }); + await page.getByLabel('语音 API Key', {exact:true}).fill('fixture-key'); + await page.getByLabel('语音 API Key', {exact:true}).press('Enter'); + await page.waitForFunction(() => window.fixture.saved().api_key==='fixture-key'); + await page.getByLabel('语音 API Key', {exact:true}).fill(''); + await settings.getByRole('button', {name:'收起',exact:true}).click(); + await page.waitForFunction(() => window.fixture.saved().api_key===''); + await settings.getByRole('button', {name:'配置',exact:true}).click(); + await page.getByLabel('语音服务地址', {exact:true}).fill('http://speech.internal:8888'); + await page.evaluate(() => window.fixture.settings(false)); + await page.waitForFunction(() => window.fixture.saved().base_url==='http://speech.internal:8888'); + await page.evaluate(() => window.fixture.settings(true)); + await settings.getByRole('button', {name:'配置',exact:true}).click(); + assert.equal(await page.getByLabel('语音服务地址', {exact:true}).inputValue(),'http://speech.internal:8888'); + await page.evaluate(() => {window.fixture.holdSave=true;}); + await page.getByLabel('语音服务地址', {exact:true}).fill('http://speech.internal:9999'); + await settings.getByRole('button', {name:'测试连接',exact:true}).click(); + await page.waitForFunction(() => !!window.fixture.pendingSave); + assert.equal(await page.evaluate(() => window.fixture.tests.length),0,'connection test waits for autosave'); + await page.evaluate(() => {window.fixture.holdSave=false;window.fixture.releaseSave();}); + await page.waitForFunction(() => window.fixture.tests.length===1); + assert.equal(await page.evaluate(() => window.fixture.tests[0].saved.base_url),'http://speech.internal:9999'); + await page.evaluate(() => {window.fixture.failSave=true;}); + await page.getByLabel('语音服务地址', {exact:true}).fill('http://speech.internal:7777'); + await settings.getByRole('button', {name:'测试连接',exact:true}).click(); + await settings.getByRole('button', {name:'重试',exact:true}).waitFor(); + assert.equal(await page.evaluate(() => window.fixture.tests.length),1,'failed autosave prevents connection test'); + assert.equal(await page.getByLabel('语音服务地址', {exact:true}).inputValue(),'http://speech.internal:7777'); + await page.evaluate(() => {window.fixture.failSave=false;}); + await settings.getByRole('button', {name:'重试',exact:true}).click(); + await page.waitForFunction(() => window.fixture.saved().base_url==='http://speech.internal:7777'); + await settings.getByRole('button', { name: '收起', exact: true }).click(); + await page.evaluate(() => window.fixture.settings(false)); + const start = async () => { + const before=await page.locator('[data-speech-composer]').boundingBox(); + await editor.click(); + await page.keyboard.press('ControlOrMeta+End'); + await page.getByRole('button', { name: '听写', exact: true }).click(); + await page.locator('[data-speech-recording="recording"]').waitFor(); + await page.waitForFunction(() => [...document.querySelectorAll('[data-speech-wave] span[aria-hidden]')] + .some(node => parseFloat(node.style.height) > 3.01)); + const after=await page.locator('[data-speech-composer]').boundingBox(); + for(const key of ['x','y','width','height'])assert.ok(Math.abs(before[key]-after[key])<1,`${key} stays stable when recording starts: ${JSON.stringify({before,after})}`); + }; + const idle = () => page.locator('[data-composer-speech]').waitFor(); + for (const theme of ['light', 'dark']) { + await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme); + await page.locator('[data-fixture-composer]').screenshot({ path: path.join(output, theme + '-idle.png') }); + await start(); + await page.locator('[data-fixture-composer]').screenshot({ path: path.join(output, theme + '-recording.png') }); + await page.getByRole('button', { name: '取消听写' }).click(); + await idle(); + } + assert.equal(await page.evaluate(() => window.fixture.uploads.length), 0); + assert.equal(await page.evaluate(() => window.fixture.content().parts[0].text), '已有草稿 '); + await start(); + await page.getByRole('button', { name: '停止录音并插入文字' }).click(); + await idle(); + assert.match(await editor.innerText(), /已有草稿.*识别文字/); + assert.equal(await page.evaluate(() => window.fixture.sent.length), 0); + const upload = await page.evaluate(() => window.fixture.uploads[0]); + assert.equal(upload.rate, 16000); assert.ok(upload.bytes > 44); + await page.evaluate(() => window.fixture.reset('')); + await start(); + await page.getByRole('button', { name: '识别并发送' }).click(); + await page.waitForFunction(() => window.fixture.sent.length === 1); + assert.equal(await page.evaluate(() => window.fixture.sent[0]), '识别文字'); + await page.evaluate(() => window.fixture.reset('保留草稿 ')); + await start(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => window.fixture.sent.length === 2); + assert.equal(await page.evaluate(() => window.fixture.sent[1]), '保留草稿 识别文字'); + await page.evaluate(() => { window.fixture.reset('失败保留'); window.fixture.failure='SPEECH_CONNECTION_FAILED'; }); + await start(); + await page.getByRole('button', { name: '识别并发送' }).click(); + await idle(); + assert.equal(await editor.innerText(), '失败保留'); + assert.equal(await page.evaluate(() => window.fixture.sent.length), 2); + await page.evaluate(() => { window.fixture.failure=null; window.fixture.nextText=''; }); + await start(); + await page.getByRole('button', { name: '识别并发送' }).click(); + await idle(); + assert.equal(await editor.innerText(), '失败保留'); + assert.equal(await page.evaluate(() => window.fixture.sent.length), 2); + await page.evaluate(() => { window.fixture.nextText='识别文字'; }); + await page.evaluate(() => { window.fixture.reset('原稿');window.fixture.hold=true; }); + await start(); + await page.getByRole('button', { name: '停止录音并插入文字' }).click(); + await page.locator('[data-speech-recording="transcribing"]').waitFor(); + await page.waitForFunction(() => !!window.fixture.pending); + await page.evaluate(() => window.fixture.switch()); + await idle(); + await page.evaluate(() => window.fixture.release()); + assert.equal(await editor.innerText(), '另一会话草稿'); + assert.ok(await page.evaluate(() => window.fixture.cancelled.length >= 1)); + await page.evaluate(() => { window.fixture.hold=false;window.fixture.tags(); }); + const before = await page.evaluate(() => window.fixture.content().parts.filter(p => p.type !== 'text')); + await start(); + await page.getByRole('button', { name: '停止录音并插入文字' }).click(); + await idle(); + const after = await page.evaluate(() => window.fixture.content().parts.filter(p => p.type !== 'text')); + assert.deepEqual(after, before, 'file and skill tags survive voice insertion'); + for (const variant of ['default', 'hero']) { + await page.setViewportSize({ width: 390, height: 700 }); + await page.evaluate(variant => window.fixture.layout({ width: 350, variant }), variant); + await start(); + const overflow = await page.locator('[data-speech-recording]').evaluate(node => node.scrollWidth > node.clientWidth + 1); + assert.equal(overflow, false); + await page.locator('[data-fixture-composer]').screenshot({ path: path.join(output, 'mobile-' + variant + '.png') }); + await page.getByRole('button', { name: '取消听写' }).click(); + await idle(); + } + await page.setViewportSize({width:1100,height:850}); + await page.evaluate(()=>window.fixture.layout({width:900,variant:'default'})); + const microphone=page.getByRole('button',{name:'听写',exact:true}); + assert.equal(await microphone.getAttribute('title'),'右键配置麦克风'); + await microphone.click({button:'right'}); + const microphoneMenu=page.getByRole('menu',{name:'麦克风配置'}); + await microphoneMenu.waitFor(); + await microphoneMenu.locator('[data-microphone-device]').first().waitFor(); + assert.equal(await microphoneMenu.getByText('Language',{exact:true}).count(),0); + assert.equal(await microphoneMenu.getByText('识别语言',{exact:true}).count(),0); + const device=await microphoneMenu.locator('[data-microphone-device]').evaluateAll(items=>(items.find(item=>item.dataset.microphoneDevice!=='default')||items[0]).dataset.microphoneDevice); + await microphoneMenu.locator('[data-microphone-device]').evaluateAll((items,id)=>items.find(item=>item.dataset.microphoneDevice===id).click(),device); + await microphone.click({button:'right'}); + await microphoneMenu.locator('[aria-checked="true"][data-microphone-device]').waitFor(); + assert.equal(await microphoneMenu.locator('[aria-checked="true"][data-microphone-device]').getAttribute('data-microphone-device'),device); + await microphoneMenu.screenshot({path:path.join(output,'microphone-menu.png')}); + await page.keyboard.press('Escape'); + await page.keyboard.press('Control+d'); + await page.locator('[data-speech-recording="recording"]').waitFor(); + await page.waitForFunction(()=>window.speechAudioRequests.some(request=>request.audio?.deviceId)); + assert.equal(await page.evaluate(()=>window.speechAudioRequests.at(-1).audio.deviceId?.exact),device); + await page.waitForFunction(()=>[...document.querySelectorAll('[data-speech-wave] span')].some(node=>parseFloat(node.style.height)>3.01)); + await page.keyboard.press('Control+d'); + await idle(); + await page.getByRole('button',{name:'打开快捷键设置',exact:true}).click(); + await page.getByRole('button',{name:'修改快捷键:开始/停止录音',exact:true}).click(); + await page.getByLabel('录入快捷键',{exact:true}).press('Control+Alt+u'); + assert.equal(await page.locator('[data-speech-recording]').count(),0,'shortcut recorder must not start audio'); + await page.getByRole('button',{name:'保存',exact:true}).click(); + await page.evaluate(()=>window.fixture.settings(false)); + await page.keyboard.press('Control+Alt+u'); + await page.locator('[data-speech-recording="recording"]').waitFor(); + await page.getByRole('button',{name:'取消听写'}).click();await idle(); + await microphone.click({button:'right'}); + await microphoneMenu.getByRole('switch',{name:'按住录音'}).click(); + await page.keyboard.press('Escape'); + await page.keyboard.down('Control');await page.keyboard.down('Alt');await page.keyboard.down('u'); + await page.locator('[data-speech-recording="recording"]').waitFor(); + await page.waitForFunction(()=>[...document.querySelectorAll('[data-speech-wave] span')].some(node=>parseFloat(node.style.height)>3.01)); + await page.keyboard.up('u');await page.keyboard.up('Alt');await page.keyboard.up('Control');await idle(); + const microphoneBounds=await microphone.boundingBox(); + await page.mouse.move(microphoneBounds.x+microphoneBounds.width/2,microphoneBounds.y+microphoneBounds.height/2); + await page.mouse.down();await page.locator('[data-speech-recording="recording"]').waitFor(); + await page.waitForFunction(()=>[...document.querySelectorAll('[data-speech-wave] span')].some(node=>parseFloat(node.style.height)>3.01)); + await page.mouse.up();await idle(); + await page.keyboard.down('Control');await page.keyboard.down('Alt');await page.keyboard.down('u'); + await page.locator('[data-speech-recording="recording"]').waitFor(); + await page.waitForFunction(()=>[...document.querySelectorAll('[data-speech-wave] span')].some(node=>parseFloat(node.style.height)>3.01)); + await page.evaluate(()=>window.dispatchEvent(new Event('blur')));await idle(); + await page.keyboard.up('u');await page.keyboard.up('Alt');await page.keyboard.up('Control'); + const uploadCount=await page.evaluate(()=>window.fixture.uploads.length); + await page.evaluate(()=>{window.holdMicrophonePermission=true;}); + await page.keyboard.down('Control');await page.keyboard.down('Alt');await page.keyboard.down('u'); + await page.waitForFunction(()=>!!window.releaseMicrophonePermission); + await page.keyboard.up('u');await page.keyboard.up('Alt');await page.keyboard.up('Control');await idle(); + await page.evaluate(()=>{window.holdMicrophonePermission=false;window.releaseMicrophonePermission();}); + await page.waitForFunction(()=>window.voicePendingStream.getTracks().every(track=>track.readyState==='ended')); + assert.equal(await page.evaluate(()=>window.fixture.uploads.length),uploadCount,'releasing before permission cannot upload'); + await microphone.click({button:'right'}); + await microphoneMenu.getByRole('switch',{name:'按住录音'}).click(); + await page.keyboard.press('Escape'); + await page.evaluate(() => { window.fixture.reset('设置变更保留'); window.fixture.hold=true; }); + await start(); + await page.getByRole('button', { name: '识别并发送' }).click(); + await page.waitForFunction(() => !!window.fixture.pending); + await page.evaluate(() => window.fixture.disable()); + await page.locator('[data-composer-speech]').waitFor({ state: 'detached' }); + await page.evaluate(() => window.fixture.release()); + await page.locator('[data-speech-recording]').waitFor({ state: 'detached' }); + assert.equal(await editor.innerText(), '设置变更保留'); + assert.equal(await page.evaluate(() => window.fixture.sent.length), 2); + assert.deepEqual(errors, []); + console.log('[pass] autosave, device selection, editable shortcuts, hold/release/blur, late permission, stable layout, real WAV, stop/send/cancel, session isolation and tags'); + console.log('Artifacts: ' + output); +} catch (error) { + console.error('Fixture state:', await page?.evaluate(() => ({ + sent: window.fixture?.sent, state: window.fixture?.state, content: window.fixture?.content(), + uploads: window.fixture?.uploads, cancelled: window.fixture?.cancelled, + })).catch(() => null)); + await page?.screenshot({ path: path.join(output, 'failure.png'), fullPage: true }).catch(() => {}); + console.error('Artifacts: ' + output, 'Page errors:', errors); + throw error; +} finally { + await browser?.close(); + await server.close(); +} diff --git a/web/src/components/ConsoleDock.jsx b/web/src/components/ConsoleDock.jsx index 8bf613558..d957a0ee1 100644 --- a/web/src/components/ConsoleDock.jsx +++ b/web/src/components/ConsoleDock.jsx @@ -267,7 +267,7 @@ export function ConsoleDock({ owner, open, height: preferredHeight, onHeightChan // 让位给应用快捷键,与 VS Code 集成终端默认行为一致。 term.attachCustomKeyEventHandler((ev) => { const shortcut = matchAppShortcut(ev); - if (shortcut && !['stop', 'reasoningUp', 'reasoningDown'].includes(shortcut)) return false; + if (shortcut && !['stop', 'reasoningUp', 'reasoningDown', 'dictation'].includes(shortcut)) return false; return true; }); term.onData((data) => { diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index 070d51370..fe21c87f7 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -11,6 +11,7 @@ const ICONS = { arrowRight: 'ArrowRight', back: 'Backwards', brain: 'Brain', + microphone: 'Microphone', sparkle: 'Sparkle', brightness: 'Brightness', bug: 'Bug', diff --git a/web/src/components/InputBar.jsx b/web/src/components/InputBar.jsx index 372af7626..8855da0ed 100644 --- a/web/src/components/InputBar.jsx +++ b/web/src/components/InputBar.jsx @@ -23,6 +23,11 @@ import { ImageLightbox } from './ImageLightbox.jsx'; import { SwarmModeIcon } from './SwarmModeIcon.jsx'; import { normalizeSwarmMode, pickSwarmMode, swarmModeLabel } from '../lib/swarmMode.js'; import { RichComposer } from './RichComposer.jsx'; +import { SpeechRecordingControls } from './SpeechRecordingControls.jsx'; +import { SpeechMicrophoneButton } from './SpeechMicrophoneButton.jsx'; +import { api as defaultApi } from '../lib/api.js'; +import { useSpeechRecognition } from '../lib/useSpeechRecognition.js'; +import { speechErrorMessage } from '../lib/speechRecognition.js'; import { PastedTextCard } from './PastedTextCard.jsx'; import { PastedTextDialog } from './PastedTextDialog.jsx'; import { PathReferenceDropdown } from './PathReferenceDropdown.jsx'; @@ -617,14 +622,19 @@ export const InputBar = forwardRef(function InputBar({ }; const submit = () => { - if (!actionState.canSubmit) return; + if (speech.interceptSubmit()) return; + // Recognition publishes editor refs before React necessarily renders the + // new draft. Gate and send the same current snapshot, not the old button state. + const currentDraft = serializeComposerGoal(valueRef.current, contentRef.current, goalModeRef.current); + const currentAction = getInputBarActionState({ value: currentDraft.text, disabled, busy, hasExtras, submitting, canRetryLastUserMessage, queuePaused }); + if (!currentAction.canSubmit) return; // 队列暂停 + 空输入框:这一下是「继续」,不是发送 —— 空内容本来也没什么可发。 - if (actionState.mode === 'resume') { + if (currentAction.mode === 'resume') { onResumeQueue?.(); requestAnimationFrame(() => ta.current?.focus()); return; } - onSubmit?.(serializeComposerGoal(valueRef.current, contentRef.current, goalModeRef.current).text); + onSubmit?.(currentDraft.text); if (!isControlled) updateValue('', composerContentFromText(''), undefined, { goalMode: false }); setHistPtr(-1); setEditedSinceHistory(false); @@ -1287,6 +1297,11 @@ export const InputBar = forwardRef(function InputBar({ }, []); const onKey = (e) => { + if (speech.active && isDismissShortcut(e)) { + e.preventDefault(); + speech.cancel(); + return true; + } // 下拉打开时,Enter / Tab / Esc / 方向键 由 SlashDropdown 在捕获阶段处理。 // 这里只处理常规情况。 if (shouldNavigateInputHistory({ @@ -1333,6 +1348,12 @@ export const InputBar = forwardRef(function InputBar({ }; const actionState = getInputBarActionState({ value: draftValue, disabled, busy, hasExtras, submitting, canRetryLastUserMessage, queuePaused }); + const speech = useSpeechRecognition({ + client: pathReferenceApi || defaultApi, scope: fileIntakeScope, editorRef: ta, rootRef, mainComposer, + disabled: !!disabled || !!submitting, + onSend: () => submit(), + onError: (error) => toast({ kind: 'err', text: speechErrorMessage(error) }), + }); const stopControl = getGoalStopControlState({ busy, stopping }); const isStopAction = actionState.mode === 'stop'; const primaryActionLabel = t(isStopAction && stopping ? stopControl.label : actionState.submitLabel); @@ -1534,6 +1555,9 @@ export const InputBar = forwardRef(function InputBar({ ) : null; const submitControls = ( + <> + {speech.available && { setCapabilityOpen(false); setDropdownClosed(true); }} />} + ); return ( @@ -1581,6 +1606,7 @@ export const InputBar = forwardRef(function InputBar({ dragActive && 'is-drag-active', )} ref={rootRef} + data-speech-composer="true" data-main-composer={mainComposer ? 'true' : undefined} data-native-file-drop-disabled={disabled ? 'true' : undefined} onPointerDownCapture={(event) => preserveComposerFocusOnPointerDown(event, rootRef.current)} @@ -1774,6 +1800,8 @@ export const InputBar = forwardRef(function InputBar({ }} /> +
+
+
+ {speech.active &&
+ +
} +
setAttachmentPreview(null)} /> {openPasteCard ? ( diff --git a/web/src/components/SettingsPage.jsx b/web/src/components/SettingsPage.jsx index 345e6167a..5ea48a83d 100644 --- a/web/src/components/SettingsPage.jsx +++ b/web/src/components/SettingsPage.jsx @@ -53,6 +53,7 @@ import { import { Modal, Toggle } from './Modal.jsx'; import { ModelSettingsSection } from './model-settings/ModelSettingsSection.jsx'; import { ImageGenerationSettings } from './ImageGenerationSettings.jsx'; +import { SpeechRecognitionSettings } from './SpeechRecognitionSettings.jsx'; import { ComputerUseSettings } from './ComputerUseSettings.jsx'; import { IosSimulatorSettings } from './IosSimulatorSettings.jsx'; import { SummaryGenerationSettings } from './SummaryGenerationSettings.jsx'; @@ -70,9 +71,11 @@ import { PERMISSION_MODES, normalizePermissionMode } from '../lib/permissionMode import { sessionDisplayTitle } from '../lib/sessionTitle.js'; import { allArchivedSessionsSelected, + ARCHIVED_SESSION_BATCH_SIZE, archivedSessionKey, archivedSessionTarget, groupArchivedSessions, + paginateArchivedSessionGroups, removeArchivedSessionsByKey, retainArchivedSessionSelection, selectedArchivedSessions, @@ -2552,6 +2555,7 @@ function SectionTools({ onCheckUpdates, onModelProfileUpdated }) { + @@ -2820,6 +2824,7 @@ function HookBadge({ hook }) { // ─── 已归档会话 ──────────────────────────────────────────────────────────── function SectionArchived() { const [list, setList] = useState([]); + const [externalArchiveRevision, setExternalArchiveRevision] = useState(0); const [workspaces, setWorkspaces] = useState([]); const [search, setSearch] = useState(''); const [workspaceKey, setWorkspaceKey] = useState(''); @@ -2830,10 +2835,33 @@ function SectionArchived() { const [unarchivingKeys, setUnarchivingKeys] = useState(() => new Set()); const [deletingKeys, setDeletingKeys] = useState(() => new Set()); const [purgeConfirmation, setPurgeConfirmation] = useState(null); + const [pagination, setPagination] = useState({ key: '', limit: ARCHIVED_SESSION_BATCH_SIZE }); + const archiveFiltersRef = useRef(null); + const loadMoreRef = useRef(null); + const paginationKey = JSON.stringify([search, workspaceKey, sortOrder]); + // Derive the reset synchronously so a filter change never mounts an old, + // potentially very large page before the effect runs. + const visibleLimit = pagination.key === paginationKey ? pagination.limit : ARCHIVED_SESSION_BATCH_SIZE; + + useEffect(() => { + setPagination({ key: paginationKey, limit: ARCHIVED_SESSION_BATCH_SIZE }); + const root = archiveFiltersRef.current?.closest('.ace-settings-content'); + if (root) root.scrollTop = 0; + }, [paginationKey]); + + useEffect(() => { + const handler = (event) => { + // Sidebar toast undo may run while this page is open. This section's own + // actions already update its rows, so only reload for external actions. + if (event.detail?.source) setExternalArchiveRevision((revision) => revision + 1); + }; + window.addEventListener('ace-session-archive-changed', handler); + return () => window.removeEventListener('ace-session-archive-changed', handler); + }, []); useEffect(() => { let cancelled = false; - setLoading(true); + if (externalArchiveRevision === 0) setLoading(true); setError(''); api.listAllArchivedSessions() .then(async (result) => { @@ -2863,15 +2891,49 @@ function SectionArchived() { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, []); + }, [externalArchiveRevision]); - const groups = useMemo( + const allGroups = useMemo( () => groupArchivedSessions(list, { query: search, workspaceKey, sortOrder, workspaceOrder: workspaces.map((workspace) => workspace.key), }), [list, search, workspaceKey, sortOrder, workspaces], ); + const { groups, hasMore } = useMemo( + () => paginateArchivedSessionGroups(allGroups, visibleLimit), + [allGroups, visibleLimit], + ); + useEffect(() => { + const sentinel = loadMoreRef.current; + const root = archiveFiltersRef.current?.closest('.ace-settings-content'); + if (!hasMore || loading || error || !sentinel || !root) return undefined; + let advanced = false; + const loadMore = () => { + if (advanced) return; + advanced = true; + setPagination({ key: paginationKey, limit: visibleLimit + ARCHIVED_SESSION_BATCH_SIZE }); + }; + if (typeof IntersectionObserver === 'function') { + const observer = new IntersectionObserver((entries) => { + if (entries.some((entry) => entry.isIntersecting)) loadMore(); + }, { root, rootMargin: '0px 0px 160px 0px' }); + observer.observe(sentinel); + return () => { advanced = true; observer.disconnect(); }; + } + // Older embedded browsers can use the same scroll root without an observer. + const onScroll = () => { + if (sentinel.getBoundingClientRect().top <= root.getBoundingClientRect().bottom + 160) loadMore(); + }; + root.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll); + onScroll(); + return () => { + advanced = true; + root.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onScroll); + }; + }, [hasMore, loading, error, paginationKey, visibleLimit]); const visibleItems = useMemo(() => groups.flatMap((group) => group.items), [groups]); const workspaceOptions = useMemo(() => workspaces.map((workspace) => ({ ...workspace, @@ -3122,7 +3184,7 @@ function SectionArchived() { {/* Match the scroll container's responsive top padding so no list content shows above the sticky bar. */} -
+
    {group.items.map((item) => { @@ -3255,6 +3317,7 @@ function SectionArchived() {
))} + {hasMore && )} {purgeConfirmation && ( diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index e0e6ef348..27a379425 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -1870,6 +1870,7 @@ export function Sidebar({ const refreshSelectionRef = useRef(null); const refreshingRef = useRef(false); const pendingRefreshHashRef = useRef(''); + const archiveOperationsRef = useRef(new Set()); const expandedRef = useRef(new Set()); const sessionLoadedWorkspacesRef = useRef(new Set()); const sessionFullyLoadedWorkspacesRef = useRef(new Set()); @@ -2593,7 +2594,7 @@ export function Sidebar({ }, [togglePinnedSession]); const refresh = useCallback(async (preferredHash = null) => { - if (treeSavingRef.current) return; + if (treeSavingRef.current || archiveOperationsRef.current.size > 0) return; const treeRevision = treeRevisionRef.current; const { activeWorkspaceHash, revealTarget } = refreshSelectionRef.current; const revealWorkspaceHash = revealTarget.noWorkspace ? '' : revealTarget.workspaceHash; @@ -2631,6 +2632,7 @@ export function Sidebar({ workspaceArr = [{ hash: '__local__', cwd: '', name: '当前会话', daemon_state: 'running', active: true }]; } + if (treeRevision !== treeRevisionRef.current) return; const availableHashes = new Set(workspaceArr.map((w) => w.hash).filter(Boolean)); const canAutoExpandWorkspace = (hash) => allowSidebarWorkspaceAutoExpand(hash, { @@ -2700,7 +2702,7 @@ export function Sidebar({ sequence, query, result: settleSidebarWorkspacePage(loadWorkspacePage(workspace, query)).then((result) => { - if (result.ok && sidebarWorkspacePageIsCurrent( + if (result.ok && treeRevision === treeRevisionRef.current && sidebarWorkspacePageIsCurrent( workspaceSessionLoadSeqRef.current.get(workspace.hash), sequence, )) { applyWorkspaceSessionList(workspace, result.page); @@ -2741,6 +2743,7 @@ export function Sidebar({ pinnedByWorkspaceRef.current.get(NO_WORKSPACE_PIN_SCOPE) || [], )), ]); + if (treeRevision !== treeRevisionRef.current) return; const nextPinnedMap = new Map(); for (const [hash, ids] of pinnedPairs) { if (hash && ids.length) nextPinnedMap.set(hash, ids); @@ -2751,6 +2754,7 @@ export function Sidebar({ setPinnedMap(nextPinnedMap); try { const orderState = await api.getPinnedSessionOrder(); + if (treeRevision !== treeRevisionRef.current) return; setPinnedOrder(normalizePinnedOrderItems(orderState?.items || [])); } catch { setPinnedOrder(pinnedOrderItemsRef.current); @@ -2812,11 +2816,12 @@ export function Sidebar({ })); // 所有等待结束后再检查代次,不能让这里较慢的请求造成旧摘要回写。 const noWorkspaceResponse = await noWorkspaceListPromise; + if (treeRevision !== treeRevisionRef.current) return; const noWorkspaceRaw = treeRevision === treeRevisionRef.current && noWorkspaceResponse ? normalizeWorkspaceSessionListResponse(noWorkspaceResponse) : null; if (noWorkspaceRaw) acceptSidebarTreePage(NO_WORKSPACE_PIN_SCOPE, noWorkspaceRaw); const perWorkspace = settledPages.flatMap(({ workspace, sequence, result }) => { - if (!result.ok) return []; + if (!result.ok || treeRevision !== treeRevisionRef.current) return []; if (sequence !== undefined && !sidebarWorkspacePageIsCurrent( workspaceSessionLoadSeqRef.current.get(workspace.hash), sequence, )) return []; @@ -2876,6 +2881,42 @@ export function Sidebar({ }, [acceptSidebarTreePage, applyWorkspaceSessionList, markWorkspaceSessionsFullyLoaded, refreshOpencodeImportPreview, setPinnedMap, setPinnedOrder, setSessionWorkspaceLoading, setSessionWorkspacesLoaded, sidebarTreeOptions, syncRetainedSessionIds, updateExpanded]); workspaceOrderRefreshRef.current = refresh; + const invalidateArchiveLists = useCallback((scope) => { + // Archive mutates the tree as well as the session list. Old polling and + // disclosure requests must not resurrect a row after the mutation. + treeRevisionRef.current += 1; + workspaceSessionLoadSeqRef.current.set(scope, + (workspaceSessionLoadSeqRef.current.get(scope) || 0) + 1); + }, []); + + const refreshArchivedScope = useCallback(async (scope) => { + const sequence = (workspaceSessionLoadSeqRef.current.get(scope) || 0) + 1; + workspaceSessionLoadSeqRef.current.set(scope, sequence); + const isCurrent = () => workspaceSessionLoadSeqRef.current.get(scope) === sequence; + const noWorkspace = scope === NO_WORKSPACE_PIN_SCOPE; + const query = sidebarWorkspaceSessionListQuery({ + visibleCount: expandedSessionListsRef.current.get(scope), + pinnedIds: pinnedByWorkspaceRef.current.get(scope), + }); + try { + const data = await api.listSidebarSessions(noWorkspace ? '' : scope, + sidebarTreeOptions(scope, noWorkspace ? 0 : query.limit)); + if (isCurrent()) treeApplyRef.current(scope, normalizeWorkspaceSessionListResponse(data)); + // A newer request owns the final state if this request was superseded. + return true; + } catch { + return !isCurrent(); + } finally { + if (isCurrent()) setSessionWorkspaceLoading([scope], false); + } + }, [setSessionWorkspaceLoading, sidebarTreeOptions]); + + const notifyArchiveChanged = useCallback(() => { + window.dispatchEvent(new CustomEvent('ace-session-archive-changed', { + detail: { source: archiveOperationsRef.current }, + })); + }, []); + const archiveSession = useCallback(async (session) => { const id = session?.id || session?.sessionId || session?.session_id || ''; const noWorkspace = isNoWorkspaceSession(session); @@ -2883,10 +2924,17 @@ export function Sidebar({ const pinScope = noWorkspace ? NO_WORKSPACE_PIN_SCOPE : workspaceHash; if (!id) return; const loadKey = sidebarSessionLoadKey({ sessionId: id, workspaceHash, noWorkspace }); + if (archiveOperationsRef.current.has(loadKey)) return; + archiveOperationsRef.current.add(loadKey); sessionLoadPoolRef.current?.invalidate(loadKey); if (sessionSelectionIntentRef.current?.loadKey === loadKey) { cancelSessionSelection(); } + const removeArchivedRow = () => setSessions((prev) => prev.filter((item) => ( + sidebarSessionLoadKey({ sessionId: item.id || item.session_id || item.sessionId, + workspaceHash: item.workspace_hash || item.workspaceHash || '', + noWorkspace: isNoWorkspaceSession(item) }) !== loadKey + ))); try { if (workspaceHash && workspaceHash !== '__local__') { @@ -2894,9 +2942,13 @@ export function Sidebar({ } else { await api.archiveSession(id); } + invalidateArchiveLists(pinScope); const previousPinned = normalizePinnedIds(pinnedByWorkspaceRef.current.get(pinScope) || []); if (pinScope && previousPinned.includes(id)) { + // Pinned rows have no backfill. Remove the row together with its pin so + // it never flashes in the ordinary list while pin persistence waits. + removeArchivedRow(); const nextPinned = unpinSessionId(previousPinned, id); const previousOrder = normalizePinnedOrderItems(pinnedOrderItemsRef.current); const nextOrder = unpinPinnedOrderItem(previousOrder, { workspace_hash: pinScope, session_id: id }); @@ -2913,8 +2965,12 @@ export function Sidebar({ } desktopTaskbarBadge.removeSession(id); - setSessions((prev) => prev.filter((item) => (item.id || item.session_id || item.sessionId) !== id)); - if (id === activeId) { + // Replace the page in one render, including its backfill row. Removing + // locally first makes every subsequent workspace move up and then down. + if (!await refreshArchivedScope(pinScope)) { + removeArchivedRow(); + } + if (sessionMatchesRevealTarget(session, treeNavigationRef.current)) { onOpenHome?.(noWorkspace ? { noWorkspace: true, name: '不使用工作区' } : { @@ -2924,13 +2980,29 @@ export function Sidebar({ name: session.workspaceName || session.workspace_name || '', }); } - toast({ kind: 'ok', text: '已归档' }); - window.dispatchEvent(new Event('ace-session-archive-changed')); - refresh(workspaceHash).catch(() => {}); + toast({ kind: 'ok', icon: 'archive', text: tr('已归档的聊天'), duration: 8000, dismissible: true, actions: [ + { label: tr('查看'), onClick: () => onOpenSettingsSection?.('archived') }, + { + label: tr('撤销'), primary: true, errorText: tr('撤销归档失败:'), + onClick: async () => { + const restored = workspaceHash && workspaceHash !== '__local__' + ? await api.unarchiveWorkspaceSession(workspaceHash, id) + : await api.unarchiveSession(id); + invalidateArchiveLists(pinScope); + if (!await refreshArchivedScope(pinScope)) { + setSessions((prev) => upsertSidebarSession(prev, { ...session, ...restored, archived: false })); + } + notifyArchiveChanged(); + }, + }, + ] }); + notifyArchiveChanged(); } catch (e) { toast({ kind: 'err', text: '归档失败:' + (e.message || '') }); + } finally { + archiveOperationsRef.current.delete(loadKey); } - }, [activeId, activeWorkspaceHash, cancelSessionSelection, onOpenHome, refresh, savePinnedScopeIds, setPinnedOrder, setPinnedWorkspaceIds]); + }, [activeWorkspaceHash, cancelSessionSelection, invalidateArchiveLists, notifyArchiveChanged, onOpenHome, onOpenSettingsSection, refreshArchivedScope, savePinnedScopeIds, setPinnedOrder, setPinnedWorkspaceIds]); const renameSession = useCallback(async (session, title) => { const id = session?.id || session?.sessionId || session?.session_id || ''; @@ -2968,7 +3040,10 @@ export function Sidebar({ }, [refresh, revealTarget.workspaceHash, revealTarget.noWorkspace]); useEffect(() => { - const handler = () => refresh().catch(() => {}); + const handler = (event) => { + if (event.detail?.source === archiveOperationsRef.current) return; + refresh().catch(() => {}); + }; window.addEventListener('ace-session-archive-changed', handler); return () => window.removeEventListener('ace-session-archive-changed', handler); }, [refresh]); diff --git a/web/src/components/SpeechMicrophoneButton.jsx b/web/src/components/SpeechMicrophoneButton.jsx new file mode 100644 index 000000000..0f5524eb1 --- /dev/null +++ b/web/src/components/SpeechMicrophoneButton.jsx @@ -0,0 +1,76 @@ +import { useEffect, useRef, useState } from 'react'; +import { AnchoredMenu } from './AnchoredMenu.jsx'; +import { Toggle } from './Modal.jsx'; +import { VsIcon } from './Icon.jsx'; +import { listSpeechDevices } from '../lib/speechDevices.js'; +import { speechErrorMessage } from '../lib/speechRecognition.js'; + +function MicrophoneMenu({ speech, anchorRef, onClose }) { + const [devices, setDevices] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [retry, setRetry] = useState(0); + useEffect(() => { + let current; + const load = (requestPermission) => { + current?.abort(); + const request = new AbortController(); current = request; + setLoading(true); setError(null); + void listSpeechDevices({ signal: request.signal, requestPermission }) + .then((items) => { if (!request.signal.aborted) setDevices(items); }) + .catch((failure) => { if (!request.signal.aborted) setError(failure); }) + .finally(() => { if (!request.signal.aborted) setLoading(false); }); + }; + load(true); + const changed = () => load(false); + navigator.mediaDevices?.addEventListener?.('devicechange', changed); + return () => { current?.abort(); navigator.mediaDevices?.removeEventListener?.('devicechange', changed); }; + }, [retry]); + return +
麦克风
+ {loading &&
加载中
} + {error &&
+ {speechErrorMessage(error)} +
} + {devices.map((device, index) => )} +
+ 按住录音 + speech.configureInput({ holdToRecord })} /> +
+
; +} + +export function SpeechMicrophoneButton({ speech, disabled, onBeforeStart }) { + const anchor = useRef(null); + const [open, setOpen] = useState(false); + useEffect(() => { if (disabled || speech.active || !speech.available) setOpen(false); }, [disabled, speech.active, speech.available]); + const beginHold = (kind) => { onBeforeStart?.(); speech.beginHold(kind); }; + return <> + + {open && setOpen(false)} />} + ; +} diff --git a/web/src/components/SpeechRecognitionSettings.jsx b/web/src/components/SpeechRecognitionSettings.jsx new file mode 100644 index 000000000..3e06058b7 --- /dev/null +++ b/web/src/components/SpeechRecognitionSettings.jsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { api } from '../lib/api.js'; +import { speechDraftValid, speechErrorMessage, speechPayload, speechSettingsStore } from '../lib/speechRecognition.js'; +import { Toggle } from './Modal.jsx'; +import { VsIcon } from './Icon.jsx'; +import { toast } from './Toast.jsx'; + +const fieldClass = 'w-full h-8 px-2 text-[12px] rounded-md border border-border bg-surface-alt text-fg outline-none focus:border-accent disabled:opacity-50'; +const buttonClass = 'px-3 py-1.5 text-[12px] rounded-md border border-border bg-surface-alt hover:bg-surface-hi disabled:opacity-50'; + +export function SpeechRecognitionSettings() { + const store = speechSettingsStore(api); + const { draft, loading, saving, error } = useSyncExternalStore(store.subscribe, store.getSnapshot); + const [expanded, setExpanded] = useState(false); + const [testing, setTesting] = useState(false); + const [testError, setTestError] = useState(null); + const mounted = useRef(false); + const pendingTest = useRef(false); + useEffect(() => { + mounted.current = true; + void store.load(); + const flush = () => { void store.flush(); }; + window.addEventListener('blur', flush); + window.addEventListener('pagehide', flush); + return () => { + mounted.current = false; + window.removeEventListener('blur', flush); + window.removeEventListener('pagehide', flush); + void store.flush().then((ok) => { + if (!ok) toast({ kind: 'err', text: speechErrorMessage(store.getSnapshot().error) }); + }); + }; + }, [store]); + const update = (field, value, immediate = false) => { + setTestError(null); store.update(field, value); + if (immediate) void store.flush(); + }; + const saveOnBlur = () => { void store.flush(); }; + const test = async () => { + if (pendingTest.current) return; + pendingTest.current = true; + setTesting(true); setTestError(null); + try { + if (!await store.flush() || !mounted.current) return; + const current = store.getSnapshot().draft; + if (!speechDraftValid(current, true)) { setTestError({ code: 'SPEECH_INVALID_CONFIG' }); return; } + await api.testSpeechRecognition(speechPayload(current)); + if (mounted.current) toast({ kind: 'ok', text: '语音识别连接正常' }); + } catch (failure) { if (mounted.current) setTestError(failure); } + finally { pendingTest.current = false; if (mounted.current) setTesting(false); } + }; + return
+
+
+ +
+
语音识别
+ + update('enabled', enabled, true)} /> +
+ {(error || testError) &&
+ {speechErrorMessage(testError || error)} + {error && } +
} + {expanded && draft &&
{ event.preventDefault(); saveOnBlur(); }} + onKeyDown={(event) => { + if (event.key !== 'Enter' || event.target.tagName !== 'INPUT' || event.nativeEvent.isComposing || event.keyCode === 229) return; + event.preventDefault(); saveOnBlur(); + }}> +
+ + + {draft.protocol === 'openai' && } + +
+ 高级设置 +
+ + +
+ 使用 ACECode 代理 + update('use_proxy', value, true)} /> +
+
+
+
+ +
+
+
} +
; +} diff --git a/web/src/components/SpeechRecordingControls.jsx b/web/src/components/SpeechRecordingControls.jsx new file mode 100644 index 000000000..0fc3f02bb --- /dev/null +++ b/web/src/components/SpeechRecordingControls.jsx @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react'; +import { VsIcon } from './Icon.jsx'; + +export function SpeechRecordingControls({ speech, className = '' }) { + const wave = useRef(null); + const [count, setCount] = useState(48); + useEffect(() => { + const measure = () => setCount(Math.max(8, Math.min(180, Math.floor((wave.current?.clientWidth || 288) / 6)))); + measure(); + if (typeof ResizeObserver !== 'function') return undefined; + const observer = new ResizeObserver(measure); + observer.observe(wave.current); + return () => observer.disconnect(); + }, []); + const levels = [...Array(count).fill(0), ...speech.levels].slice(-count); + const recording = speech.phase === 'recording'; + const button = 'w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-fg hover:bg-surface-hi focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-40 disabled:cursor-default'; + return
+ +
+ {recording ? levels.map((value, index) =>
+ + +
; +} diff --git a/web/src/components/Toast.jsx b/web/src/components/Toast.jsx index d30125415..cd2f69d38 100644 --- a/web/src/components/Toast.jsx +++ b/web/src/components/Toast.jsx @@ -1,27 +1,110 @@ // 极简 toast:模块级订阅,任何地方 import { toast } 触发,Toaster 是渲染容器。 // 不引入 react-hot-toast 等依赖。 -import { useEffect, useLayoutEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { clsx } from '../lib/format.js'; import { notifyNativeSurfaceOverlayChange } from '../lib/agentBrowserSurfaceCoordinator.js'; +import { tr } from '../i18n/index.js'; import { VsIcon } from './Icon.jsx'; const listeners = new Set(); let nextId = 1; -export function toast({ kind = 'info', text, duration = 3500 }) { +export function toast({ kind = 'info', text, duration = 3500, actions = [], icon, dismissible = false }) { const id = nextId++; - const item = { id, kind, text, duration }; + const item = { id, kind, text, duration, actions, icon, dismissible }; for (const fn of listeners) fn({ type: 'add', item }); - if (duration > 0) { - setTimeout(() => { - for (const fn of listeners) fn({ type: 'remove', id }); - }, duration); - } + if (duration > 0 && actions.length === 0) setTimeout(() => dismissToast(id), duration); return id; } +function dismissToast(id) { + for (const fn of listeners) fn({ type: 'remove', id }); +} + +function ToastItem({ item: it }) { + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + const [busy, setBusy] = useState(false); + const runningRef = useRef(false); + const hasActions = it.actions.length > 0; + + useEffect(() => { + if (!hasActions || it.duration <= 0 || hovered || focused || busy) return undefined; + const timer = setTimeout(() => dismissToast(it.id), it.duration); + return () => clearTimeout(timer); + }, [hasActions, it.id, it.duration, hovered, focused, busy]); + + const runAction = async (action) => { + if (runningRef.current) return; + runningRef.current = true; + setBusy(true); + try { + await action.onClick(); + dismissToast(it.id); + } catch (error) { + toast({ kind: 'err', text: `${action.errorText || ''}${error?.message || error}` }); + } finally { + runningRef.current = false; + setBusy(false); + } + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocusCapture={() => setFocused(true)} + onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false); }} + > + + {it.text} + {(hasActions || it.dismissible) && ( + + {it.actions.map((action) => ( + + ))} + {it.dismissible && ( + + )} + + )} +
+ ); +} + export function Toaster() { const [items, setItems] = useState([]); @@ -45,28 +128,7 @@ export function Toaster() { className="pointer-events-none fixed left-1/2 top-6 z-[2147483647] flex w-max max-w-[min(480px,calc(100vw-32px))] -translate-x-1/2 flex-col gap-2" data-ace-native-overlay="overlap" > - {items.map((it) => ( -
- - {it.text} -
- ))} + {items.map((it) => )}
); diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index cee881ca7..29153d939 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -29,6 +29,7 @@ export const sourceCatalogs = { "s_0215ff0b3cce53f1": "反馈内容", "s_0220aafc260ee131": "启用 hooks 功能后,已配置的钩子会显示在此处", "s_025486bcb86499e3": "请选择摘要模型", + "s_02739d1bae8bd996": "服务地址", "s_027be81bc401b0cd": "连接失败,原因已写入日志。确认凭据无误后重试,或回到上一步更换。", "s_029d7b3375ad16aa": "写入规则文件", "s_02b411ce6d16f108": "动态上下文", @@ -40,8 +41,10 @@ export const sourceCatalogs = { "s_03b4c3a9f038e956": "最近 1 小时", "s_03ea92a14ed4272e": "保存电脑操控配置失败,请重试确认状态", "s_0407dbdaf0dd84d9": "打开系统设置", + "s_0444649fc3f1d0fe": "停止录音并插入文字", "s_04830193b408eeb1": "总结", "s_0488d8fddd952d28": "缓存写", + "s_04b77083689b0977": "系统默认", "s_04f8b878be976c24": "仅从桌面项目列表移除,不会删除磁盘文件。继续?", "s_0515f9f59c1cea69": "展开查看剩余 {{p0}} 个文件", "s_0517e800aaa941c0": "{{p0}}失败:{{p1}}", @@ -61,7 +64,9 @@ export const sourceCatalogs = { "s_0617557e7bbc153d": "浏览…", "s_0635261aef08149c": "请至少选择一个星期", "s_0635d2ebc22e01db": "开启后,Agent 可启动、查看并操作 Xcode 中的 iOS 模拟器;你也可以在预览面板中实时观看和操作", + "s_06490c9aac171faf": "正在识别语音", "s_06a32a9dfa382580": "进入一个磁盘或目录后再选择", + "s_06ae0fdfac65c03d": "撤销归档失败:", "s_06aece1ed77e02f6": "命中禁止规则", "s_06d7b2e31d301e2e": "上下文用量", "s_071089398bfdb584": "检测", @@ -112,6 +117,7 @@ export const sourceCatalogs = { "s_0a0af8a5218a1cdc": "这个目录没有可显示的项目", "s_0a6f82252d9788fb": "返回宫格", "s_0a880a169f51924d": "置顶排序失败:", + "s_0a98a48eaaac4175": "测试连接", "s_0a9a4724ab124b4b": "双列", "s_0aaf1f62f7a179c2": "国庆节", "s_0ab653adadfadb42": "读取 {{p0}}", @@ -175,13 +181,16 @@ export const sourceCatalogs = { "s_0ecd009b61757eae": "创建独立 worktree,不包含未提交的修改。", "s_0ef300e7bd9ba7eb": "Skill 扫描路径", "s_0f415f8b2bbbb37a": "读取运行记录失败:", + "s_0f634bef59a48ff9": "使用 ACECode 代理", "s_0f673a156d4bf981": "已开启 · {{p0}}", "s_0f7f6afb91e8922f": "移除引用", "s_0fbaf5c44a3d5a28": "在独立的 git worktree 中运行本会话(发送首条消息时创建)", "s_0fc55068f541c2cb": "升级安装完成,请完全退出并重新启动 ACECode", "s_0fc80b1f2c00b1cd": "拒绝修改文件", "s_0fdada0fc86aed7f": "复制失败:", + "s_0fdadddc66f59cee": "当前网页无法使用麦克风,请使用 ACECode Desktop、localhost 或 HTTPS 页面。", "s_0ffcfe1af05f8653": "已改为直接输入,取消作答", + "s_105a9082c346f958": "测试中…", "s_10ba22de4d7ddf30": "模拟器画面已中断", "s_10e80ac177155143": "消息不能为空", "s_111448a2e1a82306": "还有 {{p0}} 个字段", @@ -193,6 +202,7 @@ export const sourceCatalogs = { "s_117b19fd23b61edb": "按命令前缀决定放行、询问或禁止。前缀按空格分词,git status|diff 表示第二个词可以是任一个。「放行(沙箱外)」会跳过沙箱,只给确实需要的命令;解释器、shell、rm、sudo 这类前缀不能放行。", "s_117c113c3b45cf89": "排队", "s_1198cb9da295145d": "新建会话默认使用此模式,当前会话会同步切换", + "s_119eee7eb104a413": "没有找到可用的麦克风。", "s_11a4dc9fc810be2f": "机器人的消息接收模式选「Stream 模式」,在「权限管理」开通企业内机器人发送消息权限,然后发布应用。", "s_11a6ac8794688be8": "命中询问规则", "s_11b999fc4610de85": "联网", @@ -211,6 +221,7 @@ export const sourceCatalogs = { "s_129dadf0a8648147": "正在启动升级任务", "s_12b4e2c5cfff6fd0": "请在 Xcode 中下载 iOS 模拟器运行时后重试", "s_12c8d9f7a9aef51f": "本次会话允许: {{p0}}", + "s_12e6846dac1851cd": "取消听写", "s_130a39597133e8f9": "围绕当前会话继续聊,不会打断主任务,也不会加入主会话。", "s_132411ba41da7354": "推理", "s_132a029e79ec9872": "与智能体共享", @@ -279,9 +290,11 @@ export const sourceCatalogs = { "s_18435419f154847b": "命令前缀", "s_1843acb8c6ea0f47": "私信机器人或在服务器频道里 @它 下达指令,结果回到频道。", "s_184568b57e5e6d3f": "正在生成测试图片…", + "s_18461c6b047266b0": "语音模型尚未就绪,请稍后重试。", "s_1847a84db6d5fc1a": "权限模式切换失败:", "s_18565a5455a0a875": "加载沙箱设置失败", "s_18578640315b5b33": "更新待办", + "s_1878849ddfed4665": "speech voice dictation microphone sensevoice whisper 录音 听写 麦克风 内网 地址 端口", "s_1882a447d5f76d5f": "反馈内容不能超过 10000 字", "s_188397c445996182": "已由另一个 ACECode 连接", "s_18850bf66a4ba4c6": "模型申请临时加宽沙盒权限", @@ -334,6 +347,7 @@ export const sourceCatalogs = { "s_1c5a73e41a5f944a": "正在分叉到新会话…", "s_1c6c23d432da9200": "最近错误", "s_1c75124a3a48732d": "取消模型探测选择", + "s_1c7a0542c9844013": "语音服务地址", "s_1c8218d8e483fe16": "刷新模型列表", "s_1c8557bec7b84d8b": "删除定时任务", "s_1ca440a127bf12c9": "重置当前工作区记忆?", @@ -468,6 +482,7 @@ export const sourceCatalogs = { "s_28751566873e4520": "{{p0}} 屏幕", "s_28786d90697520ac": "删除预设", "s_2883d2978e52bcdf": "工作日 {{p0}}", + "s_288a6c217a9ca243": "最长录音(秒)", "s_2890fe9bbc1773b9": "未知记号(可用 :workspace_roots / :tmpdir / :acecode_home):{{p0}}", "s_289e6128fc7a4b46": "隐藏桌面宠物", "s_28aacfb2e3d8d395": "专家团名称", @@ -571,6 +586,7 @@ export const sourceCatalogs = { "s_32a202d6fed3a1da": "在此工作区新建任务", "s_33281591669db79f": "产品交付专家团", "s_33360315a592f985": "间隔单位", + "s_333eda18f13dbaad": "语音识别启用后生效,停止后回填文字", "s_334c06acffa15738": "专家切换结果与当前会话不一致,已同步为 {{p0}}", "s_334d7a58ba439bea": "YOLO 下命中拒绝规则", "s_33517926747180e6": "粘贴", @@ -794,6 +810,7 @@ export const sourceCatalogs = { "s_46a3ee051056e9fb": "所选摘要模型已不存在,请重新选择", "s_472a4a526890d157": "技能目录路径已复制:", "s_4742a6772b3275ce": "网页连接意外中断,请检查网络后重试。", + "s_474ae70c7ca4384b": "语音接口协议", "s_475ac58abf66032b": "读取根目录失败:{{p0}}", "s_476c022a773e1f82": "请关闭其他 ACECode 窗口后再迁移", "s_478211dd029aa604": "网站的安全证书无效或与当前地址不匹配。", @@ -923,6 +940,7 @@ export const sourceCatalogs = { "s_521e65ffc7d0ff2d": "已授权", "s_524d07db9dd75fc1": "{{p0}} 个后台任务正在运行", "s_5253040db8643c85": "任务", + "s_5254d30be60fcb12": "正在录音", "s_5262e309f037b4f0": "当前会话还没有后台任务。", "s_526cb1883acbd3dd": "预览标签页", "s_5292ab1a36c56a6b": "归档", @@ -1049,6 +1067,7 @@ export const sourceCatalogs = { "s_5d937ed111134879": "模型能力", "s_5ddcb16ab6fcb808": "查看成员", "s_5ddffd77ea7fedae": "正在加载已保存模型…", + "s_5e3009e7cde76251": "最长录音秒数", "s_5e56bec8550f499d": "请选择文件夹", "s_5e78c941de4a98ed": "连接 {{p0}}", "s_5e80c1e561d45b11": "选择权限模式", @@ -1077,6 +1096,7 @@ export const sourceCatalogs = { "s_6135d4159e892541": "移除", "s_613de904cc604e3b": "{{p0}}本地工具 {{p1}}", "s_6155a4ec024ed037": "已有测试图片正在生成,请稍候", + "s_61a140f1685b0c4f": "开始/停止录音", "s_61df81f0b001d0b6": "导出主题", "s_61f7928dbffe9eba": "无权限读取这个目录:{{p0}}", "s_621e846d2bce3173": "请选择模型", @@ -1112,11 +1132,13 @@ export const sourceCatalogs = { "s_64fdd111ae7c2ead": "工作空间正在迁移,请等待完成并重启", "s_651ec477427c76eb": "添加能力或上下文", "s_65273e466af9881c": "来自后台任务:{{p0}}", + "s_654a661d749296d9": "语音识别", "s_6569c6e69886e855": "等待输入", "s_657d9cbf45ec9e6a": "清除筛选", "s_65927d529ce71313": "新会话{{p0}}", "s_6595e2086df44bcb": "无法读取专家组件", "s_65b3f17534a8c0ab": "加载记忆失败", + "s_660640f5b06f6efc": "正在识别…", "s_663a5905150ea46d": "请检查系统代理设置后重新加载页面。", "s_664c216a6c75054f": "/expert-manager 帮我创建一个 XXX 专家,擅长 XXXXX。我的经验是:[请补充你的行业背景、相关经验]。", "s_66687ec68c240db9": "在「Messaging API」页打开 Use webhook;在官方账号的回应设定里关闭自动回应讯息与加入好友的欢迎讯息。", @@ -1181,6 +1203,7 @@ export const sourceCatalogs = { "s_6ae40b532ea0f924": "已转至新会话继续。", "s_6aecec7bb2ae1d74": "公网地址(可选,留空则自动建立隧道)", "s_6b01fce4dbeaeeb9": "更改", + "s_6b7b01114a2db31f": "接口协议", "s_6bbaf705e8c64c2b": "目录内容", "s_6bdf7013183b1835": "与模拟器的连接已断开", "s_6bea77acefb364ac": "标准", @@ -1236,8 +1259,10 @@ export const sourceCatalogs = { "s_701aa5a8c70f1da6": "拖动调整预览面板宽度", "s_702abbca55759b5c": "当前桌面 WebView / 浏览器渲染核心", "s_703ed122eea2bf76": "连接已取消", + "s_704b1115408edd65": "无法连接语音识别服务,请检查地址、端口和网络。", "s_705dc58bfaf7471b": "自动判定", "s_706dea6f3f95395e": "模拟器画面已结束", + "s_7075abd7d616d056": "语音识别超时,请稍后重试。", "s_70a0e592c4ca41e3": "加载摘要生成配置失败", "s_70a3058229d0c848": "路径校验失败", "s_70a60168b6f2fb5b": "工作模式切换失败:", @@ -1247,6 +1272,7 @@ export const sourceCatalogs = { "s_710e98e25c8d52d6": "取消选中会话的归档", "s_710fba34d5940db6": "决策", "s_713f102978093304": "正在关闭", + "s_714cac30e2ff8bc9": "麦克风", "s_7153f0918a937aac": "更新于", "s_718ed0bb61586a10": "显示完整目标", "s_71dcce0699b98a09": "专家组件", @@ -1258,11 +1284,13 @@ export const sourceCatalogs = { "s_720182db8f88cf7d": "正在读取运行时能力…", "s_722dd20ced9e6d55": "用你的 {{p0}} 私聊机器人一句,下面会出现请求,批准后你就是机主。", "s_725831ec12394243": "取消归档失败:", + "s_72969c8ff0ed9876": "右键配置麦克风", "s_72ccb1274a093efb": "填写 QQ 开放平台里机器人的 AppID 与 AppSecret,保存前会先验证。", "s_72dde8dfef88d37d": "确认添加", "s_72ec61d7855c86a1": "请求超时(秒)", "s_7310f2c21a738bd0": "(上一条)", "s_737c725c1321d260": "已保存 {{p0}},留空则不修改", + "s_73859d3cbb42c1c7": "语音请求超时秒数", "s_73952aa91a9366b5": "已切换至 ", "s_73ae35f541bc8124": "复制附件地址", "s_73b629753f6d5fe5": "已复制全部 diff", @@ -1326,6 +1354,7 @@ export const sourceCatalogs = { "s_77e0a202eade59c0": "已选上下文", "s_77e1ea5c5688eea4": "路径", "s_78161046757aa1f3": "请输入批注内容", + "s_784c2d76956e7faf": "语音识别正在处理其他录音,请稍后重试。", "s_785e930b8f0e0dba": "请选择空文件夹", "s_7875c0f22b559b5e": "提炼和整合记忆时调用的模型;「当前模型」沿用会话正在使用的模型", "s_788db1cfec2a3db5": "主题", @@ -1427,6 +1456,7 @@ export const sourceCatalogs = { "s_80160e448f307c23": "仅显示预览", "s_8017a9a3375e2ab1": "添加文件或文件夹失败:{{p0}}", "s_802507b670bb8f2e": "项目 {{p0}}", + "s_804fd9b446527e4a": "请先在设置的工具页配置并启用语音识别。", "s_8077a5279e43effd": "暂无全局技能", "s_8078f96cff000853": "已派遣{{p0}}:{{p1}}", "s_80891538e688b066": "正在处理请求", @@ -1481,6 +1511,7 @@ export const sourceCatalogs = { "s_84a5571a1cc78c74": "网络访问", "s_84a6269c4efa5ec5": "的完整路径 (例如", "s_84ba75cf3f850d9b": "重命名项目", + "s_84e3135a0e3059ab": "停止录音", "s_850731a627b7fdd1": "彻底删除会话 {{p0}}", "s_851854de9a487d55": "搜索专家组件", "s_85217f7aff778414": "日", @@ -1544,6 +1575,7 @@ export const sourceCatalogs = { "s_89db648162e7cea1": "连接中断,正在自动重试…", "s_89e028004391a408": "正整数", "s_89e0ebd9d750f2c1": "当前后台服务不支持图像生成,请更新 ACECode 并重启后重试", + "s_8a319a13c4a11416": "当前环境不支持麦克风录音,请使用 ACECode Desktop 或支持录音的浏览器。", "s_8a32f1c8f96d5905": "沙箱后端不可用:{{p0}}", "s_8a687fb42860ccd0": "思考深度:{{p0}}", "s_8a721474fd137ec7": "检查更新失败:", @@ -1562,6 +1594,7 @@ export const sourceCatalogs = { "s_8b5f2a61b79721aa": "最低", "s_8b6ff498515bcc2f": "时间", "s_8b7cbd4cede975c1": "导入主题", + "s_8b96b96448b4343c": "麦克风配置", "s_8bcc3e177f5f3b10": "不保存", "s_8be0ea36a9bba286": "正在连接…", "s_8c1ae715559b78bd": "无法获取升级进度", @@ -1598,6 +1631,7 @@ export const sourceCatalogs = { "s_8e426128f4d08da8": "重新查看新手指引", "s_8e4b57707f882c98": "{{p0}}已保存", "s_8e4c9c4aedc98e0c": "默认权限模式更新失败:", + "s_8e5683434a8e5999": "所选麦克风不可用,请右键重新选择。", "s_8e5bb748625574f4": "拦截 / 放行记录与日志导出。", "s_8e60efaaf65cf682": "依赖与技术债巡检", "s_8e6cd5a3fba2a97c": "后台运行设置失败:", @@ -1640,6 +1674,7 @@ export const sourceCatalogs = { "s_916afcd85322d732": "更换", "s_917b1c1f18d0276b": "保存中...", "s_91e22b0baedeffd2": "API 协议无效,请选择 Chat Completions 或 Responses", + "s_91f7b5d77fd92139": "麦克风权限被拒绝,请在系统或浏览器中允许访问。", "s_92172b32227b0aae": "删除后无法恢复;记忆摘要之后也不会再自动生成这条记忆。", "s_926a50b98ece2667": "撤销", "s_92845d3b531d9fec": "每周", @@ -1685,6 +1720,7 @@ export const sourceCatalogs = { "s_95a6dd0452629c54": "文件安全", "s_95d3bfcc95f1d7e2": "{{p0}} 已{{p1}}", "s_95e5d43ad6134679": "ios simulator iphone ipad xcode 模拟器 simctl device stream screenshot 设备 画面 帧率 画质", + "s_96056ac7c36e8d56": "识别并发送", "s_963ba8b5475fbf51": "写入规则文件失败", "s_96675ef5c57b5b24": "建议环境变量 {{p0}}", "s_96741cd87c38563c": "任务完成通知已打开", @@ -1723,6 +1759,7 @@ export const sourceCatalogs = { "s_98fc221b10e87c21": "创建应用", "s_991bb7cfe5a81550": "保存修改", "s_992b4416c9d23e96": "正在读取模型…", + "s_99587cf2ca773409": "按住录音", "s_9966d6b375133f89": "路径必须是绝对路径:{{p0}}", "s_99731fe916c6b3bf": "现在可以在钉钉里私聊机器人了。把机器人拉进群后 @ 它,第一次需要在设置页批准。", "s_99845832ed7f16f6": "稍后再说", @@ -1754,6 +1791,7 @@ export const sourceCatalogs = { "s_9d0f70c4cee1d679": "本次会话放行", "s_9d1a1b1c006d85f0": "文件编码不受支持,只能只读预览", "s_9d2fb46c0ba890b9": "自定义路径", + "s_9d43d015c51b5bb1": "语音识别模型", "s_9d8692671d208b74": "群成员", "s_9d8fb8378821c838": "近 365 天内累计", "s_9dbb88d5e18dcea1": "移除 webhook 并连接", @@ -1831,7 +1869,9 @@ export const sourceCatalogs = { "s_a3a33d4898a73327": "项目配置与全局配置叠加,同名服务器以项目配置为准。", "s_a3c4db63188defb7": "在同一页打开「Message Content Intent」。", "s_a3d312771e3438f7": "iOS 模拟器仅在 macOS 上可用", + "s_a44d14888ce8f079": "听写", "s_a46b5607cf04e274": "请选择决策", + "s_a4800613a2ce283a": "语音 API Key", "s_a48c70216c141e4a": "我们该在 {{p0}} 中做什么?", "s_a49f5287fc9ef2d0": "展开目录", "s_a4c3313deb180960": "三", @@ -1875,6 +1915,7 @@ export const sourceCatalogs = { "s_a8c81f93f2e0e3b6": "浏览", "s_a8ca3c13eb75866d": "工具调用", "s_a8ca68f1ab6a28ff": "原生文件夹相对路径无效", + "s_a910682cf0e7e3d9": "语音识别连接正常", "s_a91ae60350922a63": "未命名会话", "s_a920d161e9edee81": "能力目录加载失败", "s_a924bf85d18cbc15": "连接已复制到剪贴板", @@ -1940,6 +1981,7 @@ export const sourceCatalogs = { "s_ae5358b57d3a7ea5": "找不到路径:{{p0}}", "s_ae5ead4d89074727": "连接超时", "s_ae6742618467eea1": "能力:{{p0}}", + "s_ae6d1c297557333c": "语音识别使用代理", "s_ae8f8dbbab7a0f5d": "固定引用", "s_aea9d6b5bdf48ff8": "放弃未保存的更改?", "s_aeb857d4655cccb8": "全屏演示", @@ -2145,6 +2187,7 @@ export const sourceCatalogs = { "s_c23f7ae7d246d7e4": "没有可导入的 opencode 会话", "s_c270560004b02761": "请搭配 Ctrl、Cmd 或 Alt,或使用功能键,避免影响正常输入。", "s_c27916570d04cf33": "当前工作区未在后台服务中登记", + "s_c29c290d42870ab5": "没有识别到语音,请重新录制。", "s_c2a497830ef74215": "最新版本", "s_c2b732b196ccc7f3": "会话已彻底删除", "s_c2b794434132af6e": "重新获取二维码", @@ -2205,6 +2248,7 @@ export const sourceCatalogs = { "s_c9136488e2f44217": "图像生成测试结果", "s_c92179b74af61689": "取消置顶", "s_c9401893f45a2f77": "移除粘贴的文本", + "s_c94a501ea3a492f1": "语音服务认证失败,请检查 API Key。", "s_c95309c734480385": "复制此文件 diff", "s_c95dc99afe57b285": "截图", "s_c96cc528f860f1b8": "统计来自 provider usage 回传;历史会话不会回填", @@ -2246,6 +2290,7 @@ export const sourceCatalogs = { "s_cc574bc5da5d9ac3": "绑定码 10 分钟内有效,只能用一次;发送后这里会自动完成。", "s_cc66c3f576fd5a49": "完全访问权限", "s_cc91b14c8a02a155": "将 {{p0}} 设为默认模型", + "s_cca0fa1254967a89": "录音格式或时长不符合服务要求,请重新录制。", "s_cce894450069ba5b": "未知来源", "s_cd0832ff9b505231": "主题包已交给浏览器下载", "s_cd0ce16ee4fd90de": "复制失败,请手动选中复制", @@ -2271,6 +2316,7 @@ export const sourceCatalogs = { "s_ce99adb25bdca08b": "下次退出时将停止后台进程", "s_cea5fddb03aa0275": "来自升级服务的历次版本说明", "s_ceb33e0328a6f776": "保存定时任务失败", + "s_cec67948278c164e": "语音服务返回的内容无法识别,请检查接口协议。", "s_cec844f175819030": "读取审计日志失败", "s_ced8c6c946146849": "可先在专家组件页面创建。", "s_cee5ce29016ed1ad": "Temperature 需在 0 到 2 之间(Anthropic 为 0 到 1)", @@ -2314,7 +2360,9 @@ export const sourceCatalogs = { "s_d2ca597f9bd0d73a": "用剪贴板内容替换", "s_d2e48b56205e4922": "仅 Windows Desktop", "s_d2fc539871184000": "插话已提交,正在打断当前回合", + "s_d301bc1258334c7c": "关闭提示", "s_d3136b6fa93050df": "来自 {{p0}} 个会话", + "s_d3181d911a1d80f2": "等待麦克风权限…", "s_d31c72ba37a59575": "暂无已归档会话", "s_d321ee860efcbb5d": "使用已保存的机器人,或者换一个。", "s_d33995ad669c80cf": "创建频道", @@ -2329,6 +2377,7 @@ export const sourceCatalogs = { "s_d3f7534dabbbee5c": "搜索失败:", "s_d410b948a9b9d497": "该模型正在使用中,稍后再试", "s_d416f3c13fcba9c0": "新会话1", + "s_d42d6cb412ba6563": "API Key(可选)", "s_d44108be3de90805": "该模型正在被运行中的会话使用,稍后再试", "s_d44e9b3d3b31d37b": "名称", "s_d451c07d739b3306": "黄色", @@ -2360,6 +2409,7 @@ export const sourceCatalogs = { "s_d696e50022d12267": "消息自动折叠", "s_d69766c856685234": "新手指引", "s_d69fc417bf200e1e": "管理,{{p0}} 个请求待批准", + "s_d6b75ab7a55790a6": "语音识别服务返回错误,请稍后重试。", "s_d6cd7e5f2a8a82cf": "这条记忆已不存在,请刷新列表", "s_d6d20f05ed0f96e0": "单次执行时间", "s_d6e882143a3c3b83": "扫码创建机器人", @@ -2418,6 +2468,7 @@ export const sourceCatalogs = { "s_db4740e9d8c8e52b": "移除浏览器上下文", "s_db658bd2e4597c10": "移除工作区失败:", "s_db8630211fe3934a": "原生设置不可用", + "s_db8db0530432bd15": "查看", "s_db95142124934467": "音乐", "s_db9773faa309c257": "自动选择", "s_dbaa6ba5056580a7": "不在当前可选专家范围内", @@ -2436,6 +2487,8 @@ export const sourceCatalogs = { "s_dcb31204dec123f0": "主题正在处理中,请稍后重试", "s_dcb882971a569896": "连接来源", "s_dccec95b65e15ff0": "未下载", + "s_dce65512e7d07a0c": "保存语音识别配置失败,请重试。", + "s_dcfb516f4161221e": "无法读取麦克风,请检查设备是否被其他程序占用。", "s_dd070a037b4c97a3": "创建专家团", "s_dd15d4fdc9a08a5a": "按 Provider 要求配置认证", "s_dd42f689b17393c2": "正在加载更早的消息…", @@ -2473,6 +2526,7 @@ export const sourceCatalogs = { "s_e03fe22f0e2a3f73": "预览面板中模拟器画面的帧率、画质与缩放;数值越高越清晰流畅,占用也越多", "s_e058a2b5c4f81526": "查看全文", "s_e05f7e88f89c5f88": ",下载完成后自动应用。", + "s_e06db99df5d0c3b4": "语音识别超时,请重试或调整请求超时。", "s_e07802f10db9b6b8": "找到适合当前工作的专家", "s_e0959e358684c224": "{{p0}}(留空则不修改)", "s_e09b1c37f79f440d": "放行(沙箱外)", @@ -2521,6 +2575,7 @@ export const sourceCatalogs = { "s_e4b76edd446fe460": "工作模式", "s_e4bfcdad3c3e883a": "主题配色数据无效", "s_e4c7386213cef812": "已处理 {{p0}}", + "s_e4c8173406d11018": "启用语音识别", "s_e4c978a2101ede6d": "本轮文件改动已撤销", "s_e4f9590b09058415": "适合日常工作", "s_e4fbfa27831d24a6": "已删除 {{p0}} 个会话,{{p1}} 个删除失败", @@ -2559,12 +2614,16 @@ export const sourceCatalogs = { "s_e788045df7db1869": "{{p0}}头像相对路径", "s_e78db524e7c1e3dd": "... 还有 {{p0}} 项", "s_e7a4ae94fa9dbad5": "将打开", + "s_e7a71bb4b5b5dbf4": "语音识别失败,请重试。", "s_e7bf600d2da7ec18": "返回导航历史中的上一项", "s_e804670f73f0874a": "Shift+Enter 换行", "s_e806fbbe8ec6e82d": "插件", + "s_e821845e5e671890": "已归档的聊天", "s_e833097b603cb745": "{{p0}} 个上下文", "s_e84e54a6b6dbbbb9": "读写都拒绝:{{p0}}", + "s_e882813f20af3c37": "正在请求麦克风权限", "s_e8ac23cf743aa1f8": "或者批准私聊请求,第一个被批准的人成为机主", + "s_e8c06cb613165321": "麦克风 {{p0}}", "s_e8dc871a7fdee23b": "总计", "s_e8e08b0f61dd4ddb": "正在取消…", "s_e90589613fe54246": "显示隐藏", @@ -2665,6 +2724,7 @@ export const sourceCatalogs = { "s_f1c10da84402db5d": "全局搜索", "s_f1e0dfb858e7fcfe": "钩子拒绝", "s_f21f25112af89630": "回调地址已登记。请在 LINE Developers 控制台的 Messaging API 页打开 Use webhook,打开后这里会自动变为已连接。", + "s_f228e01256a4ddf4": "麦克风已断开,请检查设备后重试。", "s_f27627514a2cb542": "专家组件加载失败", "s_f28461bb49c85647": "已完成", "s_f2902ada282332d2": "未知命令,沙箱内执行", @@ -2729,6 +2789,7 @@ export const sourceCatalogs = { "s_f733015aee32d198": "工作区菜单", "s_f76a213bcdfd4d38": "大眼回形针", "s_f77e30ee1aed8819": "铅笔", + "s_f7b5141da1a73531": "请检查语音识别地址、端口和配置。", "s_f7b94f909cd2bef0": "重置全局记忆?", "s_f7dd1b546141b550": "某些特殊审计场景,您可能需要重写工具,请确保您知道您在做什么才勾选此选项。", "s_f7e1c1a17a6c9e7e": "请先在已有会话中使用 /btw 或 /side", @@ -2868,6 +2929,7 @@ export const sourceCatalogs = { "s_0215ff0b3cce53f1": "Feedback content", "s_0220aafc260ee131": "After enabling the hooks function, the configured hooks will be displayed here", "s_025486bcb86499e3": "Please select a summary model", + "s_02739d1bae8bd996": "Service URL", "s_027be81bc401b0cd": "Connection failed; the reason is in the log. Check the credentials and retry, or go back to change them.", "s_029d7b3375ad16aa": "Rule written to file", "s_02b411ce6d16f108": "Dynamic context", @@ -2879,8 +2941,10 @@ export const sourceCatalogs = { "s_03b4c3a9f038e956": "Last hour", "s_03ea92a14ed4272e": "Could not confirm Computer Use settings. Retry to check the current state.", "s_0407dbdaf0dd84d9": "Open system settings", + "s_0444649fc3f1d0fe": "Stop recording and insert text", "s_04830193b408eeb1": "Summarize", "s_0488d8fddd952d28": "cache write", + "s_04b77083689b0977": "System default", "s_04f8b878be976c24": "Only removes from the desktop items list and does not delete the disk file. continue?", "s_0515f9f59c1cea69": "Expand to view remaining {{p0}} files", "s_0517e800aaa941c0": "{{p0}} failed: {{p1}}", @@ -2900,7 +2964,9 @@ export const sourceCatalogs = { "s_0617557e7bbc153d": "Browse…", "s_0635261aef08149c": "Please select at least one week", "s_0635d2ebc22e01db": "When enabled, the agent can boot, view and operate iOS Simulators from Xcode. You can also watch and control them live in the preview panel.", + "s_06490c9aac171faf": "Recognizing speech", "s_06a32a9dfa382580": "Open a drive or folder before selecting", + "s_06ae0fdfac65c03d": "Failed to undo archive:", "s_06aece1ed77e02f6": "Matched a forbid rule", "s_06d7b2e31d301e2e": "Context usage", "s_071089398bfdb584": "Test", @@ -2951,6 +3017,7 @@ export const sourceCatalogs = { "s_0a0af8a5218a1cdc": "Nothing to show in this folder", "s_0a6f82252d9788fb": "Return to Gongge", "s_0a880a169f51924d": "Top sorting failed:", + "s_0a98a48eaaac4175": "Test connection", "s_0a9a4724ab124b4b": "Side by side", "s_0aaf1f62f7a179c2": "National Day", "s_0ab653adadfadb42": "Read {{p0}}", @@ -3014,13 +3081,16 @@ export const sourceCatalogs = { "s_0ecd009b61757eae": "Create a separate worktree. Uncommitted changes are not included.", "s_0ef300e7bd9ba7eb": "Skill scan paths", "s_0f415f8b2bbbb37a": "Failed to read running records:", + "s_0f634bef59a48ff9": "Use ACECode proxy", "s_0f673a156d4bf981": "On · {{p0}}", "s_0f7f6afb91e8922f": "Remove reference", "s_0fbaf5c44a3d5a28": "Run this session in a separate git worktree (created when the first message is sent)", "s_0fc55068f541c2cb": "The upgrade installation is complete, please exit completely and restart ACECode", "s_0fc80b1f2c00b1cd": "File change denied", "s_0fdada0fc86aed7f": "Copy failed:", + "s_0fdadddc66f59cee": "This page cannot access the microphone. Use ACECode Desktop, localhost, or HTTPS.", "s_0ffcfe1af05f8653": "Answered by a direct message instead", + "s_105a9082c346f958": "Testing…", "s_10ba22de4d7ddf30": "Simulator stream interrupted", "s_10e80ac177155143": "Message cannot be empty", "s_111448a2e1a82306": "There are {{p0}} fields", @@ -3032,6 +3102,7 @@ export const sourceCatalogs = { "s_117b19fd23b61edb": "Allow, prompt or forbid commands by prefix. Prefixes are split on spaces; git status|diff means the second word may be either. \"Allow (outside sandbox)\" skips the sandbox, so reserve it for commands that truly need it; interpreters, shells, rm and sudo cannot be allowed.", "s_117c113c3b45cf89": "queue", "s_1198cb9da295145d": "New sessions use this mode by default, and the current session will be switched synchronously.", + "s_119eee7eb104a413": "No microphone was found.", "s_11a4dc9fc810be2f": "Set the robot's message mode to Stream, grant the internal robot send-message permission under Permissions, then publish the app.", "s_11a6ac8794688be8": "Matched a prompt rule", "s_11b999fc4610de85": "networking", @@ -3050,6 +3121,7 @@ export const sourceCatalogs = { "s_129dadf0a8648147": "Starting upgrade task", "s_12b4e2c5cfff6fd0": "Download an iOS Simulator runtime in Xcode, then try again", "s_12c8d9f7a9aef51f": "Allow for this session: {{p0}}", + "s_12e6846dac1851cd": "Cancel dictation", "s_130a39597133e8f9": "Chat about this session without interrupting the main task or adding to its conversation.", "s_132411ba41da7354": "reasoning", "s_132a029e79ec9872": "Share with agent", @@ -3118,9 +3190,11 @@ export const sourceCatalogs = { "s_18435419f154847b": "Command prefix", "s_1843acb8c6ea0f47": "DM the bot or @mention it in a server channel to give instructions. Results come back to the channel.", "s_184568b57e5e6d3f": "Generating test image…", + "s_18461c6b047266b0": "The speech model is not ready. Try again later.", "s_1847a84db6d5fc1a": "Permission mode switch failed:", "s_18565a5455a0a875": "Could not load sandbox settings", "s_18578640315b5b33": "Updating todos", + "s_1878849ddfed4665": "speech voice dictation microphone sensevoice whisper recording LAN URL port", "s_1882a447d5f76d5f": "Feedback cannot exceed 10,000 characters", "s_188397c445996182": "Connected by another ACECode", "s_18850bf66a4ba4c6": "The model requests temporary extra sandbox permissions", @@ -3173,6 +3247,7 @@ export const sourceCatalogs = { "s_1c5a73e41a5f944a": "Forking to a new session…", "s_1c6c23d432da9200": "Last error", "s_1c75124a3a48732d": "Cancel model detection selection", + "s_1c7a0542c9844013": "Speech service URL", "s_1c8218d8e483fe16": "Refresh model list", "s_1c8557bec7b84d8b": "Delete scheduled task", "s_1ca440a127bf12c9": "Reset current workspace memory?", @@ -3307,6 +3382,7 @@ export const sourceCatalogs = { "s_28751566873e4520": "{{p0}} screen", "s_28786d90697520ac": "Delete profile", "s_2883d2978e52bcdf": "Working days {{p0}}", + "s_288a6c217a9ca243": "Maximum recording (seconds)", "s_2890fe9bbc1773b9": "Unknown token (use:workspace_roots,:tmpdir or:acecode_home): {{p0}}", "s_289e6128fc7a4b46": "Hide desktop pet", "s_28aacfb2e3d8d395": "Expert group name", @@ -3410,6 +3486,7 @@ export const sourceCatalogs = { "s_32a202d6fed3a1da": "Create a new task in this workspace", "s_33281591669db79f": "Product delivery expert team", "s_33360315a592f985": "interval unit", + "s_333eda18f13dbaad": "Available when speech recognition is enabled; stopping inserts the transcript", "s_334c06acffa15738": "The expert switch did not match the current conversation. Synced to {{p0}}.", "s_334d7a58ba439bea": "Matched a deny rule in YOLO mode", "s_33517926747180e6": "Paste", @@ -3633,6 +3710,7 @@ export const sourceCatalogs = { "s_46a3ee051056e9fb": "The selected summary model no longer exists. Choose another one.", "s_472a4a526890d157": "Skill directory path copied:", "s_4742a6772b3275ce": "The page connection was interrupted. Check your network and try again.", + "s_474ae70c7ca4384b": "Speech API protocol", "s_475ac58abf66032b": "Could not load the root folders: {{p0}}", "s_476c022a773e1f82": "Close other ACECode windows before migrating", "s_478211dd029aa604": "The site security certificate is invalid or does not match this address.", @@ -3762,6 +3840,7 @@ export const sourceCatalogs = { "s_521e65ffc7d0ff2d": "Granted", "s_524d07db9dd75fc1": "{{p0}} background tasks are running", "s_5253040db8643c85": "Task", + "s_5254d30be60fcb12": "Recording", "s_5262e309f037b4f0": "There are no background tasks for the current session.", "s_526cb1883acbd3dd": "Preview tab", "s_5292ab1a36c56a6b": "Archive", @@ -3888,6 +3967,7 @@ export const sourceCatalogs = { "s_5d937ed111134879": "Model capabilities", "s_5ddcb16ab6fcb808": "Checking members", "s_5ddffd77ea7fedae": "Loading saved models…", + "s_5e3009e7cde76251": "Maximum recording seconds", "s_5e56bec8550f499d": "Choose a directory", "s_5e78c941de4a98ed": "Connect {{p0}}", "s_5e80c1e561d45b11": "Select permission mode", @@ -3916,6 +3996,7 @@ export const sourceCatalogs = { "s_6135d4159e892541": "Remove", "s_613de904cc604e3b": "{{p0}} local tool {{p1}}", "s_6155a4ec024ed037": "A test image is already being generated. Please wait.", + "s_61a140f1685b0c4f": "Start / stop recording", "s_61df81f0b001d0b6": "Export theme", "s_61f7928dbffe9eba": "No permission to read this folder: {{p0}}", "s_621e846d2bce3173": "Please select a model", @@ -3951,11 +4032,13 @@ export const sourceCatalogs = { "s_64fdd111ae7c2ead": "Workspace migration is in progress. Wait for it to finish, then restart.", "s_651ec477427c76eb": "Add capability or context", "s_65273e466af9881c": "From background task:{{p0}}", + "s_654a661d749296d9": "Speech recognition", "s_6569c6e69886e855": "waiting for input", "s_657d9cbf45ec9e6a": "Clear filters", "s_65927d529ce71313": "New Session {{p0}}", "s_6595e2086df44bcb": "Unable to read expert component", "s_65b3f17534a8c0ab": "Could not load memories", + "s_660640f5b06f6efc": "Recognizing…", "s_663a5905150ea46d": "Check your system proxy settings, then reload the page.", "s_664c216a6c75054f": "/expert-manager Help me create an XXX expert specializing in XXXXX. My experience is: [add your industry background and relevant experience].", "s_66687ec68c240db9": "Turn on Use webhook on the Messaging API page, and turn off auto-reply and greeting messages in the official account's response settings.", @@ -4020,6 +4103,7 @@ export const sourceCatalogs = { "s_6ae40b532ea0f924": "Work has continued in a new conversation.", "s_6aecec7bb2ae1d74": "Public address (optional; leave empty for an automatic tunnel)", "s_6b01fce4dbeaeeb9": "Change", + "s_6b7b01114a2db31f": "API protocol", "s_6bbaf705e8c64c2b": "Folder contents", "s_6bdf7013183b1835": "The connection to the simulator was lost", "s_6bea77acefb364ac": "Standard", @@ -4075,8 +4159,10 @@ export const sourceCatalogs = { "s_701aa5a8c70f1da6": "Drag to adjust preview panel width", "s_702abbca55759b5c": "Current desktop WebView / browser rendering core", "s_703ed122eea2bf76": "Connection cancelled", + "s_704b1115408edd65": "Cannot connect to the speech service. Check its URL, port, and network.", "s_705dc58bfaf7471b": "Automatic", "s_706dea6f3f95395e": "The simulator stream ended", + "s_7075abd7d616d056": "Speech recognition timed out. Try again later.", "s_70a0e592c4ca41e3": "Could not load summary generation settings", "s_70a3058229d0c848": "Path validation failed", "s_70a60168b6f2fb5b": "Failed to switch work mode:", @@ -4086,6 +4172,7 @@ export const sourceCatalogs = { "s_710e98e25c8d52d6": "Unarchive selected sessions", "s_710fba34d5940db6": "Decision", "s_713f102978093304": "Shutting down", + "s_714cac30e2ff8bc9": "Microphone", "s_7153f0918a937aac": "Updated", "s_718ed0bb61586a10": "Show full goal", "s_71dcce0699b98a09": "Expert components", @@ -4097,11 +4184,13 @@ export const sourceCatalogs = { "s_720182db8f88cf7d": "Reading runtime capabilities...", "s_722dd20ced9e6d55": "Send the bot a private message from your {{p0}}. A request appears below; approve it to become the owner.", "s_725831ec12394243": "Unarchiving failed:", + "s_72969c8ff0ed9876": "Right-click to configure microphone", "s_72ccb1274a093efb": "Enter the bot's AppID and AppSecret from QQ Open Platform. They are verified before saving.", "s_72dde8dfef88d37d": "Confirm to add", "s_72ec61d7855c86a1": "Request timeout (seconds)", "s_7310f2c21a738bd0": "(previous)", "s_737c725c1321d260": "Saved {{p0}}. Leave blank to keep it", + "s_73859d3cbb42c1c7": "Speech request timeout seconds", "s_73952aa91a9366b5": "Switched to", "s_73ae35f541bc8124": "Copy attachment address", "s_73b629753f6d5fe5": "All diffs copied", @@ -4165,6 +4254,7 @@ export const sourceCatalogs = { "s_77e0a202eade59c0": "Selected context", "s_77e1ea5c5688eea4": "Path", "s_78161046757aa1f3": "Enter an annotation", + "s_784c2d76956e7faf": "The speech service is processing another recording. Try again later.", "s_785e930b8f0e0dba": "Choose an empty directory", "s_7875c0f22b559b5e": "Model used to extract and consolidate memories; \"Current model\" uses the model the session is using", "s_788db1cfec2a3db5": "Theme", @@ -4266,6 +4356,7 @@ export const sourceCatalogs = { "s_80160e448f307c23": "Show preview only", "s_8017a9a3375e2ab1": "Failed to add file or folder:{{p0}}", "s_802507b670bb8f2e": "Project {{p0}}", + "s_804fd9b446527e4a": "Configure and enable speech recognition under Settings → Tools.", "s_8077a5279e43effd": "No global skills yet", "s_8078f96cff000853": "Dispatched {{p0}}:{{p1}}", "s_80891538e688b066": "Processing request", @@ -4320,6 +4411,7 @@ export const sourceCatalogs = { "s_84a5571a1cc78c74": "Network access", "s_84a6269c4efa5ec5": "The full path to (e.g.", "s_84ba75cf3f850d9b": "Rename project", + "s_84e3135a0e3059ab": "Stop recording", "s_850731a627b7fdd1": "Permanently delete session {{p0}}", "s_851854de9a487d55": "Search Expert Component", "s_85217f7aff778414": "day", @@ -4383,6 +4475,7 @@ export const sourceCatalogs = { "s_89db648162e7cea1": "Connection lost, retrying automatically…", "s_89e028004391a408": "A positive integer", "s_89e0ebd9d750f2c1": "The current backend does not support image generation. Update ACECode, restart, and try again.", + "s_8a319a13c4a11416": "Microphone recording is unavailable. Use ACECode Desktop or a supported browser.", "s_8a32f1c8f96d5905": "Sandbox backend unavailable: {{p0}}", "s_8a687fb42860ccd0": "Reasoning depth: {{p0}}", "s_8a721474fd137ec7": "Checking for updates failed:", @@ -4401,6 +4494,7 @@ export const sourceCatalogs = { "s_8b5f2a61b79721aa": "Minimal", "s_8b6ff498515bcc2f": "Timing", "s_8b7cbd4cede975c1": "Import theme", + "s_8b96b96448b4343c": "Microphone settings", "s_8bcc3e177f5f3b10": "Don't save", "s_8be0ea36a9bba286": "Connecting…", "s_8c1ae715559b78bd": "Unable to get upgrade progress", @@ -4437,6 +4531,7 @@ export const sourceCatalogs = { "s_8e426128f4d08da8": "Review the newbie guide", "s_8e4b57707f882c98": "{{p0}} saved", "s_8e4c9c4aedc98e0c": "Default permission mode update failed:", + "s_8e5683434a8e5999": "The selected microphone is unavailable. Right-click to choose another.", "s_8e5bb748625574f4": "Block / allow records and log export.", "s_8e60efaaf65cf682": "Dependencies and technical debt inspection", "s_8e6cd5a3fba2a97c": "Background run setup failed:", @@ -4479,6 +4574,7 @@ export const sourceCatalogs = { "s_916afcd85322d732": "Change", "s_917b1c1f18d0276b": "Saving...", "s_91e22b0baedeffd2": "Invalid API protocol. Select Chat Completions or Responses.", + "s_91f7b5d77fd92139": "Microphone access was denied. Allow access in your system or browser.", "s_92172b32227b0aae": "This cannot be undone, and memory summarization will not recreate this memory.", "s_926a50b98ece2667": "Cancel", "s_92845d3b531d9fec": "Weekly", @@ -4524,6 +4620,7 @@ export const sourceCatalogs = { "s_95a6dd0452629c54": "File security", "s_95d3bfcc95f1d7e2": "{{p0}} has been replaced by {{p1}}", "s_95e5d43ad6134679": "ios simulator iphone ipad xcode simctl device stream screenshot frame rate quality", + "s_96056ac7c36e8d56": "Recognize and send", "s_963ba8b5475fbf51": "Could not write the rule file", "s_96675ef5c57b5b24": "Suggested environment variable: {{p0}}", "s_96741cd87c38563c": "Task completion notifications enabled", @@ -4562,6 +4659,7 @@ export const sourceCatalogs = { "s_98fc221b10e87c21": "Create app", "s_991bb7cfe5a81550": "Save changes", "s_992b4416c9d23e96": "Reading models…", + "s_99587cf2ca773409": "Hold to record", "s_9966d6b375133f89": "Paths must be absolute: {{p0}}", "s_99731fe916c6b3bf": "You can now message the robot privately in DingTalk. Add it to a group and @mention it there; the first time needs approval in Settings.", "s_99845832ed7f16f6": "Later", @@ -4593,6 +4691,7 @@ export const sourceCatalogs = { "s_9d0f70c4cee1d679": "Allowed for this session", "s_9d1a1b1c006d85f0": "Unsupported file encoding; read-only preview only", "s_9d2fb46c0ba890b9": "Custom path", + "s_9d43d015c51b5bb1": "Speech recognition model", "s_9d8692671d208b74": "Group member", "s_9d8fb8378821c838": "Cumulative within the last 365 days", "s_9dbb88d5e18dcea1": "Remove webhook and connect", @@ -4670,7 +4769,9 @@ export const sourceCatalogs = { "s_a3a33d4898a73327": "Project servers are combined with global servers. Project definitions override matching names.", "s_a3c4db63188defb7": "On the same page, turn on Message Content Intent.", "s_a3d312771e3438f7": "iOS Simulator is only available on macOS", + "s_a44d14888ce8f079": "Dictate", "s_a46b5607cf04e274": "Choose a decision", + "s_a4800613a2ce283a": "Speech API key", "s_a48c70216c141e4a": "What should we do in {{p0}}?", "s_a49f5287fc9ef2d0": "Expand directory", "s_a4c3313deb180960": "three", @@ -4714,6 +4815,7 @@ export const sourceCatalogs = { "s_a8c81f93f2e0e3b6": "Browse", "s_a8ca3c13eb75866d": "Tool call", "s_a8ca68f1ab6a28ff": "The relative path to the native folder is invalid", + "s_a910682cf0e7e3d9": "Speech recognition service connected", "s_a91ae60350922a63": "unnamed session", "s_a920d161e9edee81": "Capability catalog loading failed", "s_a924bf85d18cbc15": "Connection copied to clipboard", @@ -4779,6 +4881,7 @@ export const sourceCatalogs = { "s_ae5358b57d3a7ea5": "Path not found: {{p0}}", "s_ae5ead4d89074727": "Connection timed out", "s_ae6742618467eea1": "Capability: {{p0}}", + "s_ae6d1c297557333c": "Use proxy for speech recognition", "s_ae8f8dbbab7a0f5d": "fixed reference", "s_aea9d6b5bdf48ff8": "Discard unsaved changes?", "s_aeb857d4655cccb8": "Presentation mode", @@ -4984,6 +5087,7 @@ export const sourceCatalogs = { "s_c23f7ae7d246d7e4": "No opencode sessions to import", "s_c270560004b02761": "Include Ctrl, Cmd or Alt, or use a function key to preserve normal typing.", "s_c27916570d04cf33": "The current workspace is not registered with the background service", + "s_c29c290d42870ab5": "No speech was recognized. Record again.", "s_c2a497830ef74215": "latest version", "s_c2b732b196ccc7f3": "Conversation has been completely deleted", "s_c2b794434132af6e": "Get a new QR code", @@ -5044,6 +5148,7 @@ export const sourceCatalogs = { "s_c9136488e2f44217": "Image generation test result", "s_c92179b74af61689": "Unpin", "s_c9401893f45a2f77": "Remove pasted text", + "s_c94a501ea3a492f1": "Speech service authentication failed. Check the API key.", "s_c95309c734480385": "Copy this file diff", "s_c95dc99afe57b285": "Screenshot", "s_c96cc528f860f1b8": "Statistics come from provider usage postback; historical sessions are not backfilled", @@ -5085,6 +5190,7 @@ export const sourceCatalogs = { "s_cc574bc5da5d9ac3": "The code is valid for 10 minutes and works once. This step completes by itself after you send it.", "s_cc66c3f576fd5a49": "Full access", "s_cc91b14c8a02a155": "Set {{p0}} as the default model", + "s_cca0fa1254967a89": "The recording format or duration is unsupported. Record again.", "s_cce894450069ba5b": "unknown source", "s_cd0832ff9b505231": "Theme package sent to browser downloads", "s_cd0ce16ee4fd90de": "Copy failed. Select the text and copy it manually", @@ -5110,6 +5216,7 @@ export const sourceCatalogs = { "s_ce99adb25bdca08b": "The background process will be stopped the next time you exit", "s_cea5fddb03aa0275": "Previous version notes from the upgrade service", "s_ceb33e0328a6f776": "Failed to save scheduled task", + "s_cec67948278c164e": "The speech response could not be read. Check the API protocol.", "s_cec844f175819030": "Could not read the audit log", "s_ced8c6c946146849": "It can be created on the expert component page first.", "s_cee5ce29016ed1ad": "Temperature must be between 0 and 2 (0 to 1 for Anthropic)", @@ -5153,7 +5260,9 @@ export const sourceCatalogs = { "s_d2ca597f9bd0d73a": "Replace with clipboard content", "s_d2e48b56205e4922": "Desktop for Windows only", "s_d2fc539871184000": "Interjection submitted; interrupting the active turn", + "s_d301bc1258334c7c": "Dismiss notification", "s_d3136b6fa93050df": "From {{p0}} session(s)", + "s_d3181d911a1d80f2": "Waiting for microphone access…", "s_d31c72ba37a59575": "No archived conversations yet", "s_d321ee860efcbb5d": "Use the saved bot, or switch to another one.", "s_d33995ad669c80cf": "Create channel", @@ -5168,6 +5277,7 @@ export const sourceCatalogs = { "s_d3f7534dabbbee5c": "Search failed:", "s_d410b948a9b9d497": "The model is in use, try again later", "s_d416f3c13fcba9c0": "New session 1", + "s_d42d6cb412ba6563": "API key (optional)", "s_d44108be3de90805": "The model is being used by a running session, try again later", "s_d44e9b3d3b31d37b": "name", "s_d451c07d739b3306": "Yellow", @@ -5199,6 +5309,7 @@ export const sourceCatalogs = { "s_d696e50022d12267": "Automatically collapse messages", "s_d69766c856685234": "Beginner's Guide", "s_d69fc417bf200e1e": "Manage, {{p0}} requests awaiting approval", + "s_d6b75ab7a55790a6": "The speech service returned an error. Try again later.", "s_d6cd7e5f2a8a82cf": "This memory no longer exists. Refresh the list.", "s_d6d20f05ed0f96e0": "Single execution time", "s_d6e882143a3c3b83": "Scan to create bot", @@ -5257,6 +5368,7 @@ export const sourceCatalogs = { "s_db4740e9d8c8e52b": "Remove browser context", "s_db658bd2e4597c10": "Removing workspace failed:", "s_db8630211fe3934a": "Native settings are not available", + "s_db8db0530432bd15": "View", "s_db95142124934467": "Music", "s_db9773faa309c257": "Automatic", "s_dbaa6ba5056580a7": "Outside the currently selectable expert set", @@ -5275,6 +5387,8 @@ export const sourceCatalogs = { "s_dcb31204dec123f0": "The theme is being processed. Try again shortly.", "s_dcb882971a569896": "Connection source", "s_dccec95b65e15ff0": "Not downloaded", + "s_dce65512e7d07a0c": "Could not save speech recognition settings. Try again.", + "s_dcfb516f4161221e": "Cannot read the microphone. Check whether another app is using it.", "s_dd070a037b4c97a3": "Create an expert team", "s_dd15d4fdc9a08a5a": "Configure authentication as required by the provider", "s_dd42f689b17393c2": "Loading earlier messages…", @@ -5312,6 +5426,7 @@ export const sourceCatalogs = { "s_e03fe22f0e2a3f73": "Frame rate, quality and scale of the simulator view in the preview panel. Higher values look sharper and smoother but use more resources.", "s_e058a2b5c4f81526": "View full text", "s_e05f7e88f89c5f88": ". The theme will be applied after download.", + "s_e06db99df5d0c3b4": "Speech recognition timed out. Retry or adjust the request timeout.", "s_e07802f10db9b6b8": "Find the right expert for your current job", "s_e0959e358684c224": "{{p0}} (leave empty to keep)", "s_e09b1c37f79f440d": "Allow (outside sandbox)", @@ -5360,6 +5475,7 @@ export const sourceCatalogs = { "s_e4b76edd446fe460": "Work mode", "s_e4bfcdad3c3e883a": "The theme colors are invalid", "s_e4c7386213cef812": "Processed {{p0}}", + "s_e4c8173406d11018": "Enable speech recognition", "s_e4c978a2101ede6d": "This round of file changes has been withdrawn", "s_e4f9590b09058415": "Everyday", "s_e4fbfa27831d24a6": "{{p0}} sessions deleted,{{p1}} deletion failed", @@ -5398,12 +5514,16 @@ export const sourceCatalogs = { "s_e788045df7db1869": "{{p0}} avatar relative path", "s_e78db524e7c1e3dd": "...and {{p0}} items", "s_e7a4ae94fa9dbad5": "Will open", + "s_e7a71bb4b5b5dbf4": "Speech recognition failed. Try again.", "s_e7bf600d2da7ec18": "Go back in navigation history", "s_e804670f73f0874a": "Shift+Enter for a new line", "s_e806fbbe8ec6e82d": "Plugins", + "s_e821845e5e671890": "Chat archived", "s_e833097b603cb745": "{{p0}} contexts", "s_e84e54a6b6dbbbb9": "Reads and writes are denied for: {{p0}}", + "s_e882813f20af3c37": "Requesting microphone access", "s_e8ac23cf743aa1f8": "Or approve a private chat request. The first person approved becomes the owner", + "s_e8c06cb613165321": "Microphone {{p0}}", "s_e8dc871a7fdee23b": "total", "s_e8e08b0f61dd4ddb": "Cancelling…", "s_e90589613fe54246": "Show hidden", @@ -5504,6 +5624,7 @@ export const sourceCatalogs = { "s_f1c10da84402db5d": "Global search", "s_f1e0dfb858e7fcfe": "Denied by hook", "s_f21f25112af89630": "The webhook URL is registered. Turn on Use webhook on the Messaging API page of the LINE Developers console; this changes to connected automatically once it is on.", + "s_f228e01256a4ddf4": "The microphone was disconnected. Check the device and try again.", "s_f27627514a2cb542": "Expert component failed to load", "s_f28461bb49c85647": "Completed", "s_f2902ada282332d2": "Unknown command, ran inside the sandbox", @@ -5568,6 +5689,7 @@ export const sourceCatalogs = { "s_f733015aee32d198": "Workspace menu", "s_f76a213bcdfd4d38": "Clippy", "s_f77e30ee1aed8819": "Pencil", + "s_f7b5141da1a73531": "Check the speech service URL, port, and settings.", "s_f7b94f909cd2bef0": "Reset global memory?", "s_f7dd1b546141b550": "In some special audit scenarios you may need to rewrite tools. Only enable this option if you know what you are doing.", "s_f7e1c1a17a6c9e7e": "Please use /btw or /side in an existing session first", diff --git a/web/src/lib/api.js b/web/src/lib/api.js index ec6101310..d785f88b9 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -283,7 +283,7 @@ async function request(method, path, body, base, options = {}) { const headers = {}; const token = baseToken(base); if (token) headers['X-ACECode-Token'] = token; - if (body !== undefined) headers['Content-Type'] = options.rawBody ? 'application/zip' : 'application/json'; + if (body !== undefined) headers['Content-Type'] = options.contentType || (options.rawBody ? 'application/zip' : 'application/json'); const rawTimeout = options.timeoutMs; const timeoutMs = rawTimeout === undefined @@ -708,6 +708,13 @@ export function createApi(base = null) { request('POST', '/api/memory/reset', memoryResetBody(scope, workspaceHash), base), getConnectors: () => request('GET', '/api/config/connectors', undefined, base), getImageGeneration: () => request('GET', '/api/config/image-generation', undefined, base), + getSpeechRecognition: () => request('GET', '/api/config/speech-recognition', undefined, base), + getSpeechAvailability: () => request('GET', '/api/speech-recognition', undefined, base), + setSpeechRecognition: (config) => request('PUT', '/api/config/speech-recognition', config, base, { keepalive: true }), + testSpeechRecognition: (config) => request('POST', '/api/config/speech-recognition/test', config, base, { timeoutMs: 35000 }), + transcribeSpeech: (id, audio, options = {}) => request('POST', '/api/speech-recognition/transcriptions/' + encodeURIComponent(id), + audio, base, { ...options, rawBody: true, contentType: 'audio/wav' }), + cancelSpeech: (id) => request('DELETE', '/api/speech-recognition/transcriptions/' + encodeURIComponent(id), undefined, base, { timeoutMs: 5000 }), getComputerUse: () => request('GET', '/api/config/computer-use', undefined, base), setComputerUse: (config) => request('PUT', '/api/config/computer-use', config, base, { keepalive: true }), requestComputerUsePermission: (permission) => request('POST', '/api/config/computer-use/permissions', { permission }, base), @@ -827,7 +834,7 @@ export function createApi(base = null) { listWorkspaces: () => request('GET', '/api/workspaces', undefined, base), listSessions: (hash) => request('GET', `/api/workspaces/${encodeURIComponent(hash)}/sessions?archived=1`, undefined, base), - listNoWorkspaceSessions: () => request('GET', '/api/sessions?archived=1', undefined, base), + listNoWorkspaceSessions: () => request('GET', '/api/sessions?archived=1&scope=no-workspace', undefined, base), }), searchSessionUserMessages: (query, limit = 50, options = {}) => request( diff --git a/web/src/lib/api.test.js b/web/src/lib/api.test.js index a894d6762..f75c7ada8 100644 --- a/web/src/lib/api.test.js +++ b/web/src/lib/api.test.js @@ -19,6 +19,33 @@ import { sessionTodosPath, } from './api.js'; +await run('archive aggregation scopes its compatibility request to no-workspace metadata', async () => { + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (url) => { + const parsed = new URL(url); + calls.push(parsed); + const body = parsed.pathname === '/api/workspaces' ? [{ hash: 'workspace', name: 'Project' }] + : parsed.pathname === '/api/sessions' ? [{ id: 'task', no_workspace: true }] + : [{ id: 'session' }]; + return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => body }; + }; + try { + const api = createApi({ origin: 'http://archive.test', token: 'fixture' }); + const result = await api.listAllArchivedSessions(); + assert.equal(calls.length, 3); + const compatibility = calls.find(url => url.pathname === '/api/sessions'); + assert.equal(compatibility.searchParams.get('scope'), 'no-workspace'); + assert.equal(compatibility.searchParams.get('archived'), '1'); + assert.equal(calls.find(url => url.pathname.endsWith('/workspace/sessions')).searchParams.get('archived'), '1'); + assert.deepEqual(result.sessions.map(row => row.id), ['session', 'task']); + assert.equal(result.sessions[0].workspace_hash, 'workspace'); + assert.equal(result.sessions[1].no_workspace, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + await run('sidebar queries retain expansion, pagination and reveal when URLSearchParams.size is unavailable', async () => { const originalFetch = globalThis.fetch; const OriginalSearchParams = globalThis.URLSearchParams; diff --git a/web/src/lib/appShortcuts.js b/web/src/lib/appShortcuts.js index dbc7287f7..36dd700fa 100644 --- a/web/src/lib/appShortcuts.js +++ b/web/src/lib/appShortcuts.js @@ -31,10 +31,10 @@ export function appShortcutContextAllows(id, target, doc = globalThis.document) if (id === 'search' && overlay.matches?.('[data-search-palette]')) continue; return false; } - if (['reasoningUp', 'reasoningDown', 'stop'].includes(id)) { + if (['reasoningUp', 'reasoningDown', 'stop', 'dictation'].includes(id)) { if (target?.closest?.('.xterm, [data-side-chat-window], [data-ace-editable-preview-text]')) return false; const editor = target?.closest?.('input, textarea, select, [contenteditable="true"]'); - if (editor && !editor.closest?.('[data-main-composer="true"]')) return false; + if (editor && !editor.closest?.(id === 'dictation' ? '[data-speech-composer]' : '[data-main-composer="true"]')) return false; } return true; } @@ -57,6 +57,7 @@ export function shortcutCatalog(mac = isMacShortcutHost()) { ['shortcuts', '界面导航', '打开快捷键页签', '进入快捷键并聚焦搜索栏'], ['find', '输入与编辑', '当前会话内查找', '在打开的会话中查找文本'], ['send', '输入与编辑', '发送消息', '在消息输入框中生效'], + ['dictation', '输入与编辑', '开始/停止录音', '语音识别启用后生效,停止后回填文字'], ['newline', '输入与编辑', '输入框换行', '在消息输入框中生效'], ['history', '输入与编辑', '浏览输入历史', '输入框为空时向上浏览,历史浏览中可向下返回'], ['save', '输入与编辑', '保存正在编辑的文件', '文件编辑器聚焦时生效'], diff --git a/web/src/lib/appShortcuts.test.js b/web/src/lib/appShortcuts.test.js index 45f67d85e..7902f60b5 100644 --- a/web/src/lib/appShortcuts.test.js +++ b/web/src/lib/appShortcuts.test.js @@ -26,6 +26,10 @@ for (const id of Object.keys(APP_SHORTCUTS)) { assert.equal(shortcutLabel('back', false), 'Ctrl+Shift+-'); assert.equal(shortcutLabel('reasoningUp', true), 'Cmd+Option+>'); assert.equal(shortcutLabel('console', true), 'Ctrl+`'); +assert.equal(shortcutLabel('dictation', true), 'Ctrl+D'); +assert.equal(shortcutLabel('dictation', false), 'Ctrl+D'); +assert.deepEqual(filterShortcuts(shortcutCatalog(false), '录音').map(row => row.id), ['dictation']); +assert.equal(appShortcutContextAllows('dictation', {closest: () => null}, {querySelectorAll: () => [{hidden:false,getClientRects:()=>[{}],matches:()=>false}]}), false); assert.equal(shortcutLabel('previewPresentation', true), 'Ctrl+F11'); assert.equal(matchAppShortcut({ key: 'F11' }), null); assert.equal(matchAppShortcut(key('F11', { shiftKey: true })), null); diff --git a/web/src/lib/archivedSessions.js b/web/src/lib/archivedSessions.js index 3e067a896..df3b6f4aa 100644 --- a/web/src/lib/archivedSessions.js +++ b/web/src/lib/archivedSessions.js @@ -1,5 +1,7 @@ import { sessionDisplayTitle } from './sessionTitle.js'; +export const ARCHIVED_SESSION_BATCH_SIZE = 100; + export function archivedSessionWorkspace(item = {}) { if (item?.no_workspace || item?.noWorkspace) { return { key: '__no_workspace__', name: '无工作区', path: '' }; @@ -60,6 +62,25 @@ export function groupArchivedSessions(items, { })); } +// Slice after filtering and sorting, sharing one row budget across workspaces. +export function paginateArchivedSessionGroups(groups, limit = ARCHIVED_SESSION_BATCH_SIZE) { + const source = Array.isArray(groups) ? groups : []; + let remaining = Number.isFinite(limit) && limit > 0 + ? Math.floor(limit) : ARCHIVED_SESSION_BATCH_SIZE; + const total = source.reduce((count, group) => count + group.items.length, 0); + const visible = []; + let visibleCount = 0; + for (const group of source) { + if (remaining <= 0) break; + const items = group.items.slice(0, remaining); + if (items.length === 0) continue; + visible.push({ ...group, items, total: group.items.length }); + visibleCount += items.length; + remaining -= items.length; + } + return { groups: visible, total, visibleCount, hasMore: visibleCount < total }; +} + export function archivedSessionTarget(item = {}) { const id = String(item?.id || item?.session_id || item?.sessionId || '').trim(); const workspaceHash = String( diff --git a/web/src/lib/archivedSessions.test.js b/web/src/lib/archivedSessions.test.js index d169391a4..65755daa4 100644 --- a/web/src/lib/archivedSessions.test.js +++ b/web/src/lib/archivedSessions.test.js @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import { allArchivedSessionsSelected, + ARCHIVED_SESSION_BATCH_SIZE, archivedSessionKey, archivedSessionTarget, archivedSessionWorkspace, groupArchivedSessions, + paginateArchivedSessionGroups, removeArchivedSessionsByKey, retainArchivedSessionSelection, selectableArchivedSessionKeys, @@ -23,6 +25,58 @@ function test(name, fn) { } } +test('archive batches share a 100-row budget across groups and retain full group counts', () => { + const items = Array.from({ length: 205 }, (_, i) => ({ id: `s-${i}`, workspace_hash: i < 5 ? 'a' : 'b' })); + const grouped = groupArchivedSessions(items); + const snapshot = JSON.stringify(grouped); + const first = paginateArchivedSessionGroups(grouped); + assert.equal(first.visibleCount, ARCHIVED_SESSION_BATCH_SIZE); + assert.equal(first.total, 205); + assert.equal(first.hasMore, true); + assert.deepEqual(first.groups.map(g => [g.key, g.items.length, g.total]), [['a', 5, 5], ['b', 95, 200]]); + const second = paginateArchivedSessionGroups(grouped, 200); + assert.equal(second.visibleCount, 200); + assert.equal(second.hasMore, true); + assert.deepEqual(second.groups[0], first.groups[0]); + const last = paginateArchivedSessionGroups(grouped, 300); + assert.equal(last.visibleCount, 205); + assert.equal(last.hasMore, false); + assert.equal(new Set(last.groups.flatMap(g => g.items.map(archivedSessionKey))).size, 205); + assert.equal(JSON.stringify(grouped), snapshot); +}); + +test('archive batch boundary handles zero, 100, 101 and invalid limits', () => { + for (const size of [0, 99, 100, 101]) { + const grouped = groupArchivedSessions(Array.from({ length: size }, (_, i) => ({ id: String(i) }))); + const page = paginateArchivedSessionGroups(grouped); + assert.equal(page.visibleCount, Math.min(size, 100)); + assert.equal(page.hasMore, size > 100); + assert.equal(page.groups.length, size ? 1 : 0); + for (const limit of [0, -1, NaN, Infinity]) { + assert.deepEqual(paginateArchivedSessionGroups(grouped, limit), page); + } + } + assert.deepEqual(paginateArchivedSessionGroups(null), { groups: [], total: 0, visibleCount: 0, hasMore: false }); +}); + +test('archive batches search the full catalog and select only mounted rows', () => { + const items = Array.from({ length: 205 }, (_, i) => ({ id: `s-${i}`, title: `Archive ${i}`, updated_at: i })); + const first = paginateArchivedSessionGroups(groupArchivedSessions(items, { sortOrder: 'oldest' })); + const shown = first.groups.flatMap(g => g.items); + const selected = toggleAllArchivedSessionSelection(shown, new Set()); + assert.equal(selected.size, 100); + const next = paginateArchivedSessionGroups(groupArchivedSessions(items, { sortOrder: 'oldest' }), 200); + assert.equal(selectedArchivedSessions(next.groups.flatMap(g => g.items), selected).length, 100); + const searched = paginateArchivedSessionGroups(groupArchivedSessions(items, { query: 'Archive 204' })); + assert.equal(searched.visibleCount, 1); + assert.equal(searched.groups[0].items[0].id, 's-204'); + assert.equal(retainArchivedSessionSelection(searched.groups[0].items, selected).size, 0); + const remaining = removeArchivedSessionsByKey(items, [archivedSessionKey(shown[0])]); + const refilled = paginateArchivedSessionGroups(groupArchivedSessions(remaining, { sortOrder: 'oldest' })); + assert.equal(refilled.visibleCount, 100); + assert.equal(refilled.groups[0].items.at(-1).id, 's-100'); +}); + test('archived session identity includes workspace and accepts legacy field names', () => { assert.deepEqual( archivedSessionTarget({ session_id: 'session-a', workspaceHash: 'workspace-1' }), diff --git a/web/src/lib/composerEditabilityArchitecture.test.js b/web/src/lib/composerEditabilityArchitecture.test.js index c00c632df..a45c98cac 100644 --- a/web/src/lib/composerEditabilityArchitecture.test.js +++ b/web/src/lib/composerEditabilityArchitecture.test.js @@ -118,7 +118,7 @@ run('InputBar 把 submitting 只接到提交动作上', () => { // 回车提交也要挡重复提交,否则去掉 disabled 后连点两次会发两条。 assert.match( inputBar, - /if \(!actionState\.canSubmit\) return;/, + /if \(!currentAction\.canSubmit\) return;/, ); // RichComposer(Slate readOnly 的唯一来源)只能吃 disabled,不能吃 submitting。 const composerProps = inputBar.match(//)?.[0] || ''; diff --git a/web/src/lib/icons/controls.js b/web/src/lib/icons/controls.js index f0b157503..da469891a 100644 --- a/web/src/lib/icons/controls.js +++ b/web/src/lib/icons/controls.js @@ -30,6 +30,7 @@ function softPolygon(points, radius = .3) { const swarmCenters = [[10, 10], [10, 5.7], [13.72, 7.85], [13.72, 12.15], [10, 14.3], [6.28, 12.15], [6.28, 7.85]]; export const controlIcons = { + Microphone: [r(7, 2.5, 6, 10, 3), p('M4.5 9.5v.5a5.5 5.5 0 0 0 11 0v-.5M10 15.5v2M7.5 17.5h5')], Columns: [panel, p('M10 3.5v13')], Add: [p('M10 4v12M4 10h12')], AddFolder: [p('M8.5 17H3.8q-1.3 0-1.3-1.3V5.3Q2.5 4 3.8 4h4.3q.5 0 .8.4l1.1 1.7q.3.4.8.4h5.4q1.3 0 1.3 1.3v2.7'), p('M14 11.5v6M11 14.5h6')], diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index d6fed20e2..57b195760 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -346,6 +346,7 @@ import './remoteWebArchitecture.test.js'; // series after the ordinary static-import suites so top-level await cannot // leak a temporary English locale into an unrelated presentation test. await import('./imageGenerationSettings.test.js'); +await import('./speechRecognition.test.js'); await import('./summaryGenerationSettings.test.js'); await import('./computerUseSettings.test.js'); await import('./computerUsePointerTheme.test.js'); diff --git a/web/src/lib/settingsSearch.js b/web/src/lib/settingsSearch.js index 38efe22cc..2b074077e 100644 --- a/web/src/lib/settingsSearch.js +++ b/web/src/lib/settingsSearch.js @@ -49,6 +49,7 @@ export function settingsSearchEntries(developerModeUnlocked = false) { ['tools', 'iOS 模拟器', 'ios simulator iphone ipad xcode 模拟器 simctl device stream screenshot 设备 画面 帧率 画质'], ['tools', '电脑操控(实验性)', 'computer use experimental windows macos accessibility screen recording desktop mouse keyboard screenshot pointer cursor style theme color ace plain 指针 样式 主题色'], ['tools', '图像生成', 'image generation drawing'], + ['tools', '语音识别', 'speech voice dictation microphone sensevoice whisper 录音 听写 麦克风 内网 地址 端口'], ['tools', '摘要生成', 'summary title generation local model 摘要模型 会话标题'], ['tools', '工具重写', 'tool rewrite rename alias audit'], ['security', '沙箱安全', 'sandbox security isolation network access'], diff --git a/web/src/lib/shortcutBindings.js b/web/src/lib/shortcutBindings.js index f0836f40e..c60602801 100644 --- a/web/src/lib/shortcutBindings.js +++ b/web/src/lib/shortcutBindings.js @@ -4,6 +4,7 @@ const binding = (code, key, extra = {}) => Object.freeze({ code, key, ...extra } export const APP_SHORTCUTS = Object.freeze({ newSession: binding('KeyN', 'N', { alt: true }), focusInput: binding('KeyI', 'I', { alt: true }), + dictation: binding('KeyD', 'D', { control: true }), toggleSidebar: binding('KeyB', 'B', { alt: true }), toggleRightPanel: binding('KeyE', 'E', { alt: true }), previewPresentation: binding('F11', 'F11', { control: true }), diff --git a/web/src/lib/sidebarWorkspaceRefresh.test.js b/web/src/lib/sidebarWorkspaceRefresh.test.js index bb1feb001..60ce13f4c 100644 --- a/web/src/lib/sidebarWorkspaceRefresh.test.js +++ b/web/src/lib/sidebarWorkspaceRefresh.test.js @@ -54,6 +54,7 @@ function fixture({ loading: new Set(), loaded: new Set(loaded ? ['w'] : []), fullyLoaded: new Set(), totals: new Map(), statuses: new Map(), workspaces: [workspace, ...otherWorkspaces], expandedSessionLists: new Map(), + retainedSyncs: [], }; const requests = []; const context = vm.createContext({ @@ -79,7 +80,7 @@ function fixture({ desktopTaskbarBadgeAvailable: () => false, connection: { subscribeWorkspaceStatus() {} }, refreshOpencodeImportPreview: async () => {}, - syncRetainedSessionIds() {}, cancelSessionSelection() {}, onOpenHome() {}, onBeforeNavigate: null, + syncRetainedSessionIds(ids) { state.retainedSyncs.push(ids); }, cancelSessionSelection() {}, onOpenHome() {}, onBeforeNavigate: null, acceptSidebarTreePage() {}, sidebarTreeOptions: (scope, limit) => ({ limit }), setPinnedMap: (value) => { context.pinnedByWorkspaceRef.current = value; }, @@ -96,6 +97,7 @@ function fixture({ pendingWorkspaceLoadsRef: new Map(), refreshingRef: false, pendingRefreshHashRef: '', treeSavingRef: false, treeRevisionRef: 0, + archiveOperationsRef: new Set(), refreshSelectionRef: { activeWorkspaceHash: 'w', revealTarget: { noWorkspace: false, workspaceHash: 'w' } }, expandedRef: new Set(['w']), workspaceCollapseAllRef: false, userCollapsedWorkspacesRef: new Set(), @@ -138,7 +140,9 @@ function fixture({ for (const name of [ 'setSessionWorkspaceLoading', 'setSessionWorkspacesLoaded', 'markWorkspaceSessionsFullyLoaded', 'applyWorkspaceSessionList', 'loadWorkspaceSessions', 'toggleSessionListExpanded', 'refresh', 'onActivate', + 'invalidateArchiveLists', 'refreshArchivedScope', ]) installCallback(context, name); + context.treeApplyRef = { current: (scope, page) => context.applyWorkspaceSessionList({ hash: scope }, page) }; return { context, state, requests, workspace }; } @@ -397,6 +401,64 @@ test('sidebar default session list is explicitly scoped to no-workspace tasks', assert.doesNotMatch(source, /api.listSessions\(\)/); }); +test('archive replaces a compact page atomically and rejects a pre-archive polling result', async () => { + const { context, state, requests } = fixture(); + const staleRefresh = context.refresh(); + await nextTask(); + context.invalidateArchiveLists('w'); + const replacement = sessions(6).slice(1); + const archiveRefresh = context.refreshArchivedScope('w'); + assert.equal(state.sessions[0].id, 'session-0', 'keep the complete page until its replacement arrives'); + requests[1].resolve({ sessions: replacement, total: 19, has_more: true }); + assert.equal(await archiveRefresh, true); + assert.equal(state.sessions.length, 5); + assert.ok(state.sessions.every(session => session.id !== 'session-0')); + requests[0].resolve(compactPage()); + await staleRefresh; + assert.equal(state.sessions.length, 5); + assert.ok(state.sessions.every(session => session.id !== 'session-0')); + assert.equal(state.totals.get('w'), 19); + assert.equal(state.retainedSyncs.length, 0, 'discarded refresh must not release current status subscriptions'); +}); + +test('concurrent archives keep the newest page when their responses arrive out of order', async () => { + const { context, state, requests } = fixture(); + context.invalidateArchiveLists('w'); + const first = context.refreshArchivedScope('w'); + context.invalidateArchiveLists('w'); + const second = context.refreshArchivedScope('w'); + requests[1].resolve({ sessions: sessions(7).slice(2), total: 18, has_more: true }); + await second; + requests[0].resolve({ sessions: sessions(6).slice(1), total: 19, has_more: true }); + await first; + assert.equal(state.sessions.length, 5); + assert.ok(state.sessions.every(session => !['session-0', 'session-1'].includes(session.id))); + assert.equal(state.totals.get('w'), 18); +}); + +test('only the latest failed archive refresh requests a local removal fallback', async () => { + const { context, requests } = fixture(); + const first = context.refreshArchivedScope('w'); + const second = context.refreshArchivedScope('w'); + requests[0].reject(new Error('old failure')); + requests[1].reject(new Error('latest failure')); + assert.equal(await first, true); + assert.equal(await second, false); +}); + +test('archive invalidation also rejects pinned metadata collected before the mutation', async () => { + const pinned = deferred(); + const { context, requests } = fixture({ pinned }); + const refresh = context.refresh(); + await nextTask(); + context.invalidateArchiveLists('w'); + context.pinnedByWorkspaceRef.current = new Map([['w', []]]); + requests[0].resolve(compactPage()); + pinned.resolve({ session_ids: ['session-0'] }); + await refresh; + assert.deepEqual(context.pinnedByWorkspaceRef.current.get('w'), []); +}); + let failures = 0; for (const { name, run } of tests) { try { diff --git a/web/src/lib/speechAudio.js b/web/src/lib/speechAudio.js new file mode 100644 index 000000000..31d7eec8b --- /dev/null +++ b/web/src/lib/speechAudio.js @@ -0,0 +1,101 @@ +import { speechAudioConstraints } from './speechDevices.js'; + +// WAV is accepted by all supported services, without a bundled decoder. +export function encodeSpeechWav(chunks, sampleRate, maxSeconds = 60) { + if (!Number.isFinite(sampleRate) || sampleRate <= 0) throw new Error('Invalid sample rate'); + const total = Math.min(chunks.reduce((sum, chunk) => sum + chunk.length, 0), Math.floor(sampleRate * maxSeconds)); + const input = new Float32Array(total); + let offset = 0; + for (const chunk of chunks) { + const count = Math.min(chunk.length, total - offset); + if (count <= 0) break; + input.set(chunk.subarray(0, count), offset); offset += count; + } + const samples = Math.floor(total * 16000 / sampleRate); + if (!samples) throw Object.assign(new Error('Empty recording'), { code: 'SPEECH_NO_SPEECH' }); + const buffer = new ArrayBuffer(44 + samples * 2); + const data = new DataView(buffer); + const text = (at, value) => { [...value].forEach((letter, i) => data.setUint8(at + i, letter.charCodeAt(0))); }; + text(0, 'RIFF'); data.setUint32(4, buffer.byteLength - 8, true); + text(8, 'WAVE'); text(12, 'fmt '); + data.setUint32(16, 16, true); data.setUint16(20, 1, true); data.setUint16(22, 1, true); + data.setUint32(24, 16000, true); data.setUint32(28, 32000, true); + data.setUint16(32, 2, true); data.setUint16(34, 16, true); + text(36, 'data'); data.setUint32(40, samples * 2, true); + const ratio = sampleRate / 16000; + for (let i = 0; i < samples; i += 1) { + const begin = Math.floor(i * ratio); + const end = Math.min(total, Math.max(begin + 1, Math.floor((i + 1) * ratio))); + let value = 0; + for (let j = begin; j < end; j += 1) value += input[j]; + value = Math.max(-1, Math.min(1, value / (end - begin))); + data.setInt16(44 + i * 2, Math.round(value < 0 ? value * 32768 : value * 32767), true); + } + return new Blob([buffer], { type: 'audio/wav' }); +} + +export async function captureSpeechAudio({ signal, maxSeconds, deviceId, onLevel, onLimit, onError }, environment = globalThis) { + if (environment.isSecureContext === false) + throw Object.assign(new Error('Microphone requires a secure page'), { code: 'SPEECH_INSECURE_CONTEXT' }); + const Context = environment.AudioContext || environment.webkitAudioContext; + if (!environment.navigator?.mediaDevices?.getUserMedia || !Context) + throw Object.assign(new Error('Microphone is unavailable'), { code: 'SPEECH_UNSUPPORTED' }); + let context = null, stream = null, source = null, processor = null, mute = null, timer = null; + let closed = false, total = 0; + let chunks = []; + const cleanup = () => { + if (closed) return; + closed = true; + if (timer != null) environment.clearTimeout(timer); + signal.removeEventListener('abort', cleanup); + if (processor) processor.onaudioprocess = null; + for (const node of [source, processor, mute]) { try { node?.disconnect(); } catch {} } + for (const track of stream?.getTracks() || []) track.stop(); + if (context) Promise.resolve(context.close()).catch(() => {}); + }; + signal.addEventListener('abort', cleanup, { once: true }); + try { + if (signal.aborted) throw new DOMException('Cancelled', 'AbortError'); + context = new Context(); + // Resume in the user gesture before waiting on the permission prompt. + const resumed = context.resume().then(() => null, (error) => error); + stream = await environment.navigator.mediaDevices.getUserMedia(speechAudioConstraints(deviceId)); + if (closed || signal.aborted) { + stream.getTracks().forEach((track) => track.stop()); + throw new DOMException('Cancelled', 'AbortError'); + } + const resumeError = await resumed; + if (resumeError) throw resumeError; + if (closed || signal.aborted) throw new DOMException('Cancelled', 'AbortError'); + source = context.createMediaStreamSource(stream); + processor = context.createScriptProcessor(2048, 1, 1); + mute = context.createGain(); mute.gain.value = 0; + processor.onaudioprocess = (event) => { + if (closed) return; + const incoming = event.inputBuffer.getChannelData(0); + const count = Math.min(incoming.length, Math.floor(context.sampleRate * maxSeconds) - total); + if (count <= 0) return; + const chunk = new Float32Array(incoming.subarray(0, count)); + chunks.push(chunk); total += count; + const energy = Math.sqrt(chunk.reduce((sum, sample) => sum + sample * sample, 0) / count); + onLevel?.(Math.min(1, energy * 4), total / context.sampleRate); + if (total >= Math.floor(context.sampleRate * maxSeconds)) onLimit?.(); + }; + for (const track of stream.getAudioTracks()) track.addEventListener('ended', () => { + if (!closed) onError?.({ code: 'SPEECH_DEVICE_ENDED' }); + }, { once: true }); + source.connect(processor); processor.connect(mute); mute.connect(context.destination); + timer = environment.setTimeout(() => { if (!closed) onLimit?.(); }, maxSeconds * 1000); + return { + stop() { + cleanup(); + try { return encodeSpeechWav(chunks, context.sampleRate, maxSeconds); } + finally { chunks = []; } + }, + cancel() { cleanup(); chunks = []; }, + }; + } catch (error) { + cleanup(); + throw error; + } +} diff --git a/web/src/lib/speechDevices.js b/web/src/lib/speechDevices.js new file mode 100644 index 000000000..4ccb0d6ee --- /dev/null +++ b/web/src/lib/speechDevices.js @@ -0,0 +1,68 @@ +import { readWithFallback, writeSafely } from './usePreference.js'; + +export const SPEECH_DEVICE_PREFS_KEY = 'acecode.speechInput.v1'; +const defaults = Object.freeze({ deviceId: 'default', holdToRecord: false }); +const normalize = (value) => Object.freeze({ + deviceId: typeof value?.deviceId === 'string' && value.deviceId ? value.deviceId : 'default', + holdToRecord: value?.holdToRecord === true, +}); + +export function createSpeechDeviceStore(storage, events) { + const read = () => normalize(readWithFallback(SPEECH_DEVICE_PREFS_KEY, defaults, null, storage)); + let state = read(); + const listeners = new Set(); + const publish = () => { for (const listener of listeners) listener(); }; + const onStorage = (event) => { + if (event.key !== SPEECH_DEVICE_PREFS_KEY && event.key !== null) return; + state = read(); publish(); + }; + return { + getSnapshot: () => state, + subscribe(listener) { + if (!listeners.size) events?.addEventListener('storage', onStorage); + listeners.add(listener); + return () => { listeners.delete(listener); if (!listeners.size) events?.removeEventListener('storage', onStorage); }; + }, + update(patch) { + state = normalize({ ...state, ...patch }); + writeSafely(SPEECH_DEVICE_PREFS_KEY, state, storage); + publish(); + }, + }; +} +let store; +export function speechDeviceStore() { + if (!store) { + let storage; + try { storage = globalThis.localStorage; } catch {} + store = createSpeechDeviceStore(storage, globalThis.window); + } + return store; +} + +export const speechAudioConstraints = (deviceId) => ({ + audio: { channelCount: 1, ...(deviceId && deviceId !== 'default' ? { deviceId: { exact: deviceId } } : {}) }, + video: false, +}); + +export async function listSpeechDevices({ signal, requestPermission = true } = {}, environment = globalThis) { + const check = () => { if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError'); }; + if (environment.isSecureContext === false) throw { code: 'SPEECH_INSECURE_CONTEXT' }; + const media = environment.navigator?.mediaDevices; + if (!media?.enumerateDevices || !media?.getUserMedia) throw { code: 'SPEECH_UNSUPPORTED' }; + check(); + let devices = (await media.enumerateDevices()).filter((device) => device.kind === 'audioinput'); + check(); + if (requestPermission && (!devices.length || devices.some((device) => !device.label || !device.deviceId))) { + let permission; + try { permission = await media.getUserMedia({ audio: true, video: false }); check(); } + finally { permission?.getTracks().forEach((track) => track.stop()); } + devices = (await media.enumerateDevices()).filter((device) => device.kind === 'audioinput'); + check(); + } + const seen = new Set(); + const result = devices.filter((device) => device.deviceId && !seen.has(device.deviceId) && seen.add(device.deviceId)) + .map((device) => ({ id: device.deviceId, label: device.label })); + if (!result.some((device) => device.id === 'default')) result.unshift({ id: 'default', label: '' }); + return result; +} diff --git a/web/src/lib/speechRecognition.js b/web/src/lib/speechRecognition.js new file mode 100644 index 000000000..a963514f1 --- /dev/null +++ b/web/src/lib/speechRecognition.js @@ -0,0 +1,163 @@ +import { apiConnectionScope } from './api.js'; + +export const SPEECH_DEFAULTS = Object.freeze({ + enabled: false, protocol: 'sensevoice', base_url: '', model: '', api_key: '', + language: 'auto', timeout_ms: 120000, max_recording_seconds: 60, use_proxy: false, +}); +export const speechEnabled = (value) => !!value?.enabled && !!value?.configured; + +export function speechPayload(draft) { + return Object.fromEntries(Object.keys(SPEECH_DEFAULTS).map((key) => { + const value = draft[key]; + return [key, typeof SPEECH_DEFAULTS[key] === 'number' ? Number(value) + : typeof SPEECH_DEFAULTS[key] === 'string' ? String(value ?? '').trim() : !!value]; + })); +} + +export function speechDraftValid(draft, requireComplete = false) { + if (!draft || !['sensevoice', 'openai', 'whisper_cpp'].includes(draft.protocol)) return false; + const value = speechPayload(draft); + if (!Number.isInteger(value.timeout_ms) || value.timeout_ms < 5000 || value.timeout_ms > 600000 + || !Number.isInteger(value.max_recording_seconds) || value.max_recording_seconds < 1 || value.max_recording_seconds > 300) return false; + if (value.base_url) { + try { + const url = new URL(value.base_url); + if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password + || url.search || url.hash || /\s|\\/.test(value.base_url) || url.port === '0') return false; + } catch { return false; } + } + return !requireComplete || (!!value.base_url && (value.protocol !== 'openai' || !!value.model)); +} + +export function speechErrorMessage(error) { + const code = error?.code || error?.name || error; + return ({ + SPEECH_INVALID_CONFIG: '请检查语音识别地址、端口和配置。', + PERSIST_FAILED: '保存语音识别配置失败,请重试。', + SPEECH_NOT_CONFIGURED: '请先在设置的工具页配置并启用语音识别。', + SPEECH_CONNECTION_FAILED: '无法连接语音识别服务,请检查地址、端口和网络。', + SPEECH_AUTH_FAILED: '语音服务认证失败,请检查 API Key。', + SPEECH_NOT_READY: '语音模型尚未就绪,请稍后重试。', + SPEECH_BUSY: '语音识别正在处理其他录音,请稍后重试。', + SPEECH_UPSTREAM_FAILED: '语音识别服务返回错误,请稍后重试。', + SPEECH_INVALID_AUDIO: '录音格式或时长不符合服务要求,请重新录制。', + SPEECH_INVALID_RESPONSE: '语音服务返回的内容无法识别,请检查接口协议。', + SPEECH_TIMEOUT: '语音识别超时,请重试或调整请求超时。', + TIMEOUT: '语音识别超时,请稍后重试。', + SPEECH_NO_SPEECH: '没有识别到语音,请重新录制。', + SPEECH_INSECURE_CONTEXT: '当前网页无法使用麦克风,请使用 ACECode Desktop、localhost 或 HTTPS 页面。', + SPEECH_UNSUPPORTED: '当前环境不支持麦克风录音,请使用 ACECode Desktop 或支持录音的浏览器。', + NotAllowedError: '麦克风权限被拒绝,请在系统或浏览器中允许访问。', + NotFoundError: '没有找到可用的麦克风。', + OverconstrainedError: '所选麦克风不可用,请右键重新选择。', + NotReadableError: '无法读取麦克风,请检查设备是否被其他程序占用。', + SPEECH_DEVICE_ENDED: '麦克风已断开,请检查设备后重试。', + })[code] || '语音识别失败,请重试。'; +} + +// Only availability is shared with composers. Credentials stay in the settings +// view and authenticated API, never in localStorage or global DOM events. +const availabilityStores = new WeakMap(); +export function speechAvailabilityStore(client) { + const scope = apiConnectionScope(client); + if (availabilityStores.has(scope)) return availabilityStores.get(scope); + let state = Object.freeze({ enabled: false, configured: false, generation: -1 }); + const listeners = new Set(); + let reading = null; + let mutation = 0; + const store = { + getSnapshot: () => state, + subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, + publish(value) { + mutation += 1; + state = Object.freeze({ + enabled: !!value.enabled, configured: !!value.configured, generation: Number(value.generation) || 0, + max_recording_seconds: Number(value.max_recording_seconds) || 60, timeout_ms: Number(value.timeout_ms) || 120000, + }); + for (const listener of listeners) listener(); + }, + load() { + if (reading) return reading; + const revision = mutation; + reading = Promise.resolve().then(() => client.getSpeechAvailability()) + .then((data) => { + if (revision === mutation && apiConnectionScope(client) === scope) store.publish(data); + }).catch(() => { + if (revision === mutation) store.publish({ enabled: false, configured: false }); + }).finally(() => { reading = null; }); + return reading; + }, + }; + availabilityStores.set(scope, store); + return store; +} + +const settingsStores = new WeakMap(); +export function speechSettingsStore(client) { + const scope = apiConnectionScope(client); + if (settingsStores.has(scope)) return settingsStores.get(scope); + let state = { draft: null, snapshot: null, loading: false, saving: false, error: null }; + let reading = null, writing = null, requested = false; + const listeners = new Set(); + const publish = (patch) => { + state = { ...state, ...patch }; + for (const listener of listeners) listener(); + }; + const changed = () => state.draft && state.snapshot && JSON.stringify(speechPayload(state.draft)) !== JSON.stringify(speechPayload(state.snapshot)); + const store = { + getSnapshot: () => state, + subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, + update(field, value) { + if (state.draft) publish({ draft: { ...state.draft, [field]: value }, error: null }); + }, + load() { + if (reading) return reading; + reading = Promise.resolve().then(async () => { + if (writing) await writing; + if (changed()) return; + publish({ loading: true, error: null }); + try { + const snapshot = await client.getSpeechRecognition(); + if (apiConnectionScope(client) !== scope) return; + publish({ snapshot, draft: { ...SPEECH_DEFAULTS, ...snapshot } }); + speechAvailabilityStore(client).publish(snapshot); + } catch (error) { publish({ error: { code: error?.code || error?.name, action: 'load' } }); } + finally { publish({ loading: false }); } + }).finally(() => { reading = null; }); + return reading; + }, + flush() { + requested = true; + if (writing) return writing; + writing = Promise.resolve().then(async () => { + while (requested && state.snapshot) { + requested = false; + if (!changed()) continue; + const submitted = state.draft; + if (!speechDraftValid(submitted)) { + publish({ error: { code: 'SPEECH_INVALID_CONFIG', action: 'save' } }); + return false; + } + publish({ saving: true, error: null }); + try { + if (apiConnectionScope(client) !== scope) throw new Error('Connection changed'); + const snapshot = await client.setSpeechRecognition(speechPayload(submitted)); + const draft = { ...SPEECH_DEFAULTS, ...snapshot }; + for (const key of Object.keys(SPEECH_DEFAULTS)) { + if (state.draft[key] !== submitted[key]) draft[key] = state.draft[key]; + } + publish({ snapshot, draft }); + speechAvailabilityStore(client).publish(snapshot); + } catch (error) { + publish({ error: { code: error?.code || error?.name, action: 'save' } }); + return false; + } + } + return true; + }).finally(() => { writing = null; publish({ saving: false }); }); + return writing; + }, + }; + settingsStores.set(scope, store); + return store; +} diff --git a/web/src/lib/speechRecognition.test.js b/web/src/lib/speechRecognition.test.js new file mode 100644 index 000000000..040090875 --- /dev/null +++ b/web/src/lib/speechRecognition.test.js @@ -0,0 +1,293 @@ +import assert from 'node:assert/strict'; +import { speechAvailabilityStore, speechDraftValid, speechEnabled, speechPayload, speechSettingsStore, SPEECH_DEFAULTS } from './speechRecognition.js'; +import { encodeSpeechWav, captureSpeechAudio } from './speechAudio.js'; +import { createSpeechSession } from './speechSession.js'; +import { createSpeechDeviceStore, listSpeechDevices, speechAudioConstraints, SPEECH_DEVICE_PREFS_KEY } from './speechDevices.js'; + +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; +const configured = { ...SPEECH_DEFAULTS, enabled: true, configured: true, base_url: 'http://192.168.1.50:9876', generation: 1 }; + +{ + const values = new Map(); + const storage = {getItem:key=>values.get(key),setItem:(key,value)=>values.set(key,value)}; + const first = createSpeechDeviceStore(storage); + first.update({deviceId:'usb-microphone',holdToRecord:true}); + assert.equal(JSON.parse(values.get(SPEECH_DEVICE_PREFS_KEY)).deviceId,'usb-microphone'); + assert.deepEqual(createSpeechDeviceStore(storage).getSnapshot(),{deviceId:'usb-microphone',holdToRecord:true}); + assert.deepEqual(speechAudioConstraints('usb-microphone'),{audio:{channelCount:1,deviceId:{exact:'usb-microphone'}},video:false}); + assert.deepEqual(speechAudioConstraints('default'),{audio:{channelCount:1},video:false}); +} +{ + let calls=0,stopped=0; + const permission=deferred(); + const media={enumerateDevices:async()=>[{kind:'audioinput',deviceId:'',label:''}], + getUserMedia:()=>{calls++;return permission.promise;}}; + const abort=new AbortController(); + const listing=listSpeechDevices({signal:abort.signal},{navigator:{mediaDevices:media}}); + await Promise.resolve(); + assert.equal(calls,1); + abort.abort(); + permission.resolve({getTracks:()=>[{stop(){stopped++;}}]}); + await assert.rejects(listing,{name:'AbortError'}); + assert.equal(stopped,1,'closing the menu releases a late permission stream'); + const devices=await listSpeechDevices({}, {navigator:{mediaDevices:{ + enumerateDevices:async()=>[{kind:'audioinput',deviceId:'usb',label:'USB'},{kind:'videoinput',deviceId:'camera',label:'Camera'}], + getUserMedia(){throw Error('labels already available');}, + }}}); + assert.deepEqual(devices,[{id:'default',label:''},{id:'usb',label:'USB'}]); +} + +assert.equal(speechEnabled(SPEECH_DEFAULTS), false); +assert.equal(speechEnabled({ ...configured, enabled: false }), false); +assert.equal(speechEnabled({ ...configured, configured: false }), false); +assert.equal(speechDraftValid(configured, true), true); +assert.equal(speechDraftValid({ ...configured, protocol: 'openai' }, true), false); +assert.equal(speechDraftValid({ ...configured, protocol: 'openai', model: 'whisper' }, true), true); +for (const base_url of ['file:///audio', 'http://user:key@server', 'http://server:0', 'http://server:65536', 'http://server?secret=value']) { + assert.equal(speechDraftValid({ ...configured, base_url }, true), false, base_url); +} +assert.equal(speechPayload({ ...configured, api_key: '' }).api_key, '', 'clearing a saved key must be explicit'); + +{ + const pending = deferred(); + const client = { getSpeechAvailability: () => pending.promise }; + const store = speechAvailabilityStore(client); + const load = store.load(); + store.publish({ ...configured, generation: 2, api_key: 'never-share' }); + pending.resolve({ enabled: false, configured: false, generation: 1 }); + await load; + assert.equal(store.getSnapshot().enabled, true, 'an older GET cannot overwrite a successful save'); + assert.equal(store.getSnapshot().generation, 2); + assert.equal('api_key' in store.getSnapshot(), false); +} +{ + const saved = deferred(); + let snapshot = configured; + const client = { + getSpeechRecognition: async () => snapshot, + setSpeechRecognition: async (payload) => { await saved.promise; snapshot = { ...payload, configured: true, generation: 2 }; return snapshot; }, + }; + const store = speechSettingsStore(client); + await store.load(); + store.update('base_url', 'http://speech.internal:7777'); + const write = store.flush(); + await Promise.resolve(); + store.update('model', 'an-edit-made-during-save'); + saved.resolve(); + assert.equal(await write, true); + assert.equal(store.getSnapshot().draft.model, 'an-edit-made-during-save'); + assert.equal(speechAvailabilityStore(client).getSnapshot().generation, 2); +} +{ + const client = { + getSpeechRecognition: async () => configured, + setSpeechRecognition: async () => { throw { code: 'PERSIST_FAILED' }; }, + }; + const store = speechSettingsStore(client); + await store.load(); + store.update('base_url', 'http://speech.internal:7777'); + assert.equal(await store.flush(), false); + await store.load(); + assert.equal(store.getSnapshot().draft.base_url, 'http://speech.internal:7777', 'failed saves keep edits on reopen'); +} +{ + const first = deferred(); + const calls = []; + let snapshot = configured, active = 0, peak = 0; + const client = { + getSpeechRecognition: async () => snapshot, + setSpeechRecognition: async (payload) => { + calls.push(payload); peak = Math.max(peak, ++active); + if (calls.length === 1) await first.promise; + snapshot = { ...payload, configured: true, generation: snapshot.generation + 1 }; + active -= 1; + return snapshot; + }, + }; + const store = speechSettingsStore(client); + await store.load(); + assert.equal(await store.flush(), true); + assert.equal(calls.length, 0, 'closing an unchanged form does not persist or cancel recognition'); + store.update('base_url', 'http://speech.internal:7777'); + const write = store.flush(); + await Promise.resolve(); + store.update('base_url', 'http://speech.internal:8888'); + store.update('api_key', 'edited-during-request'); + assert.equal(store.flush(), write); + const reopened = store.load(); + first.resolve(); + assert.equal(await write, true); + await reopened; + assert.equal(calls.length, 2, 'blur during a pending request queues the latest draft'); + assert.equal(peak, 1, 'automatic writes are serialized'); + assert.equal(snapshot.base_url, 'http://speech.internal:8888'); + assert.equal(store.getSnapshot().draft.api_key, 'edited-during-request'); + store.update('api_key', ''); + await store.flush(); + assert.equal(snapshot.api_key, '', 'clearing the key autosaves an explicit empty value'); +} +{ + let fail = true, calls = 0; + const client = { + getSpeechRecognition: async () => configured, + setSpeechRecognition: async (payload) => { + calls += 1; + if (fail) throw { code: 'PERSIST_FAILED' }; + return { ...payload, configured: true, generation: 2 }; + }, + }; + const store = speechSettingsStore(client); + await store.load(); + store.update('timeout_ms', ''); + assert.equal(await store.flush(), false); + assert.equal(calls, 0, 'incomplete numeric input never reaches the backend'); + store.update('timeout_ms', 30000); + assert.equal(await store.flush(), false); + assert.equal(store.getSnapshot().draft.timeout_ms, 30000); + fail = false; + assert.equal(await store.flush(), true, 'retry persists the retained draft'); + assert.equal(store.getSnapshot().error, null); +} +{ + const wav = encodeSpeechWav([new Float32Array(48000).fill(0.5), new Float32Array(48000).fill(-0.5)], 48000, 1); + const bytes = await wav.arrayBuffer(); + const view = new DataView(bytes); + assert.equal(wav.type, 'audio/wav'); + assert.equal(bytes.byteLength, 32044); + assert.equal(view.getUint32(24, true), 16000); + assert.equal(view.getUint16(22, true), 1); + assert.equal(view.getUint16(34, true), 16); + assert.equal(view.getInt16(44, true), 16384); + assert.throws(() => encodeSpeechWav([], 48000), (error) => error.code === 'SPEECH_NO_SPEECH'); +} +{ + let asked = 0; + await assert.rejects(captureSpeechAudio({ signal: new AbortController().signal }, { + isSecureContext: false, navigator: { mediaDevices: { getUserMedia() { asked += 1; } } }, + }), (error) => error.code === 'SPEECH_INSECURE_CONTEXT'); + assert.equal(asked, 0); + const permission = deferred(); + let tracksStopped = 0, contextsClosed = 0; + class Context { + resume() { return Promise.resolve(); } + close() { contextsClosed += 1; return Promise.resolve(); } + } + const abort = new AbortController(); + const capture = captureSpeechAudio({ signal: abort.signal, maxSeconds: 60 }, { + isSecureContext: true, AudioContext: Context, + navigator: { mediaDevices: { getUserMedia: () => permission.promise } }, + }); + abort.abort(); + permission.resolve({ getTracks: () => [{ stop() { tracksStopped += 1; } }] }); + await assert.rejects(capture, { name: 'AbortError' }); + assert.equal(tracksStopped, 1); + assert.equal(contextsClosed, 1); +} + +function fixture() { + const permission = deferred(), response = deferred(); + const events = { inserted: [], commits: [], errors: [], uploads: [], cancels: [], released: 0, stopped: 0, disposed: 0 }; + let captureOptions, valid = true; + const recorder = { + stop() { events.stopped += 1; return new Blob(['wav']); }, + cancel() { events.released += 1; }, + }; + const insertion = { + isActive: () => valid, + insertText(text) { events.inserted.push(text); return true; }, + dispose() { events.disposed += 1; }, + }; + const session = createSpeechSession({ + capture(options) { captureOptions = options; return permission.promise; }, + transcribe(id, audio, options) { events.uploads.push({ id, audio, options }); return response.promise; }, + cancelRequest(id) { events.cancels.push(id); }, + onCommit(value) { events.commits.push(value); }, + onError(error) { events.errors.push(error); }, + createId: () => 'test-request', + }); + return { session, events, permission, response, recorder, insertion, + start: () => session.start(configured, () => insertion), + invalidate: () => { valid = false; }, + limit: () => captureOptions.onLimit(), + }; +} +{ + const f = fixture(); + const start = f.start(); + await f.start(); // Repeated microphone clicks cannot request twice. + f.session.cancel(); + f.permission.resolve(f.recorder); + await start; + assert.equal(f.events.released, 1, 'late permission is released'); + assert.equal(f.events.uploads.length, 0); + assert.equal(f.events.inserted.length, 0); + assert.equal(f.session.getState().phase, 'idle'); +} +for (const send of [false, true]) { + const f = fixture(); + const start = f.start(); f.permission.resolve(f.recorder); await start; + const stop = f.session.stop(send); + await f.session.stop(!send); + f.response.resolve({ status: 'ok', text: ' recognized text ' }); + await stop; + assert.equal(f.events.stopped, 1); + assert.equal(f.events.uploads.length, 1); + assert.deepEqual(f.events.inserted, ['recognized text']); + assert.deepEqual(f.events.commits, [{ text: 'recognized text', send }]); + assert.equal(f.events.disposed, 1); +} +{ + const f = fixture(); + const start = f.start(); f.permission.resolve(f.recorder); await start; + const stop = f.session.stop(true); + f.session.cancel(); + f.response.resolve({ text: 'late text', status: 'ok' }); + await stop; + assert.equal(f.events.inserted.length, 0); + assert.equal(f.events.commits.length, 0); + assert.deepEqual(f.events.cancels, ['test-request']); + assert.equal(f.events.uploads[0].options.signal.aborted, true); +} +{ + const f = fixture(); + const start = f.start(); f.permission.resolve(f.recorder); await start; + const stop = f.session.stop(true); + f.invalidate(); f.response.resolve({ text: 'wrong session', status: 'ok' }); + await stop; + assert.equal(f.events.inserted.length, 0); + assert.equal(f.events.commits.length, 0); +} +for (const result of [{ status: 'no_speech', text: '' }, { status: 'ok', text: ' ' }]) { + const f = fixture(); + const start = f.start(); f.permission.resolve(f.recorder); await start; + const stop = f.session.stop(true); f.response.resolve(result); await stop; + assert.equal(f.events.commits.length, 0); + assert.equal(f.events.errors[0].code, 'SPEECH_NO_SPEECH'); +} +{ + const f = fixture(); + const start = f.start(); f.permission.reject({ name: 'NotAllowedError' }); await start; + assert.equal(f.session.getState().phase, 'idle'); + assert.equal(f.events.errors[0].name, 'NotAllowedError'); +} +{ + const first = fixture(), second = fixture(); + const pending = first.start(); + await second.start(); + assert.equal(second.events.errors[0].code, 'SPEECH_BUSY'); + first.session.cancel(); + first.permission.resolve(first.recorder); await pending; +} +{ + const f = fixture(); + const start = f.start(); f.permission.resolve(f.recorder); await start; + f.limit(); + f.response.resolve({ text: 'time limit', status: 'ok' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(f.events.commits, [{ text: 'time limit', send: false }]); +} +console.log('speechRecognition tests passed'); diff --git a/web/src/lib/speechSession.js b/web/src/lib/speechSession.js new file mode 100644 index 000000000..a9bfa8fff --- /dev/null +++ b/web/src/lib/speechSession.js @@ -0,0 +1,82 @@ +let captureOwner = null; +const idle = () => ({ phase: 'idle', levels: [], elapsed: 0 }); + +// Injectable capture/transport keeps permission and request ordering testable. +export function createSpeechSession({ capture, transcribe, cancelRequest, onState, onCommit, onError, createId }) { + let state = idle(), active = null; + const owner = {}; + const publish = (patch) => { state = { ...state, ...patch }; onState?.(state); }; + const releaseOwner = () => { if (captureOwner === owner) captureOwner = null; }; + const disposeInsertion = (session) => { session?.insertion?.dispose?.(); }; + const current = (session) => active === session && !session.controller.signal.aborted; + const finish = (session) => { + if (!current(session)) return; + active = null; releaseOwner(); disposeInsertion(session); publish(idle()); + }; + const fail = (session, error) => { + if (!current(session)) return; + const pending = state.phase === 'transcribing'; + session.recorder?.cancel(); session.controller.abort(); releaseOwner(); + active = null; disposeInsertion(session); publish(idle()); + if (pending) Promise.resolve(cancelRequest(session.id)).catch(() => {}); + if (error?.name !== 'AbortError') onError?.(error); + }; + const controller = { + getState: () => state, + async start(config, createInsertion) { + if (active) return; + if (captureOwner) { onError?.({ code: 'SPEECH_BUSY' }); return; } + captureOwner = owner; + const session = { controller: new AbortController(), config, id: createId(), recorder: null, insertion: null }; + active = session; + publish({ phase: 'requesting', levels: [], elapsed: 0 }); + try { + session.insertion = createInsertion(); + if (!session.insertion?.isActive()) throw new DOMException('Editor changed', 'AbortError'); + const recorder = await capture({ + signal: session.controller.signal, + maxSeconds: config.max_recording_seconds, + deviceId: config.device_id, + onLevel: (level, elapsed) => { + if (current(session) && state.phase === 'recording') + publish({ levels: [...state.levels.slice(-95), level], elapsed }); + }, + onLimit: () => { if (current(session)) void controller.stop(false); }, + onError: (error) => fail(session, error), + }); + if (!current(session)) { recorder.cancel(); return; } + session.recorder = recorder; + publish({ phase: 'recording' }); + } catch (error) { fail(session, error); } + }, + async stop(send = false) { + const session = active; + if (!session || state.phase !== 'recording') return; + publish({ phase: 'transcribing' }); + releaseOwner(); + try { + const audio = session.recorder.stop(); + session.recorder = null; + const result = await transcribe(session.id, audio, { + signal: session.controller.signal, timeoutMs: session.config.timeout_ms + 10000, + }); + if (!current(session) || !session.insertion?.isActive()) { finish(session); return; } + const text = typeof result?.text === 'string' ? result.text.trim() : ''; + if (!text || result.status === 'no_speech') throw Object.assign(new Error('No speech'), { code: 'SPEECH_NO_SPEECH' }); + const inserted = session.insertion.insertText(text); + if (inserted) onCommit?.({ text, send }); + finish(session); + } catch (error) { fail(session, error); } + }, + cancel() { + const session = active; + if (!session) return; + const pending = state.phase === 'transcribing'; + active = null; + session.controller.abort(); session.recorder?.cancel(); disposeInsertion(session); + releaseOwner(); publish(idle()); + if (pending) Promise.resolve(cancelRequest(session.id)).catch(() => {}); + }, + }; + return controller; +} diff --git a/web/src/lib/trailingUserMessageRetry.test.js b/web/src/lib/trailingUserMessageRetry.test.js index 20ca5dd4b..8003375eb 100644 --- a/web/src/lib/trailingUserMessageRetry.test.js +++ b/web/src/lib/trailingUserMessageRetry.test.js @@ -112,7 +112,11 @@ assert.equal(retryId(abortState), '', 'a later failure invalidates the stop'); // bypasses submission. The send path must inspect the // latest complete store rather than composer history or a rendered window. const inputBar = fs.readFileSync(new URL('../components/InputBar.jsx', import.meta.url), 'utf8'); -assert.match(inputBar, /const submit = \(\) => \{\s*if \(!actionState\.canSubmit\) return;/); +// Dictation consumes Enter until recognition completes; committed text then +// reaches the same existing submission gate. +assert.match(inputBar, /const submit = \(\) => \{\s*if \(speech\.interceptSubmit\(\)\) return;/); +assert.match(inputBar, /const currentDraft = serializeComposerGoal\(valueRef\.current, contentRef\.current, goalModeRef\.current\);\s*const currentAction = getInputBarActionState\(\{ value: currentDraft\.text, disabled, busy, hasExtras, submitting, canRetryLastUserMessage, queuePaused \}\);\s*if \(!currentAction\.canSubmit\) return;/); +assert.match(inputBar, /onSubmit\?\.\(currentDraft\.text\)/); assert.match(inputBar, /onSubmit=\{submit\}/); assert.match(inputBar, /const isStopAction = actionState\.mode === 'stop';/); assert.match(inputBar, /onClick=\{isStopAction \? onAbort : submit\}/); diff --git a/web/src/lib/useAppShortcuts.js b/web/src/lib/useAppShortcuts.js index e3d287a3b..2d1154649 100644 --- a/web/src/lib/useAppShortcuts.js +++ b/web/src/lib/useAppShortcuts.js @@ -3,7 +3,7 @@ import { APP_SHORTCUT_BROWSER_GUARD, appShortcutContextAllows, matchAppShortcut import { useShortcutPreferences } from './useShortcutPreferences.js'; // Actions stay with their React owner; one event is consumed at most once. -export function useAppShortcuts(handlers) { +export function useAppShortcuts(handlers, { capture = false } = {}) { useShortcutPreferences(); const latest = useRef(handlers); latest.current = handlers; @@ -24,12 +24,12 @@ export function useAppShortcuts(handlers) { }; window.addEventListener('compositionstart', start, true); window.addEventListener('compositionend', end, true); - window.addEventListener('keydown', onKey); + window.addEventListener('keydown', onKey, capture); return () => { clearTimeout(settleTimer); window.removeEventListener('compositionstart', start, true); window.removeEventListener('compositionend', end, true); - window.removeEventListener('keydown', onKey); + window.removeEventListener('keydown', onKey, capture); }; - }, []); + }, [capture]); } diff --git a/web/src/lib/useSpeechRecognition.js b/web/src/lib/useSpeechRecognition.js new file mode 100644 index 000000000..6fd0c30d7 --- /dev/null +++ b/web/src/lib/useSpeechRecognition.js @@ -0,0 +1,129 @@ +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { apiConnectionScope } from './api.js'; +import { captureSpeechAudio } from './speechAudio.js'; +import { createSpeechSession } from './speechSession.js'; +import { speechAvailabilityStore, speechEnabled } from './speechRecognition.js'; +import { speechDeviceStore } from './speechDevices.js'; +import { useAppShortcuts } from './useAppShortcuts.js'; +import { appShortcutContextAllows } from './appShortcuts.js'; + +export function useSpeechRecognition({ client, scope, editorRef, rootRef, mainComposer, disabled, onSend, onError }) { + const connection = apiConnectionScope(client); + const availability = speechAvailabilityStore(client); + const config = useSyncExternalStore(availability.subscribe, availability.getSnapshot); + const inputStore = speechDeviceStore(); + const preferences = useSyncExternalStore(inputStore.subscribe, inputStore.getSnapshot); + const [state, setState] = useState({ phase: 'idle', levels: [], elapsed: 0 }); + const callbacks = useRef(null); + const identity = [scope, config.generation, preferences.deviceId, preferences.holdToRecord, disabled ? 'locked' : 'editable'].join(':'); + callbacks.current = { identity, onSend, onError, config, preferences, disabled, editorRef }; + const mounted = useRef(false); + const sendEpoch = useRef(0); + const held = useRef(null); + const session = useMemo(() => createSpeechSession({ + capture: captureSpeechAudio, + transcribe: (id, audio, options) => client.transcribeSpeech(id, audio, options), + cancelRequest: (id) => client.cancelSpeech(id), + createId: () => globalThis.crypto?.randomUUID?.() || ('voice_' + Date.now() + '_' + Math.random().toString(36).slice(2)), + onState: (next) => { if (mounted.current) setState(next); }, + onError: (error) => callbacks.current.onError(error), + onCommit: ({ send }) => { + const committedIdentity = callbacks.current.identity; + const epoch = ++sendEpoch.current; + requestAnimationFrame(() => { + if (!mounted.current || epoch !== sendEpoch.current || committedIdentity !== callbacks.current.identity) return; + if (send && !callbacks.current.disabled) callbacks.current.onSend(); + else callbacks.current.editorRef.current?.focus({ + canFocus: () => mounted.current && epoch === sendEpoch.current + && committedIdentity === callbacks.current.identity + && appShortcutContextAllows('dictation', document.activeElement), + }); + }); + }, + }), [connection]); + useEffect(() => { + mounted.current = true; + setState(session.getState()); + void availability.load(); + const reload = () => { void availability.load(); }; + window.addEventListener('focus', reload); + return () => { + mounted.current = false; held.current = null; sendEpoch.current += 1; session.cancel(); + window.removeEventListener('focus', reload); + }; + }, [session, availability]); + useEffect(() => { held.current = null; sendEpoch.current += 1; session.cancel(); }, [session, identity, config.enabled, config.configured]); + const releaseHold = () => { + if (!held.current) return; + held.current = null; + if (session.getState().phase === 'requesting') { sendEpoch.current += 1; session.cancel(); } + else void session.stop(false); + }; + const latestRelease = useRef(releaseHold); + latestRelease.current = releaseHold; + useEffect(() => { + const keyUp = (event) => { + if (held.current?.kind === 'key' && (event.code === held.current.code || ['Control', 'Meta', 'Alt', 'Shift'].includes(event.key))) latestRelease.current(); + }; + const pointerUp = (event) => { if (held.current?.kind === 'pointer' && held.current.id === event.pointerId) latestRelease.current(); }; + const release = () => latestRelease.current(); + window.addEventListener('keyup', keyUp, true); + window.addEventListener('pointerup', pointerUp, true); + window.addEventListener('pointercancel', pointerUp, true); + window.addEventListener('blur', release); + window.addEventListener('pagehide', release); + return () => { + window.removeEventListener('keyup', keyUp, true); + window.removeEventListener('pointerup', pointerUp, true); + window.removeEventListener('pointercancel', pointerUp, true); + window.removeEventListener('blur', release); + window.removeEventListener('pagehide', release); + }; + }, [session]); + const controls = { + ...state, active: state.phase !== 'idle', available: speechEnabled(config), + preferences, configureInput: inputStore.update, + start() { + if (callbacks.current.disabled || !speechEnabled(callbacks.current.config)) return false; + sendEpoch.current += 1; + const startedIdentity = callbacks.current.identity; + void session.start({ ...callbacks.current.config, device_id: callbacks.current.preferences.deviceId }, () => { + const insertion = callbacks.current.editorRef.current?.beginFileTransfer?.(); + return { + isActive: () => callbacks.current.identity === startedIdentity && insertion?.isActive(), + insertText: (text) => insertion?.insertText(text), + dispose: () => insertion?.dispose(), + }; + }); + return true; + }, + stop: (send) => { held.current = null; return session.stop(send); }, + cancel: () => { held.current = null; sendEpoch.current += 1; session.cancel(); }, + toggle() { + const phase = session.getState().phase; + if (phase === 'idle') return controls.start(); + if (phase === 'requesting') controls.cancel(); + if (phase === 'recording') void controls.stop(false); + return true; + }, + beginHold(value) { + if (session.getState().phase === 'idle' && controls.start()) held.current = value; + }, + interceptSubmit() { + if (session.getState().phase === 'idle') return false; + void session.stop(true); + return true; + }, + }; + useAppShortcuts(controls.available && !disabled ? { + dictation: (event) => { + const root = rootRef?.current; + if (!root || !root.getClientRects().length) return false; + const owner = event.target?.closest?.('[data-speech-composer]'); + if (owner ? owner !== root : !mainComposer) return false; + if (preferences.holdToRecord) controls.beginHold({ kind: 'key', code: event.code }); + else controls.toggle(); + }, + } : {}, { capture: true }); + return controls; +}