mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.38.0
This commit is contained in:
+29
-29
@@ -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. 日志能区分下载失败、模型损坏、运行时加载失败和识别失败。
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
null
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"oldPrompt": false,
|
||||
"oldLimePrompt": false,
|
||||
"imagePreviewCount": 0,
|
||||
"errorsText": false
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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马上生成"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+18
-18
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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("输入法切换"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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("输入法切换"));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: 模块化代码组织
|
||||
@@ -1,8 +0,0 @@
|
||||
//! 屏幕截图服务(桥接层)
|
||||
//!
|
||||
//! 纯逻辑已迁移到 `lime-services` crate,
|
||||
//! 本模块保留兼容导出。
|
||||
|
||||
pub use lime_services::screenshot_capture_service::{
|
||||
cleanup_temp_file, start_capture, CaptureError, CaptureResult,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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/` 目录:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
/// 执行语音识别
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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,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,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
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+30
-16
@@ -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" />
|
||||
|
||||
+73
@@ -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
Reference in New Issue
Block a user