chore: release v1.38.0

This commit is contained in:
coso
2026-05-14 01:25:52 +08:00
parent bcebe1bccd
commit 16d8bb5053
219 changed files with 11257 additions and 8493 deletions
+29 -29
View File
@@ -1,49 +1,49 @@
## Lime v1.37.0
## Lime v1.38.0
发布日期:`2026-05-12`
递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note、校验,并在获得明确确认后执行提交。
发布日期:`2026-05-14`
递交范围:当前完整 worktree,包含 tracked、deleted 与新增文件;本次按发布要求完成版本号、release note 与发布前校验,提交和推送将在获得明确确认后执行。
> 发布说明:上一版 release tag 为 `v1.36.0`。本版升级到 `v1.37.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.36.0 发布说明不再作为当前 release note 事实源保留。
> 发布说明:上一版 release tag 为 `v1.37.0`。本版升级到 `v1.38.0`,并继续清理旧 `RELEASE_NOTES.md` 历史堆叠内容,只保留当前版本说明;旧 v1.37.0 发布说明不再作为当前 release note 事实源保留。
### 发布概览
- 应用版本从 `1.36.0` 升级到 `1.37.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。
- AgentUI / Team Workbench 继续收敛到 current 投影主链:补齐 team workspace canvas、formation、activity preview、selected session detail、team operations 与 live runtime projector 的结构化 selectors 和回归。
- Agent Chat 工作区强化图片生成与视觉任务预览链路,新增图片 workbench 状态文案、普通视觉 brief 确认、service skill entry 操作与消息列表展示回归。
- Settings v2 与 Skills 工作台删除旧入口残留,继续减少过时 surface 对 current 页面导航和技能注册投影的干扰。
- i18n 资源与类型测试扩展到 Agent 工作区新增 presentation keys,维持 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 五语言事实源一致。
- 应用版本从 `1.37.0` 升级到 `1.38.0`,同步 `package.json`、`package-lock.json`、`packages/lime-cli-npm/package.json`、`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock`、`src-tauri/tauri.conf.json` 与 `src-tauri/tauri.conf.headless.json`。
- `smart-input` 独立浮窗整体下线,删除旧页面、组件、窗口配置、前端 API 与治理回流入口,不再保留可关闭、拖动、圆角、旧 Logo 等旧浮窗问题面。
- 旧 `voice-input` 独立语音窗口下线,`Fn` / 语音快捷键改为进入首页 / Claw 输入框录音按钮同一状态机,通过主窗口事件 `voice-start-recording` / `voice-stop-recording` 驱动输入栏录音。
- 翻译快捷键、截图对话快捷键与截图实验入口清理,保留真正仍属于 current 主链的 `@翻译` skill、`translate_instruction_id` 与首页 `voice-input` guide 语义。
- 命令边界同步清理:移除旧 `open_voice_window` / `close_voice_window`、截图命令与 smart-input 调用面,补齐 Rust 注册、前端网关、DevBridge mock、governance catalog 与 contracts 守卫。
### 用户可见更新
- Team Workbench 增加更完整的队伍概览、画布分层、会话 inline detail、活动预览、协作状态与操作面板展示。
- 图片生成体验补齐生成状态、素材预览、消息列表承接、workspace image task preview 与发送动作防护。
- App Sidebar / 页面内容 / 设置首页导航继续向 current 页面集合收敛,移除旧 settings 分类和页面类型残留。
- Capability Draft 与 Workspace Registered Skills 面板更新注册技能投影与工作区技能可用性展示。
- `@` command registry smoke 和 TTFT 采样脚本补齐响应式聊天 / stream latency 证据,相关截图作为本轮 GUI 验证证据随 worktree 保留。
- 首页 / Claw 输入框成为语音识别唯一 current 入口,快捷键不再打开单独快捷输入框,也不会再残留无法关闭或无法停止识别的旧浮窗。
- 设置页快捷键列表移除已过时、未实现或已下线的翻译 / 截图 / smart-input 入口,避免用户看到无效快捷键。
- 输入栏录音按钮、语音润色失败兜底、图片附件、排队回合与工具按钮补齐回归测试,保证快捷键进入同一输入栏状态后仍能稳定发送。
- 历史消息合并修复:只有在 assistant 历史中存在工具、任务预览或过程轨迹缺用户 turn 时才补回用户消息,避免普通分页历史重复补用户消息。
- 图片任务缓存恢复回归补齐:空历史从图片工作台缓存恢复时保留原始用户输入与完成卡,新草稿不再回灌上一条会话的图片轻卡。
### 开发者与治理更新
- `docs/roadmap/agentui` 增加 completion audit、responsive chat TTFT sample matrix 与 stream latency map 更新,记录 AgentUI 主线完成状态和剩余风险。
- `docs/roadmap/i18n/prd.md` 更新本地化主线进度,明确新增 Agent 工作区文案与测试覆盖。
- Rust media / image skill launch 链路整理生成任务参数、CLI bridge 与 media task 命令测试,避免图片工具运行时继续依赖旧参数假设。
- Team Workspace runtime selectors、presentation hooks、operation state、activity previews 与 live runtime projector 增加定向单测,降低 UI 组件直接拼装 runtime facts 的风险。
- Release note 继续按单版本事实源维护,旧版本说明不再堆叠在当前发布文件内。
- 新语音快捷键事实源固定为 `src-tauri/src/voice/events.rs`、`voice/shortcut.rs`、`voice/fn_shortcut.rs` 与前端 `src/lib/api/voiceShortcutEvents.ts` / `useInputbarDictation`。
- 旧 `smart-input` / 旧语音窗口 / 截图对话链路均归类为 `dead`,不再新增 compat 包装层。
- `ShortcutSettings` 收敛到 Settings v2 shared 目录,旧 `src/components/smart-input/ShortcutSettings*` 删除。
- 五语言 i18n 资源清理旧快捷键与旧入口文案,继续保持 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 覆盖一致。
- `protocol-fact-source-guard`、`legacySurfaceCatalog` 与 hotkey catalog 回归补齐旧入口防回流断言。
### 校验状态
- `npm run verify:app-version`:通过,目标版本 `1.37.0`。
- `npm run verify:app-version`:通过,目标版本 `1.38.0`。
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:通过。
- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all -- --check`:通过。
- `npm run format`:通过。
- `CARGO_TARGET_DIR="/tmp/lime-cargo-target-v138" cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主库 `1340 passed; 0 failed; 1 ignored`,集成测试通过。
- `CARGO_TARGET_DIR="/tmp/lime-cargo-target-v138" cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。
- `npm run lint`:通过。
- `npm test -- src/components/agent/chat/hooks/agentChatHistory.test.ts src/components/agent/chat/hooks/sessionHistoryMergeController.test.ts`:通过。
- `npm test -- src/components/agent/chat/workspace/useWorkspaceImageTaskPreviewRuntime.test.tsx`:通过。
- `npm test`:通过,55/55 批次通过。
- `npm run test:contracts`:通过。
- `cargo test --manifest-path "src-tauri/Cargo.toml" --workspace --locked`:通过。
- `cargo clippy --manifest-path "src-tauri/Cargo.toml" --workspace --all-targets -- -D warnings`:通过。
- `npm run governance:legacy-report`:通过,零引用候选 `0`、边界违规 `0`。
- `LIME_AGENT_QC_PROVIDER="custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed" LIME_AGENT_QC_MODEL="sensenova-6.7-flash-lite" npm run verify:gui-smoke`:通过,覆盖 GUI 主路径发布风险。
- `LIME_AGENT_QC_PROVIDER="custom-f74b38b5-6d3f-44ef-aa0f-99d70c3482ed" LIME_AGENT_QC_MODEL="sensenova-6.7-flash-lite" npm run verify:local`:通过,包含版本一致性、前端 lint/typecheck/test、contracts、Rust test 与 GUI smoke。
- `npm run test:contracts`:通过,命令契约、Harness 契约、modality contracts 与 cleanup report contract 均通过。
- `npm run governance:legacy-report`:通过,边界违规 `0`;本轮 smart-input / screenshot / onboarding dead surface 作为已删除或零引用清理项受控记录。
- `git diff --check`:通过。
- `npm run verify:local`:通过,包含版本一致性、前端 lint/typecheck/Vitest/contracts、Rust test 与 GUI smoke。
---
**完整变更**: `v1.36.0` -> `v1.37.0`
**完整变更**: `v1.37.0` -> `v1.38.0`
File diff suppressed because one or more lines are too long
@@ -386,7 +386,7 @@ npm run verify:gui-smoke
2026-05-01 实时录音预览验证:
1. `DevBridge` 真实链路:`voice_models_get_install_state -> start_recording -> get_recording_segment -> stop_recording -> transcribe_audio` 通过;录音中返回增量 PCM,最终转写 provider 为 `SenseVoice Small 本地`。
2. 前端定向回归:`npm test -- src/lib/api/asrProvider.test.ts src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx src/pages/smart-input.test.tsx src/lib/voiceModelSettingsNavigation.test.ts src/components/settings-v2/agent/voice/index.test.tsx` 通过。
2. 前端定向回归:`npm test -- src/lib/api/asrProvider.test.ts src/components/agent/chat/components/Inputbar/components/InputbarCore.test.tsx src/lib/voiceModelSettingsNavigation.test.ts src/components/settings-v2/agent/voice/index.test.tsx` 通过。
3. 契约与 GUI 主路径:`npm run typecheck`、`npm run test:contracts`、`npm run verify:gui-smoke` 通过。
收口:
@@ -401,7 +401,7 @@ npm run verify:local
2. 用户能从设置页显式下载模型。
3. 断网但已安装模型时,语音转写可工作。
4. 删除模型后,转写不再假装可用,并提示重新下载。
5. 输入栏听写和悬浮语音窗消费同一条 ASR 主链。
5. 首页 / 工作区输入栏听写与 Fn / 语音全局快捷键消费同一条 ASR 主链。
6. 已安装模型后,可以用本机 16-bit PCM WAV 文件做测试转写,证明模型目录和本地推理链路可用。
7. 开启语音输入后,macOS Fn 监听进入 registered 状态;无法捕获时明确回退到普通语音快捷键。
8. 日志能区分下载失败、模型损坏、运行时加载失败和识别失败。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lime",
"version": "1.37.0",
"version": "1.38.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lime",
"version": "1.37.0",
"version": "1.38.0",
"license": "GPL-3.0-only",
"dependencies": {
"@babel/standalone": "^7.29.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "1.37.0",
"version": "1.38.0",
"description": "AI content workspace for Chinese creators: desktop writing, research, prompt management, knowledge base, and multi-model workflows.",
"keywords": [
"ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@limecloud/lime-cli",
"version": "1.37.0",
"version": "1.38.0",
"description": "Lime 官方任务 CLI",
"bin": {
"lime": "scripts/run.js"
+1
View File
@@ -0,0 +1 @@
null
@@ -0,0 +1,6 @@
{
"oldPrompt": false,
"oldLimePrompt": false,
"imagePreviewCount": 0,
"errorsText": false
}
+183
View File
@@ -0,0 +1,183 @@
{
"url": "http://127.0.0.1:1420/?e2e=1&ts=1778691100",
"body": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n好的,我来生成一张青柠极简插画。\n2分钟前\n新对话\n5分钟前\n新对话\n8分钟前\n国际形势分析无法执行\n23分钟前\n新对话\n45分钟前\n新对话\n46分钟前\n新对话\n50分钟前\n新对话\n51分钟前\nsmoke harness\n52分钟前\n<planner>\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Forge E2E 1778592514150\nHarness\n新对话\n工作台\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"sessionIds": [
[
"agent_session_workspace_8f16920f-b0f5-4198-9e20-bc643910be73",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_a4828a71-abed-4e31-9e43-791c58d644e1",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_bd1f156d-5d37-41f5-9d59-d80363da7928",
"\"full-access\""
],
[
"agent_session_workspace_eed7439e-273e-4b49-97a2-df6c96be5fa3",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_3239e159-dacd-4a27-b5fa-cfd0aea11d28",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_8d383c48-80e2-42e7-9bf3-c2bf5c4ef864",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_4d6b14fa-d4cb-4fe5-b3fa-6f1827b19f5e",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_43e5ac59-422c-465a-9312-d86ce678e317",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_3fe77ab3-e1d3-4f47-9515-02404d70d1e5",
"\"full-access\""
],
[
"agent_session_workspace_700543e4-9b1f-42d3-811a-0b1f0601fad2",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_8d383c48-80e2-42e7-9bf3-c2bf5c4ef864",
"\"full-access\""
],
[
"agent_session_workspace_e2545a39-417c-4ba4-a1d7-d5a7b5517b28",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_3fe77ab3-e1d3-4f47-9515-02404d70d1e5",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_d9fd2dca-4f1c-4fc9-9ab1-57a8584a365b",
"\"full-access\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_8f16920f-b0f5-4198-9e20-bc643910be73",
"\"full-access\""
],
[
"aster_last_sessionId_global",
"null"
],
[
"agent_session_workspace_9ec7e2c6-01c3-4654-9881-cead648710a1",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_31cc9300-7e26-4c85-8a0d-3f00e33168c2",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_a2738669-04c1-40db-9c88-251e1328771b",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_1b215641-00bc-41fa-8bb6-0ee64bd87819",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_2123a0bd-445f-4ded-84ee-0bdff0c86284",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_31cc9300-7e26-4c85-8a0d-3f00e33168c2",
"\"full-access\""
],
[
"agent_session_workspace_2b64278a-aa4a-442e-846c-6113dd1d4153",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_a0e782ef-a050-4126-8e8f-6bc258e13de9",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_d9fd2dca-4f1c-4fc9-9ab1-57a8584a365b",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_9ec7e2c6-01c3-4654-9881-cead648710a1",
"\"full-access\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_69c81ba7-782a-47e1-92d5-cfec94ea7760",
"\"full-access\""
],
[
"aster_last_sessionId_240ed157-3e7a-456c-a2c2-a05bd852b71c",
"\"8f16920f-b0f5-4198-9e20-bc643910be73\""
],
[
"agent_session_workspace_6a7d0ff5-0cae-4edd-a396-9411cfede225",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_access_mode_240ed157-3e7a-456c-a2c2-a05bd852b71c_6a7d0ff5-0cae-4edd-a396-9411cfede225",
"\"full-access\""
],
[
"agent_session_workspace_614c5e60-9b49-4a53-aba6-1c7b7301cb26",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_dc7fba43-1c2f-4251-8d9b-05c93943307a",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_69c81ba7-782a-47e1-92d5-cfec94ea7760",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"agent_session_workspace_bd1f156d-5d37-41f5-9d59-d80363da7928",
"\"240ed157-3e7a-456c-a2c2-a05bd852b71c\""
],
[
"aster_session_snapshots_persisted_240ed157-3e7a-456c-a2c2-a05bd852b71c",
"{\"8d383c48-80e2-42e7-9bf3-c2bf5c4ef864\":{\"messages\":[{\"id\":\"2b28ee8f-2588-4721-94f8-d9f9f31ffe24\",\"role\":\"user\",\"content\":\"@analysis 帮我分析一下今天的国际形势\",\"timestamp\":\"2026-05-13T16:31:22.532Z\"},{\"id\":\"9ddd7"
]
],
"activeButtons": [
{
"tag": "DIV",
"text": "新对话8分钟前",
"testid": null,
"active": "true",
"ariaCurrent": null
},
{
"tag": "BUTTON",
"text": "新对话8分钟前",
"testid": null,
"active": null,
"ariaCurrent": "page"
},
{
"tag": "BUTTON",
"text": "Skill Forge E2E 1778592514150",
"testid": null,
"active": null,
"ariaCurrent": null
},
{
"tag": "DIV",
"text": "新对话",
"testid": "task-center-tab-8f16920f-b0f5-4198-9e20-bc643910be73",
"active": "true",
"ariaCurrent": null
},
{
"tag": "BUTTON",
"text": "新对话",
"testid": null,
"active": null,
"ariaCurrent": "page"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
[
{
"i": 8,
"text": "",
"aria": "新建对话",
"testid": null
},
{
"i": 9,
"text": "新对话3分钟前",
"aria": null,
"testid": null
},
{
"i": 11,
"text": "新对话5分钟前",
"aria": null,
"testid": null
},
{
"i": 15,
"text": "新对话42分钟前",
"aria": null,
"testid": null
},
{
"i": 17,
"text": "新对话43分钟前",
"aria": null,
"testid": null
},
{
"i": 19,
"text": "新对话47分钟前",
"aria": null,
"testid": null
},
{
"i": 21,
"text": "新对话49分钟前",
"aria": null,
"testid": null
},
{
"i": 27,
"text": "新对话1小时前",
"aria": null,
"testid": null
},
{
"i": 38,
"text": "新对话",
"aria": null,
"testid": null
},
{
"i": 39,
"text": "",
"aria": "新建对话",
"testid": "task-center-tab-create-button"
}
]
+32
View File
@@ -0,0 +1,32 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n5分钟前\n新对话\n8分钟前\n国际形势分析无法执行\n23分钟前\n新对话\n44分钟前\n新对话\n45分钟前\n新对话\n49分钟前\n新对话\n51分钟前\nsmoke harness\n52分钟前\n<planner>\n1小时前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Forge E2E 1778592514150\nHarness\n新对话\n工作台\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"guangzhou": true,
"lime": false,
"tags": [],
"intros": [
"好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成"
],
"imageCards": [
{
"testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 1
},
{
"testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2",
"imgCount": 0
},
{
"testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "",
"imgCount": 1
},
{
"testid": "image-workbench-message-preview-caption-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 0
}
],
"failureText": false
}
@@ -0,0 +1,31 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n17分钟前\n新对话\n38分钟前\n新对话\n39分钟前\n新对话\n43分钟前\n新对话\n45分钟前\nsmoke harness\n46分钟前\n<planner>\n54分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n生成一张广州塔,从花城汇看过去的春天照片\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"imageCards": [
{
"testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 1,
"retryCount": 0
},
{
"testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2",
"imgCount": 0,
"retryCount": 0
},
{
"testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "",
"imgCount": 1,
"retryCount": 0
},
{
"testid": "image-workbench-message-preview-caption-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 0,
"retryCount": 0
}
],
"retryButtons": [],
"messageCount": 2
}
+30
View File
@@ -0,0 +1,30 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n16分钟前\n新对话\n37分钟前\n新对话\n38分钟前\n新对话\n43分钟前\n新对话\n44分钟前\nsmoke harness\n45分钟前\n<planner>\n54分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n2小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n生成一张广州塔,从花城汇看过去的春天照片\n好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n正在生成图片\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"imageCards": [
{
"testid": "image-workbench-message-preview-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2正在生成图片",
"imgCount": 0,
"buttons": []
},
{
"testid": "image-workbench-message-preview-toolbar-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "图片生成|GPT Images 2",
"imgCount": 0,
"buttons": []
},
{
"testid": "image-workbench-message-preview-single-media-e05d557e-60b5-4d47-b9e4-f825b3150df5",
"text": "正在生成图片",
"imgCount": 0,
"buttons": []
}
],
"retryButtons": [],
"userTags": [
"@配图"
],
"intros": [
"好啊,用 GPT Images 2 生成:一张广州塔,从花城汇看过去的春天照片\n先获取下工具参数\n马上生成"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n17分钟前\n新对话\n39分钟前\n新对话\n40分钟前\n新对话\n44分钟前\n新对话\n45分钟前\nsmoke harness\n46分钟前\n<planner>\n55分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我梳理一下需求分析\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · 国际形势分析无法执行\n向下滑,看看 Lime 可以帮你做什么\n↓\n你可以从这些任务开始\n\n往下看更多任务样例;真正执行仍会回到生成里继续补充。\n\n拆解一条爆款内容\n把一条高表现内容拆成标题钩子、结构节奏、素材手法和可复用套路,方便快速复刻。\n复盘这个账号/项目\n围绕目标、已有结果和下一步动作判断当前该怎么推进,适合内容账号、项目推进和运营回看。\n脚本转口播/字幕稿\n把现有脚本整理成更适合口播和字幕的版本,补齐停顿、节奏和适合出镜表达的语气。\n每日趋势摘要\n先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。\n内容主稿生成\n围绕目标受众、表达结构和关键信息,先生成一版可继续迭代的内容首稿。\n长文转多平台发布稿\n把长文、资料或文章整理成多平台可直接发布的版本,不用再自己拆标题和结构。\n复制轮播帖\n拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。\n视频配音\n围绕视频素材、语言要求与旁白意图,先在本地工作区整理一版可继续加工的配音稿。\n视频配音成其他语言\n先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。\n文章转 Slide 视频提纲\n把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。\n账号增长跟踪\n围绕参考账号和目标平台先整理一版增长打法,再持续跟踪内容节奏、关键指标和提醒条件。",
"oldPrompt": false,
"generating": false,
"imagePreviewCount": 0,
"userTags": []
}
@@ -0,0 +1,29 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n3分钟前\n新对话\n5分钟前\n国际形势分析无法执行\n20分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n47分钟前\n新对话\n49分钟前\nsmoke harness\n49分钟前\n<planner>\n58分钟前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n再生成一张青柠极简插画\n好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n搞定,图已经生成好了\n要调整的话直接说,我继续改\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"oldPrompt": false,
"newPrompt": true,
"imageCards": [
{
"testid": "image-workbench-message-preview-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "图片生成|GPT Images 2搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 1
},
{
"testid": "image-workbench-message-preview-toolbar-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "图片生成|GPT Images 2",
"imgCount": 0
},
{
"testid": "image-workbench-message-preview-single-media-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "",
"imgCount": 1
},
{
"testid": "image-workbench-message-preview-caption-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "搞定,图已经生成好了\n要调整的话直接说,我继续改",
"imgCount": 0
}
],
"retryButtons": [],
"forbidden": []
}
@@ -0,0 +1,28 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n新对话\n3分钟前\n新对话\n5分钟前\n国际形势分析无法执行\n20分钟前\n新对话\n42分钟前\n新对话\n43分钟前\n新对话\n47分钟前\n新对话\n49分钟前\nsmoke harness\n49分钟前\n<planner>\n58分钟前\n新对话\n1小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\nHarness\n新对话\n@配图\n再生成一张青柠极简插画\n好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成\n图片生成\n|\nGPT Images 2\n正在生成图片\n添加资料\n模型\ndeepseek-v4-pro\n高级设置",
"oldPrompt": false,
"newPrompt": true,
"tags": [
"@配图"
],
"intros": [
"好啊,用 GPT Images 2 生成:再生成一张青柠极简插画\n先获取下工具参数\n马上生成"
],
"imageCards": [
{
"testid": "image-workbench-message-preview-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "图片生成|GPT Images 2正在生成图片",
"imgCount": 0
},
{
"testid": "image-workbench-message-preview-toolbar-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "图片生成|GPT Images 2",
"imgCount": 0
},
{
"testid": "image-workbench-message-preview-single-media-968e9568-1dc4-4fba-be89-d3df274a4adb",
"text": "正在生成图片",
"imgCount": 0
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"text": "Lime\n邀请好友\n搜索任务\n新建任务\nSkills\n灵感\n项目资料\n最近对话\n国际形势分析无法执行\n18分钟前\n新对话\n39分钟前\n新对话\n40分钟前\n新对话\n45分钟前\n新对话\n46分钟前\nsmoke harness\n47分钟前\n<planner>\n56分钟前\n新对话\n1小时前\n新对话\n2小时前\n新对话\n3小时前\n查看更多对话\n归档\n开\n开源使用\n本地可用\nSkill Think Keep Audit 20260513\n新对话\n创作\n\n青柠一下,灵感即来\n\n说一句目标,Lime 就接着帮你做。\n\n文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。\n\n帮我整理项目资料\ntab\n添加资料\n高级设置\n当前模型\ndeepseek-v4-pro\n引导帮助\n写作\n添加资料\nPPT\n调研报告\n需求分析\n视频\n设计\nExcel\n编程\n更多做法\n回看最近结果 · 国际形势分析无法执行\n向下滑,看看 Lime 可以帮你做什么\n↓\n你可以从这些任务开始\n\n往下看更多任务样例;真正执行仍会回到生成里继续补充。\n\n拆解一条爆款内容\n把一条高表现内容拆成标题钩子、结构节奏、素材手法和可复用套路,方便快速复刻。\n复盘这个账号/项目\n围绕目标、已有结果和下一步动作判断当前该怎么推进,适合内容账号、项目推进和运营回看。\n脚本转口播/字幕稿\n把现有脚本整理成更适合口播和字幕的版本,补齐停顿、节奏和适合出镜表达的语气。\n每日趋势摘要\n先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。\n内容主稿生成\n围绕目标受众、表达结构和关键信息,先生成一版可继续迭代的内容首稿。\n长文转多平台发布稿\n把长文、资料或文章整理成多平台可直接发布的版本,不用再自己拆标题和结构。\n短视频编排\n把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。\n复制轮播帖\n拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。\n视频配音\n围绕视频素材、语言要求与旁白意图,先在本地工作区整理一版可继续加工的配音稿。\n视频配音成其他语言\n先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。\n文章转 Slide 视频提纲\n把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。\n我来读取已导出的 Markdown 文件,然后翻...\n沉淀自一次成功结果:我来读取已导出的 Markdown 文件,然后翻译成中文。 文件已读取完毕。现在我将正文翻译为中文,保留所有 Markdown 结构、代码块、链...\n加载对话历史失败: normalizeUserVisibleContent is not defined",
"oldPrompt": false,
"newPrompt": false,
"tags": [],
"intros": [],
"imagePreviewCount": 0
}
+18 -18
View File
@@ -5131,7 +5131,7 @@ dependencies = [
[[package]]
name = "lime"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"anyhow",
"arboard",
@@ -5238,7 +5238,7 @@ dependencies = [
[[package]]
name = "lime-agent"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5267,7 +5267,7 @@ dependencies = [
[[package]]
name = "lime-browser-runtime"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"chrono",
"futures",
@@ -5284,7 +5284,7 @@ dependencies = [
[[package]]
name = "lime-cli"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"clap",
"lime-core",
@@ -5296,7 +5296,7 @@ dependencies = [
[[package]]
name = "lime-config"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5312,7 +5312,7 @@ dependencies = [
[[package]]
name = "lime-core"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"aster-models",
"async-trait",
@@ -5365,7 +5365,7 @@ dependencies = [
[[package]]
name = "lime-gateway"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"aes",
"axum 0.7.9",
@@ -5395,7 +5395,7 @@ dependencies = [
[[package]]
name = "lime-infra"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"chrono",
"dashmap 5.5.3",
@@ -5415,7 +5415,7 @@ dependencies = [
[[package]]
name = "lime-knowledge"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"chrono",
"hex",
@@ -5428,7 +5428,7 @@ dependencies = [
[[package]]
name = "lime-mcp"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5444,7 +5444,7 @@ dependencies = [
[[package]]
name = "lime-media-runtime"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -5477,7 +5477,7 @@ dependencies = [
[[package]]
name = "lime-processor"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"async-trait",
"lime-core",
@@ -5496,7 +5496,7 @@ dependencies = [
[[package]]
name = "lime-providers"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"anyhow",
"async-stream",
@@ -5551,7 +5551,7 @@ dependencies = [
[[package]]
name = "lime-server"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"aster-core",
"async-stream",
@@ -5595,7 +5595,7 @@ dependencies = [
[[package]]
name = "lime-server-utils"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"axum 0.7.9",
"futures",
@@ -5610,7 +5610,7 @@ dependencies = [
[[package]]
name = "lime-services"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"anyhow",
"aster-core",
@@ -5655,7 +5655,7 @@ dependencies = [
[[package]]
name = "lime-skills"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"async-trait",
"dirs 5.0.1",
@@ -5673,7 +5673,7 @@ dependencies = [
[[package]]
name = "lime-websocket"
version = "1.37.0"
version = "1.38.0"
dependencies = [
"axum 0.7.9",
"chrono",
+2 -2
View File
@@ -9,7 +9,7 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.37.0"
version = "1.38.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -198,7 +198,7 @@ version = "2.4.9"
[package]
name = "lime"
version = "1.37.0"
version = "1.38.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Lime 默认权限配置",
"windows": ["main", "smart-input", "browser-connector-guide-*"],
"windows": ["main", "browser-connector-guide-*"],
"permissions": [
"core:default",
"core:event:default",
+88 -17
View File
@@ -48,6 +48,7 @@ pub struct SkillWorkflowExecution<'a> {
pub aster_state: &'a AsterAgentState,
pub skill: &'a LoadedSkillDefinition,
pub user_input: &'a str,
pub user_visible_input: Option<&'a str>,
pub images: &'a [SkillInputImage],
pub execution_id: &'a str,
pub session_id: &'a str,
@@ -60,6 +61,7 @@ pub struct SkillPromptExecution<'a> {
pub aster_state: &'a AsterAgentState,
pub skill: &'a LoadedSkillDefinition,
pub user_input: &'a str,
pub user_visible_input: Option<&'a str>,
pub images: &'a [SkillInputImage],
pub execution_id: &'a str,
pub session_id: &'a str,
@@ -132,29 +134,61 @@ fn build_prompt_system_prompt(skill_markdown: &str, memory_prompt: Option<&str>)
}
}
fn build_user_message(user_input: &str, images: &[SkillInputImage]) -> Message {
fn should_hide_execution_input_from_user(
execution_input: &str,
user_visible_input: Option<&str>,
) -> bool {
user_visible_input
.map(|input| {
let visible = input.trim();
!visible.is_empty() && visible != execution_input.trim()
})
.unwrap_or(false)
}
fn build_user_message(
user_input: &str,
user_visible_input: Option<&str>,
images: &[SkillInputImage],
) -> Message {
let mut user_message = Message::user().with_text(user_input);
for image in images {
user_message = user_message.with_image(image.data.clone(), image.media_type.clone());
}
if should_hide_execution_input_from_user(user_input, user_visible_input) {
user_message = user_message.agent_only();
}
user_message
}
fn build_skill_turn_context(skill: &LoadedSkillDefinition) -> Option<TurnContextOverride> {
fn build_skill_turn_context(
skill: &LoadedSkillDefinition,
user_visible_input: Option<&str>,
) -> Option<TurnContextOverride> {
let allowed_tools = skill
.allowed_tools
.as_ref()
.filter(|tools| !tools.is_empty())?;
.filter(|tools| !tools.is_empty());
let user_visible_input_text = user_visible_input.and_then(|input| {
(!input.trim().is_empty()).then(|| input.trim().to_string())
});
if allowed_tools.is_none() && user_visible_input_text.is_none() {
return None;
}
let mut metadata = HashMap::new();
metadata.insert(
"subagent".to_string(),
json!({
"allowed_tools": allowed_tools,
}),
);
if let Some(allowed_tools) = allowed_tools {
metadata.insert(
"subagent".to_string(),
json!({
"allowed_tools": allowed_tools,
}),
);
}
Some(TurnContextOverride {
user_visible_input_text,
metadata,
..TurnContextOverride::default()
})
@@ -232,6 +266,7 @@ pub async fn execute_skill_workflow(
aster_state,
skill,
user_input,
user_visible_input,
images,
execution_id,
session_id,
@@ -246,7 +281,7 @@ pub async fn execute_skill_workflow(
let mut artifact_paths = Vec::new();
let mut accumulated_context = user_input.to_string();
let mut final_output = String::new();
let skill_turn_context = build_skill_turn_context(skill);
let skill_turn_context = build_skill_turn_context(skill, user_visible_input);
tracing::info!(
"[execute_skill_workflow] 开始 workflow 执行: steps={}, skill={}",
@@ -283,7 +318,7 @@ pub async fn execute_skill_workflow(
}
let session_config = session_config_builder.build();
let step_input = build_step_input(user_input, &accumulated_context, idx == 0);
let user_message = build_user_message(&step_input, images);
let user_message = build_user_message(&step_input, user_visible_input, images);
let reply = stream_skill_session(
aster_state,
@@ -364,6 +399,7 @@ pub async fn execute_skill_prompt(
aster_state,
skill,
user_input,
user_visible_input,
images,
execution_id,
session_id,
@@ -377,11 +413,11 @@ pub async fn execute_skill_prompt(
memory_prompt,
))
.include_context_trace(true);
if let Some(turn_context) = build_skill_turn_context(skill) {
if let Some(turn_context) = build_skill_turn_context(skill, user_visible_input) {
session_config_builder = session_config_builder.turn_context(turn_context);
}
let session_config = session_config_builder.build();
let user_message = build_user_message(user_input, images);
let user_message = build_user_message(user_input, user_visible_input, images);
let reply = stream_skill_session(
aster_state,
session_id,
@@ -425,7 +461,7 @@ pub async fn execute_skill_prompt(
#[cfg(test)]
mod tests {
use super::build_skill_turn_context;
use super::{build_skill_turn_context, build_user_message};
use lime_skills::LoadedSkillDefinition;
use std::collections::HashMap;
@@ -464,7 +500,7 @@ mod tests {
"social_generate_cover_image",
]));
let turn_context = build_skill_turn_context(&skill).expect("turn context");
let turn_context = build_skill_turn_context(&skill, None).expect("turn context");
assert_eq!(
turn_context.metadata["subagent"]["allowed_tools"],
serde_json::json!([
@@ -477,9 +513,44 @@ mod tests {
#[test]
fn build_skill_turn_context_skips_empty_allowed_tools() {
let skill = build_loaded_skill(Some(Vec::new()));
assert!(build_skill_turn_context(&skill).is_none());
assert!(build_skill_turn_context(&skill, None).is_none());
let skill = build_loaded_skill(None);
assert!(build_skill_turn_context(&skill).is_none());
assert!(build_skill_turn_context(&skill, None).is_none());
}
#[test]
fn build_skill_turn_context_keeps_user_visible_input_without_allowed_tools() {
let skill = build_loaded_skill(None);
let turn_context =
build_skill_turn_context(&skill, Some(" @analysis 帮我分析一下 "))
.expect("turn context");
assert_eq!(
turn_context.user_visible_input_text.as_deref(),
Some("@analysis 帮我分析一下")
);
assert!(turn_context.metadata.is_empty());
}
#[test]
fn build_user_message_hides_execution_input_when_visible_input_differs() {
let message = build_user_message(
"结构化执行输入",
Some("@analysis 帮我分析一下今天的国际形势"),
&[],
);
assert!(!message.is_user_visible());
assert!(message.is_agent_visible());
}
#[test]
fn build_user_message_keeps_plain_input_user_visible() {
let message = build_user_message("普通用户输入", Some("普通用户输入"), &[]);
assert!(message.is_user_visible());
assert!(message.is_agent_visible());
}
}
@@ -600,6 +600,128 @@ fn extract_tool_result_metadata<T: serde::Serialize>(result: &T) -> Option<Value
.and_then(|value| find_metadata(&value, 0))
}
fn string_list_from_metadata(value: Option<&Value>) -> Vec<String> {
match value {
Some(Value::Array(items)) => items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect(),
Some(Value::String(item)) => item
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect(),
_ => Vec::new(),
}
}
fn metadata_string_matches(actual: Option<&Value>, expected: &str) -> bool {
actual
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|actual| actual.eq_ignore_ascii_case(expected.trim()))
}
fn tool_result_metadata_matches_stop_rule(metadata: &Value, rule: &Value) -> bool {
let Some(rule) = rule.as_object() else {
return false;
};
if let Some(expected) = rule
.get("metadata_equals")
.or_else(|| rule.get("metadataEquals"))
.and_then(Value::as_object)
{
let all_matched = expected.iter().all(|(key, expected_value)| {
expected_value
.as_str()
.map(|expected_text| metadata_string_matches(metadata.get(key), expected_text))
.unwrap_or_else(|| metadata.get(key) == Some(expected_value))
});
if !all_matched {
return false;
}
}
let required_keys = string_list_from_metadata(
rule.get("require_any")
.or_else(|| rule.get("requireAny"))
.or_else(|| rule.get("required_any"))
.or_else(|| rule.get("requiredAny")),
);
if !required_keys.is_empty() {
let has_required = required_keys.iter().any(|key| {
metadata.get(key).is_some_and(|value| match value {
Value::Null => false,
Value::String(text) => !text.trim().is_empty(),
_ => true,
})
});
if !has_required {
return false;
}
}
let statuses = string_list_from_metadata(
rule.get("statuses")
.or_else(|| rule.get("status_values"))
.or_else(|| rule.get("statusValues")),
);
if !statuses.is_empty() {
let Some(actual_status) = metadata
.get("status")
.and_then(Value::as_str)
.map(str::trim)
else {
return false;
};
if !statuses
.iter()
.any(|expected| actual_status.eq_ignore_ascii_case(expected))
{
return false;
}
}
true
}
fn should_stop_after_tool_result(
turn_context: Option<&TurnContextOverride>,
messages_to_add: &Conversation,
) -> bool {
let Some(stop_rule) = turn_context
.and_then(|context| context.metadata.get("runtime_control"))
.or_else(|| turn_context.and_then(|context| context.metadata.get("runtimeControl")))
.and_then(|control| {
control
.get("stop_after_tool_result")
.or_else(|| control.get("stopAfterToolResult"))
})
else {
return false;
};
messages_to_add.messages().iter().any(|message| {
message.content.iter().any(|content| {
let MessageContent::ToolResponse(tool_response) = content else {
return false;
};
let Ok(result) = tool_response.tool_result.as_ref() else {
return false;
};
let Some(metadata) = extract_tool_result_metadata(result) else {
return false;
};
tool_result_metadata_matches_stop_rule(&metadata, stop_rule)
})
})
}
fn native_tool_metadata_to_value(
metadata: std::collections::HashMap<String, Value>,
) -> Option<Value> {
@@ -1061,7 +1183,12 @@ impl TurnItemRuntimeProjector {
}
fn project_user_input(&mut self, turn: &TurnRuntime) -> Option<AgentEvent> {
let content = turn.input_text.as_ref()?.trim();
let content = turn
.context_override
.as_ref()
.and_then(|context| context.user_visible_input_text.as_deref())
.or(turn.input_text.as_deref())?
.trim();
if content.is_empty() {
return None;
}
@@ -3421,7 +3548,15 @@ impl Agent {
let scope_session_config = session_config.clone();
let stream_session_config = session_config.clone();
let scoped_session_config = session_config.clone();
let input_text_for_turn = (!message_text.trim().is_empty()).then_some(message_text.clone());
let input_text_for_turn = scoped_session_config
.turn_context
.as_ref()
.and_then(|context| context.user_visible_input_text.as_deref())
.and_then(|input_text| {
let value = input_text.trim();
(!value.is_empty()).then(|| value.to_string())
})
.or_else(|| (!message_text.trim().is_empty()).then_some(message_text.clone()));
Ok(Self::scope_reply_stream(
&scope_session_config,
@@ -4133,6 +4268,11 @@ impl Agent {
}
}
}
} else if should_stop_after_tool_result(
session_config.turn_context.as_ref(),
&messages_to_add,
) {
exit_chat = true;
}
for msg in &messages_to_add {
@@ -4964,6 +5104,38 @@ mod tests {
);
}
#[test]
fn test_project_user_input_prefers_user_visible_turn_context() {
let turn = TurnRuntime::new(
"turn-skill-visible",
"session-1",
"thread-1",
Some("内部结构化执行输入".to_string()),
Some(TurnContextOverride {
user_visible_input_text: Some(
"@analysis 帮我分析一下今天的国际形势".to_string(),
),
..TurnContextOverride::default()
}),
);
let mut projector = TurnItemRuntimeProjector::new(&turn);
let event = projector
.project_user_input(&turn)
.expect("user input event");
assert!(matches!(
event,
AgentEvent::ItemStarted { item }
if item.id == "user_input:turn-skill-visible"
&& matches!(
item.payload,
ItemRuntimePayload::UserInput { ref text }
if text == "@analysis 帮我分析一下今天的国际形势"
)
));
}
#[test]
fn test_project_tool_response_emits_file_artifact_runtime_item() {
let turn = TurnRuntime::new(
@@ -6851,6 +7023,86 @@ mod tests {
assert!(tool_surface_updated_from_call_tool_result(&call_result));
}
#[test]
fn test_should_stop_after_tool_result_matches_forwarded_image_task_metadata() {
let turn_context = TurnContextOverride {
metadata: HashMap::from([(
"runtime_control".to_string(),
serde_json::json!({
"stop_after_tool_result": {
"metadata_equals": {
"skill_forwarded_tool_name": "lime_create_image_generation_task",
"task_type": "image_generate"
},
"require_any": ["task_id", "artifact_path", "path"],
"statuses": ["pending_submit", "queued", "running", "partial", "succeeded"]
}
}),
)]),
..TurnContextOverride::default()
};
let mut messages = Conversation::default();
messages.push(Message::user().with_tool_response(
"call-image-skill",
Ok(CallToolResult {
content: vec![Content::text("图片任务已创建")],
structured_content: Some(serde_json::json!({
"skill_forwarded_tool_name": "lime_create_image_generation_task",
"task_type": "image_generate",
"task_id": "task-image-1",
"status": "pending_submit"
})),
is_error: Some(false),
meta: None,
}),
));
assert!(should_stop_after_tool_result(
Some(&turn_context),
&messages
));
}
#[test]
fn test_should_stop_after_tool_result_ignores_unmatched_metadata() {
let turn_context = TurnContextOverride {
metadata: HashMap::from([(
"runtime_control".to_string(),
serde_json::json!({
"stop_after_tool_result": {
"metadata_equals": {
"skill_forwarded_tool_name": "lime_create_image_generation_task",
"task_type": "image_generate"
},
"require_any": ["task_id"],
"statuses": ["pending_submit"]
}
}),
)]),
..TurnContextOverride::default()
};
let mut messages = Conversation::default();
messages.push(Message::user().with_tool_response(
"call-other",
Ok(CallToolResult {
content: vec![Content::text("其它工具完成")],
structured_content: Some(serde_json::json!({
"skill_forwarded_tool_name": "lime_create_cover_generation_task",
"task_type": "cover_generate",
"task_id": "task-cover-1",
"status": "pending_submit"
})),
is_error: Some(false),
meta: None,
}),
));
assert!(!should_stop_after_tool_result(
Some(&turn_context),
&messages
));
}
#[tokio::test]
async fn test_agent_register_mcp_tool() -> Result<()> {
let agent = Agent::new();
@@ -39,6 +39,8 @@ pub struct TurnContextOverride {
#[serde(skip_serializing_if = "Option::is_none")]
pub collaboration_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_visible_input_text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(value_type = Object)]
pub output_schema: Option<Value>,
#[serde(skip, default)]
+9 -9
View File
@@ -37,14 +37,14 @@ pub use types::{
MultiSearchEngineEntryConfig, NativeAgentConfig, NavigationConfig, OpenAIAsrConfig,
PairingSettings, ProviderConfig, ProviderModelsConfig, ProvidersConfig, QuotaExceededConfig,
RateLimitSettings, RemoteManagementConfig, ResponseCacheSettings, RetrySettings, RoutingConfig,
ScreenshotChatConfig, SearchEngine, SenseVoiceLocalConfig, ServerConfig,
ShellEnvironmentImportConfig, TaskSchedule, TelegramAccountConfig, TelegramBotConfig,
TelegramGroupConfig, TelegramTopicConfig, TlsConfig, ToolCallingConfig,
ToolExecutionOverrideConfig, ToolExecutionPolicyConfig, ToolExecutionRestrictionProfileConfig,
ToolExecutionSandboxProfileConfig, ToolExecutionWarningPolicyConfig, UpdateCheckConfig,
UserProfile, VertexApiKeyEntry, VertexModelAlias, VoiceInputConfig, VoiceInstruction,
VoiceOutputConfig, VoiceOutputMode, VoiceProcessorConfig, WebSearchConfig, WebSearchProvider,
WechatAccountConfig, WechatBotConfig, WechatGroupConfig, WhisperLocalConfig, WhisperModelSize,
WorkspacePreferencesConfig, WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
SearchEngine, SenseVoiceLocalConfig, ServerConfig, ShellEnvironmentImportConfig, TaskSchedule,
TelegramAccountConfig, TelegramBotConfig, TelegramGroupConfig, TelegramTopicConfig, TlsConfig,
ToolCallingConfig, ToolExecutionOverrideConfig, ToolExecutionPolicyConfig,
ToolExecutionRestrictionProfileConfig, ToolExecutionSandboxProfileConfig,
ToolExecutionWarningPolicyConfig, UpdateCheckConfig, UserProfile, VertexApiKeyEntry,
VertexModelAlias, VoiceInputConfig, VoiceInstruction, VoiceOutputConfig, VoiceOutputMode,
VoiceProcessorConfig, WebSearchConfig, WebSearchProvider, WechatAccountConfig, WechatBotConfig,
WechatGroupConfig, WhisperLocalConfig, WhisperModelSize, WorkspacePreferencesConfig,
WorkspaceSandboxConfig, XunfeiConfig, DEFAULT_API_KEY,
};
pub use yaml::{load_config, save_config, ConfigError, ConfigManager, YamlService};
+1 -52
View File
@@ -895,23 +895,6 @@ fn normalize_navigation_enabled_items(items: &[String]) -> Vec<String> {
// ============ 实验室功能配置类型 ============
/// 截图对话功能配置
///
/// 配置截图对话功能的开关和快捷键
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScreenshotChatConfig {
/// 是否启用截图对话功能
#[serde(default)]
pub enabled: bool,
/// 触发截图的全局快捷键
#[serde(default = "default_screenshot_shortcut")]
pub shortcut: String,
}
fn default_screenshot_shortcut() -> String {
"CommandOrControl+Alt+Q".to_string()
}
/// 自动更新检查配置
///
/// 配置自动检查更新的行为,符合 macOS/Windows 平台规范
@@ -998,15 +981,6 @@ impl Default for UpdateCheckConfig {
}
}
impl Default for ScreenshotChatConfig {
fn default() -> Self {
Self {
enabled: false,
shortcut: default_screenshot_shortcut(),
}
}
}
/// WebMCP 预留配置
///
/// 当前仅作为实验开关预留,不参与实际执行链。
@@ -1022,9 +996,6 @@ pub struct WebMcpConfig {
/// 管理所有实验性功能的开关和配置
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ExperimentalFeatures {
/// 截图对话功能配置
#[serde(default)]
pub screenshot_chat: ScreenshotChatConfig,
/// WebMCP 预留配置
#[serde(default)]
pub webmcp: WebMcpConfig,
@@ -1099,9 +1070,6 @@ pub struct VoiceInputConfig {
/// 是否启用交互音效
#[serde(default = "default_sound_enabled")]
pub sound_enabled: bool,
/// 翻译模式快捷键(可选)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translate_shortcut: Option<String>,
/// 翻译模式使用的指令 ID
#[serde(default = "default_translate_instruction_id")]
pub translate_instruction_id: String,
@@ -1130,7 +1098,6 @@ impl Default for VoiceInputConfig {
asr_credentials: Vec::new(),
selected_device_id: None,
sound_enabled: default_sound_enabled(),
translate_shortcut: None,
translate_instruction_id: default_translate_instruction_id(),
}
}
@@ -3359,13 +3326,6 @@ mod unit_tests {
assert_eq!(parsed, config);
}
#[test]
fn test_screenshot_chat_config_default() {
let config = ScreenshotChatConfig::default();
assert!(!config.enabled);
assert_eq!(config.shortcut, "CommandOrControl+Alt+Q");
}
#[test]
fn test_webmcp_config_default() {
let config = WebMcpConfig::default();
@@ -3375,18 +3335,12 @@ mod unit_tests {
#[test]
fn test_experimental_features_default() {
let config = ExperimentalFeatures::default();
assert!(!config.screenshot_chat.enabled);
assert_eq!(config.screenshot_chat.shortcut, "CommandOrControl+Alt+Q");
assert!(!config.webmcp.enabled);
}
#[test]
fn test_experimental_features_serialization() {
let config = ExperimentalFeatures {
screenshot_chat: ScreenshotChatConfig {
enabled: true,
shortcut: "CommandOrControl+Alt+X".to_string(),
},
webmcp: WebMcpConfig { enabled: true },
..Default::default()
};
@@ -3394,7 +3348,7 @@ mod unit_tests {
let yaml = serde_yaml::to_string(&config).unwrap();
assert!(yaml.contains("webmcp"));
assert!(yaml.contains("enabled: true"));
assert!(yaml.contains("shortcut: CommandOrControl+Alt+X"));
assert!(!yaml.contains("screenshot_chat"));
let parsed: ExperimentalFeatures = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed, config);
@@ -3434,11 +3388,6 @@ mod unit_tests {
#[test]
fn test_config_with_experimental() {
let config = Config::default();
assert!(!config.experimental.screenshot_chat.enabled);
assert_eq!(
config.experimental.screenshot_chat.shortcut,
"CommandOrControl+Alt+Q"
);
assert!(!config.experimental.webmcp.enabled);
// 语音输入测试
assert!(!config.experimental.voice_input.enabled);
-4
View File
@@ -21,8 +21,6 @@
//! - `voice_asr_service` - ASR 识别服务
//! - `voice_command_service` - 语音命令业务服务
//! - `voice_recording_service` - 录音状态与设备服务
//! - `screenshot_capture_service` - 跨平台截图服务
//! - `screenshot_image_service` - 截图图片编码服务
//! - `machine_id_service` - 机器 ID 服务
//! - `live_sync` - 实时同步
//! - `mcp_sync` - MCP 同步
@@ -43,8 +41,6 @@
// 无外部依赖的服务
pub mod context_memory_service;
pub mod file_browser_service;
pub mod screenshot_capture_service;
pub mod screenshot_image_service;
pub mod sysinfo_service;
pub mod update_check_service;
pub mod voice_asr_service;
@@ -1,244 +0,0 @@
//! 屏幕截图服务
//!
//! 提供跨平台的屏幕截图功能,支持交互式区域选择
use std::path::PathBuf;
use tracing::{debug, error, info};
#[cfg(target_os = "macos")]
use std::path::Path;
#[cfg(target_os = "macos")]
use tracing::warn;
/// 截图错误类型
#[derive(Debug, thiserror::Error)]
pub enum CaptureError {
#[error("用户取消截图")]
Cancelled,
#[error("权限不足: {0}")]
PermissionDenied(String),
#[error("系统错误: {0}")]
SystemError(String),
#[error("IO 错误: {0}")]
IoError(#[from] std::io::Error),
#[error("临时文件创建失败: {0}")]
TempFileError(String),
}
/// 截图结果
#[derive(Debug)]
pub enum CaptureResult {
/// 截图成功,包含图片路径
Success(PathBuf),
/// 用户取消截图
Cancelled,
}
/// 启动交互式截图
///
/// 调用系统截图工具进行交互式区域选择
///
/// # 返回
/// 成功返回图片路径,用户取消返回 Cancelled 错误,其他情况返回相应错误
///
/// # 平台支持
/// - macOS: 使用 `screencapture -i -x` 命令
/// - Windows: 使用 Windows API 或系统截图工具
/// - Linux: 使用 `gnome-screenshot` 或 `scrot`
pub async fn start_capture() -> Result<PathBuf, CaptureError> {
info!("启动交互式截图");
// 生成临时文件路径
let temp_dir = std::env::temp_dir();
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f");
let filename = format!("lime_screenshot_{timestamp}.png");
let temp_path = temp_dir.join(&filename);
debug!("截图临时文件路径: {:?}", temp_path);
// 根据平台调用不同的截图命令
#[cfg(target_os = "macos")]
{
capture_macos(&temp_path).await?;
}
#[cfg(target_os = "windows")]
{
capture_windows(&temp_path).await?;
}
#[cfg(target_os = "linux")]
{
capture_linux(&temp_path).await?;
}
// 检查文件是否存在(用户可能取消了截图)
if !temp_path.exists() {
info!("截图文件不存在,用户可能取消了截图");
return Err(CaptureError::Cancelled);
}
// 检查文件大小(空文件表示取消)
let metadata = std::fs::metadata(&temp_path)?;
if metadata.len() == 0 {
info!("截图文件为空,用户取消了截图");
std::fs::remove_file(&temp_path)?;
return Err(CaptureError::Cancelled);
}
info!("截图成功: {:?}", temp_path);
Ok(temp_path)
}
/// macOS 截图实现
#[cfg(target_os = "macos")]
async fn capture_macos(output_path: &Path) -> Result<(), CaptureError> {
use std::process::Command;
debug!("使用 macOS screencapture 命令");
// 先检查屏幕录制权限
// 通过尝试执行一个快速的全屏截图到 /dev/null 来检测权限
let permission_check = Command::new("screencapture")
.args(["-x", "-c"]) // -c 截图到剪贴板,快速检测权限
.output();
if let Ok(output) = permission_check {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("cannot") || stderr.contains("permission") {
warn!("屏幕录制权限不足: {}", stderr);
return Err(CaptureError::PermissionDenied(
"请在系统设置 → 隐私与安全性 → 录屏与系统录音 中授权 Lime".to_string(),
));
}
}
}
// 使用 screencapture 命令
// -i: 交互式选择区域
// -x: 不播放截图声音
let output = Command::new("screencapture")
.args(["-i", "-x", output_path.to_str().unwrap()])
.output()
.map_err(|e| CaptureError::SystemError(format!("执行 screencapture 失败: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// 用户按 ESC 取消时,screencapture 返回非零状态但不创建文件
if !output_path.exists() {
return Err(CaptureError::Cancelled);
}
error!("screencapture 命令失败: {}", stderr);
return Err(CaptureError::SystemError(format!(
"screencapture 失败: {stderr}"
)));
}
Ok(())
}
/// Windows 截图实现
#[cfg(target_os = "windows")]
async fn capture_windows(output_path: &PathBuf) -> Result<(), CaptureError> {
use std::process::Command;
debug!("使用 Windows 截图工具");
// 使用 PowerShell 调用截图功能
// 注意:这是一个简化实现,实际可能需要使用 Windows API
let script = format!(
r#"
Add-Type -AssemblyName System.Windows.Forms
$screen = [System.Windows.Forms.Screen]::PrimaryScreen
$bitmap = New-Object System.Drawing.Bitmap($screen.Bounds.Width, $screen.Bounds.Height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($screen.Bounds.Location, [System.Drawing.Point]::Empty, $screen.Bounds.Size)
$bitmap.Save('{}')
"#,
output_path.to_str().unwrap().replace("\\", "\\\\")
);
let output = Command::new("powershell")
.args(["-Command", &script])
.output()
.map_err(|e| CaptureError::SystemError(format!("执行 PowerShell 失败: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("PowerShell 截图失败: {}", stderr);
return Err(CaptureError::SystemError(format!("截图失败: {}", stderr)));
}
Ok(())
}
/// Linux 截图实现
#[cfg(target_os = "linux")]
async fn capture_linux(output_path: &PathBuf) -> Result<(), CaptureError> {
use std::process::Command;
debug!("使用 Linux 截图工具");
// 尝试使用 gnome-screenshot
let gnome_result = Command::new("gnome-screenshot")
.args(["-a", "-f", output_path.to_str().unwrap()])
.output();
if let Ok(output) = gnome_result {
if output.status.success() {
return Ok(());
}
}
// 回退到 scrot
let scrot_result = Command::new("scrot")
.args(["-s", output_path.to_str().unwrap()])
.output()
.map_err(|e| {
CaptureError::SystemError(format!(
"无法找到截图工具 (gnome-screenshot 或 scrot): {}",
e
))
})?;
if !scrot_result.status.success() {
let stderr = String::from_utf8_lossy(&scrot_result.stderr);
if !output_path.exists() {
return Err(CaptureError::Cancelled);
}
return Err(CaptureError::SystemError(format!("scrot 失败: {}", stderr)));
}
Ok(())
}
/// 清理临时截图文件
///
/// # 参数
/// - `path`: 要删除的文件路径
pub fn cleanup_temp_file(path: &PathBuf) {
if path.exists() {
if let Err(e) = std::fs::remove_file(path) {
error!("删除临时截图文件失败: {}", e);
} else {
debug!("已删除临时截图文件: {:?}", path);
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_temp_path_generation() {
let temp_dir = std::env::temp_dir();
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S_%3f");
let filename = format!("lime_screenshot_{timestamp}.png");
let temp_path = temp_dir.join(&filename);
assert!(temp_path.to_str().unwrap().contains("lime_screenshot_"));
assert!(temp_path.to_str().unwrap().ends_with(".png"));
}
}
@@ -1,31 +0,0 @@
//! 截图图片处理服务
//!
//! 提供截图文件读取与 Base64 编码能力。
use base64::{engine::general_purpose::STANDARD, Engine};
use std::path::Path;
use tokio::fs;
/// 读取图片文件并转换为 Base64
pub async fn read_image_as_base64(path: &str) -> Result<String, String> {
tracing::debug!("读取图片为 Base64: {}", path);
let file_path = Path::new(path);
if !file_path.exists() {
return Err(format!("文件不存在: {}", file_path.display()));
}
let bytes = fs::read(file_path)
.await
.map_err(|e| format!("读取文件失败: {e}"))?;
if bytes.is_empty() {
return Err("文件为空".to_string());
}
let base64 = STANDARD.encode(&bytes);
tracing::debug!("图片读取成功,大小: {} 字节", bytes.len());
Ok(base64)
}
+4 -29
View File
@@ -531,21 +531,6 @@ pub fn run() {
});
}
// 初始化截图对话模块
// _Requirements: 7.3_
{
let app_handle = app.handle();
match crate::screenshot::init(app_handle) {
Ok(()) => {
tracing::info!("[启动] 截图对话模块初始化成功");
}
Err(e) => {
tracing::error!("[启动] 截图对话模块初始化失败: {}", e);
// 截图模块初始化失败不影响应用运行
}
}
}
// 初始化语音输入模块
{
let app_handle = app.handle();
@@ -1355,18 +1340,10 @@ pub fn run() {
commands::webview_cmd::get_browser_event_buffer,
commands::webview_cmd::browser_execute_action,
commands::webview_cmd::get_browser_action_audit_logs,
// Screenshot Chat commands
// _Requirements: 1.1, 1.4, 1.5, 2.2, 2.4, 3.1, 5.1_
commands::screenshot_cmd::get_experimental_config,
commands::screenshot_cmd::get_screenshot_shortcut_runtime_status,
commands::screenshot_cmd::save_experimental_config,
commands::screenshot_cmd::start_screenshot,
commands::screenshot_cmd::validate_shortcut,
commands::screenshot_cmd::update_screenshot_shortcut,
commands::screenshot_cmd::close_screenshot_chat_window,
commands::screenshot_cmd::open_input_with_text,
commands::screenshot_cmd::read_image_as_base64,
commands::screenshot_cmd::send_screenshot_chat,
// Experimental settings commands
commands::experimental_cmd::get_experimental_config,
commands::experimental_cmd::save_experimental_config,
commands::hotkey_cmd::validate_shortcut,
// Update Check commands
commands::update_cmd::check_update,
commands::update_cmd::check_for_updates,
@@ -1562,8 +1539,6 @@ pub fn run() {
crate::voice::commands::get_voice_instructions,
crate::voice::commands::save_voice_instruction,
crate::voice::commands::delete_voice_instruction,
crate::voice::commands::open_voice_window,
crate::voice::commands::close_voice_window,
crate::voice::commands::transcribe_audio,
crate::voice::commands::polish_voice_text,
crate::voice::commands::output_voice_text,
@@ -33,6 +33,18 @@ const IMAGE_SKILL_LAUNCH_DETOUR_DENY_PATTERNS: &[&str] = &[
"mcp__playwright__*",
"playwright*",
];
const IMAGE_SKILL_LAUNCH_MAIN_ALLOWED_TOOLS: &[&str] = &["Skill"];
const IMAGE_SKILL_LAUNCH_RUNTIME_METADATA_KEY: &str = "lime_runtime";
const IMAGE_SKILL_LAUNCH_TOOL_SURFACE_KEY: &str = "tool_surface";
const IMAGE_SKILL_LAUNCH_RUNTIME_CONTROL_KEY: &str = "runtime_control";
const IMAGE_SKILL_LAUNCH_STOP_AFTER_TOOL_RESULT_KEY: &str = "stop_after_tool_result";
const IMAGE_SKILL_LAUNCH_DEFAULT_SKILL_NAME: &str = "image_generate";
#[derive(Debug, Clone)]
pub(crate) struct ImageSkillLaunchDirectTask {
pub tool_params: serde_json::Value,
pub skill_tool_arguments: String,
}
fn extract_object_string(
object: &serde_json::Map<String, serde_json::Value>,
@@ -46,6 +58,37 @@ fn extract_object_string(
.map(str::to_string)
}
fn insert_non_empty_string_if_missing(
record: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: Option<&str>,
) {
if record
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|item| !item.is_empty())
.is_some()
{
return;
}
let Some(value) = value.map(str::trim).filter(|item| !item.is_empty()) else {
return;
};
record.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
fn build_image_skill_launch_direct_skill_arguments(skill_name: &str) -> String {
serde_json::to_string(&serde_json::json!({
"skill": skill_name,
"source": "image_skill_launch"
}))
.unwrap_or_else(|_| "{\"skill\":\"image_generate\"}".to_string())
}
fn build_image_skill_input_ref(index: usize) -> String {
format!("{IMAGE_SKILL_INPUT_REF_PREFIX}{}", index + 1)
}
@@ -215,6 +258,55 @@ fn ensure_image_skill_launch_workbench_chat_mode(value: &mut serde_json::Value)
);
}
fn ensure_image_skill_launch_turn_tool_scope(value: &mut serde_json::Value) {
let Some(root) = value.as_object_mut() else {
return;
};
let launch_container = root
.get("harness")
.and_then(serde_json::Value::as_object)
.unwrap_or(root);
let has_launch = ["image_skill_launch", "imageSkillLaunch"]
.iter()
.any(|key| {
launch_container
.get(*key)
.and_then(serde_json::Value::as_object)
.is_some()
});
if !has_launch {
return;
}
root.insert(
"tool_scope".to_string(),
serde_json::json!({
"allowed_tools": IMAGE_SKILL_LAUNCH_MAIN_ALLOWED_TOOLS,
"source": "image_skill_launch"
}),
);
root.insert(
IMAGE_SKILL_LAUNCH_RUNTIME_CONTROL_KEY.to_string(),
serde_json::json!({
IMAGE_SKILL_LAUNCH_STOP_AFTER_TOOL_RESULT_KEY: {
"source": "image_skill_launch",
"metadata_equals": {
"skill_forwarded_tool_name": "lime_create_image_generation_task",
"task_type": "image_generate"
},
"require_any": ["task_id", "artifact_path", "path"],
"statuses": ["pending_submit", "queued", "running", "partial", "succeeded"]
}
}),
);
if let Some(runtime) = root
.get_mut(IMAGE_SKILL_LAUNCH_RUNTIME_METADATA_KEY)
.and_then(serde_json::Value::as_object_mut)
{
runtime.remove(IMAGE_SKILL_LAUNCH_TOOL_SURFACE_KEY);
}
}
fn ensure_image_generation_contract_metadata(
launch: &mut serde_json::Map<String, serde_json::Value>,
) {
@@ -255,6 +347,7 @@ pub(crate) fn prepare_image_skill_launch_request_metadata(
) -> Option<serde_json::Value> {
let mut metadata = request_metadata.cloned()?;
ensure_image_skill_launch_workbench_chat_mode(&mut metadata);
ensure_image_skill_launch_turn_tool_scope(&mut metadata);
let Some(launch) = extract_harness_nested_object_mut(
&mut metadata,
@@ -284,6 +377,44 @@ pub(crate) fn prepare_image_skill_launch_request_metadata(
Some(metadata)
}
pub(crate) fn build_image_skill_launch_direct_task(
request_metadata: Option<&serde_json::Value>,
session_id: &str,
thread_id: &str,
turn_id: &str,
project_id: Option<&str>,
) -> Result<Option<ImageSkillLaunchDirectTask>, String> {
let Some(launch) = extract_harness_nested_object(
request_metadata,
&["image_skill_launch", "imageSkillLaunch"],
) else {
return Ok(None);
};
let kind = extract_object_string(launch, &["kind"]).unwrap_or_else(|| "image_task".to_string());
if kind != "image_task" {
return Ok(None);
}
let skill_name = extract_object_string(launch, &["skill_name", "skillName"])
.unwrap_or_else(|| IMAGE_SKILL_LAUNCH_DEFAULT_SKILL_NAME.to_string());
let mut image_task = launch
.get("image_task")
.and_then(serde_json::Value::as_object)
.cloned()
.ok_or_else(|| "图片技能启动缺少 image_task 上下文".to_string())?;
insert_image_generation_contract_fields(&mut image_task);
insert_non_empty_string_if_missing(&mut image_task, "session_id", Some(session_id));
insert_non_empty_string_if_missing(&mut image_task, "thread_id", Some(thread_id));
insert_non_empty_string_if_missing(&mut image_task, "turn_id", Some(turn_id));
insert_non_empty_string_if_missing(&mut image_task, "project_id", project_id);
Ok(Some(ImageSkillLaunchDirectTask {
tool_params: serde_json::Value::Object(image_task),
skill_tool_arguments: build_image_skill_launch_direct_skill_arguments(&skill_name),
}))
}
pub(crate) fn merge_system_prompt_with_image_skill_launch(
base_prompt: Option<String>,
request_metadata: Option<&serde_json::Value>,
@@ -447,9 +578,9 @@ fn build_image_skill_launch_system_prompt(
let presentation_json = truncate_json_value(image_task.get("presentation"), 2_000);
let taste_context_json = truncate_json_value(image_task.get("taste_context"), 2_000);
let args_payload = serde_json::json!({
"user_input": raw_text
"user_input": prompt
.clone()
.or(prompt.clone())
.or(raw_text.clone())
.unwrap_or_else(|| "请根据当前要求执行图片任务".to_string()),
"image_task": serde_json::Value::Object(image_task.clone()),
});
@@ -477,18 +608,18 @@ fn build_image_skill_launch_system_prompt(
serde_json::to_string(&args_json).unwrap_or_else(|_| "\"{}\"".to_string())
),
"- 当前回合已经显式知道要走图片技能主链,不要为了确认技能名、工具名或命令名再去调用 ToolSearch。".to_string(),
"- 当前主会话第一刀必须先调用 Skill(image_generate),但这不等于任务已经创建。".to_string(),
"- 当前主会话第一刀必须先调用 Skill(image_generate),且用户文本里的 @命令 / 模型标签不是 Skill 名称;不要把 @ 后面的展示名当成 Skill.skill 参数。".to_string(),
"- 在 Skill(image_generate) 真正执行前,不要先走 ToolSearch / WebSearch / lime_search_web_images / Bash / Read / Write / Edit / Glob / Grep / 浏览器 MCP / Playwright 等通用工具发现、检索、联网搜图、脚本、文件或页面链路。".to_string(),
"- `lime_search_web_images` 只服务 @素材 的联网图片候选检索,不是 @配图/@Nanobanana Pro 的图片生成入口;当前回合禁止调用它。".to_string(),
"- `lime_search_web_images` 只服务 @素材 的联网图片候选检索,不是当前 @图片生成入口;当前回合禁止调用它。".to_string(),
"- 不要搜索 “Skill image_generate”、“lime media image generate --json”、“lime_create_image_generation_task” 之类目录信息;当前 image_task 已经提供了足够上下文。".to_string(),
"- 如果某个通用搜索/读文件工具因为 session policy 被拒绝,不要重复同类调用;应立即改为直调 Skill(image_generate)。".to_string(),
"- 如果 Skill(image_generate) 返回的 Lime 工具元数据里只有 allowed_tools=[\"lime_create_image_generation_task\"],而没有 task_id/path/status,说明任务尚未创建;当前主会话必须立刻继续调用 lime_create_image_generation_task。".to_string(),
"- 不要把 Skill(image_generate) success=true 误判成“任务已提交”;只有拿到 task_id、path,或 status=pending_submit/queued/running/partial/succeeded,才算真实提交成功。".to_string(),
"- 当前图片主链是 Skill(image_generate) -> lime_create_image_generation_task -> 标准 image task artifact + worker,不要停在 Skill 返回后直接写总结。".to_string(),
"- 不要把 Skill(image_generate) success=true 单独误判成任务已提交;但只要 Skill 返回的 Lime 工具元数据里出现 skill_forwarded_tool_name=lime_create_image_generation_task,或拿到 task_id/path/status,就表示真实图片任务已创建。".to_string(),
"- status=pending_submit 是标准 image task artifact 已创建、等待 worker 回流的合法状态;看到它后必须停止本回合工具调用,不要再次调用 Skill(image_generate),也不要改用“查看结果/提交/状态”等新参数重复创建第二个任务。".to_string(),
"- 当前图片主链是 Skill(image_generate) -> lime_create_image_generation_task -> 标准 image task artifact + worker;拿到任务元数据后立即收口,让同一条 assistant 消息里的任务轻卡继续展示进度与结果。".to_string(),
"- 不要再通过 Bash 拼接 CLI 命令或临时 /tmp 任务文件替代 lime_create_image_generation_task。".to_string(),
"- 调用 lime_create_image_generation_task 时,必须把 image_task 对象本身直接作为工具参数提交;不要再包一层 {\"image_task\": ...},更不要把整个对象再次序列化成字符串。".to_string(),
"- 调用 lime_create_image_generation_task 时,统一使用 snake_case 字段名;不要把 anchorHint / providerId / projectId 这类 camelCase 同义字段与 snake_case 一起重复提交。".to_string(),
"- 调用 lime_create_image_generation_task 时,必须只提交标准 image task 参数;不要传 outputPath,不要把任务写成 markdown 文稿。".to_string(),
"- Skill 内部调用 lime_create_image_generation_task 时,必须把 image_task 对象本身直接作为工具参数提交;不要再包一层 {\"image_task\": ...},更不要把整个对象再次序列化成字符串。".to_string(),
"- Skill 内部调用 lime_create_image_generation_task 时,统一使用 snake_case 字段名;不要把 anchorHint / providerId / projectId 这类 camelCase 同义字段与 snake_case 一起重复提交。".to_string(),
"- Skill 内部调用 lime_create_image_generation_task 时,必须只提交标准 image task 参数;不要传 outputPath,不要把任务写成 markdown 文稿。".to_string(),
"- 不要伪造“图片已生成完成”;在 task file 真正返回结果前,只能让工具轨迹展示任务已提交、排队或执行中,不要额外输出递交模板。".to_string(),
"- 如果当前回合已经拿到任何图片任务结果,且结果里含 task_id、path,或 status=pending_submit/queued/running/partial/succeeded,说明任务已提交;不要再次调用 Skill(image_generate) 或重复创建第二个图片任务。".to_string(),
"- 拿到上述任务结果后,不要再输出“任务类型 / 任务 ID / 任务文件 / 状态”这类提交摘要;让同一条 assistant 消息内的工具调用和图片任务轻卡继续展示进度与结果。".to_string(),
@@ -484,8 +484,8 @@ pub(crate) use form_skill_launch::{
prepare_form_skill_launch_request_metadata, prune_form_skill_launch_detour_tools_from_registry,
};
pub(crate) use image_skill_launch::{
append_image_skill_launch_session_permissions, merge_system_prompt_with_image_skill_launch,
prepare_image_skill_launch_request_metadata,
append_image_skill_launch_session_permissions, build_image_skill_launch_direct_task,
merge_system_prompt_with_image_skill_launch, prepare_image_skill_launch_request_metadata,
prune_image_skill_launch_detour_tools_from_registry,
};
pub(crate) use mcp_bridge::{ensure_lime_mcp_servers_running, inject_mcp_extensions};
@@ -597,6 +597,7 @@ pub(crate) use tool_runtime::{
pub(crate) use tool_runtime::{
ensure_browser_mcp_tools_registered, ensure_creation_task_tools_registered,
ensure_runtime_support_tools_registered, ensure_social_image_tool_registered,
submit_image_generation_task_value,
};
#[cfg(test)]
#[allow(unused_imports)]
@@ -2620,6 +2620,14 @@ fn honors_explicit_model_lock_with_capability_check(
) && matches!(model_preference_source, RequestPreferenceSource::Request)
}
fn should_allow_cross_provider_runtime_fallback(
provider_preference_source: RequestPreferenceSource,
model_preference_source: RequestPreferenceSource,
) -> bool {
!matches!(provider_preference_source, RequestPreferenceSource::Request)
&& !matches!(model_preference_source, RequestPreferenceSource::Request)
}
fn should_reselect_for_runtime_capability_gap(
resolved_model: Option<&EnhancedModelMetadata>,
compatible_candidate_count: u32,
@@ -3550,6 +3558,10 @@ pub(super) async fn resolve_runtime_request_provider_resolution(
&provider_selector,
session_context.as_ref(),
)?;
let allow_runtime_fallback = should_allow_cross_provider_runtime_fallback(
provider_preference_source,
model_preference_source,
);
if matches!(provider_preference_source, RequestPreferenceSource::Session) {
tracing::info!(
@@ -3577,7 +3589,7 @@ pub(super) async fn resolve_runtime_request_provider_resolution(
&provider_selector,
&model_preference,
model_preference_source,
matches!(model_preference_source, RequestPreferenceSource::Session),
allow_runtime_fallback,
)
.await?;
let limit_state = build_limit_state(
@@ -3589,6 +3601,12 @@ pub(super) async fn resolve_runtime_request_provider_resolution(
selection.capability_gap.clone(),
{
let mut notes = vec!["当前回合显式指定了 provider/model 偏好。".to_string()];
if !allow_runtime_fallback {
notes.push(
"当前回合含显式 provider/model 选择,运行时不会静默切换到其他 Provider。"
.to_string(),
);
}
if selection.capability_gap_source.as_deref() == Some("explicit_model_lock") {
notes.push(
"显式用户模型锁定不满足当前 execution profile 的 routing slot,模型执行前必须阻断。"
@@ -2126,6 +2126,30 @@ fn explicit_provider_preference_wins_over_session_fallback() {
);
}
#[test]
fn explicit_request_preference_disables_cross_provider_runtime_fallback() {
assert!(!should_allow_cross_provider_runtime_fallback(
RequestPreferenceSource::Request,
RequestPreferenceSource::Session,
));
assert!(!should_allow_cross_provider_runtime_fallback(
RequestPreferenceSource::Session,
RequestPreferenceSource::Request,
));
assert!(!should_allow_cross_provider_runtime_fallback(
RequestPreferenceSource::Request,
RequestPreferenceSource::Request,
));
}
#[test]
fn session_recovered_preference_can_keep_runtime_fallback() {
assert!(should_allow_cross_provider_runtime_fallback(
RequestPreferenceSource::Session,
RequestPreferenceSource::Session,
));
}
#[test]
fn service_scene_model_preference_reads_complete_preference() {
let metadata = serde_json::json!({
@@ -26,7 +26,7 @@ use crate::commands::modality_runtime_contracts::hydrate_limecore_policy_hits_fr
use aster::agents::extension::PlatformExtensionContext;
use aster::hooks::{CompactTrigger, SessionSource};
use aster::session::TurnContextOverride;
use aster::tools::{ConfigTool, SkillTool};
use aster::tools::{ConfigTool, SkillTool, ToolContext};
use lime_agent::{build_diagnostics_runtime_status_metadata, AgentEvent as RuntimeAgentEvent};
use lime_core::database::dao::agent_timeline::{AgentThreadItemPayload, AgentThreadItemStatus};
use lime_core::workspace::WorkspaceSettings;
@@ -264,10 +264,13 @@ async fn sync_runtime_skill_source_agent(session_id: &str, agent: &Agent) -> Res
extension_manager: Some(Arc::downgrade(&donor.extension_manager)),
})
.await;
donor
.inherit_runtime_tool_surface_from(agent)
.await
.map_err(|error| format!("继承 runtime tool surface 失败: {error}"))?;
if let Err(error) = donor.inherit_runtime_tool_surface_from(agent).await {
tracing::warn!(
"[AsterAgent] 继承 runtime extension 工具面失败,保留 native 工具面供 Skill 使用: session={}, error={}",
session_id,
error
);
}
SkillTool::register_source_agent_for_session(session_id.to_string(), Arc::new(donor)).await;
Ok(())
}
@@ -877,6 +880,177 @@ fn emit_service_skill_preload_runtime_events(
);
}
fn build_image_skill_launch_tool_context(
workspace_root: &str,
session_id: &str,
thread_id: &str,
turn_id: &str,
project_id: Option<&str>,
content_id: Option<&str>,
) -> ToolContext {
let mut environment = HashMap::new();
environment.insert("LIME_THREAD_ID".to_string(), thread_id.to_string());
environment.insert("PROXYCAST_THREAD_ID".to_string(), thread_id.to_string());
environment.insert("LIME_TURN_ID".to_string(), turn_id.to_string());
environment.insert("PROXYCAST_TURN_ID".to_string(), turn_id.to_string());
if let Some(project_id) = project_id.map(str::trim).filter(|value| !value.is_empty()) {
environment.insert("LIME_PROJECT_ID".to_string(), project_id.to_string());
environment.insert("PROXYCAST_PROJECT_ID".to_string(), project_id.to_string());
}
if let Some(content_id) = content_id.map(str::trim).filter(|value| !value.is_empty()) {
environment.insert("LIME_CONTENT_ID".to_string(), content_id.to_string());
environment.insert("PROXYCAST_CONTENT_ID".to_string(), content_id.to_string());
}
ToolContext::new(Path::new(workspace_root).to_path_buf())
.with_session_id(session_id.to_string())
.with_environment(environment)
}
fn image_skill_launch_agent_tool_result(
tool_result: aster::tools::ToolResult,
) -> lime_agent::AgentToolResult {
let success = tool_result.success;
let error = tool_result.error;
let mut metadata = tool_result.metadata;
let forwarded_metadata = metadata.clone();
metadata.insert(
"skill_forwarded_tool_name".to_string(),
serde_json::json!(LIME_CREATE_IMAGE_TASK_TOOL_NAME),
);
metadata.insert(
"skill_forwarded_tool_metadata".to_string(),
serde_json::json!(forwarded_metadata),
);
metadata.insert(
"allowed_tools".to_string(),
serde_json::json!([LIME_CREATE_IMAGE_TASK_TOOL_NAME]),
);
metadata.insert(
"command_name".to_string(),
serde_json::json!("user:image_generate"),
);
metadata.insert(
"skill".to_string(),
serde_json::json!({
"success": success,
"error": error.clone(),
"stepsCompleted": [],
}),
);
lime_agent::AgentToolResult {
success,
output: String::new(),
error,
images: None,
metadata: Some(metadata),
}
}
fn image_skill_launch_failed_tool_result(error: &str) -> lime_agent::AgentToolResult {
lime_agent::AgentToolResult {
success: false,
output: String::new(),
error: Some(error.to_string()),
images: None,
metadata: Some(HashMap::from([
(
"skill_forwarded_tool_name".to_string(),
serde_json::json!(LIME_CREATE_IMAGE_TASK_TOOL_NAME),
),
(
"source".to_string(),
serde_json::json!("image_skill_launch"),
),
("task_type".to_string(), serde_json::json!("image_generate")),
])),
}
}
fn emit_image_skill_launch_tool_event(
app: &AppHandle,
event_name: &str,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
workspace_root: &str,
event: RuntimeAgentEvent,
) {
emit_runtime_side_event(app, event_name, timeline_recorder, workspace_root, event);
}
fn execute_image_skill_launch_direct_task(
app: &AppHandle,
request: &AsterChatRequest,
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
workspace_root: &str,
session_id: &str,
thread_id: &str,
turn_id: &str,
request_metadata: Option<&serde_json::Value>,
) -> Result<bool, String> {
let Some(task) = build_image_skill_launch_direct_task(
request_metadata,
session_id,
thread_id,
turn_id,
request.project_id.as_deref(),
)?
else {
return Ok(false);
};
let tool_id = format!("image-skill-launch:{turn_id}");
emit_image_skill_launch_tool_event(
app,
&request.event_name,
timeline_recorder,
workspace_root,
RuntimeAgentEvent::ToolStart {
tool_name: "Skill".to_string(),
tool_id: tool_id.clone(),
arguments: Some(task.skill_tool_arguments),
},
);
let context = build_image_skill_launch_tool_context(
workspace_root,
session_id,
thread_id,
turn_id,
request.project_id.as_deref(),
None,
);
match submit_image_generation_task_value(app, &context, task.tool_params) {
Ok(tool_result) => {
emit_image_skill_launch_tool_event(
app,
&request.event_name,
timeline_recorder,
workspace_root,
RuntimeAgentEvent::ToolEnd {
tool_id,
result: image_skill_launch_agent_tool_result(tool_result),
},
);
Ok(true)
}
Err(error) => {
let message = format!("创建图片任务失败: {error}");
emit_image_skill_launch_tool_event(
app,
&request.event_name,
timeline_recorder,
workspace_root,
RuntimeAgentEvent::ToolEnd {
tool_id,
result: image_skill_launch_failed_tool_result(&message),
},
);
Err(message)
}
}
}
fn build_artifact_document_warning_message(
status: &str,
fallback_used: bool,
@@ -1522,6 +1696,59 @@ impl RuntimeTurnPreparedExecution {
}
}
match execute_image_skill_launch_direct_task(
app,
request,
&self.runtime_turn_execution_context.timeline_recorder,
workspace_root,
session_id,
self.thread_id(),
self.turn_id(),
request_metadata,
) {
Ok(true) => {
return finalize_runtime_turn_result(
agent,
app,
state,
db,
&request.event_name,
&self.runtime_turn_execution_context.timeline_recorder,
workspace_root,
&self
.runtime_turn_execution_context
.runtime_status_session_config,
&self.runtime_turn_execution_context.profile_stream,
&self.runtime_turn_execution_context.task_profile_refs,
session_id,
request_metadata,
Ok(String::new()),
)
.await;
}
Ok(false) => {}
Err(error) => {
return finalize_runtime_turn_result(
agent,
app,
state,
db,
&request.event_name,
&self.runtime_turn_execution_context.timeline_recorder,
workspace_root,
&self
.runtime_turn_execution_context
.runtime_status_session_config,
&self.runtime_turn_execution_context.profile_stream,
&self.runtime_turn_execution_context.task_profile_refs,
session_id,
request_metadata,
Err(error),
)
.await;
}
}
self.runtime_turn_execution_context
.execute_and_finalize(
tracker,
+132 -2
View File
@@ -5102,9 +5102,9 @@ mod tests {
assert!(merged.contains("`lime_search_web_images` 只服务 @素材"));
assert!(merged.contains("应立即改为直调 Skill(image_generate)"));
assert!(merged.contains(
"如果 Skill(image_generate) 返回的 Lime 工具元数据里只有 allowed_tools=[\"lime_create_image_generation_task\"]"
"skill_forwarded_tool_name=lime_create_image_generation_task"
));
assert!(merged.contains("不要把 Skill(image_generate) success=true 误判成“任务已提交”"));
assert!(merged.contains("status=pending_submit 是标准 image task artifact 已创建"));
assert!(merged.contains("Skill(image_generate) -> lime_create_image_generation_task -> 标准 image task artifact + worker"));
assert!(merged.contains("不要再通过 Bash 拼接 CLI 命令或临时 /tmp 任务文件"));
assert!(merged.contains(
@@ -6757,6 +6757,20 @@ mod tests {
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
assert_eq!(
prepared
.pointer("/tool_scope/allowed_tools/0")
.and_then(serde_json::Value::as_str),
Some("Skill")
);
assert_eq!(
prepared
.pointer(
"/runtime_control/stop_after_tool_result/metadata_equals/skill_forwarded_tool_name"
)
.and_then(serde_json::Value::as_str),
Some("lime_create_image_generation_task")
);
let launch = harness
.get("image_skill_launch")
.and_then(serde_json::Value::as_object)
@@ -6810,6 +6824,9 @@ mod tests {
#[test]
fn test_prepare_image_skill_launch_request_metadata_sets_workbench_chat_mode_without_images() {
let metadata = serde_json::json!({
"lime_runtime": {
"tool_surface": "compact_tools"
},
"harness": {
"image_skill_launch": {
"skill_name": "image_generate",
@@ -6838,6 +6855,37 @@ mod tests {
harness.get("chat_mode").and_then(serde_json::Value::as_str),
Some("workbench")
);
assert_eq!(
prepared
.pointer("/tool_scope/source")
.and_then(serde_json::Value::as_str),
Some("image_skill_launch")
);
assert_eq!(
prepared
.pointer("/tool_scope/allowed_tools/0")
.and_then(serde_json::Value::as_str),
Some("Skill")
);
assert!(
prepared.pointer("/lime_runtime/tool_surface").is_none(),
"image launch must not keep compact tool surface because it hides Skill"
);
assert_eq!(
prepared
.pointer("/runtime_control/stop_after_tool_result/source")
.and_then(serde_json::Value::as_str),
Some("image_skill_launch")
);
assert!(
prepared
.pointer("/runtime_control/stop_after_tool_result/statuses")
.and_then(serde_json::Value::as_array)
.is_some_and(|items| items
.iter()
.any(|item| item.as_str() == Some("pending_submit"))),
"image launch must stop the provider loop once the task artifact is created"
);
let launch = harness
.get("image_skill_launch")
.and_then(serde_json::Value::as_object)
@@ -6860,6 +6908,88 @@ mod tests {
);
}
#[test]
fn test_build_image_skill_launch_direct_task_keeps_current_task_contract() {
let metadata = serde_json::json!({
"harness": {
"image_skill_launch": {
"skill_name": "image_generate",
"kind": "image_task",
"image_task": {
"prompt": "一张城市春日照片",
"provider_id": "fal",
"model": "fal-ai/nano-banana-pro",
"presentation": {
"assistant_intro": "好啊\n先获取下工具参数\n马上生成"
},
"taste_context": {
"version": "lime-image-taste-v1"
}
}
}
}
});
let direct = build_image_skill_launch_direct_task(
Some(&metadata),
"session-image",
"thread-image",
"turn-image",
Some("project-image"),
)
.expect("direct task result")
.expect("direct image task");
let params = direct
.tool_params
.as_object()
.expect("tool params should be flat object");
assert_eq!(
params.get("prompt").and_then(serde_json::Value::as_str),
Some("一张城市春日照片")
);
assert_eq!(
params.get("provider_id").and_then(serde_json::Value::as_str),
Some("fal")
);
assert_eq!(
params.get("model").and_then(serde_json::Value::as_str),
Some("fal-ai/nano-banana-pro")
);
assert_eq!(
params.get("session_id").and_then(serde_json::Value::as_str),
Some("session-image")
);
assert_eq!(
params.get("thread_id").and_then(serde_json::Value::as_str),
Some("thread-image")
);
assert_eq!(
params.get("turn_id").and_then(serde_json::Value::as_str),
Some("turn-image")
);
assert_eq!(
params.get("project_id").and_then(serde_json::Value::as_str),
Some("project-image")
);
assert_eq!(
params
.get("modality_contract_key")
.and_then(serde_json::Value::as_str),
Some("image_generation")
);
let skill_args: serde_json::Value =
serde_json::from_str(&direct.skill_tool_arguments).expect("skill args json");
assert_eq!(
skill_args.get("skill").and_then(serde_json::Value::as_str),
Some("image_generate")
);
assert!(
!direct.skill_tool_arguments.contains("一张城市春日照片"),
"Skill trace arguments should not expose the full prompt in chat chrome"
);
}
#[test]
fn test_prepare_service_scene_launch_request_metadata_sets_workbench_chat_mode() {
let metadata = serde_json::json!({
@@ -28,7 +28,9 @@ mod workspace_tools;
pub(crate) use browser_tools::ensure_browser_mcp_tools_registered;
#[allow(unused_imports)]
pub(crate) use browser_tools::LimeBrowserMcpTool;
pub(crate) use creation_tools::ensure_creation_task_tools_registered;
pub(crate) use creation_tools::{
ensure_creation_task_tools_registered, submit_image_generation_task_value,
};
pub(crate) use mcp_resource_tools::ensure_mcp_resource_tools_registered;
#[allow(unused_imports)]
pub(crate) use mcp_resource_tools::{ListMcpResourcesBridgeTool, ReadMcpResourceBridgeTool};
@@ -1350,6 +1350,23 @@ fn submit_image_generation_task_record(
))
}
pub(crate) fn submit_image_generation_task_value(
app_handle: &AppHandle,
context: &ToolContext,
params: serde_json::Value,
) -> Result<ToolResult, ToolError> {
let normalized_params =
normalize_flat_image_task_tool_params(normalize_image_task_tool_params(params)?)?;
let input: ImageTaskInput = serde_json::from_value(normalized_params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if input.prompt.trim().is_empty() {
return Err(ToolError::invalid_params(
"prompt 不能为空字符串".to_string(),
));
}
submit_image_generation_task_record(app_handle, context, input)
}
#[async_trait]
impl Tool for LimeCreateImageTaskTool {
fn name(&self) -> &str {
@@ -1369,16 +1386,7 @@ impl Tool for LimeCreateImageTaskTool {
params: serde_json::Value,
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let normalized_params =
normalize_flat_image_task_tool_params(normalize_image_task_tool_params(params)?)?;
let input: ImageTaskInput = serde_json::from_value(normalized_params)
.map_err(|error| ToolError::invalid_params(format!("参数解析失败: {error}")))?;
if input.prompt.trim().is_empty() {
return Err(ToolError::invalid_params(
"prompt 不能为空字符串".to_string(),
));
}
submit_image_generation_task_record(&self.app_handle, context, input)
submit_image_generation_task_value(&self.app_handle, context, params)
}
}
@@ -92,6 +92,9 @@ pub(crate) fn emit_media_creation_task_event(app_handle: &AppHandle, output: &Me
"persona_context": payload_record.get("persona_context").cloned(),
"presentation": payload_record.get("presentation").cloned(),
"taste_context": payload_record.get("taste_context").cloned(),
"provider": read_payload_string(payload_record, &["provider", "providerName", "provider_name"]),
"provider_id": read_payload_string(payload_record, &["provider_id", "providerId"]),
"model": read_payload_string(payload_record, &["model", "modelName", "model_name"]),
"session_id": read_payload_string(payload_record, &["session_id", "sessionId"]),
"thread_id": read_payload_string(payload_record, &["thread_id", "threadId"]),
"turn_id": read_payload_string(payload_record, &["turn_id", "turnId"]),
@@ -0,0 +1,23 @@
use crate::config::{ExperimentalFeatures, GlobalConfigManagerState};
use tauri::{command, State};
#[command]
pub async fn get_experimental_config(
config_manager: State<'_, GlobalConfigManagerState>,
) -> Result<ExperimentalFeatures, String> {
Ok(config_manager.config().experimental.clone())
}
#[command]
pub async fn save_experimental_config(
config_manager: State<'_, GlobalConfigManagerState>,
experimental_config: ExperimentalFeatures,
) -> Result<(), String> {
let mut config = config_manager.config();
config.experimental = experimental_config;
config_manager
.save_config(&config)
.await
.map_err(|error| format!("保存实验室配置失败: {error}"))
}
+46
View File
@@ -0,0 +1,46 @@
use tauri::command;
use tauri_plugin_global_shortcut::Shortcut;
#[command]
pub fn validate_shortcut(shortcut_str: String) -> Result<bool, String> {
if shortcut_str.trim().is_empty() {
return Err("快捷键不能为空".to_string());
}
shortcut_str
.parse::<Shortcut>()
.map_err(|error| format!("无法解析快捷键 '{shortcut_str}': {error}"))?;
if let Some(reason) =
crate::global_shortcut_guard::reserved_system_shortcut_reason(&shortcut_str)
{
return Err(reason.to_string());
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_shortcut_accepts_common_global_shortcuts() {
assert!(validate_shortcut("CommandOrControl+Shift+S".to_string()).is_ok());
assert!(validate_shortcut("Alt+F4".to_string()).is_ok());
assert!(validate_shortcut("Ctrl+C".to_string()).is_ok());
}
#[test]
fn validate_shortcut_rejects_invalid_values() {
assert!(validate_shortcut("".to_string()).is_err());
assert!(validate_shortcut("InvalidKey".to_string()).is_err());
}
#[test]
fn validate_shortcut_rejects_input_method_reserved_shortcuts() {
let result = validate_shortcut("CommandOrControl+Space".to_string());
assert!(result.is_err());
assert!(result.unwrap_err().contains("输入法切换"));
}
}
+2 -1
View File
@@ -18,11 +18,13 @@ pub mod context_memory;
pub mod document_import_cmd;
pub mod ecommerce_review_reply_cmd;
pub mod execution_run_cmd;
pub mod experimental_cmd;
pub mod external_tools_cmd;
pub mod file_upload_cmd;
pub mod gallery_material_cmd;
pub mod gateway_channel_cmd;
pub mod gateway_tunnel_cmd;
pub mod hotkey_cmd;
pub mod image_search_cmd;
pub mod image_upload_cmd;
pub mod injection_cmd;
@@ -42,7 +44,6 @@ pub mod models_cmd;
pub mod persona_cmd;
pub mod prompt_cmd;
pub mod sceneapp_cmd;
pub mod screenshot_cmd;
pub mod security_perf_cmd;
pub mod session_files_cmd;
pub mod site_capability_cmd;
-360
View File
@@ -1,360 +0,0 @@
//! 截图对话命令模块
//!
//! 提供截图对话功能的 Tauri 命令接口,包括:
//! - 获取和保存实验室功能配置
//! - 启动截图
//! - 验证和更新快捷键
//! - 读取图片为 Base64
//!
//! _需求: 1.1, 1.4, 1.5, 2.2, 2.4, 3.1, 5.1_
use crate::config::{ExperimentalFeatures, GlobalConfigManagerState};
use crate::screenshot::{capture, shortcut};
use tauri::{AppHandle, Emitter, Manager, State};
use tracing::{debug, error, info};
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScreenshotShortcutRuntimeStatus {
pub shortcut_registered: bool,
pub registered_shortcut: Option<String>,
}
/// 获取实验室功能配置
///
/// 从应用状态中获取当前的实验室功能配置
///
/// # 返回
/// 成功返回 ExperimentalFeatures,失败返回错误信息
///
/// _需求: 1.1_
#[tauri::command]
pub async fn get_experimental_config(
config_manager: State<'_, GlobalConfigManagerState>,
) -> Result<ExperimentalFeatures, String> {
debug!("获取实验室功能配置");
let config = config_manager.config();
Ok(config.experimental.clone())
}
/// 获取截图快捷键运行时状态
#[tauri::command]
pub async fn get_screenshot_shortcut_runtime_status(
) -> Result<ScreenshotShortcutRuntimeStatus, String> {
Ok(ScreenshotShortcutRuntimeStatus {
shortcut_registered: shortcut::is_registered(),
registered_shortcut: shortcut::get_current(),
})
}
/// 保存实验室功能配置
///
/// 将实验室功能配置保存到应用状态和配置文件
/// 同时根据配置状态注册或注销快捷键
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `config_manager`: 全局配置管理器状态
/// - `experimental_config`: 要保存的实验室功能配置
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误信息
///
/// _需求: 1.4, 1.5_
#[tauri::command]
pub async fn save_experimental_config(
app: AppHandle,
config_manager: State<'_, GlobalConfigManagerState>,
experimental_config: ExperimentalFeatures,
) -> Result<(), String> {
info!(
"保存实验室功能配置: enabled={}, shortcut={}",
experimental_config.screenshot_chat.enabled, experimental_config.screenshot_chat.shortcut
);
// 获取旧配置以比较变化
let old_config = config_manager.config();
let was_enabled = old_config.experimental.screenshot_chat.enabled;
let is_enabled = experimental_config.screenshot_chat.enabled;
debug!(
"配置变化: was_enabled={}, is_enabled={}",
was_enabled, is_enabled
);
// 更新配置
let mut new_config = old_config.clone();
new_config.experimental = experimental_config.clone();
// 保存配置到文件
debug!("开始保存配置到文件...");
if let Err(e) = config_manager.save_config(&new_config).await {
error!("保存配置失败: {}", e);
return Err(format!("保存配置失败: {e}"));
}
info!("配置文件保存成功");
// 根据功能开关状态注册或注销快捷键
if was_enabled != is_enabled {
if is_enabled {
info!(
"截图对话功能已启用,注册快捷键: {}",
experimental_config.screenshot_chat.shortcut
);
if let Err(e) = shortcut::register(&app, &experimental_config.screenshot_chat.shortcut)
{
error!("注册快捷键失败: {}", e);
return Err(format!("注册快捷键失败: {e}"));
}
info!("快捷键注册成功");
} else {
info!("截图对话功能已禁用,注销快捷键");
if let Err(e) = shortcut::unregister(&app) {
error!("注销快捷键失败: {}", e);
return Err(format!("注销快捷键失败: {e}"));
}
info!("快捷键注销成功");
}
}
info!("实验室功能配置保存完成");
Ok(())
}
/// 启动截图
///
/// 启动交互式截图,返回截图文件路径
///
/// # 返回
/// 成功返回截图文件路径,用户取消返回空字符串,失败返回错误信息
///
/// _需求: 3.1_
#[tauri::command]
pub async fn start_screenshot() -> Result<String, String> {
info!("启动截图命令");
match capture::start_capture().await {
Ok(path) => {
info!("截图成功: {:?}", path);
Ok(path.to_string_lossy().to_string())
}
Err(capture::CaptureError::Cancelled) => {
info!("用户取消了截图");
Ok(String::new())
}
Err(e) => {
error!("截图失败: {}", e);
Err(format!("截图失败: {e}"))
}
}
}
/// 验证快捷键格式
///
/// 检查快捷键字符串是否符合 Tauri 快捷键格式要求
///
/// # 参数
/// - `shortcut_str`: 快捷键字符串,如 "CommandOrControl+Shift+S"
///
/// # 返回
/// 如果格式有效返回 true,否则返回错误信息
///
/// _需求: 2.2_
#[tauri::command]
pub fn validate_shortcut(shortcut_str: String) -> Result<bool, String> {
debug!("验证快捷键格式: {}", shortcut_str);
match shortcut::validate(&shortcut_str) {
Ok(()) => Ok(true),
Err(e) => Err(format!("{e}")),
}
}
/// 更新截图快捷键
///
/// 原子性地更新快捷键:先注销旧快捷键,再注册新快捷键
/// 同时更新配置文件
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `config_manager`: 全局配置管理器状态
/// - `new_shortcut`: 新的快捷键字符串
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误信息
///
/// _需求: 2.4_
#[tauri::command]
pub async fn update_screenshot_shortcut(
app: AppHandle,
config_manager: State<'_, GlobalConfigManagerState>,
new_shortcut: String,
) -> Result<(), String> {
info!("更新截图快捷键: {}", new_shortcut);
// 验证新快捷键格式
shortcut::validate(&new_shortcut).map_err(|e| format!("快捷键格式无效: {e}"))?;
// 获取当前配置
let mut config = config_manager.config();
// 检查功能是否启用
if config.experimental.screenshot_chat.enabled {
// 更新快捷键(原子操作)
shortcut::update(&app, &new_shortcut).map_err(|e| format!("更新快捷键失败: {e}"))?;
}
// 更新配置
config.experimental.screenshot_chat.shortcut = new_shortcut;
// 保存配置到文件
config_manager
.save_config(&config)
.await
.map_err(|e| format!("保存配置失败: {e}"))?;
info!("截图快捷键更新成功");
Ok(())
}
/// 关闭截图对话窗口
///
/// 关闭当前打开的截图对话悬浮窗口
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误信息
#[tauri::command]
pub fn close_screenshot_chat_window(app: AppHandle) -> Result<(), String> {
info!("关闭截图对话窗口");
crate::screenshot::window::close_floating_window(&app).map_err(|e| format!("关闭窗口失败: {e}"))
}
/// 打开带预填文本的输入框
///
/// 用于语音识别完成后,将识别结果填入输入框
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `text`: 预填文本
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误信息
#[tauri::command]
pub fn open_input_with_text(app: AppHandle, text: String) -> Result<(), String> {
info!("打开带预填文本的输入框: {} 字符", text.len());
crate::screenshot::window::open_floating_window_with_text(&app, &text)
.map_err(|e| format!("打开窗口失败: {e}"))
}
/// 读取图片文件并转换为 Base64
///
/// 读取指定路径的图片文件,并将其内容编码为 Base64 字符串
///
/// # 参数
/// - `path`: 图片文件路径
///
/// # 返回
/// 成功返回 Base64 编码的图片数据,失败返回错误信息
///
/// _需求: 5.1_
#[tauri::command]
pub async fn read_image_as_base64(path: String) -> Result<String, String> {
lime_services::screenshot_image_service::read_image_as_base64(&path).await
}
/// 截图对话消息结构
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScreenshotChatMessage {
pub message: String,
pub image_path: Option<String>,
pub image_base64: Option<String>,
}
/// 发送截图对话消息到主应用
///
/// 将用户输入的消息和截图发送到主应用的 Agent 聊天界面
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `message`: 用户输入的消息
/// - `image_path`: 截图文件路径(可选)
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误信息
#[tauri::command]
pub async fn send_screenshot_chat(
app: AppHandle,
message: String,
image_path: Option<String>,
) -> Result<(), String> {
info!(
"发送截图对话: message={}, image_path={:?}",
message, image_path
);
// 如果有图片,读取为 Base64
let image_base64 = if let Some(ref path) = image_path {
match read_image_as_base64(path.clone()).await {
Ok(base64) => Some(base64),
Err(e) => {
error!("读取图片失败: {}", e);
None
}
}
} else {
None
};
// 构建消息
let chat_message = ScreenshotChatMessage {
message,
image_path,
image_base64,
};
// 发送事件到主窗口
if let Some(main_window) = app.get_webview_window("main") {
main_window
.emit("smart-input-message", &chat_message)
.map_err(|e| format!("发送事件失败: {e}"))?;
// 恢复并聚焦主窗口(主窗口在截图时被最小化)
let _ = main_window.unminimize();
let _ = main_window.show();
let _ = main_window.set_focus();
} else {
// 尝试发送到所有窗口
app.emit("smart-input-message", &chat_message)
.map_err(|e| format!("发送事件失败: {e}"))?;
}
info!("截图对话消息已发送");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_shortcut_valid() {
assert!(validate_shortcut("CommandOrControl+Shift+S".to_string()).is_ok());
assert!(validate_shortcut("Alt+F4".to_string()).is_ok());
assert!(validate_shortcut("Ctrl+C".to_string()).is_ok());
}
#[test]
fn test_validate_shortcut_invalid() {
assert!(validate_shortcut("".to_string()).is_err());
assert!(validate_shortcut("InvalidKey".to_string()).is_err());
}
#[test]
fn test_validate_shortcut_rejects_input_method_reserved_shortcuts() {
let result = validate_shortcut("CommandOrControl+Space".to_string());
assert!(result.is_err());
assert!(result.err().unwrap().contains("输入法切换"));
}
}
+1 -1
View File
@@ -492,7 +492,7 @@ mod tests {
.unwrap();
assert!(status_value["shortcut_registered"].is_boolean());
assert!(status_value["translate_shortcut_registered"].is_boolean());
assert!(status_value["fn_registered"].is_boolean());
}
#[tokio::test]
@@ -164,18 +164,6 @@ pub(super) async fn try_handle(
crate::voice::commands::delete_voice_instruction(id).await?;
JsonValue::Null
}
"open_voice_window" => {
let args = args_or_default(args);
let target = get_optional_string_arg(&args, "target", "target");
let app_handle = require_app_handle(state)?;
crate::voice::commands::open_voice_window(app_handle, target).await?;
JsonValue::Null
}
"close_voice_window" => {
let app_handle = require_app_handle(state)?;
crate::voice::commands::close_voice_window(app_handle).await?;
JsonValue::Null
}
"list_audio_devices" => {
serde_json::to_value(crate::voice::recording_service::list_audio_devices()?)?
}
@@ -277,13 +265,6 @@ pub(super) async fn try_handle(
duration: service.get_duration(),
})?
}
"open_input_with_text" => {
let args = args_or_default(args);
let text = get_string_arg(&args, "text", "text")?;
let app_handle = require_app_handle(state)?;
crate::commands::screenshot_cmd::open_input_with_text(app_handle, text)?;
JsonValue::Null
}
_ => return Ok(None),
};
-1
View File
@@ -42,7 +42,6 @@ pub mod agent;
pub mod agent_tools;
pub mod app;
pub mod sceneapp;
pub mod screenshot;
pub mod services;
pub mod skills;
pub mod tray;
-69
View File
@@ -1,69 +0,0 @@
# 截图对话模块 (screenshot/)
截图对话功能的 Rust 后端实现,提供全局快捷键、屏幕截图和悬浮窗口管理。
## 模块结构
```
screenshot/
├── mod.rs # 模块入口,导出公共接口和初始化函数
├── capture.rs # 截图桥接层(纯逻辑在 crates/services/src/screenshot_capture_service.rs)
├── config.rs # 实验室功能配置管理
├── shortcut.rs # 全局快捷键注册/注销/更新
├── window.rs # 悬浮对话窗口管理
└── README.md # 本文档
```
## 核心功能
### 1. 模块初始化 (mod.rs)
- `init(app)`: 初始化截图对话模块,根据配置注册快捷键
- `cleanup(app)`: 清理模块资源,注销快捷键
### 2. 配置管理 (config.rs)
- `load_experimental_config(app)`: 加载实验室功能配置
- `is_screenshot_chat_enabled(config)`: 检查功能是否启用
- `save_experimental_config(app, config)`: 保存配置到文件
### 3. 快捷键管理 (shortcut.rs)
- `register(app, shortcut)`: 注册全局快捷键
- `unregister(app)`: 注销当前快捷键
- `update(app, new_shortcut)`: 原子性更新快捷键
- `validate(shortcut)`: 验证快捷键格式
### 4. 截图服务 (capture.rs)
- `start_capture()`: 启动交互式截图
- 支持 macOS (`screencapture`)、Windows (PowerShell)、Linux (`gnome-screenshot` / `scrot`)
### 5. 窗口管理 (window.rs)
- `open_floating_window(app, image_path)`: 打开悬浮对话窗口
- `close_floating_window(app)`: 关闭悬浮窗口
- `is_floating_window_open(app)`: 检查窗口状态
## 依赖
- `tauri-plugin-global-shortcut`: 全局快捷键支持
- `chrono`: 时间戳生成
- `urlencoding`: URL 参数编码
## 配置示例
```yaml
experimental:
screenshot_chat:
enabled: true
shortcut: "CommandOrControl+Shift+S"
```
## 相关需求
- 需求 1: 实验室功能开关
- 需求 2: 快捷键配置
- 需求 3: 屏幕截图
- 需求 4: 悬浮对话窗口
- 需求 7: 模块化代码组织
-8
View File
@@ -1,8 +0,0 @@
//! 屏幕截图服务(桥接层)
//!
//! 纯逻辑已迁移到 `lime-services` crate,
//! 本模块保留兼容导出。
pub use lime_services::screenshot_capture_service::{
cleanup_temp_file, start_capture, CaptureError, CaptureResult,
};
-134
View File
@@ -1,134 +0,0 @@
//! 截图对话配置管理
//!
//! 提供实验室功能配置的加载和检查功能
use crate::config::GlobalConfigManagerState;
use lime_core::config::{ExperimentalFeatures, ScreenshotChatConfig};
use tauri::{AppHandle, Manager};
use tracing::debug;
/// 配置错误类型
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("无法获取应用状态")]
StateNotFound,
#[error("配置加载失败: {0}")]
LoadFailed(String),
}
/// 加载实验室功能配置
///
/// 从应用状态中获取当前的实验室功能配置
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回 ExperimentalFeatures,失败返回错误
pub fn load_experimental_config(app: &AppHandle) -> Result<ExperimentalFeatures, ConfigError> {
debug!("加载实验室功能配置");
// 获取全局配置管理器
let config_manager = app
.try_state::<GlobalConfigManagerState>()
.ok_or(ConfigError::StateNotFound)?;
// 从配置中获取实验室功能配置
let config = config_manager.config();
let experimental = config.experimental.clone();
debug!(
"实验室功能配置: screenshot_chat.enabled={}, shortcut={}",
experimental.screenshot_chat.enabled, experimental.screenshot_chat.shortcut
);
Ok(experimental)
}
/// 检查截图对话功能是否启用
///
/// # 参数
/// - `config`: 实验室功能配置
///
/// # 返回
/// 如果截图对话功能启用返回 true,否则返回 false
pub fn is_screenshot_chat_enabled(config: &ExperimentalFeatures) -> bool {
config.screenshot_chat.enabled
}
/// 获取截图对话配置
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回 ScreenshotChatConfig,失败返回错误
pub fn get_screenshot_chat_config(app: &AppHandle) -> Result<ScreenshotChatConfig, ConfigError> {
let experimental = load_experimental_config(app)?;
Ok(experimental.screenshot_chat)
}
/// 保存实验室功能配置
///
/// 将实验室功能配置保存到应用状态和配置文件
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `experimental_config`: 要保存的实验室功能配置
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub async fn save_experimental_config(
app: &AppHandle,
experimental_config: ExperimentalFeatures,
) -> Result<(), ConfigError> {
debug!("保存实验室功能配置");
// 获取全局配置管理器
let config_manager = app
.try_state::<GlobalConfigManagerState>()
.ok_or(ConfigError::StateNotFound)?;
// 获取当前配置并更新实验室功能部分
let mut config = config_manager.config();
config.experimental = experimental_config;
// 保存配置
config_manager
.save_config(&config)
.await
.map_err(ConfigError::LoadFailed)?;
debug!("实验室功能配置已保存");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_screenshot_chat_enabled_default() {
let config = ExperimentalFeatures::default();
assert!(!is_screenshot_chat_enabled(&config));
}
#[test]
fn test_is_screenshot_chat_enabled_true() {
let config = ExperimentalFeatures {
screenshot_chat: ScreenshotChatConfig {
enabled: true,
shortcut: "CommandOrControl+Shift+S".to_string(),
},
..Default::default()
};
assert!(is_screenshot_chat_enabled(&config));
}
#[test]
fn test_default_shortcut() {
let config = ScreenshotChatConfig::default();
assert_eq!(config.shortcut, "CommandOrControl+Alt+Q");
assert!(!config.enabled);
}
}
-69
View File
@@ -1,69 +0,0 @@
//! 截图对话模块
//!
//! 提供截图对话功能的核心实现,包括:
//! - 屏幕截图服务
//! - 全局快捷键管理
//! - 悬浮窗口管理
//! - 实验室功能配置
pub mod capture;
pub mod config;
pub mod shortcut;
pub mod window;
use tauri::AppHandle;
use tracing::{error, info};
/// 截图模块错误类型
#[derive(Debug, thiserror::Error)]
pub enum ScreenshotError {
#[error("配置错误: {0}")]
Config(String),
#[error("快捷键错误: {0}")]
Shortcut(#[from] shortcut::ShortcutError),
#[error("截图错误: {0}")]
Capture(#[from] capture::CaptureError),
#[error("窗口错误: {0}")]
Window(#[from] window::WindowError),
}
/// 初始化截图对话模块
///
/// 根据配置决定是否注册全局快捷键
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn init(app: &AppHandle) -> Result<(), ScreenshotError> {
info!("初始化截图对话模块");
// 加载实验室功能配置
let experimental_config = config::load_experimental_config(app)
.map_err(|e| ScreenshotError::Config(e.to_string()))?;
// 检查截图对话功能是否启用
if config::is_screenshot_chat_enabled(&experimental_config) {
info!("截图对话功能已启用,注册快捷键");
shortcut::register(app, &experimental_config.screenshot_chat.shortcut)?;
} else {
info!("截图对话功能未启用,跳过快捷键注册");
}
Ok(())
}
/// 清理截图对话模块资源
///
/// 注销快捷键并清理临时文件
pub fn cleanup(app: &AppHandle) -> Result<(), ScreenshotError> {
info!("清理截图对话模块资源");
// 尝试注销快捷键(忽略错误,因为可能未注册)
if let Err(e) = shortcut::unregister(app) {
error!("注销快捷键失败: {}", e);
}
Ok(())
}
-361
View File
@@ -1,361 +0,0 @@
//! 全局快捷键管理
//!
//! 提供截图对话功能的全局快捷键注册、注销和更新功能
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use tauri::{AppHandle, Manager};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
use tracing::{debug, error, info, warn};
use super::capture;
use super::window;
/// 快捷键错误类型
#[derive(Debug, thiserror::Error)]
pub enum ShortcutError {
#[error("快捷键格式无效: {0}")]
InvalidFormat(String),
#[error("{0}")]
ReservedSystemShortcut(String),
#[error("快捷键注册失败: {0}")]
RegisterFailed(String),
#[error("快捷键注销失败: {0}")]
UnregisterFailed(String),
#[error("快捷键已被占用: {0}")]
AlreadyInUse(String),
#[error("快捷键解析失败: {0}")]
ParseFailed(String),
}
/// 当前注册的快捷键(用于更新时注销旧快捷键)
static CURRENT_SHORTCUT: OnceLock<parking_lot::RwLock<Option<String>>> = OnceLock::new();
/// 快捷键是否已注册
static IS_REGISTERED: AtomicBool = AtomicBool::new(false);
fn get_current_shortcut() -> &'static parking_lot::RwLock<Option<String>> {
CURRENT_SHORTCUT.get_or_init(|| parking_lot::RwLock::new(None))
}
/// 验证快捷键格式
///
/// 检查快捷键字符串是否符合 Tauri 快捷键格式要求
///
/// # 参数
/// - `shortcut`: 快捷键字符串,如 "CommandOrControl+Shift+S"
///
/// # 返回
/// 如果格式有效返回 Ok(()), 否则返回错误
///
/// # 有效格式示例
/// - "CommandOrControl+Shift+S"
/// - "Alt+F4"
/// - "Ctrl+Alt+Delete"
pub fn validate(shortcut: &str) -> Result<(), ShortcutError> {
if shortcut.is_empty() {
return Err(ShortcutError::InvalidFormat("快捷键不能为空".to_string()));
}
// 尝试解析快捷键以验证格式
shortcut
.parse::<Shortcut>()
.map_err(|e| ShortcutError::InvalidFormat(format!("无法解析快捷键 '{shortcut}': {e}")))?;
if let Some(reason) = crate::global_shortcut_guard::reserved_system_shortcut_reason(shortcut) {
return Err(ShortcutError::ReservedSystemShortcut(reason.to_string()));
}
debug!("快捷键格式验证通过: {}", shortcut);
Ok(())
}
/// 注册全局快捷键
///
/// 注册截图对话功能的全局快捷键
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `shortcut_str`: 快捷键字符串
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn register(app: &AppHandle, shortcut_str: &str) -> Result<(), ShortcutError> {
info!("注册全局快捷键: {}", shortcut_str);
// 验证快捷键格式
validate(shortcut_str)?;
// 解析快捷键
let shortcut: Shortcut = shortcut_str
.parse()
.map_err(|e| ShortcutError::ParseFailed(format!("{e}")))?;
// 获取全局快捷键管理器
let global_shortcut = app.global_shortcut();
// 检查快捷键是否已被注册
let is_already_registered = global_shortcut.is_registered(shortcut);
info!(
"快捷键 {} 是否已注册: {}",
shortcut_str, is_already_registered
);
if is_already_registered {
warn!("快捷键已被注册: {}", shortcut_str);
// 如果是我们自己注册的,先注销
if IS_REGISTERED.load(Ordering::SeqCst) {
info!("尝试注销已有的快捷键");
if let Err(e) = global_shortcut.unregister(shortcut) {
error!("注销已有快捷键失败: {}", e);
}
} else {
return Err(ShortcutError::AlreadyInUse(shortcut_str.to_string()));
}
}
// 克隆 app handle 用于回调
let app_clone = app.clone();
// 注册快捷键
info!("开始注册快捷键回调...");
global_shortcut
.on_shortcut(shortcut, move |_app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
info!("截图快捷键被触发");
handle_shortcut_triggered(&app_clone);
}
})
.map_err(|e| {
error!("注册快捷键失败: {}", e);
ShortcutError::RegisterFailed(format!("{e}"))
})?;
// 更新状态
IS_REGISTERED.store(true, Ordering::SeqCst);
*get_current_shortcut().write() = Some(shortcut_str.to_string());
info!("全局快捷键注册成功: {}", shortcut_str);
Ok(())
}
/// 注销全局快捷键
///
/// 注销当前注册的截图对话快捷键
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn unregister(app: &AppHandle) -> Result<(), ShortcutError> {
let current = get_current_shortcut().read().clone();
if let Some(shortcut_str) = current {
info!("注销全局快捷键: {}", shortcut_str);
let shortcut: Shortcut = shortcut_str
.parse()
.map_err(|e| ShortcutError::ParseFailed(format!("{e}")))?;
let global_shortcut = app.global_shortcut();
if global_shortcut.is_registered(shortcut) {
global_shortcut
.unregister(shortcut)
.map_err(|e| ShortcutError::UnregisterFailed(format!("{e}")))?;
}
// 更新状态
IS_REGISTERED.store(false, Ordering::SeqCst);
*get_current_shortcut().write() = None;
info!("全局快捷键注销成功");
} else {
debug!("没有已注册的快捷键需要注销");
}
Ok(())
}
/// 更新全局快捷键
///
/// 原子性地更新快捷键:先注销旧快捷键,再注册新快捷键
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `new_shortcut`: 新的快捷键字符串
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
///
/// # 注意
/// 此操作是原子性的:如果新快捷键注册失败,会尝试恢复旧快捷键
pub fn update(app: &AppHandle, new_shortcut: &str) -> Result<(), ShortcutError> {
info!("更新全局快捷键: {}", new_shortcut);
// 验证新快捷键格式
validate(new_shortcut)?;
// 保存旧快捷键以便恢复
let old_shortcut = get_current_shortcut().read().clone();
// 注销旧快捷键
if let Err(e) = unregister(app) {
warn!("注销旧快捷键失败: {}", e);
// 继续尝试注册新快捷键
}
// 注册新快捷键
match register(app, new_shortcut) {
Ok(()) => {
info!("快捷键更新成功: {}", new_shortcut);
Ok(())
}
Err(e) => {
error!("注册新快捷键失败: {}", e);
// 尝试恢复旧快捷键
if let Some(old) = old_shortcut {
warn!("尝试恢复旧快捷键: {}", old);
if let Err(restore_err) = register(app, &old) {
error!("恢复旧快捷键失败: {}", restore_err);
}
}
Err(e)
}
}
}
/// 检查快捷键是否已注册
pub fn is_registered() -> bool {
IS_REGISTERED.load(Ordering::SeqCst)
}
/// 获取当前注册的快捷键
pub fn get_current() -> Option<String> {
get_current_shortcut().read().clone()
}
/// 处理快捷键触发事件
///
/// 当用户按下截图快捷键时调用此函数
fn handle_shortcut_triggered(app: &AppHandle) {
info!("处理截图快捷键触发");
// 在后台线程中执行截图操作
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
// 截图前最小化主窗口(而非隐藏),避免视觉干扰但保持可恢复
let main_window = app_clone.get_webview_window("main");
if let Some(win) = main_window.as_ref() {
debug!("最小化主窗口");
let _ = win.minimize();
}
// 短暂延迟,确保窗口完全最小化
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
match capture::start_capture().await {
Ok(image_path) => {
info!("截图成功: {:?}", image_path);
// 打开悬浮窗口
if let Err(e) = window::open_floating_window(&app_clone, &image_path) {
error!("打开悬浮窗口失败: {}", e);
// 如果悬浮窗口打开失败,恢复主窗口
if let Some(win) = main_window.as_ref() {
let _ = win.unminimize();
let _ = win.set_focus();
}
}
}
Err(capture::CaptureError::Cancelled) => {
info!("用户取消了截图");
// 用户取消截图,恢复主窗口显示
if let Some(win) = main_window.as_ref() {
debug!("恢复主窗口显示");
let _ = win.unminimize();
let _ = win.set_focus();
}
}
Err(e) => {
error!("截图失败: {}", e);
// 截图失败,恢复主窗口显示
if let Some(win) = main_window.as_ref() {
let _ = win.unminimize();
let _ = win.set_focus();
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn reset_runtime_state() {
IS_REGISTERED.store(false, Ordering::SeqCst);
*get_current_shortcut().write() = None;
}
#[test]
fn test_validate_valid_shortcuts() {
// 有效的快捷键格式
assert!(validate("CommandOrControl+Shift+S").is_ok());
assert!(validate("Alt+F4").is_ok());
assert!(validate("Ctrl+C").is_ok());
assert!(validate("Shift+A").is_ok());
}
#[test]
fn test_validate_empty_shortcut() {
let result = validate("");
assert!(result.is_err());
match result {
Err(ShortcutError::InvalidFormat(msg)) => {
assert!(msg.contains("不能为空"));
}
_ => panic!("Expected InvalidFormat error"),
}
}
#[test]
fn test_validate_invalid_shortcuts() {
// 无效的快捷键格式
assert!(validate("InvalidKey").is_err());
assert!(validate("+++").is_err());
assert!(validate("Ctrl++").is_err());
}
#[test]
fn test_validate_rejects_input_method_reserved_shortcuts() {
for shortcut in ["CommandOrControl+Space", "Ctrl+Space", "Super+Space"] {
let result = validate(shortcut);
assert!(result.is_err());
match result {
Err(ShortcutError::ReservedSystemShortcut(message)) => {
assert!(message.contains("输入法切换"));
}
_ => panic!("Expected ReservedSystemShortcut error for {shortcut}"),
}
}
}
#[test]
fn test_runtime_status_accessors_reflect_state() {
reset_runtime_state();
assert!(!is_registered());
assert_eq!(get_current(), None);
IS_REGISTERED.store(true, Ordering::SeqCst);
*get_current_shortcut().write() = Some("CommandOrControl+Shift+4".to_string());
assert!(is_registered());
assert_eq!(get_current().as_deref(), Some("CommandOrControl+Shift+4"));
reset_runtime_state();
}
}
-487
View File
@@ -1,487 +0,0 @@
//! 悬浮窗口管理
//!
//! 提供截图对话悬浮窗口的创建、显示和关闭功能
use mouse_position::mouse_position::Mouse;
use std::path::Path;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use tracing::{debug, info};
#[cfg(target_os = "macos")]
#[allow(deprecated)]
use cocoa::appkit::{NSColor, NSWindow};
#[cfg(target_os = "macos")]
#[allow(deprecated)]
use cocoa::base::{id, nil};
/// 窗口错误类型
#[derive(Debug, thiserror::Error)]
pub enum WindowError {
#[error("窗口创建失败: {0}")]
CreateFailed(String),
#[error("窗口未找到: {0}")]
NotFound(String),
#[error("窗口操作失败: {0}")]
OperationFailed(String),
}
/// 悬浮窗口标签
const FLOATING_WINDOW_LABEL: &str = "smart-input";
/// 窗口尺寸(包含 padding 用于阴影)
const WINDOW_WIDTH: f64 = 645.0;
const WINDOW_HEIGHT: f64 = 70.0;
/// 距离屏幕底部的距离
const BOTTOM_MARGIN: f64 = 200.0;
/// 获取鼠标所在的显示器
///
/// 使用 mouse_position crate 获取鼠标位置,然后遍历所有显示器找到鼠标所在的显示器
fn get_monitor_at_cursor(app: &AppHandle) -> Option<tauri::Monitor> {
// 使用 mouse_position crate 获取鼠标位置
let (cursor_x, cursor_y) = match Mouse::get_mouse_position() {
Mouse::Position { x, y } => {
debug!("鼠标位置 (mouse_position crate): ({}, {})", x, y);
(x as f64, y as f64)
}
Mouse::Error => {
debug!("无法获取鼠标位置");
return None;
}
};
// 获取所有显示器
let monitors = match app.available_monitors() {
Ok(monitors) => monitors,
Err(e) => {
debug!("无法获取显示器列表: {}", e);
return None;
}
};
// 查找鼠标所在的显示器
for monitor in monitors {
let pos = monitor.position();
let size = monitor.size();
let left = pos.x as f64;
let top = pos.y as f64;
let right = left + size.width as f64;
let bottom = top + size.height as f64;
if cursor_x >= left && cursor_x < right && cursor_y >= top && cursor_y < bottom {
debug!(
"鼠标在显示器: {:?}, 位置: ({}, {}), 尺寸: {}x{}",
monitor.name(),
pos.x,
pos.y,
size.width,
size.height
);
return Some(monitor);
}
}
debug!(
"未找到鼠标所在的显示器,鼠标位置: ({}, {})",
cursor_x, cursor_y
);
None
}
/// 计算窗口位置(屏幕底部居中)
///
/// 优先使用鼠标所在的显示器,否则使用主显示器
/// 返回逻辑坐标(考虑 DPI 缩放)
fn calculate_window_position(app: &AppHandle) -> (f64, f64) {
// 优先获取鼠标所在的显示器
let monitor = get_monitor_at_cursor(app).or_else(|| app.primary_monitor().ok().flatten());
if let Some(monitor) = monitor {
let screen_pos = monitor.position();
let screen_size = monitor.size();
let scale_factor = monitor.scale_factor();
// 物理像素转换为逻辑像素
let screen_width = screen_size.width as f64 / scale_factor;
let screen_height = screen_size.height as f64 / scale_factor;
let screen_x = screen_pos.x as f64 / scale_factor;
let screen_y = screen_pos.y as f64 / scale_factor;
// 在该显示器上水平居中(使用逻辑坐标)
let x = screen_x + (screen_width - WINDOW_WIDTH) / 2.0;
// 距离该显示器底部 BOTTOM_MARGIN(使用逻辑坐标)
let y = screen_y + screen_height - WINDOW_HEIGHT - BOTTOM_MARGIN;
debug!(
"显示器: scale_factor={}, 物理位置: ({}, {}), 物理尺寸: {}x{}",
scale_factor, screen_pos.x, screen_pos.y, screen_size.width, screen_size.height
);
debug!(
"逻辑坐标: 屏幕({}, {}), 尺寸: {}x{}, 窗口位置: ({}, {})",
screen_x, screen_y, screen_width, screen_height, x, y
);
return (x, y);
}
// 默认位置(如果无法获取屏幕尺寸)
debug!("无法获取显示器信息,使用默认位置");
(400.0, 600.0)
}
/// 打开悬浮对话窗口
///
/// 如果窗口已在 tauri.conf.json 中预定义,则显示并导航到新 URL
/// 否则动态创建一个全屏透明的悬浮窗口
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `image_path`: 截图文件路径
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn open_floating_window(app: &AppHandle, image_path: &Path) -> Result<(), WindowError> {
info!("打开悬浮对话窗口");
// 构建窗口 URL,包含图片路径参数
let image_path_str = image_path.to_str().unwrap_or("");
let encoded_path = urlencoding::encode(image_path_str);
let url = format!("/smart-input?image={encoded_path}");
debug!("悬浮窗口 URL: {}", url);
// 检查窗口是否已存在(可能是预定义的或之前创建的)
if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) {
info!("悬浮窗口已存在,导航到新 URL 并显示");
// 计算窗口位置(返回逻辑坐标)
let (x, y) = calculate_window_position(app);
// 设置窗口位置(使用逻辑坐标)
use tauri::LogicalPosition;
let _ = window.set_position(LogicalPosition::new(x, y));
// macOS: 设置窗口和 webview 背景透明
#[cfg(target_os = "macos")]
{
use objc::{msg_send, sel, sel_impl};
if let Ok(ns_win) = window.ns_window() {
#[allow(deprecated, unexpected_cfgs)]
unsafe {
let ns_window = ns_win as id;
// 设置窗口背景透明
let clear_color = NSColor::clearColor(nil);
ns_window.setBackgroundColor_(clear_color);
let _: () = msg_send![ns_window, setOpaque: false];
// 禁用窗口阴影
let _: () = msg_send![ns_window, setHasShadow: false];
}
}
}
// 使用 JavaScript 导航到新的 URL(更新图片路径)
let js = format!("window.location.href = '{url}';");
window
.eval(&js)
.map_err(|e| WindowError::OperationFailed(format!("导航失败: {e}")))?;
// 显示窗口
window
.show()
.map_err(|e| WindowError::OperationFailed(format!("显示窗口失败: {e}")))?;
// 聚焦窗口
window
.set_focus()
.map_err(|e| WindowError::OperationFailed(format!("聚焦窗口失败: {e}")))?;
return Ok(());
}
// 窗口不存在,动态创建
info!("动态创建悬浮窗口");
// 计算窗口位置
let (x, y) = calculate_window_position(app);
// 创建悬浮窗口(启用透明)
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
let window = WebviewWindowBuilder::new(app, FLOATING_WINDOW_LABEL, WebviewUrl::App(url.into()))
.inner_size(WINDOW_WIDTH, WINDOW_HEIGHT)
.position(x, y)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(true)
.focused(true)
.transparent(true)
.build()
.map_err(|e| WindowError::CreateFailed(format!("{e}")))?;
// macOS: 设置窗口和 webview 背景透明
#[cfg(target_os = "macos")]
{
use objc::{msg_send, sel, sel_impl};
if let Ok(ns_win) = window.ns_window() {
#[allow(deprecated, unexpected_cfgs)]
unsafe {
let ns_window = ns_win as id;
// 设置窗口背景透明
let clear_color = NSColor::clearColor(nil);
ns_window.setBackgroundColor_(clear_color);
let _: () = msg_send![ns_window, setOpaque: false];
// 禁用窗口阴影
let _: () = msg_send![ns_window, setHasShadow: false];
}
}
}
info!("悬浮窗口创建成功: {}", FLOATING_WINDOW_LABEL);
Ok(())
}
/// 关闭悬浮对话窗口
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn close_floating_window(app: &AppHandle) -> Result<(), WindowError> {
info!("关闭悬浮对话窗口");
if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) {
window
.close()
.map_err(|e| WindowError::OperationFailed(format!("关闭窗口失败: {e}")))?;
info!("悬浮窗口已关闭");
} else {
debug!("悬浮窗口不存在,无需关闭");
}
Ok(())
}
/// 检查悬浮窗口是否打开
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 如果窗口存在且可见返回 true,否则返回 false
pub fn is_floating_window_open(app: &AppHandle) -> bool {
app.get_webview_window(FLOATING_WINDOW_LABEL)
.map(|w| w.is_visible().unwrap_or(false))
.unwrap_or(false)
}
/// 聚焦悬浮窗口
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn focus_floating_window(app: &AppHandle) -> Result<(), WindowError> {
if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) {
window
.set_focus()
.map_err(|e| WindowError::OperationFailed(format!("聚焦窗口失败: {e}")))?;
Ok(())
} else {
Err(WindowError::NotFound(FLOATING_WINDOW_LABEL.to_string()))
}
}
/// 打开带预填文本的悬浮输入框
///
/// 用于语音识别完成后,将识别结果填入输入框
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `text`: 预填文本
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn open_floating_window_with_text(app: &AppHandle, text: &str) -> Result<(), WindowError> {
info!("打开带预填文本的悬浮输入框");
// 构建窗口 URL,包含文本参数
let encoded_text = urlencoding::encode(text);
let url = format!("/smart-input?text={encoded_text}");
open_floating_window_with_url(app, &url)
}
/// 打开语音模式的悬浮输入框
///
/// 自动开始录音,录音完成后填入文本
///
/// # 参数
/// - `app`: Tauri 应用句柄
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn open_floating_window_voice_mode(app: &AppHandle) -> Result<(), WindowError> {
info!("打开语音模式的悬浮输入框");
let url = "/smart-input?voice=true";
open_floating_window_with_url(app, url)
}
/// 内部函数:打开带指定 URL 的悬浮窗口
fn open_floating_window_with_url(app: &AppHandle, url: &str) -> Result<(), WindowError> {
debug!("悬浮窗口 URL: {}", url);
// 检查是否是语音模式
let is_voice_mode = url.contains("voice=true");
// 检查窗口是否已存在
if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) {
info!("悬浮窗口已存在,导航到新 URL 并显示");
// 计算窗口位置
let (x, y) = calculate_window_position(app);
// 设置窗口位置
use tauri::LogicalPosition;
let _ = window.set_position(LogicalPosition::new(x, y));
// macOS: 设置窗口背景透明
#[cfg(target_os = "macos")]
{
use objc::{msg_send, sel, sel_impl};
if let Ok(ns_win) = window.ns_window() {
#[allow(deprecated, unexpected_cfgs)]
unsafe {
let ns_window = ns_win as id;
let clear_color = NSColor::clearColor(nil);
ns_window.setBackgroundColor_(clear_color);
let _: () = msg_send![ns_window, setOpaque: false];
let _: () = msg_send![ns_window, setHasShadow: false];
}
}
}
// 导航到新 URL(强制刷新)
let js = format!("window.location.replace('{url}');");
window
.eval(&js)
.map_err(|e| WindowError::OperationFailed(format!("导航失败: {e}")))?;
window
.show()
.map_err(|e| WindowError::OperationFailed(format!("显示窗口失败: {e}")))?;
window
.set_focus()
.map_err(|e| WindowError::OperationFailed(format!("聚焦窗口失败: {e}")))?;
// 如果是语音模式,额外发送事件确保前端收到
if is_voice_mode {
use tauri::Emitter;
// 延迟发送事件,等待页面加载
let app_clone = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = app_clone.emit("voice-start-recording", ());
info!("[语音输入] 已发送开始录音事件");
});
}
return Ok(());
}
// 窗口不存在,动态创建
info!("动态创建悬浮窗口");
let (x, y) = calculate_window_position(app);
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
let window = WebviewWindowBuilder::new(app, FLOATING_WINDOW_LABEL, WebviewUrl::App(url.into()))
.inner_size(WINDOW_WIDTH, WINDOW_HEIGHT)
.position(x, y)
.decorations(false)
.always_on_top(true)
.skip_taskbar(true)
.visible(true)
.focused(true)
.transparent(true)
.build()
.map_err(|e| WindowError::CreateFailed(format!("{e}")))?;
// macOS: 设置窗口背景透明
#[cfg(target_os = "macos")]
{
use objc::{msg_send, sel, sel_impl};
if let Ok(ns_win) = window.ns_window() {
#[allow(deprecated, unexpected_cfgs)]
unsafe {
let ns_window = ns_win as id;
let clear_color = NSColor::clearColor(nil);
ns_window.setBackgroundColor_(clear_color);
let _: () = msg_send![ns_window, setOpaque: false];
let _: () = msg_send![ns_window, setHasShadow: false];
}
}
}
info!("悬浮窗口创建成功: {}", FLOATING_WINDOW_LABEL);
if is_voice_mode {
use tauri::Emitter;
let app_clone = app.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = app_clone.emit("voice-start-recording", ());
info!("[语音输入] 已广播开始录音事件");
});
}
Ok(())
}
/// 打开语音模式的悬浮输入框(别名,供语音模块调用)
pub fn open_floating_window_with_voice(app: &AppHandle) -> Result<(), WindowError> {
open_floating_window_voice_mode(app)
}
/// 发送语音停止录音事件到截图输入框
pub fn send_voice_stop_event(app: &AppHandle) -> Result<(), WindowError> {
use tauri::Emitter;
app.emit("voice-stop-recording", ())
.map_err(|e| WindowError::OperationFailed(format!("发送停止录音事件失败: {e}")))?;
info!("[语音输入] 已广播停止录音事件");
Ok(())
}
/// 打开翻译模式的悬浮输入框
///
/// 自动开始录音,录音完成后使用指定的翻译指令处理
///
/// # 参数
/// - `app`: Tauri 应用句柄
/// - `instruction_id`: 翻译指令 ID
///
/// # 返回
/// 成功返回 Ok(()), 失败返回错误
pub fn open_floating_window_with_translate(
app: &AppHandle,
instruction_id: &str,
) -> Result<(), WindowError> {
info!("打开翻译模式的悬浮输入框,指令: {}", instruction_id);
let encoded_instruction = urlencoding::encode(instruction_id);
let url = format!("/smart-input?voice=true&translate=true&instruction={encoded_instruction}");
open_floating_window_with_url(app, &url)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_window_label() {
assert_eq!(FLOATING_WINDOW_LABEL, "smart-input");
}
}
-4
View File
@@ -42,10 +42,6 @@
- `voice_command_service.rs` - 转写/润色/输出业务流程
- `voice_recording_service.rs` - 录音状态封装与设备查询
以下截图相关纯逻辑已迁移到 `crates/services/src/`:
- `screenshot_capture_service.rs` - 跨平台截图与临时文件清理
- `screenshot_image_service.rs` - 图片读取与 Base64 编码
## Aster Agent 集成
Aster Agent 集成位于 `src-tauri/src/agent/` 目录:
+2 -4
View File
@@ -677,10 +677,8 @@ impl CompanionServiceState {
let _ = self.send_pet_bubble("你说吧,我在认真听", 1600).await;
if let Err(error) =
crate::voice::window::open_voice_window(app_handle, Some("companion-pet"))
{
tracing::warn!("[Companion] 打开桌宠语音窗口失败: {}", error);
if let Err(error) = crate::voice::events::request_start_recording(app_handle) {
tracing::warn!("[Companion] 启动主输入框语音录音失败: {}", error);
let _ = self
.send_pet_bubble("语音入口暂时没打开,你先打字和我聊吧", 2200)
.await;
+10 -1
View File
@@ -341,6 +341,7 @@ pub async fn execute_named_skill(
let execution_id_for_run = execution_id.clone();
let session_id_for_run = session_id.clone();
let user_input_for_run = effective_user_input.clone();
let user_visible_input_for_run = user_input.clone();
let skill_images_for_run = skill_images.clone();
let provider_override_for_run = provider_override.clone();
let model_override_for_run = model_override.clone();
@@ -398,6 +399,7 @@ pub async fn execute_named_skill(
&aster_state,
&skill,
&user_input_for_run,
Some(user_visible_input_for_run.as_str()),
&skill_images_for_run,
&execution_id_for_run,
&session_id_for_run,
@@ -429,6 +431,7 @@ pub async fn execute_skill_prompt(
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
user_visible_input: Option<&str>,
images: &[SkillInputImage],
execution_id: &str,
session_id: &str,
@@ -443,6 +446,7 @@ pub async fn execute_skill_prompt(
aster_state,
skill,
user_input,
user_visible_input,
images,
execution_id,
session_id,
@@ -467,7 +471,7 @@ pub async fn execute_skill_prompt(
let finalized = finalize_skill_output(
app_handle,
&skill.skill_name,
user_input,
user_visible_input.unwrap_or(user_input),
execution_id,
result.output.as_deref().unwrap_or(""),
);
@@ -488,6 +492,7 @@ pub async fn execute_skill_workflow(
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
user_visible_input: Option<&str>,
images: &[SkillInputImage],
execution_id: &str,
session_id: &str,
@@ -499,6 +504,7 @@ pub async fn execute_skill_workflow(
aster_state,
skill,
user_input,
user_visible_input,
images,
execution_id,
session_id,
@@ -516,6 +522,7 @@ pub async fn execute_skill_definition(
aster_state: &AsterAgentState,
skill: &LoadedSkillDefinition,
user_input: &str,
user_visible_input: Option<&str>,
images: &[SkillInputImage],
execution_id: &str,
session_id: &str,
@@ -528,6 +535,7 @@ pub async fn execute_skill_definition(
aster_state,
skill,
user_input,
user_visible_input,
images,
execution_id,
session_id,
@@ -541,6 +549,7 @@ pub async fn execute_skill_definition(
aster_state,
skill,
user_input,
user_visible_input,
images,
execution_id,
session_id,
-69
View File
@@ -10,23 +10,10 @@ use super::config;
use super::recording_service::{AudioDeviceInfo, RecordingServiceState};
use tauri::State;
fn normalize_shortcut(value: Option<String>) -> Option<String> {
value.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct VoiceShortcutRuntimeStatus {
pub shortcut_registered: bool,
pub registered_shortcut: Option<String>,
pub translate_shortcut_registered: bool,
pub registered_translate_shortcut: Option<String>,
pub fn_supported: bool,
pub fn_registered: bool,
pub fn_fallback_shortcut: Option<String>,
@@ -52,8 +39,6 @@ pub async fn get_voice_shortcut_runtime_status() -> Result<VoiceShortcutRuntimeS
Ok(VoiceShortcutRuntimeStatus {
shortcut_registered: super::shortcut::is_registered(),
registered_shortcut: super::shortcut::get_current(),
translate_shortcut_registered: super::shortcut::is_translate_registered(),
registered_translate_shortcut: super::shortcut::get_current_translate(),
fn_supported: fn_status.supported,
fn_registered: fn_status.registered,
fn_fallback_shortcut: super::shortcut::get_current()
@@ -78,54 +63,12 @@ pub async fn save_voice_input_config(
if old_config.shortcut != voice_config.shortcut {
super::shortcut::update(&app, &voice_config.shortcut)?;
}
let old_translate_shortcut = normalize_shortcut(old_config.translate_shortcut.clone());
let new_translate_shortcut = normalize_shortcut(voice_config.translate_shortcut.clone());
let instruction_changed =
old_config.translate_instruction_id != voice_config.translate_instruction_id;
if old_translate_shortcut != new_translate_shortcut || instruction_changed {
match (
old_translate_shortcut.as_deref(),
new_translate_shortcut.as_deref(),
) {
(Some(_), Some(new_shortcut)) => {
super::shortcut::update_translate(
&app,
new_shortcut,
&voice_config.translate_instruction_id,
)?;
}
(None, Some(new_shortcut)) => {
super::shortcut::register_translate(
&app,
new_shortcut,
&voice_config.translate_instruction_id,
)?;
}
(Some(_), None) => {
super::shortcut::unregister_translate(&app)?;
}
(None, None) => {}
}
}
} else if old_enabled && !new_enabled {
super::shortcut::unregister(&app)?;
let _ = super::shortcut::unregister_translate(&app);
let _ = super::fn_shortcut::unregister();
} else if !old_enabled && new_enabled {
super::shortcut::register(&app, &voice_config.shortcut)?;
super::register_fn_shortcut_if_supported(&app);
if let Some(translate_shortcut) =
normalize_shortcut(voice_config.translate_shortcut.clone())
{
super::shortcut::register_translate(
&app,
&translate_shortcut,
&voice_config.translate_instruction_id,
)?;
}
}
config::save_voice_config(voice_config)
@@ -149,18 +92,6 @@ pub async fn delete_voice_instruction(id: String) -> Result<(), String> {
config::delete_voice_instruction(&id)
}
/// 打开语音输入窗口
#[command]
pub async fn open_voice_window(app: AppHandle, target: Option<String>) -> Result<(), String> {
super::window::open_voice_window(&app, target.as_deref())
}
/// 关闭语音输入窗口
#[command]
pub async fn close_voice_window(app: AppHandle) -> Result<(), String> {
super::window::close_voice_window(&app)
}
pub use lime_services::voice_command_service::{PolishResult, TranscribeResult};
/// 执行语音识别
+47
View File
@@ -0,0 +1,47 @@
use tauri::{AppHandle, Emitter, Manager};
pub const VOICE_START_RECORDING_EVENT: &str = "voice-start-recording";
pub const VOICE_STOP_RECORDING_EVENT: &str = "voice-stop-recording";
fn focus_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if let Err(error) = window.unminimize() {
tracing::debug!("[语音输入] 恢复主窗口失败: {}", error);
}
if let Err(error) = window.show() {
tracing::debug!("[语音输入] 显示主窗口失败: {}", error);
}
if let Err(error) = window.set_focus() {
tracing::debug!("[语音输入] 聚焦主窗口失败: {}", error);
}
} else {
tracing::warn!("[语音输入] 未找到主窗口,仍广播录音快捷键事件");
}
}
fn emit_recording_event(app: &AppHandle, event_name: &str) -> Result<(), String> {
focus_main_window(app);
app.emit(event_name, ())
.map_err(|error| format!("广播语音快捷键事件失败: {error}"))?;
tracing::info!("[语音输入] 已广播快捷键事件: {}", event_name);
Ok(())
}
pub fn request_start_recording(app: &AppHandle) -> Result<(), String> {
emit_recording_event(app, VOICE_START_RECORDING_EVENT)
}
pub fn request_stop_recording(app: &AppHandle) -> Result<(), String> {
emit_recording_event(app, VOICE_STOP_RECORDING_EVENT)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn voice_shortcut_event_names_match_frontend_contract() {
assert_eq!(VOICE_START_RECORDING_EVENT, "voice-start-recording");
assert_eq!(VOICE_STOP_RECORDING_EVENT, "voice-stop-recording");
}
}
+3 -4
View File
@@ -95,14 +95,13 @@ mod platform {
match (was_down, fn_down) {
(false, true) => {
info!("[语音输入] Fn 按下");
if let Err(error) = crate::screenshot::window::open_floating_window_with_voice(app)
{
error!("[语音输入] Fn 打开语音窗口失败: {}", error);
if let Err(error) = crate::voice::events::request_start_recording(app) {
error!("[语音输入] Fn 发送开始录音事件失败: {}", error);
}
}
(true, false) => {
info!("[语音输入] Fn 释放,发送停止录音事件");
if let Err(error) = crate::screenshot::window::send_voice_stop_event(app) {
if let Err(error) = crate::voice::events::request_stop_recording(app) {
error!("[语音输入] Fn 发送停止录音事件失败: {}", error);
}
}
+1 -26
View File
@@ -9,12 +9,12 @@
pub mod asr_service;
pub mod commands;
pub mod config;
pub mod events;
pub mod fn_shortcut;
pub mod output_service;
pub mod processor;
pub mod recording_service;
pub mod shortcut;
pub mod window;
use tauri::AppHandle;
@@ -44,25 +44,6 @@ pub fn init(app: &AppHandle) -> Result<(), String> {
shortcut::register(app, &config.shortcut)?;
register_fn_shortcut_if_supported(app);
// 注册翻译快捷键(如果配置了)
if let Some(translate_shortcut) = &config.translate_shortcut {
if !translate_shortcut.is_empty() {
match shortcut::register_translate(
app,
translate_shortcut,
&config.translate_instruction_id,
) {
Ok(()) => {
tracing::info!("[语音输入] 翻译快捷键已注册: {}", translate_shortcut);
}
Err(e) => {
tracing::warn!("[语音输入] 翻译快捷键注册失败: {}", e);
// 翻译快捷键注册失败不影响主功能
}
}
}
}
tracing::info!("[语音输入] 模块初始化完成");
Ok(())
}
@@ -72,15 +53,9 @@ pub fn cleanup(app: &AppHandle) -> Result<(), String> {
// 注销快捷键
shortcut::unregister(app)?;
// 注销翻译快捷键
let _ = shortcut::unregister_translate(app);
// 注销 Fn 监听
let _ = fn_shortcut::unregister();
// 关闭悬浮窗口
window::close_voice_window(app)?;
tracing::info!("[语音输入] 模块已清理");
Ok(())
}
+3 -195
View File
@@ -11,23 +11,13 @@ use tracing::{debug, error, info, warn};
/// 当前注册的快捷键
static CURRENT_SHORTCUT: OnceLock<parking_lot::RwLock<Option<String>>> = OnceLock::new();
/// 当前注册的翻译快捷键
static TRANSLATE_SHORTCUT: OnceLock<parking_lot::RwLock<Option<String>>> = OnceLock::new();
/// 快捷键是否已注册
static IS_REGISTERED: AtomicBool = AtomicBool::new(false);
/// 翻译快捷键是否已注册
static IS_TRANSLATE_REGISTERED: AtomicBool = AtomicBool::new(false);
fn get_current_shortcut() -> &'static parking_lot::RwLock<Option<String>> {
CURRENT_SHORTCUT.get_or_init(|| parking_lot::RwLock::new(None))
}
fn get_translate_shortcut() -> &'static parking_lot::RwLock<Option<String>> {
TRANSLATE_SHORTCUT.get_or_init(|| parking_lot::RwLock::new(None))
}
fn parse_user_shortcut(shortcut_str: &str) -> Result<Shortcut, String> {
if let Some(reason) =
crate::global_shortcut_guard::reserved_system_shortcut_reason(shortcut_str)
@@ -79,16 +69,12 @@ pub fn register(app: &AppHandle, shortcut_str: &str) -> Result<(), String> {
.on_shortcut(shortcut, move |_app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
info!("[语音输入] 快捷键按下");
// 打开截图输入框(语音模式)
if let Err(e) =
crate::screenshot::window::open_floating_window_with_voice(&app_clone)
{
error!("[语音输入] 打开窗口失败: {}", e);
if let Err(e) = super::events::request_start_recording(&app_clone) {
error!("[语音输入] 发送开始录音事件失败: {}", e);
}
} else {
info!("[语音输入] 快捷键释放,发送停止录音事件");
// 发送停止录音事件到前端
if let Err(e) = crate::screenshot::window::send_voice_stop_event(&app_clone) {
if let Err(e) = super::events::request_stop_recording(&app_clone) {
error!("[语音输入] 发送停止录音事件失败: {}", e);
}
}
@@ -181,160 +167,6 @@ pub fn get_current() -> Option<String> {
get_current_shortcut().read().clone()
}
/// 注册翻译模式快捷键
pub fn register_translate(
app: &AppHandle,
shortcut_str: &str,
instruction_id: &str,
) -> Result<(), String> {
info!(
"[语音输入] 注册翻译快捷键: {}, 指令: {}",
shortcut_str, instruction_id
);
// 解析快捷键
let shortcut = parse_user_shortcut(shortcut_str)?;
// 获取全局快捷键管理器
let global_shortcut = app.global_shortcut();
// 检查快捷键是否已被注册
let is_already_registered = global_shortcut.is_registered(shortcut);
debug!(
"[语音输入] 翻译快捷键 {} 是否已注册: {}",
shortcut_str, is_already_registered
);
if is_already_registered {
warn!("[语音输入] 翻译快捷键已被注册: {}", shortcut_str);
// 如果是我们自己注册的,先注销
if IS_TRANSLATE_REGISTERED.load(Ordering::SeqCst) {
info!("[语音输入] 尝试注销已有的翻译快捷键");
if let Err(e) = global_shortcut.unregister(shortcut) {
error!("[语音输入] 注销已有翻译快捷键失败: {}", e);
}
} else {
return Err(format!("快捷键已被占用: {shortcut_str}"));
}
}
// 克隆 app handle 和 instruction_id 用于回调
let app_clone = app.clone();
let instruction_id_owned = instruction_id.to_string();
// 注册快捷键
info!("[语音输入] 开始注册翻译快捷键回调...");
global_shortcut
.on_shortcut(shortcut, move |_app, _shortcut, event| {
if event.state == ShortcutState::Pressed {
info!("[语音输入] 翻译快捷键按下");
// 打开截图输入框(翻译模式)
if let Err(e) = crate::screenshot::window::open_floating_window_with_translate(
&app_clone,
&instruction_id_owned,
) {
error!("[语音输入] 打开翻译窗口失败: {}", e);
}
} else {
info!("[语音输入] 翻译快捷键释放,发送停止录音事件");
// 发送停止录音事件到前端
if let Err(e) = crate::screenshot::window::send_voice_stop_event(&app_clone) {
error!("[语音输入] 发送停止录音事件失败: {}", e);
}
}
})
.map_err(|e| {
error!("[语音输入] 注册翻译快捷键失败: {}", e);
format!("注册翻译快捷键失败: {e}")
})?;
// 更新状态
IS_TRANSLATE_REGISTERED.store(true, Ordering::SeqCst);
*get_translate_shortcut().write() = Some(shortcut_str.to_string());
info!("[语音输入] 翻译快捷键已注册: {}", shortcut_str);
Ok(())
}
/// 注销翻译快捷键
pub fn unregister_translate(app: &AppHandle) -> Result<(), String> {
let current = get_translate_shortcut().read().clone();
if let Some(shortcut_str) = current {
info!("[语音输入] 注销翻译快捷键: {}", shortcut_str);
let shortcut: Shortcut = shortcut_str
.parse()
.map_err(|e| format!("解析快捷键失败: {e}"))?;
let global_shortcut = app.global_shortcut();
if global_shortcut.is_registered(shortcut) {
global_shortcut
.unregister(shortcut)
.map_err(|e| format!("注销翻译快捷键失败: {e}"))?;
}
// 更新状态
IS_TRANSLATE_REGISTERED.store(false, Ordering::SeqCst);
*get_translate_shortcut().write() = None;
info!("[语音输入] 翻译快捷键已注销");
} else {
debug!("[语音输入] 没有已注册的翻译快捷键需要注销");
}
Ok(())
}
/// 检查翻译快捷键是否已注册
pub fn is_translate_registered() -> bool {
IS_TRANSLATE_REGISTERED.load(Ordering::SeqCst)
}
/// 获取当前已注册的翻译快捷键
pub fn get_current_translate() -> Option<String> {
get_translate_shortcut().read().clone()
}
/// 更新翻译快捷键
pub fn update_translate(
app: &AppHandle,
new_shortcut: &str,
instruction_id: &str,
) -> Result<(), String> {
info!("[语音输入] 更新翻译快捷键: {}", new_shortcut);
// 保存旧快捷键以便恢复
let old_shortcut = get_translate_shortcut().read().clone();
// 注销旧快捷键
if let Err(e) = unregister_translate(app) {
warn!("[语音输入] 注销旧翻译快捷键失败: {}", e);
}
// 注册新快捷键
match register_translate(app, new_shortcut, instruction_id) {
Ok(()) => {
info!("[语音输入] 翻译快捷键更新成功: {}", new_shortcut);
Ok(())
}
Err(e) => {
error!("[语音输入] 注册新翻译快捷键失败: {}", e);
// 尝试恢复旧快捷键
if let Some(old) = old_shortcut {
warn!("[语音输入] 尝试恢复旧翻译快捷键: {}", old);
if let Err(restore_err) = register_translate(app, &old, instruction_id) {
error!("[语音输入] 恢复旧翻译快捷键失败: {}", restore_err);
}
}
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -344,11 +176,6 @@ mod tests {
*get_current_shortcut().write() = None;
}
fn reset_translate_shortcut_state() {
IS_TRANSLATE_REGISTERED.store(false, Ordering::SeqCst);
*get_translate_shortcut().write() = None;
}
#[test]
fn test_main_shortcut_runtime_accessors_reflect_state() {
reset_main_shortcut_state();
@@ -365,25 +192,6 @@ mod tests {
reset_main_shortcut_state();
}
#[test]
fn test_translate_shortcut_runtime_accessors_reflect_state() {
reset_translate_shortcut_state();
assert!(!is_translate_registered());
assert_eq!(get_current_translate(), None);
IS_TRANSLATE_REGISTERED.store(true, Ordering::SeqCst);
*get_translate_shortcut().write() = Some("CommandOrControl+Shift+T".to_string());
assert!(is_translate_registered());
assert_eq!(
get_current_translate().as_deref(),
Some("CommandOrControl+Shift+T")
);
reset_translate_shortcut_state();
}
#[test]
fn test_parse_user_shortcut_rejects_input_method_reserved_shortcuts() {
for shortcut in ["CommandOrControl+Space", "Ctrl+Space", "Super+Space"] {
-170
View File
@@ -1,170 +0,0 @@
//! 语音输入悬浮窗口管理
//!
//! 创建和管理语音输入的悬浮窗口
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
const VOICE_WINDOW_LABEL: &str = "voice-input";
const VOICE_WINDOW_WIDTH: f64 = 500.0;
const VOICE_WINDOW_HEIGHT: f64 = 80.0;
#[derive(Clone, serde::Serialize)]
struct VoiceWindowResetPayload {
target: Option<String>,
}
/// 打开语音输入窗口
pub fn open_voice_window(app: &AppHandle, target: Option<&str>) -> Result<(), String> {
let target = normalize_voice_window_target(target);
// 检查窗口是否已存在
if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) {
// 发送重置事件,让前端重新开始录音
window
.emit(
"voice-reset",
VoiceWindowResetPayload {
target: target.clone(),
},
)
.map_err(|e| format!("发送重置事件失败: {e}"))?;
// 移动到鼠标所在屏幕
position_window_on_cursor_screen(&window)?;
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
tracing::info!("[语音输入] 窗口已存在,发送重置事件");
return Ok(());
}
// 创建新窗口 - 使用 /smart-input 路由并带上 voice=true 参数
let window = WebviewWindowBuilder::new(
app,
VOICE_WINDOW_LABEL,
WebviewUrl::App(build_voice_window_route(target.as_deref()).into()),
)
.title("语音输入")
.inner_size(VOICE_WINDOW_WIDTH, VOICE_WINDOW_HEIGHT)
.resizable(false)
.decorations(false)
.always_on_top(true)
.transparent(false) // 关闭透明,避免 macOS 上的渲染问题
.skip_taskbar(true)
.build()
.map_err(|e| format!("创建窗口失败: {e}"))?;
// 移动到鼠标所在屏幕
position_window_on_cursor_screen(&window)?;
window.show().map_err(|e| e.to_string())?;
window.set_focus().map_err(|e| e.to_string())?;
tracing::info!("[语音输入] 窗口已打开");
Ok(())
}
fn normalize_voice_window_target(target: Option<&str>) -> Option<String> {
target
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn build_voice_window_route(target: Option<&str>) -> String {
match normalize_voice_window_target(target).as_deref() {
Some(target) => format!("/smart-input?voice=true&target={target}"),
None => "/smart-input?voice=true".to_string(),
}
}
/// 将窗口定位到鼠标所在屏幕的中央
fn position_window_on_cursor_screen(window: &tauri::WebviewWindow) -> Result<(), String> {
use tauri::PhysicalPosition;
// 获取鼠标位置
let cursor_pos = match window.cursor_position() {
Ok(pos) => pos,
Err(e) => {
tracing::warn!("[语音输入] 获取鼠标位置失败: {},使用默认居中", e);
window.center().map_err(|e| e.to_string())?;
return Ok(());
}
};
// 获取所有显示器
let monitors = window.available_monitors().map_err(|e| e.to_string())?;
// 找到鼠标所在的显示器
let target_monitor = monitors.iter().find(|m| {
let pos = m.position();
let size = m.size();
cursor_pos.x >= pos.x as f64
&& cursor_pos.x < (pos.x + size.width as i32) as f64
&& cursor_pos.y >= pos.y as f64
&& cursor_pos.y < (pos.y + size.height as i32) as f64
});
if let Some(monitor) = target_monitor {
let monitor_pos = monitor.position();
let monitor_size = monitor.size();
let scale_factor = monitor.scale_factor();
// 计算窗口在该显示器上的居中位置
let window_width = (VOICE_WINDOW_WIDTH * scale_factor) as i32;
let window_height = (VOICE_WINDOW_HEIGHT * scale_factor) as i32;
let x = monitor_pos.x + (monitor_size.width as i32 - window_width) / 2;
let y = monitor_pos.y + (monitor_size.height as i32 - window_height) / 2;
window
.set_position(PhysicalPosition::new(x, y))
.map_err(|e| e.to_string())?;
tracing::info!(
"[语音输入] 窗口定位到显示器 ({}, {}) 尺寸 {}x{}, 窗口位置 ({}, {})",
monitor_pos.x,
monitor_pos.y,
monitor_size.width,
monitor_size.height,
x,
y
);
} else {
// 没找到对应显示器,使用默认居中
tracing::warn!("[语音输入] 未找到鼠标所在显示器,使用默认居中");
window.center().map_err(|e| e.to_string())?;
}
Ok(())
}
/// 关闭语音输入窗口
pub fn close_voice_window(app: &AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) {
window.close().map_err(|e| e.to_string())?;
tracing::info!("[语音输入] 窗口已关闭");
}
Ok(())
}
/// 更新窗口状态(发送事件到前端)
pub fn update_window_state(app: &AppHandle, state: &str) -> Result<(), String> {
if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) {
window
.emit("voice-state-change", state)
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// 发送停止录音事件到前端
pub fn send_stop_recording_event(app: &AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window(VOICE_WINDOW_LABEL) {
window
.emit("voice-stop-recording", ())
.map_err(|e| format!("发送停止录音事件失败: {e}"))?;
tracing::info!("[语音输入] 已发送停止录音事件");
}
Ok(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.37.0",
"version": "1.38.0",
"identifier": "com.limecloud.lime.headless",
"build": {
"beforeDevCommand": "npm run dev:web-bridge",
+1 -17
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Lime",
"version": "1.37.0",
"version": "1.38.0",
"identifier": "com.limecloud.lime",
"build": {
"beforeDevCommand": "node scripts/start-tauri-dev-server.mjs",
@@ -26,22 +26,6 @@
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"dragDropEnabled": false
},
{
"label": "smart-input",
"title": "",
"url": "/smart-input",
"width": 500,
"height": 70,
"resizable": false,
"decorations": false,
"alwaysOnTop": true,
"center": true,
"visible": false,
"focus": true,
"skipTaskbar": true,
"transparent": true,
"shadow": false
}
],
"security": {
-25
View File
@@ -23,7 +23,6 @@ import {
isUserProjectType,
resolveProjectRootPath,
} from "./lib/api/project";
import { useOnboardingState } from "./components/onboarding";
import { useDeepLink } from "./hooks/useDeepLink";
import { useRelayRegistry } from "./hooks/useRelayRegistry";
import { useSkillCatalogBootstrap } from "./hooks/useSkillCatalogBootstrap";
@@ -128,11 +127,6 @@ const CreateProjectDialog = lazy(() =>
default: module.CreateProjectDialog,
})),
);
const OnboardingWizard = lazy(() =>
import("./components/onboarding").then((module) => ({
default: module.OnboardingWizard,
})),
);
const ConnectConfirmDialog = lazy(() =>
import("./components/connect").then((module) => ({
default: module.ConnectConfirmDialog,
@@ -154,7 +148,6 @@ function AppContent() {
handleNavigate,
} = useAppNavigation();
const [agentHasMessages, setAgentHasMessages] = useState(false);
const { needsOnboarding, completeOnboarding } = useOnboardingState();
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [pendingRecommendation, setPendingRecommendation] = useState<{
@@ -401,10 +394,6 @@ function AppContent() {
preloadDefaultProject();
}, []);
const handleOnboardingComplete = useCallback(() => {
completeOnboarding();
}, [completeOnboarding]);
const handleWindowDragStart = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
void startWindowDragFromMouseEvent(event, { source: "app_shell" });
@@ -417,20 +406,6 @@ function AppContent() {
return <SplashScreen onComplete={handleSplashComplete} />;
}
if (needsOnboarding === null) {
startupTracker.mark("AppContent: checking onboarding");
return null;
}
if (needsOnboarding) {
startupTracker.mark("AppContent: showing onboarding");
return (
<Suspense fallback={null}>
<OnboardingWizard onComplete={handleOnboardingComplete} />
</Suspense>
);
}
startupTracker.mark("AppContent: rendering main app");
return (
-12
View File
@@ -18,10 +18,6 @@ vi.mock("./App", () => ({
default: () => <div data-testid="main-app">主应用</div>,
}));
vi.mock("./pages/smart-input", () => ({
SmartInputPage: () => <div data-testid="smart-input-page" />,
}));
vi.mock("./pages/update-notification", () => ({
UpdateNotificationPage: () => <div data-testid="update-notification-page" />,
}));
@@ -102,14 +98,6 @@ describe("RootRouter", () => {
expect(container.textContent).toContain("主应用");
});
it("独立工具窗口不应触发主应用登录流程", async () => {
const { container } = await renderRootRouter("/smart-input");
expect(
container.querySelector('[data-testid="smart-input-page"]'),
).not.toBeNull();
});
it("Windows 独立窗口从 index.html 入口启动时应映射到连接器引导页", async () => {
const { container } = await renderRootRouter(
"/index.html?lime_window=browser-connector-guide&mode=cdp",
-11
View File
@@ -5,7 +5,6 @@
import { lazy, Suspense, useEffect } from "react";
import App from "./App";
import { SmartInputPage } from "./pages/smart-input";
import { UpdateNotificationPage } from "./pages/update-notification";
import { BrowserRuntimeDebuggerPage } from "./pages";
import { ResourceManagerPage } from "./features/resource-manager";
@@ -43,7 +42,6 @@ function getEffectivePathname(location: Location): string {
/**
* 根据 URL 路径渲染对应的组件
*
* - /smart-input: 截图对话悬浮窗口(独立 Tauri 窗口,支持语音模式)
* - /update-notification: 更新提醒悬浮窗口(独立 Tauri 窗口)
* - /browser-runtime-debugger: 浏览器运行时独立调试窗口
* - /resource-manager: 独立资源管理器窗口
@@ -66,15 +64,6 @@ export function RootRouter() {
);
}, [pathname]);
// 截图对话悬浮窗口路由(也用于语音输入)
if (pathname === "/smart-input") {
return (
<AppCrashBoundary>
<SmartInputPage />
</AppCrashBoundary>
);
}
// 更新提醒悬浮窗口路由
if (pathname === "/update-notification") {
return (
-1
View File
@@ -15,7 +15,6 @@ React 组件层,包含 UI 组件和业务组件。
- `mcp/` - MCP 服务器管理组件(配置管理、运行时控制、工具/提示词/资源浏览与调用)
- `api-key-provider/` - API Key Provider 设置组件
- `routing/` - 路由规则配置组件
- `smart-input/` - 截图/语音浮窗共享组件(当前仅保留快捷键设置)
- `settings-v2/` - 设置页面组件(当前主实现)
- `skills/` - 技能管理组件
- `widgets/` - 右侧小部件栏组件(移植自 Waveterm)
@@ -434,6 +434,14 @@ function createTaskCenterDraftTabId(): string {
return `${TASK_CENTER_DRAFT_TAB_PREFIX}-${Date.now().toString(36)}-${random}`;
}
function createLocalImageWorkbenchSessionKey(): string {
const random =
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID().slice(0, 8)
: Math.random().toString(36).slice(2, 10);
return `__local_image_workbench__:${Date.now().toString(36)}:${random}`;
}
function isTaskCenterDraftTabId(value: string): boolean {
return value.startsWith(`${TASK_CENTER_DRAFT_TAB_PREFIX}-`);
}
@@ -2492,13 +2500,27 @@ export function AgentChatWorkspace({
clearPreparedRuntimeTeamState();
}, [clearPreparedRuntimeTeamState]);
const localImageWorkbenchSessionKeyRef = useRef(
`__local_image_workbench__:${Date.now().toString(36)}:${Math.random()
.toString(36)
.slice(2, 8)}`,
createLocalImageWorkbenchSessionKey(),
);
const [localImageWorkbenchSessionKey, setLocalImageWorkbenchSessionKey] =
useState(() => localImageWorkbenchSessionKeyRef.current);
const resetLocalImageWorkbenchSessionScope = useCallback(() => {
const previousLocalSessionKey = localImageWorkbenchSessionKeyRef.current;
const nextLocalSessionKey = createLocalImageWorkbenchSessionKey();
localImageWorkbenchSessionKeyRef.current = nextLocalSessionKey;
setLocalImageWorkbenchSessionKey(nextLocalSessionKey);
setImageWorkbenchBySessionId((previous) => {
if (!previous[previousLocalSessionKey]) {
return previous;
}
const next = { ...previous };
delete next[previousLocalSessionKey];
return next;
});
}, []);
const imageWorkbenchSessionKey = useMemo(
() => sessionId?.trim() || localImageWorkbenchSessionKeyRef.current,
[sessionId],
() => sessionId?.trim() || localImageWorkbenchSessionKey,
[localImageWorkbenchSessionKey, sessionId],
);
const cachedImageWorkbenchState = useMemo(() => {
const normalizedProjectId = normalizeProjectId(projectId);
@@ -2593,7 +2615,7 @@ export function AgentChatWorkspace({
);
useEffect(() => {
const normalizedSessionId = sessionId?.trim();
const localSessionKey = localImageWorkbenchSessionKeyRef.current;
const localSessionKey = localImageWorkbenchSessionKey;
if (!normalizedSessionId || normalizedSessionId === localSessionKey) {
return;
}
@@ -2613,6 +2635,7 @@ export function AgentChatWorkspace({
);
}, [
imageWorkbenchBySessionId,
localImageWorkbenchSessionKey,
sessionId,
updateImageWorkbenchStateForSession,
]);
@@ -4446,9 +4469,7 @@ export function AgentChatWorkspace({
const isTaskCenterDraftSendInFlight = Boolean(
agentEntry === "claw" &&
activeTaskCenterDraftTab &&
(taskCenterDraftSendRequest?.draftTabId === activeTaskCenterDraftTab.id ||
isPreparingSend ||
isSending),
taskCenterDraftSendRequest?.draftTabId === activeTaskCenterDraftTab.id,
);
const shouldSuppressTaskCenterDraftContent =
isTaskCenterDraftTabActive && !isTaskCenterDraftSendInFlight;
@@ -4717,6 +4738,8 @@ export function AgentChatWorkspace({
status: "draft",
};
resetLocalImageWorkbenchSessionScope();
clearMessages({ showToast: false });
startTransition(() => {
setTaskCenterTransitionTopicId(null);
setTaskCenterDetachedTopicId(null);
@@ -4742,6 +4765,8 @@ export function AgentChatWorkspace({
return draftTab.id;
}, [
clearMessages,
resetLocalImageWorkbenchSessionScope,
resetTopicLocalState,
setInput,
setMentionedCharacters,
@@ -4987,6 +5012,7 @@ export function AgentChatWorkspace({
);
};
resetLocalImageWorkbenchSessionScope();
setTaskCenterTransitionTopicId(topicId);
setTaskCenterDetachedTopicId(null);
setActiveTaskCenterDraftTabId(null);
@@ -5036,6 +5062,7 @@ export function AgentChatWorkspace({
clearEntryPendingA2UI,
markTaskCenterLocalSessionOverride,
replaceTaskCenterOpenTabs,
resetLocalImageWorkbenchSessionScope,
setActiveTaskCenterDraftTabId,
switchTopic,
taskCenterWorkspaceId,
@@ -5048,6 +5075,7 @@ export function AgentChatWorkspace({
const handleOpenArchivedTaskTopic = useCallback(
async (topicId: string) => {
resetLocalImageWorkbenchSessionScope();
setActiveTaskCenterDraftTabId(null);
setTaskCenterDetachedTopicId(topicId);
setTaskCenterTransitionTopicId(topicId);
@@ -5071,7 +5099,12 @@ export function AgentChatWorkspace({
current === topicId ? null : current,
);
},
[clearEntryPendingA2UI, markTaskCenterLocalSessionOverride, switchTopic],
[
clearEntryPendingA2UI,
markTaskCenterLocalSessionOverride,
resetLocalImageWorkbenchSessionScope,
switchTopic,
],
);
const handleSelectTaskCenterDraftTab = useCallback(
@@ -5083,6 +5116,8 @@ export function AgentChatWorkspace({
return;
}
resetLocalImageWorkbenchSessionScope();
clearMessages({ showToast: false });
startTransition(() => {
setTaskCenterTransitionTopicId(null);
setTaskCenterDetachedTopicId(null);
@@ -5095,7 +5130,14 @@ export function AgentChatWorkspace({
setMentionedCharacters([]);
});
},
[resetTopicLocalState, setInput, setMentionedCharacters, setSelectedText],
[
clearMessages,
resetLocalImageWorkbenchSessionScope,
resetTopicLocalState,
setInput,
setMentionedCharacters,
setSelectedText,
],
);
const handleSwitchTaskTopic = useCallback(
@@ -527,7 +527,12 @@ describe("ChatModelSelector + useAsterAgentChat 集成", () => {
});
it("关闭后台预加载时,应在打开选择器后再加载 Provider 和模型", async () => {
const { container } = mount("ws-model-selector-lazy-provider-load", {
const workspaceId = "ws-model-selector-lazy-provider-load";
localStorage.setItem(`agent_pref_provider_${workspaceId}`, "gemini");
localStorage.setItem(`agent_pref_model_${workspaceId}`, "gemini-2.5-pro");
localStorage.setItem(`agent_pref_migrated_${workspaceId}`, "true");
const { container } = mount(workspaceId, {
chatModelSelectorProps: {
backgroundPreload: "disabled",
},
@@ -584,6 +584,102 @@ describe("EmptyState", () => {
).toBeNull();
});
it("通用首页静态起手与引导文案应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const container = renderEmptyState({
activeTheme: "general",
serviceSkills: [],
});
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toContain("Creation");
expect(container.textContent).toContain("Ask Lime, spark the idea");
expect(container.textContent).toContain(
"Say the goal and Lime will keep going with you.",
);
expect(container.textContent).toContain("Guide help");
expect(container.textContent).toContain("Writing");
expect(container.textContent).toContain("Add knowledge");
expect(container.textContent).toContain("More methods");
expect(container.textContent).toContain("Start from these tasks");
expect(
container.querySelector('[data-testid="home-scroll-cue"]')?.textContent,
).toContain("Scroll down to see what Lime can help you do");
const guideTrigger = container.querySelector(
'[data-testid="home-guide-help-trigger"]',
) as HTMLButtonElement | null;
act(() => {
guideTrigger?.click();
});
expect(container.textContent).toContain(
"How do I add and use project knowledge?",
);
expect(container.querySelector("textarea")?.placeholder).toContain(
"What would you like to learn?",
);
});
it("非通用首页快速启动面板应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const container = renderEmptyState({
activeTheme: "image",
serviceSkills: [],
});
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toContain("Quick start");
expect(container.textContent).toContain(
"Choose a task template first, then keep adding details and follow-ups in this session.",
);
expect(container.textContent).toContain("Generate image");
expect(container.textContent).toContain("Organize as Notebook");
expect(container.textContent).toContain("Enter research mode");
});
it("仅发送路径引用时应使用当前 locale 的兜底 prompt", async () => {
await changeLimeLocale("en-US");
const onSend = vi.fn();
const container = renderEmptyState({
activeTheme: "general",
input: "",
onSend,
pathReferences: [
{
id: "path-1",
path: "/tmp/report.md",
name: "report.md",
isDir: false,
source: "file_manager",
},
],
});
await act(async () => {
await Promise.resolve();
});
const sendButton = container.querySelector(
'button[aria-label="Send"]',
) as HTMLButtonElement | null;
expect(sendButton).toBeTruthy();
act(() => {
sendButton?.click();
});
expect(onSend.mock.calls[0]?.[0]).toBe(
"Please review these files or folders.",
);
});
it("从 Skills 页带回的技能应显示在首页输入框内的 @ 标签", async () => {
const onSend = vi.fn();
const skill = {
@@ -628,14 +724,19 @@ describe("EmptyState", () => {
sendButton?.click();
});
expect(onSend).toHaveBeenCalledWith("整理最近发布计划", "react", undefined, {
capabilityRoute: {
kind: "installed_skill",
skillKey: "writer",
skillName: "写作助手",
expect(onSend).toHaveBeenCalledWith(
"整理最近发布计划",
"react",
undefined,
{
capabilityRoute: {
kind: "installed_skill",
skillKey: "writer",
skillName: "写作助手",
},
displayContent: "整理最近发布计划",
},
displayContent: "整理最近发布计划",
});
);
});
it("首页添加资料入口应打开输入框资料中枢,而不是预填一段说明", async () => {
@@ -2635,6 +2736,9 @@ describe("EmptyState", () => {
});
const textarea = container.querySelector("textarea");
expect(textarea?.getAttribute("placeholder")).toContain(
"直接说一句话,例如:",
);
expect(textarea?.getAttribute("placeholder")).toContain(
"帮我用 GitHub 查一下 AI Agent 项目",
);
@@ -92,6 +92,7 @@ import {
getSiteSkillAutoLaunchExample,
hasAutoLaunchableSiteSkill,
} from "../service-skills/siteSkillExamplePrompts";
import { buildServiceSkillHomeCopy } from "../service-skills/homeCopy";
import { listFeaturedHomeServiceSkills } from "../service-skills/homeEntrySkills";
import type { SceneAppEntryCardItem } from "../sceneappEntryTypes";
import type { RuntimeToolAvailability } from "../utils/runtimeToolAvailability";
@@ -111,11 +112,8 @@ import {
buildHomeSkillSections,
buildHomeStarterChips,
} from "../home/buildHomeSkillSurface";
import {
HOME_COMPOSER_PLACEHOLDER,
HOME_GUIDE_HELP_CONTEXT_LABEL,
HOME_GUIDE_HELP_PLACEHOLDER,
} from "../home/homeSurfaceCopy";
import { buildHomeSurfaceCopy } from "../home/homeSurfaceCopy";
import { buildInputbarCoreCopy } from "./Inputbar/components/inputbarCoreCopy";
import type {
HomeGuideCard,
HomeSkillSurfaceItem,
@@ -439,22 +437,6 @@ const THEME_ICONS: Record<string, string> = {
general: "✨",
};
const THEME_WORKBENCH_COPY: Record<
string,
{
title: string;
description: string;
supportingDescription?: string;
}
> = {
general: {
title: "",
description: "说一句目标,Lime 就接着帮你做。",
supportingDescription:
"文案、图片、视频、搜索和网页任务围绕同一目标持续推进,并沉淀上下文、偏好和做法。",
},
};
function truncatePrompt(value: string, maxLength = 92) {
const normalized = value.trim().replace(/\s+/g, " ");
if (normalized.length <= maxLength) {
@@ -511,7 +493,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onResumeRecentSceneApp,
recentSessionTitle = null,
recentSessionSummary = null,
recentSessionActionLabel = "继续最近会话",
recentSessionActionLabel,
onResumeRecentSession,
onOpenSceneAppsDirectory,
projectId = null,
@@ -538,13 +520,27 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onToggleFileManager,
}) => {
const { t } = useTranslation("agent");
const curatedTaskTemplateCopy = useMemo(
() =>
buildCuratedTaskTemplateCopy((key, values) =>
t(key as AgentI18nKey, values ?? {}),
),
const translateAgentCopyKey = useCallback(
(key: string, values?: Record<string, number | string>) =>
t(key as AgentI18nKey, values ?? {}),
[t],
);
const curatedTaskTemplateCopy = useMemo(
() => buildCuratedTaskTemplateCopy(translateAgentCopyKey),
[translateAgentCopyKey],
);
const serviceSkillHomeCopy = useMemo(
() => buildServiceSkillHomeCopy(translateAgentCopyKey),
[translateAgentCopyKey],
);
const homeSurfaceCopy = useMemo(
() => buildHomeSurfaceCopy(translateAgentCopyKey),
[translateAgentCopyKey],
);
const inputbarCoreCopy = useMemo(
() => buildInputbarCoreCopy(translateAgentCopyKey),
[translateAgentCopyKey],
);
const pageContainerRef = useRef<HTMLDivElement | null>(null);
const handledInitialInputCapabilitySignatureRef = useRef("");
const [activeCapability, setActiveCapability] =
@@ -859,7 +855,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
setPendingImages((prev) => [...prev, image]);
})
.catch(() => {
toast.error(`图片读取失败: ${file.name || "未命名图片"}`);
toast.error(
homeSurfaceCopy.toast.imageReadFailed(
file.name || homeSurfaceCopy.toast.unnamedImage,
),
);
});
});
@@ -878,11 +878,15 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
.then((image) => {
setPendingImages((prev) => [...prev, image]);
if (index === 0) {
toast.success("已粘贴图片");
toast.success(homeSurfaceCopy.toast.imagePasted);
}
})
.catch(() => {
toast.error(`图片读取失败: ${file.name || "未命名图片"}`);
toast.error(
homeSurfaceCopy.toast.imageReadFailed(
file.name || homeSurfaceCopy.toast.unnamedImage,
),
);
});
});
};
@@ -922,11 +926,15 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
.then((image) => {
setPendingImages((prev) => [...prev, image]);
if (index === 0) {
toast.success("已添加图片");
toast.success(homeSurfaceCopy.toast.imageAdded);
}
})
.catch(() => {
toast.error(`图片读取失败: ${file.name || "未命名图片"}`);
toast.error(
homeSurfaceCopy.toast.imageReadFailed(
file.name || homeSurfaceCopy.toast.unnamedImage,
),
);
});
});
return;
@@ -935,7 +943,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
if (files && files.length > 0) {
event.preventDefault();
event.stopPropagation();
toast.error("无法读取系统文件路径,请从内置文件管理器拖入。");
toast.error(homeSurfaceCopy.toast.systemPathDropUnsupported);
}
};
@@ -981,7 +989,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const effectiveInput = inputOverride.trim()
? inputOverride
: hasPathReferences
? "请查看这些文件或文件夹。"
? homeSurfaceCopy.composerPathReferenceFallbackPrompt
: inputOverride;
const sendOptions =
capabilityDispatch.capabilityRoute ||
@@ -1008,14 +1016,13 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
clearSelectedSkill?.();
};
const workbenchCopy =
THEME_WORKBENCH_COPY[activeTheme] || THEME_WORKBENCH_COPY.general;
// Dynamic Placeholder
const getPlaceholder = () => {
return hasAutoLaunchSiteSkill
? `直接说一句话,例如:${siteSkillAutoLaunchExample}`
: HOME_COMPOSER_PLACEHOLDER;
? homeSurfaceCopy.composerAutoLaunchPlaceholder(
siteSkillAutoLaunchExample,
)
: homeSurfaceCopy.composerPlaceholder;
};
const handleApplyRecommendation = useCallback(
@@ -1105,10 +1112,10 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
options.referenceSelection.referenceEntries,
);
setCuratedTaskLauncherPrefillHint(
`已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。`,
homeSurfaceCopy.curatedTaskReviewSuggestionPrefillHint,
);
},
[],
[homeSurfaceCopy.curatedTaskReviewSuggestionPrefillHint],
);
const handleApplyCuratedTaskTemplate = useCallback(
@@ -1201,67 +1208,15 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
key: `${activeTheme}-${shortLabel}`,
title: shortLabel,
description: truncatePrompt(fullPrompt),
badge: `${THEME_ICONS[activeTheme] || "✨"} 快速启动`,
badge: homeSurfaceCopy.quickActions.badge(
THEME_ICONS[activeTheme] || "✨",
),
prompt: fullPrompt,
})),
[activeTheme, currentRecommendations],
[activeTheme, currentRecommendations, homeSurfaceCopy.quickActions],
);
const quickStartPresets = useMemo(() => {
const presets = [
{
key: "generate-image",
label: "生成配图",
icon: "✨",
prompt:
"请帮我生成一张适合当前主题的高质量图片,并先帮我整理一版可直接用于生图模型的详细 Prompt。",
},
{
key: "join-notebook",
label: "整理为 Notebook",
icon: "📒",
prompt:
"请把这个主题整理成 notebook 工作方式:背景、资料、思路、草稿、待办分栏组织。",
},
{
key: "create-skill",
label: "设计 Skill",
icon: "🧩",
prompt:
"请帮我设计一个可复用的 Skill,先定义适用场景、输入输出、执行步骤和失败回退策略。",
},
{
key: "create-slides",
label: "生成演示稿",
icon: "🖥️",
prompt:
"请基于当前主题生成一份演示文稿结构,包含封面、目录、核心论点、案例页和结论页。",
},
{
key: "frontend-design",
label: "前端界面方案",
icon: "🌐",
prompt:
"请帮我设计一个前端界面方案,先给出信息架构、关键模块、视觉方向和组件层级。",
},
{
key: "copymail-skill",
label: "专业邮件草稿",
icon: "✉️",
prompt:
"请帮我起草一封专业邮件,先确认收件对象、语气、目标和希望对方采取的下一步动作。",
},
{
key: "research-skills",
label: "进入研究模式",
icon: "🔎",
prompt:
"请先进入研究模式,帮我围绕当前主题做信息收集、观点归纳、风险点识别和结论总结。",
},
];
return presets;
}, []);
const quickStartPresets = homeSurfaceCopy.quickActions.presets;
const [homeCatalogEntries, setHomeCatalogEntries] = useState<
SkillCatalogEntry[]
@@ -1318,16 +1273,16 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
[serviceSkills],
);
const homeStarterChips = useMemo(
() => buildHomeStarterChips(homeCatalogEntries),
[homeCatalogEntries],
() => buildHomeStarterChips(homeCatalogEntries, homeSurfaceCopy),
[homeCatalogEntries, homeSurfaceCopy],
);
const homeInputSuggestions = useMemo(
() => buildHomeInputSuggestions(homeCatalogEntries),
[homeCatalogEntries],
() => buildHomeInputSuggestions(homeCatalogEntries, homeSurfaceCopy),
[homeCatalogEntries, homeSurfaceCopy],
);
const homeGuideCards = useMemo(
() => buildHomeGuideCards(homeCatalogEntries),
[homeCatalogEntries],
() => buildHomeGuideCards(homeCatalogEntries, homeSurfaceCopy),
[homeCatalogEntries, homeSurfaceCopy],
);
const guideHelpStarterLabel = useMemo(
() =>
@@ -1336,13 +1291,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
[homeStarterChips],
);
const guideHelpLabel = guideHelpStarterLabel
? `Lime ${guideHelpStarterLabel}`
: HOME_GUIDE_HELP_CONTEXT_LABEL;
? homeSurfaceCopy.guideHelpContextLabelWithStarter(guideHelpStarterLabel)
: homeSurfaceCopy.guideHelpContextLabel;
const homeSkillItems = useMemo(() => {
void slashEntryUsageVersion;
return buildHomeSkillItems({
curatedTasks: curatedTaskTemplates,
serviceSkillHomeCopy,
catalogSceneEntries: homeCatalogSceneEntries,
serviceSkills: homeServiceSkillItems,
installedSkills: skillSelection.skills ?? [],
@@ -1354,13 +1310,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
featuredSceneApps,
homeCatalogSceneEntries,
homeServiceSkillItems,
serviceSkillHomeCopy,
skillSelection.skills,
slashEntryUsageVersion,
]);
const homeSkillSections = useMemo(
() => buildHomeSkillSections(homeSkillItems),
[homeSkillItems],
() => buildHomeSkillSections(homeSkillItems, homeSurfaceCopy),
[homeSkillItems, homeSurfaceCopy],
);
const homeGalleryItems = useMemo(
() => buildHomeGalleryItems(homeSkillItems, "all"),
@@ -1535,11 +1492,18 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const recentSessionLinkLabel = useMemo(() => {
const normalizedTitle = truncatePrompt(recentSessionTitle || "", 18);
const effectiveRecentSessionActionLabel =
recentSessionActionLabel ??
homeSurfaceCopy.chrome.recentSessionDefaultActionLabel;
if (!normalizedTitle) {
return recentSessionActionLabel;
return effectiveRecentSessionActionLabel;
}
return `${recentSessionActionLabel} · ${normalizedTitle}`;
}, [recentSessionActionLabel, recentSessionTitle]);
return `${effectiveRecentSessionActionLabel} · ${normalizedTitle}`;
}, [
homeSurfaceCopy.chrome.recentSessionDefaultActionLabel,
recentSessionActionLabel,
recentSessionTitle,
]);
const recentSessionLinkTitle = useMemo(
() =>
[recentSessionTitle, recentSessionSummary]
@@ -1565,7 +1529,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
if (canResumeRecentSceneApp && onResumeRecentSceneApp) {
actions.push({
id: "recent-sceneapp",
label: "继续最近做法",
label: homeSurfaceCopy.chrome.recentSceneAppActionLabel,
testId: "entry-sceneapp-resume",
onSelect: onResumeRecentSceneApp,
});
@@ -1574,6 +1538,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
return actions;
}, [
canResumeRecentSceneApp,
homeSurfaceCopy.chrome.recentSceneAppActionLabel,
onResumeRecentSceneApp,
onResumeRecentSession,
recentSessionLinkLabel,
@@ -1586,7 +1551,9 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
<EmptyStateComposerPanel
input={input}
placeholder={
guideHelpActive ? HOME_GUIDE_HELP_PLACEHOLDER : getPlaceholder()
guideHelpActive
? homeSurfaceCopy.guideHelpPlaceholder
: getPlaceholder()
}
onSend={handleSend}
activeTheme={activeTheme}
@@ -1630,7 +1597,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
effectiveDefaultCuratedTaskReferenceMemoryIds,
activeCuratedTaskReferenceEntries ||
effectiveDefaultCuratedTaskReferenceEntries,
"已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。",
homeSurfaceCopy.curatedTaskReviewSuggestionPrefillHint,
)
: undefined
}
@@ -1651,6 +1618,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
onToggleKnowledgeCompanionPack={onToggleKnowledgeCompanionPack}
onStartKnowledgeOrganize={onStartKnowledgeOrganize}
onManageKnowledgePacks={onManageKnowledgePacks}
copy={homeSurfaceCopy.composer}
inputbarCopy={inputbarCoreCopy}
showCreationModeSelector={showCreationModeSelector}
creationMode={creationMode}
onCreationModeChange={onCreationModeChange}
@@ -1688,8 +1657,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const defaultQuickActionsPanel = (
<EmptyStateQuickActions
title="快速启动"
description="先选一个任务模板,再在当前会话里继续补充和追问。"
title={homeSurfaceCopy.quickActions.title}
description={homeSurfaceCopy.quickActions.description}
selectedTextPreview={selectedTextPreview}
presets={quickStartPresets}
items={quickActionItems}
@@ -1704,6 +1673,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
const homeStartSurfacePanel = (
<HomeStartSurface
starterChips={homeStarterChips}
copy={homeSurfaceCopy.chrome}
guideCards={homeGuideCards}
guideOpen={guideHelpActive}
sections={homeSkillSections}
@@ -1730,11 +1700,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
<PageContainer ref={pageContainerRef}>
<ContentWrapper>
<EmptyStateHero
eyebrow="创作"
title={workbenchCopy.title}
slogan="青柠一下,灵感即来"
description={workbenchCopy.description}
supportingDescription={workbenchCopy.supportingDescription}
eyebrow={homeSurfaceCopy.hero.eyebrow}
title=""
slogan={homeSurfaceCopy.hero.slogan}
description={homeSurfaceCopy.hero.description}
supportingDescription={homeSurfaceCopy.hero.supportingDescription}
cards={[]}
prioritySlot={composerPanel}
supportingSlot={
@@ -1745,11 +1715,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
<ScrollCue
href="#home-skill-gallery-screen"
data-testid="home-scroll-cue"
aria-label="向下滑,看看 Lime 可以帮你做什么"
aria-label={homeSurfaceCopy.chrome.scrollCueLabel}
>
<ScrollCueLine aria-hidden />
<ScrollCueText>
向下滑,看看 Lime 可以帮你做什么
{homeSurfaceCopy.chrome.scrollCueLabel}
<ScrollCueArrow aria-hidden>↓</ScrollCueArrow>
</ScrollCueText>
<ScrollCueLine aria-hidden />
@@ -1759,13 +1729,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
{isGeneralTheme && homeGalleryItems.length > 0 ? (
<SecondScreenSection
id="home-skill-gallery-screen"
aria-label="Lime 可执行任务示例"
aria-label={homeSurfaceCopy.chrome.secondScreenLabel}
data-testid="home-second-screen"
onWheel={handleSecondScreenWheel}
>
<SecondScreenInner>
<HomeSkillGallery
items={homeGalleryItems}
copy={homeSurfaceCopy.chrome}
onSelectItem={handleSelectHomeSkillItem}
/>
</SecondScreenInner>
@@ -7,6 +7,17 @@ import {
createSkillSelectionProps,
type SkillSelectionProps,
} from "../skill-selection/skillSelectionBindings";
import agentResource from "@/i18n/resources/zh-CN/agent.json";
import enAgentResource from "@/i18n/resources/en-US/agent.json";
import {
buildHomeSurfaceCopy,
type HomeSurfaceCopyKey,
} from "../home/homeSurfaceCopy";
import {
buildInputbarCoreCopy,
type InputbarCoreCopyKey,
} from "./Inputbar/components/inputbarCoreCopy";
import { changeLimeLocale } from "@/i18n/createI18n";
vi.mock("./ChatModelSelector", () => ({
ChatModelSelector: () => <div data-testid="empty-state-model-selector" />,
@@ -45,6 +56,34 @@ vi.mock("./Inputbar/components/TeamSelector", () => ({
),
}));
function translateResource(
resource: Partial<Record<HomeSurfaceCopyKey | InputbarCoreCopyKey, string>>,
key: HomeSurfaceCopyKey | InputbarCoreCopyKey,
values?: Record<string, number | string>,
) {
return Object.entries(values ?? {}).reduce(
(text, [name, value]) =>
text.split(`{{${name}}}`).join(String(value)),
resource[key] ?? key,
);
}
const TEST_COMPOSER_COPY = buildHomeSurfaceCopy((key, values) =>
translateResource(agentResource, key, values),
).composer;
const TEST_EN_COMPOSER_COPY = buildHomeSurfaceCopy((key, values) =>
translateResource(enAgentResource, key, values),
).composer;
const TEST_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) =>
translateResource(agentResource, key, values),
);
const TEST_EN_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) =>
translateResource(enAgentResource, key, values),
);
const mockSelectedTeam = {
id: "frontend-triage-team",
source: "builtin" as const,
@@ -105,12 +144,13 @@ function createGithubSearchServiceSkill() {
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
await changeLimeLocale("zh-CN");
});
afterEach(() => {
@@ -162,6 +202,8 @@ function renderPanel(
isGeneralTheme: false,
characters: [],
skillSelection: createSkillSelection(),
copy: TEST_COMPOSER_COPY,
inputbarCopy: TEST_INPUTBAR_CORE_COPY,
showCreationModeSelector: false,
creationMode: "guided",
onCreationModeChange: vi.fn(),
@@ -218,6 +260,8 @@ function renderStatefulPanel(
isGeneralTheme
characters={[]}
skillSelection={createSkillSelection()}
copy={TEST_COMPOSER_COPY}
inputbarCopy={TEST_INPUTBAR_CORE_COPY}
showCreationModeSelector={false}
creationMode="guided"
onCreationModeChange={vi.fn()}
@@ -360,6 +404,62 @@ describe("EmptyStateComposerPanel", () => {
expect(onToggleFileManager).toHaveBeenCalledTimes(1);
});
it("首页空态输入区 chrome 文案应支持 en-US copy", () => {
const container = renderPanel({
copy: TEST_EN_COMPOSER_COPY,
inputbarCopy: TEST_EN_INPUTBAR_CORE_COPY,
isGeneralTheme: true,
guideHelpActive: true,
onClearGuideHelp: vi.fn(),
onToggleFileManager: vi.fn(),
showCreationModeSelector: true,
});
const guideBadge = container.querySelector(
'[data-testid="home-guide-help-active-badge"]',
);
expect(guideBadge?.textContent).toContain("Lime guide help");
const guideCloseButton = guideBadge?.querySelector("button");
expect(guideCloseButton?.getAttribute("aria-label")).toBe(
"Close Lime guide help",
);
expect(guideCloseButton?.getAttribute("title")).toBe("Close guide help");
expect(
container
.querySelector('[data-testid="home-guide-help-toolbar-badge"]')
?.getAttribute("title"),
).toBe("Close guide help");
const advancedToggle = container.querySelector(
'[data-testid="empty-state-advanced-toggle"]',
) as HTMLButtonElement | null;
expect(advancedToggle?.getAttribute("aria-label")).toBe(
"Expand advanced settings",
);
expect(advancedToggle?.getAttribute("title")).toBe(
"Expand advanced settings",
);
expect(advancedToggle?.textContent).toContain("Advanced settings");
expect(container.textContent).toContain("Current model");
const fileManagerToggle = container.querySelector(
'[data-testid="inputbar-file-manager-toggle"]',
) as HTMLButtonElement | null;
expect(fileManagerToggle?.getAttribute("aria-label")).toBe(
"Open file manager sidebar",
);
expandAdvancedControls(container);
expect(
container
.querySelector('[data-testid="empty-state-advanced-toggle"]')
?.getAttribute("aria-label"),
).toBe("Collapse advanced settings");
expect(container.textContent).toContain("General task context");
});
it("首页空态输入区应把项目资料作为底栏主入口", () => {
const container = renderPanel({
knowledgePackSelection: {
@@ -50,6 +50,7 @@ import {
buildSkillSelectionBindings,
type SkillSelectionProps,
} from "../skill-selection/skillSelectionBindings";
import type { HomeSurfaceComposerCopy } from "../home/homeSurfaceCopy";
import type { AgentAccessMode } from "../hooks/agentChatStorage";
import type {
InputCapabilitySelection,
@@ -64,6 +65,7 @@ import type {
InputbarKnowledgePackOption,
InputbarKnowledgePackSelection,
} from "./Inputbar/types";
import type { InputbarCoreCopy } from "./Inputbar/components/inputbarCoreCopy";
interface EmptyStateComposerPanelProps {
input: string;
@@ -104,6 +106,8 @@ interface EmptyStateComposerPanelProps {
onToggleKnowledgeCompanionPack?: (packName: string, enabled: boolean) => void;
onStartKnowledgeOrganize?: () => void;
onManageKnowledgePacks?: () => void;
copy: HomeSurfaceComposerCopy;
inputbarCopy: InputbarCoreCopy;
showCreationModeSelector: boolean;
creationMode: CreationMode;
onCreationModeChange?: (mode: CreationMode) => void;
@@ -137,9 +141,13 @@ interface EmptyStateComposerPanelProps {
function GuideHelpBadge({
label,
closeLabel,
closeTitle,
onClear,
}: {
label: string;
closeLabel: string;
closeTitle: string;
onClear: () => void;
}) {
return (
@@ -154,8 +162,8 @@ function GuideHelpBadge({
type="button"
onClick={onClear}
className="ml-0.5 text-emerald-800/70 hover:opacity-70"
aria-label={`关闭${label}`}
title="关闭引导帮助"
aria-label={closeLabel}
title={closeTitle}
>
<X className="h-3 w-3" />
</button>
@@ -165,9 +173,11 @@ function GuideHelpBadge({
function GuideHelpToolbarBadge({
label,
title,
onClear,
}: {
label: string;
title: string;
onClear: () => void;
}) {
return (
@@ -175,7 +185,7 @@ function GuideHelpToolbarBadge({
type="button"
data-testid="home-guide-help-toolbar-badge"
className="inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-full border border-emerald-200/80 bg-emerald-50/90 px-3 text-xs font-semibold text-emerald-900 shadow-sm shadow-emerald-950/5 transition hover:bg-white hover:text-emerald-950"
title="关闭引导帮助"
title={title}
onClick={onClear}
>
<Lightbulb className="h-3.5 w-3.5" strokeWidth={1.9} />
@@ -222,6 +232,8 @@ export function EmptyStateComposerPanel({
onToggleKnowledgeCompanionPack,
onStartKnowledgeOrganize,
onManageKnowledgePacks,
copy,
inputbarCopy,
showCreationModeSelector,
creationMode,
onCreationModeChange,
@@ -249,7 +261,7 @@ export function EmptyStateComposerPanel({
onToggleFileManager,
inputSuggestions = [],
guideHelpActive = false,
guideHelpLabel = "Lime 引导帮助",
guideHelpLabel,
onClearGuideHelp,
}: EmptyStateComposerPanelProps) {
const [draftInput, setDraftInput] = useState(input);
@@ -407,6 +419,8 @@ export function EmptyStateComposerPanel({
}
};
const effectiveGuideHelpLabel =
guideHelpLabel ?? copy.guideHelpDefaultLabel;
const topExtra =
guideHelpActive ||
activeBuiltinCommand ||
@@ -418,7 +432,9 @@ export function EmptyStateComposerPanel({
<>
{guideHelpActive ? (
<GuideHelpBadge
label={guideHelpLabel}
label={effectiveGuideHelpLabel}
closeLabel={copy.guideHelpCloseWithLabel(effectiveGuideHelpLabel)}
closeTitle={copy.guideHelpClose}
onClear={onClearGuideHelp ?? (() => undefined)}
/>
) : null}
@@ -524,6 +540,12 @@ export function EmptyStateComposerPanel({
Boolean(onToggleFileManager);
const shouldShowLeftExtra =
Boolean(knowledgePackControl) || shouldShowAdvancedToggle;
const advancedToggleLabel = showAdvancedControls
? copy.advancedSettings.collapse
: copy.advancedSettings.expand;
const fileManagerToggleLabel = fileManagerOpen
? copy.fileManager.close
: copy.fileManager.open;
const leftExtra = shouldShowLeftExtra ? (
<>
{knowledgePackControl}
@@ -532,10 +554,10 @@ export function EmptyStateComposerPanel({
<MetaToggleButton
type="button"
$checked={showAdvancedControls || hasHighlightedAdvancedPreference}
aria-label={showAdvancedControls ? "收起高级设置" : "展开高级设置"}
aria-label={advancedToggleLabel}
aria-expanded={showAdvancedControls}
data-testid="empty-state-advanced-toggle"
title={showAdvancedControls ? "收起高级设置" : "展开高级设置"}
title={advancedToggleLabel}
onClick={() => setShowAdvancedControls((previous) => !previous)}
>
<MetaToggleCheck
@@ -545,7 +567,7 @@ export function EmptyStateComposerPanel({
<MetaToggleGlyph aria-hidden>
<Settings2 strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel>高级设置</MetaToggleLabel>
<MetaToggleLabel>{copy.advancedSettings.label}</MetaToggleLabel>
{showAdvancedControls ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden />
) : (
@@ -556,7 +578,8 @@ export function EmptyStateComposerPanel({
{guideHelpActive ? (
<GuideHelpToolbarBadge
label={guideHelpLabel}
label={effectiveGuideHelpLabel}
title={copy.guideHelpClose}
onClear={onClearGuideHelp ?? (() => undefined)}
/>
) : null}
@@ -565,9 +588,11 @@ export function EmptyStateComposerPanel({
<Badge
variant="outline"
className={`${EMPTY_STATE_PASSIVE_BADGE_CLASSNAME} max-w-[240px] items-center overflow-hidden`}
title={`当前模型:${currentModelSummary}`}
title={copy.currentModel.title(currentModelSummary)}
>
<span className="mr-1 text-slate-500">当前模型</span>
<span className="mr-1 text-slate-500">
{copy.currentModel.label}
</span>
<span className="truncate">{trimmedModel}</span>
</Badge>
) : null}
@@ -576,10 +601,8 @@ export function EmptyStateComposerPanel({
<MetaIconButton
type="button"
$active={fileManagerOpen}
aria-label={
fileManagerOpen ? "关闭左侧文件管理器" : "打开左侧文件管理器"
}
title={fileManagerOpen ? "关闭左侧文件管理器" : "打开左侧文件管理器"}
aria-label={fileManagerToggleLabel}
title={fileManagerToggleLabel}
data-testid="inputbar-file-manager-toggle"
onClick={onToggleFileManager}
>
@@ -639,7 +662,7 @@ export function EmptyStateComposerPanel({
</SelectTrigger>
<SelectContent className="min-w-[200px] p-1" side="top">
<div className="px-2 py-1.5 text-xs font-medium text-slate-500">
选择创作模式
{copy.creationMode.label}
</div>
{(
Object.entries(CREATION_MODE_CONFIG) as [
@@ -664,7 +687,7 @@ export function EmptyStateComposerPanel({
className={EMPTY_STATE_PASSIVE_BADGE_CLASSNAME}
>
<Globe className="mr-1 h-3.5 w-3.5" />
通用任务上下文
{copy.generalContext}
</Badge>
) : null}
</>
@@ -706,6 +729,7 @@ export function EmptyStateComposerPanel({
/>
<InputbarCore
uiCopy={inputbarCopy}
textareaRef={textareaRef}
text={draftInput}
setText={setDraftInput}
@@ -741,6 +765,7 @@ export function EmptyStateComposerPanel({
showMetaTools={showAdvancedControls}
inputSuggestion={activeInputSuggestion}
onAcceptInputSuggestion={handleAcceptInputSuggestion}
listenForVoiceShortcut
/>
</>
);
@@ -6,6 +6,7 @@ import type { MessageImageWorkbenchPreview } from "../types";
import { ImageWorkbenchMessagePreview } from "./ImageWorkbenchMessagePreview";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
const IMAGE_WORKBENCH_TASK_ACTION_EVENT = "lime:image-workbench-task-action";
function renderPreview(preview: MessageImageWorkbenchPreview) {
const container = document.createElement("div");
@@ -60,6 +61,13 @@ describe("ImageWorkbenchMessagePreview", () => {
expect(container.textContent).toContain("Image Generation");
expect(container.textContent).toContain("Nanobanana Pro");
expect(container.textContent).toContain("搞定,已生成这张图。");
expect(
container
.querySelector(
'[data-testid="image-workbench-message-preview-toolbar-image-preview-complete"]',
)
?.className.toString(),
).not.toContain("border");
expect(container.querySelector("img")?.getAttribute("src")).toBe(
"data:image/png;base64,aW1hZ2U=",
);
@@ -101,4 +109,51 @@ describe("ImageWorkbenchMessagePreview", () => {
expect(container.textContent).not.toContain("参考图");
expect(container.textContent).not.toContain("部分完成");
});
it("failed preview exposes a dedicated retry action without replacing the open card", () => {
const { container } = renderPreview({
taskId: "image-preview-failed",
prompt: "Create a lime poster",
mode: "generate",
status: "failed",
projectId: "project-1",
contentId: "content-1",
statusMessage: "Provider timeout",
});
let actionDetail: Record<string, unknown> | null = null;
const handleAction = (event: Event) => {
if (!(event instanceof CustomEvent)) {
return;
}
actionDetail = event.detail as Record<string, unknown>;
};
window.addEventListener(IMAGE_WORKBENCH_TASK_ACTION_EVENT, handleAction);
const openButton = container.querySelector(
'[data-testid="image-workbench-message-preview-image-preview-failed"]',
) as HTMLButtonElement | null;
const retryButton = container.querySelector(
'[data-testid="image-workbench-message-preview-action-image-preview-failed-retry"]',
) as HTMLButtonElement | null;
expect(openButton?.getAttribute("aria-label")).toBe("Open image result");
expect(retryButton?.textContent).toContain("Retry");
act(() => {
retryButton?.click();
});
expect(actionDetail).toEqual({
action: "retry",
taskId: "image-preview-failed",
projectId: "project-1",
contentId: "content-1",
});
window.removeEventListener(
IMAGE_WORKBENCH_TASK_ACTION_EVENT,
handleAction,
);
});
});
@@ -1,8 +1,11 @@
import React from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Leaf, LoaderCircle } from "lucide-react";
import { emitImageWorkbenchFocus } from "@/lib/imageWorkbenchEvents";
import { Leaf, LoaderCircle, RotateCcw } from "lucide-react";
import {
emitImageWorkbenchFocus,
emitImageWorkbenchTaskAction,
} from "@/lib/imageWorkbenchEvents";
import { cn } from "@/lib/utils";
import type { MessageImageWorkbenchPreview } from "../types";
import { resolveImageWorkbenchPreviewModelLabel } from "../utils/imageWorkbenchPresentation";
@@ -206,48 +209,76 @@ export const ImageWorkbenchMessagePreview: React.FC<
const toolLabel = resolveToolLabel(preview, t);
const modelLabel = resolveImageWorkbenchPreviewModelLabel(preview);
const caption = preview.caption?.trim();
const showRetryAction = preview.status === "failed";
const openPreview = () => {
if (onOpen) {
onOpen(preview);
return;
}
emitImageWorkbenchFocus({
projectId: preview.projectId ?? null,
contentId: preview.contentId ?? null,
});
};
const handleRetry = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
emitImageWorkbenchTaskAction({
action: "retry",
taskId: preview.taskId,
projectId: preview.projectId ?? null,
contentId: preview.contentId ?? null,
});
};
return (
<button
type="button"
aria-label={t("agentChat.imageWorkbenchPreview.media.open")}
onClick={() => {
if (onOpen) {
onOpen(preview);
return;
}
emitImageWorkbenchFocus({
projectId: preview.projectId ?? null,
contentId: preview.contentId ?? null,
});
}}
data-testid={`image-workbench-message-preview-${preview.taskId}`}
className="group block w-full max-w-[800px] text-left"
>
<div
data-testid={`image-workbench-message-preview-toolbar-${preview.taskId}`}
className="mb-2.5 flex min-h-10 w-full max-w-full items-center gap-2 rounded-[12px] border border-stone-200 bg-stone-100/75 px-3.5 py-2 text-[13px] font-medium leading-5 text-[#435d2e] shadow-[0_8px_24px_-22px_rgba(15,23,42,0.32)]"
<div className="w-full max-w-[800px]">
<button
type="button"
aria-label={t("agentChat.imageWorkbenchPreview.media.open")}
onClick={openPreview}
data-testid={`image-workbench-message-preview-${preview.taskId}`}
className="group block w-full text-left"
>
<Leaf className="h-3.5 w-3.5 shrink-0 fill-[#496631]/15 text-[#496631]" />
<span className="truncate">{toolLabel}</span>
{modelLabel ? (
<>
<span className="text-[#8aa47b]/55">|</span>
<span className="truncate text-[#8aa47b]">{modelLabel}</span>
</>
) : null}
</div>
<div className="relative max-w-full transition">
{renderPreviewMedia(preview, t)}
</div>
{caption ? (
<div
data-testid={`image-workbench-message-preview-caption-${preview.taskId}`}
className="mt-2 max-w-[800px] whitespace-pre-line text-sm leading-6 text-slate-700"
data-testid={`image-workbench-message-preview-toolbar-${preview.taskId}`}
className="mb-2 flex min-h-6 w-full max-w-full items-center gap-2 px-0.5 text-[13px] font-medium leading-5 text-[#435d2e]"
>
{caption}
<Leaf className="h-3.5 w-3.5 shrink-0 fill-[#496631]/15 text-[#496631]" />
<span className="truncate">{toolLabel}</span>
{modelLabel ? (
<>
<span className="text-[#8aa47b]/55">|</span>
<span className="truncate text-[#8aa47b]">{modelLabel}</span>
</>
) : null}
</div>
<div className="relative max-w-full transition">
{renderPreviewMedia(preview, t)}
</div>
{caption ? (
<div
data-testid={`image-workbench-message-preview-caption-${preview.taskId}`}
className="mt-2 max-w-[800px] whitespace-pre-line text-sm leading-6 text-slate-700"
>
{caption}
</div>
) : null}
</button>
{showRetryAction ? (
<div className="mt-2 flex items-center">
<button
type="button"
onClick={handleRetry}
data-testid={`image-workbench-message-preview-action-${preview.taskId}-retry`}
className="inline-flex items-center gap-1.5 rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-stone-50"
>
<RotateCcw className="h-3.5 w-3.5" />
{t("agentChat.imageWorkbenchPreview.action.retry")}
</button>
</div>
) : null}
</button>
</div>
);
};
@@ -34,6 +34,8 @@ import type {
InputbarKnowledgePackSelection,
} from "../types";
import { InputbarKnowledgeControl } from "../knowledge/InputbarKnowledgeControl";
import type { InputbarComposerSectionCopy } from "./inputbarComposerSectionCopy";
import type { InputbarCoreCopy } from "./inputbarCoreCopy";
import type {
WorkflowGateState,
WorkflowQuickAction,
@@ -112,6 +114,8 @@ interface InputbarComposerSectionProps {
projectId?: string | null;
sessionId?: string | null;
inputCompletionEnabled?: boolean;
copy: InputbarComposerSectionCopy;
inputbarCopy: InputbarCoreCopy;
}
export const InputbarComposerSection: React.FC<
@@ -177,6 +181,8 @@ export const InputbarComposerSection: React.FC<
projectId = null,
sessionId = null,
inputCompletionEnabled = true,
copy,
inputbarCopy,
}) => {
const [teamSelectorAutoOpenToken, setTeamSelectorAutoOpenToken] = useState<
number | null
@@ -262,6 +268,18 @@ export const InputbarComposerSection: React.FC<
Boolean(onToggleFileManager);
const shouldShowLeftExtra =
Boolean(knowledgePackControl) || shouldShowAdvancedToggle;
const advancedSettingsLabel = showAdvancedControls
? copy.advancedSettings.collapse
: copy.advancedSettings.expand;
const fileManagerLabel = fileManagerOpen
? copy.fileManager.close
: copy.fileManager.open;
const workspacePlaceholder =
workflowGate?.status === "waiting"
? copy.workspacePlaceholder.waiting
: contextVariant === "task-center"
? copy.workspacePlaceholder.taskCenter
: copy.workspacePlaceholder.default;
const leftExtra = shouldShowLeftExtra ? (
<>
{knowledgePackControl}
@@ -270,9 +288,11 @@ export const InputbarComposerSection: React.FC<
<Badge
variant="outline"
className="h-8 max-w-[240px] items-center overflow-hidden rounded-full border-slate-200/80 bg-white/90 px-3 text-xs font-medium text-slate-600"
title={`当前模型:${currentModelSummary}`}
title={copy.currentModel.title(currentModelSummary)}
>
<span className="mr-1 text-slate-500">模型</span>
<span className="mr-1 text-slate-500">
{copy.currentModel.label}
</span>
<span className="truncate">{trimmedModel}</span>
</Badge>
) : null}
@@ -296,10 +316,10 @@ export const InputbarComposerSection: React.FC<
<MetaToggleButton
type="button"
$checked={showAdvancedControls || hasHighlightedAdvancedPreference}
aria-label={showAdvancedControls ? "收起高级设置" : "展开高级设置"}
aria-label={advancedSettingsLabel}
aria-expanded={showAdvancedControls}
data-testid="inputbar-advanced-toggle"
title={showAdvancedControls ? "收起高级设置" : "展开高级设置"}
title={advancedSettingsLabel}
onClick={() => setShowAdvancedControls((previous) => !previous)}
>
<MetaToggleCheck
@@ -309,7 +329,7 @@ export const InputbarComposerSection: React.FC<
<MetaToggleGlyph aria-hidden>
<Settings2 strokeWidth={1.8} />
</MetaToggleGlyph>
<MetaToggleLabel>高级设置</MetaToggleLabel>
<MetaToggleLabel>{copy.advancedSettings.label}</MetaToggleLabel>
{showAdvancedControls ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden />
) : (
@@ -322,10 +342,8 @@ export const InputbarComposerSection: React.FC<
<MetaIconButton
type="button"
$active={fileManagerOpen}
aria-label={
fileManagerOpen ? "关闭左侧文件管理器" : "打开左侧文件管理器"
}
title={fileManagerOpen ? "关闭左侧文件管理器" : "打开左侧文件管理器"}
aria-label={fileManagerLabel}
title={fileManagerLabel}
data-testid="inputbar-file-manager-toggle"
onClick={onToggleFileManager}
>
@@ -434,6 +452,7 @@ export const InputbarComposerSection: React.FC<
inputCompletionEnabled={inputCompletionEnabled}
/>
<InputbarCore
uiCopy={inputbarCopy}
textareaRef={textareaRef}
text={inputAdapter.state.text}
setText={inputAdapter.actions.setText}
@@ -451,13 +470,7 @@ export const InputbarComposerSection: React.FC<
onPaste={onPaste}
isFullscreen={isFullscreen}
placeholder={
isWorkspaceVariant
? workflowGate?.status === "waiting"
? "说说你的选择,剩下的交给我"
: contextVariant === "task-center"
? "继续补充这轮生成,或回到左侧继续旧历史"
: "试着输入任何指令,剩下的交给我"
: undefined
isWorkspaceVariant ? workspacePlaceholder : undefined
}
toolMode={isWorkspaceVariant ? "attach-only" : "default"}
showDragHandle={!isWorkspaceVariant}
@@ -469,6 +482,7 @@ export const InputbarComposerSection: React.FC<
onRemoveQueuedTurn={onRemoveQueuedTurn}
leftExtra={leftExtra}
showMetaTools={showAdvancedControls}
listenForVoiceShortcut={isWorkspaceVariant}
/>
</>
);
@@ -3,7 +3,14 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPEN_VOICE_MODEL_SETTINGS_EVENT } from "@/lib/voiceModelSettingsNavigation";
import { changeLimeLocale } from "@/i18n/createI18n";
import agentResource from "@/i18n/resources/zh-CN/agent.json";
import enAgentResource from "@/i18n/resources/en-US/agent.json";
import { InputbarCore } from "./InputbarCore";
import {
buildInputbarCoreCopy,
type InputbarCoreCopyKey,
} from "./inputbarCoreCopy";
const {
mockGetVoiceInputConfig,
@@ -15,7 +22,10 @@ const {
mockGetRecordingSegment,
mockGetRecordingStatus,
mockGetDefaultLocalVoiceModelReadiness,
mockOnVoiceStartRecording,
mockOnVoiceStopRecording,
mockToastError,
mockToastInfo,
} = vi.hoisted(() => ({
mockGetVoiceInputConfig: vi.fn(async () => ({
enabled: true,
@@ -63,7 +73,10 @@ const {
mockGetDefaultLocalVoiceModelReadiness: vi.fn(async () => ({
ready: true,
})),
mockOnVoiceStartRecording: vi.fn(),
mockOnVoiceStopRecording: vi.fn(),
mockToastError: vi.fn(),
mockToastInfo: vi.fn(),
}));
vi.mock("./InputbarTools", () => ({
@@ -85,10 +98,15 @@ vi.mock("@/lib/api/voiceModels", () => ({
getDefaultLocalVoiceModelReadiness: mockGetDefaultLocalVoiceModelReadiness,
}));
vi.mock("@/lib/api/voiceShortcutEvents", () => ({
onVoiceStartRecording: mockOnVoiceStartRecording,
onVoiceStopRecording: mockOnVoiceStopRecording,
}));
vi.mock("sonner", () => ({
toast: {
error: mockToastError,
info: vi.fn(),
info: mockToastInfo,
},
}));
@@ -101,12 +119,35 @@ vi.mock("@/hooks/useVoiceSound", () => ({
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
function translateResource(
resource: Partial<Record<InputbarCoreCopyKey, string>>,
key: InputbarCoreCopyKey,
values?: Record<string, number | string>,
) {
return Object.entries(values ?? {}).reduce(
(text, [name, value]) =>
text.split(`{{${name}}}`).join(String(value)),
resource[key] ?? key,
);
}
const TEST_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) =>
translateResource(agentResource, key, values),
);
const TEST_EN_INPUTBAR_CORE_COPY = buildInputbarCoreCopy((key, values) =>
translateResource(enAgentResource, key, values),
);
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
await changeLimeLocale("zh-CN");
mockOnVoiceStartRecording.mockImplementation(async () => vi.fn());
mockOnVoiceStopRecording.mockImplementation(async () => vi.fn());
});
afterEach(() => {
@@ -123,7 +164,11 @@ afterEach(() => {
});
const renderInputbarCore = async (
props?: Partial<React.ComponentProps<typeof InputbarCore>>,
props?: Partial<
Omit<React.ComponentProps<typeof InputbarCore>, "uiCopy">
> & {
uiCopy?: React.ComponentProps<typeof InputbarCore>["uiCopy"];
},
) => {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -132,6 +177,7 @@ const renderInputbarCore = async (
act(() => {
root.render(
<InputbarCore
uiCopy={TEST_INPUTBAR_CORE_COPY}
text=""
setText={vi.fn()}
onSend={vi.fn()}
@@ -202,6 +248,39 @@ describe("InputbarCore", () => {
).toBeTruthy();
});
it("应支持使用 en-US copy 渲染输入区 chrome 文案", async () => {
const container = await renderInputbarCore({
uiCopy: TEST_EN_INPUTBAR_CORE_COPY,
visualVariant: "default",
toolMode: "default",
isLoading: true,
queuedTurns: [
{
queued_turn_id: "queued-en-1",
message_preview: "Follow-up brief",
message_text: "Queue body",
created_at: 1700000000000,
image_count: 0,
position: 1,
},
],
});
expect(
container.querySelector('button[aria-label="Add image"]'),
).toBeTruthy();
expect(
container.querySelector('button[aria-label="Expand input"]'),
).toBeTruthy();
expect(
container.querySelector('button[aria-label="Send"]'),
).toBeNull();
expect(container.textContent).toContain("Handle later");
expect(
container.querySelector('button[aria-label="Stop"]'),
).toBeTruthy();
});
it("添加路径引用时应显示 chip 并允许移除", async () => {
const onRemovePathReference = vi.fn();
const container = await renderInputbarCore({
@@ -545,6 +624,7 @@ describe("InputbarCore", () => {
expect(mockGetDefaultLocalVoiceModelReadiness).toHaveBeenCalledTimes(1);
expect(mockStartRecording).not.toHaveBeenCalled();
expect(mockToastInfo).toHaveBeenCalledWith("先下载语音模型");
expect(container.querySelector('[aria-live="polite"]')).toBeNull();
expect(navigationRequests).toEqual([
expect.objectContaining({
@@ -555,6 +635,100 @@ describe("InputbarCore", () => {
]);
});
it("语音输入未启用反馈应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
mockGetVoiceInputConfig.mockResolvedValueOnce({
enabled: false,
shortcut: "Alt+Space",
processor: {
polish_enabled: true,
default_instruction_id: "default",
},
output: {
mode: "type",
type_delay_ms: 0,
},
instructions: [],
sound_enabled: false,
translate_instruction_id: "",
});
const container = await renderInputbarCore({
uiCopy: TEST_EN_INPUTBAR_CORE_COPY,
visualVariant: "default",
toolMode: "default",
});
const micButton = container.querySelector(
'button[aria-label="Start voice input"]',
) as HTMLButtonElement | null;
expect(micButton).toBeTruthy();
await act(async () => {
micButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(mockToastInfo).toHaveBeenCalledWith(
"Enable voice input in Settings first",
);
expect(mockGetDefaultLocalVoiceModelReadiness).not.toHaveBeenCalled();
expect(mockStartRecording).not.toHaveBeenCalled();
});
it("启用快捷键监听后应复用输入框录音状态机完成开始和停止", async () => {
let startHandler: (() => void | Promise<void>) | null = null;
let stopHandler: (() => void | Promise<void>) | null = null;
const setText = vi.fn();
mockOnVoiceStartRecording.mockImplementationOnce(async (handler) => {
startHandler = handler;
return vi.fn();
});
mockOnVoiceStopRecording.mockImplementationOnce(async (handler) => {
stopHandler = handler;
return vi.fn();
});
await renderInputbarCore({
setText,
listenForVoiceShortcut: true,
visualVariant: "default",
toolMode: "default",
});
await act(async () => {
await startHandler?.();
await Promise.resolve();
await Promise.resolve();
});
expect(mockStartRecording).toHaveBeenCalledTimes(1);
await act(async () => {
await stopHandler?.();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(mockStopRecording).toHaveBeenCalledTimes(1);
expect(mockTranscribeAudio).toHaveBeenCalled();
expect(mockPolishVoiceText).toHaveBeenCalledWith("原始识别文本");
expect(setText).toHaveBeenLastCalledWith("润色后的文本");
});
it("未启用快捷键监听时不应注册语音快捷键事件", async () => {
await renderInputbarCore({
listenForVoiceShortcut: false,
visualVariant: "default",
toolMode: "default",
});
expect(mockOnVoiceStartRecording).not.toHaveBeenCalled();
expect(mockOnVoiceStopRecording).not.toHaveBeenCalled();
});
it("生成中应显示稍后处理与停止按钮,并渲染待处理列表", async () => {
const onSend = vi.fn();
const onStop = vi.fn();
@@ -50,6 +50,7 @@ import type { MessageImage, MessagePathReference } from "../../../types";
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
import { QueuedTurnsPanel } from "./QueuedTurnsPanel";
import { useInputbarDictation } from "../hooks/useInputbarDictation";
import type { InputbarCoreCopy } from "./inputbarCoreCopy";
const INTERACTIVE_TARGET_SELECTOR =
"button, a, input, textarea, select, option, [role='button'], [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only']";
@@ -63,15 +64,16 @@ function formatDictationDuration(duration = 0): string {
function buildDictationStatusText(
state: "idle" | "listening" | "transcribing" | "polishing",
copy: InputbarCoreCopy,
duration = 0,
): string {
switch (state) {
case "listening":
return `录音中 ${formatDictationDuration(duration)}`;
return copy.dictation.recording(formatDictationDuration(duration));
case "transcribing":
return "识别中";
return copy.dictation.transcribing;
case "polishing":
return "润色中";
return copy.dictation.polishing;
case "idle":
default:
return "";
@@ -86,6 +88,7 @@ function shouldFocusComposerTextarea(target: EventTarget | null): boolean {
}
interface InputbarCoreProps {
uiCopy: InputbarCoreCopy;
text: string;
setText: (text: string) => void;
onSend: () => void;
@@ -135,9 +138,11 @@ interface InputbarCoreProps {
prompt: string;
testId?: string;
}) => void;
listenForVoiceShortcut?: boolean;
}
export const InputbarCore: React.FC<InputbarCoreProps> = ({
uiCopy,
text,
setText,
onSend,
@@ -170,6 +175,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
showMetaTools = true,
inputSuggestion = null,
onAcceptInputSuggestion,
listenForVoiceShortcut = false,
}) => {
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
const inputBarContainerRef = useRef<HTMLDivElement | null>(null);
@@ -191,6 +197,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
setText,
textareaRef: resolvedTextareaRef,
disabled,
listenForVoiceShortcut,
});
const hasInlineComposerContent =
text.trim().length > 0 ||
@@ -247,21 +254,24 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
Boolean(inputSuggestion) && text.trim().length === 0 && !disabled;
const dictationStatusText = buildDictationStatusText(
dictationState,
uiCopy,
recordingStatus?.duration,
);
const dictationStatusLabel =
dictationState === "listening" && liveTranscript
? `${dictationStatusText} · 实时识别`
? `${dictationStatusText} · ${uiCopy.dictation.liveTranscript}`
: dictationStatusText;
const dictationButtonTitle = isDictationProcessing
? dictationState === "polishing"
? "语音润色中"
: "语音识别中"
? uiCopy.dictation.polishingTitle
: uiCopy.dictation.transcribingTitle
: isDictating
? `${dictationStatusLabel || "录音中"},点击停止`
? uiCopy.dictation.stopRecording(
dictationStatusLabel || uiCopy.dictation.recordingLabel,
)
: dictationEnabled || !voiceConfigLoaded
? "开始语音输入"
: "语音输入未启用";
? uiCopy.dictation.start
: uiCopy.dictation.disabled;
const handleInputSuggestionKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -394,8 +404,8 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
? ""
: placeholder ||
(isFullscreen
? "全屏编辑模式,按 ESC 退出,Enter 发送"
: "在这里输入消息, 按 Enter 发送")
? uiCopy.placeholder.fullscreen
: uiCopy.placeholder.default)
}
>
{({ textareaProps, textareaRef, isPrimaryDisabled, onPrimaryAction }) => {
@@ -432,11 +442,11 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
<ImagePreviewItem key={index}>
<ImagePreviewImg
src={`data:${img.mediaType};base64,${img.data}`}
alt={`预览 ${index + 1}`}
alt={uiCopy.image.previewAlt(index + 1)}
/>
<ImageRemoveButton
type="button"
aria-label={`移除图片 ${index + 1}`}
aria-label={uiCopy.image.remove(index + 1)}
onMouseDown={handleRemoveImageMouseDown}
onClick={(event) =>
handleRemoveImageClick(event, index)
@@ -450,7 +460,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
)}
{pathReferences.length > 0 ? (
<PathReferenceContainer aria-label="已添加的本地路径">
<PathReferenceContainer aria-label={uiCopy.path.containerLabel}>
{pathReferences.map((reference) => {
const ReferenceIcon = reference.isDir ? Folder : FileText;
return (
@@ -467,26 +477,30 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
{reference.name}
</PathReferenceName>
<PathReferencePath>
{reference.isDir ? "本地文件夹" : "本地文件"}
{reference.isDir
? uiCopy.path.localFolder
: uiCopy.path.localFile}
</PathReferencePath>
</PathReferenceText>
{onImportPathReferenceAsKnowledge &&
isKnowledgeTextSourceCandidate(reference) ? (
<PathReferenceKnowledgeButton
type="button"
aria-label={`设为项目资料 ${reference.name}`}
aria-label={uiCopy.path.importAsKnowledge(
reference.name,
)}
onMouseDown={handleImportPathReferenceMouseDown}
onClick={(event) =>
handleImportPathReferenceClick(event, reference)
}
>
<FileText size={12} aria-hidden />
设为资料
{uiCopy.path.importAction}
</PathReferenceKnowledgeButton>
) : null}
<PathReferenceRemoveButton
type="button"
aria-label={`移除路径 ${reference.name}`}
aria-label={uiCopy.path.remove(reference.name)}
onMouseDown={handleRemovePathReferenceMouseDown}
onClick={(event) =>
handleRemovePathReferenceClick(event, reference.id)
@@ -511,8 +525,8 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
<InputIconButton
type="button"
onClick={() => onToolClick("attach")}
aria-label="添加图片"
title="添加图片"
aria-label={uiCopy.image.add}
title={uiCopy.image.add}
>
<ImagePlus size={14} />
</InputIconButton>
@@ -521,7 +535,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
<InputSuggestionLayer
className={textareaClassName}
data-testid="home-input-tab-suggestion"
title="按 Tab 使用这条起手建议"
title={uiCopy.suggestion.acceptTitle}
>
<InputSuggestionText>
{inputSuggestion.label}
@@ -542,9 +556,15 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
disabled={disabled}
className={isTextareaExpanded ? "is-active" : ""}
aria-label={
isTextareaExpanded ? "收起输入框" : "展开输入框"
isTextareaExpanded
? uiCopy.textarea.collapse
: uiCopy.textarea.expand
}
title={
isTextareaExpanded
? uiCopy.textarea.collapse
: uiCopy.textarea.expand
}
title={isTextareaExpanded ? "收起输入框" : "展开输入框"}
>
{isTextareaExpanded ? (
<ChevronDown size={14} />
@@ -585,7 +605,7 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
onClick={onPrimaryAction}
disabled={isPrimaryDisabled}
>
<span>稍后处理</span>
<span>{uiCopy.action.defer}</span>
</SecondaryActionButton>
) : null}
{isLoading ? (
@@ -594,8 +614,8 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
onClick={onStop}
disabled={!onStop}
$destructive
aria-label="停止"
title="停止"
aria-label={uiCopy.action.stop}
title={uiCopy.action.stop}
>
<Square size={14} fill="currentColor" />
</InputIconButton>
@@ -605,8 +625,8 @@ export const InputbarCore: React.FC<InputbarCoreProps> = ({
type="button"
onClick={onPrimaryAction}
disabled={isPrimaryDisabled}
aria-label="发送"
title="发送"
aria-label={uiCopy.action.send}
title={uiCopy.action.send}
>
<ArrowUp size={16} strokeWidth={2.4} />
</SendButton>
@@ -0,0 +1,105 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
import { InputbarTools } from "./InputbarTools";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
await changeLimeLocale("zh-CN");
});
afterEach(() => {
while (mountedRoots.length > 0) {
const mounted = mountedRoots.pop();
if (!mounted) break;
act(() => {
mounted.root.unmount();
});
mounted.container.remove();
}
vi.clearAllMocks();
});
function renderInputbarTools(
props?: Partial<React.ComponentProps<typeof InputbarTools>>,
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const defaultProps: React.ComponentProps<typeof InputbarTools> = {
onToolClick: vi.fn(),
activeTools: {},
};
act(() => {
root.render(<InputbarTools {...defaultProps} {...props} />);
});
mountedRoots.push({ root, container });
return {
container,
onToolClick: props?.onToolClick ?? defaultProps.onToolClick,
};
}
describe("InputbarTools", () => {
it("工具开关 chrome 文案应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const onToolClick = vi.fn();
const { container } = renderInputbarTools({
onToolClick,
activeTheme: "general",
activeTools: {
thinking: false,
web_search: true,
subagent_mode: false,
},
});
expect(container.textContent).toContain("Thinking");
expect(container.textContent).toContain("Search");
expect(container.textContent).toContain("Task split");
expect(container.textContent).not.toContain("思考");
expect(container.textContent).not.toContain("任务拆分");
const buttons = Array.from(container.querySelectorAll("button"));
expect(buttons.map((button) => button.getAttribute("title"))).toEqual([
"Deep thinking is off",
"Web search is on",
"Task splitting preference is off",
]);
act(() => {
buttons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttons[2]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onToolClick).toHaveBeenNthCalledWith(1, "thinking");
expect(onToolClick).toHaveBeenNthCalledWith(2, "web_search");
expect(onToolClick).toHaveBeenNthCalledWith(3, "subagent_mode");
});
it("attach-only 模式不渲染工具开关", () => {
const { container } = renderInputbarTools({
toolMode: "attach-only",
activeTheme: "general",
activeTools: {
thinking: true,
web_search: true,
subagent_mode: true,
},
});
expect(container.querySelectorAll("button")).toHaveLength(0);
expect(container.textContent).toBe("");
});
});
@@ -1,7 +1,9 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Lightbulb, Globe, Workflow } from "lucide-react";
import { ToolButton } from "../styles";
import { isGeneralResearchTheme } from "../../../utils/generalAgentPrompt";
import { buildInputbarToolsCopy } from "./inputbarToolsCopy";
interface InputbarToolsProps {
onToolClick: (tool: string) => void;
@@ -16,7 +18,15 @@ export const InputbarTools: React.FC<InputbarToolsProps> = ({
toolMode = "default",
activeTheme,
}) => {
const { t } = useTranslation("agent");
const copy = React.useMemo(
() => buildInputbarToolsCopy((key) => t(key, {})),
[t],
);
const isGeneralTheme = isGeneralResearchTheme(activeTheme);
const thinkingEnabled = Boolean(activeTools["thinking"]);
const webSearchEnabled = Boolean(activeTools["web_search"]);
const subagentEnabled = Boolean(activeTools["subagent_mode"]);
return (
<div className="flex items-center flex-wrap gap-2">
@@ -25,24 +35,24 @@ export const InputbarTools: React.FC<InputbarToolsProps> = ({
<ToolButton
type="button"
onClick={() => onToolClick("thinking")}
className={activeTools["thinking"] ? "active" : ""}
aria-pressed={activeTools["thinking"]}
title={`深度思考${activeTools["thinking"] ? "已开启" : "已关闭"}`}
className={thinkingEnabled ? "active" : ""}
aria-pressed={thinkingEnabled}
title={copy.thinking.title(thinkingEnabled)}
>
<Lightbulb />
<span>思考</span>
<span>{copy.thinking.label}</span>
</ToolButton>
<ToolButton
type="button"
onClick={() => onToolClick("web_search")}
className={activeTools["web_search"] ? "active" : ""}
aria-pressed={activeTools["web_search"]}
title={`联网搜索${activeTools["web_search"] ? "已开启" : "已关闭"}`}
className={webSearchEnabled ? "active" : ""}
aria-pressed={webSearchEnabled}
title={copy.webSearch.title(webSearchEnabled)}
data-testid="toggle-web-search"
>
<Globe />
<span>搜索</span>
<span>{copy.webSearch.label}</span>
</ToolButton>
{isGeneralTheme ? (
@@ -50,13 +60,13 @@ export const InputbarTools: React.FC<InputbarToolsProps> = ({
<ToolButton
type="button"
onClick={() => onToolClick("subagent_mode")}
className={activeTools["subagent_mode"] ? "active" : ""}
aria-pressed={activeTools["subagent_mode"]}
title={`任务拆分偏好${activeTools["subagent_mode"] ? "已开启" : "已关闭"}`}
className={subagentEnabled ? "active" : ""}
aria-pressed={subagentEnabled}
title={copy.subagent.title(subagentEnabled)}
data-testid="toggle-subagent-mode"
>
<Workflow />
<span>任务拆分</span>
<span>{copy.subagent.label}</span>
</ToolButton>
</>
) : null}
@@ -2,16 +2,18 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
import { QueuedTurnsPanel } from "./QueuedTurnsPanel";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
await changeLimeLocale("zh-CN");
});
afterEach(() => {
@@ -56,6 +58,36 @@ function renderQueuedTurnsPanel(
}
describe("QueuedTurnsPanel", () => {
it("排队消息 chrome 文案应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const container = renderQueuedTurnsPanel({
queuedTurns: [
{
queued_turn_id: "queued-en-1",
message_preview: "",
message_text: "",
created_at: 1_700_000_000_000,
image_count: 2,
position: 1,
},
],
});
expect(container.textContent).toContain("Handle later 1");
expect(container.textContent).toContain("Starts in order");
expect(container.textContent).toContain("Blank input");
expect(container.textContent).toContain("2 images attached");
expect(container.textContent).toContain("View");
expect(
container.querySelector('button[aria-label="Run queued message now"]'),
).toBeTruthy();
expect(
container.querySelector('button[aria-label="Remove queued message"]'),
).toBeTruthy();
expect(container.textContent).not.toContain("稍后处理");
expect(container.textContent).not.toContain("附图");
});
it("应展示立即执行按钮,并触发 promote 回调", async () => {
const onPromoteQueuedTurn = vi.fn().mockResolvedValue(true);
const container = renderQueuedTurnsPanel({ onPromoteQueuedTurn });
@@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, Play, X } from "lucide-react";
import type { QueuedTurnSnapshot } from "@/lib/api/agentRuntime";
import { buildInputbarQueuedTurnsCopy } from "./inputbarQueuedTurnsCopy";
interface QueuedTurnsPanelProps {
queuedTurns: QueuedTurnSnapshot[];
@@ -13,6 +15,14 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
onPromoteQueuedTurn,
onRemoveQueuedTurn,
}) => {
const { t } = useTranslation("agent");
const copy = useMemo(
() =>
buildInputbarQueuedTurnsCopy((key, values) =>
t(key, values ?? {}),
),
[t],
);
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<{
queuedTurnId: string;
@@ -57,14 +67,14 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
return (
<div className="px-3 pb-2">
<div className="mb-2 flex items-center justify-between text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span>稍后处理 {queuedTurns.length}</span>
<span>会依次开始</span>
<span>{copy.queuedCount(queuedTurns.length)}</span>
<span>{copy.sequenceHint}</span>
</div>
<div className="flex flex-col gap-2">
{queuedTurns.map((item) => {
const messageText = item.message_text.trim()
? item.message_text
: item.message_preview || "空白输入";
: item.message_preview || copy.emptyInput;
const title = item.message_preview.trim()
? item.message_preview
: messageText;
@@ -104,13 +114,13 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
{title}
</div>
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
{isExpanded ? "收起" : "查看"}
{isExpanded ? copy.collapse : copy.expand}
</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{item.image_count > 0
? `附图 ${item.image_count} 张`
: "纯文本请求"}
? copy.imageCount(item.image_count)
: copy.textOnly}
</div>
{isExpanded ? (
<div
@@ -130,14 +140,14 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
void runQueuedAction(item.queued_turn_id, "promote")
}
disabled={isBusy}
aria-label="插队立即执行"
aria-label={copy.promoteAria}
>
{isPromoting ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Play size={13} />
)}
<span>{isPromoting ? "切换中" : "立即执行"}</span>
<span>{isPromoting ? copy.promoting : copy.promote}</span>
</button>
<button
type="button"
@@ -146,7 +156,7 @@ export const QueuedTurnsPanel: React.FC<QueuedTurnsPanelProps> = ({
void runQueuedAction(item.queued_turn_id, "remove")
}
disabled={isBusy}
aria-label={isRemoving ? "正在移除排队消息" : "移除排队消息"}
aria-label={isRemoving ? copy.removing : copy.remove}
>
{isRemoving ? (
<Loader2 size={14} className="animate-spin" />
@@ -0,0 +1,73 @@
export type InputbarComposerSectionCopyKey =
| "agentChat.inputbar.composer.advancedSettings.label"
| "agentChat.inputbar.composer.advancedSettings.expand"
| "agentChat.inputbar.composer.advancedSettings.collapse"
| "agentChat.inputbar.composer.currentModel.label"
| "agentChat.inputbar.composer.currentModel.title"
| "agentChat.inputbar.composer.fileManager.open"
| "agentChat.inputbar.composer.fileManager.close"
| "agentChat.inputbar.composer.workspacePlaceholder.waiting"
| "agentChat.inputbar.composer.workspacePlaceholder.taskCenter"
| "agentChat.inputbar.composer.workspacePlaceholder.default";
type InputbarComposerSectionCopyValue = number | string;
export type InputbarComposerSectionCopyTranslate = (
key: InputbarComposerSectionCopyKey,
values?: Record<string, InputbarComposerSectionCopyValue>,
) => string;
export interface InputbarComposerSectionCopy {
advancedSettings: {
label: string;
expand: string;
collapse: string;
};
currentModel: {
label: string;
title: (model: string) => string;
};
fileManager: {
open: string;
close: string;
};
workspacePlaceholder: {
waiting: string;
taskCenter: string;
default: string;
};
}
export function buildInputbarComposerSectionCopy(
translate: InputbarComposerSectionCopyTranslate,
): InputbarComposerSectionCopy {
return {
advancedSettings: {
label: translate("agentChat.inputbar.composer.advancedSettings.label"),
expand: translate("agentChat.inputbar.composer.advancedSettings.expand"),
collapse: translate(
"agentChat.inputbar.composer.advancedSettings.collapse",
),
},
currentModel: {
label: translate("agentChat.inputbar.composer.currentModel.label"),
title: (model) =>
translate("agentChat.inputbar.composer.currentModel.title", { model }),
},
fileManager: {
open: translate("agentChat.inputbar.composer.fileManager.open"),
close: translate("agentChat.inputbar.composer.fileManager.close"),
},
workspacePlaceholder: {
waiting: translate(
"agentChat.inputbar.composer.workspacePlaceholder.waiting",
),
taskCenter: translate(
"agentChat.inputbar.composer.workspacePlaceholder.taskCenter",
),
default: translate(
"agentChat.inputbar.composer.workspacePlaceholder.default",
),
},
};
}
@@ -0,0 +1,149 @@
export type InputbarCoreCopyKey =
| "agentChat.inputbar.core.dictation.recording"
| "agentChat.inputbar.core.dictation.recordingLabel"
| "agentChat.inputbar.core.dictation.transcribing"
| "agentChat.inputbar.core.dictation.polishing"
| "agentChat.inputbar.core.dictation.liveTranscript"
| "agentChat.inputbar.core.dictation.polishingTitle"
| "agentChat.inputbar.core.dictation.transcribingTitle"
| "agentChat.inputbar.core.dictation.stopRecording"
| "agentChat.inputbar.core.dictation.start"
| "agentChat.inputbar.core.dictation.disabled"
| "agentChat.inputbar.core.placeholder.fullscreen"
| "agentChat.inputbar.core.placeholder.default"
| "agentChat.inputbar.core.image.previewAlt"
| "agentChat.inputbar.core.image.remove"
| "agentChat.inputbar.core.image.add"
| "agentChat.inputbar.core.path.containerLabel"
| "agentChat.inputbar.core.path.localFolder"
| "agentChat.inputbar.core.path.localFile"
| "agentChat.inputbar.core.path.importAsKnowledge"
| "agentChat.inputbar.core.path.importAction"
| "agentChat.inputbar.core.path.remove"
| "agentChat.inputbar.core.suggestion.acceptTitle"
| "agentChat.inputbar.core.textarea.expand"
| "agentChat.inputbar.core.textarea.collapse"
| "agentChat.inputbar.core.action.defer"
| "agentChat.inputbar.core.action.stop"
| "agentChat.inputbar.core.action.send";
type InputbarCoreCopyValue = number | string;
export type InputbarCoreCopyTranslate = (
key: InputbarCoreCopyKey,
values?: Record<string, InputbarCoreCopyValue>,
) => string;
export interface InputbarCoreCopy {
dictation: {
recording: (duration: string) => string;
recordingLabel: string;
transcribing: string;
polishing: string;
liveTranscript: string;
polishingTitle: string;
transcribingTitle: string;
stopRecording: (label: string) => string;
start: string;
disabled: string;
};
placeholder: {
fullscreen: string;
default: string;
};
image: {
previewAlt: (index: number) => string;
remove: (index: number) => string;
add: string;
};
path: {
containerLabel: string;
localFolder: string;
localFile: string;
importAsKnowledge: (name: string) => string;
importAction: string;
remove: (name: string) => string;
};
suggestion: {
acceptTitle: string;
};
textarea: {
expand: string;
collapse: string;
};
action: {
defer: string;
stop: string;
send: string;
};
}
export function buildInputbarCoreCopy(
translate: InputbarCoreCopyTranslate,
): InputbarCoreCopy {
return {
dictation: {
recording: (duration) =>
translate("agentChat.inputbar.core.dictation.recording", {
duration,
}),
recordingLabel: translate(
"agentChat.inputbar.core.dictation.recordingLabel",
),
transcribing: translate(
"agentChat.inputbar.core.dictation.transcribing",
),
polishing: translate("agentChat.inputbar.core.dictation.polishing"),
liveTranscript: translate(
"agentChat.inputbar.core.dictation.liveTranscript",
),
polishingTitle: translate(
"agentChat.inputbar.core.dictation.polishingTitle",
),
transcribingTitle: translate(
"agentChat.inputbar.core.dictation.transcribingTitle",
),
stopRecording: (label) =>
translate("agentChat.inputbar.core.dictation.stopRecording", {
label,
}),
start: translate("agentChat.inputbar.core.dictation.start"),
disabled: translate("agentChat.inputbar.core.dictation.disabled"),
},
placeholder: {
fullscreen: translate("agentChat.inputbar.core.placeholder.fullscreen"),
default: translate("agentChat.inputbar.core.placeholder.default"),
},
image: {
previewAlt: (index) =>
translate("agentChat.inputbar.core.image.previewAlt", { index }),
remove: (index) =>
translate("agentChat.inputbar.core.image.remove", { index }),
add: translate("agentChat.inputbar.core.image.add"),
},
path: {
containerLabel: translate(
"agentChat.inputbar.core.path.containerLabel",
),
localFolder: translate("agentChat.inputbar.core.path.localFolder"),
localFile: translate("agentChat.inputbar.core.path.localFile"),
importAsKnowledge: (name) =>
translate("agentChat.inputbar.core.path.importAsKnowledge", { name }),
importAction: translate("agentChat.inputbar.core.path.importAction"),
remove: (name) =>
translate("agentChat.inputbar.core.path.remove", { name }),
},
suggestion: {
acceptTitle: translate("agentChat.inputbar.core.suggestion.acceptTitle"),
},
textarea: {
expand: translate("agentChat.inputbar.core.textarea.expand"),
collapse: translate("agentChat.inputbar.core.textarea.collapse"),
},
action: {
defer: translate("agentChat.inputbar.core.action.defer"),
stop: translate("agentChat.inputbar.core.action.stop"),
send: translate("agentChat.inputbar.core.action.send"),
},
};
}
@@ -0,0 +1,60 @@
export type InputbarQueuedTurnsCopyKey =
| "agentChat.inputbar.queuedTurns.header.count"
| "agentChat.inputbar.queuedTurns.header.sequenceHint"
| "agentChat.inputbar.queuedTurns.emptyInput"
| "agentChat.inputbar.queuedTurns.action.expand"
| "agentChat.inputbar.queuedTurns.action.collapse"
| "agentChat.inputbar.queuedTurns.meta.imageCount"
| "agentChat.inputbar.queuedTurns.meta.textOnly"
| "agentChat.inputbar.queuedTurns.action.promoteAria"
| "agentChat.inputbar.queuedTurns.action.promoting"
| "agentChat.inputbar.queuedTurns.action.promote"
| "agentChat.inputbar.queuedTurns.action.remove"
| "agentChat.inputbar.queuedTurns.action.removing";
type InputbarQueuedTurnsCopyValue = number | string;
export type InputbarQueuedTurnsCopyTranslate = (
key: InputbarQueuedTurnsCopyKey,
values?: Record<string, InputbarQueuedTurnsCopyValue>,
) => string;
export interface InputbarQueuedTurnsCopy {
queuedCount: (count: number) => string;
sequenceHint: string;
emptyInput: string;
expand: string;
collapse: string;
imageCount: (count: number) => string;
textOnly: string;
promoteAria: string;
promoting: string;
promote: string;
remove: string;
removing: string;
}
export function buildInputbarQueuedTurnsCopy(
translate: InputbarQueuedTurnsCopyTranslate,
): InputbarQueuedTurnsCopy {
return {
queuedCount: (count) =>
translate("agentChat.inputbar.queuedTurns.header.count", { count }),
sequenceHint: translate(
"agentChat.inputbar.queuedTurns.header.sequenceHint",
),
emptyInput: translate("agentChat.inputbar.queuedTurns.emptyInput"),
expand: translate("agentChat.inputbar.queuedTurns.action.expand"),
collapse: translate("agentChat.inputbar.queuedTurns.action.collapse"),
imageCount: (count) =>
translate("agentChat.inputbar.queuedTurns.meta.imageCount", { count }),
textOnly: translate("agentChat.inputbar.queuedTurns.meta.textOnly"),
promoteAria: translate(
"agentChat.inputbar.queuedTurns.action.promoteAria",
),
promoting: translate("agentChat.inputbar.queuedTurns.action.promoting"),
promote: translate("agentChat.inputbar.queuedTurns.action.promote"),
remove: translate("agentChat.inputbar.queuedTurns.action.remove"),
removing: translate("agentChat.inputbar.queuedTurns.action.removing"),
};
}
@@ -0,0 +1,123 @@
export type InputbarToolsCopyKey =
| "agentChat.inputbar.tools.thinking.label"
| "agentChat.inputbar.tools.thinking.title.enabled"
| "agentChat.inputbar.tools.thinking.title.disabled"
| "agentChat.inputbar.tools.thinking.toast.enabled"
| "agentChat.inputbar.tools.thinking.toast.disabled"
| "agentChat.inputbar.tools.webSearch.label"
| "agentChat.inputbar.tools.webSearch.title.enabled"
| "agentChat.inputbar.tools.webSearch.title.disabled"
| "agentChat.inputbar.tools.webSearch.toast.enabled"
| "agentChat.inputbar.tools.webSearch.toast.disabled"
| "agentChat.inputbar.tools.subagent.label"
| "agentChat.inputbar.tools.subagent.title.enabled"
| "agentChat.inputbar.tools.subagent.title.disabled"
| "agentChat.inputbar.tools.subagent.toast.enabled"
| "agentChat.inputbar.tools.subagent.toast.disabled"
| "agentChat.inputbar.tools.fullscreen.toast.entered"
| "agentChat.inputbar.tools.fullscreen.toast.exited";
export type InputbarToolsCopyTranslate = (
key: InputbarToolsCopyKey,
) => string;
interface InputbarToolToggleCopy {
label: string;
title: (enabled: boolean) => string;
toast: (enabled: boolean) => string;
}
export interface InputbarToolsCopy {
thinking: InputbarToolToggleCopy;
webSearch: InputbarToolToggleCopy;
subagent: InputbarToolToggleCopy;
fullscreen: {
entered: string;
exited: string;
};
}
function chooseToggleCopy(
enabled: boolean,
enabledText: string,
disabledText: string,
): string {
return enabled ? enabledText : disabledText;
}
export function buildInputbarToolsCopy(
translate: InputbarToolsCopyTranslate,
): InputbarToolsCopy {
const thinkingTitleEnabled = translate(
"agentChat.inputbar.tools.thinking.title.enabled",
);
const thinkingTitleDisabled = translate(
"agentChat.inputbar.tools.thinking.title.disabled",
);
const thinkingToastEnabled = translate(
"agentChat.inputbar.tools.thinking.toast.enabled",
);
const thinkingToastDisabled = translate(
"agentChat.inputbar.tools.thinking.toast.disabled",
);
const webSearchTitleEnabled = translate(
"agentChat.inputbar.tools.webSearch.title.enabled",
);
const webSearchTitleDisabled = translate(
"agentChat.inputbar.tools.webSearch.title.disabled",
);
const webSearchToastEnabled = translate(
"agentChat.inputbar.tools.webSearch.toast.enabled",
);
const webSearchToastDisabled = translate(
"agentChat.inputbar.tools.webSearch.toast.disabled",
);
const subagentTitleEnabled = translate(
"agentChat.inputbar.tools.subagent.title.enabled",
);
const subagentTitleDisabled = translate(
"agentChat.inputbar.tools.subagent.title.disabled",
);
const subagentToastEnabled = translate(
"agentChat.inputbar.tools.subagent.toast.enabled",
);
const subagentToastDisabled = translate(
"agentChat.inputbar.tools.subagent.toast.disabled",
);
return {
thinking: {
label: translate("agentChat.inputbar.tools.thinking.label"),
title: (enabled) =>
chooseToggleCopy(enabled, thinkingTitleEnabled, thinkingTitleDisabled),
toast: (enabled) =>
chooseToggleCopy(enabled, thinkingToastEnabled, thinkingToastDisabled),
},
webSearch: {
label: translate("agentChat.inputbar.tools.webSearch.label"),
title: (enabled) =>
chooseToggleCopy(
enabled,
webSearchTitleEnabled,
webSearchTitleDisabled,
),
toast: (enabled) =>
chooseToggleCopy(
enabled,
webSearchToastEnabled,
webSearchToastDisabled,
),
},
subagent: {
label: translate("agentChat.inputbar.tools.subagent.label"),
title: (enabled) =>
chooseToggleCopy(enabled, subagentTitleEnabled, subagentTitleDisabled),
toast: (enabled) =>
chooseToggleCopy(enabled, subagentToastEnabled, subagentToastDisabled),
},
fullscreen: {
entered: translate("agentChat.inputbar.tools.fullscreen.toast.entered"),
exited: translate("agentChat.inputbar.tools.fullscreen.toast.exited"),
},
};
}
@@ -0,0 +1,25 @@
export type InputbarControllerCopyKey =
| "agentChat.inputbar.controller.toast.systemPathDropUnsupported"
| "agentChat.inputbar.controller.curatedTask.reviewSuggestionPrefillHint";
export type InputbarControllerCopyTranslate = (
key: InputbarControllerCopyKey,
) => string;
export interface InputbarControllerCopy {
systemPathDropUnsupported: string;
curatedTaskReviewSuggestionPrefillHint: string;
}
export function buildInputbarControllerCopy(
translate: InputbarControllerCopyTranslate,
): InputbarControllerCopy {
return {
systemPathDropUnsupported: translate(
"agentChat.inputbar.controller.toast.systemPathDropUnsupported",
),
curatedTaskReviewSuggestionPrefillHint: translate(
"agentChat.inputbar.controller.curatedTask.reviewSuggestionPrefillHint",
),
};
}
@@ -0,0 +1,44 @@
export type InputbarDictationCopyKey =
| "agentChat.inputbar.dictation.feedback.unavailable"
| "agentChat.inputbar.dictation.feedback.enableInSettings"
| "agentChat.inputbar.dictation.feedback.downloadVoiceModel"
| "agentChat.inputbar.dictation.feedback.startFailed"
| "agentChat.inputbar.dictation.feedback.tooShort"
| "agentChat.inputbar.dictation.feedback.emptyTranscript"
| "agentChat.inputbar.dictation.feedback.recognitionFailed";
export type InputbarDictationCopyTranslate = (
key: InputbarDictationCopyKey,
) => string;
export interface InputbarDictationCopy {
unavailable: string;
enableInSettings: string;
downloadVoiceModel: string;
startFailed: string;
tooShort: string;
emptyTranscript: string;
recognitionFailed: string;
}
export function buildInputbarDictationCopy(
translate: InputbarDictationCopyTranslate,
): InputbarDictationCopy {
return {
unavailable: translate("agentChat.inputbar.dictation.feedback.unavailable"),
enableInSettings: translate(
"agentChat.inputbar.dictation.feedback.enableInSettings",
),
downloadVoiceModel: translate(
"agentChat.inputbar.dictation.feedback.downloadVoiceModel",
),
startFailed: translate("agentChat.inputbar.dictation.feedback.startFailed"),
tooShort: translate("agentChat.inputbar.dictation.feedback.tooShort"),
emptyTranscript: translate(
"agentChat.inputbar.dictation.feedback.emptyTranscript",
),
recognitionFailed: translate(
"agentChat.inputbar.dictation.feedback.recognitionFailed",
),
};
}
@@ -0,0 +1,36 @@
export type InputbarImageAttachmentsCopyKey =
| "agentChat.inputbar.imageAttachments.unnamedImage"
| "agentChat.inputbar.imageAttachments.imageReadFailed"
| "agentChat.inputbar.imageAttachments.imageAdded"
| "agentChat.inputbar.imageAttachments.imagePasted";
type InputbarImageAttachmentsCopyValue = number | string;
export type InputbarImageAttachmentsCopyTranslate = (
key: InputbarImageAttachmentsCopyKey,
values?: Record<string, InputbarImageAttachmentsCopyValue>,
) => string;
export interface InputbarImageAttachmentsCopy {
unnamedImage: string;
imageReadFailed: (fileName: string) => string;
imageAdded: (fileName: string) => string;
imagePasted: string;
}
export function buildInputbarImageAttachmentsCopy(
translate: InputbarImageAttachmentsCopyTranslate,
): InputbarImageAttachmentsCopy {
return {
unnamedImage: translate("agentChat.inputbar.imageAttachments.unnamedImage"),
imageReadFailed: (fileName) =>
translate("agentChat.inputbar.imageAttachments.imageReadFailed", {
fileName,
}),
imageAdded: (fileName) =>
translate("agentChat.inputbar.imageAttachments.imageAdded", {
fileName,
}),
imagePasted: translate("agentChat.inputbar.imageAttachments.imagePasted"),
};
}
@@ -2,6 +2,7 @@ import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { changeLimeLocale } from "@/i18n/createI18n";
import { useImageAttachments } from "./useImageAttachments";
const { toastMock } = vi.hoisted(() => ({
@@ -136,7 +137,7 @@ function renderHarness(): HTMLDivElement {
return container;
}
beforeEach(() => {
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
@@ -144,6 +145,7 @@ beforeEach(() => {
}
).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.FileReader = MockFileReader as never;
await changeLimeLocale("zh-CN");
});
afterEach(() => {
@@ -224,6 +226,24 @@ describe("useImageAttachments", () => {
expect(toastMock.success).toHaveBeenCalledWith("已粘贴图片");
});
it("图片粘贴反馈应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const container = renderHarness();
const pasteButton = container.querySelector(
'[data-testid="paste-image"]',
) as HTMLButtonElement | null;
await act(async () => {
pasteButton?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="image-count"]')?.textContent,
).toBe("1");
expect(toastMock.success).toHaveBeenCalledWith("Image pasted");
});
it("单轮最多保留 20 张待发送图片", async () => {
const container = renderHarness();
const pasteButton = container.querySelector(
@@ -5,7 +5,9 @@ import {
type ChangeEvent,
type ClipboardEvent,
type DragEvent,
useMemo,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { MessageImage } from "../../../types";
import {
@@ -13,8 +15,17 @@ import {
MAX_IMAGE_ATTACHMENTS_PER_TURN,
readImageAttachment,
} from "../../../utils/imageAttachments";
import { buildInputbarImageAttachmentsCopy } from "./inputbarImageAttachmentsCopy";
export function useImageAttachments() {
const { t } = useTranslation("agent");
const copy = useMemo(
() =>
buildInputbarImageAttachmentsCopy((key, values) =>
t(key, values ?? {}),
),
[t],
);
const [pendingImages, setPendingImages] = useState<MessageImage[]>([]);
const pendingImagesRef = useRef<MessageImage[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -30,27 +41,26 @@ export function useImageAttachments() {
successMessage?: string,
preferredMediaType?: string,
) => {
const fileName = file.name || copy.unnamedImage;
try {
if (pendingImagesRef.current.length >= MAX_IMAGE_ATTACHMENTS_PER_TURN) {
toast.error(`图片读取失败: ${file.name || "未命名图片"}`);
toast.error(copy.imageReadFailed(fileName));
return;
}
const image = await readImageAttachment(file, preferredMediaType);
if (pendingImagesRef.current.length >= MAX_IMAGE_ATTACHMENTS_PER_TURN) {
toast.error(`图片读取失败: ${file.name || "未命名图片"}`);
toast.error(copy.imageReadFailed(fileName));
return;
}
setPendingImagesSnapshot([...pendingImagesRef.current, image]);
toast.success(
successMessage ?? `已添加图片: ${file.name || "未命名图片"}`,
);
toast.success(successMessage ?? copy.imageAdded(fileName));
} catch {
toast.error(`图片读取失败: ${file.name || "未命名图片"}`);
toast.error(copy.imageReadFailed(fileName));
}
},
[setPendingImagesSnapshot],
[copy, setPendingImagesSnapshot],
);
const appendImageFiles = useCallback(
@@ -86,12 +96,12 @@ export function useImageAttachments() {
imageFiles.forEach(({ file, mediaType }, index) => {
void appendImageFile(
file,
index === 0 ? "已粘贴图片" : undefined,
index === 0 ? copy.imagePasted : undefined,
mediaType,
);
});
},
[appendImageFile],
[appendImageFile, copy.imagePasted],
);
const handleDragOver = useCallback((event: DragEvent) => {
@@ -5,6 +5,7 @@ import React, {
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { BuiltinCommandBadge } from "../components/BuiltinCommandBadge";
import { RuntimeSceneBadge } from "../components/RuntimeSceneBadge";
import { CuratedTaskBadge } from "../../../skill-selection/CuratedTaskBadge";
@@ -56,6 +57,7 @@ import {
readSystemPathReferencesFromFiles,
} from "../../../utils/pathReferences";
import { toast } from "sonner";
import { buildInputbarControllerCopy } from "./inputbarControllerCopy";
interface UseInputbarControllerParams {
input: string;
@@ -134,6 +136,11 @@ export function useInputbarController({
onImportSkill,
onRefreshSkills,
}: UseInputbarControllerParams & SkillSelectionSourceProps) {
const { t } = useTranslation("agent");
const copy = useMemo(
() => buildInputbarControllerCopy((key) => t(key)),
[t],
);
const [activeCapability, setActiveCapability] =
useState<InputCapabilitySelection | null>(null);
const [knowledgeHubOpenRequestKey, setKnowledgeHubOpenRequestKey] =
@@ -186,7 +193,7 @@ export function useInputbarController({
file.type.startsWith("image/"),
);
if (!hasImageFile) {
toast.error("无法读取系统文件路径,请从内置文件管理器拖入。");
toast.error(copy.systemPathDropUnsupported);
event.preventDefault();
event.stopPropagation();
return;
@@ -195,7 +202,7 @@ export function useInputbarController({
handleImageDrop(event);
},
[handleImageDrop, onAddPathReferences],
[copy.systemPathDropUnsupported, handleImageDrop, onAddPathReferences],
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isWorkspaceVariant = variant === "workspace";
@@ -532,10 +539,10 @@ export function useInputbarController({
};
});
setCuratedTaskEditorPrefillHint(
"已按最近判断切到更适合的结果模板,你可以继续改后再发。",
copy.curatedTaskReviewSuggestionPrefillHint,
);
},
[],
[copy.curatedTaskReviewSuggestionPrefillHint],
);
const handleConfirmCuratedTaskEdit = useCallback(
(
@@ -1,10 +1,12 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
cancelRecording,
@@ -19,6 +21,10 @@ import {
type VoiceInputConfig,
} from "@/lib/api/asrProvider";
import { getDefaultLocalVoiceModelReadiness } from "@/lib/api/voiceModels";
import {
onVoiceStartRecording,
onVoiceStopRecording,
} from "@/lib/api/voiceShortcutEvents";
import { requestOpenVoiceModelSettings } from "@/lib/voiceModelSettingsNavigation";
import {
isAudiblePcm16LeSegment,
@@ -28,6 +34,7 @@ import {
mergeLiveTranscript,
} from "@/lib/voiceLivePreview";
import { useVoiceSound } from "@/hooks/useVoiceSound";
import { buildInputbarDictationCopy } from "./inputbarDictationCopy";
type InputbarDictationState =
| "idle"
@@ -40,6 +47,7 @@ interface UseInputbarDictationArgs {
setText: (value: string) => void;
textareaRef: RefObject<HTMLTextAreaElement | null>;
disabled: boolean;
listenForVoiceShortcut?: boolean;
}
interface TranscriptSelection {
@@ -85,12 +93,34 @@ function insertTranscriptAtCursor(
});
}
function getRuntimeErrorMessage(error: unknown, fallback: string): string {
if (typeof error === "string" && error.trim()) {
return error;
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message.trim()) {
return message;
}
}
return fallback;
}
export function useInputbarDictation({
text,
setText,
textareaRef,
disabled,
listenForVoiceShortcut = false,
}: UseInputbarDictationArgs) {
const { t } = useTranslation("agent");
const copy = useMemo(
() => buildInputbarDictationCopy((key) => t(key)),
[t],
);
const [dictationState, setDictationState] =
useState<InputbarDictationState>("idle");
const [recordingStatus, setRecordingStatus] =
@@ -108,6 +138,14 @@ export function useInputbarDictation({
const lastLiveTranscribedSampleRef = useRef(0);
const { playStartSound, playStopSound } = useVoiceSound(soundEnabled);
const updateDictationState = useCallback(
(nextState: InputbarDictationState) => {
dictationStateRef.current = nextState;
setDictationState(nextState);
},
[],
);
const focusTextarea = useCallback(
(cursor: number) => {
window.requestAnimationFrame(() => {
@@ -293,19 +331,19 @@ export function useInputbarDictation({
config = await refreshVoiceConfig();
} catch (error) {
console.error("[输入栏] 读取语音配置失败:", error);
toast.error("语音输入暂不可用");
toast.error(copy.unavailable);
return;
}
if (!config.enabled) {
toast.info("请先在设置里启用语音输入");
toast.info(copy.enableInSettings);
return;
}
try {
const readiness = await getDefaultLocalVoiceModelReadiness();
if (!readiness.ready) {
toast.info(readiness.message || "先下载语音模型");
toast.info(readiness.message || copy.downloadVoiceModel);
requestOpenVoiceModelSettings({
source: "inputbar",
reason: "missing-model",
@@ -317,7 +355,7 @@ export function useInputbarDictation({
console.error("[输入栏] 检查本地语音模型失败:", error);
}
setDictationState("listening");
updateDictationState("listening");
setLiveTranscript("");
lastLiveTranscriptRef.current = "";
lastLiveTranscribedSampleRef.current = 0;
@@ -342,15 +380,20 @@ export function useInputbarDictation({
volume: 0,
duration: 0,
});
} catch (error: any) {
} catch (error: unknown) {
console.error("[输入栏] 开始录音失败:", error);
const message =
typeof error === "string" ? error : error?.message || "无法开始录音";
toast.error(message);
toast.error(getRuntimeErrorMessage(error, copy.startFailed));
setRecordingStatus(null);
setDictationState("idle");
updateDictationState("idle");
}
}, [disabled, playStartSound, refreshVoiceConfig, textareaRef]);
}, [
copy,
disabled,
playStartSound,
refreshVoiceConfig,
textareaRef,
updateDictationState,
]);
const finishDictation = useCallback(async () => {
if (dictationStateRef.current !== "listening") {
@@ -359,14 +402,14 @@ export function useInputbarDictation({
playStopSound();
setRecordingStatus(null);
setDictationState("transcribing");
updateDictationState("transcribing");
try {
const result = await stopRecording();
if (result.duration < 0.5) {
toast.info("录音时间太短,请再试一次");
toast.info(copy.tooShort);
setRecordingStatus(null);
setDictationState("idle");
updateDictationState("idle");
return;
}
@@ -377,9 +420,9 @@ export function useInputbarDictation({
if (!transcription.text.trim()) {
if (!lastLiveTranscriptRef.current) {
toast.info("未识别到语音内容");
toast.info(copy.emptyTranscript);
setRecordingStatus(null);
setDictationState("idle");
updateDictationState("idle");
return;
}
}
@@ -389,7 +432,7 @@ export function useInputbarDictation({
const config = voiceConfigRef.current ?? (await refreshVoiceConfig());
if (config.processor.polish_enabled) {
setDictationState("polishing");
updateDictationState("polishing");
try {
const polished = await polishVoiceText(finalText);
finalText = polished.text;
@@ -400,16 +443,20 @@ export function useInputbarDictation({
applyTranscriptToDictationBase(finalText, true);
setRecordingStatus(null);
setDictationState("idle");
} catch (error: any) {
updateDictationState("idle");
} catch (error: unknown) {
console.error("[输入栏] 完成语音输入失败:", error);
const message =
typeof error === "string" ? error : error?.message || "语音识别失败";
toast.error(message);
toast.error(getRuntimeErrorMessage(error, copy.recognitionFailed));
setRecordingStatus(null);
setDictationState("idle");
updateDictationState("idle");
}
}, [applyTranscriptToDictationBase, playStopSound, refreshVoiceConfig]);
}, [
applyTranscriptToDictationBase,
copy,
playStopSound,
refreshVoiceConfig,
updateDictationState,
]);
const handleDictationToggle = useCallback(async () => {
if (dictationStateRef.current === "listening") {
@@ -424,6 +471,42 @@ export function useInputbarDictation({
await startDictation();
}, [finishDictation, startDictation]);
useEffect(() => {
if (!listenForVoiceShortcut) {
return;
}
let disposed = false;
let unlistenStart: (() => void) | null = null;
let unlistenStop: (() => void) | null = null;
void onVoiceStartRecording(async () => {
await startDictation();
}).then((unlisten) => {
if (disposed) {
unlisten();
return;
}
unlistenStart = unlisten;
});
void onVoiceStopRecording(async () => {
await finishDictation();
}).then((unlisten) => {
if (disposed) {
unlisten();
return;
}
unlistenStop = unlisten;
});
return () => {
disposed = true;
unlistenStart?.();
unlistenStop?.();
};
}, [finishDictation, listenForVoiceShortcut, startDictation]);
return {
dictationEnabled,
voiceConfigLoaded,
@@ -1,5 +1,7 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { buildInputbarToolsCopy } from "../components/inputbarToolsCopy";
export interface InputbarToolStates {
webSearch: boolean;
@@ -24,6 +26,11 @@ export function useInputbarToolState({
onToolStatesChange,
openFileDialog,
}: UseInputbarToolStateParams) {
const { t } = useTranslation("agent");
const copy = useMemo(
() => buildInputbarToolsCopy((key) => t(key, {})),
[t],
);
const [localToolStates, setLocalToolStates] = useState<InputbarToolStates>(
DEFAULT_INPUTBAR_TOOL_STATES,
);
@@ -70,7 +77,7 @@ export function useInputbarToolState({
thinking: nextThinking,
subagent: subagentEnabled,
});
toast.info(`深度思考${nextThinking ? "已开启" : "已关闭"}`);
toast.info(copy.thinking.toast(nextThinking));
break;
}
case "web_search": {
@@ -80,7 +87,7 @@ export function useInputbarToolState({
thinking: thinkingEnabled,
subagent: subagentEnabled,
});
toast.info(`联网搜索${nextWebSearch ? "已开启" : "已关闭"}`);
toast.info(copy.webSearch.toast(nextWebSearch));
break;
}
case "subagent_mode": {
@@ -90,7 +97,7 @@ export function useInputbarToolState({
thinking: thinkingEnabled,
subagent: nextSubagent,
});
toast.info(`任务拆分${nextSubagent ? "偏好已开启" : "偏好已关闭"}`);
toast.info(copy.subagent.toast(nextSubagent));
break;
}
case "attach":
@@ -98,13 +105,16 @@ export function useInputbarToolState({
break;
case "fullscreen":
setIsFullscreen((prev) => !prev);
toast.info(isFullscreen ? "已退出全屏" : "已进入全屏编辑");
toast.info(
isFullscreen ? copy.fullscreen.exited : copy.fullscreen.entered,
);
break;
default:
break;
}
},
[
copy,
isFullscreen,
openFileDialog,
thinkingEnabled,
@@ -124,9 +134,15 @@ export function useInputbarToolState({
thinking: thinkingEnabled,
subagent: enabled,
});
toast.info(`任务拆分${enabled ? "偏好已开启" : "偏好已关闭"}`);
toast.info(copy.subagent.toast(enabled));
},
[subagentEnabled, thinkingEnabled, updateToolStates, webSearchEnabled],
[
copy,
subagentEnabled,
thinkingEnabled,
updateToolStates,
webSearchEnabled,
],
);
return {
@@ -3,6 +3,8 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Inputbar } from "./index";
import { changeLimeLocale } from "@/i18n/createI18n";
import { toast } from "sonner";
import type { Character } from "@/lib/api/memory";
import type { Skill } from "@/lib/api/skills";
import type { ServiceSkillHomeItem } from "@/components/agent/chat/service-skills/types";
@@ -502,13 +504,14 @@ vi.mock("sonner", () => ({
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
beforeEach(() => {
beforeEach(async () => {
(
globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
).IS_REACT_ACT_ENVIRONMENT = true;
window.localStorage.clear();
await changeLimeLocale("zh-CN");
});
afterEach(() => {
@@ -590,6 +593,30 @@ function updateFieldValue(
element.dispatchEvent(new Event("change", { bubbles: true }));
}
function dispatchInputbarDrop(
container: HTMLDivElement,
dataTransfer: {
files: File[];
getData: (format: string) => string;
},
): Event {
const surface = container.firstElementChild;
expect(surface).toBeTruthy();
const event = new Event("drop", {
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, "dataTransfer", {
value: dataTransfer,
});
act(() => {
surface?.dispatchEvent(event);
});
return event;
}
describe("Inputbar", () => {
it("即使角色和技能为空,也应挂载 CharacterMention", async () => {
const { container } = renderInputbar();
@@ -1054,6 +1081,33 @@ describe("Inputbar", () => {
);
});
it("无法读取系统文件路径时应按 en-US 资源提示", async () => {
await changeLimeLocale("en-US");
const onAddPathReferences = vi.fn();
const { container } = renderInputbar({
onAddPathReferences,
});
await act(async () => {
await Promise.resolve();
});
const dropEvent = dispatchInputbarDrop(container, {
files: [
new File(["notes"], "notes.txt", {
type: "text/plain",
}),
],
getData: () => "",
});
expect(toast.error).toHaveBeenCalledWith(
"Cannot read system file paths. Drag files in from the built-in file manager.",
);
expect(onAddPathReferences).not.toHaveBeenCalled();
expect(dropEvent.defaultPrevented).toBe(true);
});
it("应在输入框底栏提供文件管理器打开按钮", async () => {
const onToggleFileManager = vi.fn();
const { container } = renderInputbar({
@@ -1079,6 +1133,58 @@ describe("Inputbar", () => {
expect(onToggleFileManager).toHaveBeenCalledTimes(1);
});
it("通用输入区 chrome 文案应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const { container } = renderInputbar({
providerType: "openai",
setProviderType: vi.fn(),
model: "gpt-4.1",
setModel: vi.fn(),
onToggleFileManager: vi.fn(),
});
await act(async () => {
await Promise.resolve();
});
expect(container.textContent).toContain("Model");
expect(container.textContent).toContain("Advanced settings");
const modelBadge = Array.from(
container.querySelectorAll("[title]"),
).find((element) =>
element.getAttribute("title")?.startsWith("Current model:"),
);
expect(modelBadge?.getAttribute("title")).toBe(
"Current model: OpenAI / gpt-4.1",
);
const advancedToggle = container.querySelector(
'[data-testid="inputbar-advanced-toggle"]',
) as HTMLButtonElement | null;
expect(advancedToggle?.getAttribute("aria-label")).toBe(
"Expand advanced settings",
);
expect(advancedToggle?.getAttribute("title")).toBe(
"Expand advanced settings",
);
const fileManagerToggle = container.querySelector(
'[data-testid="inputbar-file-manager-toggle"]',
) as HTMLButtonElement | null;
expect(fileManagerToggle?.getAttribute("aria-label")).toBe(
"Open file manager sidebar",
);
expandAdvancedControls(container);
expect(
container
.querySelector('[data-testid="inputbar-advanced-toggle"]')
?.getAttribute("aria-label"),
).toBe("Collapse advanced settings");
});
it("带着初始已安装技能进入时,应恢复 capability badge 并继续按 route 发送", async () => {
const onSend = vi.fn();
const skill = {
@@ -1719,7 +1825,8 @@ describe("Inputbar", () => {
);
});
it("输入条编辑态 launcher 按最近判断切模板时,应保留参考选择并显示提示", async () => {
it("输入条编辑态 launcher 按最近判断切模板时,应保留参考选择并显示 en-US 提示", async () => {
await changeLimeLocale("en-US");
const initialLaunchInputValues = {
theme_target: "AI 内容创作",
platform_region: "X 与 TikTok 北美区",
@@ -1805,8 +1912,8 @@ describe("Inputbar", () => {
const prefillHint = document.querySelector(
'[data-testid="curated-task-launcher-prefill-hint"]',
);
expect(prefillHint?.textContent).toContain(
"已按最近判断切到更适合的结果模板",
expect(prefillHint?.textContent).toBe(
"Switched to a better-fit result template based on the latest review. You can keep editing before sending.",
);
});
@@ -2634,6 +2741,39 @@ describe("Inputbar", () => {
thinking: false,
subagent: false,
});
expect(toast.info).toHaveBeenCalledWith("联网搜索已开启");
});
it("工具开关 toast 应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const onToolStatesChange = vi.fn();
const { container } = renderInputbar({
input: "please search the web",
toolStates: { webSearch: false, thinking: false, subagent: false },
onToolStatesChange,
});
await act(async () => {
await Promise.resolve();
});
expandAdvancedControls(container);
const toggleButton = container.querySelector(
'[data-testid="toggle-web-search"]',
) as HTMLButtonElement | null;
expect(toggleButton).toBeTruthy();
act(() => {
toggleButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onToolStatesChange).toHaveBeenCalledWith({
webSearch: true,
thinking: false,
subagent: false,
});
expect(toast.info).toHaveBeenCalledWith("Web search is on");
});
it("通用聊天态复杂任务应显示任务分工建议并支持开启多代理", async () => {
@@ -3548,6 +3688,79 @@ describe("Inputbar", () => {
);
});
it("工作区输入提示应跟随 en-US 资源", async () => {
await changeLimeLocale("en-US");
const { rerender } = renderInputbar({
variant: "workspace",
providerType: "openai",
setProviderType: vi.fn(),
model: "gpt-4.1",
setModel: vi.fn(),
executionStrategy: "auto",
setExecutionStrategy: vi.fn(),
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
let latestCall =
mockInputbarCore.mock.calls[mockInputbarCore.mock.calls.length - 1]?.[0];
expect(latestCall?.placeholder).toBe(
"Try entering any instruction and I will handle the rest.",
);
rerender({
variant: "workspace",
contextVariant: "task-center",
providerType: "openai",
setProviderType: vi.fn(),
model: "gpt-4.1",
setModel: vi.fn(),
executionStrategy: "auto",
setExecutionStrategy: vi.fn(),
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
latestCall =
mockInputbarCore.mock.calls[mockInputbarCore.mock.calls.length - 1]?.[0];
expect(latestCall?.placeholder).toBe(
"Keep adding to this generation, or use the left side to return to earlier history.",
);
rerender({
variant: "workspace",
workflowGate: {
key: "review_choice",
title: "Review choice",
status: "waiting",
description: "Choose how to continue.",
},
providerType: "openai",
setProviderType: vi.fn(),
model: "gpt-4.1",
setModel: vi.fn(),
executionStrategy: "auto",
setExecutionStrategy: vi.fn(),
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
latestCall =
mockInputbarCore.mock.calls[mockInputbarCore.mock.calls.length - 1]?.[0];
expect(latestCall?.placeholder).toBe(
"Tell me your choice and I will handle the rest.",
);
});
it("工作区工作流在待启动状态下不应显示闸门条", async () => {
const { container } = renderInputbar({
variant: "workspace",
@@ -1,5 +1,6 @@
import React from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import type { MessageImage, MessagePathReference } from "../../types";
import type { Character } from "@/lib/api/memory";
import type {
@@ -29,6 +30,8 @@ import type {
InputbarKnowledgePackOption,
InputbarKnowledgePackSelection,
} from "./types";
import { buildInputbarComposerSectionCopy } from "./components/inputbarComposerSectionCopy";
import { buildInputbarCoreCopy } from "./components/inputbarCoreCopy";
const SecondaryControlsRow = styled.div`
position: absolute;
@@ -201,6 +204,18 @@ export const Inputbar: React.FC<InputbarProps> = ({
defaultCuratedTaskReferenceEntries = [],
inputCompletionEnabled = true,
}) => {
const { t } = useTranslation("agent");
const inputbarCopy = React.useMemo(
() => buildInputbarCoreCopy((key, values) => t(key, values ?? {})),
[t],
);
const inputbarComposerCopy = React.useMemo(
() =>
buildInputbarComposerSectionCopy((key, values) =>
t(key, values ?? {}),
),
[t],
);
const {
textareaRef,
isWorkspaceVariant,
@@ -372,6 +387,8 @@ export const Inputbar: React.FC<InputbarProps> = ({
onRemoveQueuedTurn={onRemoveQueuedTurn}
contextVariant={contextVariant}
inputCompletionEnabled={inputCompletionEnabled}
copy={inputbarComposerCopy}
inputbarCopy={inputbarCopy}
/>
{dialogLayer}
</InputbarSurface>
@@ -15,6 +15,7 @@ import {
} from "../hooks/agentStreamTextOverlayStore";
const IMAGE_WORKBENCH_FOCUS_EVENT = "lime:image-workbench-focus";
const IMAGE_WORKBENCH_TASK_ACTION_EVENT = "lime:image-workbench-task-action";
const VIDEO_WORKBENCH_TASK_ACTION_EVENT = "lime:video-workbench-task-action";
type MockConfiguredProvider = {
key: string;
@@ -446,11 +447,11 @@ describe("MessageList", () => {
expect(
container.querySelector('[data-testid="message-list-restoring-session"]'),
).not.toBeNull();
expect(container.textContent).toContain("正在恢复生成会话...");
expect(container.textContent).toContain("Restoring generation session...");
expect(container.textContent).toContain(
"正在同步最近一次生成会话,请稍候。",
"Syncing the latest generation session. Please wait.",
);
expect(container.textContent).not.toContain("开始一段新的对话吧");
expect(container.textContent).not.toContain("Start a new conversation");
});
it("旧会话首屏只加载尾部历史时应提供继续加载入口", () => {
@@ -470,13 +471,13 @@ describe("MessageList", () => {
'[data-testid="message-list-persisted-history-window"]',
),
).not.toBeNull();
expect(container.textContent).toContain("最近 2 / 320 条消息");
expect(container.textContent).toContain("latest 2 / 320 messages");
const button = container.querySelector(
'[data-testid="message-list-load-full-history"]',
) as HTMLButtonElement | null;
expect(button).not.toBeNull();
expect(button?.textContent).toContain("加载更多历史");
expect(button?.textContent).toContain("Load more history");
act(() => {
button?.click();
@@ -556,11 +557,11 @@ describe("MessageList", () => {
},
});
expect(container.textContent).toContain("最近 40 / 188 条消息");
expect(container.textContent).toContain("latest 40 / 188 messages");
expect(container.textContent).toContain("消息 40");
expect(container.textContent).toContain("消息 31");
expect(container.textContent).not.toContain("消息 30");
expect(container.textContent).toContain("更早的 30 条可按需展开");
expect(container.textContent).toContain("30 earlier messages can be expanded");
act(() => {
vi.advanceTimersByTime(2_000);
@@ -782,7 +783,7 @@ describe("MessageList", () => {
'[data-testid="message-list-historical-timeline-preview:leading"]',
);
expect(deferredPreview).not.toBeNull();
expect(deferredPreview?.textContent).toContain("点击展开后加载执行细节");
expect(deferredPreview?.textContent).toContain("Expand to load execution details");
const idleCommit = getAgentUiPerformanceMetrics()
.filter((entry) => entry.phase === "messageList.commit")
.find(
@@ -1183,13 +1184,13 @@ describe("MessageList", () => {
);
expect(preview).not.toBeNull();
expect(preview?.textContent).toContain("此历史消息较长");
expect(preview?.textContent).toContain("纯文本预览");
expect(preview?.textContent).toContain("This history message is long");
expect(preview?.textContent).toContain("plain-text preview");
expect(preview?.textContent).not.toContain("末尾完整内容");
expect(mockStreamingRenderer).not.toHaveBeenCalled();
const expandButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("展开完整内容"),
(button) => button.textContent?.includes("Show full content"),
) as HTMLButtonElement | undefined;
act(() => {
@@ -1254,7 +1255,7 @@ describe("MessageList", () => {
);
expect(preview).not.toBeNull();
expect(preview?.textContent).toContain("历史助手回复较长");
expect(preview?.textContent).toContain("This assistant reply is long");
expect(preview?.textContent).not.toContain("旧回复末尾完整内容");
expect(container.textContent).toContain(latestAssistantContent);
expect(mockStreamingRenderer).not.toHaveBeenCalledWith(
@@ -1264,7 +1265,7 @@ describe("MessageList", () => {
);
const expandButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("展开完整内容"),
(button) => button.textContent?.includes("Show full content"),
) as HTMLButtonElement | undefined;
act(() => {
@@ -1315,18 +1316,18 @@ describe("MessageList", () => {
expect(
container.querySelector('[data-testid="message-list-empty-task-center"]'),
).not.toBeNull();
expect(container.textContent).toContain("对话");
expect(container.textContent).toContain("最近对话");
expect(container.textContent).toContain("Chat");
expect(container.textContent).toContain("Recent chats");
expect(container.textContent).toContain(
"这里集中展示最近对话、待继续会话和更早归档,方便你随时回到上一次工作现场。",
"Recent chats, sessions to continue, and earlier archives are gathered here so you can return to the last working context.",
);
expect(container.textContent).toContain(
"还没有对话时,可以先从“新建对话”开始;后续的结果、素材和中间过程都会继续留在这里。",
"When there are no chats yet, start from “New chat”. Results, materials, and intermediate steps will stay here later.",
);
expect(container.textContent).toContain("左侧会优先显示待继续的对话");
expect(container.textContent).toContain("最近对话和归档会按时间自动整理");
expect(container.textContent).toContain("恢复中的会话会自动回到这里继续");
expect(container.textContent).not.toContain("开始一段新的对话吧");
expect(container.textContent).toContain("Chats to continue appear on the left first");
expect(container.textContent).toContain("Recent chats and archives are organized by time");
expect(container.textContent).toContain("Restoring sessions return here automatically");
expect(container.textContent).not.toContain("Start a new conversation");
});
it("应过滤空白 user 消息,避免渲染空白气泡", () => {
@@ -1377,7 +1378,7 @@ describe("MessageList", () => {
) as HTMLButtonElement | null;
expect(historyWindow).not.toBeNull();
expect(container.textContent).toContain("为了更快打开对话");
expect(container.textContent).toContain("To open the chat faster");
expect(container.textContent).toContain("消息 90");
expect(container.textContent).not.toContain("消息 1");
expect(expandButton).not.toBeNull();
@@ -2289,7 +2290,7 @@ describe("MessageList", () => {
'[data-testid="image-workbench-message-preview-task-1"]',
) as HTMLButtonElement | null;
expect(previewCard?.textContent).toContain("Image Generation");
expect(previewCard?.textContent).toContain("图片生成");
expect(previewCard?.textContent).not.toContain("一颗戴耳机的青柠");
expect(previewCard?.textContent).not.toContain("已生成");
expect(previewCard?.textContent).not.toContain("可在右侧继续查看与使用");
@@ -2342,7 +2343,7 @@ describe("MessageList", () => {
'[data-testid="image-workbench-message-preview-task-verbose-template"]',
);
expect(previewCard?.textContent).toContain("Image Generation");
expect(previewCard?.textContent).toContain("图片生成");
expect(previewCard?.textContent).toContain("Nanobanana Pro");
expect(container.textContent).not.toContain("任务已创建成功");
expect(container.textContent).not.toContain("这里是生成详情");
@@ -2352,7 +2353,7 @@ describe("MessageList", () => {
expect(previewCard?.querySelector("img")).not.toBeNull();
});
it("图片任务消息应在同一条 assistant 回复里呈现自然文案、工具条、图片和结果描述", async () => {
it("图片任务消息应在同一条 assistant 回复里保留自然铺垫、轻卡、图片和结果描述", async () => {
const messages: Message[] = [
{
id: "msg-user-image-workbench-natural",
@@ -2419,9 +2420,8 @@ describe("MessageList", () => {
expect(text).toContain("好啊,用 Nanobanana Pro");
expect(text).toContain("先获取下工具参数");
expect(text).toContain("马上生成");
expect(text).toContain("Image Generation");
expect(text).toContain("图片生成");
expect(text).toContain("Nanobanana Pro");
expect(text).toContain("Lime");
expect(text).toContain("我继续改");
expect(
container.querySelector('[data-testid="token-usage-display"]'),
@@ -2429,9 +2429,10 @@ describe("MessageList", () => {
expect(text).not.toContain("limeCreateImageGenerationTask");
expect(text).not.toContain("异步图片任务");
expect(
container.querySelector('[data-testid="image-workbench-assistant-header"]')
?.textContent,
).toContain("Lime");
container.querySelector('[data-testid="image-workbench-assistant-header"]'),
).toBeNull();
expect(intro).not.toBeNull();
expect(intro?.textContent).toContain("先获取下工具参数");
expect(
container.querySelector('[data-testid="message-user-command-tag"]')
?.textContent,
@@ -2440,12 +2441,115 @@ describe("MessageList", () => {
container.querySelector('[data-testid="message-user-command-content"]')
?.textContent,
).toContain("广州塔");
expect(intro?.textContent).toContain("好啊,用 Nanobanana Pro");
expect(container.querySelector('[data-testid="streaming-renderer"]')).toBe(
null,
);
});
it("同一会话连续两次图片生成应分别保留用户指令、自然铺垫和对应轻卡", async () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-user-image-turn-1",
role: "user",
content: "@配图 生成一张广州塔春天照片",
timestamp: now,
},
{
id: "msg-assistant-image-turn-1",
role: "assistant",
content: "好啊,先生成广州塔春天照片\n先获取下工具参数\n马上生成",
timestamp: new Date(now.getTime() + 1_000),
thinkingContent: "先判断广州塔照片的季节和视角。",
contentParts: [
{ type: "thinking", text: "先判断广州塔照片的季节和视角。" },
{
type: "text",
text: "好啊,先生成广州塔春天照片\n先获取下工具参数\n马上生成",
},
],
imageWorkbenchPreview: {
taskId: "task-image-turn-1",
prompt: "广州塔春天照片",
mode: "generate",
status: "complete",
imageUrl: "https://example.com/guangzhou-tower.png",
imageCount: 1,
modelName: "gpt-images-2",
caption: "第一张已经好了,可以继续调春天氛围。",
},
},
{
id: "msg-user-image-turn-2",
role: "user",
content: "@配图 再生成一张青柠极简插画",
timestamp: new Date(now.getTime() + 2_000),
},
{
id: "msg-assistant-image-turn-2",
role: "assistant",
content: "好啊,再生成青柠极简插画\n先获取下工具参数\n马上生成",
timestamp: new Date(now.getTime() + 3_000),
thinkingContent: "再判断青柠插画的极简构图。",
contentParts: [
{ type: "thinking", text: "再判断青柠插画的极简构图。" },
{
type: "text",
text: "好啊,再生成青柠极简插画\n先获取下工具参数\n马上生成",
},
],
imageWorkbenchPreview: {
taskId: "task-image-turn-2",
prompt: "青柠极简插画",
mode: "generate",
status: "complete",
imageUrl: "https://example.com/lime-minimal.png",
imageCount: 1,
modelName: "gpt-images-2",
caption: "第二张也好了,可以继续改构图。",
},
},
];
const container = await renderZh(messages);
const commandTags = Array.from(
container.querySelectorAll('[data-testid="message-user-command-tag"]'),
).map((node) => node.textContent);
const intros = Array.from(
container.querySelectorAll('[data-testid="image-workbench-assistant-intro"]'),
).map((node) => node.textContent || "");
expect(commandTags).toEqual(["@配图", "@配图"]);
expect(intros).toHaveLength(2);
expect(intros[0]).toContain("广州塔春天照片");
expect(intros[0]).not.toContain("青柠极简插画");
expect(intros[1]).toContain("青柠极简插画");
expect(intros[1]).not.toContain("广州塔春天照片");
expect(
mockStreamingRenderer.mock.calls.map((call) => call[0].thinkingContent),
).toEqual([
"先判断广州塔照片的季节和视角。",
"再判断青柠插画的极简构图。",
]);
expect(
mockStreamingRenderer.mock.calls.every(
(call) => !call[0].toolCalls && !call[0].contentParts,
),
).toBe(true);
expect(
container.querySelector(
'[data-testid="image-workbench-message-preview-task-image-turn-1"]',
)?.textContent,
).toContain("第一张已经好了");
expect(
container.querySelector(
'[data-testid="image-workbench-message-preview-task-image-turn-2"]',
)?.textContent,
).toContain("第二张也好了");
expect(container.textContent).not.toContain("任务 ID");
expect(container.textContent).not.toContain("任务已提交");
});
it("用户消息带已安装 Skill route 时应保留 @ Skill 标签展示", async () => {
const container = await renderZh([
{
@@ -2475,6 +2579,31 @@ describe("MessageList", () => {
expect(container.textContent).toContain("帮我写一篇关于三国的故事");
});
it("用户消息仅通过 builtin command route 进入时,也应保留 @命令 标签展示", async () => {
const container = await renderZh([
{
id: "msg-user-builtin-route-only-image-command",
role: "user",
content: "生成一张广州塔,从花城汇看过去的春天照片",
timestamp: new Date(),
inputCapabilityRoute: {
kind: "builtin_command",
commandKey: "image_generate",
commandPrefix: "@配图",
},
} as Message,
]);
expect(
container.querySelector('[data-testid="message-user-command-tag"]')
?.textContent,
).toBe("@配图");
expect(
container.querySelector('[data-testid="message-user-command-content"]')
?.textContent,
).toContain("广州塔");
});
it("历史助手消息没有图片轻卡时,也不应继续展示旧图片任务详情模板", async () => {
const messages: Message[] = [
{
@@ -2537,7 +2666,7 @@ describe("MessageList", () => {
);
});
it("图片任务消息应收起内部 process flow,只保留任务卡与正文", () => {
it("图片任务消息应保留思考但收起内部工具 process flow", () => {
const container = render(
[
{
@@ -2606,8 +2735,26 @@ describe("MessageList", () => {
},
);
const streamingCall = mockStreamingRenderer.mock.calls.at(-1)?.[0];
expect(streamingCall?.suppressProcessFlow).toBe(true);
expect(mockStreamingRenderer).toHaveBeenCalledTimes(1);
const rendererProps = mockStreamingRenderer.mock.calls[0]?.[0] as
| {
content?: string;
thinkingContent?: string;
contentParts?: unknown[];
toolCalls?: unknown[];
}
| undefined;
expect(rendererProps).toMatchObject({
content: "",
thinkingContent: "先执行图片技能。",
});
expect(rendererProps?.contentParts).toBeUndefined();
expect(rendererProps?.toolCalls).toBeUndefined();
expect(
container.querySelector(
'[data-testid="image-workbench-message-preview-task-image-process-flow"]',
),
).not.toBeNull();
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).toBeNull();
@@ -3207,7 +3354,7 @@ describe("MessageList", () => {
'[data-testid="image-workbench-message-preview-task-edit-1"]',
);
expect(previewCard?.textContent).toContain("Image Editing");
expect(previewCard?.textContent).toContain("图片编辑");
expect(previewCard?.querySelector("img")).not.toBeNull();
expect(previewCard?.textContent).not.toContain("已修图");
expect(previewCard?.textContent).not.toContain("来源图");
@@ -3270,14 +3417,14 @@ describe("MessageList", () => {
'[data-testid="image-workbench-message-preview-task-complete-sync-copy"]',
);
expect(previewCard?.textContent).toContain("Image Generation");
expect(previewCard?.textContent).toContain("图片生成");
expect(previewCard?.textContent).not.toContain("已生成");
expect(previewCard?.textContent).not.toContain("可在右侧继续查看与使用");
expect(previewCard?.textContent).not.toContain("正在同步任务状态");
expect(previewCard?.textContent).not.toContain("图片任务已提交");
});
it("失败的图片任务卡应收敛为静态状态卡,不再展示操作按钮", async () => {
it("失败的图片任务卡应提供单独重试按钮,并继续保留打开查看能力", async () => {
const now = new Date();
const messages: Message[] = [
{
@@ -3295,18 +3442,38 @@ describe("MessageList", () => {
},
];
let actionDetail: Record<string, unknown> | null = null;
const handleAction = (event: Event) => {
if (!(event instanceof CustomEvent)) {
return;
}
actionDetail = event.detail as Record<string, unknown>;
};
window.addEventListener(IMAGE_WORKBENCH_TASK_ACTION_EVENT, handleAction);
const container = await renderZh(messages);
const previewCard = container.querySelector(
'[data-testid="image-workbench-message-preview-task-failed-1"]',
);
) as HTMLButtonElement | null;
const retryButton = container.querySelector(
'[data-testid="image-workbench-message-preview-action-task-failed-1-retry"]',
) as HTMLButtonElement | null;
expect(previewCard?.textContent).toContain("生成失败");
expect(previewCard?.textContent).not.toContain("调整描述后重试");
expect(
container.querySelector(
'[data-testid="image-workbench-message-preview-action-task-failed-1-retry"]',
),
).toBeNull();
expect(retryButton?.textContent).toContain("重试");
act(() => {
retryButton?.click();
});
expect(actionDetail).toEqual({
action: "retry",
taskId: "task-failed-1",
projectId: "project-1",
contentId: "content-1",
});
window.removeEventListener(IMAGE_WORKBENCH_TASK_ACTION_EVENT, handleAction);
});
it("生成中的图片任务卡应展示同会话占位,但不再展示取消按钮", async () => {
@@ -3342,7 +3509,7 @@ describe("MessageList", () => {
).toBeNull();
});
it("失败的图片任务卡应保留错误文案,但不再突出不可重试标签", async () => {
it("失败的图片任务卡应保留错误文案,即使原状态标记不可重试也提供单独重试入口", async () => {
const now = new Date();
const messages: Message[] = [
{
@@ -3372,6 +3539,11 @@ describe("MessageList", () => {
"FAL 请求参数无效,请先调整配置。",
);
expect(previewCard?.textContent).not.toContain("不可重试");
expect(
container.querySelector(
'[data-testid="image-workbench-message-preview-action-task-failed-no-retry-retry"]',
)?.textContent,
).toContain("重试");
});
it("已取消的图片任务卡应显示独立状态且不再展示重试按钮", async () => {
@@ -3506,7 +3678,7 @@ describe("MessageList", () => {
'[data-testid="image-workbench-message-preview-grid-task-storyboard-preview-1"]',
) as HTMLDivElement | null;
expect(container.textContent).toContain("Image Generation");
expect(container.textContent).toContain("图片生成");
expect(container.textContent).not.toContain(
"3x3 分镜已经完成,可在右侧继续查看与使用。",
);
@@ -3790,6 +3962,319 @@ describe("MessageList", () => {
);
});
it("当前尾部 assistant 完成后 turn timeline 暂缺时应继续显示本地思考内容", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-user-tail-runtime-thinking-fallback",
role: "user",
content: "先思考再回答",
timestamp: now,
},
{
id: "msg-assistant-tail-runtime-thinking-fallback",
role: "assistant",
content: "这是最终回答。",
timestamp: now,
runtimeTurnId: "turn-tail-runtime-thinking-fallback",
thinkingContent: "先梳理约束。",
contentParts: [
{
type: "thinking",
text: "先梳理约束。",
},
{
type: "text",
text: "这是最终回答。",
},
],
},
];
render(messages, {
currentTurnId: null,
turns: [],
threadItems: [],
});
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
thinkingContent: "先梳理约束。",
contentParts: [
{ type: "thinking", text: "先梳理约束。" },
{ type: "text", text: "这是最终回答。" },
],
}),
);
});
it("已完成的直执 Skill 消息即使不在尾部,也不应丢失本地思考内容", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-user-skill-inline-process",
role: "user",
content: "整理产品资料",
timestamp: now,
},
{
id: "msg-assistant-skill-inline-process",
role: "assistant",
content: "产品知识库说明。",
timestamp: now,
runtimeTurnId: "skill-exec-msg-assistant-skill-inline-process",
thinkingContent: "先读取 Skill,再分析产品资料边界。",
contentParts: [
{
type: "thinking",
text: "先读取 Skill,再分析产品资料边界。",
},
{
type: "text",
text: "产品知识库说明。",
},
],
},
{
id: "msg-user-after-skill-inline-process",
role: "user",
content: "继续",
timestamp: now,
},
{
id: "msg-assistant-after-skill-inline-process",
role: "assistant",
content: "继续回答。",
timestamp: now,
},
];
render(messages, {
currentTurnId: null,
turns: [],
threadItems: [],
});
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
content: "产品知识库说明。",
thinkingContent: "先读取 Skill,再分析产品资料边界。",
contentParts: [
{
type: "thinking",
text: "先读取 Skill,再分析产品资料边界。",
},
{
type: "text",
text: "产品知识库说明。",
},
],
}),
);
});
it("已完成的服务型 Skill 消息即使不在尾部,也不应丢失本地思考内容", () => {
const now = new Date();
const messages: Message[] = [
{
id: "msg-user-service-skill-inline-process",
role: "user",
content: "整理产品资料",
timestamp: now,
},
{
id: "msg-assistant-service-skill-inline-process",
role: "assistant",
content: "产品知识库说明。",
timestamp: now,
runtimeTurnId: "turn-service-skill-inline-process",
inlineProcessRetention: "skill",
thinkingContent: "先读取服务型 Skill,再分析产品资料边界。",
contentParts: [
{
type: "thinking",
text: "先读取服务型 Skill,再分析产品资料边界。",
},
{
type: "text",
text: "产品知识库说明。",
},
],
},
{
id: "msg-user-after-service-skill-inline-process",
role: "user",
content: "继续",
timestamp: now,
},
{
id: "msg-assistant-after-service-skill-inline-process",
role: "assistant",
content: "继续回答。",
timestamp: now,
},
];
render(messages, {
currentTurnId: null,
turns: [],
threadItems: [],
});
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
content: "产品知识库说明。",
thinkingContent: "先读取服务型 Skill,再分析产品资料边界。",
contentParts: [
{
type: "thinking",
text: "先读取服务型 Skill,再分析产品资料边界。",
},
{
type: "text",
text: "产品知识库说明。",
},
],
}),
);
});
it("已完成的直执 Skill 消息已有 turn timeline 时仍应保留内联思考内容", () => {
const now = new Date();
const turnId = "skill-exec-analysis-retained";
const messages: Message[] = [
{
id: "msg-user-skill-retained-with-timeline",
role: "user",
content: "@analysis 帮我分析一下今天的国际形势",
timestamp: now,
},
{
id: "msg-assistant-skill-retained-with-timeline",
role: "assistant",
content: "国际形势分析结果。",
timestamp: now,
runtimeTurnId: turnId,
inlineProcessRetention: "skill",
thinkingContent: "先读取 analysis Skill,再拆解地区变量。",
contentParts: [
{
type: "thinking",
text: "先读取 analysis Skill,再拆解地区变量。",
},
{
type: "text",
text: "国际形势分析结果。",
},
],
},
];
render(messages, {
currentTurnId: turnId,
turns: [
{
id: turnId,
thread_id: "thread-1",
prompt_text: "@analysis 帮我分析一下今天的国际形势",
status: "completed",
started_at: "2026-05-13T12:00:00Z",
completed_at: "2026-05-13T12:00:02Z",
created_at: "2026-05-13T12:00:00Z",
updated_at: "2026-05-13T12:00:02Z",
},
],
threadItems: [
{
id: "reasoning-skill-retained-with-timeline",
thread_id: "thread-1",
turn_id: turnId,
sequence: 1,
status: "completed",
started_at: "2026-05-13T12:00:00Z",
completed_at: "2026-05-13T12:00:01Z",
updated_at: "2026-05-13T12:00:01Z",
type: "reasoning",
text: "先读取 analysis Skill,再拆解地区变量。",
},
],
});
expect(mockStreamingRenderer).toHaveBeenCalledWith(
expect.objectContaining({
content: "国际形势分析结果。",
thinkingContent: "先读取 analysis Skill,再拆解地区变量。",
contentParts: [
{
type: "thinking",
text: "先读取 analysis Skill,再拆解地区变量。",
},
{
type: "text",
text: "国际形势分析结果。",
},
],
}),
);
});
it("当前尾部 assistant 完成后 turn 记录暂缺时应按 runtimeTurnId 保留过程 timeline", () => {
const now = new Date("2026-05-12T10:00:02.000Z");
const messages: Message[] = [
{
id: "msg-user-orphan-runtime-timeline",
role: "user",
content: "保留过程",
timestamp: new Date("2026-05-12T10:00:00.000Z"),
},
{
id: "msg-assistant-orphan-runtime-timeline",
role: "assistant",
content: "最终回答。",
timestamp: now,
runtimeTurnId: "turn-orphan-runtime-timeline",
},
];
const container = render(messages, {
currentTurnId: null,
turns: [],
threadItems: [
{
id: "reasoning-orphan-runtime-timeline",
thread_id: "thread-1",
turn_id: "turn-orphan-runtime-timeline",
sequence: 1,
status: "completed",
started_at: "2026-05-12T10:00:01.000Z",
completed_at: "2026-05-12T10:00:02.000Z",
updated_at: "2026-05-12T10:00:02.000Z",
type: "reasoning",
text: "先确认过程是否还在。",
},
],
});
expect(
container.querySelector('[data-testid="agent-thread-timeline:leading"]'),
).not.toBeNull();
expect(
container
.querySelector('[data-testid="agent-thread-timeline:leading"]')
?.getAttribute("data-turn-id"),
).toBe("turn-orphan-runtime-timeline");
expect(mockAgentThreadTimeline).toHaveBeenCalledWith(
expect.objectContaining({
items: [
expect.objectContaining({
id: "reasoning-orphan-runtime-timeline",
type: "reasoning",
}),
],
}),
);
});
it("持久化 reasoning 已接管时不应重复传递本地思考过程", () => {
const now = new Date();
const messages: Message[] = [
@@ -5397,7 +5882,7 @@ describe("MessageList", () => {
const container = render(messages, { onQuoteMessage });
const quoteButton = container.querySelector(
'button[aria-label="引用消息"]',
'button[aria-label="Quote message"]',
);
expect(quoteButton).toBeTruthy();
@@ -5450,7 +5935,7 @@ describe("MessageList", () => {
const container = render(messages, { onSaveMessageAsSkill });
const saveButton = container.querySelector(
'button[aria-label="保存为技能"]',
'button[aria-label="Save as Skill"]',
);
expect(saveButton).not.toBeNull();
@@ -5481,7 +5966,7 @@ describe("MessageList", () => {
const container = render(messages, { onSaveMessageAsInspiration });
const saveButton = container.querySelector(
'button[aria-label="保存到灵感库"]',
'button[aria-label="Save to inspiration"]',
);
expect(saveButton).not.toBeNull();
@@ -5512,7 +5997,7 @@ describe("MessageList", () => {
const container = render(messages, { onSaveMessageAsKnowledge });
const saveButton = container.querySelector(
'button[aria-label="保存到项目资料"]',
'button[aria-label="Save to project knowledge"]',
);
expect(saveButton).not.toBeNull();
@@ -5559,10 +6044,10 @@ describe("MessageList", () => {
];
const container = render(messages, { onSaveMessageAsKnowledge });
expect(container.textContent).toContain("Document 产物");
expect(container.textContent).toContain("可保存到项目资料");
expect(container.textContent).toContain("Document artifact");
expect(container.textContent).toContain("Can save to project knowledge");
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("保存这份文档"),
(button) => button.textContent?.includes("Save this document"),
);
expect(saveButton).not.toBeNull();
@@ -5722,7 +6207,7 @@ describe("MessageList", () => {
const artifactCard = container.querySelector("button");
expect(artifactCard?.textContent).toContain("demo.md");
expect(artifactCard?.textContent).toContain("docs/demo.md");
expect(container.textContent).toContain("Document 产物");
expect(container.textContent).toContain("Document artifact");
act(() => {
artifactCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -125,6 +125,8 @@ import {
useAgentStreamTextOverlay,
} from "../hooks/agentStreamTextOverlayStore";
import { INPUTBAR_BUILTIN_COMMANDS } from "../skill-selection/builtinCommands";
import { isRetainedSkillProcessMessage } from "../utils/skillInlineProcessRetention";
import { formatNumber } from "@/i18n/format";
interface MessageListProps {
sessionId?: string | null;
@@ -348,7 +350,7 @@ function buildLongHistoricalMessagePreview(content: string): string {
}
function formatContentLength(value: number): string {
return value.toLocaleString("zh-CN");
return formatNumber(value);
}
interface HistoricalAssistantMessagePreviewProps {
@@ -364,7 +366,11 @@ function HistoricalAssistantMessagePreview({
variant,
onExpand,
}: HistoricalAssistantMessagePreviewProps) {
const { t } = useTranslation("agent");
const isLong = variant === "long";
const noticeKey = isLong
? "agentChat.messageList.historicalAssistantPreview.longNotice"
: "agentChat.messageList.historicalAssistantPreview.compactNotice";
return (
<div
@@ -381,15 +387,16 @@ function HistoricalAssistantMessagePreview({
</div>
<div className="flex flex-wrap items-center justify-between gap-2 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-3 py-2 text-sm text-slate-600">
<span>
{isLong ? "此历史消息较长" : "历史助手回复较长"}(约{" "}
{formatContentLength(contentLength)} 字),已先展示纯文本预览。
{t(noticeKey, {
countLabel: formatContentLength(contentLength),
})}
</span>
<button
type="button"
className="rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-100"
onClick={onExpand}
>
展开完整内容
{t("agentChat.messageList.historicalAssistantPreview.expandFull")}
</button>
</div>
</div>
@@ -407,9 +414,22 @@ const HistoricalMarkdownHydrationPreview: React.FC<{ content: string }> = ({
</div>
);
const ImageWorkbenchAssistantIntro: React.FC<{ content: string }> = ({
content,
}) => (
<div
data-testid="image-workbench-assistant-intro"
className="max-w-[760px] whitespace-pre-line break-words px-0.5 text-[15px] leading-7 text-slate-950"
>
{content}
</div>
);
function summarizeHistoricalTimelineItems(items: AgentThreadItem[]): {
stepsCount: number;
metaText: string;
toolStepsCount: number;
thinkingStepsCount: number;
artifactStepsCount: number;
} {
const visibleItems = items.filter((item) => {
if (item.type === "user_message" || item.type === "agent_message") {
@@ -437,15 +457,11 @@ function summarizeHistoricalTimelineItems(items: AgentThreadItem[]): {
const artifactStepsCount = visibleItems.filter(
(item) => item.type === "file_artifact",
).length;
const metaParts = [
toolStepsCount > 0 ? `${toolStepsCount} 个工具步骤` : null,
thinkingStepsCount > 0 ? `${thinkingStepsCount} 条思路` : null,
artifactStepsCount > 0 ? `${artifactStepsCount} 份产物` : null,
].filter((part): part is string => Boolean(part));
return {
stepsCount: visibleItems.length,
metaText: metaParts.length > 0 ? metaParts.join(",") : "执行细节已折叠",
toolStepsCount,
thinkingStepsCount,
artifactStepsCount,
};
}
@@ -455,6 +471,7 @@ const HistoricalTimelinePreview: React.FC<{
detailsDeferred?: boolean;
onExpand: () => void;
}> = ({ items, placement, detailsDeferred = false, onExpand }) => {
const { t } = useTranslation("agent");
const summary = useMemo(
() => summarizeHistoricalTimelineItems(items),
[items],
@@ -463,10 +480,34 @@ const HistoricalTimelinePreview: React.FC<{
if (summary.stepsCount <= 0 && !detailsDeferred) {
return null;
}
const metaParts = [
summary.toolStepsCount > 0
? t("agentChat.messageList.historicalTimeline.toolSteps", {
countLabel: formatContentLength(summary.toolStepsCount),
})
: null,
summary.thinkingStepsCount > 0
? t("agentChat.messageList.historicalTimeline.thinkingSteps", {
countLabel: formatContentLength(summary.thinkingStepsCount),
})
: null,
summary.artifactStepsCount > 0
? t("agentChat.messageList.historicalTimeline.artifactSteps", {
countLabel: formatContentLength(summary.artifactStepsCount),
})
: null,
].filter((part): part is string => Boolean(part));
const summaryMetaText =
metaParts.length > 0
? metaParts.join(t("agentChat.messageList.historicalTimeline.separator"))
: t("agentChat.messageList.historicalTimeline.foldedMeta");
const metaText =
summary.stepsCount > 0
? `${summary.stepsCount} 步 · ${summary.metaText}`
: "点击展开后加载执行细节";
? t("agentChat.messageList.historicalTimeline.meta", {
stepCountLabel: formatContentLength(summary.stepsCount),
meta: summaryMetaText,
})
: t("agentChat.messageList.historicalTimeline.deferredMeta");
return (
<button
@@ -476,13 +517,15 @@ const HistoricalTimelinePreview: React.FC<{
onClick={onExpand}
>
<span className="min-w-0 flex-1">
<span className="block font-medium text-slate-800">执行过程已折叠</span>
<span className="block font-medium text-slate-800">
{t("agentChat.messageList.historicalTimeline.title")}
</span>
<span className="mt-0.5 block text-xs leading-5 text-slate-500">
{metaText}
</span>
</span>
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-medium text-slate-700">
展开
{t("agentChat.messageList.historicalTimeline.expand")}
</span>
</button>
);
@@ -577,22 +620,12 @@ const AssistantFirstTokenRuntimeStatus: React.FC<{
}> = ({ status }) => {
const { t } = useTranslation("agent");
const phase = status?.phase || "submitted";
const submittedTitle = t(
"agentChat.messageList.firstTokenStatus.submitted.title",
);
const submittedDetail = t(
"agentChat.messageList.firstTokenStatus.submitted.detail",
);
const title = truncateRuntimeStatusText(
t(`agentChat.messageList.firstTokenStatus.${phase}.title`, {
defaultValue: submittedTitle,
}),
t(`agentChat.messageList.firstTokenStatus.${phase}.title`),
48,
);
const detail = truncateRuntimeStatusText(
t(`agentChat.messageList.firstTokenStatus.${phase}.detail`, {
defaultValue: submittedDetail,
}),
t(`agentChat.messageList.firstTokenStatus.${phase}.detail`),
120,
);
@@ -641,90 +674,12 @@ const AssistantStreamingInlineIndicator: React.FC<{
aria-hidden
/>
<span>
{t(`agentChat.messageList.streamingInline.${status}`, {
defaultValue: isQueued ? "等待输出" : "正在输出",
})}
{t(`agentChat.messageList.streamingInline.${status}`)}
</span>
</div>
);
};
const ImageWorkbenchAssistantIntro: React.FC<{ content: string }> = ({
content,
}) => {
const lines = content
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return null;
}
return (
<div
data-testid="image-workbench-assistant-intro"
className="max-w-[680px] space-y-0.5 text-[15px] leading-7 text-slate-950"
>
{lines.map((line, index) => (
<p
key={`${index}:${line}`}
className={index === 2 ? "m-0 pt-2" : "m-0"}
>
{line}
</p>
))}
</div>
);
};
const ImageWorkbenchAssistantHeader: React.FC<{ label: string }> = ({
label,
}) => (
<div
data-testid="image-workbench-assistant-header"
className="mb-5 flex items-center gap-2.5"
>
<svg
aria-hidden
className="h-8 w-8 shrink-0"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.4 18.2C3.6 12.8 7.7 8.4 14.3 7.4c7-1 12.4 2.1 13.3 7.5.8 5.5-3.5 9.8-10.4 10.8C10.4 26.7 5.2 23.5 4.4 18.2Z"
fill="#D9F222"
stroke="#111111"
strokeWidth="1.7"
/>
<path
d="M7 12.1c1.9-3.1 6.9-4.8 12-3.8 2.3.4 4.2 1.4 5.6 2.7-2.1 1.2-5.1 1.8-8.2 1.4-3.8-.5-7-1.5-9.4-.3Z"
fill="#F0FF63"
/>
<path
d="M5.7 18.4c4.5-.6 7.4-2.4 9.2-5.6 1.1 3.5-.3 7-3.6 8.3-2.2.8-4.3.2-5.6-2.7Z"
fill="#111111"
/>
<path
d="M26.4 15.1c-4.3.4-7.1-.7-9-3.7-.4 3.8 1.8 6.9 5.2 7.4 2.1.4 3.4-.8 3.8-3.7Z"
fill="#111111"
/>
<circle cx="11.7" cy="14.6" r="2.2" fill="#E9FF54" />
<circle cx="22" cy="13.3" r="2" fill="#E9FF54" />
<path
d="M12.1 22.1c2.7 1.1 6.2.6 8.4-1.3"
stroke="#111111"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
<span className="text-[15px] font-semibold leading-6 text-slate-900">
{label}
</span>
</div>
);
const USER_COMMAND_TAG_CANDIDATES = INPUTBAR_BUILTIN_COMMANDS.map((command) =>
command.commandPrefix.trim(),
)
@@ -749,11 +704,16 @@ function parseLeadingUserCommandTag(
route?: Message["inputCapabilityRoute"],
): { tag: string; body: string } | null {
const trimmed = content.trimStart();
const routePrefix = resolveUserCommandRoutePrefix(route);
if (!trimmed.startsWith("@")) {
return null;
return routePrefix
? {
tag: routePrefix,
body: content.trim(),
}
: null;
}
const routePrefix = resolveUserCommandRoutePrefix(route);
const candidates = routePrefix
? [
routePrefix,
@@ -789,11 +749,14 @@ const UserCommandMessageContent: React.FC<{
if (!command) {
return null;
}
const ariaLabel = command.body
? `${command.tag} ${command.body}`.trim()
: command.tag;
return (
<div
data-testid="message-user-command-content"
aria-label={content}
aria-label={ariaLabel}
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[15px] leading-7 text-slate-950"
>
<span
@@ -957,6 +920,29 @@ function hasInlineThinkingContent(message: Message): boolean {
);
}
function resolveInlineThinkingContent(message: Message): string | undefined {
const explicitThinking = message.thinkingContent?.trim()
? message.thinkingContent
: undefined;
if (explicitThinking) {
return explicitThinking;
}
const thinkingText = (message.contentParts || [])
.filter(
(
part,
): part is Extract<
NonNullable<Message["contentParts"]>[number],
{ type: "thinking"; text: string }
> => part.type === "thinking" && part.text.trim().length > 0,
)
.map((part) => part.text)
.join("");
return thinkingText.trim() ? thinkingText : undefined;
}
function hasNonTextInlineProcessPart(message: Message): boolean {
return Boolean(
message.contentParts?.some(
@@ -1278,6 +1264,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
activePendingA2UISource = null,
providerType,
}) => {
const { t } = useTranslation("agent");
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const previousVisibleMessageCountRef = useRef<number | null>(null);
@@ -2029,6 +2016,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
return true;
}
if (
isRetainedSkillProcessMessage(message) &&
hasInlineThinkingContent(message)
) {
return true;
}
if (!isConversationTailAssistant) {
return false;
}
@@ -2041,6 +2035,14 @@ const MessageListInner: React.FC<MessageListProps> = ({
return true;
}
if (
!hasTurnContext &&
message.runtimeTurnId?.trim() &&
hasInlineThinkingContent(message)
) {
return true;
}
const hasRunningToolCall =
(message.toolCalls || []).some(
(toolCall) => toolCall.status === "running",
@@ -2166,10 +2168,10 @@ const MessageListInner: React.FC<MessageListProps> = ({
try {
await navigator.clipboard.writeText(content);
setCopiedId(id);
toast.success("已复制到剪贴板");
toast.success(t("agentChat.messageList.toast.copySuccess"));
setTimeout(() => setCopiedId(null), 2000);
} catch {
toast.error("复制失败");
toast.error(t("agentChat.messageList.toast.copyFailed"));
}
};
@@ -2211,9 +2213,14 @@ const MessageListInner: React.FC<MessageListProps> = ({
msg.imageWorkbenchPreview ||
shouldSuppressStandaloneImageWorkbenchProcess,
);
const shouldSuppressImageWorkbenchAssistantText =
msg.role === "assistant" &&
Boolean(msg.imageWorkbenchPreview) &&
shouldSuppressImageWorkbenchStatusText(rawDisplayContent);
const visibleRawDisplayContent =
msg.role === "assistant" &&
shouldSuppressImageWorkbenchStatusText(rawDisplayContent)
(shouldSuppressImageWorkbenchAssistantText ||
shouldSuppressStandaloneImageWorkbenchProcess)
? ""
: rawDisplayContent;
const displayContent = sanitizeMessageTextForDisplay(
@@ -2271,6 +2278,10 @@ const MessageListInner: React.FC<MessageListProps> = ({
msg.role === "assistant" && includeInlineProcessFlow
? msg.thinkingContent
: undefined;
const imageWorkbenchThinkingContent =
msg.role === "assistant" && msg.imageWorkbenchPreview
? resolveInlineThinkingContent(msg)
: undefined;
const conversationToolCalls =
msg.role === "assistant" && includeInlineProcessFlow
? msg.toolCalls
@@ -2785,9 +2796,6 @@ const MessageListInner: React.FC<MessageListProps> = ({
>
{msg.role === "assistant" ? (
<>
{msg.imageWorkbenchPreview ? (
<ImageWorkbenchAssistantHeader label={assistantLabel} />
) : null}
{shouldRenderPrimaryTimelineOutsideBubble
? null
: primaryTimelineNode}
@@ -2812,8 +2820,27 @@ const MessageListInner: React.FC<MessageListProps> = ({
<HistoricalMarkdownHydrationPreview
content={rendererContent}
/>
) : msg.imageWorkbenchPreview && hasVisibleAssistantText ? (
<ImageWorkbenchAssistantIntro content={rendererContent} />
) : msg.imageWorkbenchPreview ? (
<>
{imageWorkbenchThinkingContent ? (
<StreamingRenderer
content=""
rawContent=""
isStreaming={false}
showCursor={false}
thinkingContent={imageWorkbenchThinkingContent}
contentParts={undefined}
toolCalls={undefined}
actionRequests={undefined}
markdownRenderMode="light"
/>
) : null}
{hasVisibleAssistantText ? (
<ImageWorkbenchAssistantIntro
content={rendererContent}
/>
) : null}
</>
) : (
<StreamingRenderer
content={rendererContent}
@@ -3028,8 +3055,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
size="icon"
className="h-7 w-7 rounded-full border border-slate-200/90 bg-white/92 text-slate-400 shadow-sm shadow-slate-950/5 hover:bg-slate-50 hover:text-slate-700"
onClick={() => onQuoteMessage?.(actionContent, msg.id)}
aria-label="引用消息"
title="引用消息"
aria-label={t("agentChat.messageList.actions.quote")}
title={t("agentChat.messageList.actions.quote")}
>
<Quote size={12} />
</Button>
@@ -3040,8 +3067,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
size="icon"
className="h-7 w-7 rounded-full border border-slate-200/90 bg-white/92 text-slate-400 shadow-sm shadow-slate-950/5 hover:bg-slate-50 hover:text-slate-700"
onClick={() => handleCopy(actionContent, msg.id)}
aria-label="复制消息"
title="复制消息"
aria-label={t("agentChat.messageList.actions.copy")}
title={t("agentChat.messageList.actions.copy")}
>
{copiedId === msg.id ? (
<Check size={12} className="text-emerald-600" />
@@ -3061,8 +3088,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
content: actionContent,
})
}
aria-label="保存为技能"
title="保存为技能"
aria-label={t("agentChat.messageList.actions.saveAsSkill")}
title={t("agentChat.messageList.actions.saveAsSkill")}
>
<Sparkles size={12} />
</Button>
@@ -3078,8 +3105,12 @@ const MessageListInner: React.FC<MessageListProps> = ({
content: actionContent,
})
}
aria-label="保存到灵感库"
title="保存到灵感库"
aria-label={t(
"agentChat.messageList.actions.saveToInspiration",
)}
title={t(
"agentChat.messageList.actions.saveToInspiration",
)}
>
<BookmarkPlus size={12} />
</Button>
@@ -3097,11 +3128,17 @@ const MessageListInner: React.FC<MessageListProps> = ({
description: knowledgeArtifactSource?.description,
})
}
aria-label="保存到项目资料"
title="保存到项目资料"
aria-label={t(
"agentChat.messageList.actions.saveToKnowledge",
)}
title={t(
"agentChat.messageList.actions.saveToKnowledge",
)}
>
<FileText size={12} />
<span>保存到项目资料</span>
<span>
{t("agentChat.messageList.actions.saveToKnowledge")}
</span>
</Button>
) : null}
</MessageActions>
@@ -3166,11 +3203,11 @@ const MessageListInner: React.FC<MessageListProps> = ({
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-1.5">
<span className="inline-flex rounded-full border border-sky-200 bg-white px-2 py-0.5 text-[11px] font-semibold text-sky-700">
Document 产物
{t("agentChat.messageList.artifact.documentBadge")}
</span>
{knowledgeSource ? (
<span className="inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
可保存到项目资料
{t("agentChat.messageList.artifact.saveableBadge")}
</span>
) : null}
</div>
@@ -3190,7 +3227,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
</span>
) : artifact.status === "streaming" ? (
<span className="text-xs text-slate-500">
正在准备文件内容...
{t("agentChat.messageList.artifact.streaming")}
</span>
) : null}
</div>
@@ -3210,7 +3247,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
}
className="flex shrink-0 items-center justify-center rounded-lg border border-sky-200 bg-white px-3 py-2 text-xs font-semibold text-sky-700 transition-colors hover:border-sky-300 hover:bg-sky-100 sm:py-0"
>
保存这份文档
{t("agentChat.messageList.artifact.saveDocument")}
</button>
) : null}
</div>
@@ -3242,10 +3279,16 @@ const MessageListInner: React.FC<MessageListProps> = ({
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-4 py-3 text-sm text-slate-600"
>
<div className="min-w-0 flex-1">
为了更快打开旧对话,当前先展示最近{" "}
{sessionHistoryWindow?.loadedMessages ?? renderedMessages.length}{" "}
/ {sessionHistoryWindow?.totalMessages ?? renderedMessages.length}{" "}
条消息。
{t("agentChat.messageList.history.persistedSummary", {
loaded: formatContentLength(
sessionHistoryWindow?.loadedMessages ??
renderedMessages.length,
),
total: formatContentLength(
sessionHistoryWindow?.totalMessages ??
renderedMessages.length,
),
})}
{sessionHistoryWindow?.error ? (
<span className="ml-2 text-red-600">
{sessionHistoryWindow.error}
@@ -3265,8 +3308,8 @@ const MessageListInner: React.FC<MessageListProps> = ({
}}
>
{sessionHistoryWindow?.isLoadingFull
? "正在加载更多历史"
: "加载更多历史"}
? t("agentChat.messageList.history.loadingMore")
: t("agentChat.messageList.history.loadMore")}
</button>
</div>
) : null}
@@ -3276,11 +3319,15 @@ const MessageListInner: React.FC<MessageListProps> = ({
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/80 px-4 py-3 text-sm text-slate-600"
>
<div className="min-w-0 flex-1">
为了更快打开对话,当前先展示最近 {renderedMessages.length}{" "}
条消息,
{isRestoredHistoryWindow
? `更早的 ${hiddenHistoryCount} 条可按需展开。`
: `更早的 ${hiddenHistoryCount} 条会在空闲时继续补齐。`}
{t(
isRestoredHistoryWindow
? "agentChat.messageList.history.windowSummaryRestored"
: "agentChat.messageList.history.windowSummaryDeferred",
{
loaded: formatContentLength(renderedMessages.length),
hidden: formatContentLength(hiddenHistoryCount),
},
)}
</div>
<button
type="button"
@@ -3288,7 +3335,7 @@ const MessageListInner: React.FC<MessageListProps> = ({
className="shrink-0 rounded-full border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100"
onClick={handleExpandAllHistory}
>
立即展开更早消息
{t("agentChat.messageList.history.expandEarlier")}
</button>
</div>
) : null}
@@ -3305,10 +3352,10 @@ const MessageListInner: React.FC<MessageListProps> = ({
</div>
<div className="space-y-1 text-center">
<p className="text-lg font-medium text-foreground">
正在恢复生成会话...
{t("agentChat.messageList.restoring.title")}
</p>
<p className="text-sm text-muted-foreground">
正在同步最近一次生成会话,请稍候。
{t("agentChat.messageList.restoring.description")}
</p>
</div>
</div>
@@ -3328,18 +3375,18 @@ const MessageListInner: React.FC<MessageListProps> = ({
</div>
<div className="min-w-0 flex-1">
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600">
对话
{t("agentChat.messageList.taskCenterEmpty.badge")}
</span>
<div className="mt-4 space-y-2">
<h2 className="text-[32px] font-semibold tracking-tight text-slate-900 md:text-[36px]">
最近对话
{t("agentChat.messageList.taskCenterEmpty.title")}
</h2>
<p className="max-w-[48rem] text-[15px] leading-7 text-slate-600">
这里集中展示最近对话、待继续会话和更早归档,方便你随时回到上一次工作现场。
{t("agentChat.messageList.taskCenterEmpty.description")}
</p>
<p className="text-sm leading-7 text-slate-500">
还没有对话时,可以先从“新建对话”开始;后续的结果、素材和中间过程都会继续留在这里。
{t("agentChat.messageList.taskCenterEmpty.helper")}
</p>
</div>
</div>
@@ -3347,13 +3394,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
<div className="mt-6 flex flex-wrap gap-2">
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
左侧会优先显示待继续的对话
{t("agentChat.messageList.taskCenterEmpty.chip.pending")}
</span>
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
最近对话和归档会按时间自动整理
{t("agentChat.messageList.taskCenterEmpty.chip.organized")}
</span>
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
恢复中的会话会自动回到这里继续
{t("agentChat.messageList.taskCenterEmpty.chip.restore")}
</span>
</div>
</section>
@@ -3365,7 +3412,9 @@ const MessageListInner: React.FC<MessageListProps> = ({
alt={LIME_BRAND_NAME}
className="w-12 h-12 mb-4 opacity-20"
/>
<p className="text-lg font-medium">开始一段新的对话吧</p>
<p className="text-lg font-medium">
{t("agentChat.messageList.empty.defaultTitle")}
</p>
</div>
))}
@@ -1,5 +1,6 @@
import styled from "styled-components";
import type { HomeGuideCard } from "./homeSurfaceTypes";
import type { HomeSurfaceChromeCopy } from "./homeSurfaceCopy";
const GuideGrid = styled.div`
display: grid;
@@ -83,16 +84,17 @@ const GuideSummary = styled.span`
interface HomeGuideCardsProps {
cards: HomeGuideCard[];
copy: Pick<HomeSurfaceChromeCopy, "guideCardsLabel">;
onSelect: (card: HomeGuideCard) => void;
}
export function HomeGuideCards({ cards, onSelect }: HomeGuideCardsProps) {
export function HomeGuideCards({ cards, copy, onSelect }: HomeGuideCardsProps) {
if (cards.length === 0) {
return null;
}
return (
<GuideGrid data-testid="home-guide-cards" aria-label="首页引导帮助">
<GuideGrid data-testid="home-guide-cards" aria-label={copy.guideCardsLabel}>
{cards.map((card) => (
<GuideButton
key={card.id}
@@ -7,9 +7,14 @@ import type {
HomeSkillSection,
HomeSkillSurfaceItem,
} from "./homeSurfaceTypes";
import type { HomeSurfaceChromeCopy } from "./homeSurfaceCopy";
const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = [];
const TEST_CHROME_COPY: Pick<HomeSurfaceChromeCopy, "moreSkillsDrawerLabel"> = {
moreSkillsDrawerLabel: "更多做法",
};
beforeEach(() => {
(
globalThis as typeof globalThis & {
@@ -48,6 +53,7 @@ function renderDrawer(
root.render(
<HomeMoreSkillsDrawer
open={open}
copy={TEST_CHROME_COPY}
sections={sections}
onSelectItem={onSelectItem}
/>,
@@ -3,6 +3,7 @@ import type {
HomeSkillSection,
HomeSkillSurfaceItem,
} from "./homeSurfaceTypes";
import type { HomeSurfaceChromeCopy } from "./homeSurfaceCopy";
import { resolveHomeCoverAsset } from "./homeCoverAssets";
const Drawer = styled.div`
@@ -131,12 +132,14 @@ function resolveCoverGradient(token: string): string {
interface HomeMoreSkillsDrawerProps {
open: boolean;
copy: Pick<HomeSurfaceChromeCopy, "moreSkillsDrawerLabel">;
sections: HomeSkillSection[];
onSelectItem: (item: HomeSkillSurfaceItem) => void;
}
export function HomeMoreSkillsDrawer({
open,
copy,
sections,
onSelectItem,
}: HomeMoreSkillsDrawerProps) {
@@ -145,7 +148,10 @@ export function HomeMoreSkillsDrawer({
}
return (
<Drawer data-testid="home-more-skills-drawer" aria-label="更多做法">
<Drawer
data-testid="home-more-skills-drawer"
aria-label={copy.moreSkillsDrawerLabel}
>
{sections.map((section) => (
<Section key={section.id}>
<SectionTitle>{section.title}</SectionTitle>

Some files were not shown because too many files have changed in this diff Show More