diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cdc1341c4..7669cbb79 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,79 +1,69 @@ -## Lime v1.18.0 +## Lime v1.19.0 -发布日期:`2026-04-24` +发布日期:`2026-04-25` ### 发布概览 -- 本次发布目标 tag 为 `v1.18.0`。 -- 本说明按当前待递交工作树重新整理,覆盖这次准备一起递交的版本同步、Rust/runtime、前端工作台、SceneApp、Memory、automation、bridge、roadmap 文档与 Playwright 复盘材料,不再沿用旧版“只写版本号”的简化摘要。 -- 本次重新发布还补充纳入了聊天空态、技能入口、图片预览容错,以及 macOS release workflow 诊断文案修正,确保 `v1.18.0` 对外指向的是当前完整提交状态。 +- 本次发布目标 tag 为 `v1.19.0`。 +- 本说明按当前待递交工作树重新整理,覆盖这次准备一起递交的版本同步、Agent runtime/current surface、聊天工作台布局、Task Center、SceneApp、Memory、automation、bridge、roadmap 文档与现场材料,不再沿用旧版“只写版本号”的简化摘要。 +- 本次发布额外吸纳了近期关于人物切换、最新对话容器高度、导航层级精简与工作台展示密度的一组 UI 收口,确保 `v1.19.0` 对外指向的是当前完整提交状态。 ### 递交范围 -- 已跟踪改动主要分布在:`src/` `94` 个文件、`src-tauri/` `42` 个文件、`docs/` `6` 个文件,以及根目录/版本与打包文件。 -- 未跟踪新增项共 `27` 个,包含: - - `docs/roadmap/task/` 任务层/模型层/经济调度路线图整套专题文档 - - `18` 份 Playwright 现场记录与 `playwright-network-after-image.json` 抓包结果 - - `src-tauri/src/dev_bridge/dispatcher/files.rs` - - `src/components/agent/chat/hooks/agentSilentTurnRecovery.{ts,test.ts}` - - `src/components/agent/chat/utils/saveSceneAppExecutionAsInspiration.{ts,test.ts}` - - `src/components/agent/chat/utils/sceneAppExecutionInspirationDraft.{ts,test.ts}` - - `src/components/memory/MemoryCuratedTaskSuggestionPanel.tsx` -- 本次 release note 以“整批递交”视角书写,默认上述内容均属于本轮提交范围。 +- 已跟踪改动主要覆盖:`src/` 工作台与业务页面、`src-tauri/` runtime 与命令面、`docs/` 路线图与执行文档,以及根目录版本/打包文件。 +- 未跟踪新增项主要包含: + - 工作台布局与任务中心相关新文件:`TaskCenterTabStrip`、`chatLayoutVisibility`、`taskCenterTabs`、`chatSurfaceProps.test.ts` + - Rust 侧新增服务实现:`src-tauri/src/services/runtime_auxiliary_projection_service.rs` + - 一组 Playwright/截图/控制台现场材料,用于保留这批 GUI 调整与回归排查证据 +- 本次 release note 继续以“整批递交”视角书写,默认当前工作树中的这些版本、代码、文档和现场材料都属于本轮提交范围。 ### 重点更新 -#### 0. 补充收口:发布前端构建阻断与 release 诊断信息校正 +#### 0. 发布同步与提交口径收口 -- `src/components/agent/chat/components/{EmptyState,ImageTaskViewer}.tsx`、`src/components/agent/chat/skill-selection/{CharacterMentionPanel,inputCapabilitySections}.ts` 与配套测试继续收口聊天空态、技能入口和图片预览体验。 -- `src/components/provider-pool/api-key/ProviderModelList.tsx` 修正 provider 模型来源判断的类型收窄问题,避免 release 构建因前端 TypeScript 报错提前中断。 -- `ImageTaskViewer.tsx` 为补图预览和弹窗预览统一补齐 `RenderableTaskImage` 的 fallback 渲染,避免新 props 约束下的构建失败。 -- `.github/workflows/release.yml` 将原先误导性的 “macOS notarization failed twice” 兜底报错改成“release build failed before notarized artifact became available”,方便后续直接定位真实前序构建错误,而不是被公证文案误导。 +- 根目录应用版本、Tauri 配置、CLI wrapper、Cargo 版本与 release note 统一提升到 `1.19.0`,避免发布 tag、桌面端版本、npm wrapper 和分发说明继续错位。 +- 本说明继续按“当前完整工作树一起递交”的口径整理,明确这次不是单点补版本,而是把当前待提交的 runtime、前端、文档和证据材料统一归档到同一个发布切片。 +- 校验章节会同步记录这次实际跑过的 `verify:app-version`、Rust 校验和前端 lint,方便后续直接作为发布前检查清单引用。 -#### 1. Agent runtime、任务层与模型层主线继续 current 化 +#### 1. Agent runtime、任务层与模型层继续 current 化 -- `src-tauri/crates/agent/src/session_execution_runtime.rs`、`protocol.rs`、`provider_safety.rs`、`credential_bridge.rs`,以及 `src-tauri/src/commands/aster_agent_cmd/{request_model_resolution,runtime_turn,dto,subagent_runtime}.rs` 继续扩展 runtime 路由、任务画像、provider 安全边界、会话上下文与事件投影。 -- `src-tauri/crates/aster-rust/crates/aster/src/{agents/agent.rs,skills/tool.rs}` 与 `src-tauri/src/agent/{aster_agent.rs,mod.rs}` 同步收敛 current runtime surface,减少 compat 语义继续回流。 -- 新增 `docs/roadmap/task/README.md` 及 `overview.md`、`architecture.md`、`task-taxonomy.md`、`model-routing.md`、`oem-and-local-policy.md`、`cost-limit-events.md`、`event-chain.md`、`runtime-integration.md`、`diagrams.md`、`rollout-plan.md`、`acceptance.md`,把任务层、模型层、成本/限额事件与 OEM/本地协同沉淀为专题路线图。 +- `src-tauri/crates/agent/`、`src-tauri/src/commands/aster_agent_cmd/`、`src-tauri/src/services/` 与 `src/lib/api/agentRuntime/` 继续围绕 session runtime、tool policy、handoff/evidence/replay/review 以及请求模型解析收敛 current surface。 +- `src-tauri/crates/core/` 与 `src-tauri/crates/services/` 继续补齐 agent session 数据层、schema、模型注册和 provider 归一化,减少运行时事实源分叉。 +- `docs/roadmap/task/`、`docs/exec-plans/limenext-progress.md` 与相关 acceptance/runtime-integration 文档同步更新,把任务层、模型层和交付验收条件继续沉淀为可追踪路线图。 -#### 2. 模型路由、媒体运行时与图片任务协议升级 +#### 2. 工作台、导航层级与布局展示继续整理 -- `src-tauri/crates/services/src/model_registry_service.rs`、`src-tauri/crates/core/src/api_host_utils.rs`、`src-tauri/src/commands/auxiliary_model_selection.rs` 持续补齐模型 taxonomy、host 归一化与服务模型选择主链。 -- `src-tauri/crates/media-runtime/src/lib.rs`、`src-tauri/crates/server/src/handlers/image_api_provider.rs`、`src-tauri/src/commands/media_task_cmd.rs`、`src-tauri/src/commands/aster_agent_cmd/tool_runtime/{creation_tools,media_cli_bridge}.rs` 继续收敛图片任务创建、artifact、执行与桥接协议。 -- 本轮额外修正两处图片任务契约漂移: - - 图片任务 tool schema 不再公开 `outputPath`,但仍保留 `output_path` 兼容解析。 - - 图片任务 artifact 内的 `storyboard_slots` 逐格字段统一按 snake_case 写回,避免 current 文件协议和测试断言继续分叉。 -- `src-tauri/resources/default-skills/image_generate/SKILL.md` 与相关 command/runtime 也同步更新,保持默认技能与当前运行时口径一致。 +- `src/components/agent/chat/components/`、`workspace/`、`hooks/` 与 `utils/` 大范围调整聊天工作台、导航栏、侧栏、对话区和画布区的布局关系,重点围绕一级导航收敛、人物切换稳定性、最新对话容器高度和页面可视区域利用率继续修正。 +- 新增 `TaskCenterTabStrip`、`chatLayoutVisibility`、`taskCenterTabs` 等文件,把任务中心与聊天主视图的布局控制收成更清晰的 current 结构,减少 UI 逻辑散落在多个组件里。 +- `MessageList`、`ChatSidebar`、`ChatNavbar`、`WorkspaceConversationScene`、`WorkspaceMainArea`、`WorkspaceShellScene` 及其测试一并更新,确保工作台密度调整不会把最新对话区域挤空、重载或遮挡。 -#### 3. 聊天工作台、图片工作流、SceneApp 与 Memory 继续重构 +#### 3. SceneApp、Memory、Resources 与设置页继续并行收口 -- `src/components/agent/chat/` 大范围更新,涉及 `AgentChatWorkspace.tsx`、`EmptyState.tsx`、`CuratedTaskLauncherDialog.tsx`、`CharacterMention{,Panel}.tsx`、`CuratedTaskBadge.tsx`、`ImageTaskViewer.tsx`、`ImageWorkbenchMessagePreview.tsx`、`useWorkspaceImageTaskPreviewRuntime.ts`、`useWorkspaceImageWorkbenchActionRuntime.ts`、`useWorkspaceSendActions.ts`、`workspaceSendHelpers.ts` 等主链文件及配套测试。 -- 新增 `agentSilentTurnRecovery`,用于处理用户发送后出现“静默 turn”时的恢复判定,减少聊天面板看起来“没发出去”但后台其实已入队的错觉。 -- 新增 `saveSceneAppExecutionAsInspiration` 与 `sceneAppExecutionInspirationDraft`,支持把 SceneApp 执行结果沉淀到灵感库,并与精选任务推荐信号联动。 -- `src/components/memory/MemoryPage.tsx` 与新增的 `MemoryCuratedTaskSuggestionPanel.tsx` 把 Memory 页面从“看数据”继续推进到“基于记忆继续下一步任务”。 -- `src/components/sceneapps/{SceneAppsPage,SceneAppRunDetailPanel,useSceneAppsPageRuntime}.tsx` 与 `SceneAppExecutionSummaryCard.tsx` 继续补齐 follow-up、复盘和结果去向表达。 +- `src/components/sceneapps/`、`src/components/memory/`、`src/components/resources/`、`src/components/skills/`、`src/components/settings-v2/` 继续同步调整页面结构、结果展示和 follow-up 入口,让工作台新布局和周边页面表达保持一致。 +- `useSceneAppsPageRuntime.ts`、`sceneAppExecutionFollowupDestinations.ts`、`SceneAppExecutionSummaryCard.tsx` 与相关测试持续补齐 SceneApp 结果去向、复盘和后续动作表达。 +- `MemoryPage.tsx`、`ResourcesPage.tsx`、`AutomationHealthPanel.tsx`、`AutomationJob*`、`ChannelsDebugWorkbench.tsx` 等页面也同步纳入这轮整理,避免只有聊天主路径更新、外围页面仍停留在旧密度和旧结构。 -#### 4. Provider 设置、服务模型偏好与自动化页面整理 +#### 4. Bridge、API、mock 与前端事实源同步补齐 -- `src/components/provider-pool/api-key/{ProviderSetting,ProviderModelList}.tsx` 及测试继续增强 provider 设置、模型推荐、默认图片服务偏好与 UI 反馈。 -- `src/lib/mediaGeneration.ts`、`src/components/image-gen/useImageGen.ts`、`src/components/workspace/video/VideoCanvas.tsx` 与媒体相关前端状态同步更新,进一步对齐媒体默认配置与工作台行为。 -- `src/components/settings-v2/system/automation/` 下的 `AutomationJobDetailsDialog`、`AutomationJobFocusStrip`、`AutomationOverviewFocusCard`、`index.tsx` 继续围绕自动化任务焦点、结果复盘与下一步动作整理 UI。 +- `src/lib/dev-bridge/`、`src/lib/api/`、`src/lib/governance/`、`src/lib/tauri-mock/` 与 `src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs` 等文件继续同步命令契约、前端客户端、mock 与治理目录册,减少 runtime surface 在桌面端和浏览器 fallback 之间继续漂移。 +- `src/lib/navigation/sidebarNav.ts`、`src/components/AppSidebar.tsx`、`src/RootRouter.tsx` 等导航事实源同步更新,配合这轮“只保留一级导航”的整理口径。 +- `src/hooks/useConfiguredProviders.ts`、`src/lib/serviceModels.ts`、`src/components/provider-pool/api-key/ProviderConfigForm.tsx` 及测试也随之刷新,保证 provider/model 配置与工作台入口描述保持一致。 -#### 5. Bridge、API、mock、文档与证据材料同步补齐 +#### 5. 文档、测试与现场材料继续跟上 -- 新增 `src-tauri/src/dev_bridge/dispatcher/files.rs`,补上会话文件保存、路径解析与文件预览桥接分发。 -- `src-tauri/src/dev_bridge/dispatcher/{app_runtime,providers,project_resources,agent_sessions}.rs`、`src/lib/api/{agentProtocol,agentExecutionRuntime}.ts`、`src/lib/api/agentRuntime/{agentClient,types}.ts`、`src/lib/dev-bridge/mockPriorityCommands.ts`、`src/lib/tauri-mock/core.ts` 同步更新,确保 bridge、mock、前端协议和 runtime 消费者保持同一组 current surface。 -- `docs/README.md`、`docs/aiprompts/{command-runtime,providers,query-loop,task-agent-taxonomy}.md`、`docs/exec-plans/limenext-progress.md` 同步刷新;未跟踪的 Playwright 记录与抓包文件一并纳入本轮递交说明,作为这批图片/工作台链路调整的现场证据。 +- 相关 `*.test.tsx` / `*.test.ts` 随工作台、导航、runtime、provider、SceneApp、settings 调整一并更新,保证这批用户可见改动有稳定回归。 +- `docs/roadmap/task/acceptance.md`、`runtime-integration.md` 与 `docs/exec-plans/limenext-progress.md` 继续同步当前主线进度和验收口径。 +- 截图、快照、console/network 导出等现场材料保留在工作树中,作为这批 GUI 调整和问题复盘的直接证据。 ### 版本同步 -- 应用版本事实源已同步为 `1.18.0`: +- 应用版本事实源已同步为 `1.19.0`: - `package.json` - `package-lock.json` - `src-tauri/Cargo.toml` - `src-tauri/tauri.conf.json` - `src-tauri/tauri.conf.headless.json` -- 发布与分发相关版本串已同步为 `1.18.0`: +- 发布与分发相关版本串已同步为 `1.19.0`: - `packages/lime-cli-npm/package.json` - `packages/lime-cli-npm/README.md` - `src-tauri/Cargo.lock` @@ -82,14 +72,19 @@ ### 校验状态 -- 本会话已实际通过: +- 本会话已实际执行并通过: - `npm run verify:app-version` - `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all` - - `npm run lint` - `cargo test --manifest-path "src-tauri/Cargo.toml"` - - `cargo clippy --manifest-path "src-tauri/Cargo.toml"` + - `cargo clippy --manifest-path "src-tauri/Cargo.toml" --all-targets --all-features` + - `npm run lint` +- `cargo test` 实际结果为 `1087 passed / 0 failed / 2 ignored`;ignored 用例是显式依赖真实联网环境的 `real_web_search_*`。 +- `cargo clippy` 通过,但当前工作树仍存在 3 条 warning: + - `crates/skills/src/lime_llm_provider.rs` 的 `too_many_arguments` + - `crates/agent/src/session_execution_runtime.rs` 的 `needless_lifetimes` + - `src/services/runtime_evidence_pack_service.rs` 的 `dead_code` --- -**完整变更**: `v1.17.0` -> `v1.18.0` +**完整变更**: `v1.18.0` -> `v1.19.0` diff --git a/advanced-settings-open.png b/advanced-settings-open.png new file mode 100644 index 000000000..52c86002d Binary files /dev/null and b/advanced-settings-open.png differ diff --git a/console-current.txt b/console-current.txt new file mode 100644 index 000000000..d86e05257 --- /dev/null +++ b/console-current.txt @@ -0,0 +1,190 @@ +Total messages: 148 (Errors: 45, Warnings: 13) +Returning 45 messages for level "error" + +[ERROR] Failed to load resource: net::ERR_INCOMPLETE_CHUNKED_ENCODING @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/invoke:0 +[ERROR] [DevBridge] HTTP 调用失败: get_api_key_providers TypeError: Failed to fetch + at fetchWithTimeout (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:93:18) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:325:28) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:22) + at async loadProviders (http://127.0.0.1:1420/src/lib/api/apiKeyProvider.ts?t=1777021306639:33:28) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [useApiKeyProvider] 获取 Provider 列表失败: Error: [DevBridge] 浏览器模式无法连接后端桥接,命令 "get_api_key_providers" 执行失败。请先启动 Tauri 开发后端(例如 npm run tauri:dev 或 npm run tauri:dev:headless),并确认 http://127.0.0.1:3030 可访问。原始错误: Failed to fetch + at normalizeDevBridgeError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:168:12) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:354:31) + at async loadProviders (http://127.0.0.1:1420/src/lib/api/apiKeyProvider.ts?t=1777021306639:33:28) + at async http://127.0.0.1:1420/src/hooks/useApiKeyProvider.ts?t=1777021306639:89:22 @ http://127.0.0.1:1420/src/hooks/useApiKeyProvider.ts?t=1777021306639:105 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/invoke:0 +[ERROR] [DevBridge] HTTP 调用失败: get_provider_ui_state TypeError: Failed to fetch + at fetchWithTimeout (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:93:18) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:325:28) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:22) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_local_skills_for_app Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:28) + at Object.getLocal (http://127.0.0.1:1420/src/lib/api/skills.ts?t=1777021306639:32:26) + at http://127.0.0.1:1420/src/components/agent/chat/hooks/useLimeSkills.ts?t=1777021306639:34:95 + at scheduleMinimumDelayIdleTask.minimumDelayMs (http://127.0.0.1:1420/src/components/agent/chat/hooks/useLimeSkills.ts?t=1777021306639:77:16) + at window.requestIdleCallback.timeout (http://127.0.0.1:1420/src/lib/utils/scheduleMinimumDelayIdleTask.ts:22:13) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_local_skills_for_app Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts?t=1777021306623:5822:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts?t=1777021306623:13:10) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:369:24) + at async Object.getLocal (http://127.0.0.1:1420/src/lib/api/skills.ts?t=1777021306639:32:20) + at async http://127.0.0.1:1420/src/components/agent/chat/hooks/useLimeSkills.ts?t=1777021306639:34:79 @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_automation_jobs Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:28) + at getAutomationJobs (http://127.0.0.1:1420/src/lib/api/automation.ts?t=1777021306639:12:10) + at http://127.0.0.1:1420/src/components/agent/chat/service-skills/useServiceSkills.ts?t=1777021885115:112:38 + at http://127.0.0.1:1420/src/components/agent/chat/service-skills/useServiceSkills.ts?t=1777021885115:133:11 + at async http://127.0.0.1:1420/src/components/agent/chat/service-skills/useServiceSkills.ts?t=1777021885115:150:24 @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_automation_jobs Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts?t=1777021306623:5822:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts?t=1777021306623:13:10) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: project_memory_get Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:28) + at getProjectMemory (http://127.0.0.1:1420/src/lib/api/memory.ts?t=1777021306639:65:26) + at scheduleMinimumDelayIdleTask.minimumDelayMs (http://127.0.0.1:1420/src/components/agent/chat/AgentChatWorkspace.tsx?t=1777021885115:1193:14) + at window.requestIdleCallback.timeout (http://127.0.0.1:1420/src/lib/utils/scheduleMinimumDelayIdleTask.ts:22:13) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: project_memory_get Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts?t=1777021306623:5822:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts?t=1777021306623:13:10) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: workspace_ensure_ready Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:28) + at ensureWorkspaceReady (http://127.0.0.1:1420/src/lib/api/project.ts?t=1777021306639:60:10) + at runWorkspaceCheck (http://127.0.0.1:1420/src/components/agent/chat/AgentChatWorkspace.tsx?t=1777021885115:1253:12) + at window.requestIdleCallback.timeout (http://127.0.0.1:1420/src/lib/utils/scheduleMinimumDelayIdleTask.ts:22:13) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: agent_runtime_list_sessions Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:28) + at http://127.0.0.1:1420/src/lib/api/agentRuntime/transport.ts?t=1777021306639:7:20 + at http://127.0.0.1:1420/src/lib/api/agentRuntime/transport.ts?t=1777021306639:20:20 + at Object.listAgentRuntimeSessions2 [as listAgentRuntimeSessions] (http://127.0.0.1:1420/src/lib/api/agentRuntime/sessionClient.ts?t=1777021413357:52:30) + at Object.listSessions (http://127.0.0.1:1420/src/components/agent/chat/hooks/agentRuntimeAdapter.ts?t=1777021885115:26:21) + at runListSessions (http://127.0.0.1:1420/src/components/agent/chat/hooks/useAgentSession.ts?t=1777021306639:376:15) + at window.requestIdleCallback.timeout (http://127.0.0.1:1420/src/lib/utils/scheduleMinimumDelayIdleTask.ts:22:13) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [AgentDebug] AgentApi.runtimeListSessions.error {durationMs: 1, error: Object} @ http://127.0.0.1:1420/src/lib/agentDebug.ts?t=1777021306639:99 +[ERROR] [AsterChat] 加载话题失败: Error: [DevBridge] 浏览器模式无法连接后端桥接,命令 "agent_runtime_list_sessions" 执行失败。请先启动 Tauri 开发后端(例如 npm run tauri:dev 或 npm run tauri:dev:headless),并确认 http://127.0.0.1:3030 可访问。原始错误: Failed to fetch (bridge cooldown active) + at normalizeDevBridgeError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:168:12) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:354:31) + at async http://127.0.0.1:1420/src/lib/api/agentRuntime/transport.ts?t=1777021306639:7:14 + at async http://127.0.0.1:1420/src/lib/api/agentRuntime/transport.ts?t=1777021306639:20:14 + at async Object.listAgentRuntimeSessions2 [as listAgentRuntimeSessions] (http://127.0.0.1:1420/src/lib/api/agentRuntime/sessionClient.ts?t=1777021413357:52:24) @ http://127.0.0.1:1420/src/components/agent/chat/hooks/useAgentSession.ts?t=1777021306639:391 +[ERROR] [AgentDebug] useAgentSession.listSessions.error {durationMs: 1, error: Object, workspaceId: 849e36ff-8f64-45ed-ba51-aab6b8e182e4} @ http://127.0.0.1:1420/src/lib/agentDebug.ts?t=1777021306639:99 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted:0 +[ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://127.0.0.1:3030/health:0 +[ERROR] [DevBridge] HTTP 调用失败: get_api_key_providers Error: Failed to fetch (bridge health check failed) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:151:11) + at async invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:5) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:22) + at async loadProviders (http://127.0.0.1:1420/src/lib/api/apiKeyProvider.ts?t=1777021306639:33:28) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_provider_ui_state Error: Failed to fetch (bridge health check failed) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:151:11) + at async invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:5) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:22) + at async http://127.0.0.1:1420/src/hooks/useApiKeyProvider.ts?t=1777021306639:121:29 @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_model_preferences Error: Failed to fetch (bridge health check failed) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:151:11) + at async invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:5) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:22) + at async Promise.all (index 1) + at async http://127.0.0.1:1420/src/hooks/useModelRegistry.ts?t=1777021306639:98:45 @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_model_sync_state Error: Failed to fetch (bridge health check failed) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:151:11) + at async invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:5) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:22) + at async Promise.all (index 2) + at async http://127.0.0.1:1420/src/hooks/useModelRegistry.ts?t=1777021306639:98:45 @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_provider_ui_state Error: Failed to fetch (bridge health check failed) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:151:11) + at async invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:5) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:22) + at async http://127.0.0.1:1420/src/hooks/useApiKeyProvider.ts?t=1777021306639:121:29 @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_local_kiro_credential_uuid Error: Failed to fetch (bridge health check failed) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:151:11) + at async invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:5) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:22) + at async fetchLocalActiveUuid (http://127.0.0.1:1420/src/components/provider-pool/ProviderPoolPage.tsx:109:22) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_model_preferences Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts?t=1777021306623:5822:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts?t=1777021306623:13:10) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_model_sync_state Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts?t=1777021306623:5822:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts?t=1777021306623:13:10) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_local_kiro_credential_uuid Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts?t=1777021306623:5822:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts?t=1777021306623:13:10) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [useApiKeyProvider] 获取 Provider 列表失败: Error: [DevBridge] 浏览器模式无法连接后端桥接,命令 "get_api_key_providers" 执行失败。请先启动 Tauri 开发后端(例如 npm run tauri:dev 或 npm run tauri:dev:headless),并确认 http://127.0.0.1:3030 可访问。原始错误: Failed to fetch (bridge health check failed) + at normalizeDevBridgeError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:168:12) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:354:31) + at async loadProviders (http://127.0.0.1:1420/src/lib/api/apiKeyProvider.ts?t=1777021306639:33:28) @ http://127.0.0.1:1420/src/hooks/useApiKeyProvider.ts?t=1777021306639:105 +[ERROR] [useApiKeyProvider] 获取 Provider 列表失败: Error: [DevBridge] 浏览器模式无法连接后端桥接,命令 "get_api_key_providers" 执行失败。请先启动 Tauri 开发后端(例如 npm run tauri:dev 或 npm run tauri:dev:headless),并确认 http://127.0.0.1:3030 可访问。原始错误: Failed to fetch (bridge health check failed) + at normalizeDevBridgeError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:168:12) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:354:31) + at async loadProviders (http://127.0.0.1:1420/src/lib/api/apiKeyProvider.ts?t=1777021306639:33:28) @ http://127.0.0.1:1420/src/hooks/useApiKeyProvider.ts?t=1777021306639:105 +[ERROR] [DevBridge] HTTP 调用失败: get_local_kiro_credential_uuid Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts?t=1777021306639:349:28) + at getLocalKiroCredentialUuid (http://127.0.0.1:1420/src/lib/api/providerPool.ts?t=1777021306639:336:10) + at fetchLocalActiveUuid (http://127.0.0.1:1420/src/components/provider-pool/ProviderPoolPage.tsx:109:28) + at http://127.0.0.1:1420/src/components/provider-pool/ProviderPoolPage.tsx:119:9 + at commitHookEffectListMount (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=c470abb8:16915:34) + at commitPassiveMountOnFiber (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=c470abb8:18156:19) + at commitPassiveMountEffects_complete (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=c470abb8:18129:17) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 +[ERROR] [DevBridge] HTTP 调用失败: get_local_kiro_credential_uuid Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts?t=1777021306623:5822:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts?t=1777021306623:13:10) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts?t=1777021306623:349 \ No newline at end of file diff --git a/current-lime-page.png b/current-lime-page.png new file mode 100644 index 000000000..761c33304 Binary files /dev/null and b/current-lime-page.png differ diff --git a/docs/exec-plans/limenext-progress.md b/docs/exec-plans/limenext-progress.md index 0f583e23c..0dd7c3171 100644 --- a/docs/exec-plans/limenext-progress.md +++ b/docs/exec-plans/limenext-progress.md @@ -1,6 +1,6 @@ # LimeNext 推进日志 -> 注:自 2026-04-19 起,LimeNext current 前台主词固定为 `技能 / 灵感库 / 生成`。历史日志里若继续出现 `SceneApp / 创作场景 / Project Pack / 复盘 / 记忆工作台 / 任务工作台 / 任务视图` 等词,默认按当时实现阶段或 compat 旧称理解,不再代表 current 产品口径。 +> 注:自 2026-04-19 起,LimeNext current 前台主词固定为 `我的方法 / 灵感库 / 生成`。历史日志里若继续出现 `技能 / SceneApp / 创作场景 / Project Pack / 复盘 / 记忆工作台 / 任务工作台 / 任务视图` 等词,默认按当时实现阶段或 compat 旧称理解,不再代表 current 产品口径。 > > 补充说明:2026-04-17 中段几次关于“纯标题目录 / 无图场景墙”的尝试,当前都已被后续 `经营信号目录卡片` 方案取代。阅读本日志时,凡旧条目仍写“纯标题目录”,都按已废弃试探理解;current 以最新“目录卡片经营信号回流”条目为准。 > @@ -8,10 +8,211 @@ > > 补充说明(2026-04-22):`sceneapp` 对象级 `SceneAppCurrent*` 过渡类型已全部删除。历史日志若继续出现 `SceneAppCurrentDescriptor / SceneAppCurrentPlanResult / SceneAppCurrentCatalog` 等表述,默认按当时过渡阶段理解;current 事实源已切回 base `SceneAppDescriptor / SceneAppCatalog / SceneAppPlanResult`,current 标量只保留 `SceneAppExecutorBindingFamily / SceneAppExecutionRuntimeAction / SceneAppType / SceneAppLaunchRequirementCoreKind`。 +## 2026-04-25 + +### 已完成 + +- 把 `新建任务 / EmptyState` 首页主区从“顺序堆叠的信息货架”重排成更接近参考图的 `输入主舞台 + tab 化入口 deck`,避免首屏仍像说明页而不是开工页: + - 已更新: + - `src/components/agent/chat/components/EmptyState.tsx` + - `src/components/agent/chat/components/Inputbar/styles.ts` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - 首页推荐区当前不再把 `主推荐 / 其他起手结果 / 继续上次做法 / 做法入口` 全部顺序摊开;现在先收成一块带 tab 的入口 deck,默认聚焦 `推荐起手`,并按 `继续上次 / 做法入口` 分层切换 + - `推荐起手` 当前仍保留 `主推荐大卡 + 次级卡` 的结果导向结构,但卡区已经收进统一 surface,不再像三段说明区那样散开 + - `继续上次` 与 `做法入口` 当前正式拆成两套前台口径;不再沿用旧的 `directMethodItems` 回退逻辑把最近记录重复渲染到“做法入口”里 + - 首页大输入框当前也同步加厚成更明确的主卡壳:圆角、边框、阴影和底部轻设置区都更接近首页主舞台,而不是通用聊天输入条 + - 当前定向验证: + - `npx eslint "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/Inputbar/styles.ts"` + - `pnpm vitest run "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/agent/chat/components/EmptyStateComposerPanel.test.tsx"` + - `npm run verify:gui-smoke` + - 结果: + - `eslint` 通过 + - `vitest` 通过:`2 files / 73 tests passed` + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` 当前都已跑通,说明这轮首页壳层重排没有把 GUI 主路径打断 + +- 把 `我的方法 -> launcher` 里的参考对象头部从“来源 / 分类 / tag 标签墙”收成轻量元信息,并同步把方法页搜索提示从对象词改成动作词: + - 已更新: + - `src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx` + - `src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx` + - `src/components/skills/SkillsWorkspacePage.tsx` + - `src/components/skills/SkillsWorkspacePage.test.tsx` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `CuratedTaskLauncherDialog` 里的参考对象卡当前不再把 `来源 / 分类 / tag` 堆成一排 pill;现在先给一条 `来源 · 类型` 元信息,再把 tag 收成正文里的 `相关线索` + - 用户当前进入 launcher 时,会更先看到“这条参考是什么、为什么和这轮有关”,而不是像在看素材目录标签 + - `我的方法` 页搜索框当前也不再写 `站点或做法标题` 这类对象化提示,而是统一成 `搜索想拿的结果、这一步或做法名` + - 当前定向验证: + - `npm exec vitest run "src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npx eslint "src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx" "src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm run verify:gui-smoke` + +- 把 `service skill / 我的方法 / 首页结果模板` 里还残留的“工作区 / 补参 / 创建任务”系统动作词继续压回 `生成` 主链,避免首页、方法页和做法入口已经切到结果口径后,实际按钮、提示和结果去向仍把用户拉回旧心智: + - 已更新: + - `src/components/agent/chat/service-skills/skillPresentation.ts` + - `src/components/agent/chat/service-skills/skillPresentation.test.ts` + - `src/components/skills/SkillsWorkspacePage.tsx` + - `src/components/skills/SkillsWorkspacePage.test.tsx` + - `src/components/agent/chat/components/EmptyState.tsx` + - `src/components/agent/chat/components/EmptyState.test.tsx` + - `src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts` + - `src/components/agent/chat/utils/curatedTaskTemplates.ts` + - `src/components/agent/chat/utils/workflowStepPresentation.ts` + - `src/components/agent/chat/utils/toolProcessSummary.ts` + - `src/components/agent/chat/utils/toolProcessSummary.test.ts` + - `src/components/agent/chat/utils/toolDisplayInfo.ts` + - `src/components/agent/chat/utils/toolDisplayInfo.test.ts` + - `src/components/memory/MemoryPage.tsx` + - `src/components/memory/MemoryPage.test.tsx` + - `src/lib/api/serviceSkills.ts` + - `src/components/settings-v2/system/automation/index.tsx` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `service skill` 当前已统一从 `对话内补参 / 创建任务 / 创建跟踪 / 进入工作区` 收成 `开始这一步 / 开始持续 / 开始跟进 / 补齐这一步 / 接着继续` + - `service skill` 的运行说明与结果去向当前也已统一改成 `回到生成 / 回到当前内容`,不再默认把用户带回 `当前工作区` + - `我的方法` 页里与继续开工直接相关的入口说明、回跳 banner、已沉淀方法说明和草稿 CTA 当前也同步改成 `回到生成 / 把这轮做下去`,不再继续暴露 `补参` 口径 + - 首页 `service skill` 推荐卡当前已把缺信息提示收成 `补齐这一步后开始`,结果模板里 `长文转多平台发布稿` 的结果去向也同步改成当前内容 / 生成口径 + - workflow 空态进度当前已从 `等待创建任务` 收成 `等待开始`,避免做法流程 rail 再把用户拉回旧任务系统 + - runtime 工具过程提示当前也已把 `先创建任务 / 已创建任务 / 创建任务中` 收成 `先开始这一步 / 已开始 / 开始中`,避免做法已经回到 `生成`,但执行经过又重新掉回旧任务系统 + - `灵感库` 当前把结果模板回跳 banner 也统一成 `回到生成 / 把这轮做下去`;`serviceSkills` 底层兜底结果去向与自动化入口标题也已同步跟上,不再只在前台卡片层换词 + - 当前定向验证: + - `npm exec vitest run "src/components/agent/chat/service-skills/skillPresentation.test.ts" "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx"` + - `npm exec vitest run "src/components/agent/chat/utils/curatedTaskTemplates.test.ts" "src/components/agent/chat/utils/workflowStepPresentation.test.ts"` + - `npm exec vitest run "src/components/agent/chat/utils/toolProcessSummary.test.ts" "src/components/agent/chat/utils/toolDisplayInfo.test.ts"` + - `npm exec vitest run "src/components/memory/MemoryPage.test.tsx" "src/lib/api/serviceSkills.test.ts"` + - `npx eslint "src/components/agent/chat/service-skills/skillPresentation.ts" "src/components/agent/chat/service-skills/skillPresentation.test.ts" "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx" "src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts" "src/components/agent/chat/utils/curatedTaskTemplates.ts" "src/components/agent/chat/utils/workflowStepPresentation.ts"` + - `npx eslint "src/components/agent/chat/utils/toolProcessSummary.ts" "src/components/agent/chat/utils/toolProcessSummary.test.ts" "src/components/agent/chat/utils/toolDisplayInfo.ts" "src/components/agent/chat/utils/toolDisplayInfo.test.ts"` + - `npx eslint "src/components/memory/MemoryPage.tsx" "src/components/memory/MemoryPage.test.tsx" "src/lib/api/serviceSkills.ts" "src/components/settings-v2/system/automation/index.tsx"` + - `npm run verify:gui-smoke` + - 结果: + - 上述前台口径收口相关定向 `vitest`、`eslint` 与 `verify:gui-smoke` 当前都已通过;说明首页 / 我的方法 / service skill / 灵感库 / automation 入口这一组 current 主词已经能在现有 GUI 主链里稳定共存 + +- 把 `持续流程` 页当前还残留的旧 `任务 / 工作区 / 自动化任务` 词继续压回 current 前台对象,避免 `系统 -> 持续流程` 已经是正式入口,但弹窗、概览、健康面板、详情摘要和 sceneapp 回流文案仍把用户拉回旧自动化任务系统: + - 已更新: + - `src/components/settings-v2/system/automation/AutomationJobDialog.tsx` + - `src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` + - `src/components/settings-v2/system/automation/AutomationOverviewFocusCard.tsx` + - `src/components/settings-v2/system/automation/AutomationHealthPanel.tsx` + - `src/components/settings-v2/system/automation/index.tsx` + - `src/components/settings-v2/system/automation/automationPresentation.ts` + - `src/components/settings-v2/system/automation/serviceSkillContext.ts` + - `src/components/settings-v2/system/automation/useAutomationSceneAppRuntime.ts` + - `src/lib/sceneapp/product.ts` + - `src/lib/sceneapp/presentation.ts` + - `src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx` + - `src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx` + - `src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx` + - `src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx` + - `src/components/settings-v2/system/automation/index.test.tsx` + - `src/lib/sceneapp/product.test.ts` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `持续流程` 弹窗头部、详情页、默认列表、概览卡与健康面板当前已统一改用 `持续流程 / 归属 / 开始方式 / 当前状态 / 当前起手内容`,不再继续暴露 `任务详情 / 任务类型 / 工作区 / 未填写任务描述` 这一套旧对象词 + - `service skill` 在 `持续流程` 页里的摘要、上下文和运行上下文当前也已统一成 `技能流程 / 定时运行 / 技能:...`,避免自动化页里又长回第二套 `技能任务` 语言 + - 输出投递说明当前也已同步收成 `运行完成后投递 / 投递失败不阻塞本轮 / 投递失败记为本轮失败`,不再把失败策略继续表述成旧任务执行语义 + - `sceneapp` 回流到 `持续流程` 详情和概览卡时,当前摘要源也不再生成 `自动化任务 / 最近投放任务 / 打开自动化任务`,而是统一成 `持续流程 / 最近运行 / 打开持续流程` + - 因此这一步继续服务 P1 前台对象语言收口:`系统 -> 持续流程` 当前终于不只是入口名称变了,而是页面内外的 current 文案也开始围绕同一条持续流程主词收敛 + - 当前定向验证: + - `npm exec vitest run "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx" "src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/lib/sceneapp/product.test.ts"` + - `npx eslint "src/components/settings-v2/system/automation/AutomationJobDialog.tsx" "src/components/settings-v2/system/automation/automationPresentation.ts" "src/components/settings-v2/system/automation/serviceSkillContext.ts" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.tsx" "src/components/settings-v2/system/automation/AutomationHealthPanel.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx" "src/components/settings-v2/system/automation/index.tsx" "src/components/settings-v2/system/automation/useAutomationSceneAppRuntime.ts" "src/lib/sceneapp/presentation.ts" "src/lib/sceneapp/product.ts" "src/components/settings-v2/system/automation/AutomationJobDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx" "src/components/settings-v2/system/automation/AutomationOverviewFocusCard.test.tsx" "src/components/settings-v2/system/automation/AutomationHealthPanel.test.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/lib/sceneapp/product.test.ts"` + - `npm run verify:gui-smoke` + - 结果: + - 自动化页与 sceneapp 摘要源相关定向 `vitest` 通过:`6 files / 51 tests passed` + - 定向 `eslint` 通过 + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` 当前都已跑通,说明这轮 `持续流程` 前台收口没有把 GUI 主路径打断 + +- 把 `消息渠道` 页当前还残留的旧 `联调 / 调试 / 高级工作台` 技术词收轻一层,避免 `系统 -> 消息渠道` 虽然已经是 current 正式入口,但首页说明、接入检查和日志页标题仍然更像开发工具面,而不是系统辅助页: + - 已更新: + - `src/components/channels/ImConfigPage.tsx` + - `src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx` + - `src/components/channels/ImConfigPage.test.tsx` + - `src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `消息渠道` 首页当前已把说明文案从 `对话和投递` 收成更明确的 `消息接收 / 回复 / 结果投递`,避免用户一进来先读到系统内部词 + - 各渠道卡片里的 `联调检查` 当前已统一收成 `接入检查`,查看入口也改成 `查看日志与检查`,不再直接暴露开发排障语气 + - 下方折叠区与 `ChannelsDebugWorkbench` 当前也已统一从 `联调与排查` 收成 `日志与检查`,继续保留 current 运行排查能力,但不再把当前页面说成另一套调试工作台 + - `ImConfigPage` 里的折叠头当前顺手修掉了一处嵌套 `button` 结构警告,避免 `WorkbenchInfoTip` 继续在当前页触发 DOM nesting warning + - 因此这一步继续服务 P1 前台对象语言收口:`系统 -> 消息渠道` 当前不再一边叫“消息渠道”,一边在核心说明层继续用 `联调 / 调试` 这套旧技术面口径 + - 当前定向验证: + - `npm exec vitest run "src/components/channels/ImConfigPage.test.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx"` + - `npx eslint "src/components/channels/ImConfigPage.tsx" "src/components/channels/ImConfigPage.test.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.tsx" "src/components/settings-v2/system/channels/ChannelsDebugWorkbench.test.tsx"` + - `npm run verify:gui-smoke` + - `npm run smoke:agent-runtime-tool-surface-page` + - 结果: + - `消息渠道` 相关定向 `vitest` 通过:`2 files / 9 tests passed` + - 定向 `eslint` 通过 + - `verify:gui-smoke` 本轮未拿到最终通过;`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface` 均已通过,但最后 `smoke:agent-runtime-tool-surface-page` 两次都停在 `wait-empty-state`,报 `等待 首页空态加载 超时` + - 该超时链路与本轮 `消息渠道` 页面不在同一路径;当前先按仓库现有环境 / 主壳 smoke 波动记录,不把它直接记成 `消息渠道` 页回归 + +- 把 `resources / 项目资料` 页与相邻图片、视频同步提示里的旧 `资料库 / 资料分类 / 项目素材库` 继续压回 `项目资料` 口径,避免辅助页标题已经换了,但页内说明、切换区和结果同步提示仍停在旧库心智: + - 已更新: + - `src/components/resources/ResourcesPage.tsx` + - `src/components/resources/ResourcesPage.test.tsx` + - `src/components/resources/ResourcesImageWorkbench.tsx` + - `src/components/resources/ResourcesImageWorkbench.test.tsx` + - `src/components/memory/MemoryPage.tsx` + - `src/components/memory/MemoryPage.test.tsx` + - `src/i18n/patches/zh.json` + - `src/i18n/patches/en.json` + - `src/i18n/__tests__/translation-coverage.test.ts` + - `src/components/image-gen/README.md` + - `src/components/workspace/media/ImageGallery.tsx` + - `src/lib/governance/legacySurfaceCatalog.json` + - `src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx` + - `src/lib/serviceModels.test.ts` + - `src/components/agent/chat/components/Inputbar/index.test.tsx` + - `src/components/workspace/video/VideoWorkspace.tsx` + - `src/components/workspace/video/VideoTipsBehavior.test.tsx` + - `docs/roadmap/limenextv2/entry-migration-map.md` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - `项目资料` 页首 tips 当前已从“导入资源 / 项目资料 / 外部素材”这套旧对象说明,收成更直接的 `当前项目里的文档、图片和导入内容` + - 迁移提示条当前也已统一成 `浏览 / 补图 / 整理`,并明确 `回生成 / 去灵感库`,不再继续强调“默认主导航”这类入口治理术语 + - 左侧 `资料分类` 当前已改成 `内容分类`,项目切换区也不再继续把自己讲成“项目资料主入口” + - 跨项目图片提示、未选项目提示和空态说明当前已统一回 `当前项目 / 项目资料` 口径,不再出现 `资料库` + - `灵感库` 首屏里的 `项目资料` 辅助入口说明当前也已从 `项目素材` 收回 `项目资料 / 导入内容`,避免 `灵感库 -> 项目资料` 回桥继续暴露旧对象 + - 图片工作台发给画布的来源名,以及视频工作台里的同步提示、摘要卡和成功状态当前也已统一从 `项目素材库 / 项目素材` 收成 `项目资料` + - `i18n` patch、`translation coverage`、`image-gen` README、治理目录和 `ImageGallery` 注释里的旧 `资料库 / 项目素材` 现在也已统一改成 `项目资料` + - `Inputbar` 工作流示例、`@素材` 提词重写夹具和 `serviceModels` 配置夹具里的旧 `检索项目素材 / 资料库语义 / 资料库上下文` 也已同步收回 `项目资料` + - `entry-migration-map` 里的 current 页面语义与后续目标当前也已从 `项目资源 / 导入素材 / 项目素材浏览能力` 收成 `项目资料 / 导入内容 / 项目资料浏览能力` + - 当前定向验证: + - `npm exec vitest run "src/components/resources/ResourcesPage.test.tsx" "src/components/resources/ResourcesImageWorkbench.test.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx"` + - `npm exec vitest run "src/components/memory/MemoryPage.test.tsx"` + - `npm exec vitest run "src/i18n/__tests__/translation-coverage.test.ts" "src/lib/serviceModels.test.ts" "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx"` + - `npx eslint "src/components/resources/ResourcesPage.tsx" "src/components/resources/ResourcesPage.test.tsx" "src/components/resources/ResourcesImageWorkbench.tsx" "src/components/resources/ResourcesImageWorkbench.test.tsx" "src/components/workspace/video/VideoWorkspace.tsx" "src/components/workspace/video/VideoTipsBehavior.test.tsx"` + - `npx eslint "src/components/memory/MemoryPage.tsx" "src/components/memory/MemoryPage.test.tsx"` + - `npx eslint "src/i18n/__tests__/translation-coverage.test.ts" "src/components/workspace/media/ImageGallery.tsx" "src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx" "src/lib/serviceModels.test.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx"` + - `npm run verify:gui-smoke` + - 结果: + - `项目资料 / 图片工作台 / 视频 tips` 定向 `vitest` 通过:`3 files / 14 tests passed` + - `灵感库` 辅助入口定向 `vitest` 通过:`1 file / 20 tests passed` + - `i18n / 工作流夹具 / 项目资料测试夹具` 定向 `vitest` 通过:`4 files / 151 tests passed` + - 定向 `eslint` 通过 + - `verify:gui-smoke` 本轮未通过;headless Tauri 在 `DevBridge` 就绪前提前退出,最终卡在仓库现有 Rust 编译错误: + - `src/dev_bridge/dispatcher/agent_sessions.rs:182` + - `crate::commands::aster_agent_cmd::agent_runtime_list_sessions(db, logs)` 缺少第三个参数 `Option` + - 该错误发生在 Rust 当前主干编译阶段,和本轮 `项目资料` / `视频` 前台文案收口不在同一改动边界;当前先按仓库现有编译阻塞记录,不直接记成这轮页面回归 + ## 2026-04-24 ### 已完成 +- 把左侧 `任务` 分组里缺失的 `生成` 主入口补回 current 导航,避免文档、测试和真实前台继续分叉: + - 已更新: + - `src/lib/navigation/sidebarNav.ts` + - `src/components/AppSidebar.test.tsx` + - `docs/exec-plans/limenext-progress.md` + - 当前统一结论: + - 左侧一级导航当前继续保持 `任务 / 能力 / 资料 / 系统` 四段式结构,但 `任务` 分组不再只剩 `新建任务` + - `生成` 当前已作为固定主入口回到 `任务` 分组,并直接走 `agentEntry=claw` 主执行面 + - 因此 current 侧栏终于重新对齐 V2 文档口径:`新建任务 / 生成 / 我的方法 / 灵感库 / 设置` + - 这一步也同步回应了前面入口收口后的一个真实断层:用户不需要只靠首页或回跳才能重新进入 `生成` + - 当前定向验证: + - `npm exec vitest run "src/lib/navigation/sidebarNav.test.ts" "src/components/AppSidebar.test.tsx"` + - `npx eslint "src/lib/navigation/sidebarNav.ts" "src/components/AppSidebar.test.tsx"` + - `npm run verify:gui-smoke` + - 把 `我的方法 / slash / @` 里的“方法库 / 技能组 / 已安装技能”再收成更接近 skill-first 的前台口径,避免首页已经切到 `先拿结果 -> 再续这轮`,但方法页和输入层还在继续暴露目录/来源语义: - 已更新: - `src/components/skills/SkillsWorkspacePage.tsx` @@ -5862,3 +6063,224 @@ - `vitest` 通过:`3 files / 140 tests passed` - `eslint` 通过 - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + +- `@ / slash` 输入能力面板当前又继续前推了一刀,避免首页和 `我的方法` 已经逐步切到 `先拿结果 -> 继续上次做法 -> 已经沉淀的方法`,但真正进入输入补全面板后,用户仍然会在行项目右侧看到 `技能 / 结果模板 / 工作台操作 · /new / 云目录式` 这一类对象标签: + - `inputCapabilitySections` 当前已把 `recent service skill`、`service skill group`、`scene result`、`recent installed skill`、`recent curated task` 这些条目的 `kindLabel` 收掉;分组标题负责说明语义,行项目只保留标题和轻合同描述 + - `slash command` 当前只保留最小可识别的命令前缀,例如 `/new /clear /compact`;不再继续把 `工作台操作 / 提示命令 / 状态帮助` 这层分类重复挂到条目右侧 + - `@` 面板里的内建命令分组当前也不再重复挂 `搜索 / 读取 / 生成 / 表达` 这类类别标签;用户先看到的是动作本身,而不是分类名 + - `CharacterMentionPanel` 当前又加了一层前台保险:即使后续数据层误塞了标签,除了真正需要识别的 slash 命令前缀,其它 `kindLabel` 也不会再轻易显影到用户前台 +- 因此用户当前沿着 `输入框 -> @ / slash 面板 -> 选一条继续` 这条主链时,认知会进一步从“我在挑某种能力对象”收回到“我现在先接这一步”: + - recent continuation 当前会优先展示“这条结果 / 这套方法”的标题和合同,而不是对象类别 + - scene / result / local skill 当前不会再额外挂 `结果模板 / 技能 / /skill-key` 这类次级标签 + - 工作台命令仍然保留最小的 `/command` 前缀,避免完全失去命令识别性 +- 这一步继续遵守 P1 / P2 的 current 边界: + - 不新增第二套输入补全面板 + - 不改 `onSelect*`、launcher、scene command、installed skill 的现有激活协议 + - 继续只在 `inputCapabilitySections` 与 `CharacterMentionPanel` 上做前台呈现减法,不改底层能力编排和选择主链 +- 这次子改动的定向验证当前已通过: + - `npm exec vitest run "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/components/agent/chat/skill-selection/inputCapabilitySections.test.ts"` + - `npx eslint "src/components/agent/chat/skill-selection/inputCapabilitySections.ts" "src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx" "src/components/agent/chat/skill-selection/CharacterMention.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/components/agent/chat/skill-selection/inputCapabilitySections.test.ts"` + - `npm run verify:gui-smoke -- --timeout-ms 180000` + - `vitest` 通过:`2 files / 93 tests passed` + - `eslint` 通过 + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + +- 首页推荐 shelf 当前又继续前推了一刀,避免 `我的方法`、`slash`、`@` 面板已经逐步收成“先做哪一步 / 为什么先做这一步”,但 `EmptyState` 里的 service skill 推荐卡仍然会以 `云目录 / 最近使用` 这类 badge-first 方式抢占标题层: + - `EmptyState` 当前已把 service skill 推荐项上的 `badge` 去掉;首页推荐 shelf 里这类卡片现在只保留标题、摘要、`需要 / 交付` 合同,不再把 `云目录 / 最近使用` 直接挂到卡头 + - `CharacterMention` 运行时回退把 `scene -> service skill` 补参接管时,当前也不再额外给 `ServiceSkillHomeItem` 注入 `云目录 / 本地技能` badge,避免其它入口后续再把来源标签漏回前台 +- 因此用户当前沿着 `首页 -> 直接开工的做法` 和 `slash scene -> A2UI 补参接管` 这两条主链时,认知会继续从“这是哪个来源的能力对象”收回到“这条做法现在能帮我做什么”: + - 首页 service skill 推荐卡当前会先解释动作与交付,不再先解释目录来源 + - runtime scene 回退成 service skill 时,也不会再把 `云目录 / 本地技能` 这类历史 badge 带回当前链路 +- 这一步继续遵守 P1 / P2 的 current 边界: + - 不改首页推荐排序与 lead/alternative 逻辑 + - 不改 `onSelectServiceSkill`、scene 接管、launcher 或 service skill 执行合同 + - 继续只在 `EmptyState` 与 `CharacterMention` 的前台投影层做减法,不碰底层能力选择主链 +- 这次子改动的定向验证当前已通过: + - `npm exec vitest run "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx"` + - `npx eslint "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/EmptyState.test.tsx" "src/components/agent/chat/skill-selection/CharacterMention.tsx"` + - `npm run verify:gui-smoke -- --timeout-ms 180000` + - `vitest` 通过:`2 files / 137 tests passed` + - `eslint` 通过 + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + - 补充说明:这次 `verify:gui-smoke` 走的是重新拉起 headless Tauri 的路径,期间出现了异常 API Key UTF-8 warning 与 `3030/8999` 端口已占用噪音;但最终 smoke 全链已通过,应视为环境复用噪音,而不是本轮首页收口回归 + +- `我的方法` 页顶栏当前也继续做了一刀减法,避免首页、`slash`、`@` 面板都已经逐步收成前台动作语言,但方法页里的当前草稿条仍然用 `来源:...` 这种偏后台对象说明的口径: + - `SkillsWorkspacePage` 当前已把当前草稿条里的 `来源:{摘要}` 改成 `这次沿用:{摘要}`,让这条信息更像“这轮准备沿用哪条起手线索”,而不是在解释对象出处 +- 因此用户当前沿着 `首页 -> 我的方法 -> 当前草稿 -> 带回生成` 这条链路时,首屏认知会继续从“这是哪个对象来的”收回到“这轮准备接着什么继续做”: + - 当前草稿条当前会先说“这次沿用什么” + - `上次目标` 仍然保留,继续承担真正影响下一步动作的语义 +- 这一步继续遵守 P2 的 current 边界: + - 不改当前草稿条的结构、按钮、路由或带回生成主链 + - 继续只做文案层减法,不碰方法页的数据和激活协议 +- 这次子改动的定向验证当前已通过: + - `npm exec vitest run "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm run verify:gui-smoke -- --timeout-ms 180000` + - `vitest` 通过:`1 file / 27 tests passed` + - `eslint` 通过 + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + +- 首页 `EmptyState` hero 当前又继续做了一刀减法,避免 `我的方法`、`slash`、首页推荐 shelf 都已经逐步收成前台起手层,但首屏顶部仍然挂着 `通用对话 / 模式 / 已挂载做法` 这一排系统状态 badge,把用户重新拉回“先读状态条”的心智: + - `EmptyState` 当前已移除 hero 的 `workspaceBadges` 注入,首屏现在只保留 `slogan / 创作说明 / 结果入口 / 继续这轮` + - 首页当前不再把 `通用对话`、创作模式和当前已挂载方法做成顶部 pill;即使已经选中本地方法,这层状态也只留在输入主执行面里,不再在 hero 再喊一次 +- 因此用户当前沿着 `新建任务 -> 结果入口 -> 输入 -> 发送` 这条链路时,首屏认知会继续从“我先确认系统状态”收回到“我现在先拿什么结果开工”: + - hero 当前只负责创作语义和起手承诺 + - 方法、模板、当前模型这些真正影响执行的状态,当前分别留在结果入口、输入执行面和高级设置里,不再叠成第二条状态带 +- 这一步继续遵守 P1 / P2 的 current 边界: + - 不改首页推荐排序、输入执行协议或 capability route + - 不新增新的 hero 提示层或替代 badge 样式 + - 继续只在 `EmptyState` 前台投影层做减法,不碰底层能力选择主链 +- 这次子改动的定向验证当前已通过: + - `npm exec vitest run "src/components/agent/chat/components/EmptyState.test.tsx"` + - `npx eslint "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/EmptyState.test.tsx"` + - `npm run verify:gui-smoke -- --timeout-ms 180000` + - `vitest` 通过:`1 file / 49 tests passed` + - `eslint` 通过 + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + - 补充说明:本轮 smoke 仍出现了异常 API Key UTF-8 warning、`8999` 端口占用与 companion 端口占用噪音,但最终整条 GUI 主链已跑通,应按环境复用噪音处理,而不是首页 hero 收口回归 + +- `我的方法` 页顶栏和当前草稿条当前又继续做了一刀减法,避免首页 hero 已经去掉系统状态带后,方法页首屏仍然残留 `当前草稿 / 项目内整理 / 技术化 tip` 这一类对象提示: + - `SkillsWorkspacePage` 当前已把方法主入口 tips 改成 `先从结果起手,顺手的做法和自己沉淀下来的方法都在这里续上`,不再继续解释 `Agent / adapter / runtime / YAML` + - 搜索 tips 当前也从“整理自己的方法”收成“先找这轮想拿的结果方向,没命中再接着自己顺手的方法”,减少管理台心智 + - 当前草稿 strip 当前已把 `当前草稿` 改成 `这次续用`,并移除了 `项目内整理` 来源 pill;草稿动作同步收成 `继续整理` + - 右侧 continuation 区的 tips 当前也从“方法库 / 沉淀区说明”收成“最近顺手的做法 / 自己的固定做法”这种前台口径 +- 因此用户当前沿着 `首页 -> 我的方法 -> 当前草稿/继续上次做法/已经沉淀的方法 -> 生成` 这条链路时,首屏认知会继续从“我在看一个方法对象目录”收回到“我这轮接着哪套做法往下做”: + - 顶栏 tips 当前先回答“这里怎么继续开工”,而不是“这是哪套系统结构” + - 当前草稿条当前先表达“这次续用什么”,而不是“这条东西来自哪里” + - 右侧两块当前也更像下一步动作入口,而不是方法库存说明 +- 这一步继续遵守 P2 的 current 边界: + - 不改 `SkillsWorkspacePage` 的布局骨架、分区顺序、跳转路由或带回生成主链 + - 不新增第二套方法详情页或草稿管理协议 + - 继续只在方法页首屏文案与轻标签层做减法,不碰底层数据、recent continuation 与 launcher 主链 +- 这次子改动的定向验证当前已通过: + - `npm exec vitest run "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm run verify:gui-smoke -- --timeout-ms 180000` + - `vitest` 通过:`1 file / 27 tests passed` + - `eslint` 通过 + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + - 补充说明:本轮 smoke 继续出现了异常 API Key UTF-8 warning,以及 `3030/8999` 与 companion 端口占用噪音;但最终 GUI 主链已通过,应按环境复用噪音处理,而不是本轮方法页收口回归 + +- `我的方法` 页顶栏当前又继续做了一刀减法,避免首页和方法页都已经逐步压低系统说明后,header 右上角仍然挂着一个和下方重复的 `整理` 入口,把首屏重新拉回“先管理方法”而不是“先继续这轮”: + - `SkillsWorkspacePage` 当前已移除顶栏右上角那颗重复的 `整理` 按钮 + - `整理` 入口当前只保留在真正需要它的局部位置,例如当前草稿条和 `已经沉淀的方法` 区域,不再在页头重复提醒 +- 因此用户当前沿着 `首页 -> 我的方法 -> 继续这轮 / 已沉淀的方法 -> 生成` 这条链路时,首屏重心会继续从“先打开管理入口”收回到“先选一条做法继续”: + - 顶栏当前只保留与当前页主任务直接相关的动作 + - 整理动作当前退到具体上下文里出现,不再抢首屏标题区 +- 这一步继续遵守 P2 的 current 边界: + - 不改 `SkillsWorkspacePage` 的分区结构、弹窗路由或整理功能本身 + - 不新增新的管理页入口层级 + - 继续只做前台可见入口减法,不碰方法页数据与整理弹窗主链 +- 这次子改动的定向验证当前已通过: + - `npm exec vitest run "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npx eslint "src/components/skills/SkillsWorkspacePage.tsx" "src/components/skills/SkillsWorkspacePage.test.tsx"` + - `npm run verify:gui-smoke -- --timeout-ms 180000` + - `vitest` 通过:`1 file / 27 tests passed` + - `eslint` 通过 + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + - 补充说明:本轮 `verify:gui-smoke` 复用了已运行的 headless Tauri 环境,仍经过完整 GUI 主链 smoke;最终通过,应视为当前 UI 收口已在真实主路径上坐实 + +- `skill-first` 主线当前继续对齐了一刀,直接处理了 `我的方法 / launcher / service skill` 里仍残留的目录心智,避免页面首屏已经收成“先拿结果 -> 继续上次做法 -> 已经沉淀的方法”,但实际数据和补参弹窗还在讲 `云目录 / Seeded 目录 / 租户技能目录 / 最近整理`: + - `useServiceSkills` 当前已把 service skill 的默认 badge 从来源标签改成前台动作语义:非 recent 做法统一回到 `现成做法`,站点型做法统一回到 `浏览器继续`;`catalogMeta.sourceLabel` 也同步收成 `起步做法 / 已同步做法` + - `SkillsWorkspacePage` 当前已去掉 header 搜索区下方的 `最近整理` 目录说明,不再在“我的方法”页首屏提醒用户这是一个被整理过的目录 + - `SkillsWorkspacePage` 的失败提示当前也已改成面向前台入口的两类提示:`现成做法暂时没同步下来` / `已经沉淀的方法暂时没读到`,避免错误态把用户拉回“云端目录 / 本地目录”心智 + - `CuratedTaskLauncherDialog` 当前又补上了和页面卡片同一套 `你先给 / 会拿到 / 接着可做` 轻合同;因此用户从 `我的方法` 或 slash 进入 launcher 后,看到的不再只是“确认几件事”,而是和入口页一致的 skill-first 起手合同 +- 因此当前主链又更接近 Ribbi 的 `前台卖任务,不卖目录来源`: + - `我的方法` 页首屏不再拿 catalog 元信息解释自己 + - service skill 数据层默认也不再把来源标签往前台漏 + - launcher 和方法页当前已经回到同一套起手合同,而不是各讲一套 +- 这一步继续遵守 P2 的 current 边界: + - 不改 runtime 协议、service skill 执行链和 sceneapps route + - 不新增长期 compat 包装层 + - 继续只收 `skill-first` 前台投影与事实源表述,不碰底层 agent / tool 合同 + +- `@命令` 当前继续回到主线,直接把 mention 面板收成更像 Ribbi 式的统一调用面,而不是“角色 / 技能混排列表”: + - `inputCapabilitySections` 当前已把 mention recent 分组从 `最近使用` 收成 `最近调用`,把 `推荐做法` 收成 `场景做法`,把 `角色` 收成 `协作角色` + - `CharacterMentionPanel` 当前已把输入提示和空态从 `搜索角色或技能 / 暂无可用角色或技能` 收成 `搜索 @命令、做法或协作角色 / 暂无可用 @命令、做法或协作角色`,明确 `@` 主体首先是调用面 + - mention 面板当前也补了命令主导的 landing 语义:recent 调用高亮保留,`场景做法 / 我的方法 / 更多做法 / 协作角色` 这些补位分组统一压轻,不再和命令分组抢同一层视觉重心 + - 因此用户当前沿着 `生成 -> @` 进入时,会先理解“我在调什么命令”,然后才看到整套做法、我的方法与协作对象;更接近 Ribbi 那种 `统一调用注册表`,而不是“另一个技能面板” +- 这一步继续遵守 P2 的 current 边界: + - 不改 `@` 选择后的 capability route、service skill 接管或 builtin command 执行合同 + - 不新开独立命令页,不把 `@` 长成第二执行系统 + - 继续只收 mention 输入层的前台分组、提示和视觉重心 + +- `@命令` 这一轮又继续补了一刀注册表语义,避免 mention 面板虽然已经“命令优先”,但首屏仍然不够像 Ribbi 那种统一调用注册表: + - `inputCapabilitySections` 当前已把 `浏览器 / 执行` 收成 `浏览器 / 编排`,让这组能力更贴近 `browser / code / web orchestration agents`,不再像一组模糊的杂项执行动作 + - mention 空查询当前已新增 `统一调用注册表` 首屏 banner:有 recent 时优先讲“继续最近调用,或切到其他执行器”,没有 recent 时明确讲“先调命令,再补做法”,并再次强调所有调用都会继续写回当前生成线程 + - mention 空查询的补位层当前也继续下沉:`场景做法 / 分组做法 / 我的方法 / 更多做法 / 协作角色` 现在统一压成命令后的补位层;`协作角色` 也下移到方法区之后,不再夹在方法中间抢注意力 + - `CharacterMentionPanel` 当前也补了方法层 helper 文案:`场景做法 / 我的方法 / 更多做法` 明确是命令后的补位入口,而不是和上面的 `@命令` 同层竞争 +- 因此用户当前沿着 `生成 -> @` 打开空查询面板时,会先读到“这是统一调用注册表”,再沿着 `最近调用 / 命令家族 / 做法补位 / 协作角色` 这条顺序做选择: + - `@` 首屏当前先卖执行器与命令家族,而不是混排对象目录 + - 浏览器与代码类命令当前也明确回到“编排执行器”心智,而不是挂在一个模糊的“执行”分组里 + - 协作角色当前继续保留,但已经退回命令和方法之后,避免重新长成第二主入口 +- 这一步继续遵守 P2 的 current 边界: + - 不改 `onSelectInputCapability`、`capabilityRoute`、builtin command 执行链或 service skill 接管链 + - 不新增新的命令页、执行页或角色页 + - 继续只在 mention 输入层做结构语义收口,不碰底层 runtime contract +- 这次子改动的定向验证当前已通过: + - `npm exec vitest run "src/components/agent/chat/skill-selection/inputCapabilitySections.test.ts" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx"` + - `npm run verify:gui-smoke` + - `vitest` 通过:`2 files / 94 tests passed` + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + - `verify:local` 本轮已确认经过 `verify:app-version / lint / typecheck`,但仓库当前因大范围脏改动触发全量 smart 批次,后续批次出现既有 React warning 风暴;因此这轮仍以 mention 定向回归 + GUI smoke 作为主线交付证明 + +- `@命令` 当前又继续做了一刀治理减法,直接收掉 `CharacterMention` 对外还保留的旧选择表面,避免前台已经讲“统一调用注册表”,真正落地时却仍靠 `onSelectBuiltinCommand / onSelectSceneCommand / onSelectSkill` 三套并行接线: + - `CharacterMention` 当前已删除上述三条 public callback surface;内建命令、runtime scene、已安装方法现在只认 `onSelectInputCapability` 这条 current 主链 + - mention / slash 两侧的选择分支也同步收口:当上层给了 `onSelectInputCapability` 时,不再额外分流到旧 callback;只有在没有 current capability 边界的 fallback 场景下,才保留输入框文本回填 + - `CharacterMention.test.tsx` 当前也同步删掉了对应 compat 测试,改成围绕 current 路径补守卫:最近使用的 scene / 已安装方法现在都断言 `replayText` 会沿 `onSelectInputCapability` 一起回到发送主链 +- 因此当前 `@ / /` 的选择后合同又更接近路线图里要求的单一事实源: + - current:`onSelectInputCapability -> activeCapability -> resolveInputCapabilityDispatch -> capabilityRoute/displayContent` + - compat:仅剩“未提供 current callback 时的文本回填”这一条局部 fallback,用于脱离主工作区的纯输入场景 + - deprecated / dead:`CharacterMention` 级别的 `onSelectBuiltinCommand / onSelectSceneCommand / onSelectSkill` 已不再保留 +- 这一步继续遵守 P2 的 current 边界: + - 不改 service skill 补参与 A2UI 接管链 + - 不改 `InputbarController / EmptyState` 里的 active capability 事实源 + - 继续只清理 `CharacterMention` 的并行选择边界,不碰 runtime 协议和发送路由 +- 这次子改动的定向验证当前已通过: + - `npm exec vitest run "src/components/agent/chat/skill-selection/CharacterMention.test.tsx"` + - `npx eslint "src/components/agent/chat/skill-selection/CharacterMention.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + - `vitest` 通过:`1 file / 62 tests passed` + - `eslint` 通过 + - `typecheck` 通过 + - `verify:gui-smoke` 通过:`workspace-ready / browser-runtime / site-adapters / agent-service-skill-entry / agent-runtime-tool-surface / agent-runtime-tool-surface-page` + +- `@命令` 当前继续沿着 `统一调用注册表` 主线把最后一条明显的选择分叉收掉:`service skill` 不再作为 `CharacterMention -> Inputbar / EmptyState` 的特例 surface 暴露,而是并回统一 capability 选择协议,父层内部再转到既有的 service skill 启动链: + - `InputCapabilitySelection` 当前已新增 `service_skill`,但它被明确标成“只用于统一选择边界,不直接进入 send route”;因此 current 主链不再把 service skill 误当成普通 capability route 发送 + - `CharacterMention` 当前在 `@` 选择 service skill、以及 `/scene` 命中带补参需求的 linked scene 时,都会优先走 `onSelectInputCapability({ kind: "service_skill" })`;普通 runtime scene 则继续停留在 `runtime_scene` 主链,不再因为 scene 查询统一 callback 就额外走一轮异步 service skill 探测 + - `skillSelectionBindings` 当前也已停止把 `onSelectServiceSkill` 继续透传给主输入区的 `CharacterMention`;对 `Inputbar / EmptyState` 来说,current 选择边界现在只剩 `onSelectInputCapability` + - `useInputbarController` 与 `EmptyState` 当前则把 `service_skill` capability 作为内部转发分支消费:清掉已有 active capability 后,继续复用既有 `onSelectServiceSkill -> workspaceServiceSkillEntryActions` 启动链,因此这一步只统一“选择协议”,不重写 service skill 执行合同 + - `RuntimeSceneSlashCommand` 当前补上了 `linkedSkillId` 事实源;所以只有真正 linked 的 scene 才会继续尝试 A2UI / service skill 补参接管,普通 scene 不再被误判成 service skill 特例 +- 因此当前 `@ / /` 的 current / compat 口径又更清楚了一层: + - `current`:`CharacterMention -> onSelectInputCapability -> Inputbar / EmptyState -> service_skill 内部转发或 capability route` + - `compat`:`CharacterMention` 仅保留“没有 current callback 时,把 service skill 名称回填到输入框”这条纯输入 fallback + - `deprecated / dead`:主输入区上游继续直接依赖 `onSelectServiceSkill` 作为选择协议的做法,当前已不再保留 +- 这一步继续遵守 P2 的 current 边界: + - 不改 `workspaceServiceSkillEntryActions`、`service_skill_launch` metadata 和既有 service skill 工作区启动链 + - 不把 service skill 粗暴并进 `capabilityRoute` 发送合同,避免把补参型做法误降级成普通 slash route + - 继续只收口 `@命令` 的选择边界,不扩散到 runtime / workspace 执行协议 +- 这次子改动目前已完成的定向验证: + - `npm exec vitest run "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/EmptyState.test.tsx"` + - `npx eslint "src/components/agent/chat/skill-selection/inputCapabilitySelection.ts" "src/components/agent/chat/skill-selection/builtinCommands.ts" "src/components/agent/chat/service-skills/runtimeSceneBinding.ts" "src/components/agent/chat/skill-selection/CharacterMention.tsx" "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/components/agent/chat/components/Inputbar/hooks/useInputbarController.ts" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/EmptyState.tsx" "src/components/agent/chat/components/EmptyState.test.tsx"` + - `vitest` 通过:`3 files / 157 tests passed` + - `eslint` 通过 + - `typecheck / verify:gui-smoke`:本轮已启动;当前环境因 `verify:gui-smoke` 触发独立 Cargo target 全量重建,仍在等待最终收口日志 + +- `@命令` 当前又继续往里收了一刀,把 `CharacterMentionPanel` 从“内部还保留多套 item -> callback 分发”的状态收成纯展示层,避免虽然外层已经统一成 `onSelectInputCapability`,但面板内部仍各自知道 `builtin / service skill / scene / installed skill` 的旧分叉: + - `CharacterMentionPanel` 当前只再暴露 `onSelectCapability(item)`,不再直接持有 `onSelectBuiltinCommand / onSelectServiceSkill / onSelectSceneCommand / onSelectInstalledSkill` 这一串内部 surface + - `CharacterMention` 当前接管全部 item 选择分发;因此 mention/slash 的真正行为事实源现在进一步集中到 `CharacterMention` 自己,而不是“面板分一部分、输入组件分一部分” + - `SkillSelector` 也同步复用同一条 `onSelectCapability(item)` 内部分发,但它只消费 `service_skill / installed_skill / available_skill` 三类与技能选择器主任务直接相关的 item,不再让面板替它理解选择语义 +- 因此当前 `@命令` 这一层的分类又更清楚了一点: + - `current`:`CharacterMentionPanel` 负责渲染,`CharacterMention` 负责选择语义,`Inputbar / EmptyState` 负责 capability 消费与 service skill 转发 + - `compat`:仅剩 `SkillSelector` 和工作区入口对 `onSelectServiceSkill` 的执行型消费;这条仍属于既有 service skill 工作区启动链,不是 `@命令` 选择协议 + - `dead`:`CharacterMentionPanel` 内部那套 `service skill` 特例分发当前已收掉 +- 这次子改动新增的定向验证当前已通过: + - `npm exec vitest run "src/components/agent/chat/skill-selection/CharacterMention.test.tsx" "src/components/agent/chat/skill-selection/SkillSelector.test.tsx" "src/components/agent/chat/components/Inputbar/index.test.tsx" "src/components/agent/chat/components/EmptyState.test.tsx"` + - `npx eslint "src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx" "src/components/agent/chat/skill-selection/CharacterMention.tsx" "src/components/agent/chat/skill-selection/SkillSelector.tsx"` + - `vitest` 通过:`4 files / 165 tests passed` + - `eslint` 通过 +- 这轮补跑后的交付验证结论: + - `npm run typecheck` 通过 + - `npm run smoke:agent-runtime-tool-surface-page -- --timeout-ms 240000` 通过 + - `npm run verify:gui-smoke -- --timeout-ms 240000` 首轮在 `smoke:agent-runtime-tool-surface-page` 的 `wait-runtime-summary` 阶段超时;但同一环境下随后的单独复跑已通过,因此当前更接近页面级 smoke 偶发卡顿,而不是本轮 `@命令` 收口引入的新结构性失败 diff --git a/docs/roadmap/task/acceptance.md b/docs/roadmap/task/acceptance.md index b5cbb2341..188b2a407 100644 --- a/docs/roadmap/task/acceptance.md +++ b/docs/roadmap/task/acceptance.md @@ -31,12 +31,16 @@ 7. `history_compress` internal turn 已写入统一 `lime_runtime` metadata,并复用同一组 side events 语义 8. `topic / generation_topic / agent_meta` 辅助会话已补齐统一 `task_profile / routing_decision / limit_state / cost_state` metadata,`agent_generate_title / generate_persona` 也已能回传 auxiliary session 的 `execution_runtime` 诊断快照,但仍停留在 auxiliary/compat 观测面 9. TS 协议解析测试已覆盖新的路由与限额事件链,并补齐 `agent_generate_title` runtime 诊断结果解析 +10. `@配图` 首发与重试链已把 auxiliary 标题生成诊断固化到图片 task artifact 的 `title_generation_result`,保证这份事实可被重试与后续诊断复读,同时不进入幂等指纹 +11. evidence pack 已能从图片 task artifact 提取 `title_generation_result.execution_runtime`,并导出 `auxiliaryRuntimeSnapshots + auxiliaryTaskRuntime` signal,供 replay / analysis / review 走统一消费链复用 +12. `request_model_resolution.rs` 已补齐多候选自动优选,当前会综合 family / lineage 连续性、reasoning / vision 能力匹配、成本等级、发布时间等信号做 current 优选 +13. `thread_read` 已补齐 `decision_reason / fallback_chain / oem_policy / runtime_summary / auxiliary_task_runtime`,前端 `AgentThreadReliabilityPanel / HarnessStatusPanel` 已走 current 字段展示运行时事实 +14. `runtime_handoff_artifact_service / runtime_evidence_pack_service / runtime_replay_case_service / runtime_analysis_handoff_service` 已改为统一消费 current runtime 事实,不再各自重新猜测路由原因 仍待继续的验收切片: -1. 更丰富的多候选自动优选策略 -2. 更多 OEM policy 字段、服务端下发配额策略与设置页展示的闭环 -3. `topic / generation_topic / agent_meta` 从 auxiliary session metadata 进一步提升到 current runtime 事件 / evidence 主链 +1. 更多 OEM policy 字段与服务端下发配额策略的完整闭环仍待继续;当前聊天侧运行时事实面板已可展示 `quotaStatus / offerState / providerSource / providerKey / fallbackToLocalAllowed / canInvoke / tenantId`,剩余缺口主要在设置页与服务端治理进一步打通。 +2. `topic / generation_topic / agent_meta` 已补齐 current submit-turn 入口识别、基础 side-event 覆盖、协议层解析与 `thread_read` 回退提升;当前剩余缺口主要收缩为更完整的端到端回归与把 auxiliary 事实进一步沉到更稳定的 submit-turn runtime 来源。 ## 2. 行为验收 diff --git a/docs/roadmap/task/runtime-integration.md b/docs/roadmap/task/runtime-integration.md index fa0654763..a1823c76a 100644 --- a/docs/roadmap/task/runtime-integration.md +++ b/docs/roadmap/task/runtime-integration.md @@ -127,12 +127,23 @@ - `topic / generation_topic / agent_meta` 辅助会话已携带同构 `lime_runtime` metadata,但仍停留在 auxiliary/compat 观测面,不算 current submit-turn 事件主链 - `agent_generate_title / generate_persona` 已开始把 auxiliary session 的 `execution_runtime` 诊断快照回传给命令结果,供调用方读取同构 `task_profile / routing_decision / limit_state / cost_state` - 这份命令结果诊断仍属于 auxiliary/compat 本地观察面,不等于 current submit-turn 的 thread read / evidence / replay 真相 +- 图片任务稳定工件 + - `@配图` 首发与重试链已把 `agent_generate_title` 的完整结果透传到图片任务协议,并以 `title_generation_result` 固化到 task artifact payload + - 这一步的定位是“稳定 task artifact 事实层”,用于后续重试、诊断、evidence 消费复读 auxiliary runtime 快照 + - evidence pack 已开始从图片 task artifact 提取 `title_generation_result.execution_runtime`,导出到 `runtime.json / artifacts.json / observabilitySummary.signalCoverage` + - `runtime_replay_case_service / runtime_analysis_handoff_service / runtime_review_decision_service` 继续通过统一 evidence pack 复用这份辅助 runtime 快照,不再分别单点拼装 + - 它仍不等于 current submit-turn 的 thread/evidence/replay 主链,只是把 auxiliary 观察面从函数返回值提升到可持久化工件,并通过 evidence pack 进入统一消费面 + +当前已继续补齐: + +- 多候选自动优选已进入 `request_model_resolution.rs` current 主链,并输出更完整的 `decision_reason` +- `thread_read` 已补齐 `decision_reason / fallback_chain / oem_policy / runtime_summary / auxiliary_task_runtime`,且当 current `execution_runtime` 缺失时,会从 auxiliary runtime task artifact 快照回退提升 `task_kind / service_model_slot / routing_mode / decision_source / decision_reason / candidate_count / fallback_chain / capability_gap / estimated_cost_class` +- `runtime_handoff_artifact_service / runtime_evidence_pack_service / runtime_replay_case_service / runtime_analysis_handoff_service` 已统一消费这组 current runtime 事实,减少旧的手工推断 仍待后续补齐: -- 更丰富的多候选自动优选 - 更多 OEM policy 字段、服务端下发配额策略与设置页展示的闭环 -- `topic / generation_topic / agent_meta` 从 auxiliary 本地 metadata 继续提升到 current runtime 事件 / evidence 主链 +- `topic / generation_topic / agent_meta` 已补齐 current submit-turn 入口识别、`service_model_slot` 映射与基础 side-event 覆盖;当前剩余缺口收缩为:补更完整的端到端验证与把 auxiliary 事实进一步沉到更稳定的 submit-turn 运行时来源。 这些事件的关系见 [event-chain.md](./event-chain.md)。 diff --git a/mimo-openai-doc.png b/mimo-openai-doc.png new file mode 100644 index 000000000..fb11142b1 Binary files /dev/null and b/mimo-openai-doc.png differ diff --git a/network-3030.txt b/network-3030.txt new file mode 100644 index 000000000..f34c5d3d9 --- /dev/null +++ b/network-3030.txt @@ -0,0 +1,6 @@ +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1b49115cf..cefd77f7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.18.0", + "version": "1.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.18.0", + "version": "1.19.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index 076eaefcd..ba6f1510c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.18.0", + "version": "1.19.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index 56ca6cf41..f236f281d 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.18.0" \ + --version "1.19.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index cb4d0f126..2941224ad 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.18.0", + "version": "1.19.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/playwright-after-input.md b/playwright-after-input.md new file mode 100644 index 000000000..e59e5f916 --- /dev/null +++ b/playwright-after-input.md @@ -0,0 +1,118 @@ +- generic [ref=e2]: + - generic [ref=e14]: + - complementary [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - button "Lime Lime" [ref=e18] [cursor=pointer]: + - img "Lime" [ref=e20] + - generic [ref=e21]: Lime + - button "折叠导航栏" [ref=e22] [cursor=pointer]: + - img [ref=e23] + - button "搜索任务" [ref=e26] [cursor=pointer]: + - img [ref=e27] + - generic [ref=e30]: 搜索任务 + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: 任务 + - button "新建任务" [ref=e34] [cursor=pointer]: + - img [ref=e35] + - generic [ref=e36]: 新建任务 + - button "生成" [ref=e37] [cursor=pointer]: + - img [ref=e38] + - generic [ref=e40]: 生成 + - generic [ref=e41]: + - generic [ref=e42]: 能力 + - button "我的方法" [ref=e43] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e46]: 我的方法 + - button "创作场景" [ref=e47] [cursor=pointer]: + - img [ref=e48] + - generic [ref=e51]: 创作场景 + - button "持续流程" [ref=e52] [cursor=pointer]: + - img [ref=e53] + - generic [ref=e57]: 持续流程 + - button "消息渠道" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e61]: 消息渠道 + - generic [ref=e62]: + - generic [ref=e63]: 资料 + - button "资料库" [ref=e64] [cursor=pointer]: + - img [ref=e65] + - generic [ref=e68]: 资料库 + - button "灵感库" [ref=e69] [cursor=pointer]: + - img [ref=e70] + - generic [ref=e82]: 灵感库 + - generic [ref=e83]: + - generic [ref=e84]: + - generic [ref=e85]: 系统 + - button "设置" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - generic [ref=e90]: 设置 + - button "切换到深色模式" [ref=e92] [cursor=pointer]: + - img [ref=e93] + - main [ref=e99]: + - generic [ref=e113]: + - generic [ref=e116]: + - generic [ref=e117]: + - generic [ref=e118]: 创作 + - generic [ref=e120]: + - button "📁 默认项目 默认" [ref=e121] [cursor=pointer]: + - generic [ref=e122] + - img [ref=e129] + - button "打开设置" [ref=e132] [cursor=pointer]: + - img [ref=e133] + - generic [ref=e137]: + - paragraph [ref=e138]: 青柠一下,灵感即来 + - paragraph [ref=e139]: 说一句目标,剩下的交给 Lime。 + - paragraph [ref=e140]: 文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。 + - generic [ref=e143]: + - generic [ref=e144]: + - button "添加图片" [ref=e145] [cursor=pointer]: + - img [ref=e146] + - textbox "有什么我可以帮你的?" [active] [ref=e151]: 你好,回复1 + - generic [ref=e152]: + - button "展开输入框" [ref=e153] [cursor=pointer]: + - img [ref=e154] + - button "开始语音输入" [ref=e156] [cursor=pointer]: + - img [ref=e157] + - button "发送" [ref=e160] [cursor=pointer]: + - img [ref=e161] + - generic [ref=e165]: + - button "展开高级设置" [ref=e166] [cursor=pointer]: + - img [ref=e169] + - generic [ref=e172]: 高级设置 + - img [ref=e173] + - generic "当前模型:Custom-cae6e762-fb45-4f71-878c-3106510ade78 / mimo-v2.5-pro" [ref=e175]: + - generic [ref=e176]: 当前模型 + - generic [ref=e177]: mimo-v2.5-pro + - generic [ref=e178]: + - generic [ref=e179]: + - generic [ref=e181]: + - generic [ref=e182]: 从这里开始 + - generic [ref=e183]: 先拿一个结果开工,后面继续补参考、改方向、扩成更多版本,都还在同一轮里推进。 + - button "我建议先做这个 每日趋势摘要 先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。 需要:主题或赛道、希望关注的平台/地域 · 交付:趋势摘要、3 个优先选题 等 3 项 · 去向:趋势摘要会先写回当前内容,方便继续展开选题和主稿。 · 下一步:继续展开其中一个选题 等 2 项 开始这一轮" [ref=e184] [cursor=pointer]: + - generic [ref=e186]: 我建议先做这个 + - generic [ref=e187]: 每日趋势摘要 + - generic [ref=e188]: 先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。 + - generic [ref=e189]: 需要:主题或赛道、希望关注的平台/地域 · 交付:趋势摘要、3 个优先选题 等 3 项 · 去向:趋势摘要会先写回当前内容,方便继续展开选题和主稿。 · 下一步:继续展开其中一个选题 等 2 项 + - generic [ref=e190]: + - text: 开始这一轮 + - img [ref=e191] + - generic [ref=e193]: + - generic [ref=e194]: + - generic [ref=e195]: 也可以换个结果开始 + - generic [ref=e196] + - generic [ref=e217]: + - generic [ref=e218]: 已经知道做法时,也可以直接这样开工 + - generic [ref=e219] + - generic [ref=e228]: 本轮产出会沉淀到当前项目,跑通过的方法会继续留在这里。 + - generic [ref=e229]: + - generic [ref=e230]: 也可以直接继续这轮。 + - generic [ref=e231]: + - button "回看最近结果 · MiMo E2E 2026-04-2…" [ref=e232] [cursor=pointer] + - generic [ref=e233]: + - button "短视频编排" [ref=e234] [cursor=pointer] + - button "每日趋势摘要" [ref=e235] [cursor=pointer] + - button "查看全部做法" [ref=e236] [cursor=pointer] + - button "连接浏览器" [ref=e237] [cursor=pointer] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-after-start.md b/playwright-after-start.md new file mode 100644 index 000000000..1a53548f2 --- /dev/null +++ b/playwright-after-start.md @@ -0,0 +1,8 @@ +- generic [ref=e2]: + - generic [ref=e44]: + - img [ref=e46] + - heading "设置完成!" [level=2] [ref=e52] + - paragraph [ref=e53]: Lime 已准备就绪,您可以开始使用了。 + - paragraph [ref=e54]: 提示:后续可在设置中继续调整语音输入和快捷键。 + - button "开始使用" [ref=e55] [cursor=pointer] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-current-console.txt b/playwright-current-console.txt new file mode 100644 index 000000000..57fcc9989 --- /dev/null +++ b/playwright-current-console.txt @@ -0,0 +1,2 @@ +Total messages: 59 (Errors: 0, Warnings: 1) +Returning 0 messages for level "error" diff --git a/playwright-current-lime.md b/playwright-current-lime.md new file mode 100644 index 000000000..e021f6c9d --- /dev/null +++ b/playwright-current-lime.md @@ -0,0 +1,19 @@ +- generic [ref=e2]: + - generic [ref=e4]: + - complementary [ref=e5]: + - generic [ref=e6]: + - generic [ref=e7] + - button "搜索任务" [ref=e16] [cursor=pointer] + - generic [ref=e21]: + - generic [ref=e22] + - generic [ref=e31] + - generic [ref=e52] + - generic [ref=e73]: + - generic [ref=e74] + - button "切换到深色模式" [ref=e82] [cursor=pointer] + - main [ref=e89]: + - generic [ref=e103]: + - generic [ref=e106] + - generic [ref=e133] + - generic [ref=e168] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-current-network.txt b/playwright-current-network.txt new file mode 100644 index 000000000..095b367d5 --- /dev/null +++ b/playwright-current-network.txt @@ -0,0 +1,607 @@ +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/health => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/health => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [FAILED] net::ERR_CONNECTION_REFUSED +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/health => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/health => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/health => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[GET] http://127.0.0.1:3030/health => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[GET] http://127.0.0.1:3030/events?event=lime%3A%2F%2Fcreation_task_submitted => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK +[POST] http://127.0.0.1:3030/invoke => [200] OK \ No newline at end of file diff --git a/playwright-current-state.md b/playwright-current-state.md new file mode 100644 index 000000000..e021f6c9d --- /dev/null +++ b/playwright-current-state.md @@ -0,0 +1,19 @@ +- generic [ref=e2]: + - generic [ref=e4]: + - complementary [ref=e5]: + - generic [ref=e6]: + - generic [ref=e7] + - button "搜索任务" [ref=e16] [cursor=pointer] + - generic [ref=e21]: + - generic [ref=e22] + - generic [ref=e31] + - generic [ref=e52] + - generic [ref=e73]: + - generic [ref=e74] + - button "切换到深色模式" [ref=e82] [cursor=pointer] + - main [ref=e89]: + - generic [ref=e103]: + - generic [ref=e106] + - generic [ref=e133] + - generic [ref=e168] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-home-deep.md b/playwright-home-deep.md new file mode 100644 index 000000000..be9a02a77 --- /dev/null +++ b/playwright-home-deep.md @@ -0,0 +1,118 @@ +- generic [ref=e2]: + - generic [ref=e14]: + - complementary [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - button "Lime Lime" [ref=e18] [cursor=pointer]: + - img "Lime" [ref=e20] + - generic [ref=e21]: Lime + - button "折叠导航栏" [ref=e22] [cursor=pointer]: + - img [ref=e23] + - button "搜索任务" [ref=e26] [cursor=pointer]: + - img [ref=e27] + - generic [ref=e30]: 搜索任务 + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: 任务 + - button "新建任务" [ref=e34] [cursor=pointer]: + - img [ref=e35] + - generic [ref=e36]: 新建任务 + - button "生成" [ref=e37] [cursor=pointer]: + - img [ref=e38] + - generic [ref=e40]: 生成 + - generic [ref=e41]: + - generic [ref=e42]: 能力 + - button "我的方法" [ref=e43] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e46]: 我的方法 + - button "创作场景" [ref=e47] [cursor=pointer]: + - img [ref=e48] + - generic [ref=e51]: 创作场景 + - button "持续流程" [ref=e52] [cursor=pointer]: + - img [ref=e53] + - generic [ref=e57]: 持续流程 + - button "消息渠道" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e61]: 消息渠道 + - generic [ref=e62]: + - generic [ref=e63]: 资料 + - button "资料库" [ref=e64] [cursor=pointer]: + - img [ref=e65] + - generic [ref=e68]: 资料库 + - button "灵感库" [ref=e69] [cursor=pointer]: + - img [ref=e70] + - generic [ref=e82]: 灵感库 + - generic [ref=e83]: + - generic [ref=e84]: + - generic [ref=e85]: 系统 + - button "设置" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - generic [ref=e90]: 设置 + - button "切换到深色模式" [ref=e92] [cursor=pointer]: + - img [ref=e93] + - main [ref=e99]: + - generic [ref=e113]: + - generic [ref=e116]: + - generic [ref=e117]: + - generic [ref=e118]: 创作 + - generic [ref=e120]: + - button "📁 默认项目 默认" [ref=e121] [cursor=pointer]: + - generic [ref=e122] + - img [ref=e129] + - button "打开设置" [ref=e132] [cursor=pointer]: + - img [ref=e133] + - generic [ref=e137]: + - paragraph [ref=e138]: 青柠一下,灵感即来 + - paragraph [ref=e139]: 说一句目标,剩下的交给 Lime。 + - paragraph [ref=e140]: 文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。 + - generic [ref=e143]: + - generic [ref=e144]: + - button "添加图片" [ref=e145] [cursor=pointer]: + - img [ref=e146] + - textbox "有什么我可以帮你的?" [ref=e151] + - generic [ref=e152]: + - button "展开输入框" [ref=e153] [cursor=pointer]: + - img [ref=e154] + - button "开始语音输入" [ref=e156] [cursor=pointer]: + - img [ref=e157] + - button "发送" [disabled] [ref=e160]: + - img [ref=e161] + - generic [ref=e165]: + - button "展开高级设置" [ref=e166] [cursor=pointer]: + - img [ref=e169] + - generic [ref=e172]: 高级设置 + - img [ref=e173] + - generic "当前模型:Custom-cae6e762-fb45-4f71-878c-3106510ade78 / mimo-v2.5-pro" [ref=e175]: + - generic [ref=e176]: 当前模型 + - generic [ref=e177]: mimo-v2.5-pro + - generic [ref=e178]: + - generic [ref=e179]: + - generic [ref=e181]: + - generic [ref=e182]: 从这里开始 + - generic [ref=e183]: 先拿一个结果开工,后面继续补参考、改方向、扩成更多版本,都还在同一轮里推进。 + - button "我建议先做这个 每日趋势摘要 先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。 需要:主题或赛道、希望关注的平台/地域 · 交付:趋势摘要、3 个优先选题 等 3 项 · 去向:趋势摘要会先写回当前内容,方便继续展开选题和主稿。 · 下一步:继续展开其中一个选题 等 2 项 开始这一轮" [ref=e184] [cursor=pointer]: + - generic [ref=e186]: 我建议先做这个 + - generic [ref=e187]: 每日趋势摘要 + - generic [ref=e188]: 先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。 + - generic [ref=e189]: 需要:主题或赛道、希望关注的平台/地域 · 交付:趋势摘要、3 个优先选题 等 3 项 · 去向:趋势摘要会先写回当前内容,方便继续展开选题和主稿。 · 下一步:继续展开其中一个选题 等 2 项 + - generic [ref=e190]: + - text: 开始这一轮 + - img [ref=e191] + - generic [ref=e193]: + - generic [ref=e194]: + - generic [ref=e195]: 也可以换个结果开始 + - generic [ref=e196] + - generic [ref=e217]: + - generic [ref=e218]: 已经知道做法时,也可以直接这样开工 + - generic [ref=e219] + - generic [ref=e228]: 本轮产出会沉淀到当前项目,跑通过的方法会继续留在这里。 + - generic [ref=e229]: + - generic [ref=e230]: 也可以直接继续这轮。 + - generic [ref=e231]: + - button "回看最近结果 · MiMo E2E 2026-04-2…" [ref=e232] [cursor=pointer] + - generic [ref=e233]: + - button "短视频编排" [ref=e234] [cursor=pointer] + - button "每日趋势摘要" [ref=e235] [cursor=pointer] + - button "查看全部做法" [ref=e236] [cursor=pointer] + - button "连接浏览器" [ref=e237] [cursor=pointer] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-home-snapshot.md b/playwright-home-snapshot.md new file mode 100644 index 000000000..5ab3f1b1b --- /dev/null +++ b/playwright-home-snapshot.md @@ -0,0 +1,22 @@ +- generic [ref=e2]: + - generic [ref=e12]: + - img "Lime" [ref=e14] + - heading "欢迎使用 Lime" [level=1] [ref=e15] + - paragraph [ref=e16]: Lime 是一款强大的 AI 客户端代理,帮助您轻松管理和使用多种 AI 服务。 + - generic [ref=e17]: + - generic [ref=e18]: + - img [ref=e20] + - heading "多模型支持" [level=3] [ref=e23] + - paragraph [ref=e24]: 支持 Claude、OpenAI、Gemini 等主流 AI 模型 + - generic [ref=e25]: + - img [ref=e27] + - heading "持续工作区" [level=3] [ref=e32] + - paragraph [ref=e33]: 围绕同一目标持续整理对话、素材和结果,减少来回切换。 + - generic [ref=e34]: + - img [ref=e36] + - heading "语音交互" [level=3] [ref=e39] + - paragraph [ref=e40]: 便捷的语音输入,提升对话效率 + - generic [ref=e41]: + - button "跳过引导" [ref=e42] [cursor=pointer] + - button "开始使用" [ref=e43] [cursor=pointer] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-lime-snapshot.md b/playwright-lime-snapshot.md new file mode 100644 index 000000000..8ef207e8d --- /dev/null +++ b/playwright-lime-snapshot.md @@ -0,0 +1,138 @@ +- generic [ref=e2]: + - generic [ref=e4]: + - complementary [ref=e5]: + - generic [ref=e6]: + - generic [ref=e7]: + - button "Lime Lime" [ref=e8] [cursor=pointer]: + - img "Lime" [ref=e10] + - generic [ref=e11]: Lime + - button "折叠导航栏" [ref=e12] [cursor=pointer]: + - img [ref=e13] + - button "搜索任务" [ref=e16] [cursor=pointer]: + - img [ref=e17] + - generic [ref=e20]: 搜索任务 + - generic [ref=e21]: + - generic [ref=e22]: + - generic [ref=e23]: 任务 + - button "新建任务" [ref=e24] [cursor=pointer]: + - img [ref=e25] + - generic [ref=e26]: 新建任务 + - button "生成" [ref=e27] [cursor=pointer]: + - img [ref=e28] + - generic [ref=e30]: 生成 + - generic [ref=e31]: + - generic [ref=e32]: 能力 + - button "我的方法" [ref=e33] [cursor=pointer]: + - img [ref=e34] + - generic [ref=e36]: 我的方法 + - button "创作场景" [ref=e37] [cursor=pointer]: + - img [ref=e38] + - generic [ref=e41]: 创作场景 + - button "持续流程" [ref=e42] [cursor=pointer]: + - img [ref=e43] + - generic [ref=e47]: 持续流程 + - button "消息渠道" [ref=e48] [cursor=pointer]: + - img [ref=e49] + - generic [ref=e51]: 消息渠道 + - generic [ref=e52]: + - generic [ref=e53]: 资料 + - button "资料库" [ref=e54] [cursor=pointer]: + - img [ref=e55] + - generic [ref=e58]: 资料库 + - button "灵感库" [ref=e59] [cursor=pointer]: + - img [ref=e60] + - generic [ref=e72]: 灵感库 + - generic [ref=e73]: + - generic [ref=e74]: + - generic [ref=e75]: 系统 + - button "设置" [ref=e76] [cursor=pointer]: + - img [ref=e77] + - generic [ref=e80]: 设置 + - button "切换到深色模式" [ref=e82] [cursor=pointer]: + - img [ref=e83] + - main [ref=e89]: + - generic [ref=e415]: + - generic [ref=e418]: + - generic [ref=e419]: + - generic [ref=e420]: 创作 + - generic [ref=e422]: + - button "选择项目" [ref=e423] [cursor=pointer]: + - generic [ref=e424]: + - img [ref=e426] + - generic [ref=e429]: 选择项目 + - img [ref=e431] + - button "打开设置" [ref=e434] [cursor=pointer]: + - img [ref=e435] + - generic [ref=e439]: + - paragraph [ref=e440]: 青柠一下,灵感即来 + - paragraph [ref=e441]: 说一句目标,剩下的交给 Lime。 + - paragraph [ref=e442]: 文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。 + - generic [ref=e445]: + - generic [ref=e446]: + - button "添加图片" [ref=e447] [cursor=pointer]: + - img [ref=e448] + - textbox "有什么我可以帮你的?" [ref=e453] + - generic [ref=e454]: + - button "展开输入框" [ref=e455] [cursor=pointer]: + - img [ref=e456] + - button "开始语音输入" [ref=e458] [cursor=pointer]: + - img [ref=e459] + - button "发送" [disabled] [ref=e462]: + - img [ref=e463] + - generic [ref=e467]: + - button "展开高级设置" [ref=e468] [cursor=pointer]: + - img [ref=e471] + - generic [ref=e474]: 高级设置 + - img [ref=e475] + - generic "当前模型:Custom-cae6e762-fb45-4f71-878c-3106510ade78 / mimo-v2.5-pro" [ref=e477]: + - generic [ref=e478]: 当前模型 + - generic [ref=e479]: mimo-v2.5-pro + - generic [ref=e480]: + - generic [ref=e481]: + - generic [ref=e483]: + - generic [ref=e484]: 从这里开始 + - generic [ref=e485]: 先拿一个结果开工,后面继续补参考、改方向、扩成更多版本,都还在同一轮里推进。 + - button "我建议先做这个 每日趋势摘要 先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。 需要:主题或赛道、希望关注的平台/地域 · 交付:趋势摘要、3 个优先选题 等 3 项 · 去向:趋势摘要会先写回当前内容,方便继续展开选题和主稿。 · 下一步:继续展开其中一个选题 等 2 项 开始这一轮" [ref=e486] [cursor=pointer]: + - generic [ref=e488]: 我建议先做这个 + - generic [ref=e489]: 每日趋势摘要 + - generic [ref=e490]: 先收一版内容趋势、热点方向和值得继续跟进的切口,适合每天快速开工前拉一遍。 + - generic [ref=e491]: 需要:主题或赛道、希望关注的平台/地域 · 交付:趋势摘要、3 个优先选题 等 3 项 · 去向:趋势摘要会先写回当前内容,方便继续展开选题和主稿。 · 下一步:继续展开其中一个选题 等 2 项 + - generic [ref=e492]: + - text: 开始这一轮 + - img [ref=e493] + - generic [ref=e495]: + - generic [ref=e496]: + - generic [ref=e497]: 也可以换个结果开始 + - generic [ref=e498]: + - button "内容主稿生成 需要:主题或产品信息、目标受众 · 交付:内容首稿、结构提纲 等 3 项 · 去向:首版主稿会先进入当前内容,方便继续改写、拆成多平台版本。 · 下一步:改成多平台版本 等 2 项" [ref=e499] [cursor=pointer]: + - generic [ref=e501]: 内容主稿生成 + - generic [ref=e502]: 需要:主题或产品信息、目标受众 · 交付:内容首稿、结构提纲 等 3 项 · 去向:首版主稿会先进入当前内容,方便继续改写、拆成多平台版本。 · 下一步:改成多平台版本 等 2 项 + - button "拆解一条爆款内容 需要:爆款链接或内容文本、你想复用的目标 · 交付:结构拆解、可复用套路 等 3 项 · 去向:拆解结论会先沉淀到当前内容,方便马上复刻成你的版本。 · 下一步:改成你的版本 等 2 项" [ref=e503] [cursor=pointer]: + - generic [ref=e505]: 拆解一条爆款内容 + - generic [ref=e506]: 需要:爆款链接或内容文本、你想复用的目标 · 交付:结构拆解、可复用套路 等 3 项 · 去向:拆解结论会先沉淀到当前内容,方便马上复刻成你的版本。 · 下一步:改成你的版本 等 2 项 + - button "长文转多平台发布稿 需要:原始长文或资料、目标平台 · 交付:标题组、多平台发布稿 等 3 项 · 去向:多平台发布稿会先写回当前内容,继续在当前工作区整理和拆分。 · 下一步:生成配图方向 等 2 项" [ref=e507] [cursor=pointer]: + - generic [ref=e509]: 长文转多平台发布稿 + - generic [ref=e510]: 需要:原始长文或资料、目标平台 · 交付:标题组、多平台发布稿 等 3 项 · 去向:多平台发布稿会先写回当前内容,继续在当前工作区整理和拆分。 · 下一步:生成配图方向 等 2 项 + - button "脚本转口播/字幕稿 需要:现有脚本、口播场景或时长 · 交付:口播稿、字幕切分 等 3 项 · 去向:口播稿和字幕稿会先写回当前内容,方便继续改分镜、配音或多语言版本。 · 下一步:继续生成分镜 等 2 项" [ref=e511] [cursor=pointer]: + - generic [ref=e513]: 脚本转口播/字幕稿 + - generic [ref=e514]: 需要:现有脚本、口播场景或时长 · 交付:口播稿、字幕切分 等 3 项 · 去向:口播稿和字幕稿会先写回当前内容,方便继续改分镜、配音或多语言版本。 · 下一步:继续生成分镜 等 2 项 + - button "复盘这个账号/项目 需要:账号或项目目标、已有结果或数据 · 交付:复盘摘要、关键问题 等 3 项 · 去向:复盘摘要会先回到当前内容,并把下一轮动作继续带回生成工作台。 · 下一步:继续做趋势摘要 等 2 项" [ref=e515] [cursor=pointer]: + - generic [ref=e517]: 复盘这个账号/项目 + - generic [ref=e518]: 需要:账号或项目目标、已有结果或数据 · 交付:复盘摘要、关键问题 等 3 项 · 去向:复盘摘要会先回到当前内容,并把下一轮动作继续带回生成工作台。 · 下一步:继续做趋势摘要 等 2 项 + - generic [ref=e519]: + - generic [ref=e520]: 已经知道做法时,也可以直接这样开工 + - generic [ref=e521]: + - button "复制轮播帖 需要:参考帖子、执行方式 等 3 项 · 交付:轮播结构 + 文案初稿" [ref=e522] [cursor=pointer]: + - generic [ref=e524]: 复制轮播帖 + - generic [ref=e525]: 需要:参考帖子、执行方式 等 3 项 · 交付:轮播结构 + 文案初稿 + - button "复制视频脚本 需要:参考视频链接/素材、脚本模式 等 3 项 · 交付:脚本大纲 + 镜头节奏" [ref=e526] [cursor=pointer]: + - generic [ref=e528]: 复制视频脚本 + - generic [ref=e529]: 需要:参考视频链接/素材、脚本模式 等 3 项 · 交付:脚本大纲 + 镜头节奏 + - generic [ref=e530]: 本轮产出会沉淀到当前项目,跑通过的方法会继续留在这里。 + - generic [ref=e531]: + - generic [ref=e532]: 也可以直接继续这轮。 + - generic [ref=e533]: + - button "回看最近结果 · 新对话" [ref=e534] [cursor=pointer] + - button "查看全部做法" [ref=e535] [cursor=pointer] + - button "连接浏览器" [ref=e536] [cursor=pointer] + - region "Notifications alt+T" \ No newline at end of file diff --git a/post-hmr-state.png b/post-hmr-state.png new file mode 100644 index 000000000..853bddc7e Binary files /dev/null and b/post-hmr-state.png differ diff --git a/scripts/agent-runtime-tool-surface-page-smoke.mjs b/scripts/agent-runtime-tool-surface-page-smoke.mjs index 2bc96dd58..1571da876 100644 --- a/scripts/agent-runtime-tool-surface-page-smoke.mjs +++ b/scripts/agent-runtime-tool-surface-page-smoke.mjs @@ -259,7 +259,7 @@ function buildHarnessBootstrapScript() { localStorage.setItem("lime.chat.harness-panel.visible.v1", "true"); localStorage.setItem( "lime:debug:runtime-tool-availability:v1", - ${JSON.stringify(JSON.stringify(RUNTIME_TOOL_AVAILABILITY_OVERRIDE))} + JSON.stringify(${JSON.stringify(RUNTIME_TOOL_AVAILABILITY_OVERRIDE)}) ); return true; })()`; @@ -297,7 +297,20 @@ function buildFillPromptScript(prompt) { aria: button.getAttribute("aria-label"), disabled: Boolean(button.disabled), })); - const textarea = document.querySelector('textarea[placeholder="有什么我可以帮你的?"]'); + const resolveTextarea = () => { + const candidates = Array.from( + document.querySelectorAll( + '[data-testid="inputbar-core-container"] textarea, textarea', + ), + ); + return ( + candidates.find( + (candidate) => + candidate instanceof HTMLTextAreaElement && !candidate.disabled, + ) || null + ); + }; + const textarea = resolveTextarea(); const initialSend = document.querySelector('button[aria-label="发送"]'); if (!textarea || !initialSend) { return { @@ -320,9 +333,7 @@ function buildFillPromptScript(prompt) { })); textarea.dispatchEvent(new Event("change", { bubbles: true })); - const currentTextarea = - document.querySelector('textarea[placeholder="有什么我可以帮你的?"]') || - textarea; + const currentTextarea = resolveTextarea() || textarea; const currentSend = document.querySelector('button[aria-label="发送"]'); return { ok: true, @@ -335,7 +346,15 @@ function buildFillPromptScript(prompt) { function buildSendReadyScript() { return `(() => { - const textarea = document.querySelector('textarea[placeholder="有什么我可以帮你的?"]'); + const textarea = + Array.from( + document.querySelectorAll( + '[data-testid="inputbar-core-container"] textarea, textarea', + ), + ).find( + (candidate) => + candidate instanceof HTMLTextAreaElement && !candidate.disabled, + ) || null; const send = document.querySelector('button[aria-label="发送"]'); return { ok: @@ -391,7 +410,6 @@ function buildClickSendScript() { function buildOpenWorkbenchScript() { return `(() => { - const bodyText = document.body ? document.body.innerText : ""; const target = Array.from(document.querySelectorAll("button")).find( (button) => ((button.textContent || "").trim() === "工作台" || @@ -409,8 +427,11 @@ function buildOpenWorkbenchScript() { }; } + const dialogOpen = Boolean( + document.querySelector('[role="dialog"] [data-harness-drag-handle="true"]'), + ); const alreadyOpen = - bodyText.includes("处理工作台") || + dialogOpen || target.getAttribute("aria-expanded") === "true" || (target.getAttribute("aria-label") || "").includes("收起工作台") || (target.getAttribute("title") || "").includes("收起工作台"); @@ -419,6 +440,7 @@ function buildOpenWorkbenchScript() { return { ok: true, alreadyOpen: true, + dialogOpen, ariaExpanded: target.getAttribute("aria-expanded"), ariaLabel: target.getAttribute("aria-label"), }; @@ -428,12 +450,34 @@ function buildOpenWorkbenchScript() { return { ok: true, alreadyOpen: false, + dialogOpen: Boolean( + document.querySelector('[role="dialog"] [data-harness-drag-handle="true"]'), + ), ariaExpanded: target.getAttribute("aria-expanded"), ariaLabel: target.getAttribute("aria-label"), }; })()`; } +function buildWorkbenchButtonCheckScript() { + return `(() => { + const button = Array.from(document.querySelectorAll("button")).find( + (candidate) => + ((candidate.textContent || "").trim() === "工作台" || + (candidate.getAttribute("aria-label") || "").includes("工作台") || + (candidate.getAttribute("title") || "").includes("工作台")) && + candidate instanceof HTMLButtonElement, + ); + + return { + hasButton: Boolean(button), + text: document.body ? document.body.innerText : "", + ariaLabel: button?.getAttribute("aria-label") || null, + title: button?.getAttribute("title") || null, + }; + })()`; +} + function buildRuntimeSummaryCheckScript() { return `(() => { const text = document.body ? document.body.innerText : ""; @@ -622,7 +666,7 @@ async function main() { ok: typeof text === "string" && (text.includes("青柠一下,灵感即来") || - text.includes("有什么我可以帮你的?")), + text.includes("先说这轮要做什么")), value: text, }; }); @@ -671,15 +715,15 @@ async function main() { logStage("wait-workbench-button"); await waitForCheck(options, "运行态工作台按钮出现", async () => { - const text = await runJavascript( + const value = await runJavascript( options, profileKey, - 'document.body ? document.body.innerText : ""', + buildWorkbenchButtonCheckScript(), "wait-workbench-button", ); return { - ok: typeof text === "string" && text.includes("工作台"), - value: text, + ok: value?.hasButton === true, + value, }; }); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fedc16f15..a36c8e256 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.18.0" +version = "1.19.0" dependencies = [ "anyhow", "arboard", @@ -5205,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.18.0" +version = "1.19.0" dependencies = [ "anyhow", "aster-core", @@ -5234,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.18.0" +version = "1.19.0" dependencies = [ "chrono", "futures", @@ -5251,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.18.0" +version = "1.19.0" dependencies = [ "clap", "lime-core", @@ -5263,7 +5263,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.18.0" +version = "1.19.0" dependencies = [ "async-trait", "lime-core", @@ -5279,7 +5279,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.18.0" +version = "1.19.0" dependencies = [ "aster-models", "async-trait", @@ -5319,7 +5319,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.18.0" +version = "1.19.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5354,7 +5354,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.18.0" +version = "1.19.0" dependencies = [ "aes", "axum 0.7.9", @@ -5384,7 +5384,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.18.0" +version = "1.19.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5404,7 +5404,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.18.0" +version = "1.19.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5420,7 +5420,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.18.0" +version = "1.19.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5451,7 +5451,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.18.0" +version = "1.19.0" dependencies = [ "async-trait", "lime-core", @@ -5470,7 +5470,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.18.0" +version = "1.19.0" dependencies = [ "anyhow", "async-stream", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.18.0" +version = "1.19.0" dependencies = [ "aster-core", "async-stream", @@ -5570,7 +5570,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.18.0" +version = "1.19.0" dependencies = [ "axum 0.7.9", "futures", @@ -5585,7 +5585,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.18.0" +version = "1.19.0" dependencies = [ "anyhow", "aster-core", @@ -5627,7 +5627,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.18.0" +version = "1.19.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5645,7 +5645,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.18.0" +version = "1.19.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 247f7e24f..1b15da5a3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"] resolver = "2" [workspace.package] -version = "1.18.0" +version = "1.19.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -188,7 +188,7 @@ version = "2.4" [package] name = "lime" -version = "1.18.0" +version = "1.19.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/crates/agent/src/lib.rs b/src-tauri/crates/agent/src/lib.rs index f48b63ec3..0299763f7 100644 --- a/src-tauri/crates/agent/src/lib.rs +++ b/src-tauri/crates/agent/src/lib.rs @@ -108,9 +108,10 @@ pub use session_execution_runtime::{ persist_session_recent_preferences, persist_session_recent_team_selection, SessionExecutionRuntime, SessionExecutionRuntimeAccessMode, SessionExecutionRuntimeCostState, SessionExecutionRuntimeLimitEvent, SessionExecutionRuntimeLimitState, - SessionExecutionRuntimePreferences, SessionExecutionRuntimeRecentTeamRole, - SessionExecutionRuntimeRecentTeamSelection, SessionExecutionRuntimeRoutingDecision, - SessionExecutionRuntimeSource, SessionExecutionRuntimeTaskProfile, + SessionExecutionRuntimeOemPolicy, SessionExecutionRuntimePreferences, + SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection, + SessionExecutionRuntimeRoutingDecision, SessionExecutionRuntimeSource, + SessionExecutionRuntimeSummary, SessionExecutionRuntimeTaskProfile, }; pub use session_query::{ collect_subagent_cascade_session_ids, list_child_subagent_sessions, @@ -120,11 +121,11 @@ pub use session_state_snapshot::SessionStateSnapshot; pub use session_store::{ create_session_sync, delete_session, get_persisted_session_metadata_sync, get_runtime_session_detail, get_session_sync, list_sessions_sync, - list_title_preview_messages_sync, rename_session_sync, update_session_execution_strategy_sync, - update_session_provider_config_sync, update_session_working_dir_sync, - ChildSubagentRuntimeStatus, ChildSubagentSession, PersistedSessionMetadata, SessionDetail, - SessionInfo, SessionTitlePreviewMessage, SessionTodoItem, SessionTodoStatus, - SubagentParentContext, + list_title_preview_messages_sync, rename_session_sync, update_session_archived_state_sync, + update_session_execution_strategy_sync, update_session_provider_config_sync, + update_session_working_dir_sync, ChildSubagentRuntimeStatus, ChildSubagentSession, + PersistedSessionMetadata, SessionDetail, SessionInfo, SessionTitlePreviewMessage, + SessionTodoItem, SessionTodoStatus, SubagentParentContext, }; pub use session_update::{ create_subagent_session, persist_compaction_session_metrics_update, diff --git a/src-tauri/crates/agent/src/request_tool_policy.rs b/src-tauri/crates/agent/src/request_tool_policy.rs index f3f281fce..594837e7a 100644 --- a/src-tauri/crates/agent/src/request_tool_policy.rs +++ b/src-tauri/crates/agent/src/request_tool_policy.rs @@ -1485,6 +1485,7 @@ where F: FnMut(&RuntimeAgentEvent), { let mut auto_compaction_projection = AutoCompactionProjectionState; + let mut inline_provider_error = None; let mut stream = agent .reply(user_message, session_config, cancel_token) .await @@ -1497,17 +1498,13 @@ where match event_result { Ok(agent_event) => { *emitted_any = true; - let inline_provider_error = match &agent_event { - AsterAgentEvent::Message(message) => { - extract_inline_agent_provider_error(message) - } - _ => None, - }; - if let Some(message) = inline_provider_error { - return Err(ReplyAttemptError { - message, - emitted_any: true, - }); + if inline_provider_error.is_none() { + inline_provider_error = match &agent_event { + AsterAgentEvent::Message(message) => { + extract_inline_agent_provider_error(message) + } + _ => None, + }; } let runtime_events = auto_compaction_projection .project_event(&agent_event) @@ -1553,13 +1550,20 @@ where } Err(e) => { return Err(ReplyAttemptError { - message: format!("Stream error: {e}"), + message: inline_provider_error.unwrap_or_else(|| format!("Stream error: {e}")), emitted_any: *emitted_any, }); } } } + if let Some(message) = inline_provider_error { + return Err(ReplyAttemptError { + message, + emitted_any: *emitted_any, + }); + } + Ok(()) } @@ -2140,7 +2144,7 @@ mod tests { use super::*; use aster::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; use aster::providers::errors::ProviderError; - use aster::session::{SessionManager, SessionType, TurnContextOverride}; + use aster::session::{SessionManager, SessionType, TurnContextOverride, TurnStatus}; use async_trait::async_trait; use std::collections::HashMap; use std::path::PathBuf; @@ -2221,6 +2225,39 @@ mod tests { } } + struct AuthenticationErrorProvider; + + #[async_trait] + impl Provider for AuthenticationErrorProvider { + fn metadata() -> ProviderMetadata + where + Self: Sized, + { + ProviderMetadata::empty() + } + + fn get_name(&self) -> &str { + "authentication-error-provider" + } + + async fn complete_with_model( + &self, + _model_config: &aster::model::ModelConfig, + _system: &str, + _messages: &[Message], + _tools: &[rmcp::model::Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Err(ProviderError::Authentication( + "Authentication failed. Status: 403 Forbidden. Response: Illegal access" + .to_string(), + )) + } + + fn get_model_config(&self) -> aster::model::ModelConfig { + aster::model::ModelConfig::new("mimo-v2.5-pro").expect("test model config") + } + } + fn build_auto_compaction_disabled_turn_context() -> TurnContextOverride { let mut metadata = HashMap::new(); metadata.insert( @@ -2812,4 +2849,79 @@ mod tests { "应向前端投影空答复重试状态" ); } + + #[tokio::test] + async fn stream_message_reply_with_policy_should_drain_inline_provider_error_and_mark_turn_failed( + ) { + let session = SessionManager::create_session( + PathBuf::default(), + "lime-inline-provider-error".to_string(), + SessionType::Hidden, + ) + .await + .expect("应创建测试 session"); + let agent = Agent::new(); + agent + .update_provider(Arc::new(AuthenticationErrorProvider), &session.id) + .await + .expect("应配置测试 provider"); + + let session_config = aster::agents::SessionConfig { + id: session.id.clone(), + thread_id: None, + turn_id: Some("turn-inline-provider-error".to_string()), + schedule_id: None, + max_turns: None, + retry_config: None, + system_prompt: None, + include_context_trace: None, + turn_context: None, + }; + let policy = resolve_request_tool_policy(Some(false), false); + let mut runtime_events = Vec::new(); + + let error = stream_message_reply_with_policy( + &agent, + Message::user().with_text("你好,回复1"), + None, + session_config, + None, + &policy, + |event| runtime_events.push(event.clone()), + ) + .await + .expect_err("鉴权失败时应返回 provider 执行错误"); + + assert!(error.message.contains("Agent provider execution failed")); + assert!(error.message.contains("Authentication failed")); + assert!( + runtime_events.iter().any(|event| matches!( + event, + RuntimeAgentEvent::TextDelta { text } + if text.contains("Ran into this error:") + && text.contains("Authentication failed") + )), + "应保留底层 provider 错误文本事件" + ); + + let snapshot = agent + .runtime_snapshot(&session.id) + .await + .expect("应读取 runtime snapshot"); + let latest_turn = snapshot + .threads + .iter() + .flat_map(|thread| thread.turns.iter()) + .max_by_key(|turn| turn.updated_at.timestamp_millis()) + .expect("应存在 runtime turn"); + + assert_ne!(latest_turn.status, TurnStatus::Running); + assert!( + matches!( + latest_turn.status, + TurnStatus::Completed | TurnStatus::Failed + ), + "turn 至少应进入终态,不能继续停留在 running" + ); + } } diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs index 5d926ad00..6bf4ed940 100644 --- a/src-tauri/crates/agent/src/session_execution_runtime.rs +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -4,6 +4,7 @@ use aster::session::extension_data::{ExtensionData, ExtensionState}; use aster::session::{ Session, SessionRuntimeSnapshot, TurnContextOverride, TurnOutputSchemaRuntime, TurnStatus, }; +use lime_core::database::dao::agent_timeline::{AgentThreadItem, AgentThreadItemPayload}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; @@ -13,6 +14,9 @@ const LIME_RUNTIME_ROUTING_DECISION_KEY: &str = "routing_decision"; const LIME_RUNTIME_LIMIT_STATE_KEY: &str = "limit_state"; const LIME_RUNTIME_COST_STATE_KEY: &str = "cost_state"; const LIME_RUNTIME_LIMIT_EVENT_KEY: &str = "limit_event"; +const LIME_RUNTIME_OEM_POLICY_KEY: &str = "oem_policy"; +const LIME_RUNTIME_SUMMARY_KEY: &str = "runtime_summary"; +const RUNTIME_MODEL_PERMISSION_FALLBACK_WARNING_CODE: &str = "runtime_model_permission_fallback"; fn normalize_optional_text(value: Option) -> Option { let trimmed = value?.trim().to_string(); @@ -400,6 +404,61 @@ pub struct SessionExecutionRuntimeLimitEvent { pub retryable: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SessionExecutionRuntimeOemPolicy { + pub tenant_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub config_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offer_state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quota_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_to_local_allowed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub can_invoke: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SessionExecutionRuntimeSummary { + #[serde(skip_serializing_if = "Option::is_none")] + pub candidate_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub routing_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_reason: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fallback_chain: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub estimated_cost_class: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub estimated_total_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_event_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit_event_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub capability_gap: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub single_candidate_only: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub oem_locked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quota_low: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SessionExecutionRuntime { pub session_id: String, @@ -446,6 +505,10 @@ pub struct SessionExecutionRuntime { pub cost_state: Option, #[serde(skip_serializing_if = "Option::is_none")] pub limit_event: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub oem_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_summary: Option, } fn resolve_session_token_usage(session: &Session) -> Option { @@ -903,6 +966,43 @@ fn extract_limit_event_from_metadata( Some(limit_event) } +fn extract_oem_policy_from_metadata( + metadata: &std::collections::HashMap, +) -> Option { + let mut oem_policy: SessionExecutionRuntimeOemPolicy = + extract_lime_runtime_payload(metadata, LIME_RUNTIME_OEM_POLICY_KEY)?; + oem_policy.tenant_id = + normalize_optional_text(Some(std::mem::take(&mut oem_policy.tenant_id)))?; + oem_policy.provider_source = normalize_optional_text(oem_policy.provider_source); + oem_policy.provider_key = normalize_optional_text(oem_policy.provider_key); + oem_policy.default_model = normalize_optional_text(oem_policy.default_model); + oem_policy.config_mode = normalize_optional_text(oem_policy.config_mode); + oem_policy.offer_state = normalize_optional_text(oem_policy.offer_state); + oem_policy.quota_status = normalize_optional_text(oem_policy.quota_status); + Some(oem_policy) +} + +fn extract_runtime_summary_from_metadata( + metadata: &std::collections::HashMap, +) -> Option { + let mut summary: SessionExecutionRuntimeSummary = + extract_lime_runtime_payload(metadata, LIME_RUNTIME_SUMMARY_KEY)?; + summary.routing_mode = normalize_optional_text(summary.routing_mode); + summary.decision_source = normalize_optional_text(summary.decision_source); + summary.decision_reason = normalize_optional_text(summary.decision_reason); + summary.estimated_cost_class = normalize_optional_text(summary.estimated_cost_class); + summary.limit_status = normalize_optional_text(summary.limit_status); + summary.limit_event_kind = normalize_optional_text(summary.limit_event_kind); + summary.limit_event_message = normalize_optional_text(summary.limit_event_message); + summary.capability_gap = normalize_optional_text(summary.capability_gap); + summary.fallback_chain = summary + .fallback_chain + .into_iter() + .filter_map(|value| normalize_optional_text(Some(value))) + .collect(); + Some(summary) +} + fn extract_recent_harness_context_from_runtime_snapshot( snapshot: &SessionRuntimeSnapshot, ) -> RecentHarnessContext { @@ -1072,6 +1172,8 @@ pub fn build_session_execution_runtime( limit_state: None, cost_state: None, limit_event: None, + oem_policy: None, + runtime_summary: None, }; if let Some(snapshot) = snapshot { @@ -1127,6 +1229,14 @@ pub fn build_session_execution_runtime( .context_override .as_ref() .and_then(|value| extract_cost_state_from_metadata(&value.metadata)); + runtime.oem_policy = latest_turn + .context_override + .as_ref() + .and_then(|value| extract_oem_policy_from_metadata(&value.metadata)); + runtime.runtime_summary = latest_turn + .context_override + .as_ref() + .and_then(|value| extract_runtime_summary_from_metadata(&value.metadata)); let metadata_limit_event = latest_turn .context_override .as_ref() @@ -1183,14 +1293,59 @@ pub fn build_session_execution_runtime( Some(runtime) } +fn has_runtime_model_permission_fallback_warning(items: &[AgentThreadItem], turn_id: &str) -> bool { + items.iter().any(|item| { + item.turn_id == turn_id + && matches!( + &item.payload, + AgentThreadItemPayload::Warning { + code: Some(code), + .. + } if code == RUNTIME_MODEL_PERMISSION_FALLBACK_WARNING_CODE + ) + }) +} + +pub fn reconcile_session_execution_runtime_permission_fallback( + runtime: &mut SessionExecutionRuntime, + items: &[AgentThreadItem], + persisted_session_model_name: Option<&str>, +) { + let Some(latest_turn_id) = runtime.latest_turn_id.as_deref() else { + return; + }; + if !has_runtime_model_permission_fallback_warning(items, latest_turn_id) { + return; + } + + let Some(session_model_name) = persisted_session_model_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + else { + return; + }; + + runtime.model_name = Some(session_model_name.clone()); + + if let Some(output_schema_runtime) = runtime.output_schema_runtime.as_mut() { + output_schema_runtime.model_name = Some(session_model_name.clone()); + } + + if let Some(routing_decision) = runtime.routing_decision.as_mut() { + routing_decision.selected_model = Some(session_model_name); + } +} + #[cfg(test)] mod tests { use super::{ apply_usage_to_cost_state, build_session_execution_runtime, detect_runtime_limit_event, + reconcile_session_execution_runtime_permission_fallback, SessionExecutionRuntime, SessionExecutionRuntimeAccessMode, SessionExecutionRuntimeCostState, SessionExecutionRuntimeLimitEvent, SessionExecutionRuntimePreferences, SessionExecutionRuntimeRecentTeamRole, SessionExecutionRuntimeRecentTeamSelection, - SessionExecutionRuntimeSource, + SessionExecutionRuntimeRoutingDecision, SessionExecutionRuntimeSource, }; use aster::model::ModelConfig; use aster::session::ExtensionState; @@ -1200,6 +1355,9 @@ mod tests { TurnStatus, }; use chrono::{Duration, Utc}; + use lime_core::database::dao::agent_timeline::{ + AgentThreadItem, AgentThreadItemPayload, AgentThreadItemStatus, + }; use serde_json::json; use std::path::PathBuf; @@ -2085,6 +2243,103 @@ mod tests { retryable: true, }) ); + assert_eq!( + runtime + .oem_policy + .as_ref() + .and_then(|value| value.tenant_id.clone().into()), + Some("tenant-1".to_string()) + ); + assert_eq!( + runtime + .runtime_summary + .as_ref() + .and_then(|value| value.limit_event_kind.as_deref()), + Some("quota_low") + ); + } + + #[test] + fn permission_fallback_warning_prefers_persisted_session_model_in_runtime_view() { + let mut runtime = SessionExecutionRuntime { + session_id: "session-fallback".to_string(), + provider_selector: Some("custom-mimo".to_string()), + provider_name: Some("anthropic".to_string()), + model_name: Some("mimo-v2-flash".to_string()), + execution_strategy: Some("react".to_string()), + output_schema_runtime: None, + source: SessionExecutionRuntimeSource::RuntimeSnapshot, + mode: None, + latest_turn_id: Some("turn-fallback".to_string()), + latest_turn_status: Some("completed".to_string()), + recent_access_mode: None, + recent_preferences: None, + recent_team_selection: None, + recent_theme: None, + recent_session_mode: None, + recent_gate_key: None, + recent_run_title: None, + recent_content_id: None, + task_profile: None, + routing_decision: Some(SessionExecutionRuntimeRoutingDecision { + routing_mode: "multi_candidate".to_string(), + decision_source: "request_override".to_string(), + decision_reason: + "当前回合的 provider/model 选择优先遵循显式偏好,其次回退到会话默认。" + .to_string(), + selected_provider: Some("custom-mimo".to_string()), + selected_model: Some("mimo-v2-flash".to_string()), + requested_provider: Some("custom-mimo".to_string()), + requested_model: Some("mimo-v2-flash".to_string()), + candidate_count: 7, + estimated_cost_class: Some("medium".to_string()), + capability_gap: None, + fallback_chain: Vec::new(), + settings_source: None, + service_model_slot: None, + }), + limit_state: None, + cost_state: None, + limit_event: None, + oem_policy: None, + runtime_summary: None, + }; + let items = vec![AgentThreadItem { + id: "warning-1".to_string(), + thread_id: "session-fallback".to_string(), + turn_id: "turn-fallback".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: Utc::now().to_rfc3339(), + completed_at: Some(Utc::now().to_rfc3339()), + updated_at: Utc::now().to_rfc3339(), + payload: AgentThreadItemPayload::Warning { + message: "当前模型暂不可用,已自动切换到兼容候选。".to_string(), + code: Some("runtime_model_permission_fallback".to_string()), + }, + }]; + + reconcile_session_execution_runtime_permission_fallback( + &mut runtime, + &items, + Some("mimo-v2.5-pro"), + ); + + assert_eq!(runtime.model_name.as_deref(), Some("mimo-v2.5-pro")); + assert_eq!( + runtime + .routing_decision + .as_ref() + .and_then(|value| value.selected_model.as_deref()), + Some("mimo-v2.5-pro") + ); + assert_eq!( + runtime + .routing_decision + .as_ref() + .and_then(|value| value.requested_model.as_deref()), + Some("mimo-v2-flash") + ); } #[test] diff --git a/src-tauri/crates/agent/src/session_store.rs b/src-tauri/crates/agent/src/session_store.rs index f8e33f1fc..94f6bba95 100644 --- a/src-tauri/crates/agent/src/session_store.rs +++ b/src-tauri/crates/agent/src/session_store.rs @@ -29,7 +29,10 @@ use crate::protocol::{ AgentMessage as RuntimeAgentMessage, AgentMessageContent as RuntimeAgentMessageContent, }; use crate::protocol_projection::{project_item_runtime, project_turn_runtime}; -use crate::session_execution_runtime::{build_session_execution_runtime, SessionExecutionRuntime}; +use crate::session_execution_runtime::{ + build_session_execution_runtime, reconcile_session_execution_runtime_permission_fallback, + SessionExecutionRuntime, +}; use crate::session_query::{list_child_subagent_sessions, read_session}; use crate::subagent_control::{load_subagent_runtime_status, SubagentRuntimeStatusKind}; use crate::subagent_profiles::{SubagentCustomizationState, SubagentSkillSummary}; @@ -48,6 +51,8 @@ pub struct SessionInfo { pub name: String, pub created_at: i64, pub updated_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub archived_at: Option, pub messages_count: usize, pub execution_strategy: Option, pub model: Option, @@ -776,6 +781,11 @@ fn apply_aster_runtime_snapshot(detail: &mut SessionDetail, snapshot: &SessionRu fn build_runtime_session_info(overview: SessionRecordOverview) -> SessionInfo { let working_dir = overview.working_dir; let workspace_id = overview.workspace_id; + let archived_at = overview.archived_at.and_then(|value| { + chrono::DateTime::parse_from_rfc3339(&value) + .map(|dt| dt.timestamp()) + .ok() + }); SessionInfo { id: overview.id, @@ -786,6 +796,7 @@ fn build_runtime_session_info(overview: SessionRecordOverview) -> SessionInfo { updated_at: chrono::DateTime::parse_from_rfc3339(&overview.updated_at) .map(|dt| dt.timestamp()) .unwrap_or(0), + archived_at, messages_count: overview.messages_count, execution_strategy: overview.execution_strategy, model: Some(overview.model), @@ -895,9 +906,12 @@ pub fn create_session_sync( } /// 列出所有会话 -pub fn list_sessions_sync(db: &DbConnection) -> Result, String> { +pub fn list_sessions_sync( + db: &DbConnection, + include_archived: bool, +) -> Result, String> { let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; - let sessions = agent_session_repository::list_session_overviews(&conn)?; + let sessions = agent_session_repository::list_session_overviews(&conn, include_archived)?; Ok(sessions .into_iter() @@ -1150,6 +1164,13 @@ pub async fn get_runtime_session_detail( if let Some(snapshot) = runtime_snapshot.as_ref() { apply_aster_runtime_snapshot(&mut detail, snapshot); } + if let Some(runtime) = detail.execution_runtime.as_mut() { + reconcile_session_execution_runtime_permission_fallback( + runtime, + &detail.items, + detail.model.as_deref(), + ); + } match load_child_subagent_sessions(db, session_id).await { Ok(child_subagent_sessions) => { @@ -1261,6 +1282,18 @@ pub fn update_session_provider_config_sync( Ok(()) } +pub fn update_session_archived_state_sync( + db: &DbConnection, + session_id: &str, + archived: bool, +) -> Result<(), String> { + let conn = db.lock().map_err(|e| format!("数据库锁定失败: {e}"))?; + let now = Utc::now().to_rfc3339(); + let archived_at = if archived { Some(now.as_str()) } else { None }; + agent_session_repository::update_session_archived_at(&conn, session_id, archived_at, &now)?; + Ok(()) +} + /// 删除会话 pub async fn delete_session(db: &DbConnection, session_id: &str) -> Result<(), String> { aster::session::SessionStore::delete_session(&LimeSessionStore::new(db.clone()), session_id) @@ -2297,7 +2330,7 @@ mod tests { insert_test_workspace(&db, "workspace-1", "/tmp/lime-workspace-1"); insert_test_session_with_message(&db, "session-1", "/tmp/lime-workspace-1", "你好,世界"); - let sessions = list_sessions_sync(&db).expect("list sessions"); + let sessions = list_sessions_sync(&db, false).expect("list sessions"); let session = sessions .iter() .find(|item| item.id == "session-1") @@ -2311,6 +2344,26 @@ mod tests { assert_eq!(session.messages_count, 1); } + #[test] + fn list_sessions_sync_should_include_archived_sessions_when_requested() { + let db = create_test_db(); + insert_test_session_with_message(&db, "session-active", "/tmp/lime-workspace-6", "活跃"); + insert_test_session_with_message(&db, "session-archived", "/tmp/lime-workspace-7", "归档"); + + update_session_archived_state_sync(&db, "session-archived", true).expect("archive session"); + + let active_only = list_sessions_sync(&db, false).expect("list active sessions"); + assert_eq!(active_only.len(), 1); + assert_eq!(active_only[0].id, "session-active"); + + let with_archived = list_sessions_sync(&db, true).expect("list all sessions"); + let archived_session = with_archived + .iter() + .find(|item| item.id == "session-archived") + .expect("archived session exists"); + assert!(archived_session.archived_at.is_some()); + } + #[test] fn get_session_sync_should_resolve_workspace_id_from_working_dir() { let db = create_test_db(); diff --git a/src-tauri/crates/aster-rust/Cargo.lock b/src-tauri/crates/aster-rust/Cargo.lock index c8520c0d1..8c3db4cd2 100644 --- a/src-tauri/crates/aster-rust/Cargo.lock +++ b/src-tauri/crates/aster-rust/Cargo.lock @@ -3792,7 +3792,7 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ diff --git a/src-tauri/crates/aster-rust/crates/aster/tests/mcp_replays/cargorun--quiet-paster-server--binasterd--mcpdeveloper b/src-tauri/crates/aster-rust/crates/aster/tests/mcp_replays/cargorun--quiet-paster-server--binasterd--mcpdeveloper index af5adfb36..504c513a9 100644 --- a/src-tauri/crates/aster-rust/crates/aster/tests/mcp_replays/cargorun--quiet-paster-server--binasterd--mcpdeveloper +++ b/src-tauri/crates/aster-rust/crates/aster/tests/mcp_replays/cargorun--quiet-paster-server--binasterd--mcpdeveloper @@ -8,7 +8,7 @@ STDERR: STDERR: 2025-12-11T19:43:38.683096Z INFO aster_mcp::developer::analyze::cache: Initializing analysis cache with size 100 STDERR: at crates/aster-mcp/src/developer/analyze/cache.rs:26 STDERR: -STDOUT: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"prompts":{},"tools":{}},"serverInfo":{"name":"aster-developer","version":"1.18.0"},"instructions":" The developer extension gives you the capabilities to edit code files and run shell commands,\n and can be used to solve a wide range of problems.\n\nYou can use the shell tool to run any command that would work on the relevant operating system.\nUse the shell tool as needed to locate files or interact with the project.\n\nLeverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context\n- delegate analysis, retain summaries\n\nYour windows/screen tools can be used for visual debugging. You should not use these tools unless\nprompted to, but you can mention they are available if they are relevant.\n\nAlways prefer ripgrep (rg -C 3) to grep.\n\noperating system: macos\ncurrent directory: /Users/douwe/proj/aster/crates/aster\nshell: /bin/zsh\n\n \nAdditional Text Editor Tool Instructions:\n\nPerform text editing operations on files.\n\nThe `command` parameter specifies the operation to perform. Allowed options are:\n- `view`: View the content of a file.\n- `write`: Create or overwrite a file with the given content\n- `str_replace`: Replace text in one or more files.\n- `insert`: Insert text at a specific line location in the file.\n- `undo_edit`: Undo the last edit made to a file.\n\nTo use the write command, you must specify `file_text` which will become the new content of the file. Be careful with\nexisting files! This is a full overwrite, so you must include everything - not just sections you are modifying.\n\nTo use the str_replace command to edit multiple files, use the `diff` parameter with a unified diff.\nTo use the str_replace command to edit one file, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one\nunique section of the original file, including any whitespace. Make sure to include enough context that the match is not\nambiguous. The entire original string will be replaced with `new_str`\n\nWhen possible, batch file edits together by using a multi-file unified `diff` within a single str_replace tool call.\n\nTo use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning, -1 for end)\nand `new_str` (the text to insert).\n\n\n\nAdditional Shell Tool Instructions:\nExecute a command in the shell.\n\nThis will return the output and error concatenated into a single string, as\nyou would see from running on the command line. There will also be an indication\nof if the command succeeded or failed.\n\nAvoid commands that produce a large amount of output, and consider piping those outputs to files.\n\n**Important**: Each shell command runs in its own process. Things like directory changes or\nsourcing files do not persist between tool calls. So you may need to repeat them each time by\nstringing together commands.\n\nIf fetching web content, consider adding Accept: text/markdown header\nIf you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that\nthis tool does not run indefinitely.\n\n**Important**: Use ripgrep - `rg` - exclusively when you need to locate a file or a code reference,\nother solutions may produce too large output because of hidden files! For example *do not* use `find` or `ls -r`\n - List files by name: `rg --files | rg `\n - List files that contain a regex: `rg '' -l`\n\n - Multiple commands: Use && to chain commands, avoid newlines\n - Example: `cd example && ls` or `source env/bin/activate && pip install numpy`\n"}} +STDOUT: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"prompts":{},"tools":{}},"serverInfo":{"name":"aster-developer","version":"1.19.0"},"instructions":" The developer extension gives you the capabilities to edit code files and run shell commands,\n and can be used to solve a wide range of problems.\n\nYou can use the shell tool to run any command that would work on the relevant operating system.\nUse the shell tool as needed to locate files or interact with the project.\n\nLeverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context\n- delegate analysis, retain summaries\n\nYour windows/screen tools can be used for visual debugging. You should not use these tools unless\nprompted to, but you can mention they are available if they are relevant.\n\nAlways prefer ripgrep (rg -C 3) to grep.\n\noperating system: macos\ncurrent directory: /Users/douwe/proj/aster/crates/aster\nshell: /bin/zsh\n\n \nAdditional Text Editor Tool Instructions:\n\nPerform text editing operations on files.\n\nThe `command` parameter specifies the operation to perform. Allowed options are:\n- `view`: View the content of a file.\n- `write`: Create or overwrite a file with the given content\n- `str_replace`: Replace text in one or more files.\n- `insert`: Insert text at a specific line location in the file.\n- `undo_edit`: Undo the last edit made to a file.\n\nTo use the write command, you must specify `file_text` which will become the new content of the file. Be careful with\nexisting files! This is a full overwrite, so you must include everything - not just sections you are modifying.\n\nTo use the str_replace command to edit multiple files, use the `diff` parameter with a unified diff.\nTo use the str_replace command to edit one file, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one\nunique section of the original file, including any whitespace. Make sure to include enough context that the match is not\nambiguous. The entire original string will be replaced with `new_str`\n\nWhen possible, batch file edits together by using a multi-file unified `diff` within a single str_replace tool call.\n\nTo use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning, -1 for end)\nand `new_str` (the text to insert).\n\n\n\nAdditional Shell Tool Instructions:\nExecute a command in the shell.\n\nThis will return the output and error concatenated into a single string, as\nyou would see from running on the command line. There will also be an indication\nof if the command succeeded or failed.\n\nAvoid commands that produce a large amount of output, and consider piping those outputs to files.\n\n**Important**: Each shell command runs in its own process. Things like directory changes or\nsourcing files do not persist between tool calls. So you may need to repeat them each time by\nstringing together commands.\n\nIf fetching web content, consider adding Accept: text/markdown header\nIf you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that\nthis tool does not run indefinitely.\n\n**Important**: Use ripgrep - `rg` - exclusively when you need to locate a file or a code reference,\nother solutions may produce too large output because of hidden files! For example *do not* use `find` or `ls -r`\n - List files by name: `rg --files | rg `\n - List files that contain a regex: `rg '' -l`\n\n - Multiple commands: Use && to chain commands, avoid newlines\n - Example: `cd example && ls` or `source env/bin/activate && pip install numpy`\n"}} STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"} STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"progressToken":0},"name":"text_editor","arguments":{"command":"view","path":"/tmp/aster_test/aster.txt"}}} STDOUT: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"resource","resource":{"uri":"file:///tmp/aster_test/aster.txt","mimeType":"text","text":"# aster\n"},"annotations":{"audience":["assistant"]}},{"type":"text","text":"### /tmp/aster_test/aster.txt\n```\n1: # aster\n```\n","annotations":{"audience":["user"],"priority":0.0}}],"isError":false}} diff --git a/src-tauri/crates/core/src/database/agent_session_repository.rs b/src-tauri/crates/core/src/database/agent_session_repository.rs index 714c479ec..e8496807c 100644 --- a/src-tauri/crates/core/src/database/agent_session_repository.rs +++ b/src-tauri/crates/core/src/database/agent_session_repository.rs @@ -15,6 +15,7 @@ pub struct SessionRecordOverview { pub title: Option, pub created_at: String, pub updated_at: String, + pub archived_at: Option, pub working_dir: Option, pub workspace_id: Option, pub execution_strategy: Option, @@ -83,6 +84,7 @@ fn map_session_overview( title: overview.session.title, created_at: overview.session.created_at, updated_at: overview.session.updated_at, + archived_at: overview.archived_at, working_dir, workspace_id, execution_strategy: overview.session.execution_strategy, @@ -94,8 +96,11 @@ pub fn create_session(conn: &Connection, session: &AgentSession) -> Result<(), S AgentDao::create_session(conn, session).map_err(|error| format!("创建会话失败: {error}")) } -pub fn list_session_overviews(conn: &Connection) -> Result, String> { - AgentDao::list_session_overviews(conn) +pub fn list_session_overviews( + conn: &Connection, + include_archived: bool, +) -> Result, String> { + AgentDao::list_session_overviews(conn, include_archived) .map(|rows| { rows.into_iter() .map(|row| map_session_overview(conn, row)) @@ -214,6 +219,16 @@ pub fn update_session_provider_config( .map_err(|error| format!("更新会话 provider/model 失败: {error}")) } +pub fn update_session_archived_at( + conn: &Connection, + session_id: &str, + archived_at: Option<&str>, + updated_at: &str, +) -> Result<(), String> { + AgentDao::update_archived_at(conn, session_id, archived_at, updated_at) + .map_err(|error| format!("更新会话归档状态失败: {error}")) +} + pub fn update_latest_assistant_message_usage( conn: &Connection, session_id: &str, diff --git a/src-tauri/crates/core/src/database/dao/agent.rs b/src-tauri/crates/core/src/database/dao/agent.rs index eb0e331c6..985168060 100644 --- a/src-tauri/crates/core/src/database/dao/agent.rs +++ b/src-tauri/crates/core/src/database/dao/agent.rs @@ -468,6 +468,7 @@ pub struct AgentModelUsageRow { pub struct AgentSessionOverviewRow { pub session: AgentSession, pub messages_count: usize, + pub archived_at: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -587,22 +588,26 @@ impl AgentDao { pub fn list_session_overviews( conn: &Connection, + include_archived: bool, ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT s.id, s.model, s.system_prompt, s.title, s.created_at, s.updated_at, - s.working_dir, s.execution_strategy, COUNT(m.id) AS messages_count + s.working_dir, s.execution_strategy, COUNT(m.id) AS messages_count, + s.archived_at FROM agent_sessions s LEFT JOIN agent_messages m ON m.session_id = s.id + WHERE (?1 = 1 OR s.archived_at IS NULL) GROUP BY s.id, s.model, s.system_prompt, s.title, s.created_at, s.updated_at, - s.working_dir, s.execution_strategy + s.working_dir, s.execution_strategy, s.archived_at ORDER BY s.updated_at DESC", )?; - let rows = stmt.query_map([], |row| { + let rows = stmt.query_map([if include_archived { 1 } else { 0 }], |row| { let messages_count: i64 = row.get(8)?; Ok(AgentSessionOverviewRow { session: map_agent_session_row(row)?, messages_count: messages_count.max(0) as usize, + archived_at: row.get(9)?, }) })?; @@ -615,12 +620,13 @@ impl AgentDao { ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "SELECT s.id, s.model, s.system_prompt, s.title, s.created_at, s.updated_at, - s.working_dir, s.execution_strategy, COUNT(m.id) AS messages_count + s.working_dir, s.execution_strategy, COUNT(m.id) AS messages_count, + s.archived_at FROM agent_sessions s LEFT JOIN agent_messages m ON m.session_id = s.id WHERE s.id = ?1 GROUP BY s.id, s.model, s.system_prompt, s.title, s.created_at, s.updated_at, - s.working_dir, s.execution_strategy", + s.working_dir, s.execution_strategy, s.archived_at", )?; let mut rows = stmt.query([session_id])?; @@ -629,6 +635,7 @@ impl AgentDao { Ok(Some(AgentSessionOverviewRow { session: map_agent_session_row(row)?, messages_count: messages_count.max(0) as usize, + archived_at: row.get(9)?, })) } else { Ok(None) @@ -1195,6 +1202,19 @@ impl AgentDao { )?; Ok(()) } + + pub fn update_archived_at( + conn: &Connection, + session_id: &str, + archived_at: Option<&str>, + updated_at: &str, + ) -> Result<(), rusqlite::Error> { + conn.execute( + "UPDATE agent_sessions SET archived_at = ?1, updated_at = ?2 WHERE id = ?3", + params![archived_at, updated_at, session_id], + )?; + Ok(()) + } } #[cfg(test)] @@ -1219,7 +1239,8 @@ mod tests { created_at TEXT NOT NULL, updated_at TEXT NOT NULL, working_dir TEXT, - execution_strategy TEXT + execution_strategy TEXT, + archived_at TEXT ); CREATE TABLE agent_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1537,10 +1558,11 @@ mod tests { ) .unwrap(); - let overviews = AgentDao::list_session_overviews(&conn).unwrap(); + let overviews = AgentDao::list_session_overviews(&conn, false).unwrap(); assert_eq!(overviews.len(), 2); assert_eq!(overviews[0].session.id, "session-b"); assert_eq!(overviews[0].messages_count, 0); + assert_eq!(overviews[0].archived_at, None); assert_eq!(overviews[1].session.id, "session-a"); assert_eq!(overviews[1].messages_count, 2); @@ -1561,6 +1583,51 @@ mod tests { assert_eq!(renamed.session.updated_at, "2026-03-12T09:00:00+08:00"); } + #[test] + fn list_session_overviews_should_filter_archived_sessions_by_default() { + let conn = setup_pattern_test_db(); + + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, archived_at) + VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6)", + params![ + "session-active", + "gpt-4.1", + "活跃会话", + "2026-03-10T10:00:00+08:00", + "2026-03-12T10:00:00+08:00", + Option::::None, + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO agent_sessions (id, model, system_prompt, title, created_at, updated_at, archived_at) + VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6)", + params![ + "session-archived", + "gpt-4.1", + "归档会话", + "2026-03-09T10:00:00+08:00", + "2026-03-11T10:00:00+08:00", + Some("2026-03-11T10:00:00+08:00".to_string()), + ], + ) + .unwrap(); + + let active_only = AgentDao::list_session_overviews(&conn, false).unwrap(); + assert_eq!(active_only.len(), 1); + assert_eq!(active_only[0].session.id, "session-active"); + + let with_archived = AgentDao::list_session_overviews(&conn, true).unwrap(); + assert_eq!(with_archived.len(), 2); + assert_eq!(with_archived[0].session.id, "session-active"); + assert_eq!(with_archived[1].session.id, "session-archived"); + assert_eq!( + with_archived[1].archived_at.as_deref(), + Some("2026-03-11T10:00:00+08:00"), + ); + } + #[test] fn add_message_and_get_messages_should_roundtrip_reasoning_content() { let conn = setup_pattern_test_db(); diff --git a/src-tauri/crates/core/src/database/schema.rs b/src-tauri/crates/core/src/database/schema.rs index 76acdfea2..1dc7be94b 100644 --- a/src-tauri/crates/core/src/database/schema.rs +++ b/src-tauri/crates/core/src/database/schema.rs @@ -552,7 +552,8 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { recipe_json TEXT, user_recipe_values_json TEXT, provider_name TEXT, - model_config_json TEXT + model_config_json TEXT, + archived_at TEXT )", [], )?; @@ -626,6 +627,12 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> { "ALTER TABLE agent_sessions ADD COLUMN model_config_json TEXT", [], ); + let _ = conn.execute("ALTER TABLE agent_sessions ADD COLUMN archived_at TEXT", []); + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_agent_sessions_archived_updated_at + ON agent_sessions(archived_at, updated_at DESC)", + [], + )?; // Agent 消息表 // 存储每个会话的消息历史 diff --git a/src-tauri/crates/services/src/model_registry_service.rs b/src-tauri/crates/services/src/model_registry_service.rs index c7ddf9587..f8d3a803e 100644 --- a/src-tauri/crates/services/src/model_registry_service.rs +++ b/src-tauri/crates/services/src/model_registry_service.rs @@ -2021,6 +2021,16 @@ impl ModelRegistryService { let matched_custom_models = self .match_local_models_by_ids(custom_models, Some(scoped_provider_candidates.as_slice())) .await; + let scoped_catalog_models = self + .collect_local_models_for_candidates(&scoped_provider_candidates) + .await; + + if Self::should_prefer_scoped_catalog_for_host(api_host, &scoped_provider_candidates) + && !scoped_catalog_models.is_empty() + { + return (scoped_catalog_models, ModelFetchSource::Catalog); + } + if !matched_custom_models.is_empty() { return (matched_custom_models, ModelFetchSource::CustomModels); } @@ -2036,9 +2046,6 @@ impl ModelRegistryService { ); } - let scoped_catalog_models = self - .collect_local_models_for_candidates(&scoped_provider_candidates) - .await; if !scoped_catalog_models.is_empty() { return (scoped_catalog_models, ModelFetchSource::Catalog); } @@ -2046,6 +2053,19 @@ impl ModelRegistryService { (Vec::new(), ModelFetchSource::LocalFallback) } + fn should_prefer_scoped_catalog_for_host( + api_host: &str, + scoped_provider_candidates: &[String], + ) -> bool { + if !api_host.to_lowercase().contains("xiaomimimo.com") { + return false; + } + + scoped_provider_candidates + .iter() + .any(|candidate| candidate == "xiaomi") + } + async fn collect_local_models_for_candidates( &self, candidate_provider_ids: &[String], @@ -3751,7 +3771,35 @@ mod tests { ) .await; - assert_eq!(fallback_models.len(), 3); + assert_eq!(fallback_models.len(), 5); + assert!(fallback_models.contains(&"mimo-v2.5-pro".to_string())); + assert!(fallback_models.contains(&"mimo-v2.5".to_string())); + assert!(fallback_models.contains(&"mimo-v2-pro".to_string())); + assert!(fallback_models.contains(&"mimo-v2-omni".to_string())); + assert!(fallback_models.contains(&"mimo-v2-flash".to_string())); + } + + #[tokio::test] + async fn test_anthropic_compatible_local_fallback_prefers_catalog_for_known_mimo_host_even_with_custom_models( + ) { + let service = create_service_with_repo_resource_dir(); + service + .initialize() + .await + .expect("initialize model registry"); + + let fallback_models = service + .get_local_fallback_model_ids_with_hints( + "custom-mimo", + "https://token-plan-cn.xiaomimimo.com/anthropic", + Some(ApiProviderType::AnthropicCompatible), + &["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()], + ) + .await; + + assert_eq!(fallback_models.len(), 5); + assert!(fallback_models.contains(&"mimo-v2.5-pro".to_string())); + assert!(fallback_models.contains(&"mimo-v2.5".to_string())); assert!(fallback_models.contains(&"mimo-v2-pro".to_string())); assert!(fallback_models.contains(&"mimo-v2-omni".to_string())); assert!(fallback_models.contains(&"mimo-v2-flash".to_string())); diff --git a/src-tauri/resources/models/providers/xiaomi.json b/src-tauri/resources/models/providers/xiaomi.json index 60618c707..4d2fdfefb 100644 --- a/src-tauri/resources/models/providers/xiaomi.json +++ b/src-tauri/resources/models/providers/xiaomi.json @@ -5,6 +5,46 @@ "name": "Xiaomi" }, "models": [ + { + "id": "mimo-v2.5-pro", + "name": "MiMo-V2.5-Pro", + "family": "mimo-v2.5-pro", + "tier": "max", + "capabilities": { + "vision": false, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "limits": { + "context": 1000000, + "max_output": 131072 + }, + "status": "active", + "is_latest": true + }, + { + "id": "mimo-v2.5", + "name": "MiMo-V2.5", + "family": "mimo-v2.5", + "tier": "pro", + "capabilities": { + "vision": false, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "limits": { + "context": 256000, + "max_output": 32768 + }, + "status": "active", + "is_latest": true + }, { "id": "mimo-v2-pro", "name": "MiMo-V2-Pro", @@ -84,6 +124,6 @@ "is_latest": false } ], - "updated_at": "2026-04-22T10:50:00.000Z", + "updated_at": "2026-04-24T09:55:00.000Z", "source": "models.dev" } diff --git a/src-tauri/src/agent/aster_agent.rs b/src-tauri/src/agent/aster_agent.rs index f232c4e38..1b805ae94 100644 --- a/src-tauri/src/agent/aster_agent.rs +++ b/src-tauri/src/agent/aster_agent.rs @@ -68,8 +68,11 @@ impl AsterAgentWrapper { } /// 列出所有会话 - pub fn list_sessions_sync(db: &DbConnection) -> Result, String> { - lime_agent::list_sessions_sync(db) + pub fn list_sessions_sync( + db: &DbConnection, + include_archived: bool, + ) -> Result, String> { + lime_agent::list_sessions_sync(db, include_archived) } /// 获取会话详情 @@ -143,6 +146,14 @@ impl AsterAgentWrapper { lime_agent::update_session_provider_config_sync(db, session_id, provider_name, model_name) } + pub fn update_session_archived_state_sync( + db: &DbConnection, + session_id: &str, + archived: bool, + ) -> Result<(), String> { + lime_agent::update_session_archived_state_sync(db, session_id, archived) + } + pub async fn persist_session_recent_preferences( session_id: &str, preferences: lime_agent::SessionExecutionRuntimePreferences, diff --git a/src-tauri/src/commands/agent_cmd.rs b/src-tauri/src/commands/agent_cmd.rs index 8632ec062..06c4c60f3 100644 --- a/src-tauri/src/commands/agent_cmd.rs +++ b/src-tauri/src/commands/agent_cmd.rs @@ -12,12 +12,16 @@ use crate::commands::auxiliary_model_selection::{ }; use crate::config::GlobalConfigManagerState; use crate::database::DbConnection; +use crate::services::runtime_auxiliary_projection_service::{ + project_auxiliary_runtime_to_parent_session, AuxiliaryRuntimeProjectionInput, + AuxiliaryRuntimeProjectionResult, +}; use crate::AppState; use aster::conversation::message::Message; use futures::StreamExt; use lime_agent::merge_system_prompt_with_runtime_agents; use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; use uuid::Uuid; const TITLE_FALLBACK_PROVIDER_CHAIN: [(&str, &str); 4] = [ @@ -370,6 +374,7 @@ pub async fn agent_get_process_status( /// 根据对话内容生成一个简洁的标题 #[tauri::command] pub async fn agent_generate_title( + app: AppHandle, agent_state: State<'_, AsterAgentState>, db: State<'_, DbConnection>, config_manager: State<'_, GlobalConfigManagerState>, @@ -407,27 +412,57 @@ pub async fn agent_generate_title( let execution_runtime = AsterAgentWrapper::get_runtime_session_execution_runtime(&db, &auxiliary_session_id).await; - match title_result { - Ok(title) => Ok(AgentGeneratedTitleResult { + let result = match title_result { + Ok(title) => AgentGeneratedTitleResult { title, session_id: Some(auxiliary_session_id), execution_runtime, used_fallback: false, fallback_reason: None, - }), + }, Err(error) => { tracing::warn!( "[AgentTitle] 智能标题生成失败,已回退摘要标题: kind={}, error={}", resolved_title_kind, error ); - Ok(AgentGeneratedTitleResult { + AgentGeneratedTitleResult { title: build_fallback_title(&source_text, &resolved_title_kind), session_id: Some(auxiliary_session_id), execution_runtime, used_fallback: true, fallback_reason: Some(error), - }) + } + } + }; + + if let (Some(parent_session_id), Some(auxiliary_session_id)) = + (resolved_session_id.as_deref(), result.session_id.as_deref()) + { + if let Err(error) = project_auxiliary_runtime_to_parent_session( + &app, + &db, + AuxiliaryRuntimeProjectionInput { + parent_session_id: parent_session_id.to_string(), + auxiliary_session_id: auxiliary_session_id.to_string(), + execution_runtime: result.execution_runtime.clone(), + result: AuxiliaryRuntimeProjectionResult::TitleGeneration { + title: result.title.clone(), + used_fallback: result.used_fallback, + fallback_reason: result.fallback_reason.clone(), + }, + }, + ) + .await + { + tracing::warn!( + "[AgentTitle] 投影父会话辅助运行时失败,已降级继续: parent_session_id={}, auxiliary_session_id={}, error={}", + parent_session_id, + auxiliary_session_id, + error + ); } } + + Ok(result) } diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs index d128c2c36..228974c0a 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/session_api.rs @@ -1,4 +1,5 @@ use super::*; +use crate::commands::aster_agent_cmd::dto::AgentRuntimeListSessionsRequest; /// 创建新会话 #[tauri::command] @@ -26,20 +27,25 @@ pub async fn agent_runtime_create_session( pub async fn agent_runtime_list_sessions( db: State<'_, DbConnection>, logs: State<'_, LogState>, + request: Option, ) -> Result, String> { let started_at = Instant::now(); + let include_archived = request + .and_then(|value| value.include_archived) + .unwrap_or(false); logs.write() .await .add("info", "[AgentDiag] agent_runtime_list_sessions.start"); - match list_runtime_sessions_internal(db.inner()) { + match list_runtime_sessions_internal(db.inner(), include_archived) { Ok(sessions) => { logs.write().await.add( "info", &format!( - "[AgentDiag] agent_runtime_list_sessions.success duration_ms={} sessions={}", + "[AgentDiag] agent_runtime_list_sessions.success duration_ms={} sessions={} include_archived={}", started_at.elapsed().as_millis(), - sessions.len() + sessions.len(), + include_archived ), ); Ok(sessions) @@ -83,6 +89,14 @@ pub async fn agent_runtime_update_session( )?; } + if let Some(archived) = request.archived { + AsterAgentWrapper::update_session_archived_state_sync( + db.inner(), + &trimmed_session_id, + archived, + )?; + } + let provider_name = request .provider_name .as_deref() diff --git a/src-tauri/src/commands/aster_agent_cmd/dto.rs b/src-tauri/src/commands/aster_agent_cmd/dto.rs index bf5532673..6a1c5897c 100644 --- a/src-tauri/src/commands/aster_agent_cmd/dto.rs +++ b/src-tauri/src/commands/aster_agent_cmd/dto.rs @@ -603,14 +603,24 @@ pub struct AgentRuntimeThreadReadModel { #[serde(skip_serializing_if = "Option::is_none")] pub decision_source: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub decision_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub candidate_count: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub fallback_chain: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub capability_gap: Option, #[serde(skip_serializing_if = "Option::is_none")] pub estimated_cost_class: Option, #[serde(skip_serializing_if = "Option::is_none")] pub single_candidate_only: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub oem_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub runtime_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auxiliary_task_runtime: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub limit_state: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cost_state: Option, @@ -618,6 +628,154 @@ pub struct AgentRuntimeThreadReadModel { pub limit_event: Option, } +fn extract_oem_policy_summary( + runtime: Option<&lime_agent::SessionExecutionRuntime>, +) -> Option { + if let Some(policy) = runtime.and_then(|value| value.oem_policy.as_ref()) { + let limit_event = runtime.and_then(|value| value.limit_event.as_ref()); + let routing = runtime.and_then(|value| value.routing_decision.as_ref()); + let limit_state = runtime.and_then(|value| value.limit_state.as_ref()); + return Some(serde_json::json!({ + "tenantId": policy.tenant_id, + "providerSource": policy.provider_source, + "providerKey": policy.provider_key, + "defaultModel": policy.default_model, + "configMode": policy.config_mode, + "offerState": policy.offer_state, + "quotaStatus": policy.quota_status, + "fallbackToLocalAllowed": policy.fallback_to_local_allowed, + "canInvoke": policy.can_invoke, + "locked": limit_state.map(|state| state.oem_locked), + "quotaLow": matches!(limit_event.map(|event| event.event_kind.as_str()), Some("quota_low")), + "limitEventKind": limit_event.map(|event| event.event_kind.clone()), + "limitEventMessage": limit_event.map(|event| event.message.clone()), + "decisionSource": routing.map(|decision| decision.decision_source.clone()), + "selectedProvider": routing.and_then(|decision| decision.selected_provider.clone()), + "selectedModel": routing.and_then(|decision| decision.selected_model.clone()), + })); + } + + let limit_state = runtime.and_then(|value| value.limit_state.as_ref()); + let limit_event = runtime.and_then(|value| value.limit_event.as_ref()); + let routing = runtime.and_then(|value| value.routing_decision.as_ref()); + + let oem_locked = limit_state.map(|state| state.oem_locked); + let quota_low = matches!( + limit_event.map(|event| event.event_kind.as_str()), + Some("quota_low") + ); + + if oem_locked != Some(true) && !quota_low { + return None; + } + + Some(serde_json::json!({ + "locked": oem_locked, + "quotaLow": quota_low, + "limitEventKind": limit_event.map(|event| event.event_kind.clone()), + "limitEventMessage": limit_event.map(|event| event.message.clone()), + "decisionSource": routing.map(|decision| decision.decision_source.clone()), + "selectedProvider": routing.and_then(|decision| decision.selected_provider.clone()), + "selectedModel": routing.and_then(|decision| decision.selected_model.clone()), + })) +} + +fn extract_runtime_summary( + runtime: Option<&lime_agent::SessionExecutionRuntime>, +) -> Option { + if let Some(summary) = runtime.and_then(|value| value.runtime_summary.as_ref()) { + return serde_json::to_value(summary).ok(); + } + + let runtime = runtime?; + let routing = runtime.routing_decision.as_ref(); + let limit_state = runtime.limit_state.as_ref(); + let cost_state = runtime.cost_state.as_ref(); + let limit_event = runtime.limit_event.as_ref(); + + if routing.is_none() && limit_state.is_none() && cost_state.is_none() && limit_event.is_none() { + return None; + } + + Some(serde_json::json!({ + "candidateCount": routing.map(|value| value.candidate_count), + "routingMode": routing.map(|value| value.routing_mode.clone()), + "decisionSource": routing.map(|value| value.decision_source.clone()), + "decisionReason": routing.map(|value| value.decision_reason.clone()), + "fallbackChain": routing.map(|value| value.fallback_chain.clone()).unwrap_or_default(), + "estimatedCostClass": routing + .and_then(|value| value.estimated_cost_class.clone()) + .or_else(|| cost_state.and_then(|value| value.estimated_cost_class.clone())), + "estimatedTotalCost": cost_state.and_then(|value| value.estimated_total_cost), + "limitStatus": limit_state.map(|value| value.status.clone()), + "limitEventKind": limit_event.map(|value| value.event_kind.clone()), + "limitEventMessage": limit_event.map(|value| value.message.clone()), + "capabilityGap": routing + .and_then(|value| value.capability_gap.clone()) + .or_else(|| limit_state.and_then(|value| value.capability_gap.clone())), + "singleCandidateOnly": limit_state.map(|value| value.single_candidate_only), + "oemLocked": limit_state.map(|value| value.oem_locked), + "quotaLow": Some(matches!(limit_event.map(|value| value.event_kind.as_str()), Some("quota_low"))), + })) +} + +fn extract_auxiliary_runtime_snapshots(detail: &SessionDetail) -> Option> { + let snapshots = detail + .items + .iter() + .filter_map(|item| match &item.payload { + lime_core::database::dao::agent_timeline::AgentThreadItemPayload::FileArtifact { + path, + content, + metadata, + .. + } if path.contains("/auxiliary-runtime/") + || metadata + .as_ref() + .and_then(|value| value.get("artifactType")) + .and_then(serde_json::Value::as_str) + == Some("auxiliary_runtime_projection") => + { + let document = content + .as_ref() + .and_then(|raw| serde_json::from_str::(raw).ok())?; + let projection_kind = document + .get("projectionKind") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let execution_runtime = document.get("executionRuntime").cloned(); + let route = execution_runtime.as_ref().and_then(|runtime| { + runtime + .get("route") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }); + let task_kind = execution_runtime.as_ref().and_then(|runtime| { + runtime + .get("task_profile") + .and_then(|profile| profile.get("kind")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }); + Some(serde_json::json!({ + "artifactPath": path, + "projectionKind": projection_kind, + "route": route, + "taskKind": task_kind, + "executionRuntime": execution_runtime, + })) + } + _ => None, + }) + .collect::>(); + + if snapshots.is_empty() { + None + } else { + Some(snapshots) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentRuntimeReplayedActionRequiredView { #[serde(rename = "type")] @@ -729,6 +887,53 @@ impl AgentRuntimeReplayedActionRequiredView { } } +fn read_auxiliary_runtime_string( + auxiliary_task_runtime: Option<&Vec>, + path: &[&str], +) -> Option { + auxiliary_task_runtime?.iter().rev().find_map(|snapshot| { + let runtime = snapshot.get("executionRuntime")?; + let mut current = runtime; + for key in path { + current = current.get(*key)?; + } + current.as_str().map(str::to_string) + }) +} + +fn read_auxiliary_runtime_u32( + auxiliary_task_runtime: Option<&Vec>, + path: &[&str], +) -> Option { + auxiliary_task_runtime?.iter().rev().find_map(|snapshot| { + let runtime = snapshot.get("executionRuntime")?; + let mut current = runtime; + for key in path { + current = current.get(*key)?; + } + current.as_u64().and_then(|value| u32::try_from(value).ok()) + }) +} + +fn read_auxiliary_runtime_string_vec( + auxiliary_task_runtime: Option<&Vec>, + path: &[&str], +) -> Option> { + auxiliary_task_runtime?.iter().rev().find_map(|snapshot| { + let runtime = snapshot.get("executionRuntime")?; + let mut current = runtime; + for key in path { + current = current.get(*key)?; + } + let items = current.as_array()?; + let values = items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect::>(); + (!values.is_empty()).then_some(values) + }) +} + impl AgentRuntimeThreadReadModel { #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn from_session_detail( @@ -844,6 +1049,61 @@ impl AgentRuntimeThreadReadModel { .execution_runtime .as_ref() .and_then(|runtime| runtime.limit_event.clone()); + let oem_policy = extract_oem_policy_summary(detail.execution_runtime.as_ref()); + let runtime_summary = extract_runtime_summary(detail.execution_runtime.as_ref()); + let auxiliary_task_runtime = extract_auxiliary_runtime_snapshots(detail); + let auxiliary_task_kind = read_auxiliary_runtime_string( + auxiliary_task_runtime.as_ref(), + &["task_profile", "kind"], + ) + .or_else(|| { + auxiliary_task_runtime.as_ref().and_then(|snapshots| { + snapshots.iter().rev().find_map(|snapshot| { + snapshot + .get("taskKind") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + }) + }); + let auxiliary_service_model_slot = read_auxiliary_runtime_string( + auxiliary_task_runtime.as_ref(), + &["task_profile", "service_model_slot"], + ); + let auxiliary_routing_mode = read_auxiliary_runtime_string( + auxiliary_task_runtime.as_ref(), + &["routing_decision", "routing_mode"], + ); + let auxiliary_decision_source = read_auxiliary_runtime_string( + auxiliary_task_runtime.as_ref(), + &["routing_decision", "decision_source"], + ); + let auxiliary_decision_reason = read_auxiliary_runtime_string( + auxiliary_task_runtime.as_ref(), + &["routing_decision", "decision_reason"], + ); + let auxiliary_candidate_count = read_auxiliary_runtime_u32( + auxiliary_task_runtime.as_ref(), + &["routing_decision", "candidate_count"], + ); + let auxiliary_fallback_chain = read_auxiliary_runtime_string_vec( + auxiliary_task_runtime.as_ref(), + &["routing_decision", "fallback_chain"], + ); + let auxiliary_capability_gap = read_auxiliary_runtime_string( + auxiliary_task_runtime.as_ref(), + &["routing_decision", "capability_gap"], + ); + let auxiliary_estimated_cost_class = read_auxiliary_runtime_string( + auxiliary_task_runtime.as_ref(), + &["cost_state", "estimated_cost_class"], + ) + .or_else(|| { + read_auxiliary_runtime_string( + auxiliary_task_runtime.as_ref(), + &["routing_decision", "estimated_cost_class"], + ) + }); Self { thread_id: detail.thread_id.clone(), @@ -860,28 +1120,55 @@ impl AgentRuntimeThreadReadModel { latest_compaction_boundary, file_checkpoint_summary, diagnostics, - task_kind: task_profile.map(|profile| profile.kind.clone()), - service_model_slot: task_profile.and_then(|profile| profile.service_model_slot.clone()), - routing_mode: routing_decision.map(|decision| decision.routing_mode.clone()), - decision_source: routing_decision.map(|decision| decision.decision_source.clone()), - candidate_count: routing_decision.map(|decision| decision.candidate_count), + task_kind: task_profile + .map(|profile| profile.kind.clone()) + .or(auxiliary_task_kind), + service_model_slot: task_profile + .and_then(|profile| profile.service_model_slot.clone()) + .or(auxiliary_service_model_slot), + routing_mode: routing_decision + .map(|decision| decision.routing_mode.clone()) + .or(auxiliary_routing_mode), + decision_source: routing_decision + .map(|decision| decision.decision_source.clone()) + .or(auxiliary_decision_source), + decision_reason: routing_decision + .map(|decision| decision.decision_reason.clone()) + .or(auxiliary_decision_reason), + candidate_count: routing_decision + .map(|decision| decision.candidate_count) + .or(auxiliary_candidate_count), + fallback_chain: routing_decision + .and_then(|decision| { + if decision.fallback_chain.is_empty() { + None + } else { + Some(decision.fallback_chain.clone()) + } + }) + .or(auxiliary_fallback_chain), capability_gap: routing_decision .and_then(|decision| decision.capability_gap.clone()) .or_else(|| { limit_state .as_ref() .and_then(|state| state.capability_gap.clone()) - }), + }) + .or(auxiliary_capability_gap), estimated_cost_class: routing_decision .and_then(|decision| decision.estimated_cost_class.clone()) .or_else(|| { cost_state .as_ref() .and_then(|state| state.estimated_cost_class.clone()) - }), + }) + .or(auxiliary_estimated_cost_class), single_candidate_only: limit_state .as_ref() .map(|state| state.single_candidate_only), + oem_policy, + runtime_summary, + auxiliary_task_runtime, limit_state, cost_state, limit_event, @@ -1850,6 +2137,12 @@ pub struct AgentRuntimeRespondActionRequest { pub action_scope: Option, } +#[derive(Debug, Deserialize)] +pub struct AgentRuntimeListSessionsRequest { + #[serde(default, alias = "includeArchived")] + pub include_archived: Option, +} + #[derive(Debug, Deserialize)] pub struct AgentRuntimeUpdateSessionRequest { #[serde(alias = "sessionId")] @@ -1864,6 +2157,8 @@ pub struct AgentRuntimeUpdateSessionRequest { pub model_name: Option, #[serde(default, alias = "executionStrategy")] pub execution_strategy: Option, + #[serde(default, alias = "isArchived")] + pub archived: Option, #[serde(default, alias = "recentAccessMode")] pub recent_access_mode: Option, #[serde(default, alias = "recentPreferences")] @@ -2265,11 +2560,26 @@ mod tests { message: "429 Too Many Requests".to_string(), retryable: true, }), + oem_policy: None, + runtime_summary: None, }); let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); assert_eq!(thread_read.estimated_cost_class.as_deref(), Some("low")); + assert_eq!( + thread_read.decision_reason.as_deref(), + Some("命中 service_models.translation") + ); + assert_eq!(thread_read.fallback_chain, None); + assert_eq!( + thread_read + .runtime_summary + .as_ref() + .and_then(|value| value.get("decisionReason")) + .and_then(serde_json::Value::as_str), + Some("命中 service_models.translation") + ); assert_eq!( thread_read .cost_state @@ -2286,6 +2596,227 @@ mod tests { ); } + #[test] + fn thread_read_should_promote_auxiliary_runtime_into_current_thread_read_when_runtime_missing() + { + let detail = + build_session_detail( + vec![AgentThreadTurn { + id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + prompt_text: "继续生成标题".to_string(), + status: AgentThreadTurnStatus::Completed, + started_at: "2026-03-23T09:10:00Z".to_string(), + completed_at: Some("2026-03-23T09:10:05Z".to_string()), + error_message: None, + created_at: "2026-03-23T09:10:00Z".to_string(), + updated_at: "2026-03-23T09:10:05Z".to_string(), + }], + vec![AgentThreadItem { + id: "artifact-1".to_string(), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-03-23T09:10:03Z".to_string(), + completed_at: Some("2026-03-23T09:10:04Z".to_string()), + updated_at: "2026-03-23T09:10:04Z".to_string(), + payload: AgentThreadItemPayload::FileArtifact { + path: ".lime/artifacts/thread-1/auxiliary-runtime/title.json".to_string(), + source: "artifact_snapshot".to_string(), + content: Some(serde_json::json!({ + "projectionKind": "title_generation_runtime", + "executionRuntime": { + "route": "auxiliary.generate_title", + "task_profile": { + "kind": "generation_topic", + "service_model_slot": "title_generation" + }, + "routing_decision": { + "routing_mode": "fallback_chain", + "decision_source": "service_model_setting", + "decision_reason": "辅助标题生成命中服务模型并回退到低成本模型", + "candidate_count": 2, + "capability_gap": "vision", + "fallback_chain": ["openai:gpt-5.4", "openai:gpt-5.4-mini"], + "estimated_cost_class": "low" + }, + "cost_state": { + "estimated_cost_class": "low" + } + } + }).to_string()), + metadata: Some(serde_json::json!({ + "artifactType": "auxiliary_runtime_projection" + })), + }, + }], + ); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!(thread_read.task_kind.as_deref(), Some("generation_topic")); + assert_eq!( + thread_read.service_model_slot.as_deref(), + Some("title_generation") + ); + assert_eq!(thread_read.routing_mode.as_deref(), Some("fallback_chain")); + assert_eq!( + thread_read.decision_source.as_deref(), + Some("service_model_setting") + ); + assert_eq!( + thread_read.decision_reason.as_deref(), + Some("辅助标题生成命中服务模型并回退到低成本模型") + ); + assert_eq!(thread_read.candidate_count, Some(2)); + assert_eq!(thread_read.capability_gap.as_deref(), Some("vision")); + assert_eq!(thread_read.estimated_cost_class.as_deref(), Some("low")); + assert_eq!( + thread_read.fallback_chain, + Some(vec![ + "openai:gpt-5.4".to_string(), + "openai:gpt-5.4-mini".to_string() + ]) + ); + } + + #[test] + fn thread_read_should_surface_oem_policy_summary() { + let mut detail = build_session_detail(Vec::new(), Vec::new()); + detail.execution_runtime = Some(lime_agent::SessionExecutionRuntime { + session_id: "session-1".to_string(), + provider_selector: Some("lime-hub".to_string()), + provider_name: Some("lime-hub".to_string()), + model_name: Some("claude-sonnet-4".to_string()), + execution_strategy: None, + output_schema_runtime: None, + source: lime_agent::SessionExecutionRuntimeSource::RuntimeSnapshot, + mode: None, + latest_turn_id: None, + latest_turn_status: None, + recent_access_mode: None, + recent_preferences: None, + recent_team_selection: None, + recent_theme: None, + recent_session_mode: None, + recent_gate_key: None, + recent_run_title: None, + recent_content_id: None, + task_profile: None, + routing_decision: Some(lime_agent::SessionExecutionRuntimeRoutingDecision { + routing_mode: "single_candidate".to_string(), + decision_source: "session_default".to_string(), + decision_reason: "当前回合受 OEM 托管约束。".to_string(), + selected_provider: Some("lime-hub".to_string()), + selected_model: Some("claude-sonnet-4".to_string()), + requested_provider: None, + requested_model: None, + candidate_count: 1, + estimated_cost_class: Some("medium".to_string()), + capability_gap: None, + fallback_chain: Vec::new(), + settings_source: None, + service_model_slot: None, + }), + limit_state: Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "single_candidate_only".to_string(), + single_candidate_only: true, + provider_locked: true, + settings_locked: false, + oem_locked: true, + candidate_count: 1, + capability_gap: None, + notes: vec!["当前回合受 OEM 路由约束。".to_string()], + }), + cost_state: None, + limit_event: Some(lime_agent::SessionExecutionRuntimeLimitEvent { + event_kind: "quota_low".to_string(), + message: "OEM 云端额度偏低".to_string(), + retryable: true, + }), + oem_policy: None, + runtime_summary: None, + }); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!( + thread_read + .oem_policy + .as_ref() + .and_then(|value| value.get("locked")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + thread_read + .oem_policy + .as_ref() + .and_then(|value| value.get("quotaLow")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + } + + #[test] + fn thread_read_should_include_auxiliary_runtime_projection_snapshots() { + let mut detail = build_session_detail(Vec::new(), Vec::new()); + detail.items.push(AgentThreadItem { + id: "artifact-1".to_string(), + thread_id: detail.thread_id.clone(), + turn_id: "turn-aux".to_string(), + sequence: 1, + status: AgentThreadItemStatus::Completed, + started_at: "2026-04-24T10:00:00Z".to_string(), + completed_at: Some("2026-04-24T10:00:00Z".to_string()), + updated_at: "2026-04-24T10:00:00Z".to_string(), + payload: AgentThreadItemPayload::FileArtifact { + path: + ".lime/harness/sessions/session-1/auxiliary-runtime/title-generation-aux-1.json" + .to_string(), + source: "auxiliary.generate_title".to_string(), + content: Some( + serde_json::json!({ + "artifactType": "auxiliary_runtime_projection", + "projectionKind": "title_generation", + "executionRuntime": { + "route": "auxiliary.generate_title", + "task_profile": { + "kind": "generation_topic" + } + } + }) + .to_string(), + ), + metadata: Some(serde_json::json!({ + "artifactType": "auxiliary_runtime_projection" + })), + }, + }); + + let thread_read = AgentRuntimeThreadReadModel::from_session_detail(&detail, &[]); + + assert_eq!( + thread_read + .auxiliary_task_runtime + .as_ref() + .and_then(|items| items.first()) + .and_then(|value| value.get("projectionKind")) + .and_then(serde_json::Value::as_str), + Some("title_generation") + ); + assert_eq!( + thread_read + .auxiliary_task_runtime + .as_ref() + .and_then(|items| items.first()) + .and_then(|value| value.get("route")) + .and_then(serde_json::Value::as_str), + Some("auxiliary.generate_title") + ); + } + #[test] fn thread_read_should_classify_running_turn_stuck() { let detail = build_session_detail( diff --git a/src-tauri/src/commands/aster_agent_cmd/mod.rs b/src-tauri/src/commands/aster_agent_cmd/mod.rs index f1c03a3bb..b0a4990f0 100644 --- a/src-tauri/src/commands/aster_agent_cmd/mod.rs +++ b/src-tauri/src/commands/aster_agent_cmd/mod.rs @@ -369,10 +369,11 @@ pub(crate) use dto::{ AgentRuntimeFileCheckpointListResult, AgentRuntimeFileCheckpointSummary, AgentRuntimeFileCheckpointThreadSummary, AgentRuntimeGetFileCheckpointRequest, AgentRuntimeIncidentView, AgentRuntimeInterruptTurnRequest, - AgentRuntimeListFileCheckpointsRequest, AgentRuntimeOutcomeView, - AgentRuntimePromoteQueuedTurnRequest, AgentRuntimeRemoveQueuedTurnRequest, - AgentRuntimeReplayRequestRequest, AgentRuntimeReplayedActionRequiredView, - AgentRuntimeRequestView, AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest, + AgentRuntimeListFileCheckpointsRequest, AgentRuntimeListSessionsRequest, + AgentRuntimeOutcomeView, AgentRuntimePromoteQueuedTurnRequest, + AgentRuntimeRemoveQueuedTurnRequest, AgentRuntimeReplayRequestRequest, + AgentRuntimeReplayedActionRequiredView, AgentRuntimeRequestView, + AgentRuntimeRespondActionRequest, AgentRuntimeResumeSubagentRequest, AgentRuntimeResumeSubagentResponse, AgentRuntimeResumeThreadRequest, AgentRuntimeSaveReviewDecisionRequest, AgentRuntimeSendSubagentInputRequest, AgentRuntimeSendSubagentInputResponse, AgentRuntimeSessionDetail, diff --git a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs index 3578763a1..c1af9ff4d 100644 --- a/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs +++ b/src-tauri/src/commands/aster_agent_cmd/request_model_resolution.rs @@ -11,6 +11,8 @@ use lime_core::models::model_registry::{ use std::collections::HashSet; use tauri::Manager; +const LIME_RUNTIME_METADATA_KEY: &str = "lime_runtime"; + #[derive(Debug, Clone)] struct ProviderResolutionContext { provider_selector: String, @@ -40,6 +42,8 @@ pub(super) struct RuntimeRequestProviderResolution { pub limit_state: lime_agent::SessionExecutionRuntimeLimitState, pub cost_state: lime_agent::SessionExecutionRuntimeCostState, pub limit_event: Option, + pub oem_policy: Option, + pub runtime_summary: lime_agent::SessionExecutionRuntimeSummary, } #[derive(Debug, Clone)] @@ -56,22 +60,31 @@ struct ResolvedRuntimeProviderSelection { } #[derive(Debug, Clone, PartialEq, Eq)] -struct RequestOemRoutingContext { - tenant_id: String, - provider_source: Option, - provider_key: Option, - default_model: Option, - config_mode: Option, - offer_state: Option, - quota_status: Option, - fallback_to_local_allowed: Option, - can_invoke: Option, +pub(crate) struct RequestOemRoutingContext { + pub tenant_id: String, + pub provider_source: Option, + pub provider_key: Option, + pub default_model: Option, + pub config_mode: Option, + pub offer_state: Option, + pub quota_status: Option, + pub fallback_to_local_allowed: Option, + pub can_invoke: Option, } fn normalize_identifier(value: &str) -> String { value.trim().to_ascii_lowercase() } +fn canonical_provider_selector(provider_selector: &str) -> String { + match normalize_identifier(provider_selector).as_str() { + "mimo" | "xiaomimimo" => "xiaomi".to_string(), + normalized => normalized.to_string(), + } +} + +const XIAOMI_HOST_KEYWORDS: [&str; 1] = ["xiaomimimo.com"]; + fn provider_alias_config_key(provider_key: &str) -> String { match normalize_identifier(provider_key).as_str() { "gemini_api_key" => "gemini".to_string(), @@ -605,7 +618,7 @@ fn build_provider_resolution_context( api_key_provider_service: &ApiKeyProviderServiceState, provider_selector: &str, ) -> Result { - let provider_selector = normalize_identifier(provider_selector); + let provider_selector = canonical_provider_selector(provider_selector); let is_custom_provider = lime_core::models::provider_type::is_custom_provider_id(&provider_selector); let mut provider_type = provider_type_from_key(&provider_selector); @@ -650,7 +663,7 @@ fn build_provider_resolution_context( !provider_id.trim().is_empty() && seen.insert(normalize_identifier(provider_id)) }); - Ok(ProviderResolutionContext { + let mut context = ProviderResolutionContext { aster_provider_name, alias_key: provider_alias_config_key(&provider_selector), compatibility_provider_key, @@ -662,7 +675,10 @@ fn build_provider_resolution_context( provider_type, provider_selector, registry_provider_ids, - }) + }; + context.custom_models = canonicalize_provider_custom_models(&context, &context.custom_models); + + Ok(context) } async fn load_model_registry_catalog( @@ -732,6 +748,79 @@ fn find_model_meta<'a>( .find(|model| normalize_identifier(&model.id) == normalized) } +fn is_xiaomi_like_provider_context(context: &ProviderResolutionContext) -> bool { + let provider_selector = normalize_identifier(&context.provider_selector); + let compatibility_provider = normalize_identifier(&context.compatibility_provider_key); + let provider_type = context + .provider_type + .map(|provider_type| normalize_identifier(&provider_type.to_string())) + .unwrap_or_default(); + let api_host = context + .configured_api_host + .as_deref() + .map(normalize_identifier) + .unwrap_or_default(); + + matches!(provider_selector.as_str(), "xiaomi" | "mimo" | "xiaomimimo") + || matches!( + compatibility_provider.as_str(), + "xiaomi" | "mimo" | "xiaomimimo" + ) + || matches!(provider_type.as_str(), "xiaomi" | "mimo" | "xiaomimimo") + || XIAOMI_HOST_KEYWORDS + .iter() + .any(|keyword| api_host.contains(keyword)) +} + +fn canonicalize_xiaomi_model_id(model_id: &str) -> String { + let trimmed = model_id.trim(); + if trimmed.is_empty() { + return String::new(); + } + + match normalize_identifier(trimmed).as_str() { + "mimo-v2-pro" | "mimo-v2.5" | "mimo-v2.5-pro" => "mimo-v2.5-pro".to_string(), + _ => trimmed.to_string(), + } +} + +fn canonicalize_known_provider_model_id( + context: &ProviderResolutionContext, + model_id: &str, +) -> String { + let trimmed = model_id.trim(); + if trimmed.is_empty() { + return String::new(); + } + + if is_xiaomi_like_provider_context(context) { + return canonicalize_xiaomi_model_id(trimmed); + } + + trimmed.to_string() +} + +fn canonicalize_provider_custom_models( + context: &ProviderResolutionContext, + model_ids: &[String], +) -> Vec { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + + for model_id in model_ids { + let canonical = canonicalize_known_provider_model_id(context, model_id); + if canonical.is_empty() { + continue; + } + + if seen.insert(normalize_identifier(&canonical)) { + result.push(canonical); + } + } + + result +} + fn model_has_reasoning_capability( model: Option<&EnhancedModelMetadata>, fallback_model_id: &str, @@ -1052,6 +1141,165 @@ fn capability_score(model: &EnhancedModelMetadata) -> u8 { score } +fn estimated_cost_rank(cost_class: Option<&str>) -> u8 { + match cost_class.map(normalize_identifier).as_deref() { + Some("low") => 3, + Some("medium") => 2, + Some("high") => 1, + _ => 0, + } +} + +fn choose_best_multi_candidate_model( + current_model_id: &str, + models: &[EnhancedModelMetadata], + thinking_enabled: bool, + has_images: bool, +) -> Option { + let current_model = find_model_meta(current_model_id, models); + let current_family = current_model + .and_then(|model| model.family.as_deref()) + .map(normalize_identifier) + .unwrap_or_else(|| normalize_model_lineage_key(current_model_id)); + let current_lineage = normalize_model_lineage_key(current_model_id); + + let mut candidates = models + .iter() + .filter(|candidate| is_compatible_candidate_model(candidate, thinking_enabled, has_images)) + .filter(|candidate| !is_likely_non_chat_model(candidate)) + .collect::>(); + + if candidates.is_empty() { + return None; + } + + candidates.sort_by(|left, right| { + let left_same_family = !current_family.is_empty() + && left.family.as_deref().map(normalize_identifier) == Some(current_family.clone()); + let right_same_family = !current_family.is_empty() + && right.family.as_deref().map(normalize_identifier) == Some(current_family.clone()); + let left_same_lineage = + !current_lineage.is_empty() && normalize_model_lineage_key(&left.id) == current_lineage; + let right_same_lineage = !current_lineage.is_empty() + && normalize_model_lineage_key(&right.id) == current_lineage; + let left_reasoning = model_has_reasoning_capability(Some(left), &left.id); + let right_reasoning = model_has_reasoning_capability(Some(right), &right.id); + let left_vision = supports_vision(Some(left), &left.id); + let right_vision = supports_vision(Some(right), &right.id); + let left_cost = estimated_cost_rank(estimate_cost_class(&left.id, Some(left)).as_deref()); + let right_cost = + estimated_cost_rank(estimate_cost_class(&right.id, Some(right)).as_deref()); + + left_same_family + .cmp(&right_same_family) + .reverse() + .then(left_same_lineage.cmp(&right_same_lineage).reverse()) + .then( + (left_reasoning == thinking_enabled) + .cmp(&(right_reasoning == thinking_enabled)) + .reverse(), + ) + .then( + (left_vision == has_images) + .cmp(&(right_vision == has_images)) + .reverse(), + ) + .then( + capability_score(left) + .cmp(&capability_score(right)) + .reverse(), + ) + .then(left_cost.cmp(&right_cost).reverse()) + .then(left.is_latest.cmp(&right.is_latest).reverse()) + .then( + tier_weight(&left.tier) + .cmp(&tier_weight(&right.tier)) + .reverse(), + ) + .then(compare_release_date_desc(left, right).cmp(&0)) + .then(left.id.cmp(&right.id)) + }); + + candidates.first().map(|model| model.id.clone()) +} + +fn should_auto_reselect_multi_candidate_model(context: &ProviderResolutionContext) -> bool { + !context.is_custom_provider +} + +fn choose_provider_permission_recovery_model( + context: &ProviderResolutionContext, + current_model_id: &str, + models: &[EnhancedModelMetadata], + thinking_enabled: bool, + has_images: bool, +) -> Option { + if !context.is_custom_provider || !is_xiaomi_like_provider_context(context) { + return None; + } + + let normalized_current_id = normalize_identifier(current_model_id); + let current_model = find_model_meta(current_model_id, models); + let current_family = current_model + .and_then(|model| model.family.as_deref()) + .map(normalize_identifier) + .unwrap_or_else(|| normalize_model_lineage_key(current_model_id)); + let current_lineage = normalize_model_lineage_key(current_model_id); + + let mut candidates = models + .iter() + .filter(|candidate| normalize_identifier(&candidate.id) != normalized_current_id) + .filter(|candidate| is_compatible_candidate_model(candidate, thinking_enabled, has_images)) + .filter(|candidate| !is_likely_non_chat_model(candidate)) + .collect::>(); + + if candidates.is_empty() { + return None; + } + + candidates.sort_by(|left, right| { + let left_same_family = !current_family.is_empty() + && left.family.as_deref().map(normalize_identifier) == Some(current_family.clone()); + let right_same_family = !current_family.is_empty() + && right.family.as_deref().map(normalize_identifier) == Some(current_family.clone()); + let left_same_lineage = + !current_lineage.is_empty() && normalize_model_lineage_key(&left.id) == current_lineage; + let right_same_lineage = !current_lineage.is_empty() + && normalize_model_lineage_key(&right.id) == current_lineage; + let left_non_flash = !normalize_identifier(&left.id).contains("flash"); + let right_non_flash = !normalize_identifier(&right.id).contains("flash"); + let left_reasoning_match = + model_has_reasoning_capability(Some(left), &left.id) == thinking_enabled; + let right_reasoning_match = + model_has_reasoning_capability(Some(right), &right.id) == thinking_enabled; + let left_vision_match = supports_vision(Some(left), &left.id) == has_images; + let right_vision_match = supports_vision(Some(right), &right.id) == has_images; + + left_same_family + .cmp(&right_same_family) + .reverse() + .then(left_same_lineage.cmp(&right_same_lineage).reverse()) + .then(left_non_flash.cmp(&right_non_flash).reverse()) + .then(left_reasoning_match.cmp(&right_reasoning_match).reverse()) + .then(left_vision_match.cmp(&right_vision_match).reverse()) + .then( + capability_score(left) + .cmp(&capability_score(right)) + .reverse(), + ) + .then(left.is_latest.cmp(&right.is_latest).reverse()) + .then( + tier_weight(&left.tier) + .cmp(&tier_weight(&right.tier)) + .reverse(), + ) + .then(compare_release_date_desc(left, right).cmp(&0)) + .then(left.id.cmp(&right.id)) + }); + + candidates.first().map(|candidate| candidate.id.clone()) +} + fn tier_weight(tier: &ModelTier) -> u8 { match tier { ModelTier::Mini => 1, @@ -1117,15 +1365,19 @@ fn resolve_vision_model_id( }) } -fn resolve_provider_model_compatibility(provider_key: &str, model_id: &str) -> String { - let normalized_provider = normalize_identifier(provider_key); - let normalized_model = normalize_identifier(model_id); +fn resolve_provider_model_compatibility( + context: &ProviderResolutionContext, + model_id: &str, +) -> String { + let normalized_provider = normalize_identifier(&context.compatibility_provider_key); + let canonical_model = canonicalize_known_provider_model_id(context, model_id); + let normalized_model = normalize_identifier(&canonical_model); if normalized_provider == "codex" && normalized_model == "gpt-5.3-codex" { return "gpt-5.2-codex".to_string(); } - model_id.to_string() + canonical_model } fn extract_request_thinking_enabled(request: &AsterChatRequest) -> Option { @@ -1177,7 +1429,18 @@ fn extract_metadata_bool( .find_map(|key| object.get(*key).and_then(serde_json::Value::as_bool)) } -fn resolve_request_oem_routing_context( +fn extract_turn_context_runtime( + request_metadata: Option<&serde_json::Value>, +) -> Option<&serde_json::Map> { + request_metadata + .and_then(serde_json::Value::as_object) + .and_then(|root| root.get(LIME_RUNTIME_METADATA_KEY)) + .and_then(serde_json::Value::as_object) + .and_then(|runtime| runtime.get("task_profile")) + .and_then(serde_json::Value::as_object) +} + +pub(crate) fn resolve_request_oem_routing_context( request_metadata: Option<&serde_json::Value>, ) -> Option { let routing = extract_harness_nested_object(request_metadata, &["oem_routing", "oemRouting"])?; @@ -1199,13 +1462,63 @@ fn resolve_request_oem_routing_context( }) } -fn request_oem_routing_is_locked(context: Option<&RequestOemRoutingContext>) -> bool { +pub(crate) fn request_oem_routing_is_locked(context: Option<&RequestOemRoutingContext>) -> bool { context.is_some_and(|value| { matches!(value.config_mode.as_deref(), Some("managed")) || matches!(value.fallback_to_local_allowed, Some(false)) }) } +fn build_request_oem_policy( + context: Option<&RequestOemRoutingContext>, +) -> Option { + let context = context?; + Some(lime_agent::SessionExecutionRuntimeOemPolicy { + tenant_id: context.tenant_id.clone(), + provider_source: context.provider_source.clone(), + provider_key: context.provider_key.clone(), + default_model: context.default_model.clone(), + config_mode: context.config_mode.clone(), + offer_state: context.offer_state.clone(), + quota_status: context.quota_status.clone(), + fallback_to_local_allowed: context.fallback_to_local_allowed, + can_invoke: context.can_invoke, + }) +} + +fn build_runtime_summary( + routing_decision: &lime_agent::SessionExecutionRuntimeRoutingDecision, + limit_state: &lime_agent::SessionExecutionRuntimeLimitState, + cost_state: &lime_agent::SessionExecutionRuntimeCostState, + limit_event: Option<&lime_agent::SessionExecutionRuntimeLimitEvent>, +) -> lime_agent::SessionExecutionRuntimeSummary { + lime_agent::SessionExecutionRuntimeSummary { + candidate_count: Some(routing_decision.candidate_count), + routing_mode: Some(routing_decision.routing_mode.clone()), + decision_source: Some(routing_decision.decision_source.clone()), + decision_reason: Some(routing_decision.decision_reason.clone()), + fallback_chain: routing_decision.fallback_chain.clone(), + estimated_cost_class: routing_decision + .estimated_cost_class + .clone() + .or_else(|| cost_state.estimated_cost_class.clone()), + estimated_total_cost: cost_state.estimated_total_cost, + limit_status: Some(limit_state.status.clone()), + limit_event_kind: limit_event.map(|event| event.event_kind.clone()), + limit_event_message: limit_event.map(|event| event.message.clone()), + capability_gap: routing_decision + .capability_gap + .clone() + .or_else(|| limit_state.capability_gap.clone()), + single_candidate_only: Some(limit_state.single_candidate_only), + oem_locked: Some(limit_state.oem_locked), + quota_low: Some(matches!( + limit_event.map(|event| event.event_kind.as_str()), + Some("quota_low") + )), + } +} + fn build_request_oem_limit_event( context: Option<&RequestOemRoutingContext>, ) -> Option { @@ -1232,6 +1545,12 @@ fn build_request_oem_limit_event( fn resolve_request_service_model_slot( request_metadata: Option<&serde_json::Value>, ) -> Option { + if let Some(slot) = extract_turn_context_runtime(request_metadata) + .and_then(|runtime| extract_metadata_text(runtime, &["service_model_slot"])) + { + return Some(slot); + } + if extract_harness_nested_object( request_metadata, &["translation_skill_launch", "translationSkillLaunch"], @@ -1250,6 +1569,36 @@ fn resolve_request_service_model_slot( return Some("resource_prompt_rewrite".to_string()); } + if extract_harness_nested_object( + request_metadata, + &["topic_skill_launch", "topicSkillLaunch"], + ) + .is_some() + { + return Some("topic".to_string()); + } + + if extract_harness_nested_object( + request_metadata, + &[ + "generation_topic_skill_launch", + "generationTopicSkillLaunch", + ], + ) + .is_some() + { + return Some("generation_topic".to_string()); + } + + if extract_harness_nested_object( + request_metadata, + &["agent_meta_skill_launch", "agentMetaSkillLaunch"], + ) + .is_some() + { + return Some("agent_meta".to_string()); + } + if extract_harness_string(request_metadata, &["turn_purpose", "turnPurpose"]) .as_deref() .is_some_and(is_prompt_rewrite_turn_purpose) @@ -1387,6 +1736,69 @@ fn build_runtime_task_profile( }; } + if extract_harness_nested_object( + request_metadata, + &["topic_skill_launch", "topicSkillLaunch"], + ) + .is_some() + { + return lime_agent::SessionExecutionRuntimeTaskProfile { + kind: "topic".to_string(), + source: "auxiliary_topic".to_string(), + traits, + service_model_slot, + scene_kind: None, + scene_skill_id: None, + entry_source: extract_harness_string( + request_metadata, + &["entry_source", "entrySource"], + ), + }; + } + + if extract_harness_nested_object( + request_metadata, + &[ + "generation_topic_skill_launch", + "generationTopicSkillLaunch", + ], + ) + .is_some() + { + return lime_agent::SessionExecutionRuntimeTaskProfile { + kind: "generation_topic".to_string(), + source: "auxiliary_generation_topic".to_string(), + traits, + service_model_slot, + scene_kind: None, + scene_skill_id: None, + entry_source: extract_harness_string( + request_metadata, + &["entry_source", "entrySource"], + ), + }; + } + + if extract_harness_nested_object( + request_metadata, + &["agent_meta_skill_launch", "agentMetaSkillLaunch"], + ) + .is_some() + { + return lime_agent::SessionExecutionRuntimeTaskProfile { + kind: "agent_meta".to_string(), + source: "auxiliary_agent_meta".to_string(), + traits, + service_model_slot, + scene_kind: None, + scene_skill_id: None, + entry_source: extract_harness_string( + request_metadata, + &["entry_source", "entrySource"], + ), + }; + } + if extract_harness_string(request_metadata, &["turn_purpose", "turnPurpose"]) .as_deref() .is_some_and(is_prompt_rewrite_turn_purpose) @@ -1630,12 +2042,50 @@ fn build_routing_decision( } } +fn compose_routing_decision_reason( + base_reason: impl Into, + selection: Option<&ResolvedRuntimeProviderSelection>, + oem_locked: bool, + fallback_note: Option<&str>, +) -> String { + let mut parts = vec![base_reason.into()]; + + if let Some(selection) = selection { + if selection.candidate_count > 1 { + parts.push(format!( + "当前 provider 候选池共有 {} 个兼容候选,已按连续性、能力与成本优选。", + selection.candidate_count + )); + } + if selection.fallback_chain.len() >= 2 { + parts.push(format!( + "回退链为 {}。", + selection.fallback_chain.join(" -> ") + )); + } + if let Some(gap) = selection.capability_gap.as_deref() { + parts.push(format!("当前仍存在能力提示:{gap}。")); + } + } + + if oem_locked { + parts.push("当前回合受 OEM 托管约束,自动策略不会越出 OEM 允许范围。".to_string()); + } + + if let Some(note) = fallback_note.map(str::trim).filter(|note| !note.is_empty()) { + parts.push(note.to_string()); + } + + parts.join(" ") +} + fn build_no_candidate_resolution( task_profile: lime_agent::SessionExecutionRuntimeTaskProfile, decision_source: &str, decision_reason: String, oem_locked: bool, limit_event: Option, + oem_policy: Option, ) -> RuntimeRequestProviderResolution { let capability_gap = if task_profile.kind == "vision_chat" { Some("vision_candidate_missing".to_string()) @@ -1661,6 +2111,12 @@ fn build_no_candidate_resolution( None, ); let cost_state = build_cost_state(None, None, "unavailable"); + let runtime_summary = build_runtime_summary( + &routing_decision, + &limit_state, + &cost_state, + limit_event.as_ref(), + ); RuntimeRequestProviderResolution { provider_config: None, @@ -1669,6 +2125,8 @@ fn build_no_candidate_resolution( limit_state, cost_state, limit_event, + oem_policy, + runtime_summary, } } @@ -1838,6 +2296,8 @@ async fn build_runtime_request_provider_config_from_preference( let context = build_provider_resolution_context(db, api_key_provider_service, provider_selector)?; let (catalog, _alias_config) = load_model_registry_catalog(app, &context).await; + let normalized_model_preference = + canonicalize_known_provider_model_id(&context, model_preference); let thinking_enabled = resolve_request_thinking_enabled(request).await?; let has_images = request .images @@ -1860,11 +2320,26 @@ async fn build_runtime_request_provider_config_from_preference( }); let mut resolved_model = if thinking_enabled { - resolve_thinking_model_id(model_preference, &catalog) + resolve_thinking_model_id(&normalized_model_preference, &catalog) } else { - resolve_base_model_on_thinking_off(model_preference, &catalog) + resolve_base_model_on_thinking_off(&normalized_model_preference, &catalog) }; let mut fallback_chain = Vec::new(); + if compatible_candidate_count > 1 && should_auto_reselect_multi_candidate_model(&context) { + if let Some(best_candidate) = choose_best_multi_candidate_model( + &resolved_model, + &catalog, + thinking_enabled, + has_images, + ) { + if best_candidate != resolved_model { + fallback_chain.push(format!("{}:{}", context.provider_selector, resolved_model)); + resolved_model = best_candidate; + fallback_chain.push(format!("{}:{}", context.provider_selector, resolved_model)); + } + } + } + let should_fallback_unknown_model = allow_runtime_fallback && !context.is_custom_provider && find_model_meta(&resolved_model, &catalog).is_none(); @@ -1889,8 +2364,7 @@ async fn build_runtime_request_provider_config_from_preference( fallback_chain.push(format!("{}:{}", context.provider_selector, resolved_model)); } } - resolved_model = - resolve_provider_model_compatibility(&context.compatibility_provider_key, &resolved_model); + resolved_model = resolve_provider_model_compatibility(&context, &resolved_model); if has_images { resolved_model = resolve_vision_model_id(&resolved_model, &catalog)?; } @@ -1962,6 +2436,75 @@ async fn build_runtime_request_provider_config_from_preference( }) } +pub(super) async fn resolve_runtime_provider_auth_recovery_config( + app: &AppHandle, + db: &DbConnection, + api_key_provider_service: &ApiKeyProviderServiceState, + request: &AsterChatRequest, + provider_selector: &str, + failed_model: &str, +) -> Result, String> { + let context = + build_provider_resolution_context(db, api_key_provider_service, provider_selector)?; + let failed_model = canonicalize_known_provider_model_id(&context, failed_model); + let (catalog, _alias_config) = load_model_registry_catalog(app, &context).await; + let thinking_enabled = resolve_request_thinking_enabled(request).await?; + let has_images = request + .images + .as_ref() + .map(|images| !images.is_empty()) + .unwrap_or(false); + + let Some(fallback_model) = choose_provider_permission_recovery_model( + &context, + &failed_model, + &catalog, + thinking_enabled, + has_images, + ) else { + return Ok(None); + }; + + if normalize_identifier(&fallback_model) == normalize_identifier(&failed_model) { + return Ok(None); + } + + let provider_strategy = resolve_runtime_provider_configuration_strategy(&context); + let base_url = match provider_strategy { + RuntimeProviderConfigurationStrategy::Manual { base_url } => base_url, + RuntimeProviderConfigurationStrategy::CredentialPool => None, + }; + let model_meta = find_model_meta(&fallback_model, &catalog); + let model_capabilities = model_meta + .map(|model| model.capabilities.clone()) + .unwrap_or_else(|| { + let inferred_task_families = infer_model_task_families( + &fallback_model, + Some(&context.provider_selector), + None, + None, + ); + infer_model_capabilities( + &fallback_model, + Some(&context.provider_selector), + &inferred_task_families, + None, + None, + ) + }); + + Ok(Some(ConfigureProviderRequest { + provider_id: Some(context.provider_selector.clone()), + provider_name: context.aster_provider_name, + model_name: fallback_model, + api_key: None, + base_url, + model_capabilities: Some(model_capabilities), + tool_call_strategy: None, + toolshim_model: None, + })) +} + pub(super) async fn resolve_runtime_request_provider_resolution( app: &AppHandle, db: &DbConnection, @@ -1972,6 +2515,7 @@ pub(super) async fn resolve_runtime_request_provider_resolution( let request_oem_routing = resolve_request_oem_routing_context(request.metadata.as_ref()); let oem_locked = request_oem_routing_is_locked(request_oem_routing.as_ref()); let oem_limit_event = build_request_oem_limit_event(request_oem_routing.as_ref()); + let oem_policy = build_request_oem_policy(request_oem_routing.as_ref()); if request.provider_config.is_some() { let explicit_provider = request.provider_config.as_ref().and_then(|config| { @@ -1998,7 +2542,12 @@ pub(super) async fn resolve_runtime_request_provider_resolution( let routing_decision = build_routing_decision( &task_profile, "provider_config", - "请求已显式传入 provider_config,运行时仅补齐能力与工具策略。".to_string(), + compose_routing_decision_reason( + "请求已显式传入 provider_config,运行时仅补齐能力与工具策略。", + None, + oem_locked, + None, + ), None, explicit_provider.clone(), explicit_model.clone(), @@ -2013,6 +2562,12 @@ pub(super) async fn resolve_runtime_request_provider_resolution( .and_then(|config| estimate_cost_class(&config.model_name, None)), "estimated", ); + let runtime_summary = build_runtime_summary( + &routing_decision, + &limit_state, + &cost_state, + oem_limit_event.as_ref(), + ); return Ok(RuntimeRequestProviderResolution { provider_config: None, @@ -2031,6 +2586,8 @@ pub(super) async fn resolve_runtime_request_provider_resolution( limit_state, cost_state, limit_event: oem_limit_event, + oem_policy: oem_policy.clone(), + runtime_summary, }); } @@ -2060,6 +2617,7 @@ pub(super) async fn resolve_runtime_request_provider_resolution( .to_string(), oem_locked, oem_limit_event.clone(), + oem_policy.clone(), )); }; let (model_preference, model_preference_source) = @@ -2127,6 +2685,12 @@ pub(super) async fn resolve_runtime_request_provider_resolution( ); let cost_state = build_cost_state(Some(&selection), None, "estimated"); + let runtime_summary = build_runtime_summary( + &routing_decision, + &limit_state, + &cost_state, + oem_limit_event.as_ref(), + ); return Ok(RuntimeRequestProviderResolution { provider_config: Some(selection.provider_config), @@ -2135,6 +2699,8 @@ pub(super) async fn resolve_runtime_request_provider_resolution( limit_state, cost_state, limit_event: oem_limit_event, + oem_policy: oem_policy.clone(), + runtime_summary, }); } @@ -2183,6 +2749,12 @@ pub(super) async fn resolve_runtime_request_provider_resolution( Some("service_scene_launch".to_string()), ); let cost_state = build_cost_state(Some(&selection), None, "estimated"); + let runtime_summary = build_runtime_summary( + &routing_decision, + &limit_state, + &cost_state, + oem_limit_event.as_ref(), + ); return Ok(RuntimeRequestProviderResolution { provider_config: Some(selection.provider_config), @@ -2191,6 +2763,8 @@ pub(super) async fn resolve_runtime_request_provider_resolution( limit_state, cost_state, limit_event: oem_limit_event, + oem_policy: oem_policy.clone(), + runtime_summary, }); } Err(error) => { @@ -2251,9 +2825,14 @@ pub(super) async fn resolve_runtime_request_provider_resolution( let routing_decision = build_routing_decision( &task_profile, "service_model_setting", - format!( - "当前回合命中 {},优先使用设置中的 provider/model。", - setting_preference.settings_source + compose_routing_decision_reason( + format!( + "当前回合命中 {},优先使用设置中的 provider/model。", + setting_preference.settings_source + ), + Some(&selection), + oem_locked, + fallback_note.as_deref(), ), Some(&selection), Some(setting_preference.provider_selector.clone()), @@ -2261,6 +2840,12 @@ pub(super) async fn resolve_runtime_request_provider_resolution( Some(setting_preference.settings_source.clone()), ); let cost_state = build_cost_state(Some(&selection), None, "estimated"); + let runtime_summary = build_runtime_summary( + &routing_decision, + &limit_state, + &cost_state, + oem_limit_event.as_ref(), + ); return Ok(RuntimeRequestProviderResolution { provider_config: Some(selection.provider_config), @@ -2269,6 +2854,8 @@ pub(super) async fn resolve_runtime_request_provider_resolution( limit_state, cost_state, limit_event: oem_limit_event, + oem_policy: oem_policy.clone(), + runtime_summary, }); } Err(error) => { @@ -2301,6 +2888,7 @@ pub(super) async fn resolve_runtime_request_provider_resolution( }), oem_locked, oem_limit_event.clone(), + oem_policy.clone(), )); }; let (model_preference, model_preference_source) = @@ -2338,8 +2926,8 @@ pub(super) async fn resolve_runtime_request_provider_resolution( ) .await?; let mut notes = vec!["当前回合沿用会话最近一次持久化的 provider/model 默认值。".to_string()]; - if let Some(note) = fallback_note { - notes.push(note); + if let Some(note) = fallback_note.as_ref() { + notes.push(note.clone()); } let limit_state = build_limit_state( if selection.candidate_count <= 1 { @@ -2363,8 +2951,12 @@ pub(super) async fn resolve_runtime_request_provider_resolution( } else { "auto_default" }, - "当前回合没有显式指定 provider/model,运行时沿用会话默认并在当前 provider 内做能力兼容。" - .to_string(), + compose_routing_decision_reason( + "当前回合没有显式指定 provider/model,运行时沿用会话默认并在当前 provider 内做能力兼容。", + Some(&selection), + oem_locked, + fallback_note.as_deref(), + ), Some(&selection), Some(provider_selector), Some(model_preference), @@ -2372,6 +2964,12 @@ pub(super) async fn resolve_runtime_request_provider_resolution( ); let cost_state = build_cost_state(Some(&selection), None, "estimated"); + let runtime_summary = build_runtime_summary( + &routing_decision, + &limit_state, + &cost_state, + oem_limit_event.as_ref(), + ); Ok(RuntimeRequestProviderResolution { provider_config: Some(selection.provider_config), @@ -2380,6 +2978,8 @@ pub(super) async fn resolve_runtime_request_provider_resolution( limit_state, cost_state, limit_event: oem_limit_event, + oem_policy, + runtime_summary, }) } @@ -2566,12 +3166,63 @@ mod tests { #[test] fn codex_compatibility_falls_back_to_supported_model() { + let context = ProviderResolutionContext { + provider_selector: "codex".to_string(), + aster_provider_name: "openai".to_string(), + compatibility_provider_key: "codex".to_string(), + registry_provider_ids: vec!["codex".to_string()], + alias_key: "codex".to_string(), + custom_models: vec![], + is_custom_provider: false, + provider_type: Some(ApiProviderType::Codex), + provider_group: None, + configured_api_host: Some("https://api.openai.com/v1".to_string()), + has_credentials: true, + }; + assert_eq!( - resolve_provider_model_compatibility("codex", "gpt-5.3-codex"), + resolve_provider_model_compatibility(&context, "gpt-5.3-codex"), "gpt-5.2-codex" ); } + #[test] + fn xiaomi_compatibility_canonicalizes_display_name_and_legacy_alias() { + let context = ProviderResolutionContext { + provider_selector: "custom-mimo".to_string(), + aster_provider_name: "anthropic".to_string(), + compatibility_provider_key: "anthropic-compatible".to_string(), + registry_provider_ids: vec!["xiaomi".to_string()], + alias_key: "custom-mimo".to_string(), + custom_models: vec![], + is_custom_provider: true, + provider_type: Some(ApiProviderType::AnthropicCompatible), + provider_group: None, + configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), + has_credentials: true, + }; + + assert_eq!( + resolve_provider_model_compatibility(&context, "MiMo-V2.5-Pro"), + "mimo-v2.5-pro" + ); + assert_eq!( + resolve_provider_model_compatibility(&context, "mimo-v2-pro"), + "mimo-v2.5-pro" + ); + assert_eq!( + canonicalize_provider_custom_models( + &context, + &[ + "MiMo-V2.5-Pro".to_string(), + "mimo-v2-pro".to_string(), + "mimo-v2-flash".to_string(), + ], + ), + vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()] + ); + } + #[test] fn catalog_fallback_prefers_latest_same_lineage_chat_model_for_unknown_session_model() { let models = vec![ @@ -2619,6 +3270,172 @@ mod tests { ); } + #[test] + fn multi_candidate_selection_prefers_same_family_lower_cost_model() { + let models = vec![ + build_model( + "gpt-5.4", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Pro, + Some("2026-01-03"), + ), + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-04"), + ), + build_model( + "gemini-2.5-pro", + Some("gemini-2.5"), + false, + false, + true, + ModelTier::Pro, + Some("2026-01-05"), + ), + ]; + + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4", &models, false, false).as_deref(), + Some("gpt-5.4-mini") + ); + } + + #[test] + fn multi_candidate_selection_prefers_reasoning_model_when_thinking_enabled() { + let mut models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-03"), + ), + build_model( + "gpt-5.4-mini-thinking", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-04"), + ), + ]; + models[1].capabilities.reasoning = true; + models[1].task_families.push(ModelTaskFamily::Reasoning); + + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4-mini", &models, true, false).as_deref(), + Some("gpt-5.4-mini-thinking") + ); + } + + #[test] + fn multi_candidate_selection_prefers_vision_candidate_when_images_present() { + let models = vec![ + build_model( + "gpt-5.4-mini", + Some("gpt-5.4"), + false, + false, + true, + ModelTier::Mini, + Some("2026-01-03"), + ), + build_model( + "gpt-5.4", + Some("gpt-5.4"), + true, + true, + true, + ModelTier::Pro, + Some("2026-01-04"), + ), + ]; + + assert_eq!( + choose_best_multi_candidate_model("gpt-5.4-mini", &models, false, true).as_deref(), + Some("gpt-5.4") + ); + } + + #[test] + fn custom_provider_multi_candidate_reselection_is_disabled() { + let context = ProviderResolutionContext { + provider_selector: "custom-mimo".to_string(), + aster_provider_name: "anthropic".to_string(), + compatibility_provider_key: "anthropic-compatible".to_string(), + registry_provider_ids: vec!["xiaomi".to_string()], + alias_key: "custom-mimo".to_string(), + custom_models: vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()], + is_custom_provider: true, + provider_type: Some(ApiProviderType::AnthropicCompatible), + provider_group: None, + configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), + has_credentials: true, + }; + + assert!(!should_auto_reselect_multi_candidate_model(&context)); + } + + #[test] + fn xiaomi_permission_recovery_prefers_non_flash_candidate() { + let context = ProviderResolutionContext { + provider_selector: "custom-mimo".to_string(), + aster_provider_name: "anthropic".to_string(), + compatibility_provider_key: "anthropic-compatible".to_string(), + registry_provider_ids: vec!["xiaomi".to_string()], + alias_key: "custom-mimo".to_string(), + custom_models: vec!["mimo-v2.5-pro".to_string(), "mimo-v2-flash".to_string()], + is_custom_provider: true, + provider_type: Some(ApiProviderType::AnthropicCompatible), + provider_group: None, + configured_api_host: Some("https://token-plan-cn.xiaomimimo.com/anthropic".to_string()), + has_credentials: true, + }; + let models = vec![ + build_model( + "mimo-v2.5-pro", + Some("mimo-v2"), + false, + false, + true, + ModelTier::Pro, + Some("2026-04-01"), + ), + build_model( + "mimo-v2-flash", + Some("mimo-v2"), + false, + false, + true, + ModelTier::Mini, + Some("2026-04-02"), + ), + ]; + + assert_eq!( + choose_provider_permission_recovery_model( + &context, + "mimo-v2-flash", + &models, + false, + false + ) + .as_deref(), + Some("mimo-v2.5-pro") + ); + } + #[test] fn model_preference_falls_back_to_session_model_when_provider_matches() { let resolved = resolve_model_preference_with_session_fallback( @@ -2861,6 +3678,54 @@ mod tests { assert_eq!(summary_profile.source, "summary_skill_launch"); assert_eq!(summary_profile.service_model_slot, None); + let mut topic_request = base_request.clone(); + topic_request.metadata = Some(serde_json::json!({ + "harness": { + "topic_skill_launch": { + "kind": "topic_request" + } + } + })); + let topic_profile = build_runtime_task_profile(&topic_request); + assert_eq!(topic_profile.kind, "topic"); + assert_eq!(topic_profile.source, "auxiliary_topic"); + assert_eq!(topic_profile.service_model_slot.as_deref(), Some("topic")); + + let mut generation_topic_request = base_request.clone(); + generation_topic_request.metadata = Some(serde_json::json!({ + "harness": { + "generation_topic_skill_launch": { + "kind": "generation_topic_request" + } + } + })); + let generation_topic_profile = build_runtime_task_profile(&generation_topic_request); + assert_eq!(generation_topic_profile.kind, "generation_topic"); + assert_eq!( + generation_topic_profile.source, + "auxiliary_generation_topic" + ); + assert_eq!( + generation_topic_profile.service_model_slot.as_deref(), + Some("generation_topic") + ); + + let mut agent_meta_request = base_request.clone(); + agent_meta_request.metadata = Some(serde_json::json!({ + "harness": { + "agent_meta_skill_launch": { + "kind": "agent_meta_request" + } + } + })); + let agent_meta_profile = build_runtime_task_profile(&agent_meta_request); + assert_eq!(agent_meta_profile.kind, "agent_meta"); + assert_eq!(agent_meta_profile.source, "auxiliary_agent_meta"); + assert_eq!( + agent_meta_profile.service_model_slot.as_deref(), + Some("agent_meta") + ); + let mut rewrite_request = base_request; rewrite_request.metadata = Some(serde_json::json!({ "harness": { @@ -3041,4 +3906,11 @@ mod tests { Some("http://127.0.0.1:11434".to_string()) ); } + + #[test] + fn canonical_provider_selector_maps_legacy_mimo_ids() { + assert_eq!(canonical_provider_selector("mimo"), "xiaomi"); + assert_eq!(canonical_provider_selector("xiaomimimo"), "xiaomi"); + assert_eq!(canonical_provider_selector("xiaomi"), "xiaomi"); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index 657aec24a..aa71d0dc6 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -1,3 +1,4 @@ +use super::request_model_resolution::resolve_runtime_provider_auth_recovery_config; #[cfg(test)] use super::runtime_project_hooks::enforce_runtime_turn_user_prompt_submit_hooks; use super::runtime_project_hooks::{ @@ -29,6 +30,7 @@ const ARTIFACT_DOCUMENT_PERSIST_FAILED_WARNING_CODE: &str = "artifact_document_p const AUTO_CONTEXT_COMPACTION_EVENT_PREFIX: &str = "agent_context_compaction_auto_internal"; const AUTO_CONTEXT_COMPACTION_FAILED_WARNING_CODE: &str = "context_compaction_auto_failed"; const CONTEXT_COMPACTION_NOT_NEEDED_WARNING_CODE: &str = "context_compaction_not_needed"; +const RUNTIME_MODEL_PERMISSION_FALLBACK_WARNING_CODE: &str = "runtime_model_permission_fallback"; const STOP_HOOK_CONTINUATION_UNSUPPORTED_WARNING_CODE: &str = "stop_hook_continuation_unsupported"; const TURN_MEMORY_PREFETCH_PROMPT_MARKER: &str = "【运行时记忆召回】"; const TURN_LOCAL_PATH_FOCUS_PROMPT_MARKER: &str = "【本回合本地路径焦点】"; @@ -56,6 +58,44 @@ fn emit_runtime_events(app: &AppHandle, event_name: &str, events: Vec bool { + let normalized = message.trim().to_ascii_lowercase(); + normalized.contains("authentication failed") + && normalized.contains("403") + && normalized.contains("illegal access") +} + +fn build_submit_accepted_runtime_status() -> AgentRuntimeStatus { + AgentRuntimeStatus { + phase: "preparing".to_string(), + title: "已接收请求,正在准备执行".to_string(), + detail: "系统正在初始化本轮执行环境并整理上下文,稍后会继续返回更详细进度。".to_string(), + checkpoints: vec![ + "请求已进入运行时主链".to_string(), + "正在准备工作区与会话上下文".to_string(), + "等待后续详细执行事件".to_string(), + ], + metadata: None, + } +} + +fn emit_submit_accepted_runtime_status(app: &AppHandle, event_name: &str) { + if event_name.trim().is_empty() { + return; + } + + let event = RuntimeAgentEvent::RuntimeStatus { + status: build_submit_accepted_runtime_status(), + }; + if let Err(error) = app.emit(event_name, &event) { + tracing::warn!( + "[AsterAgent] 发送 submit accepted runtime_status 失败: event_name={}, error={}", + event_name, + error + ); + } +} + async fn sync_runtime_skill_source_agent(session_id: &str, agent: &Agent) -> Result<(), String> { let donor = Agent::new().with_shared_native_tool_surface_from(agent); donor @@ -925,6 +965,7 @@ impl RuntimeTurnExecutionContext { execute_runtime_stream_with_strategy( agent, app, + state, db, request, &timeline_recorder, @@ -2399,6 +2440,8 @@ async fn prepare_runtime_turn_ingress_context( &provider_resolution.limit_state, &provider_resolution.cost_state, provider_resolution.limit_event.as_ref(), + provider_resolution.oem_policy.as_ref(), + &provider_resolution.runtime_summary, ); if let Some(resolved_provider_config) = provider_resolution.provider_config { request.provider_config = Some(resolved_provider_config); @@ -2982,6 +3025,8 @@ fn merge_runtime_request_resolution_metadata( limit_state: &lime_agent::SessionExecutionRuntimeLimitState, cost_state: &lime_agent::SessionExecutionRuntimeCostState, limit_event: Option<&lime_agent::SessionExecutionRuntimeLimitEvent>, + oem_policy: Option<&lime_agent::SessionExecutionRuntimeOemPolicy>, + runtime_summary: &lime_agent::SessionExecutionRuntimeSummary, ) -> Option { let mut root = match request_metadata { Some(serde_json::Value::Object(object)) => object, @@ -3000,9 +3045,13 @@ fn merge_runtime_request_resolution_metadata( insert_serialized_run_metadata(runtime_object, "routing_decision", routing_decision); insert_serialized_run_metadata(runtime_object, "limit_state", limit_state); insert_serialized_run_metadata(runtime_object, "cost_state", cost_state); + insert_serialized_run_metadata(runtime_object, "runtime_summary", runtime_summary); if let Some(limit_event) = limit_event { insert_serialized_run_metadata(runtime_object, "limit_event", limit_event); } + if let Some(oem_policy) = oem_policy { + insert_serialized_run_metadata(runtime_object, "oem_policy", oem_policy); + } Some(serde_json::Value::Object(root)) } @@ -3541,6 +3590,7 @@ async fn remove_code_execution_extension_if_added( async fn execute_runtime_stream_with_strategy( agent: &Agent, app: &AppHandle, + state: &AsterAgentState, db: &DbConnection, request: &AsterChatRequest, timeline_recorder: &Arc>, @@ -3628,6 +3678,98 @@ where .await .map_err(|fallback_err| fallback_err.message) } + Err(primary_error) if is_runtime_model_permission_denied_error(&primary_error.message) => { + let recovery_result: Result, String> = async { + let Some(provider_config) = request.provider_config.as_ref() else { + return Ok(None); + }; + let provider_selector = provider_config + .provider_id + .as_deref() + .unwrap_or(&provider_config.provider_name) + .trim(); + if provider_selector.is_empty() { + return Ok(None); + } + + let api_key_provider_service = app.state::(); + let Some(fallback_provider_config) = resolve_runtime_provider_auth_recovery_config( + app, + db, + api_key_provider_service.inner(), + request, + provider_selector, + &provider_config.model_name, + ) + .await? + else { + return Ok(None); + }; + + tracing::warn!( + "[AsterAgent] 模型访问受限,自动回退同 provider 候选: session={}, provider={}, failed_model={}, fallback_model={}", + session_id, + provider_selector, + provider_config.model_name, + fallback_provider_config.model_name + ); + emit_runtime_side_event( + app, + &request.event_name, + timeline_recorder, + workspace_root, + RuntimeAgentEvent::Warning { + code: Some(RUNTIME_MODEL_PERMISSION_FALLBACK_WARNING_CODE.to_string()), + message: format!( + "当前模型暂不可用,已自动切换到同 Provider 的兼容候选模型 `{}` 后重试。", + fallback_provider_config.model_name + ), + }, + ); + + apply_runtime_turn_provider_config( + state, + db, + session_id, + Some(&fallback_provider_config), + ) + .await?; + + let mut fallback_request = request.clone(); + fallback_request.provider_config = Some(fallback_provider_config); + + execute_runtime_stream_attempt( + agent, + app, + db, + &fallback_request, + timeline_recorder, + run_observation, + runtime_memory_config, + session_id, + workspace_root, + workspace_id, + thread_id, + turn_id, + execution_profile, + request_metadata, + provider_continuation_capability, + build_session_config(), + cancel_token.clone(), + request_tool_policy, + ) + .await + .map(Some) + .map_err(|fallback_error| fallback_error.message) + } + .await; + + match recovery_result { + Ok(Some(assistant_output)) => Ok(assistant_output), + Ok(None) => Err(primary_error.message), + Err(recovery_error) => Err(recovery_error), + } + } Err(primary_error) => Err(primary_error.message), }; @@ -3924,6 +4066,7 @@ async fn execute_aster_chat_request( request.session_id, request.event_name ); + emit_submit_accepted_runtime_status(app, &request.event_name); execute_runtime_turn_pipeline( app, @@ -6597,6 +6740,69 @@ mod tests { ); } + #[test] + fn collect_runtime_request_resolution_side_events_should_cover_generation_topic_current_chain() + { + let metadata = json!({ + "lime_runtime": { + "task_profile": { + "kind": "generation_topic", + "source": "auxiliary_generation_topic", + "traits": ["service_model_slot"], + "serviceModelSlot": "generation_topic" + }, + "routing_decision": { + "routingMode": "single_candidate", + "decisionSource": "service_model_setting", + "decisionReason": "命中 service_models.generation_topic", + "selectedProvider": "openai", + "selectedModel": "gpt-5.4-mini", + "candidateCount": 1, + "fallbackChain": [] + }, + "limit_state": { + "status": "single_candidate_only", + "singleCandidateOnly": true, + "providerLocked": false, + "settingsLocked": true, + "oemLocked": false, + "candidateCount": 1 + }, + "cost_state": { + "status": "estimated", + "estimatedCostClass": "low" + } + } + }); + + let events = collect_runtime_request_resolution_side_events(Some(&metadata)) + .into_iter() + .map(|event| { + serde_json::to_value(event) + .ok() + .and_then(|value| { + value + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + }) + .expect("应能序列化 runtime event") + }) + .collect::>(); + + assert_eq!( + events, + vec![ + "task_profile_resolved".to_string(), + "candidate_set_resolved".to_string(), + "routing_decision_made".to_string(), + "limit_state_updated".to_string(), + "single_candidate_only".to_string(), + "cost_estimated".to_string(), + ] + ); + } + #[test] fn collect_runtime_request_resolution_side_events_should_emit_routing_not_possible() { let metadata = json!({ @@ -6666,4 +6872,25 @@ mod tests { assert_eq!(event_type, "quota_low"); } + + #[test] + fn build_submit_accepted_runtime_status_should_use_preparing_copy() { + let status = build_submit_accepted_runtime_status(); + + assert_eq!(status.phase, "preparing"); + assert_eq!(status.title, "已接收请求,正在准备执行"); + assert_eq!( + status.detail, + "系统正在初始化本轮执行环境并整理上下文,稍后会继续返回更详细进度。" + ); + assert_eq!( + status.checkpoints, + vec![ + "请求已进入运行时主链".to_string(), + "正在准备工作区与会话上下文".to_string(), + "等待后续详细执行事件".to_string(), + ] + ); + assert!(status.metadata.is_none()); + } } diff --git a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs index 757abf94a..6ab9f7e34 100644 --- a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs @@ -305,9 +305,10 @@ pub(crate) fn update_runtime_session_execution_strategy_internal( pub(crate) fn list_runtime_sessions_internal( db: &DbConnection, + include_archived: bool, ) -> Result, String> { tracing::info!("[AsterAgent] 列出会话"); - AsterAgentWrapper::list_sessions_sync(db) + AsterAgentWrapper::list_sessions_sync(db, include_archived) } pub(crate) fn rename_runtime_session_internal( diff --git a/src-tauri/src/commands/aster_agent_cmd/tests.rs b/src-tauri/src/commands/aster_agent_cmd/tests.rs index 434c9ff70..db92f7684 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tests.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tests.rs @@ -4,7 +4,9 @@ mod tests { use crate::commands::aster_agent_cmd::action_runtime::{ build_runtime_action_scope, build_runtime_action_session_config, }; - use crate::commands::aster_agent_cmd::dto::AgentRuntimeActionScope; + use crate::commands::aster_agent_cmd::dto::{ + AgentRuntimeActionScope, AgentRuntimeListSessionsRequest, + }; use crate::commands::aster_agent_cmd::service_skill_launch::build_service_skill_preload_tool_projection; use crate::commands::aster_agent_cmd::tool_runtime::{ append_fast_chat_request_tool_policy_session_permissions, @@ -3287,6 +3289,17 @@ mod tests { ); } + #[test] + fn test_agent_runtime_list_sessions_request_deserializes_include_archived_alias() { + let request: AgentRuntimeListSessionsRequest = + serde_json::from_value(serde_json::json!({ + "includeArchived": true + })) + .expect("request should deserialize"); + + assert_eq!(request.include_archived, Some(true)); + } + #[test] fn test_agent_runtime_update_session_request_deserializes_recent_access_mode_aliases() { let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({ @@ -3314,6 +3327,18 @@ mod tests { ); } + #[test] + fn test_agent_runtime_update_session_request_deserializes_archived_alias() { + let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({ + "sessionId": "session-1", + "isArchived": true + })) + .expect("request should deserialize"); + + assert_eq!(request.session_id, "session-1"); + assert_eq!(request.archived, Some(true)); + } + #[test] fn test_agent_runtime_update_session_request_deserializes_recent_team_selection_aliases() { let request: AgentRuntimeUpdateSessionRequest = serde_json::from_value(serde_json::json!({ diff --git a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs index a6b103ee4..b9c2947ce 100644 --- a/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs +++ b/src-tauri/src/commands/aster_agent_cmd/tool_runtime/creation_tools.rs @@ -480,6 +480,7 @@ fn canonicalize_image_task_alias_fields(record: &mut serde_json::Map, + #[serde(default, alias = "title_generation_result")] + title_generation_result: Option, #[serde(default)] mode: Option, #[serde(default, alias = "raw_text")] @@ -689,6 +692,22 @@ fn image_task_input_schema() -> serde_json::Value { "title", serde_json::json!({ "type": "string", "description": "任务标题(可选)。" }), ); + insert_property( + "titleGenerationResult", + serde_json::json!({ + "type": ["object", "null"], + "description": "辅助标题生成的 runtime 诊断快照(可选)。", + "additionalProperties": true + }), + ); + insert_property( + "title_generation_result", + serde_json::json!({ + "type": ["object", "null"], + "description": "辅助标题生成的 runtime 诊断快照(snake_case 兼容,可选)。", + "additionalProperties": true + }), + ); insert_property( "mode", serde_json::json!({ "type": "string", "description": "任务模式 generate/edit/variation(可选)。" }), @@ -864,6 +883,7 @@ fn build_image_generation_task_request( let ImageTaskInput { prompt, title, + title_generation_result, mode, raw_text, layout_hint, @@ -926,6 +946,7 @@ fn build_image_generation_task_request( project_root_path: context.working_directory.to_string_lossy().to_string(), prompt, title, + title_generation_result, mode, raw_text, layout_hint, @@ -1550,6 +1571,14 @@ mod tests { ImageTaskInput { prompt: "未来感青柠实验室".to_string(), title: Some("青柠主视觉".to_string()), + title_generation_result: Some(serde_json::json!({ + "title": "青柠主视觉", + "sessionId": "title-session-1", + "executionRuntime": { + "route": "auxiliary.generate_title" + }, + "usedFallback": false + })), mode: Some("generate".to_string()), raw_text: Some("@配图 未来感青柠实验室".to_string()), layout_hint: None, @@ -1595,6 +1624,17 @@ mod tests { Some("content-image-compat-1") ); assert_eq!(request.entry_source.as_deref(), Some("at_image_command")); + assert_eq!( + request.title_generation_result, + Some(serde_json::json!({ + "title": "青柠主视觉", + "sessionId": "title-session-1", + "executionRuntime": { + "route": "auxiliary.generate_title" + }, + "usedFallback": false + })) + ); let output = create_image_generation_task_artifact_inner(request).expect("create image artifact"); @@ -1653,6 +1693,10 @@ mod tests { fn image_task_input_should_accept_snake_case_fields_from_skill_context() { let input: ImageTaskInput = serde_json::from_value(serde_json::json!({ "prompt": "未来感青柠实验室", + "title_generation_result": { + "title": "未来感青柠实验室", + "session_id": "title-session-2" + }, "raw_text": "@配图 未来感青柠实验室", "layout_hint": "storyboard_3x3", "aspect_ratio": "1:1", @@ -1675,6 +1719,13 @@ mod tests { .expect("parse snake_case image task input"); assert_eq!(input.raw_text.as_deref(), Some("@配图 未来感青柠实验室")); + assert_eq!( + input.title_generation_result, + Some(serde_json::json!({ + "title": "未来感青柠实验室", + "session_id": "title-session-2" + })) + ); assert_eq!(input.layout_hint.as_deref(), Some("storyboard_3x3")); assert_eq!(input.aspect_ratio.as_deref(), Some("1:1")); assert_eq!(input.count, Some(9)); @@ -1764,6 +1815,8 @@ mod tests { let normalized = normalize_flat_image_task_tool_params(serde_json::json!({ "prompt": "三国主要人物", "count": "9", + "titleGenerationResult": { "title": "三国主要人物", "sessionId": "title-camel" }, + "title_generation_result": { "title": "三国主要人物", "sessionId": "title-snake" }, "providerId": "custom-provider-camel", "provider_id": "custom-provider-snake", "model": "gpt-images-2", @@ -1789,9 +1842,14 @@ mod tests { record.get("anchor_hint"), Some(&serde_json::json!("section-top")) ); + assert_eq!( + record.get("title_generation_result"), + Some(&serde_json::json!({ "title": "三国主要人物", "sessionId": "title-snake" })) + ); assert!(!record.contains_key("providerId")); assert!(!record.contains_key("projectId")); assert!(!record.contains_key("anchorHint")); + assert!(!record.contains_key("titleGenerationResult")); assert!(!record.contains_key("referenceImages")); assert!(!record.contains_key("skillInputImages")); @@ -1800,6 +1858,10 @@ mod tests { assert_eq!(input.provider_id.as_deref(), Some("custom-provider-snake")); assert_eq!(input.project_id.as_deref(), Some("project-snake")); assert_eq!(input.anchor_hint.as_deref(), Some("section-top")); + assert_eq!( + input.title_generation_result, + Some(serde_json::json!({ "title": "三国主要人物", "sessionId": "title-snake" })) + ); assert!(input.reference_images.is_empty()); } @@ -1812,6 +1874,7 @@ mod tests { for field in [ "raw_text", + "title_generation_result", "layout_hint", "aspect_ratio", "provider_id", diff --git a/src-tauri/src/commands/media_task_cmd.rs b/src-tauri/src/commands/media_task_cmd.rs index d8065755e..7c2495805 100644 --- a/src-tauri/src/commands/media_task_cmd.rs +++ b/src-tauri/src/commands/media_task_cmd.rs @@ -44,6 +44,8 @@ pub struct CreateImageGenerationTaskArtifactRequest { pub prompt: String, #[serde(default)] pub title: Option, + #[serde(default, alias = "title_generation_result")] + pub title_generation_result: Option, #[serde(default)] pub mode: Option, #[serde(default)] @@ -220,6 +222,15 @@ fn normalize_reference_images(reference_images: Vec) -> Vec { normalized } +fn normalize_optional_json_object(value: Option) -> Option { + match value { + Some(serde_json::Value::Object(record)) if !record.is_empty() => { + Some(serde_json::Value::Object(record)) + } + _ => None, + } +} + fn normalize_storyboard_slots( storyboard_slots: Vec, ) -> Vec { @@ -588,6 +599,8 @@ pub(crate) fn create_image_generation_task_artifact_inner( let anchor_hint = normalize_optional_string(request.anchor_hint.clone()); let anchor_section_title = normalize_optional_string(request.anchor_section_title.clone()); let anchor_text = normalize_optional_string(request.anchor_text.clone()); + let title_generation_result = + normalize_optional_json_object(request.title_generation_result.clone()); let target_output_id = normalize_optional_string(request.target_output_id.clone()); let target_output_ref_id = normalize_optional_string(request.target_output_ref_id.clone()); let normalized_reference_images = normalize_reference_images(request.reference_images.clone()); @@ -630,6 +643,7 @@ pub(crate) fn create_image_generation_task_artifact_inner( "anchor_hint": anchor_hint, "anchor_section_title": anchor_section_title, "anchor_text": anchor_text, + "title_generation_result": title_generation_result, "target_output_id": target_output_id, "target_output_ref_id": target_output_ref_id, "reference_images": normalized_reference_images, @@ -774,6 +788,15 @@ mod tests { project_root_path: temp_dir.path().to_string_lossy().to_string(), prompt: "未来感青柠实验室".to_string(), title: Some("青柠主视觉".to_string()), + title_generation_result: Some(json!({ + "title": "青柠主视觉", + "sessionId": "title-session-1", + "executionRuntime": { + "route": "auxiliary.generate_title" + }, + "usedFallback": false, + "fallbackReason": null + })), mode: Some("variation".to_string()), raw_text: Some("@配图 变体 #img-1 未来感青柠实验室".to_string()), layout_hint: Some("storyboard_3x3".to_string()), @@ -822,6 +845,15 @@ mod tests { project_root_path: temp_dir.path().to_string_lossy().to_string(), prompt: "未来感青柠实验室".to_string(), title: Some("青柠主视觉".to_string()), + title_generation_result: Some(json!({ + "title": "青柠主视觉", + "sessionId": "title-session-2", + "executionRuntime": { + "route": "auxiliary.generate_title" + }, + "usedFallback": false, + "fallbackReason": null + })), mode: Some("variation".to_string()), raw_text: Some("@配图 变体 #img-1 未来感青柠实验室".to_string()), layout_hint: Some("storyboard_3x3".to_string()), @@ -875,6 +907,18 @@ mod tests { first.record.payload.get("entry_source"), Some(&json!("at_image_command")) ); + assert_eq!( + first.record.payload.get("title_generation_result"), + Some(&json!({ + "title": "青柠主视觉", + "sessionId": "title-session-1", + "executionRuntime": { + "route": "auxiliary.generate_title" + }, + "usedFallback": false, + "fallbackReason": null + })) + ); assert_eq!( first.record.payload.get("reference_images"), Some(&json!(["https://example.com/reference-a.png"])) @@ -958,6 +1002,7 @@ mod tests { project_root_path: temp_dir.path().to_string_lossy().to_string(), prompt: "用于正文的未来感实验室配图".to_string(), title: Some("正文配图".to_string()), + title_generation_result: None, mode: Some("generate".to_string()), raw_text: Some("@配图 生成 用于正文的未来感实验室配图".to_string()), layout_hint: None, @@ -1019,6 +1064,7 @@ mod tests { project_root_path: temp_dir.path().to_string_lossy().to_string(), prompt: "未来感青柠实验室".to_string(), title: Some("青柠主视觉".to_string()), + title_generation_result: None, mode: Some("generate".to_string()), raw_text: Some("@配图 生成 未来感青柠实验室".to_string()), layout_hint: None, @@ -1057,6 +1103,7 @@ mod tests { project_root_path: temp_dir.path().to_string_lossy().to_string(), prompt: "未来感青柠实验室".to_string(), title: Some("青柠主视觉".to_string()), + title_generation_result: None, mode: Some("generate".to_string()), raw_text: Some("@配图 生成 未来感青柠实验室".to_string()), layout_hint: None, @@ -1098,6 +1145,7 @@ mod tests { project_root_path: temp_dir.path().to_string_lossy().to_string(), prompt: "未来感青柠实验室".to_string(), title: Some("青柠主视觉".to_string()), + title_generation_result: None, mode: Some("generate".to_string()), raw_text: Some("@配图 生成 未来感青柠实验室".to_string()), layout_hint: None, @@ -1265,6 +1313,7 @@ mod tests { project_root_path: temp_dir.path().to_string_lossy().to_string(), prompt: "未来感青柠实验室".to_string(), title: Some("青柠主视觉".to_string()), + title_generation_result: None, mode: Some("generate".to_string()), raw_text: Some("@配图 生成 未来感青柠实验室".to_string()), layout_hint: None, diff --git a/src-tauri/src/commands/persona_cmd.rs b/src-tauri/src/commands/persona_cmd.rs index b21506346..92647ef83 100644 --- a/src-tauri/src/commands/persona_cmd.rs +++ b/src-tauri/src/commands/persona_cmd.rs @@ -16,7 +16,7 @@ //! - Requirements 6.7: AI 一键生成人设 use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; use crate::agent::build_auxiliary_session_config_with_turn_context; use crate::agent::AsterAgentWrapper; @@ -28,6 +28,10 @@ use crate::commands::auxiliary_model_selection::{ use crate::database::DbConnection; use crate::models::project_model::{CreatePersonaRequest, Persona, PersonaTemplate, PersonaUpdate}; use crate::services::memory_profile_prompt_service::{build_memory_prompt, MemoryPromptContext}; +use crate::services::runtime_auxiliary_projection_service::{ + project_auxiliary_runtime_to_parent_session, AuxiliaryRuntimeProjectionInput, + AuxiliaryRuntimeProjectionResult, +}; use lime_agent::merge_system_prompt_with_runtime_agents; use lime_services::persona_service::PersonaService; @@ -293,9 +297,11 @@ pub struct GeneratedPersonaCommandResult { /// - 失败返回错误信息 #[tauri::command] pub async fn generate_persona( + app: AppHandle, agent_state: State<'_, crate::agent::AsterAgentState>, db: State<'_, DbConnection>, config_manager: State<'_, crate::config::GlobalConfigManagerState>, + session_id: Option, prompt: String, ) -> Result { use aster::conversation::message::Message; @@ -316,12 +322,12 @@ pub async fn generate_persona( ensure_browser_mcp_tools_registered(agent_state.inner(), &db).await?; // 创建临时会话 ID - let session_id = format!("persona-gen-{}", uuid::Uuid::new_v4()); + let auxiliary_session_id = format!("persona-gen-{}", uuid::Uuid::new_v4()); let provider_scope = prepare_auxiliary_provider_scope( &agent_state, &db, &config_manager, - &session_id, + &auxiliary_session_id, AuxiliaryServiceModelSlot::AgentMeta, &PERSONA_FALLBACK_PROVIDER_CHAIN, ) @@ -341,7 +347,7 @@ pub async fn generate_persona( let user_prompt = format!("{system_prompt}\n\n请为以下描述生成人设配置:{prompt}"); let result = async { - let cancel_token = agent_state.create_cancel_token(&session_id).await; + let cancel_token = agent_state.create_cancel_token(&auxiliary_session_id).await; let user_message = Message::user().with_text(&user_prompt); let base_runtime_prompt = merge_system_prompt_with_runtime_agents(None, None); @@ -366,7 +372,7 @@ pub async fn generate_persona( ], ); let session_config = build_auxiliary_session_config_with_turn_context( - &session_id, + &auxiliary_session_id, merged_prompt, true, build_auxiliary_turn_context_override(auxiliary_runtime_metadata), @@ -408,13 +414,13 @@ pub async fn generate_persona( } } Err(e) => { - agent_state.remove_cancel_token(&session_id).await; + agent_state.remove_cancel_token(&auxiliary_session_id).await; return Err(format!("AI 调用失败: {e}")); } } // 清理取消令牌 - agent_state.remove_cancel_token(&session_id).await; + agent_state.remove_cancel_token(&auxiliary_session_id).await; if full_content.is_empty() { return Err("AI 返回空内容".to_string()); @@ -432,13 +438,46 @@ pub async fn generate_persona( .await; let execution_runtime = - AsterAgentWrapper::get_runtime_session_execution_runtime(&db, &session_id).await; + AsterAgentWrapper::get_runtime_session_execution_runtime(&db, &auxiliary_session_id).await; provider_scope.restore(&agent_state, &db).await; - result.map(|persona| GeneratedPersonaCommandResult { + let result = result.map(|persona| GeneratedPersonaCommandResult { persona, - session_id: Some(session_id), + session_id: Some(auxiliary_session_id), execution_runtime, - }) + })?; + + if let (Some(parent_session_id), Some(auxiliary_session_id)) = ( + session_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + result.session_id.as_deref(), + ) { + if let Err(error) = project_auxiliary_runtime_to_parent_session( + &app, + &db, + AuxiliaryRuntimeProjectionInput { + parent_session_id: parent_session_id.to_string(), + auxiliary_session_id: auxiliary_session_id.to_string(), + execution_runtime: result.execution_runtime.clone(), + result: AuxiliaryRuntimeProjectionResult::PersonaGeneration { + persona: serde_json::to_value(&result.persona) + .unwrap_or(serde_json::Value::Null), + }, + }, + ) + .await + { + tracing::warn!( + "[Persona] 投影父会话辅助运行时失败,已降级继续: parent_session_id={}, auxiliary_session_id={}, error={}", + parent_session_id, + auxiliary_session_id, + error + ); + } + } + + Ok(result) } /// 解析 AI 返回的人设 JSON diff --git a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs index fb7681fe9..956acf87a 100644 --- a/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs +++ b/src-tauri/src/dev_bridge/dispatcher/agent_sessions.rs @@ -175,11 +175,15 @@ pub(super) async fn try_handle( )? } "agent_runtime_list_sessions" => { + let args = args_or_default(args); + let request: Option = + parse_optional_nested_arg(&args, "request")?; let db = app_handle.state::(); let logs = app_handle.state::(); serde_json::to_value( - crate::commands::aster_agent_cmd::agent_runtime_list_sessions(db, logs).await?, + crate::commands::aster_agent_cmd::agent_runtime_list_sessions(db, logs, request) + .await?, )? } "agent_runtime_get_session" => { diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index fc0a3b439..48d429cfa 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -31,6 +31,7 @@ pub mod memory_source_resolver_service; pub mod openclaw_service; pub mod runtime_agents_template_service; pub mod runtime_analysis_handoff_service; +pub mod runtime_auxiliary_projection_service; pub mod runtime_evidence_pack_service; pub mod runtime_file_checkpoint_service; pub mod runtime_handoff_artifact_service; diff --git a/src-tauri/src/services/runtime_analysis_handoff_service.rs b/src-tauri/src/services/runtime_analysis_handoff_service.rs index bd37b35ea..2106e1297 100644 --- a/src-tauri/src/services/runtime_analysis_handoff_service.rs +++ b/src-tauri/src/services/runtime_analysis_handoff_service.rs @@ -106,6 +106,11 @@ struct AnalysisContextSummary { thread_status: String, primary_blocking_kind: String, primary_blocking_summary: String, + routing_mode: String, + decision_source: String, + decision_reason: String, + estimated_cost_class: String, + fallback_chain: Vec, failure_modes: Vec, suite_tags: Vec, pending_request_count: usize, @@ -365,6 +370,39 @@ pub fn export_runtime_analysis_handoff( .unwrap_or_else(|| thread_read.status.clone()), primary_blocking_kind, primary_blocking_summary, + routing_mode: value_string( + input_payload + .pointer("/runtimeContext/runtimeFacts/routingMode") + .unwrap_or(&Value::Null), + ) + .or_else(|| normalize_optional_text(thread_read.routing_mode.clone())) + .unwrap_or_default(), + decision_source: value_string( + input_payload + .pointer("/runtimeContext/runtimeFacts/decisionSource") + .unwrap_or(&Value::Null), + ) + .or_else(|| normalize_optional_text(thread_read.decision_source.clone())) + .unwrap_or_default(), + decision_reason: value_string( + input_payload + .pointer("/runtimeContext/runtimeFacts/decisionReason") + .unwrap_or(&Value::Null), + ) + .or_else(|| normalize_optional_text(thread_read.decision_reason.clone())) + .unwrap_or_default(), + estimated_cost_class: value_string( + input_payload + .pointer("/runtimeContext/runtimeFacts/estimatedCostClass") + .unwrap_or(&Value::Null), + ) + .or_else(|| normalize_optional_text(thread_read.estimated_cost_class.clone())) + .unwrap_or_default(), + fallback_chain: value_string_list( + input_payload + .pointer("/runtimeContext/runtimeFacts/fallbackChain") + .unwrap_or(&Value::Null), + ), failure_modes: failure_modes.clone(), suite_tags: suite_tags.clone(), pending_request_count: thread_read.pending_requests.len(), @@ -1343,17 +1381,63 @@ mod tests { created_at: None, }), }), - task_kind: None, - service_model_slot: None, - routing_mode: None, - decision_source: None, - candidate_count: None, - capability_gap: None, - single_candidate_only: None, - limit_state: None, - estimated_cost_class: None, - cost_state: None, - limit_event: None, + task_kind: Some("generation_topic".to_string()), + service_model_slot: Some("planner".to_string()), + routing_mode: Some("fallback_chain".to_string()), + decision_source: Some("model_router".to_string()), + candidate_count: Some(2), + capability_gap: Some("vision".to_string()), + single_candidate_only: Some(false), + oem_policy: Some(json!({ + "quotaStatus": "low_credit", + "defaultModel": "oem/gpt-5.4-mini", + "offerState": "managed" + })), + runtime_summary: Some(json!({ + "decisionReason": "主路由能力不足,切到回退模型", + "capabilityGap": "vision", + "limitStatus": "soft_limited", + "estimatedCostClass": "low" + })), + decision_reason: Some("主路由能力不足,切到回退模型".to_string()), + fallback_chain: Some(vec![ + "openai:gpt-5.4".to_string(), + "openai:gpt-5.4-mini".to_string(), + ]), + auxiliary_task_runtime: Some(vec![ + json!({"route": "auxiliary.generate_title", "taskKind": "generation_topic"}), + ]), + limit_state: Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "soft_limited".to_string(), + single_candidate_only: false, + provider_locked: false, + settings_locked: false, + oem_locked: false, + candidate_count: 2, + capability_gap: Some("vision".to_string()), + notes: vec!["需要回退链".to_string()], + }), + estimated_cost_class: Some("low".to_string()), + cost_state: Some(lime_agent::SessionExecutionRuntimeCostState { + status: "estimated".to_string(), + estimated_cost_class: Some("low".to_string()), + input_per_million: None, + output_per_million: None, + cache_read_per_million: None, + cache_write_per_million: None, + currency: None, + estimated_total_cost: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + cached_input_tokens: None, + cache_creation_input_tokens: None, + }), + limit_event: Some(lime_agent::SessionExecutionRuntimeLimitEvent { + event_kind: "fallback_applied".to_string(), + message: "因能力缺口触发回退链".to_string(), + retryable: true, + }), } } diff --git a/src-tauri/src/services/runtime_auxiliary_projection_service.rs b/src-tauri/src/services/runtime_auxiliary_projection_service.rs new file mode 100644 index 000000000..04cb80dbf --- /dev/null +++ b/src-tauri/src/services/runtime_auxiliary_projection_service.rs @@ -0,0 +1,545 @@ +use crate::agent::AsterAgentWrapper; +use crate::database::DbConnection; +use crate::services::agent_timeline_service::AgentTimelineRecorder; +use lime_agent::{AgentArtifactSignal, AgentEvent as RuntimeAgentEvent, SessionExecutionRuntime}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Emitter}; +use uuid::Uuid; + +const AUXILIARY_RUNTIME_ARTIFACT_TYPE: &str = "auxiliary_runtime_projection"; +const AUXILIARY_RUNTIME_EVENT_PREFIX: &str = "agent_auxiliary_runtime_projection"; + +#[derive(Debug, Clone)] +pub enum AuxiliaryRuntimeProjectionResult { + TitleGeneration { + title: String, + used_fallback: bool, + fallback_reason: Option, + }, + PersonaGeneration { + persona: Value, + }, +} + +#[derive(Debug, Clone)] +pub struct AuxiliaryRuntimeProjectionInput { + pub parent_session_id: String, + pub auxiliary_session_id: String, + pub execution_runtime: Option, + pub result: AuxiliaryRuntimeProjectionResult, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AuxiliaryRuntimeProjectionKind { + TitleGeneration, + PersonaGeneration, +} + +impl AuxiliaryRuntimeProjectionKind { + fn as_str(self) -> &'static str { + match self { + Self::TitleGeneration => "title_generation", + Self::PersonaGeneration => "persona_generation", + } + } + + fn artifact_prefix(self) -> &'static str { + match self { + Self::TitleGeneration => "title-generation", + Self::PersonaGeneration => "persona-generation", + } + } + + fn item_title(self) -> &'static str { + match self { + Self::TitleGeneration => "辅助标题生成", + Self::PersonaGeneration => "辅助人设生成", + } + } + + fn snapshot_source(self) -> &'static str { + match self { + Self::TitleGeneration => "auxiliary.title_generation_result", + Self::PersonaGeneration => "auxiliary.generate_persona", + } + } + + fn runtime_route(self) -> &'static str { + match self { + Self::TitleGeneration => "auxiliary.generate_title", + Self::PersonaGeneration => "auxiliary.generate_persona", + } + } +} + +fn normalize_optional_text(value: Option) -> Option { + let trimmed = value?.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn truncate_chars(input: &str, max_chars: usize) -> String { + let mut chars = input.chars(); + let prefix: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{prefix}...") + } else { + prefix + } +} + +fn build_projection_turn_prompt( + kind: AuxiliaryRuntimeProjectionKind, + result: &AuxiliaryRuntimeProjectionResult, +) -> String { + let subject = match result { + AuxiliaryRuntimeProjectionResult::TitleGeneration { title, .. } => { + normalize_optional_text(Some(title.clone())) + } + AuxiliaryRuntimeProjectionResult::PersonaGeneration { persona } => read_json_string( + persona, + &[&["name"][..], &["personaName"][..], &["persona_name"][..]], + ), + }; + + match subject { + Some(subject) => format!("{} · {}", kind.item_title(), truncate_chars(&subject, 24)), + None => kind.item_title().to_string(), + } +} + +fn read_json_string(value: &Value, paths: &[&[&str]]) -> Option { + for path in paths { + let mut current = value; + let mut matched = true; + for segment in *path { + let Some(next) = current.get(*segment) else { + matched = false; + break; + }; + current = next; + } + if !matched { + continue; + } + if let Some(raw) = current.as_str() { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +fn build_projection_document( + kind: AuxiliaryRuntimeProjectionKind, + parent_session_id: &str, + auxiliary_session_id: &str, + execution_runtime: Option<&SessionExecutionRuntime>, + result: &AuxiliaryRuntimeProjectionResult, +) -> Value { + let mut execution_runtime_value = execution_runtime + .and_then(|runtime| serde_json::to_value(runtime).ok()) + .unwrap_or(Value::Null); + if let Value::Object(runtime_object) = &mut execution_runtime_value { + runtime_object + .entry("route".to_string()) + .or_insert_with(|| Value::String(kind.runtime_route().to_string())); + } + let result_payload = match result { + AuxiliaryRuntimeProjectionResult::TitleGeneration { + title, + used_fallback, + fallback_reason, + } => json!({ + "title": title, + "sessionId": auxiliary_session_id, + "usedFallback": used_fallback, + "fallbackReason": fallback_reason, + "executionRuntime": execution_runtime_value.clone() + }), + AuxiliaryRuntimeProjectionResult::PersonaGeneration { persona } => json!({ + "sessionId": auxiliary_session_id, + "persona": persona, + "executionRuntime": execution_runtime_value.clone() + }), + }; + let payload_key = match kind { + AuxiliaryRuntimeProjectionKind::TitleGeneration => "titleGenerationResult", + AuxiliaryRuntimeProjectionKind::PersonaGeneration => "personaGenerationResult", + }; + + json!({ + "schemaVersion": 1, + "artifactType": AUXILIARY_RUNTIME_ARTIFACT_TYPE, + "projectionKind": kind.as_str(), + "source": kind.snapshot_source(), + "parentSessionId": parent_session_id, + "auxiliarySessionId": auxiliary_session_id, + "executionRuntime": execution_runtime_value, + payload_key: result_payload + }) +} + +fn build_projection_artifact_metadata( + kind: AuxiliaryRuntimeProjectionKind, + parent_session_id: &str, + auxiliary_session_id: &str, + execution_runtime: Option<&SessionExecutionRuntime>, +) -> HashMap { + let mut metadata = HashMap::from([ + ( + "task_type".to_string(), + Value::String(AUXILIARY_RUNTIME_ARTIFACT_TYPE.to_string()), + ), + ( + "projection_kind".to_string(), + Value::String(kind.as_str().to_string()), + ), + ( + "source".to_string(), + Value::String(kind.snapshot_source().to_string()), + ), + ( + "parent_session_id".to_string(), + Value::String(parent_session_id.to_string()), + ), + ( + "session_id".to_string(), + Value::String(auxiliary_session_id.to_string()), + ), + ( + "lastUpdateSource".to_string(), + Value::String("auxiliary_runtime_projection".to_string()), + ), + ]); + + if let Some(runtime) = execution_runtime { + if let Some(task_kind) = runtime + .task_profile + .as_ref() + .and_then(|profile| normalize_optional_text(Some(profile.kind.clone()))) + { + metadata.insert("task_kind".to_string(), Value::String(task_kind)); + } + metadata.insert( + "route".to_string(), + Value::String(kind.runtime_route().to_string()), + ); + if let Some(runtime_source) = serde_json::to_value(runtime.source) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .and_then(|value| normalize_optional_text(Some(value))) + { + metadata.insert("runtime_source".to_string(), Value::String(runtime_source)); + } + if let Some(routing_mode) = runtime + .routing_decision + .as_ref() + .and_then(|decision| normalize_optional_text(Some(decision.routing_mode.clone()))) + { + metadata.insert("routing_mode".to_string(), Value::String(routing_mode)); + } + if let Some(decision_source) = runtime + .routing_decision + .as_ref() + .and_then(|decision| normalize_optional_text(Some(decision.decision_source.clone()))) + { + metadata.insert( + "decision_source".to_string(), + Value::String(decision_source), + ); + } + if let Some(estimated_cost_class) = runtime + .cost_state + .as_ref() + .and_then(|state| normalize_optional_text(state.estimated_cost_class.clone())) + { + metadata.insert( + "estimated_cost_class".to_string(), + Value::String(estimated_cost_class), + ); + } + } + + metadata +} + +fn resolve_projection_kind( + result: &AuxiliaryRuntimeProjectionResult, +) -> AuxiliaryRuntimeProjectionKind { + match result { + AuxiliaryRuntimeProjectionResult::TitleGeneration { .. } => { + AuxiliaryRuntimeProjectionKind::TitleGeneration + } + AuxiliaryRuntimeProjectionResult::PersonaGeneration { .. } => { + AuxiliaryRuntimeProjectionKind::PersonaGeneration + } + } +} + +fn build_projection_relative_path( + kind: AuxiliaryRuntimeProjectionKind, + parent_session_id: &str, + auxiliary_session_id: &str, +) -> String { + format!( + ".lime/harness/sessions/{parent_session_id}/auxiliary-runtime/{}-{auxiliary_session_id}.json", + kind.artifact_prefix() + ) +} + +fn build_projection_event_name(parent_session_id: &str) -> String { + format!("{AUXILIARY_RUNTIME_EVENT_PREFIX}:{parent_session_id}") +} + +fn record_and_emit_event( + app: &AppHandle, + event_name: &str, + recorder: &mut AgentTimelineRecorder, + workspace_root: &str, + event: RuntimeAgentEvent, +) { + if let Err(error) = recorder.record_runtime_event(app, event_name, &event, workspace_root) { + tracing::warn!( + "[AuxRuntimeProjection] 记录辅助运行时投影事件失败,已降级继续: {}", + error + ); + } + if let Err(error) = app.emit(event_name, &event) { + tracing::warn!( + "[AuxRuntimeProjection] 发送辅助运行时投影事件失败,已降级继续: {}", + error + ); + } +} + +pub async fn project_auxiliary_runtime_to_parent_session( + app: &AppHandle, + db: &DbConnection, + input: AuxiliaryRuntimeProjectionInput, +) -> Result, String> { + let Some(parent_session_id) = normalize_optional_text(Some(input.parent_session_id)) else { + return Ok(None); + }; + let Some(auxiliary_session_id) = normalize_optional_text(Some(input.auxiliary_session_id)) + else { + return Ok(None); + }; + + let parent_detail = + match AsterAgentWrapper::get_runtime_session_detail(db, &parent_session_id).await { + Ok(detail) => detail, + Err(error) => { + tracing::warn!( + "[AuxRuntimeProjection] 读取父会话详情失败,已跳过投影: session_id={}, error={}", + parent_session_id, + error + ); + return Ok(None); + } + }; + let Some(workspace_root) = normalize_optional_text(parent_detail.working_dir.clone()) else { + tracing::warn!( + "[AuxRuntimeProjection] 父会话缺少 working_dir,已跳过投影: session_id={}", + parent_session_id + ); + return Ok(None); + }; + let thread_id = parent_detail.thread_id.trim().to_string(); + if thread_id.is_empty() { + tracing::warn!( + "[AuxRuntimeProjection] 父会话缺少 thread_id,已跳过投影: session_id={}", + parent_session_id + ); + return Ok(None); + } + + let kind = resolve_projection_kind(&input.result); + let relative_path = + build_projection_relative_path(kind, &parent_session_id, &auxiliary_session_id); + let absolute_path = PathBuf::from(workspace_root.as_str()).join(relative_path.as_str()); + let document = build_projection_document( + kind, + &parent_session_id, + &auxiliary_session_id, + input.execution_runtime.as_ref(), + &input.result, + ); + let serialized_document = serde_json::to_string_pretty(&document) + .map_err(|error| format!("序列化辅助运行时投影失败: {error}"))?; + let metadata = build_projection_artifact_metadata( + kind, + &parent_session_id, + &auxiliary_session_id, + input.execution_runtime.as_ref(), + ); + let turn_id = format!("auxiliary-runtime-projection-{}", Uuid::new_v4()); + let event_name = build_projection_event_name(&parent_session_id); + let turn_prompt = build_projection_turn_prompt(kind, &input.result); + let mut recorder = AgentTimelineRecorder::create(db.clone(), thread_id, turn_id, turn_prompt) + .map_err(|error| format!("创建辅助运行时投影时间线失败: {error}"))?; + + let projection_result = (|| -> Result<(), String> { + if let Some(parent) = absolute_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建辅助运行时投影目录失败: {error}"))?; + } + fs::write(&absolute_path, serialized_document.as_bytes()) + .map_err(|error| format!("写入辅助运行时投影文件失败: {error}"))?; + + record_and_emit_event( + app, + &event_name, + &mut recorder, + workspace_root.as_str(), + RuntimeAgentEvent::ArtifactSnapshot { + artifact: AgentArtifactSignal { + artifact_id: format!( + "auxiliary-runtime:{}:{}", + kind.as_str(), + auxiliary_session_id + ), + file_path: relative_path.clone(), + content: Some(serialized_document.clone()), + metadata: Some(metadata), + }, + }, + ); + + Ok(()) + })(); + + match projection_result { + Ok(()) => { + if let Ok(events) = recorder.complete_turn_success() { + for event in events { + if let Err(error) = app.emit(&event_name, &event) { + tracing::warn!( + "[AuxRuntimeProjection] 发送辅助运行时投影完成事件失败,已降级继续: {}", + error + ); + } + } + } + Ok(Some(relative_path)) + } + Err(error) => { + if let Ok(events) = recorder.fail_turn(&error) { + for event in events { + if let Err(emit_error) = app.emit(&event_name, &event) { + tracing::warn!( + "[AuxRuntimeProjection] 发送辅助运行时投影失败事件失败,已降级继续: {}", + emit_error + ); + } + } + } + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_execution_runtime() -> SessionExecutionRuntime { + serde_json::from_value(json!({ + "session_id": "aux-session-1", + "source": "runtime_snapshot", + "task_profile": { + "kind": "generation_topic", + "source": "auxiliary_generation_topic" + }, + "routing_decision": { + "routingMode": "single_candidate", + "decisionSource": "service_model_setting", + "decisionReason": "命中辅助模型配置", + "candidateCount": 1 + }, + "cost_state": { + "status": "estimated", + "estimatedCostClass": "low" + } + })) + .expect("runtime") + } + + #[test] + fn build_projection_document_should_include_title_generation_payload() { + let document = build_projection_document( + AuxiliaryRuntimeProjectionKind::TitleGeneration, + "session-parent-1", + "title-gen-1", + Some(&sample_execution_runtime()), + &AuxiliaryRuntimeProjectionResult::TitleGeneration { + title: "城市夜景主视觉".to_string(), + used_fallback: false, + fallback_reason: None, + }, + ); + + assert_eq!( + document.get("artifactType").and_then(Value::as_str), + Some(AUXILIARY_RUNTIME_ARTIFACT_TYPE) + ); + assert_eq!( + document.get("projectionKind").and_then(Value::as_str), + Some("title_generation") + ); + assert_eq!( + document + .pointer("/titleGenerationResult/title") + .and_then(Value::as_str), + Some("城市夜景主视觉") + ); + assert_eq!( + document + .pointer("/executionRuntime/route") + .and_then(Value::as_str), + Some("auxiliary.generate_title") + ); + } + + #[test] + fn build_projection_document_should_include_persona_payload() { + let document = build_projection_document( + AuxiliaryRuntimeProjectionKind::PersonaGeneration, + "session-parent-1", + "persona-gen-1", + Some(&sample_execution_runtime()), + &AuxiliaryRuntimeProjectionResult::PersonaGeneration { + persona: json!({ + "name": "理性产品经理", + "style": "结构化", + "tone": "克制" + }), + }, + ); + + assert_eq!( + document.get("projectionKind").and_then(Value::as_str), + Some("persona_generation") + ); + assert_eq!( + document + .pointer("/personaGenerationResult/persona/name") + .and_then(Value::as_str), + Some("理性产品经理") + ); + assert_eq!( + document + .pointer("/executionRuntime/route") + .and_then(Value::as_str), + Some("auxiliary.generate_persona") + ); + } +} diff --git a/src-tauri/src/services/runtime_evidence_pack_service.rs b/src-tauri/src/services/runtime_evidence_pack_service.rs index 38b31f706..875c07ae6 100644 --- a/src-tauri/src/services/runtime_evidence_pack_service.rs +++ b/src-tauri/src/services/runtime_evidence_pack_service.rs @@ -134,6 +134,12 @@ struct RuntimeRequestTelemetrySummary { requests: Vec, } +#[derive(Debug, Clone, Default, PartialEq)] +struct RuntimeAuxiliaryRuntimeSnapshotSummary { + applicable_count: usize, + snapshots: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct RuntimeEvidenceSignalCoverageEntry { signal: &'static str, @@ -182,10 +188,13 @@ pub fn export_runtime_evidence_pack( let request_telemetry = collect_request_telemetry(detail, workspace_root.as_path()); let verification = collect_runtime_verification(detail, Some(workspace_root.as_path()), &recent_artifacts); + let auxiliary_runtime = + collect_auxiliary_runtime_snapshots(Some(workspace_root.as_path()), &recent_artifacts); let signal_coverage = build_signal_coverage( thread_read, &recent_artifacts, &request_telemetry, + &auxiliary_runtime, &verification, ); let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage); @@ -194,6 +203,7 @@ pub fn export_runtime_evidence_pack( thread_read, &recent_artifact_paths, &request_telemetry, + &auxiliary_runtime, &verification, &signal_coverage, &known_gaps, @@ -228,6 +238,7 @@ pub fn export_runtime_evidence_pack( workspace_root.as_path(), &recent_artifact_paths, &file_checkpoints.checkpoints, + &auxiliary_runtime, &observability_summary, &known_gaps, exported_at.as_str(), @@ -252,6 +263,7 @@ pub fn export_runtime_evidence_pack( thread_read, &recent_artifact_paths, &file_checkpoints.checkpoints, + &auxiliary_runtime, &observability_summary, &request_telemetry, &verification, @@ -326,11 +338,13 @@ pub(crate) fn build_runtime_evidence_sceneapp_snapshot( let request_telemetry = workspace_root .map(|root| collect_request_telemetry(detail, root)) .unwrap_or_default(); + let auxiliary_runtime = collect_auxiliary_runtime_snapshots(workspace_root, &recent_artifacts); let verification = collect_runtime_verification(detail, workspace_root, &recent_artifacts); let signal_coverage = build_signal_coverage( thread_read, &recent_artifacts, &request_telemetry, + &auxiliary_runtime, &verification, ); let known_gaps = build_known_gaps(&recent_artifacts, &signal_coverage); @@ -510,6 +524,7 @@ fn build_runtime_json( workspace_root: &Path, recent_artifacts: &[String], file_checkpoints: &[crate::commands::aster_agent_cmd::AgentRuntimeFileCheckpointSummary], + auxiliary_runtime: &RuntimeAuxiliaryRuntimeSnapshotSummary, observability_summary: &Value, known_gaps: &[String], exported_at: &str, @@ -534,6 +549,7 @@ fn build_runtime_json( }, "thread": { "status": thread_read.status, + "runtimeFacts": build_thread_runtime_facts_json(thread_read), "activeTurnId": thread_read.active_turn_id, "interruptState": thread_read.interrupt_state, "latestTurnStatus": thread_read.diagnostics.as_ref().and_then(|value| value.latest_turn_status.clone()), @@ -593,6 +609,7 @@ fn build_runtime_json( }) }).collect::>(), "observabilitySummary": observability_summary, + "auxiliaryRuntimeSnapshots": build_auxiliary_runtime_snapshots_json(auxiliary_runtime), "recentArtifacts": recent_artifacts, "fileCheckpointCount": file_checkpoints.len(), "fileCheckpoints": file_checkpoints, @@ -640,6 +657,7 @@ fn build_artifacts_json( thread_read: &AgentRuntimeThreadReadModel, recent_artifacts: &[String], file_checkpoints: &[crate::commands::aster_agent_cmd::AgentRuntimeFileCheckpointSummary], + auxiliary_runtime: &RuntimeAuxiliaryRuntimeSnapshotSummary, observability_summary: &Value, request_telemetry: &RuntimeRequestTelemetrySummary, verification: &RuntimeEvidenceVerificationSummary, @@ -653,7 +671,9 @@ fn build_artifacts_json( "artifactCount": recent_artifacts.len(), "fileCheckpointCount": file_checkpoints.len(), "fileCheckpoints": file_checkpoints, + "auxiliaryRuntimeSnapshots": build_auxiliary_runtime_snapshots_json(auxiliary_runtime), "observabilitySummary": observability_summary, + "threadRuntimeFacts": build_thread_runtime_facts_json(thread_read), "requests": { "pending": thread_read.pending_requests.iter().map(|item| { json!({ @@ -686,6 +706,16 @@ fn build_artifacts_json( .map_err(|error| format!("序列化 artifacts.json 失败: {error}")) } +fn build_auxiliary_runtime_snapshots_json( + summary: &RuntimeAuxiliaryRuntimeSnapshotSummary, +) -> Value { + json!({ + "applicableArtifactCount": summary.applicable_count, + "snapshotCount": summary.snapshots.len(), + "snapshots": summary.snapshots.clone() + }) +} + fn build_known_gaps( recent_artifacts: &[RuntimeRecentArtifact], signal_coverage: &[RuntimeEvidenceSignalCoverageEntry], @@ -870,6 +900,120 @@ fn request_log_matches_session( } } +fn build_thread_runtime_facts_json(thread_read: &AgentRuntimeThreadReadModel) -> Value { + json!({ + "taskKind": thread_read.task_kind, + "serviceModelSlot": thread_read.service_model_slot, + "routingMode": thread_read.routing_mode, + "decisionSource": thread_read.decision_source, + "candidateCount": thread_read.candidate_count, + "capabilityGap": thread_read.capability_gap, + "singleCandidateOnly": thread_read.single_candidate_only, + "decisionReason": thread_read.decision_reason, + "fallbackChain": thread_read.fallback_chain, + "estimatedCostClass": thread_read.estimated_cost_class, + "limitState": thread_read.limit_state, + "costState": thread_read.cost_state, + "limitEvent": thread_read.limit_event, + "runtimeSummary": thread_read.runtime_summary, + "oemPolicy": thread_read.oem_policy, + "auxiliaryTaskRuntime": thread_read.auxiliary_task_runtime + }) +} + +fn build_runtime_fact_signal_coverage( + thread_read: &AgentRuntimeThreadReadModel, +) -> Vec { + vec![ + RuntimeEvidenceSignalCoverageEntry { + signal: "decisionReason", + status: if normalize_optional_text(thread_read.decision_reason.clone()).is_some() { + "exported" + } else { + "missing" + }, + source: "thread_read.decision_reason", + detail: if normalize_optional_text(thread_read.decision_reason.clone()).is_some() { + "thread_read 已导出 decision_reason。".to_string() + } else { + "thread_read 缺少 decision_reason。".to_string() + }, + }, + RuntimeEvidenceSignalCoverageEntry { + signal: "fallbackChain", + status: if thread_read + .fallback_chain + .as_ref() + .is_some_and(|items| !items.is_empty()) + { + "exported" + } else { + "missing" + }, + source: "thread_read.fallback_chain", + detail: if thread_read + .fallback_chain + .as_ref() + .is_some_and(|items| !items.is_empty()) + { + "thread_read 已导出 fallback_chain。".to_string() + } else { + "thread_read 缺少 fallback_chain。".to_string() + }, + }, + RuntimeEvidenceSignalCoverageEntry { + signal: "oemPolicy", + status: if thread_read.oem_policy.is_some() { + "exported" + } else { + "missing" + }, + source: "thread_read.oem_policy", + detail: if thread_read.oem_policy.is_some() { + "thread_read 已导出 oem_policy。".to_string() + } else { + "thread_read 缺少 oem_policy。".to_string() + }, + }, + RuntimeEvidenceSignalCoverageEntry { + signal: "runtimeSummary", + status: if thread_read.runtime_summary.is_some() { + "exported" + } else { + "missing" + }, + source: "thread_read.runtime_summary", + detail: if thread_read.runtime_summary.is_some() { + "thread_read 已导出 runtime_summary。".to_string() + } else { + "thread_read 缺少 runtime_summary。".to_string() + }, + }, + RuntimeEvidenceSignalCoverageEntry { + signal: "auxiliaryTaskRuntime", + status: if thread_read + .auxiliary_task_runtime + .as_ref() + .is_some_and(|items| !items.is_empty()) + { + "exported" + } else { + "missing" + }, + source: "thread_read.auxiliary_task_runtime", + detail: if thread_read + .auxiliary_task_runtime + .as_ref() + .is_some_and(|items| !items.is_empty()) + { + "thread_read 已导出 auxiliary_task_runtime。".to_string() + } else { + "thread_read 缺少 auxiliary_task_runtime。".to_string() + }, + }, + ] +} + fn request_log_to_json(log: RequestLog) -> Value { json!({ "id": log.id, @@ -942,11 +1086,14 @@ fn build_runtime_observability_summary_json( thread_read: &AgentRuntimeThreadReadModel, recent_artifacts: &[String], request_telemetry: &RuntimeRequestTelemetrySummary, + auxiliary_runtime: &RuntimeAuxiliaryRuntimeSnapshotSummary, verification: &RuntimeEvidenceVerificationSummary, signal_coverage: &[RuntimeEvidenceSignalCoverageEntry], known_gaps: &[String], ) -> Value { let diagnostics = thread_read.diagnostics.as_ref(); + let mut signal_coverage = signal_coverage.to_vec(); + signal_coverage.extend(build_runtime_fact_signal_coverage(thread_read)); let mut payload = json!({ "schemaVersion": "v1", "correlation": { @@ -974,7 +1121,8 @@ fn build_runtime_observability_summary_json( "failedToolCallCount": diagnostics.map(|value| value.failed_tool_call_count).unwrap_or(0), "failedCommandCount": diagnostics.map(|value| value.failed_command_count).unwrap_or(0), "subagentCount": detail.child_subagent_sessions.len(), - "recentArtifactCount": recent_artifacts.len() + "recentArtifactCount": recent_artifacts.len(), + "auxiliaryRuntimeSnapshotCount": auxiliary_runtime.snapshots.len() }, "latest": { "warning": latest_warning_json(diagnostics), @@ -1014,6 +1162,40 @@ fn collect_runtime_verification( } } +fn collect_auxiliary_runtime_snapshots( + workspace_root: Option<&Path>, + recent_artifacts: &[RuntimeRecentArtifact], +) -> RuntimeAuxiliaryRuntimeSnapshotSummary { + let mut summary = RuntimeAuxiliaryRuntimeSnapshotSummary::default(); + + for artifact in recent_artifacts { + if !is_auxiliary_runtime_applicable(artifact) { + continue; + } + + summary.applicable_count += 1; + + let Some(workspace_root) = workspace_root else { + continue; + }; + + let absolute_path = resolve_workspace_path(workspace_root, artifact.path.as_str()); + let Ok(raw) = fs::read_to_string(&absolute_path) else { + continue; + }; + let Ok(document) = serde_json::from_str::(raw.as_str()) else { + continue; + }; + + if let Some(snapshot) = extract_auxiliary_runtime_snapshot(document, artifact.path.as_str()) + { + summary.snapshots.push(snapshot); + } + } + + summary +} + fn collect_artifact_validator_summary( workspace_root: Option<&Path>, recent_artifacts: &[RuntimeRecentArtifact], @@ -1158,6 +1340,7 @@ fn build_signal_coverage( thread_read: &AgentRuntimeThreadReadModel, recent_artifacts: &[RuntimeRecentArtifact], request_telemetry: &RuntimeRequestTelemetrySummary, + auxiliary_runtime: &RuntimeAuxiliaryRuntimeSnapshotSummary, verification: &RuntimeEvidenceVerificationSummary, ) -> Vec { let diagnostics = thread_read.diagnostics.as_ref(); @@ -1213,6 +1396,30 @@ fn build_signal_coverage( request_telemetry_entry, ]; + if auxiliary_runtime.applicable_count > 0 { + coverage.push(RuntimeEvidenceSignalCoverageEntry { + signal: "auxiliaryTaskRuntime", + status: if auxiliary_runtime.snapshots.is_empty() { + "known_gap" + } else { + "exported" + }, + source: "image_task.title_generation_result", + detail: if auxiliary_runtime.snapshots.is_empty() { + format!( + "当前检测到 {} 个图片任务工件,但未从稳定 task artifact 中提取到 title_generation_result.execution_runtime 快照。", + auxiliary_runtime.applicable_count + ) + } else { + format!( + "当前证据包已从 {} 个图片任务工件中导出 {} 条辅助标题生成 runtime 快照。", + auxiliary_runtime.applicable_count, + auxiliary_runtime.snapshots.len() + ) + }, + }); + } + if verification.artifact_validator.applicable { coverage.push(RuntimeEvidenceSignalCoverageEntry { signal: "artifactValidator", @@ -1503,6 +1710,278 @@ fn browser_evidence_record_outcome(record: &Value) -> Option { None } +fn is_auxiliary_runtime_applicable(artifact: &RuntimeRecentArtifact) -> bool { + let normalized_path = artifact.path.replace('\\', "/").to_ascii_lowercase(); + if normalized_path.contains(".lime/tasks/image_generate/") { + return true; + } + if normalized_path.contains("/auxiliary-runtime/") { + return true; + } + + artifact + .metadata + .as_ref() + .and_then(|metadata| { + read_json_string( + metadata, + &[ + &["task_type"][..], + &["taskType"][..], + &["type"][..], + &["artifactType"][..], + ], + ) + }) + .map(|value| { + value.eq_ignore_ascii_case("image_generate") + || value.eq_ignore_ascii_case("auxiliary_runtime_projection") + }) + .unwrap_or(false) +} + +fn extract_auxiliary_runtime_snapshot(document: Value, artifact_path: &str) -> Option { + if let Some(snapshot) = extract_auxiliary_runtime_projection_snapshot(&document, artifact_path) + { + return Some(snapshot); + } + + let title_generation_result = find_json_value_at_paths( + &document, + &[ + &["title_generation_result"][..], + &["titleGenerationResult"][..], + &["payload", "title_generation_result"][..], + &["payload", "titleGenerationResult"][..], + &["record", "payload", "title_generation_result"][..], + &["record", "payload", "titleGenerationResult"][..], + ], + )?; + let execution_runtime = find_json_value_at_paths( + title_generation_result, + &[ + &["execution_runtime"][..], + &["executionRuntime"][..], + &["runtime"][..], + ], + )? + .clone(); + + let session_id = read_json_string( + title_generation_result, + &[&["sessionId"][..], &["session_id"][..]], + ) + .or_else(|| { + read_json_string( + &execution_runtime, + &[&["session_id"][..], &["sessionId"][..]], + ) + }); + + Some(json!({ + "artifactPath": artifact_path, + "source": "image_task.title_generation_result", + "title": read_json_string(title_generation_result, &[&["title"][..]]), + "sessionId": session_id, + "usedFallback": read_json_bool( + title_generation_result, + &[&["usedFallback"][..], &["used_fallback"][..]] + ), + "fallbackReason": read_json_string( + title_generation_result, + &[&["fallbackReason"][..], &["fallback_reason"][..]] + ), + "route": read_json_string(&execution_runtime, &[&["route"][..]]), + "runtimeSource": read_json_string(&execution_runtime, &[&["source"][..]]), + "taskKind": read_json_string( + &execution_runtime, + &[ + &["task_profile", "kind"][..], + &["task_profile", "task_kind"][..], + &["taskProfile", "kind"][..], + &["taskProfile", "taskKind"][..], + &["task_kind"][..], + &["taskKind"][..], + ] + ), + "routingMode": read_json_string( + &execution_runtime, + &[ + &["routing_decision", "routingMode"][..], + &["routing_decision", "routing_mode"][..], + &["routingDecision", "routingMode"][..], + &["routingDecision", "routing_mode"][..], + &["routing_mode"][..], + &["routingMode"][..], + ] + ), + "decisionSource": read_json_string( + &execution_runtime, + &[ + &["routing_decision", "decisionSource"][..], + &["routing_decision", "decision_source"][..], + &["routingDecision", "decisionSource"][..], + &["routingDecision", "decision_source"][..], + &["decision_source"][..], + &["decisionSource"][..], + ] + ), + "estimatedCostClass": read_json_string( + &execution_runtime, + &[ + &["cost_state", "estimatedCostClass"][..], + &["cost_state", "estimated_cost_class"][..], + &["costState", "estimatedCostClass"][..], + &["costState", "estimated_cost_class"][..], + &["estimated_cost_class"][..], + &["estimatedCostClass"][..], + ] + ), + "executionRuntime": execution_runtime + })) +} + +fn extract_auxiliary_runtime_projection_snapshot( + document: &Value, + artifact_path: &str, +) -> Option { + let projection_kind = read_json_string( + document, + &[&["projectionKind"][..], &["projection_kind"][..]], + )?; + let execution_runtime = find_json_value_at_paths( + document, + &[&["executionRuntime"][..], &["execution_runtime"][..]], + ) + .cloned(); + let title_generation_result = find_json_value_at_paths( + document, + &[ + &["titleGenerationResult"][..], + &["title_generation_result"][..], + ], + ); + let persona_generation_result = find_json_value_at_paths( + document, + &[ + &["personaGenerationResult"][..], + &["persona_generation_result"][..], + ], + ); + + let session_id = read_json_string( + document, + &[ + &["auxiliarySessionId"][..], + &["auxiliary_session_id"][..], + &["sessionId"][..], + &["session_id"][..], + ], + ) + .or_else(|| { + execution_runtime.as_ref().and_then(|runtime| { + read_json_string(runtime, &[&["sessionId"][..], &["session_id"][..]]) + }) + }); + let source = read_json_string(document, &[&["source"][..]]).unwrap_or_else(|| { + if projection_kind.eq_ignore_ascii_case("persona_generation") { + "auxiliary.generate_persona".to_string() + } else { + "auxiliary.title_generation_result".to_string() + } + }); + let title = title_generation_result + .and_then(|result| read_json_string(result, &[&["title"][..]])) + .or_else(|| { + persona_generation_result.and_then(|result| { + read_json_string( + result, + &[ + &["persona", "name"][..], + &["personaName"][..], + &["persona_name"][..], + ], + ) + }) + }); + + Some(json!({ + "artifactPath": artifact_path, + "source": source, + "projectionKind": projection_kind, + "title": title, + "sessionId": session_id, + "usedFallback": title_generation_result.and_then(|result| { + read_json_bool(result, &[&["usedFallback"][..], &["used_fallback"][..]]) + }), + "fallbackReason": title_generation_result.and_then(|result| { + read_json_string( + result, + &[&["fallbackReason"][..], &["fallback_reason"][..]] + ) + }), + "route": execution_runtime.as_ref().and_then(|runtime| { + read_json_string(runtime, &[&["route"][..]]) + }), + "runtimeSource": execution_runtime.as_ref().and_then(|runtime| { + read_json_string(runtime, &[&["source"][..]]) + }), + "taskKind": execution_runtime.as_ref().and_then(|runtime| { + read_json_string( + runtime, + &[ + &["task_profile", "kind"][..], + &["task_profile", "task_kind"][..], + &["taskProfile", "kind"][..], + &["taskProfile", "taskKind"][..], + &["task_kind"][..], + &["taskKind"][..], + ] + ) + }), + "routingMode": execution_runtime.as_ref().and_then(|runtime| { + read_json_string( + runtime, + &[ + &["routing_decision", "routingMode"][..], + &["routing_decision", "routing_mode"][..], + &["routingDecision", "routingMode"][..], + &["routingDecision", "routing_mode"][..], + &["routing_mode"][..], + &["routingMode"][..], + ] + ) + }), + "decisionSource": execution_runtime.as_ref().and_then(|runtime| { + read_json_string( + runtime, + &[ + &["routing_decision", "decisionSource"][..], + &["routing_decision", "decision_source"][..], + &["routingDecision", "decisionSource"][..], + &["routingDecision", "decision_source"][..], + &["decision_source"][..], + &["decisionSource"][..], + ] + ) + }), + "estimatedCostClass": execution_runtime.as_ref().and_then(|runtime| { + read_json_string( + runtime, + &[ + &["cost_state", "estimatedCostClass"][..], + &["cost_state", "estimated_cost_class"][..], + &["costState", "estimatedCostClass"][..], + &["costState", "estimated_cost_class"][..], + &["estimated_cost_class"][..], + &["estimatedCostClass"][..], + ] + ) + }), + "executionRuntime": execution_runtime + })) +} + fn is_artifact_validator_applicable(artifact: &RuntimeRecentArtifact) -> bool { artifact.path.ends_with(".artifact.json") || artifact @@ -1610,6 +2089,37 @@ fn resolve_workspace_path(workspace_root: &Path, path: &str) -> PathBuf { } } +fn find_json_value_at_paths<'a>(value: &'a Value, paths: &[&[&str]]) -> Option<&'a Value> { + for path in paths { + if let Some(found) = find_json_value(value, path) { + return Some(found); + } + } + + None +} + +fn find_json_value<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + Some(current) +} + +fn read_json_string(value: &Value, paths: &[&[&str]]) -> Option { + let resolved = find_json_value_at_paths(value, paths)?; + match resolved { + Value::String(text) => normalize_optional_text(Some(text.clone())), + Value::Number(number) => Some(number.to_string()), + _ => None, + } +} + +fn read_json_bool(value: &Value, paths: &[&[&str]]) -> Option { + find_json_value_at_paths(value, paths).and_then(Value::as_bool) +} + fn is_browser_tool_name(tool_name: &str) -> bool { let normalized = tool_name.trim().to_ascii_lowercase(); normalized.contains("browser") @@ -1894,17 +2404,63 @@ mod tests { latest_pending_request: None, }, ), - task_kind: None, - service_model_slot: None, - routing_mode: None, - decision_source: None, - candidate_count: None, - capability_gap: None, - single_candidate_only: None, - limit_state: None, - estimated_cost_class: None, - cost_state: None, - limit_event: None, + task_kind: Some("generation_topic".to_string()), + service_model_slot: Some("planner".to_string()), + routing_mode: Some("fallback_chain".to_string()), + decision_source: Some("model_router".to_string()), + candidate_count: Some(2), + capability_gap: Some("vision".to_string()), + single_candidate_only: Some(false), + oem_policy: Some(json!({ + "quotaStatus": "low_credit", + "defaultModel": "oem/gpt-5.4-mini", + "offerState": "managed" + })), + runtime_summary: Some(json!({ + "decisionReason": "主路由能力不足,切到回退模型", + "capabilityGap": "vision", + "limitStatus": "soft_limited", + "estimatedCostClass": "low" + })), + decision_reason: Some("主路由能力不足,切到回退模型".to_string()), + fallback_chain: Some(vec![ + "openai:gpt-5.4".to_string(), + "openai:gpt-5.4-mini".to_string(), + ]), + auxiliary_task_runtime: Some(vec![ + json!({"route": "auxiliary.generate_title", "taskKind": "generation_topic"}), + ]), + limit_state: Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "soft_limited".to_string(), + single_candidate_only: false, + provider_locked: false, + settings_locked: false, + oem_locked: false, + candidate_count: 2, + capability_gap: Some("vision".to_string()), + notes: vec!["需要回退链".to_string()], + }), + estimated_cost_class: Some("low".to_string()), + cost_state: Some(lime_agent::SessionExecutionRuntimeCostState { + status: "estimated".to_string(), + estimated_cost_class: Some("low".to_string()), + input_per_million: None, + output_per_million: None, + cache_read_per_million: None, + cache_write_per_million: None, + currency: None, + estimated_total_cost: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + cached_input_tokens: None, + cache_creation_input_tokens: None, + }), + limit_event: Some(lime_agent::SessionExecutionRuntimeLimitEvent { + event_kind: "fallback_applied".to_string(), + message: "因能力缺口触发回退链".to_string(), + retryable: true, + }), } } @@ -1961,6 +2517,166 @@ mod tests { .expect("write unmatched request log"); } + fn write_image_task_fixture(root: &Path, relative_path: &str) { + let absolute_path = root.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + fs::create_dir_all( + absolute_path + .parent() + .expect("image task path should have parent"), + ) + .expect("create image task dir"); + fs::write( + absolute_path, + serde_json::to_string_pretty(&json!({ + "task_id": "task-image-1", + "task_type": "image_generate", + "task_family": "image", + "title": "城市夜景主视觉", + "summary": "城市夜景图片任务", + "payload": { + "prompt": "赛博朋克风城市夜景主视觉", + "title_generation_result": { + "title": "城市夜景主视觉", + "sessionId": "title-gen-1", + "usedFallback": false, + "fallbackReason": null, + "executionRuntime": { + "route": "auxiliary.generate_title", + "session_id": "title-gen-1", + "task_profile": { + "kind": "generation_topic", + "source": "auxiliary_generation_topic" + }, + "routing_decision": { + "routingMode": "single_candidate", + "decisionSource": "service_model_setting", + "candidateCount": 1 + }, + "cost_state": { + "status": "estimated", + "estimatedCostClass": "low" + } + } + } + }, + "status": "pending_submit", + "normalized_status": "pending", + "created_at": "2026-04-24T10:00:00Z", + "updated_at": null, + "submitted_at": null, + "started_at": null, + "completed_at": null, + "cancelled_at": null, + "idempotency_key": null, + "retry_count": 0, + "source_task_id": null, + "result": null, + "last_error": null, + "current_attempt_id": "attempt-1", + "attempts": [], + "relationships": {}, + "progress": {}, + "ui_hints": {} + })) + .expect("serialize image task"), + ) + .expect("write image task"); + } + + fn write_auxiliary_runtime_projection_fixture( + root: &Path, + relative_path: &str, + projection_kind: &str, + ) { + let absolute_path = root.join(relative_path.replace('/', std::path::MAIN_SEPARATOR_STR)); + fs::create_dir_all( + absolute_path + .parent() + .expect("auxiliary projection path should have parent"), + ) + .expect("create auxiliary projection dir"); + + let document = if projection_kind == "persona_generation" { + json!({ + "schemaVersion": 1, + "artifactType": "auxiliary_runtime_projection", + "projectionKind": "persona_generation", + "source": "auxiliary.generate_persona", + "parentSessionId": "session-1", + "auxiliarySessionId": "persona-gen-1", + "executionRuntime": { + "route": "auxiliary.generate_persona", + "session_id": "persona-gen-1", + "source": "runtime_snapshot", + "task_profile": { + "kind": "agent_meta", + "source": "auxiliary_agent_meta" + }, + "routing_decision": { + "routingMode": "single_candidate", + "decisionSource": "service_model_setting", + "candidateCount": 1 + }, + "cost_state": { + "status": "estimated", + "estimatedCostClass": "low" + } + }, + "personaGenerationResult": { + "sessionId": "persona-gen-1", + "persona": { + "name": "理性产品经理", + "description": "强调问题拆解与收益平衡", + "style": "结构化", + "tone": "克制", + "targetAudience": "团队负责人", + "forbiddenWords": ["绝对"], + "preferredWords": ["权衡"] + } + } + }) + } else { + json!({ + "schemaVersion": 1, + "artifactType": "auxiliary_runtime_projection", + "projectionKind": "title_generation", + "source": "auxiliary.title_generation_result", + "parentSessionId": "session-1", + "auxiliarySessionId": "title-gen-2", + "executionRuntime": { + "route": "auxiliary.generate_title", + "session_id": "title-gen-2", + "source": "runtime_snapshot", + "task_profile": { + "kind": "topic", + "source": "auxiliary_title_generation" + }, + "routing_decision": { + "routingMode": "single_candidate", + "decisionSource": "service_model_setting", + "candidateCount": 1 + }, + "cost_state": { + "status": "estimated", + "estimatedCostClass": "low" + } + }, + "titleGenerationResult": { + "title": "多模型调度方案", + "sessionId": "title-gen-2", + "usedFallback": false, + "fallbackReason": null + } + }) + }; + + fs::write( + absolute_path, + serde_json::to_string_pretty(&document).expect("serialize auxiliary projection"), + ) + .expect("write auxiliary projection"); + } + #[test] fn should_export_runtime_evidence_pack_to_workspace() { let temp_dir = TempDir::new().expect("temp dir"); @@ -2207,4 +2923,141 @@ mod tests { assert!(artifacts.contains("\"telemetry\"")); assert!(artifacts.contains("\"matchedRequestCount\": 0")); } + + #[test] + fn should_export_auxiliary_runtime_snapshots_from_image_task_artifact() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut detail = build_detail(); + let thread_read = build_thread_read(); + let image_task_relative_path = ".lime/tasks/image_generate/task-image-1.json"; + + write_request_telemetry_fixture(temp_dir.path()); + write_image_task_fixture(temp_dir.path(), image_task_relative_path); + + if let AgentThreadItemPayload::FileArtifact { path, metadata, .. } = + &mut detail.items[1].payload + { + *path = image_task_relative_path.to_string(); + *metadata = Some(json!({ + "task_type": "image_generate" + })); + } + + let result = + export_runtime_evidence_pack(&detail, &thread_read, temp_dir.path()).expect("export"); + + assert!(result + .known_gaps + .iter() + .all(|gap| !gap.contains("title_generation_result.execution_runtime"))); + + let runtime_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/runtime.json"); + let artifacts_path = temp_dir + .path() + .join(".lime/harness/sessions/session-1/evidence/artifacts.json"); + + let runtime = serde_json::from_str::( + fs::read_to_string(runtime_path).expect("runtime").as_str(), + ) + .expect("parse runtime json"); + let runtime_snapshots = runtime + .pointer("/auxiliaryRuntimeSnapshots/snapshots") + .and_then(Value::as_array) + .expect("runtime snapshots should exist"); + assert_eq!( + runtime + .pointer("/auxiliaryRuntimeSnapshots/applicableArtifactCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer("/auxiliaryRuntimeSnapshots/snapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + runtime + .pointer("/observabilitySummary/counts/auxiliaryRuntimeSnapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + let runtime_snapshot = runtime_snapshots + .first() + .expect("runtime snapshot should exist"); + assert_eq!( + runtime_snapshot.get("artifactPath").and_then(Value::as_str), + Some(image_task_relative_path) + ); + assert_eq!( + runtime_snapshot.get("source").and_then(Value::as_str), + Some("image_task.title_generation_result") + ); + assert_eq!( + runtime_snapshot.get("title").and_then(Value::as_str), + Some("城市夜景主视觉") + ); + assert_eq!( + runtime_snapshot.get("sessionId").and_then(Value::as_str), + Some("title-gen-1") + ); + assert_eq!( + runtime_snapshot.get("route").and_then(Value::as_str), + Some("auxiliary.generate_title") + ); + assert_eq!( + runtime_snapshot.get("taskKind").and_then(Value::as_str), + Some("generation_topic") + ); + assert_eq!( + runtime_snapshot.get("routingMode").and_then(Value::as_str), + Some("single_candidate") + ); + assert_eq!( + runtime_snapshot + .get("decisionSource") + .and_then(Value::as_str), + Some("service_model_setting") + ); + assert_eq!( + runtime_snapshot + .get("estimatedCostClass") + .and_then(Value::as_str), + Some("low") + ); + assert_eq!( + runtime + .pointer("/observabilitySummary/signalCoverage") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("signal").and_then(Value::as_str) == Some("auxiliaryTaskRuntime") + }) + }) + .and_then(|item| item.get("status")) + .and_then(Value::as_str), + Some("exported") + ); + + let artifacts = serde_json::from_str::( + fs::read_to_string(artifacts_path) + .expect("artifacts") + .as_str(), + ) + .expect("parse artifacts json"); + assert_eq!( + artifacts + .pointer("/auxiliaryRuntimeSnapshots/snapshotCount") + .and_then(Value::as_u64), + Some(1) + ); + assert_eq!( + artifacts + .pointer("/auxiliaryRuntimeSnapshots/snapshots/0/route") + .and_then(Value::as_str), + Some("auxiliary.generate_title") + ); + } } diff --git a/src-tauri/src/services/runtime_handoff_artifact_service.rs b/src-tauri/src/services/runtime_handoff_artifact_service.rs index 41b11acdb..a239edc08 100644 --- a/src-tauri/src/services/runtime_handoff_artifact_service.rs +++ b/src-tauri/src/services/runtime_handoff_artifact_service.rs @@ -297,6 +297,17 @@ fn build_plan_markdown( } } let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 运行时事实"); + let _ = writeln!(markdown); + let runtime_fact_lines = build_runtime_fact_lines(thread_read); + if runtime_fact_lines.is_empty() { + let _ = writeln!(markdown, "- 当前没有可导出的任务 / 路由 / 经济事实。"); + } else { + for line in runtime_fact_lines { + let _ = writeln!(markdown, "- {line}"); + } + } + let _ = writeln!(markdown); let _ = writeln!(markdown, "## 最近产物"); let _ = writeln!(markdown); if recent_artifacts.is_empty() { @@ -388,6 +399,7 @@ fn build_progress_json( }).collect::>(), "artifacts": recent_artifacts, "latestTurnSummary": latest_turn_summary, + "runtimeFacts": build_runtime_facts_json(thread_read), "diagnostics": { "primaryBlockingKind": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_kind.clone()), "primaryBlockingSummary": thread_read.diagnostics.as_ref().and_then(|value| value.primary_blocking_summary.clone()), @@ -495,6 +507,17 @@ fn build_handoff_markdown( } } let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 运行时事实"); + let _ = writeln!(markdown); + let runtime_fact_lines = build_runtime_fact_lines(thread_read); + if runtime_fact_lines.is_empty() { + let _ = writeln!(markdown, "- 当前没有可导出的任务 / 路由 / 经济事实。"); + } else { + for line in runtime_fact_lines { + let _ = writeln!(markdown, "- {line}"); + } + } + let _ = writeln!(markdown); let _ = writeln!(markdown, "## 审查与恢复建议"); let _ = writeln!(markdown); if review_actions.is_empty() { @@ -595,6 +618,17 @@ fn build_review_summary_markdown( ); } let _ = writeln!(markdown); + let _ = writeln!(markdown, "## 运行时事实"); + let _ = writeln!(markdown); + let runtime_fact_lines = build_runtime_fact_lines(thread_read); + if runtime_fact_lines.is_empty() { + let _ = writeln!(markdown, "- 当前没有可导出的任务 / 路由 / 经济事实。"); + } else { + for line in runtime_fact_lines { + let _ = writeln!(markdown, "- {line}"); + } + } + let _ = writeln!(markdown); let _ = writeln!(markdown, "## 建议动作"); let _ = writeln!(markdown); if review_actions.is_empty() { @@ -621,6 +655,142 @@ fn build_review_summary_markdown( markdown } +fn build_runtime_fact_lines(thread_read: &AgentRuntimeThreadReadModel) -> Vec { + let mut lines = Vec::new(); + + if let Some(task_kind) = normalize_optional_text(thread_read.task_kind.clone()) { + lines.push(format!("任务类型:{task_kind}")); + } + if let Some(service_model_slot) = + normalize_optional_text(thread_read.service_model_slot.clone()) + { + lines.push(format!("服务模型槽位:{service_model_slot}")); + } + if let Some(routing_mode) = normalize_optional_text(thread_read.routing_mode.clone()) { + lines.push(format!("路由模式:{routing_mode}")); + } + if let Some(decision_source) = normalize_optional_text(thread_read.decision_source.clone()) { + lines.push(format!("决策来源:{decision_source}")); + } + if let Some(candidate_count) = thread_read.candidate_count { + lines.push(format!("候选数:{candidate_count}")); + } + if let Some(capability_gap) = normalize_optional_text(thread_read.capability_gap.clone()) { + lines.push(format!("能力缺口:{capability_gap}")); + } + if let Some(decision_reason) = normalize_optional_text(thread_read.decision_reason.clone()) { + lines.push(format!("决策解释:{decision_reason}")); + } + if let Some(fallback_chain) = thread_read + .fallback_chain + .as_ref() + .filter(|items| !items.is_empty()) + { + lines.push(format!("回退链:{}", fallback_chain.join(" -> "))); + } + if let Some(estimated_cost_class) = + normalize_optional_text(thread_read.estimated_cost_class.clone()) + { + lines.push(format!("预估成本等级:{estimated_cost_class}")); + } + if let Some(limit_state) = thread_read.limit_state.as_ref() { + lines.push(format!("额度状态:{}", limit_state.status)); + if !limit_state.notes.is_empty() { + lines.push(format!("额度备注:{}", limit_state.notes.join(";"))); + } + } + if let Some(cost_state) = thread_read.cost_state.as_ref() { + lines.push(format!("成本状态:{}", cost_state.status)); + if let Some(value) = normalize_optional_text(cost_state.estimated_cost_class.clone()) { + lines.push(format!("成本等级:{value}")); + } + } + if let Some(limit_event) = thread_read.limit_event.as_ref() { + lines.push(format!("最近额度事件:{}", limit_event.event_kind)); + if let Some(message) = normalize_optional_text(Some(limit_event.message.clone())) { + lines.push(format!("额度事件说明:{message}")); + } + } + if let Some(runtime_summary) = thread_read.runtime_summary.as_ref() { + if let Some(value) = runtime_summary + .get("decisionReason") + .and_then(|value| value.as_str()) + { + lines.push(format!("运行时摘要 / 决策解释:{value}")); + } + if let Some(value) = runtime_summary + .get("capabilityGap") + .and_then(|value| value.as_str()) + { + lines.push(format!("运行时摘要 / 能力缺口:{value}")); + } + if let Some(value) = runtime_summary + .get("limitStatus") + .and_then(|value| value.as_str()) + { + lines.push(format!("运行时摘要 / 额度:{value}")); + } + if let Some(value) = runtime_summary + .get("estimatedCostClass") + .and_then(|value| value.as_str()) + { + lines.push(format!("运行时摘要 / 成本:{value}")); + } + } + if let Some(oem_policy) = thread_read.oem_policy.as_ref() { + if let Some(value) = oem_policy + .get("quotaStatus") + .and_then(|value| value.as_str()) + { + lines.push(format!("OEM 额度状态:{value}")); + } + if let Some(value) = oem_policy + .get("defaultModel") + .and_then(|value| value.as_str()) + { + lines.push(format!("OEM 模型:{value}")); + } + if let Some(value) = oem_policy + .get("offerState") + .and_then(|value| value.as_str()) + { + lines.push(format!("OEM 策略状态:{value}")); + } + } + if let Some(auxiliary_runtime) = thread_read + .auxiliary_task_runtime + .as_ref() + .filter(|items| !items.is_empty()) + { + lines.push(format!( + "辅助任务运行时快照:{} 条", + auxiliary_runtime.len() + )); + } + + lines +} + +fn build_runtime_facts_json(thread_read: &AgentRuntimeThreadReadModel) -> serde_json::Value { + json!({ + "taskKind": thread_read.task_kind, + "serviceModelSlot": thread_read.service_model_slot, + "routingMode": thread_read.routing_mode, + "decisionSource": thread_read.decision_source, + "candidateCount": thread_read.candidate_count, + "capabilityGap": thread_read.capability_gap, + "decisionReason": thread_read.decision_reason, + "fallbackChain": thread_read.fallback_chain, + "estimatedCostClass": thread_read.estimated_cost_class, + "limitState": thread_read.limit_state, + "costState": thread_read.cost_state, + "limitEvent": thread_read.limit_event, + "runtimeSummary": thread_read.runtime_summary, + "oemPolicy": thread_read.oem_policy, + "auxiliaryTaskRuntime": thread_read.auxiliary_task_runtime + }) +} + fn build_resume_order() -> Vec<&'static str> { vec![ "先读 `handoff.md`,确认当前目标、最近摘要和建议接手顺序。", @@ -1003,17 +1173,58 @@ mod tests { }, ), }), - task_kind: None, - service_model_slot: None, - routing_mode: None, - decision_source: None, - candidate_count: None, - capability_gap: None, - single_candidate_only: None, - limit_state: None, - estimated_cost_class: None, - cost_state: None, - limit_event: None, + task_kind: Some("generation_topic".to_string()), + service_model_slot: Some("planner".to_string()), + routing_mode: Some("fallback_chain".to_string()), + decision_source: Some("model_router".to_string()), + candidate_count: Some(2), + capability_gap: Some("vision".to_string()), + single_candidate_only: Some(false), + oem_policy: Some(json!({ + "quotaStatus": "low_credit", + "defaultModel": "oem/gpt-5.4-mini", + "offerState": "managed" + })), + runtime_summary: Some(json!({ + "decisionReason": "主路由能力不足,切到回退模型", + "capabilityGap": "vision", + "limitStatus": "soft_limited", + "estimatedCostClass": "low" + })), + decision_reason: Some("主路由能力不足,切到回退模型".to_string()), + fallback_chain: Some(vec!["openai:gpt-5.4".to_string(), "openai:gpt-5.4-mini".to_string()]), + auxiliary_task_runtime: Some(vec![json!({"route": "auxiliary.generate_title", "taskKind": "generation_topic"})]), + limit_state: Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "soft_limited".to_string(), + single_candidate_only: false, + provider_locked: false, + settings_locked: false, + oem_locked: false, + candidate_count: 2, + capability_gap: Some("vision".to_string()), + notes: vec!["需要回退链".to_string()], + }), + estimated_cost_class: Some("low".to_string()), + cost_state: Some(lime_agent::SessionExecutionRuntimeCostState { + status: "estimated".to_string(), + estimated_cost_class: Some("low".to_string()), + input_per_million: None, + output_per_million: None, + cache_read_per_million: None, + cache_write_per_million: None, + currency: None, + estimated_total_cost: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + cached_input_tokens: None, + cache_creation_input_tokens: None, + }), + limit_event: Some(lime_agent::SessionExecutionRuntimeLimitEvent { + event_kind: "fallback_applied".to_string(), + message: "因能力缺口触发回退链".to_string(), + retryable: true, + }), } } @@ -1053,6 +1264,8 @@ mod tests { let plan = fs::read_to_string(plan_path).expect("plan"); assert!(plan.contains("会话计划")); assert!(plan.contains("补前端入口")); + assert!(plan.contains("运行时事实")); + assert!(plan.contains("决策解释:主路由能力不足,切到回退模型")); let handoff = fs::read_to_string(handoff_path).expect("handoff"); assert!(handoff.contains("已完成后端导出链路")); diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index 9c68243bf..0cb0e44b9 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -391,6 +391,7 @@ fn build_input_json( "recentTimeline": recent_timeline, "lastOutcome": &thread_read.last_outcome, "incidents": &thread_read.incidents, + "runtimeFacts": build_replay_runtime_facts(thread_read), }, "observability": observability_summary, "linkedArtifacts": { @@ -546,6 +547,26 @@ fn build_evidence_links_json( .map_err(|error| format!("序列化 evidence-links.json 失败: {error}")) } +fn build_replay_runtime_facts(thread_read: &AgentRuntimeThreadReadModel) -> Value { + json!({ + "taskKind": thread_read.task_kind, + "serviceModelSlot": thread_read.service_model_slot, + "routingMode": thread_read.routing_mode, + "decisionSource": thread_read.decision_source, + "candidateCount": thread_read.candidate_count, + "capabilityGap": thread_read.capability_gap, + "decisionReason": thread_read.decision_reason, + "fallbackChain": thread_read.fallback_chain, + "estimatedCostClass": thread_read.estimated_cost_class, + "limitState": thread_read.limit_state, + "costState": thread_read.cost_state, + "limitEvent": thread_read.limit_event, + "runtimeSummary": thread_read.runtime_summary, + "oemPolicy": thread_read.oem_policy, + "auxiliaryTaskRuntime": thread_read.auxiliary_task_runtime + }) +} + fn build_success_criteria( detail: &SessionDetail, thread_read: &AgentRuntimeThreadReadModel, @@ -580,6 +601,11 @@ fn build_success_criteria( ); } + if normalize_optional_text(thread_read.decision_reason.clone()).is_some() { + criteria + .push("结果应与当前 runtime 决策解释保持一致,除非样本明确要求切换路径。".to_string()); + } + if !recent_artifacts.is_empty() { criteria.push(format!( "如果任务继续沿用已有产物,应优先验证这些文件没有偏离:{}", @@ -1224,17 +1250,58 @@ mod tests { }, ), }), - task_kind: None, - service_model_slot: None, - routing_mode: None, - decision_source: None, - candidate_count: None, - capability_gap: None, - single_candidate_only: None, - limit_state: None, - estimated_cost_class: None, - cost_state: None, - limit_event: None, + task_kind: Some("generation_topic".to_string()), + service_model_slot: Some("planner".to_string()), + routing_mode: Some("fallback_chain".to_string()), + decision_source: Some("model_router".to_string()), + candidate_count: Some(2), + capability_gap: Some("vision".to_string()), + single_candidate_only: Some(false), + oem_policy: Some(json!({ + "quotaStatus": "low_credit", + "defaultModel": "oem/gpt-5.4-mini", + "offerState": "managed" + })), + runtime_summary: Some(json!({ + "decisionReason": "主路由能力不足,切到回退模型", + "capabilityGap": "vision", + "limitStatus": "soft_limited", + "estimatedCostClass": "low" + })), + decision_reason: Some("主路由能力不足,切到回退模型".to_string()), + fallback_chain: Some(vec!["openai:gpt-5.4".to_string(), "openai:gpt-5.4-mini".to_string()]), + auxiliary_task_runtime: Some(vec![json!({"route": "auxiliary.generate_title", "taskKind": "generation_topic"})]), + limit_state: Some(lime_agent::SessionExecutionRuntimeLimitState { + status: "soft_limited".to_string(), + single_candidate_only: false, + provider_locked: false, + settings_locked: false, + oem_locked: false, + candidate_count: 2, + capability_gap: Some("vision".to_string()), + notes: vec!["需要回退链".to_string()], + }), + estimated_cost_class: Some("low".to_string()), + cost_state: Some(lime_agent::SessionExecutionRuntimeCostState { + status: "estimated".to_string(), + estimated_cost_class: Some("low".to_string()), + input_per_million: None, + output_per_million: None, + cache_read_per_million: None, + cache_write_per_million: None, + currency: None, + estimated_total_cost: None, + input_tokens: None, + output_tokens: None, + total_tokens: None, + cached_input_tokens: None, + cache_creation_input_tokens: None, + }), + limit_event: Some(lime_agent::SessionExecutionRuntimeLimitEvent { + event_kind: "fallback_applied".to_string(), + message: "因能力缺口触发回退链".to_string(), + retryable: true, + }), } } diff --git a/src-tauri/src/services/runtime_review_decision_service.rs b/src-tauri/src/services/runtime_review_decision_service.rs index ef286115a..eec42ad91 100644 --- a/src-tauri/src/services/runtime_review_decision_service.rs +++ b/src-tauri/src/services/runtime_review_decision_service.rs @@ -1211,6 +1211,11 @@ mod tests { candidate_count: None, capability_gap: None, single_candidate_only: None, + oem_policy: None, + runtime_summary: None, + decision_reason: None, + fallback_chain: None, + auxiliary_task_runtime: None, limit_state: None, estimated_cost_class: None, cost_state: None, diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index bc91d8e3c..13803a5d2 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.18.0", + "version": "1.19.0", "identifier": "com.limecloud.lime.headless", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 53c39b181..be6dec8c0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.18.0", + "version": "1.19.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "node scripts/start-tauri-dev-server.mjs", diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx index 38fd6a0dc..dd9734d3a 100644 --- a/src/RootRouter.tsx +++ b/src/RootRouter.tsx @@ -3,12 +3,15 @@ * @description 根路由组件 - 根据 URL 路径渲染对应的组件 */ +import { useEffect } from "react"; import App from "./App"; import { SmartInputPage } from "./pages/smart-input"; import { UpdateNotificationPage } from "./pages/update-notification"; import { BrowserRuntimeDebuggerPage } from "./pages"; import { Toaster } from "./components/ui/sonner"; import { AppCrashBoundary } from "./components/layout/AppCrashBoundary"; +import { finalizeModuleImportAutoReload } from "./components/layout/CrashRecoveryPanel.helpers"; +import { getRuntimeAppVersion } from "./lib/appVersion"; /** * 根据 URL 路径渲染对应的组件 @@ -21,6 +24,19 @@ import { AppCrashBoundary } from "./components/layout/AppCrashBoundary"; export function RootRouter() { const pathname = window.location.pathname; + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + finalizeModuleImportAutoReload( + window.location.href, + getRuntimeAppVersion(), + window.sessionStorage, + window.history, + ); + }, [pathname]); + // 截图对话悬浮窗口路由(也用于语音输入) if (pathname === "/smart-input") { return ( diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index 298de2735..66a355844 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -6,11 +6,18 @@ import type { AgentPageParams, Page, PageParams } from "@/types/page"; import { SettingsTabs } from "@/types/settings"; import { AppSidebar } from "./AppSidebar"; -const { mockGetConfig, mockGetPluginsForSurface, mockSubscribeAppConfigChanged } = - vi.hoisted(() => ({ +const { + mockGetConfig, + mockGetPluginsForSurface, + mockSubscribeAppConfigChanged, + mockListAgentRuntimeSessions, + mockUpdateAgentRuntimeSession, +} = vi.hoisted(() => ({ mockGetConfig: vi.fn(), mockGetPluginsForSurface: vi.fn(), mockSubscribeAppConfigChanged: vi.fn(), + mockListAgentRuntimeSessions: vi.fn(), + mockUpdateAgentRuntimeSession: vi.fn(), })); vi.mock("@/lib/api/appConfig", () => ({ @@ -22,6 +29,11 @@ vi.mock("@/lib/api/pluginUI", () => ({ getPluginsForSurface: mockGetPluginsForSurface, })); +vi.mock("@/lib/api/agentRuntime", () => ({ + listAgentRuntimeSessions: mockListAgentRuntimeSessions, + updateAgentRuntimeSession: mockUpdateAgentRuntimeSession, +})); + vi.mock("@/lib/utils/scheduleMinimumDelayIdleTask", () => ({ scheduleMinimumDelayIdleTask: (task: () => void) => { task(); @@ -42,7 +54,7 @@ function mountSidebar(options?: { currentPage?: Page; currentPageParams?: PageParams; onNavigate?: (page: Page, params?: PageParams) => void; -}): MountedSidebar["container"] { +}): MountedSidebar { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -57,8 +69,17 @@ function mountSidebar(options?: { ); }); - mountedSidebars.push({ container, root }); - return container; + const mounted = { container, root }; + mountedSidebars.push(mounted); + return mounted; +} + +function mountSidebarContainer(options?: { + currentPage?: Page; + currentPageParams?: PageParams; + onNavigate?: (page: Page, params?: PageParams) => void; +}) { + return mountSidebar(options).container; } async function flushEffects(times = 1) { @@ -75,6 +96,8 @@ describe("AppSidebar", () => { localStorage.clear(); mockGetConfig.mockResolvedValue({}); mockGetPluginsForSurface.mockResolvedValue([]); + mockListAgentRuntimeSessions.mockResolvedValue([]); + mockUpdateAgentRuntimeSession.mockResolvedValue(undefined); mockSubscribeAppConfigChanged.mockImplementation( (listener: () => void) => { (globalThis as typeof globalThis & { __appConfigListener?: () => void }) @@ -112,10 +135,10 @@ describe("AppSidebar", () => { ).__appConfigListener = undefined; }); - it("进入生成页时应自动折叠导航栏", async () => { + it("进入任务中心页时应保持导航栏展开,以承接左侧任务导航", async () => { localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "false"); - const container = mountSidebar({ + const container = mountSidebarContainer({ currentPageParams: { agentEntry: "claw", } as AgentPageParams, @@ -123,17 +146,17 @@ describe("AppSidebar", () => { await flushEffects(); expect( - container.querySelector('button[aria-label="展开导航栏"]'), + container.querySelector('button[aria-label="折叠导航栏"]'), ).not.toBeNull(); expect(localStorage.getItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe( - "true", + "false", ); }); it("新建任务页应自动展开导航栏", async () => { localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "true"); - const container = mountSidebar({ + const container = mountSidebarContainer({ currentPageParams: { agentEntry: "new-task", } as AgentPageParams, @@ -148,8 +171,8 @@ describe("AppSidebar", () => { ); }); - it("默认应渲染任务、能力、资料、系统四段导航,并隐藏系统扩展入口", async () => { - const container = mountSidebar({ + it("默认应渲染一级主导航和底部系统入口", async () => { + const container = mountSidebarContainer({ currentPageParams: { agentEntry: "new-task", } as AgentPageParams, @@ -158,55 +181,234 @@ describe("AppSidebar", () => { expect(container.textContent).toContain("任务"); expect(container.textContent).toContain("新建任务"); - expect(container.textContent).toContain("生成"); - expect(container.textContent).toContain("能力"); + expect(container.textContent).not.toContain("生成"); expect(container.textContent).toContain("我的方法"); - expect(container.textContent).toContain("创作场景"); + expect(container.textContent).toContain("灵感库"); + expect(container.textContent).toContain("设置"); expect(container.textContent).toContain("持续流程"); expect(container.textContent).toContain("消息渠道"); - expect(container.textContent).toContain("资料"); - expect(container.textContent).toContain("资料库"); - expect(container.textContent).toContain("灵感库"); - expect(container.textContent).toContain("系统"); - expect(container.textContent).toContain("设置"); expect(container.textContent).not.toContain("插件中心"); expect(container.textContent).not.toContain("OpenClaw"); expect(container.textContent).not.toContain("桌宠"); expect(container.textContent).not.toContain("支撑"); expect(container.textContent).not.toContain("技能"); + expect(container.textContent).not.toContain("能力"); + expect(container.textContent).not.toContain("资料"); + expect(container.textContent).not.toContain("系统"); + + const mainNavButtons = Array.from( + container.querySelectorAll('[data-testid="app-sidebar-main-nav"] button'), + ).map((button) => button.getAttribute("aria-label")); + const footerNavButtons = Array.from( + container.querySelectorAll('[data-testid="app-sidebar-footer-nav"] button'), + ).map((button) => button.getAttribute("aria-label")); + const footerArea = container.querySelector( + '[data-testid="app-sidebar-footer-area"]', + ); + + expect(mainNavButtons).toEqual(["新建任务", "我的方法", "灵感库"]); + expect(footerNavButtons).toEqual(["设置", "持续流程", "消息渠道"]); + expect(footerArea).not.toBeNull(); + expect(getComputedStyle(footerArea as Element).paddingBottom).toBe("16px"); }); - it("恢复默认 agent 页时应把当前主舞台归到生成", async () => { - const container = mountSidebar({ + it("新建任务页应高亮新建任务入口", async () => { + const container = mountSidebarContainer({ currentPage: "agent", - }); - await flushEffects(2); - - expect( - container.querySelector('button[aria-label="生成"][aria-current="page"]'), - ).not.toBeNull(); - }); - - it("显式开启后应显示隐藏的系统扩展入口", async () => { - mockGetConfig.mockResolvedValue({ - navigation: { - enabled_items: ["plugins", "openclaw", "companion"], - }, - }); - - const container = mountSidebar({ currentPageParams: { agentEntry: "new-task", } as AgentPageParams, }); await flushEffects(2); + expect( + container.querySelector('button[aria-label="新建任务"][aria-current="page"]'), + ).not.toBeNull(); + }); + + it("生成页不应再展示旧的侧栏生成入口", async () => { + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "claw", + } as AgentPageParams, + }); + await flushEffects(2); + + expect(container.querySelector('button[aria-label="生成"]')).toBeNull(); + }); + + it("一级导航下方应继续展示最近对话与归档", async () => { + mockListAgentRuntimeSessions.mockResolvedValue([ + { + id: "session-recent", + name: "最近会话", + created_at: 1714000000, + updated_at: 1714000600, + archived_at: null, + workspace_id: "project-1", + }, + { + id: "session-archived", + name: "归档会话", + created_at: 1713000000, + updated_at: 1713000600, + archived_at: 1713003600, + workspace_id: "project-1", + }, + ]); + + const container = mountSidebarContainer({ + currentPage: "settings", + }); + await flushEffects(2); + + expect(container.textContent).toContain("最近对话"); + expect(container.textContent).toContain("归档"); + expect(container.textContent).toContain("最近会话"); + expect(container.textContent).toContain("归档会话"); + + const mainNav = container.querySelector( + '[data-testid="app-sidebar-main-nav"]', + ); + const conversationShelf = container.querySelector( + '[data-testid="app-sidebar-conversation-shelf"]', + ); + const recentConversationList = container.querySelector( + '[data-testid="app-sidebar-recent-conversations"]', + ); + const archivedConversationList = container.querySelector( + '[data-testid="app-sidebar-archived-conversations"]', + ); + + expect(mainNav).not.toBeNull(); + expect(conversationShelf).not.toBeNull(); + expect(recentConversationList).not.toBeNull(); + expect(archivedConversationList).not.toBeNull(); + expect(getComputedStyle(recentConversationList as Element).overflowY).toBe( + "auto", + ); + expect(getComputedStyle(archivedConversationList as Element).overflowY).toBe( + "auto", + ); + expect( + Boolean( + mainNav && + conversationShelf && + (mainNav.compareDocumentPosition(conversationShelf) & + Node.DOCUMENT_POSITION_FOLLOWING) !== + 0, + ), + ).toBe(true); + }); + + it("切换人物或项目上下文时不应把已有最近对话重置成加载态", async () => { + mockListAgentRuntimeSessions.mockResolvedValue([ + { + id: "session-recent", + name: "最近会话", + created_at: 1714000000, + updated_at: 1714000600, + archived_at: null, + workspace_id: null, + }, + ]); + + const mounted = mountSidebar({ + currentPage: "agent", + currentPageParams: { + agentEntry: "claw", + projectId: "project-1", + } as AgentPageParams, + }); + await flushEffects(2); + + expect(mounted.container.textContent).toContain("最近会话"); + expect(mounted.container.textContent).not.toContain("正在加载对话"); + expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(1); + + await act(async () => { + mounted.root.render( + , + ); + await Promise.resolve(); + }); + await flushEffects(2); + + expect(mounted.container.textContent).toContain("最近会话"); + expect(mounted.container.textContent).not.toContain("正在加载对话"); + expect(mockListAgentRuntimeSessions).toHaveBeenCalledTimes(1); + }); + + it("点击导航栏归档动作时应走统一 session update 命令", async () => { + mockListAgentRuntimeSessions.mockResolvedValue([ + { + id: "session-recent", + name: "最近会话", + created_at: 1714000000, + updated_at: 1714000600, + archived_at: null, + workspace_id: "project-1", + }, + ]); + + const container = mountSidebarContainer({ + currentPage: "agent", + currentPageParams: { + agentEntry: "claw", + projectId: "project-1", + } as AgentPageParams, + }); + await flushEffects(2); + + const archiveButton = container.querySelector( + 'button[aria-label="归档 最近会话"]', + ) as HTMLButtonElement | null; + + expect(archiveButton).not.toBeNull(); + + await act(async () => { + archiveButton?.click(); + await Promise.resolve(); + }); + + expect(mockUpdateAgentRuntimeSession).toHaveBeenCalledWith({ + session_id: "session-recent", + archived: true, + }); + }); + + it("显式开启后应显示可选系统扩展入口", async () => { + mockGetConfig.mockResolvedValue({ + navigation: { + enabled_items: ["plugins", "openclaw", "companion"], + }, + }); + + const container = mountSidebarContainer({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + await flushEffects(2); + + expect(container.textContent).toContain("持续流程"); + expect(container.textContent).toContain("消息渠道"); expect(container.textContent).toContain("插件中心"); expect(container.textContent).toContain("OpenClaw"); expect(container.textContent).toContain("桌宠"); }); - it("配置变更后应重新读取隐藏入口并刷新侧栏", async () => { + it("配置变更后应重新读取可选入口并刷新侧栏", async () => { mockGetConfig .mockResolvedValueOnce({ navigation: { @@ -219,13 +421,15 @@ describe("AppSidebar", () => { }, }); - const container = mountSidebar({ + const container = mountSidebarContainer({ currentPageParams: { agentEntry: "new-task", } as AgentPageParams, }); await flushEffects(2); + expect(container.textContent).toContain("持续流程"); + expect(container.textContent).toContain("消息渠道"); expect(container.textContent).not.toContain("插件中心"); expect(container.textContent).not.toContain("桌宠"); @@ -243,8 +447,8 @@ describe("AppSidebar", () => { expect(container.textContent).toContain("桌宠"); }); - it("sceneapps 页面应高亮创作场景,而不是把它并回我的方法", async () => { - const container = mountSidebar({ + it("sceneapps 页面不应再在侧栏展示独立主入口", async () => { + const container = mountSidebarContainer({ currentPage: "sceneapps", currentPageParams: { sceneappId: "story-video-suite", @@ -254,18 +458,13 @@ describe("AppSidebar", () => { await flushEffects(2); expect( - container.querySelector( - 'button[aria-label="创作场景"][aria-current="page"]', - ), - ).not.toBeNull(); - expect( - container.querySelector('button[aria-label="我的方法"][aria-current="page"]'), + container.querySelector('button[aria-label="创作场景"]'), ).toBeNull(); }); it("点击当前已激活的我的方法入口时不应重复导航", async () => { const onNavigate = vi.fn(); - const container = mountSidebar({ + const container = mountSidebarContainer({ currentPage: "skills", onNavigate, }); @@ -292,7 +491,7 @@ describe("AppSidebar", () => { ); mockGetConfig.mockImplementation(() => new Promise(() => undefined)); - const container = mountSidebar({ + const container = mountSidebarContainer({ currentPageParams: { agentEntry: "new-task", } as AgentPageParams, @@ -311,7 +510,7 @@ describe("AppSidebar", () => { }, }); - const container = mountSidebar({ + const container = mountSidebarContainer({ currentPage: "settings", currentPageParams: { tab: SettingsTabs.Providers, @@ -339,7 +538,7 @@ describe("AppSidebar", () => { }, ]); - const container = mountSidebar({ + const container = mountSidebarContainer({ currentPageParams: { agentEntry: "new-task", } as AgentPageParams, @@ -366,7 +565,7 @@ describe("AppSidebar", () => { }, ]); - const container = mountSidebar({ + const container = mountSidebarContainer({ currentPageParams: { agentEntry: "new-task", } as AgentPageParams, diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 99d69887c..30213590d 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,20 +1,31 @@ /** * 全局应用侧边栏 * - * 参考 LimeNext V2 current IA:任务 / 能力 / 资料 / 系统 四段式导航, - * 默认只暴露主线入口,历史系统面收回到显式开启的隐藏项。 + * 当前导航收口为一级主入口 + 底部系统入口。 + * 默认只暴露主线入口;底部固定保留设置、持续流程、消息渠道, + * 可选项只剩插件中心、OpenClaw、桌宠。 */ -import { useState, useEffect, useMemo, useRef, type ReactElement } from "react"; +import { + useState, + useEffect, + useMemo, + useRef, + useCallback, + type ReactElement, +} from "react"; import styled from "styled-components"; import { ChevronDown, - ChevronRight, + Clock3, + MessageSquarePlus, + Archive, Moon, Sun, Search, PanelLeftClose, PanelLeftOpen, + Undo2, Activity, LucideIcon, } from "lucide-react"; @@ -23,16 +34,20 @@ import { getPluginsForSurface, PluginUIInfo } from "@/lib/api/pluginUI"; import { AgentPageParams, Page, PageParams } from "@/types/page"; import { getConfig, subscribeAppConfigChanged } from "@/lib/api/appConfig"; import { + buildClawAgentParams, buildHomeAgentParams, } from "@/lib/workspace/navigation"; +import { + listAgentRuntimeSessions, + updateAgentRuntimeSession, + type AsterSessionInfo, +} from "@/lib/api/agentRuntime"; import { DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, - FOOTER_SIDEBAR_NAV_SECTIONS, - MAIN_SIDEBAR_NAV_SECTIONS, + FOOTER_SIDEBAR_NAV_ITEMS, MAIN_SIDEBAR_NAV_ITEMS, resolveEnabledSidebarNavItems, type SidebarNavItemDefinition, - type SidebarNavSectionDefinition, } from "@/lib/navigation/sidebarNav"; import { Tooltip, @@ -53,13 +68,11 @@ interface AppSidebarProps { } type SidebarNavItem = SidebarNavItemDefinition; -type SidebarNavSection = SidebarNavSectionDefinition; const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; const SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID = "plugins"; const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200; const SIDEBAR_PLUGIN_BROWSER_IDLE_TIMEOUT_MS = 6000; - function scheduleSidebarPluginLoad(task: () => void): () => void { const minimumDelayMs = hasTauriInvokeCapability() ? SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS @@ -147,18 +160,30 @@ const Container = styled.aside<{ $themeMode === "dark" ? "#24313d" : "#f4fdf4"}; --sidebar-search-border-hover: ${({ $themeMode }) => $themeMode === "dark" ? "#3a4a57" : "#bbf7d0"}; + --sidebar-card-surface: ${({ $themeMode }) => + $themeMode === "dark" + ? "linear-gradient(180deg, rgba(24, 34, 44, 0.96) 0%, rgba(19, 29, 38, 0.98) 100%)" + : "linear-gradient(180deg, rgba(255, 255, 255, 0.76) 0%, rgba(249, 252, 246, 0.94) 100%)"}; + --sidebar-card-shadow: ${({ $themeMode }) => + $themeMode === "dark" + ? "0 18px 34px -28px rgba(2, 8, 23, 0.7)" + : "0 16px 30px -28px rgba(15, 23, 42, 0.4)"}; + --sidebar-card-highlight: ${({ $themeMode }) => + $themeMode === "dark" + ? "rgba(255, 255, 255, 0.08)" + : "rgba(255, 255, 255, 0.72)"}; display: flex; flex-direction: column; - width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")}; - min-width: ${({ $collapsed }) => ($collapsed ? "72px" : "248px")}; + width: ${({ $collapsed }) => ($collapsed ? "72px" : "272px")}; + min-width: ${({ $collapsed }) => ($collapsed ? "72px" : "272px")}; height: 100vh; - padding: ${({ $collapsed }) => ($collapsed ? "12px 6px" : "12px 10px")}; + padding: ${({ $collapsed }) => ($collapsed ? "12px 6px" : "14px 14px 12px")}; position: relative; isolation: isolate; background: linear-gradient( 180deg, var(--sidebar-surface-top) 0%, - var(--sidebar-surface-middle) 34%, + var(--sidebar-surface-middle) 38%, var(--sidebar-surface-bottom) 100% ); border-right: 1px solid var(--sidebar-border); @@ -175,13 +200,18 @@ const Container = styled.aside<{ background: radial-gradient( circle at top left, - rgba(132, 204, 22, 0.12) 0%, - rgba(132, 204, 22, 0) 44% + rgba(132, 204, 22, 0.14) 0%, + rgba(132, 204, 22, 0) 46% + ), + radial-gradient( + circle at 18% 18%, + rgba(16, 185, 129, 0.12) 0%, + rgba(16, 185, 129, 0) 30% ), radial-gradient( circle at bottom left, - rgba(16, 185, 129, 0.1) 0%, - rgba(16, 185, 129, 0) 36% + rgba(186, 230, 253, 0.12) 0%, + rgba(186, 230, 253, 0) 34% ); pointer-events: none; z-index: 0; @@ -196,8 +226,8 @@ const Container = styled.aside<{ const HeaderArea = styled.div<{ $collapsed?: boolean }>` display: flex; flex-direction: column; - gap: ${({ $collapsed }) => ($collapsed ? "8px" : "10px")}; - margin-bottom: 12px; + gap: ${({ $collapsed }) => ($collapsed ? "8px" : "14px")}; + margin-bottom: 16px; `; const HeaderTopRow = styled.div<{ $collapsed?: boolean }>` @@ -219,8 +249,8 @@ const UserButton = styled.button<{ $collapsed?: boolean }>` width: 100%; border: none; background: transparent; - border-radius: 10px; - padding: ${({ $collapsed }) => ($collapsed ? "8px" : "8px 10px")}; + border-radius: 14px; + padding: ${({ $collapsed }) => ($collapsed ? "8px" : "10px 12px")}; cursor: pointer; color: var(--sidebar-foreground); justify-content: ${({ $collapsed }) => @@ -251,8 +281,8 @@ const Avatar = styled.div` const UserName = styled.div<{ $collapsed?: boolean }>` flex: 1; - font-size: 14px; - font-weight: 600; + font-size: 15px; + font-weight: 700; text-align: left; white-space: nowrap; overflow: hidden; @@ -265,12 +295,12 @@ const SearchButton = styled.button<{ $collapsed?: boolean }>` align-items: center; gap: 8px; width: 100%; - height: 34px; - border-radius: 12px; + height: 44px; + border-radius: 16px; border: 1px solid var(--sidebar-border); background: var(--sidebar-search-bg); color: var(--sidebar-muted); - padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")}; + padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 14px")}; cursor: pointer; justify-content: ${({ $collapsed }) => $collapsed ? "center" : "flex-start"}; @@ -288,8 +318,8 @@ const SearchButton = styled.button<{ $collapsed?: boolean }>` } span { - font-size: 13px; - font-weight: 500; + font-size: 14px; + font-weight: 600; display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")}; } `; @@ -315,17 +345,24 @@ const MenuScroll = styled.div` } `; +const MainNavList = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +`; + const Section = styled.div<{ $collapsed?: boolean }>` display: flex; flex-direction: column; gap: 4px; - margin-bottom: 14px; + margin-bottom: 12px; `; const SectionTitle = styled.div<{ $collapsed?: boolean }>` padding: 0 10px; - font-size: 12px; - font-weight: 500; + font-size: 13px; + font-weight: 600; color: var(--sidebar-muted); opacity: 0.9; display: ${({ $collapsed }) => ($collapsed ? "none" : "block")}; @@ -336,21 +373,26 @@ const NavButton = styled.button<{ $active?: boolean; $collapsed?: boolean }>` align-items: center; gap: ${({ $collapsed }) => ($collapsed ? "0" : "10px")}; width: 100%; - height: 38px; + height: ${({ $collapsed }) => ($collapsed ? "40px" : "46px")}; border: none; - border-radius: 12px; - padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 10px")}; + border-radius: 14px; + padding: ${({ $collapsed }) => ($collapsed ? "0" : "0 12px")}; background: ${({ $active }) => - $active ? "var(--sidebar-active)" : "transparent"}; + $active + ? "linear-gradient(180deg, rgba(230, 248, 234, 0.98), rgba(221, 247, 230, 0.92))" + : "transparent"}; color: ${({ $active }) => $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-muted)"}; cursor: pointer; transition: background-color 0.18s ease, color 0.18s ease, - transform 0.18s ease; + transform 0.18s ease, + box-shadow 0.18s ease; justify-content: ${({ $collapsed }) => $collapsed ? "center" : "flex-start"}; + box-shadow: ${({ $active }) => + $active ? "0 12px 24px -22px rgba(22, 101, 52, 0.34)" : "none"}; &:hover { background: ${({ $active }) => @@ -380,15 +422,52 @@ const NavLabel = styled.span<{ $collapsed?: boolean }>` display: ${({ $collapsed }) => ($collapsed ? "none" : "inline")}; `; -const NavTrailingIcon = styled.span<{ - $collapsed?: boolean; - $active?: boolean; -}>` - display: ${({ $collapsed }) => ($collapsed ? "none" : "inline-flex")}; +const ConversationShelf = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + margin: 2px 0 12px; +`; + +const ConversationSection = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + min-height: 140px; + max-height: 208px; + padding: 10px; + border-radius: 24px; + border: 1px solid var(--sidebar-border); + background: + var(--sidebar-card-surface), + var(--sidebar-search-bg); + box-shadow: + inset 0 1px 0 var(--sidebar-card-highlight), + var(--sidebar-card-shadow); + overflow: hidden; +`; + +const ConversationSectionHeader = styled.div` + display: flex; align-items: center; - justify-content: center; - color: ${({ $active }) => - $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-muted)"}; + justify-content: space-between; + gap: 8px; + padding: 0 4px; + color: var(--sidebar-muted); +`; + +const ConversationSectionTitle = styled.button` + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + margin: 0; + border: none; + background: transparent; + color: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; svg { width: 14px; @@ -396,25 +475,158 @@ const NavTrailingIcon = styled.span<{ } `; -const NavGroup = styled.div` +const ConversationActionButton = styled.button` + width: 28px; + height: 28px; + border: none; + border-radius: 9px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + color: var(--sidebar-muted); + cursor: pointer; + transition: + background-color 0.18s ease, + color 0.18s ease; + + &:hover { + background: var(--sidebar-hover); + color: var(--sidebar-foreground); + } + + svg { + width: 16px; + height: 16px; + } +`; + +const ConversationList = styled.div` display: flex; flex-direction: column; gap: 4px; + flex: 1; + min-height: 0; + max-height: 132px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 2px; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--sidebar-border); + border-radius: 9999px; + } `; -const SubNavList = styled.div` +const ConversationItemRow = styled.div<{ + $active?: boolean; +}>` display: flex; - flex-direction: column; + align-items: center; gap: 4px; - margin-left: 16px; - padding-left: 12px; - border-left: 1px solid var(--sidebar-divider); + width: 100%; + border-radius: 12px; + background: ${({ $active }) => + $active + ? "linear-gradient(180deg, rgba(230, 248, 234, 0.98), rgba(221, 247, 230, 0.92))" + : "transparent"}; + transition: + background-color 0.18s ease, + color 0.18s ease; + + &:hover { + background: ${({ $active }) => + $active ? "var(--sidebar-active)" : "var(--sidebar-hover)"}; + } `; -const SubNavButton = styled(NavButton)` - height: 34px; - border-radius: 10px; - padding: 0 10px 0 12px; +const ConversationItemButton = styled.button<{ + $active?: boolean; +}>` + display: flex; + align-items: center; + gap: 10px; + flex: 1; + min-width: 0; + min-height: 38px; + border: none; + border-radius: 12px; + padding: 0 10px; + background: transparent; + color: ${({ $active }) => + $active ? "var(--sidebar-active-foreground)" : "var(--sidebar-foreground)"}; + cursor: pointer; + transition: color 0.18s ease; +`; + +const ConversationItemDot = styled.span<{ $active?: boolean }>` + width: 8px; + height: 8px; + flex-shrink: 0; + border-radius: 999px; + background: ${({ $active }) => + $active ? "var(--sidebar-active-foreground)" : "rgba(148, 163, 184, 0.72)"}; +`; + +const ConversationItemLabel = styled.span` + flex: 1; + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 500; +`; + +const ConversationItemMeta = styled.span` + flex-shrink: 0; + font-size: 11px; + color: var(--sidebar-muted); +`; + +const ConversationItemActionButton = styled.button` + width: 30px; + min-width: 30px; + height: 38px; + border: none; + border-radius: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + color: var(--sidebar-muted); + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: + opacity 0.18s ease, + background-color 0.18s ease, + color 0.18s ease; + + &:hover { + background: var(--sidebar-hover); + color: var(--sidebar-foreground); + } + + &:disabled { + cursor: wait; + opacity: 0.6; + } + + ${ConversationItemRow}:hover &, + ${ConversationItemRow}[data-active="true"] & { + opacity: 1; + pointer-events: auto; + } svg { width: 15px; @@ -422,17 +634,28 @@ const SubNavButton = styled(NavButton)` } `; -const SubNavLabel = styled(NavLabel)` +const ConversationEmptyState = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex: 1; + min-height: 0; + border-radius: 16px; + padding: 12px; + color: var(--sidebar-muted); font-size: 13px; + background: rgba(255, 255, 255, 0.28); + text-align: center; `; const FooterArea = styled.div<{ $collapsed?: boolean }>` - margin-top: auto; padding-top: 10px; + padding-bottom: 16px; border-top: 1px solid var(--sidebar-divider); display: flex; flex-direction: column; - gap: 8px; + gap: 6px; `; const ActionRow = styled.div<{ $collapsed?: boolean }>` @@ -482,6 +705,56 @@ function getIconByName(iconName: string): LucideIcon { return IconComponent || Activity; } +function sortSidebarSessions(sessions: AsterSessionInfo[]): AsterSessionInfo[] { + return [...sessions].sort((left, right) => { + if (left.updated_at !== right.updated_at) { + return right.updated_at - left.updated_at; + } + + if (left.created_at !== right.created_at) { + return right.created_at - left.created_at; + } + + return left.id.localeCompare(right.id); + }); +} + +function formatSidebarSessionTime(updatedAt: number): string { + const diffMs = Date.now() - updatedAt * 1000; + const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60))); + + if (diffMinutes < 60) { + return `${diffMinutes}分`; + } + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) { + return `${diffHours}时`; + } + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) { + return `${diffDays}天`; + } + + return new Date(updatedAt * 1000).toLocaleDateString("zh-CN", { + month: "numeric", + day: "numeric", + }); +} + +function formatSidebarSessionMeta(session: AsterSessionInfo): string { + if (typeof session.archived_at === "number" && session.archived_at > 0) { + return `归档 ${formatSidebarSessionTime(session.archived_at)}`; + } + + return formatSidebarSessionTime(session.updated_at); +} + +function resolveSidebarSessionTitle(session: AsterSessionInfo): string { + return session.name?.trim() || "未命名对话"; +} + export function AppSidebar({ currentPage, currentPageParams, @@ -501,8 +774,11 @@ export function AppSidebar({ }); const agentEntry = (activePageParams as AgentPageParams | undefined) ?.agentEntry; + const activeAgentPageParams = activePageParams as AgentPageParams | undefined; const isClawTaskCenter = activePage === "agent" && agentEntry === "claw"; const isNewTaskHome = activePage === "agent" && agentEntry === "new-task"; + const currentProjectId = activeAgentPageParams?.projectId?.trim() || null; + const currentSessionId = activeAgentPageParams?.initialSessionId?.trim() || null; const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") { return false; @@ -526,12 +802,15 @@ export function AppSidebar({ ); const [sidebarPlugins, setSidebarPlugins] = useState([]); const [refreshTrigger, setRefreshTrigger] = useState(0); - const [expandedGroupIds, setExpandedGroupIds] = useState(() => - MAIN_SIDEBAR_NAV_ITEMS.filter( - (item) => - item.defaultExpanded && item.children && item.children.length > 0, - ).map((item) => item.id), + const sidebarSessionsRef = useRef([]); + const [sidebarSessions, setSidebarSessions] = useState([]); + const [sidebarSessionsLoading, setSidebarSessionsLoading] = useState(false); + const [sidebarSessionActionId, setSidebarSessionActionId] = useState( + null, ); + const [recentSessionsCollapsed, setRecentSessionsCollapsed] = useState(false); + const [archivedSessionsCollapsed, setArchivedSessionsCollapsed] = + useState(false); useEffect(() => { const loadNavConfig = async () => { @@ -553,24 +832,16 @@ export function AppSidebar({ }); }, []); - const filteredMainSections = useMemo(() => { - return MAIN_SIDEBAR_NAV_SECTIONS.map((section) => ({ - ...section, - items: section.items.filter( - (item) => - item.configurable === false || enabledNavItems.includes(item.id), - ), - })).filter((section) => section.items.length > 0); + const filteredMainNavItems = useMemo(() => { + return MAIN_SIDEBAR_NAV_ITEMS.filter( + (item) => item.configurable === false || enabledNavItems.includes(item.id), + ); }, [enabledNavItems]); - const filteredFooterSections = useMemo(() => { - return FOOTER_SIDEBAR_NAV_SECTIONS.map((section) => ({ - ...section, - items: section.items.filter( - (item) => - item.configurable === false || enabledNavItems.includes(item.id), - ), - })).filter((section) => section.items.length > 0); + const filteredFooterNavItems = useMemo(() => { + return FOOTER_SIDEBAR_NAV_ITEMS.filter( + (item) => item.configurable === false || enabledNavItems.includes(item.id), + ); }, [enabledNavItems]); useEffect(() => { @@ -665,9 +936,82 @@ export function AppSidebar({ return; } - setCollapsed(true); + setCollapsed(false); }, [isClawTaskCenter, isNewTaskHome]); + const shouldShowConversationList = + !collapsed && + !(activePage === "agent" && activeAgentPageParams?.immersiveHome); + const shouldShowSessionLoadingState = + sidebarSessionsLoading && sidebarSessions.length === 0; + + useEffect(() => { + sidebarSessionsRef.current = sidebarSessions; + }, [sidebarSessions]); + + const refreshSidebarSessions = useCallback(async () => { + if (!shouldShowConversationList) { + return; + } + + setSidebarSessionsLoading( + (current) => current || sidebarSessionsRef.current.length === 0, + ); + try { + const sessions = await listAgentRuntimeSessions({ + includeArchived: true, + }); + setSidebarSessions(sortSidebarSessions(sessions)); + } catch (error) { + console.error("加载导航任务列表失败:", error); + setSidebarSessions([]); + } finally { + setSidebarSessionsLoading(false); + } + }, [shouldShowConversationList]); + + useEffect(() => { + if (!shouldShowConversationList) { + return; + } + + let cancelled = false; + + const loadSessions = async () => { + try { + const sessions = await listAgentRuntimeSessions({ + includeArchived: true, + }); + if (!cancelled) { + setSidebarSessions(sortSidebarSessions(sessions)); + } + } catch (error) { + if (!cancelled) { + console.error("加载导航任务列表失败:", error); + setSidebarSessions([]); + } + } finally { + if (!cancelled) { + setSidebarSessionsLoading(false); + } + } + }; + + setSidebarSessionsLoading(sidebarSessionsRef.current.length === 0); + void loadSessions(); + + const handleFocus = () => { + void refreshSidebarSessions(); + }; + + window.addEventListener("focus", handleFocus); + + return () => { + cancelled = true; + window.removeEventListener("focus", handleFocus); + }; + }, [refreshSidebarSessions, shouldShowConversationList]); + const assistantItems = useMemo(() => { return sidebarPlugins.map((plugin) => { const pluginPageId = `plugin:${plugin.pluginId}` as Page; @@ -682,12 +1026,25 @@ export function AppSidebar({ const shouldShowPluginExtensionsSection = enabledNavItems.includes(SIDEBAR_PLUGIN_CENTER_NAV_ITEM_ID) && assistantItems.length > 0; - - const isActive = (item: SidebarNavItem): boolean => { - if (item.children && item.children.length > 0) { - return item.children.some((child) => isActive(child)); + const filteredSidebarSessions = useMemo(() => { + if (!currentProjectId) { + return sidebarSessions; } + return sidebarSessions.filter( + (session) => !session.workspace_id || session.workspace_id === currentProjectId, + ); + }, [currentProjectId, sidebarSessions]); + const recentSidebarSessions = useMemo( + () => filteredSidebarSessions.filter((session) => !session.archived_at), + [filteredSidebarSessions], + ); + const archivedSidebarSessions = useMemo( + () => filteredSidebarSessions.filter((session) => Boolean(session.archived_at)), + [filteredSidebarSessions], + ); + + const isActive = (item: SidebarNavItem): boolean => { if (!item.page) { return false; } @@ -699,48 +1056,6 @@ export function AppSidebar({ return activePage === item.page; }; - const activeLeafNavItemId = - [ - ...filteredMainSections.flatMap((section) => section.items), - ...assistantItems, - ...filteredFooterSections.flatMap((section) => section.items), - ].find( - (item) => (!item.children || item.children.length === 0) && isActive(item), - )?.id ?? null; - - useEffect(() => { - const isGroupActive = (item: SidebarNavItem): boolean => { - if (item.children && item.children.length > 0) { - return item.children.some((child) => isGroupActive(child)); - } - - if (!item.page) { - return false; - } - - if (item.isActive) { - return item.isActive(activePage, activePageParams); - } - - return activePage === item.page; - }; - - const activeGroupIds = MAIN_SIDEBAR_NAV_ITEMS.filter( - (item) => - item.children && item.children.length > 0 && isGroupActive(item), - ).map((item) => item.id); - - if (activeGroupIds.length === 0) { - return; - } - - setExpandedGroupIds((current) => { - const next = new Set(current); - activeGroupIds.forEach((id) => next.add(id)); - return Array.from(next); - }); - }, [activeLeafNavItemId, activePage, activePageParams]); - const handleNavigate = (item: SidebarNavItem) => { const target = resolveSidebarNavigationTarget(item); @@ -762,14 +1077,6 @@ export function AppSidebar({ onNavigate(target.page, target.rawParams); }; - const toggleGroup = (groupId: string) => { - setExpandedGroupIds((current) => - current.includes(groupId) - ? current.filter((id) => id !== groupId) - : [...current, groupId], - ); - }; - const maybeWrapWithTooltip = (node: ReactElement, label: string) => { if (!collapsed) { return node; @@ -783,58 +1090,10 @@ export function AppSidebar({ ); }; - const renderNavItem = (item: SidebarNavItem, nested: boolean = false) => { + const renderNavItem = (item: SidebarNavItem) => { const active = isActive(item); - - if (item.children && item.children.length > 0) { - const expanded = expandedGroupIds.includes(item.id); - const groupButton = ( - { - if (collapsed) { - setCollapsed(false); - if (!expanded) { - setExpandedGroupIds((current) => - current.includes(item.id) ? current : [...current, item.id], - ); - } - return; - } - - toggleGroup(item.id); - }} - title={item.label} - aria-label={item.label} - aria-current={active ? "page" : undefined} - aria-expanded={collapsed ? undefined : expanded} - > - - {item.label} - - {expanded ? : } - - - ); - - return ( - - {maybeWrapWithTooltip(groupButton, item.label)} - {!collapsed && expanded ? ( - - {item.children.map((child) => renderNavItem(child, true))} - - ) : null} - - ); - } - - const ButtonComponent = nested ? SubNavButton : NavButton; - const LabelComponent = nested ? SubNavLabel : NavLabel; const button = ( - - {item.label} - + {item.label} + ); return maybeWrapWithTooltip(button, item.label); }; + const handleNavigateToConversation = (session: AsterSessionInfo) => { + const targetParams = buildClawAgentParams({ + projectId: session.workspace_id ?? currentProjectId ?? undefined, + initialSessionId: session.id, + }); + const target = { + page: "agent" as Page, + rawParams: targetParams, + paramsKey: serializeNavigationParams(targetParams), + } satisfies SidebarNavigationTarget; + + if ( + isSameSidebarNavigationTarget( + target, + requestedNavigationTargetRef.current.page, + requestedNavigationTargetRef.current.rawParams, + ) + ) { + return; + } + + requestedNavigationTargetRef.current = target; + onNavigate(target.page, target.rawParams); + }; + + const handleNavigateToNewTask = () => { + const targetParams = buildHomeAgentParams({ + projectId: currentProjectId ?? undefined, + }); + const target = { + page: "agent" as Page, + rawParams: targetParams, + paramsKey: serializeNavigationParams(targetParams), + } satisfies SidebarNavigationTarget; + + if ( + isSameSidebarNavigationTarget( + target, + requestedNavigationTargetRef.current.page, + requestedNavigationTargetRef.current.rawParams, + ) + ) { + return; + } + + requestedNavigationTargetRef.current = target; + onNavigate(target.page, target.rawParams); + }; + + const handleToggleSessionArchive = useCallback( + async (session: AsterSessionInfo, archived: boolean) => { + const nextUpdatedAt = Math.floor(Date.now() / 1000); + setSidebarSessionActionId(session.id); + setSidebarSessions((current) => + sortSidebarSessions( + current.map((item) => + item.id === session.id + ? { + ...item, + updated_at: nextUpdatedAt, + archived_at: archived ? nextUpdatedAt : null, + } + : item, + ), + ), + ); + + try { + await updateAgentRuntimeSession({ + session_id: session.id, + archived, + }); + await refreshSidebarSessions(); + } catch (error) { + console.error(archived ? "归档会话失败:" : "恢复会话失败:", error); + await refreshSidebarSessions(); + } finally { + setSidebarSessionActionId((current) => + current === session.id ? null : current, + ); + } + }, + [refreshSidebarSessions], + ); + return ( @@ -897,14 +1241,162 @@ export function AppSidebar({ - {filteredMainSections.map((section) => ( -
- - {section.title} - - {section.items.map((item) => renderNavItem(item))} -
- ))} + + {filteredMainNavItems.map((item) => renderNavItem(item))} + + + {shouldShowConversationList ? ( + + + + setRecentSessionsCollapsed((value) => !value)} + aria-expanded={!recentSessionsCollapsed} + > + + 最近对话 + + + + + + {!recentSessionsCollapsed ? ( + + {shouldShowSessionLoadingState ? ( + + + 正在加载对话 + + ) : recentSidebarSessions.length > 0 ? ( + recentSidebarSessions.map((session) => { + const isCurrentConversation = currentSessionId === session.id; + return ( + + + handleNavigateToConversation(session) + } + title={resolveSidebarSessionTitle(session)} + > + + + {resolveSidebarSessionTitle(session)} + + + {formatSidebarSessionMeta(session)} + + + { + event.stopPropagation(); + void handleToggleSessionArchive(session, true); + }} + > + + + + ); + }) + ) : ( + + + 还没有开始对话 + + )} + + ) : null} + + + + + setArchivedSessionsCollapsed((value) => !value)} + aria-expanded={!archivedSessionsCollapsed} + > + + 归档 + + + {!archivedSessionsCollapsed ? ( + + {shouldShowSessionLoadingState ? null : archivedSidebarSessions.length > + 0 ? ( + archivedSidebarSessions.map((session) => ( + + handleNavigateToConversation(session)} + title={resolveSidebarSessionTitle(session)} + > + + + {resolveSidebarSessionTitle(session)} + + + {formatSidebarSessionMeta(session)} + + + { + event.stopPropagation(); + void handleToggleSessionArchive(session, false); + }} + > + + + + )) + ) : ( + + + 暂无归档内容 + + )} + + ) : null} + + + ) : null} {shouldShowPluginExtensionsSection && (
@@ -912,34 +1404,32 @@ export function AppSidebar({ {assistantItems.map((item) => renderNavItem(item))}
)} -
- - {filteredFooterSections.map((section) => ( -
- - {section.title} - - {section.items.map((item) => renderNavItem(item))} + +
+ {filteredFooterNavItems.map((item) => renderNavItem(item))}
- ))} - - {!collapsed ?
: null} - {maybeWrapWithTooltip( - setTheme(theme === "dark" ? "light" : "dark")} - title={theme === "dark" ? "深色模式" : "浅色模式"} - aria-label={ - theme === "dark" ? "切换到浅色模式" : "切换到深色模式" - } - > - {theme === "dark" ? : } - , - theme === "dark" ? "切换到浅色模式" : "切换到深色模式", - )} - - + + {!collapsed ?
: null} + {maybeWrapWithTooltip( + setTheme(theme === "dark" ? "light" : "dark")} + title={theme === "dark" ? "深色模式" : "浅色模式"} + aria-label={ + theme === "dark" ? "切换到浅色模式" : "切换到深色模式" + } + > + {theme === "dark" ? : } + , + theme === "dark" ? "切换到浅色模式" : "切换到深色模式", + )} + + + ); diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index 07bdd7c3e..67c084116 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -92,6 +92,7 @@ import { recordWorkspaceRepair } from "@/lib/workspaceHealthTelemetry"; import { useImageGen } from "@/components/image-gen/useImageGen"; import { resolveMediaGenerationPreference } from "@/lib/mediaGeneration"; import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayIdleTask"; +import { buildHomeAgentParams } from "@/lib/workspace/navigation"; import { buildTeamMemoryShadowRequestMetadata, readTeamMemorySnapshot, @@ -133,6 +134,7 @@ import { buildGeneralAgentSystemPrompt, resolveAgentChatMode, } from "./utils/generalAgentPrompt"; +import { loadPersisted, savePersisted } from "./hooks/agentChatStorage"; import { loadPersistedProjectId } from "./hooks/agentProjectStorage"; import { loadPersistedSessionWorkspaceId } from "./hooks/agentProjectStorage"; import { useSelectedTeamPreference } from "./hooks/useSelectedTeamPreference"; @@ -211,6 +213,10 @@ import { useWorkspaceInitialSessionNavigation } from "./workspace/useWorkspaceIn import { WorkspaceGeneralWorkbenchSidebar } from "./workspace/WorkspaceGeneralWorkbenchSidebar"; import { GeneralWorkbenchHarnessDialogSection } from "./workspace/WorkspaceHarnessDialogs"; import { WorkspaceShellScene } from "./workspace/WorkspaceShellScene"; +import { + TaskCenterTabStrip, + type TaskCenterTabItem, +} from "./components/TaskCenterTabStrip"; import type { GeneralWorkbenchFollowUpActionPayload } from "./components/generalWorkbenchSidebarContract"; import { RuntimeReviewDecisionDialog } from "./components/RuntimeReviewDecisionDialog"; import { @@ -233,6 +239,18 @@ import type { ArtifactDocumentV1 } from "@/lib/artifact-document"; import type { ArtifactTimelineOpenTarget } from "./utils/artifactTimelineNavigation"; import { createUnifiedMemory } from "@/lib/api/unifiedMemory"; import { getDefaultGuidePromptByTheme } from "./utils/defaultGuidePrompt"; +import { shouldShowChatLayout } from "./utils/chatLayoutVisibility"; +import { resolveInternalImageTaskDisplayName } from "./utils/internalImagePlaceholder"; +import { + MAX_TASK_CENTER_OPEN_TABS, + normalizeTaskCenterWorkspaceTabMap, + reconcileTaskCenterTabIds, + resolveTaskCenterTabIdsForWorkspace, + shouldResumeTaskSession, + TASK_CENTER_OPEN_TAB_IDS_STORAGE_KEY, + type TaskCenterWorkspaceTabMap, + updateTaskCenterTabIdsForWorkspace, +} from "./utils/taskCenterTabs"; import { createInitialSessionImageWorkbenchState, type SessionImageWorkbenchState, @@ -453,8 +471,7 @@ export function AgentChatWorkspace({ initialSiteSkillLaunch, }: AgentChatWorkspaceProps) { const normalizedEntryTheme = normalizeInitialTheme(initialTheme); - const shouldAutoCollapseClassicClawSidebar = - agentEntry === "claw" && normalizedEntryTheme === "general"; + const shouldAutoCollapseClassicClawSidebar = false; const defaultTopicSidebarVisible = showChatPanel && !shouldAutoCollapseClassicClawSidebar; const [showSidebar, setShowSidebar] = useState( @@ -546,6 +563,7 @@ export function AgentChatWorkspace({ externalProjectId, newChatAt, }); + const taskCenterWorkspaceId = normalizeProjectId(projectId); const [layoutMode, setLayoutMode] = useState( shouldBootstrapCanvasOnEntry ? "canvas" : "chat", ); @@ -1098,7 +1116,7 @@ export function AgentChatWorkspace({ }, [_onNavigate]); const handleOpenSceneAppsDirectory = useCallback(() => { if (!_onNavigate) { - toast.error("当前入口暂不支持跳转到做法目录"); + toast.error("当前入口暂不支持跳转到全部做法"); return; } @@ -1117,7 +1135,7 @@ export function AgentChatWorkspace({ }, [_onNavigate, input, projectId]); const handleResumeRecentSceneApp = useCallback(() => { if (!_onNavigate) { - toast.error("当前入口暂不支持跳转到做法目录"); + toast.error("当前入口暂不支持跳转到全部做法"); return; } @@ -2995,7 +3013,6 @@ export function AgentChatWorkspace({ lockTheme, initialTheme, sessionFiles, - readSessionFile, taskFilesLength: taskFiles.length, setActiveTheme, setCreationMode, @@ -3057,6 +3074,58 @@ export function AgentChatWorkspace({ currentSessionId: sessionId, switchTopic, }); + const [taskCenterOpenTabMap, setTaskCenterOpenTabMap] = + useState(() => + normalizeTaskCenterWorkspaceTabMap( + loadPersisted(TASK_CENTER_OPEN_TAB_IDS_STORAGE_KEY, []), + { + workspaceId: taskCenterWorkspaceId, + }, + ), + ); + const taskCenterOpenTabIds = useMemo( + () => + resolveTaskCenterTabIdsForWorkspace( + taskCenterOpenTabMap, + taskCenterWorkspaceId, + ), + [taskCenterOpenTabMap, taskCenterWorkspaceId], + ); + const taskCenterOpenTabIdsRef = useRef(taskCenterOpenTabIds); + + useEffect(() => { + taskCenterOpenTabIdsRef.current = taskCenterOpenTabIds; + }, [taskCenterOpenTabIds]); + + useEffect(() => { + if (agentEntry !== "claw" || !taskCenterWorkspaceId) { + return; + } + + setTaskCenterOpenTabMap((currentMap) => { + const nextIds = reconcileTaskCenterTabIds({ + existingIds: resolveTaskCenterTabIdsForWorkspace( + currentMap, + taskCenterWorkspaceId, + ), + topics, + currentTopicId: sessionId ?? null, + }); + return updateTaskCenterTabIdsForWorkspace( + currentMap, + taskCenterWorkspaceId, + nextIds, + ); + }); + }, [agentEntry, sessionId, taskCenterWorkspaceId, topics]); + + useEffect(() => { + if (agentEntry !== "claw") { + return; + } + + savePersisted(TASK_CENTER_OPEN_TAB_IDS_STORAGE_KEY, taskCenterOpenTabMap); + }, [agentEntry, taskCenterOpenTabMap]); useTrayModelShortcuts({ providerType, @@ -3455,14 +3524,99 @@ export function AgentChatWorkspace({ upsertGeneralArtifact(settledLiveArtifact); }, [activeTheme, liveArtifact, settledLiveArtifact, upsertGeneralArtifact]); + const upsertTaskCenterOpenTab = useCallback( + (topicId: string, workspaceIdOverride?: string | null) => { + const targetWorkspaceId = + normalizeProjectId(workspaceIdOverride) ?? taskCenterWorkspaceId; + if (!targetWorkspaceId) { + return; + } + + setTaskCenterOpenTabMap((currentMap) => + updateTaskCenterTabIdsForWorkspace( + currentMap, + targetWorkspaceId, + (currentIds) => + [topicId, ...currentIds.filter((item) => item !== topicId)].slice( + 0, + MAX_TASK_CENTER_OPEN_TABS, + ), + ), + ); + }, + [taskCenterWorkspaceId], + ); + + const finalizeFreshTaskCenterConversation = useCallback( + (newSessionId: string, workspaceIdOverride?: string | null) => { + resetTopicLocalState(); + setInput(""); + setSelectedText(""); + setMentionedCharacters([]); + upsertTaskCenterOpenTab(newSessionId, workspaceIdOverride); + }, + [ + resetTopicLocalState, + setInput, + setMentionedCharacters, + setSelectedText, + upsertTaskCenterOpenTab, + ], + ); + + const handleOpenTaskTopic = useCallback( + async ( + topicId: string, + options?: { + preferResume?: boolean; + forceRefresh?: boolean; + }, + ) => { + const topic = topics.find((item) => item.id === topicId); + const topicWorkspaceId = normalizeProjectId( + topic?.workspaceId ?? + loadPersistedSessionWorkspaceId(topicId) ?? + taskCenterWorkspaceId, + ); + const shouldResume = + options?.preferResume === true || shouldResumeTaskSession(topic); + const switchOptions = + shouldResume || options?.forceRefresh === true + ? { + ...(options?.forceRefresh === true ? { forceRefresh: true } : {}), + ...(shouldResume ? { resumeSessionStartHooks: true } : {}), + } + : undefined; + + await switchTopic(topicId, switchOptions); + if (agentEntry === "claw") { + upsertTaskCenterOpenTab(topicId, topicWorkspaceId); + } + }, + [ + agentEntry, + switchTopic, + taskCenterWorkspaceId, + topics, + upsertTaskCenterOpenTab, + ], + ); + + const handleSwitchTaskTopic = useCallback( + async (topicId: string) => { + await handleOpenTaskTopic(topicId); + }, + [handleOpenTaskTopic], + ); + const handleResumeSidebarTask = useCallback( - async (topicId: string, _statusReason?: TaskStatusReason) => { - await switchTopic(topicId, { - forceRefresh: true, - resumeSessionStartHooks: true, + async (topicId: string, statusReason?: TaskStatusReason) => { + await handleOpenTaskTopic(topicId, { + preferResume: true, + forceRefresh: statusReason === "workspace_error", }); }, - [switchTopic], + [handleOpenTaskTopic], ); const recentSessionTopic = useMemo( @@ -3481,11 +3635,189 @@ export function AgentChatWorkspace({ return; } - void handleResumeSidebarTask( - recentSessionTopic.id, - recentSessionTopic.statusReason, + void handleOpenTaskTopic(recentSessionTopic.id, { + preferResume: true, + forceRefresh: recentSessionTopic.statusReason === "workspace_error", + }); + }, [handleOpenTaskTopic, recentSessionTopic]); + const handleCreateTaskCenterConversation = useCallback(async () => { + if (!taskCenterWorkspaceId) { + toast.info("请先选择工作区后再新建对话"); + return; + } + + const newSessionId = await createFreshSession(); + if (!newSessionId) { + return; + } + + finalizeFreshTaskCenterConversation(newSessionId, taskCenterWorkspaceId); + }, [ + createFreshSession, + finalizeFreshTaskCenterConversation, + taskCenterWorkspaceId, + ]); + const handleCloseTaskCenterTab = useCallback( + async (topicId: string) => { + const currentIds = taskCenterOpenTabIdsRef.current; + const currentIndex = currentIds.indexOf(topicId); + const remainingIds = currentIds.filter((item) => item !== topicId); + const isActiveTab = sessionId === topicId; + + setTaskCenterOpenTabMap((currentMap) => + updateTaskCenterTabIdsForWorkspace( + currentMap, + taskCenterWorkspaceId, + remainingIds, + ), + ); + + if (isActiveTab) { + const fallbackId = + remainingIds[currentIndex] ?? + remainingIds[currentIndex - 1] ?? + remainingIds[0] ?? + null; + + if (fallbackId) { + await handleSwitchTaskTopic(fallbackId); + } else { + const newSessionId = await createFreshSession(); + if (newSessionId) { + finalizeFreshTaskCenterConversation( + newSessionId, + taskCenterWorkspaceId, + ); + } else { + upsertTaskCenterOpenTab(topicId, taskCenterWorkspaceId); + } + } + } + }, + [ + createFreshSession, + finalizeFreshTaskCenterConversation, + handleSwitchTaskTopic, + sessionId, + taskCenterWorkspaceId, + upsertTaskCenterOpenTab, + ], + ); + const taskCenterTabItems = useMemo( + () => + taskCenterOpenTabIds + .map((topicId) => topics.find((topic) => topic.id === topicId)) + .filter((topic): topic is NonNullable => Boolean(topic)) + .map((topic) => ({ + id: topic.id, + title: + resolveInternalImageTaskDisplayName(topic.title) || "未命名任务", + status: topic.status ?? "done", + updatedAt: + topic.updatedAt instanceof Date + ? topic.updatedAt + : new Date(topic.updatedAt ?? topic.createdAt ?? Date.now()), + isActive: topic.id === sessionId, + hasUnread: Boolean(topic.hasUnread), + isPinned: Boolean(topic.isPinned), + })), + [sessionId, taskCenterOpenTabIds, topics], + ); + useEffect(() => { + if ( + agentEntry !== "claw" || + !taskCenterWorkspaceId || + isAutoRestoringSession + ) { + return; + } + + const activeSessionVisibleInWorkspace = sessionId + ? topics.some((topic) => topic.id === sessionId) + : false; + if (activeSessionVisibleInWorkspace) { + return; + } + + const fallbackId = taskCenterOpenTabIds.find((topicId) => + topics.some((topic) => topic.id === topicId), ); - }, [handleResumeSidebarTask, recentSessionTopic]); + if (!fallbackId) { + return; + } + + void handleSwitchTaskTopic(fallbackId); + }, [ + agentEntry, + handleSwitchTaskTopic, + isAutoRestoringSession, + sessionId, + taskCenterOpenTabIds, + taskCenterWorkspaceId, + topics, + ]); + const taskCenterTabsNode = useMemo(() => { + if (agentEntry !== "claw") { + return null; + } + + return ( + { + void handleSwitchTaskTopic(topicId); + }} + onCloseTask={(topicId) => { + void handleCloseTaskCenterTab(topicId); + }} + onCreateTask={() => { + void handleCreateTaskCenterConversation(); + }} + showCanvasToggle={!isThemeWorkbench} + isCanvasOpen={layoutMode !== "chat"} + onToggleCanvas={handleToggleCanvas} + /> + ); + }, [ + agentEntry, + handleCreateTaskCenterConversation, + handleCloseTaskCenterTab, + handleToggleCanvas, + handleSwitchTaskTopic, + isThemeWorkbench, + layoutMode, + taskCenterTabItems, + ]); + const handleOpenTaskCenterNewTaskPage = useCallback(() => { + if (_onNavigate) { + _onNavigate( + "agent", + buildHomeAgentParams({ + projectId: projectId ?? undefined, + }), + ); + return; + } + + handleBackHome(); + }, [_onNavigate, handleBackHome, projectId]); + const handleOpenTaskCenterSkillsPage = useCallback(() => { + if (!_onNavigate) { + return; + } + + _onNavigate( + "skills", + projectId + ? { + creationProjectId: projectId, + } + : undefined, + ); + }, [_onNavigate, projectId]); + const handleOpenTaskCenterMemoryPage = useCallback(() => { + _onNavigate?.("memory"); + }, [_onNavigate]); const handleWriteFile = useWorkspaceWriteFileAction({ activeTheme, @@ -3977,7 +4309,7 @@ export function AgentChatWorkspace({ referenceEntries: defaultCuratedTaskReferenceEntries, }); if (!followUpAction) { - toast.error("当前还没有足够的项目结果基线,暂时无法直接进入复盘。"); + toast.error("当前还没有足够的项目结果基线,暂时无法直接进入下一步判断。"); return; } @@ -3993,7 +4325,7 @@ export function AgentChatWorkspace({ taskId, }); if (!followUpAction) { - toast.error("当前复盘建议还缺少可继续的结果模板。"); + toast.error("当前判断建议还缺少可继续的结果模板。"); return; } @@ -4151,7 +4483,7 @@ export function AgentChatWorkspace({ action, sceneTitle: sceneAppExecutionSummaryState?.summary?.title, failureSignal: sceneAppExecutionFailureSignal, - sourceLabel: "生成主执行面", + sourceLabel: "生成", }), { closeDialog: false, @@ -4234,7 +4566,7 @@ export function AgentChatWorkspace({ const target = resolveSceneAppRuntimeArtifactOpenTarget({ entry: artifactEntry, fallbackProjectId: projectId, - bannerPrefix: "已从生成主执行面打开结果文件", + bannerPrefix: "已从生成打开结果文件", }); if (!target) { toast.error("当前这次运行还没有可打开的结果文件路径。"); @@ -4279,7 +4611,7 @@ export function AgentChatWorkspace({ artifactEntry.artifactRef.kind, ); if (!refreshed) { - toast.error("当前运行已不存在,无法继续准备治理文件。"); + toast.error("当前运行已不存在,无法继续准备结果材料。"); return; } @@ -4301,7 +4633,7 @@ export function AgentChatWorkspace({ const target = resolveSceneAppRuntimeArtifactOpenTarget({ entry: resolvedEntry, fallbackProjectId: projectId, - bannerPrefix: "已从生成主执行面打开治理文件", + bannerPrefix: "已从生成打开结果材料", }); if (!target) { toast.error("当前这次运行还没有可打开的证据或复核文件。"); @@ -4348,7 +4680,7 @@ export function AgentChatWorkspace({ sceneAppReviewTargetRunSummary?.runId?.trim() || ""; if (!runId) { - toast.error("当前还没有可用于治理动作的运行样本。"); + toast.error("当前还没有可用于后续动作的运行样本。"); return; } @@ -4361,7 +4693,7 @@ export function AgentChatWorkspace({ action.artifactKinds, ); if (!refreshed) { - toast.error("当前运行已不存在,无法继续准备治理动作。"); + toast.error("当前运行已不存在,无法继续准备后续动作。"); return; } @@ -4377,11 +4709,11 @@ export function AgentChatWorkspace({ const target = resolveSceneAppRuntimeArtifactOpenTarget({ entry: targetEntry, fallbackProjectId: projectId, - bannerPrefix: "已从生成主执行面打开治理动作", + bannerPrefix: "已从生成打开后续动作", }); if (!target) { toast.error( - `治理动作已准备完成,但当前没有可打开的${action.primaryArtifactLabel}路径。`, + `后续动作已准备完成,但当前没有可打开的${action.primaryArtifactLabel}路径。`, ); return; } @@ -4423,7 +4755,7 @@ export function AgentChatWorkspace({ action, sceneappId: sceneAppExecutionSummaryState.summary.sceneappId, sceneTitle: sceneAppExecutionSummaryState.summary.title, - sourceLabel: "生成主执行面", + sourceLabel: "生成", projectId, linkedServiceSkillId: sceneAppExecutionSummaryState.summary.descriptorSnapshot @@ -4967,14 +5299,16 @@ export function AgentChatWorkspace({ const hasUnconsumedInitialDispatch = !shouldUseCompactGeneralWorkbench && isBootstrapDispatchPending; - const showChatLayout = - agentEntry === "claw" || - hasDisplayMessages || - hasPendingA2UIForm || - isThemeWorkbench || - hasUnconsumedInitialDispatch || - isSending || - queuedTurns.length > 0; + const showChatLayout = shouldShowChatLayout({ + agentEntry, + hasDisplayMessages, + hasPendingA2UIForm, + isThemeWorkbench, + hasUnconsumedInitialDispatch, + isPreparingSend, + isSending, + queuedTurnCount: queuedTurns.length, + }); const shouldHideGeneralWorkbenchInputForTheme = shouldUseCompactGeneralWorkbench; @@ -5022,6 +5356,7 @@ export function AgentChatWorkspace({ hasPendingA2UIForm, hideTopBar, isBootstrapDispatchPending, + isPreparingSend, isSending, isSpecializedThemeMode, isThemeWorkbench, @@ -5555,6 +5890,7 @@ export function AgentChatWorkspace({ recentSessionActionLabel, handleResumeRecentSession, handleOpenSceneAppsDirectory, + taskCenterTabsNode, hideHistoryToggle, showChatPanel: effectiveShowChatPanel, topBarChrome, @@ -5653,7 +5989,10 @@ export function AgentChatWorkspace({ currentTopicId={sessionId ?? null} topics={topics} onNewChat={shellHandleBackHome} - onSwitchTopic={switchTopic} + onOpenTaskCenterHome={handleOpenTaskCenterNewTaskPage} + onOpenSkillsPage={handleOpenTaskCenterSkillsPage} + onOpenMemoryPage={handleOpenTaskCenterMemoryPage} + onSwitchTopic={handleSwitchTaskTopic} onResumeTask={handleResumeSidebarTask} onDeleteTopic={deleteTopic} onRenameTopic={renameTopic} diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index 49dc48a70..04a5420b3 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -465,6 +465,34 @@ describe("AgentThreadReliabilityPanel", () => { expect(onInterruptCurrentTurn).toHaveBeenCalledTimes(1); }); + it("应展示当前 runtime 路由事实与 OEM 约束", () => { + const container = renderPanel({ + threadRead: { + thread_id: "thread-1", + status: "running", + decision_reason: + "当前 provider 候选池共有 3 个兼容候选,已按连续性、能力与成本优选。", + fallback_chain: ["openai:gpt-5.4", "openai:gpt-5.4-mini"], + oem_policy: { + locked: true, + quotaLow: true, + defaultModel: "claude-sonnet-4", + }, + runtime_summary: { + decisionReason: + "当前 provider 候选池共有 3 个兼容候选,已按连续性、能力与成本优选。", + }, + }, + }); + + expect(container.textContent).toContain("当前路由事实"); + expect(container.textContent).toContain("决策原因"); + expect(container.textContent).toContain("回退链"); + expect(container.textContent).toContain("OEM 托管锁定"); + expect(container.textContent).toContain("OEM 额度偏低"); + expect(container.textContent).toContain("claude-sonnet-4"); + }); + it("应展示最近文件快照摘要", () => { const container = renderPanel({ threadRead: { diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index 11241c35c..e7fa28e12 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -995,6 +995,18 @@ export const AgentThreadReliabilityPanel: React.FC< : view.summary; const latestCompactionBoundary = threadRead?.latest_compaction_boundary || null; + const runtimeSummary = threadRead?.runtime_summary || null; + const oemPolicy = threadRead?.oem_policy || null; + const fallbackChain = Array.isArray(threadRead?.fallback_chain) + ? threadRead?.fallback_chain || [] + : Array.isArray((runtimeSummary as { fallbackChain?: unknown } | null)?.fallbackChain) + ? (((runtimeSummary as { fallbackChain?: string[] | null }).fallbackChain || []) as string[]) + : []; + const runtimeDecisionReason = + threadRead?.decision_reason || + ((runtimeSummary as { decisionReason?: string | null } | null) + ?.decisionReason ?? + null); const latestCompactionCreatedLabel = formatDiagnosticDateTime( latestCompactionBoundary?.created_at, ); @@ -1391,6 +1403,53 @@ export const AgentThreadReliabilityPanel: React.FC<
+ {runtimeDecisionReason || fallbackChain.length > 0 || oemPolicy ? ( +
+
当前路由事实
+
+ {runtimeDecisionReason ? ( +
+ 决策原因: + {runtimeDecisionReason} +
+ ) : null} + {fallbackChain.length > 0 ? ( +
+ 回退链: + {fallbackChain.join(" → ")} +
+ ) : null} + {oemPolicy ? ( +
+ {(oemPolicy as { locked?: boolean | null }).locked ? ( + + OEM 托管锁定 + + ) : null} + {(oemPolicy as { quotaLow?: boolean | null }).quotaLow ? ( + + OEM 额度偏低 + + ) : null} + {((oemPolicy as { defaultModel?: string | null }).defaultModel || + (oemPolicy as { selectedModel?: string | null }).selectedModel) ? ( + + OEM 模型 {((oemPolicy as { defaultModel?: string | null }).defaultModel || + (oemPolicy as { selectedModel?: string | null }).selectedModel)!} + + ) : null} +
+ ) : null} +
+
+ ) : null} + {(canInterrupt && onInterruptCurrentTurn) || (view.pendingRequests.length > 0 && onReplayPendingRequest) || (view.nextQueuedTurn && onResumeThread) || diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx index 21a66168c..ec03b6dce 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.test.tsx @@ -1360,12 +1360,12 @@ describe("CanvasWorkbenchLayout", () => { renderPreview, teamView: { enabled: true, - title: "生成工作台", + title: "生成", subtitle: "任务进行时", badges: [ { key: "team-runtime", - label: "生成工作台", + label: "生成", tone: "accent", }, { @@ -1401,7 +1401,7 @@ describe("CanvasWorkbenchLayout", () => { ).not.toBeNull(); expect( container.querySelector( - 'button[aria-label="切换画布标签-生成工作台"]', + 'button[aria-label="切换画布标签-生成"]', ), ).not.toBeNull(); expect(renderPreview).toHaveBeenCalled(); @@ -1441,7 +1441,7 @@ describe("CanvasWorkbenchLayout", () => { renderPreview, teamView: { enabled: true, - title: "生成工作台", + title: "生成", subtitle: "任务进行时", autoFocusToken: 1, renderPreview: () =>
unused-team-preview
, diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index ce470478f..7eb50c3e1 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -1438,7 +1438,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ } return { kind: "team-workbench", - title: teamView.title || "生成工作台", + title: teamView.title || "生成", }; }, [teamView]); @@ -1582,7 +1582,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ label: teamView.tabLabel?.trim() || teamView.title?.trim() || - "生成工作台", + "生成", badge: teamView.tabBadge?.trim() || teamView.triggerState?.label?.trim() || @@ -2029,7 +2029,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({
{teamView?.panelCopy?.emptyText || - "当前没有可展示的生成工作台。"} + "当前没有可展示的生成结果。"}
); diff --git a/src/components/agent/chat/components/ChatNavbar.test.tsx b/src/components/agent/chat/components/ChatNavbar.test.tsx index 2bfe3dc36..4df2b26bb 100644 --- a/src/components/agent/chat/components/ChatNavbar.test.tsx +++ b/src/components/agent/chat/components/ChatNavbar.test.tsx @@ -145,27 +145,95 @@ describe("ChatNavbar", () => { ).toBeNull(); }); - it("生成顶栏应展示轻量上下文提示", () => { + it("任务中心折叠顶栏应隐藏左侧重导航,但保留右侧项目与工具入口", () => { const container = renderChatNavbar({ - entryContextLabel: "生成", - entryContextHint: "在这里继续推进当前创作、回看最近结果和旧历史。", + collapseChrome: true, + onBackHome: vi.fn(), + onBackToResources: vi.fn(), + onBackToProjectManagement: vi.fn(), + onToggleSettings: vi.fn(), + showHistoryToggle: true, + showCanvasToggle: true, + projectId: "project-1", + workspaceType: "general", + showContextCompactionAction: true, }); - expect(container.textContent).toContain("生成"); - expect(container.textContent).toContain("在这里继续推进当前创作、回看最近结果和旧历史。"); + expect( + container.querySelector('button[aria-label="返回新建任务"]'), + ).toBeNull(); + expect(container.textContent).not.toContain("返回资源"); + expect(container.textContent).not.toContain("项目管理"); + expect(container.querySelector('[aria-label="切换历史"]')).toBeNull(); + expect(container.querySelector('[aria-label="展开画布"]')).toBeNull(); + expect( + container.querySelector('[data-testid="project-selector"]'), + ).not.toBeNull(); + expect(container.querySelector('[aria-label="打开设置"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="压缩上下文"]')).not.toBeNull(); }); - it("紧凑顶栏应只保留生成标签,不额外展开说明", () => { + it("任务中心顶栏应渲染第一层 workspace tab bar", () => { + const onBackToProjectManagement = vi.fn(); const container = renderChatNavbar({ - chrome: "workspace-compact", - entryContextLabel: "生成", - entryContextHint: "在这里继续推进当前创作、回看最近结果和旧历史。", + contextVariant: "task-center", + projectId: "project-1", + workspaceType: "general", + onBackToProjectManagement, + onToggleSettings: vi.fn(), }); - expect(container.textContent).toContain("生成"); - expect(container.textContent).not.toContain( - "在这里继续推进当前创作、回看最近结果和旧历史。", + expect( + container.querySelector('[data-testid="task-center-workspace-bar"]'), + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="project-selector"]'), + ).not.toBeNull(); + expect(mockProjectSelector).toHaveBeenCalledWith( + expect.objectContaining({ + chrome: "workspace-tab", + open: false, + passiveTrigger: true, + }), ); + expect( + container.querySelector('[data-testid="task-center-workspace-menu-trigger"]'), + ).not.toBeNull(); + expect(container.querySelector('[aria-label="打开设置"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="切换历史"]')).toBeNull(); + + act(() => { + ( + container.querySelector( + 'button[aria-label="展开工作区菜单"]', + ) as HTMLButtonElement | null + )?.click(); + }); + + expect(onBackToProjectManagement).not.toHaveBeenCalled(); + expect(mockProjectSelector).toHaveBeenLastCalledWith( + expect.objectContaining({ + chrome: "workspace-tab", + open: true, + passiveTrigger: true, + }), + ); + }); + + it("任务中心顶栏应保留 Harness 状态入口", () => { + const container = renderChatNavbar({ + contextVariant: "task-center", + showHarnessToggle: true, + harnessPendingCount: 3, + }); + + const button = container.querySelector( + 'button[aria-label="展开Harness"]', + ) as HTMLButtonElement | null; + + expect(button).not.toBeNull(); + expect(button?.textContent).toContain("Harness"); + expect(button?.textContent).toContain("3"); }); it("点击顶栏按钮后应切换 Harness 面板显隐", () => { diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index c024ebfe6..02f99866a 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -6,6 +6,7 @@ import { Home, PanelRightClose, PanelRightOpen, + Plus, Settings2, Sparkles, } from "lucide-react"; @@ -17,6 +18,8 @@ import { Navbar } from "../styles"; interface ChatNavbarProps { isRunning: boolean; chrome?: "full" | "workspace-compact"; + collapseChrome?: boolean; + contextVariant?: "default" | "task-center"; entryContextLabel?: string; entryContextHint?: string; onToggleHistory: () => void; @@ -57,9 +60,20 @@ const toolbarGhostIconButtonClassName = const toolbarTextButtonClassName = "gap-1.5 text-slate-700 hover:bg-white hover:text-slate-900"; +const taskCenterChromeShellClassName = + "flex w-full items-center justify-between gap-1.5 rounded-t-[18px] border border-b-0 border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.96)_64%,rgba(240,249,255,0.88)_100%)] px-2 pt-1"; + +const taskCenterIconButtonClassName = + "h-7 w-7 rounded-[10px] border border-transparent bg-transparent text-slate-500 shadow-none transition-[background-color,color] hover:bg-slate-100 hover:text-slate-900"; + +const taskCenterPillButtonClassName = + "h-7 rounded-[10px] border border-transparent bg-transparent px-2 text-[11px] font-medium text-slate-700 shadow-none transition-[background-color,color] hover:bg-slate-100 hover:text-slate-900"; + export const ChatNavbar: React.FC = ({ isRunning: _isRunning, chrome = "full", + collapseChrome = false, + contextVariant = "default", entryContextLabel, entryContextHint, onToggleHistory, @@ -85,40 +99,170 @@ export const ChatNavbar: React.FC = ({ contextCompactionRunning = false, onCompactContext, }) => { + const [workspaceSelectorOpen, setWorkspaceSelectorOpen] = React.useState(false); + const isTaskCenterChrome = contextVariant === "task-center"; const isWorkspaceCompact = chrome === "workspace-compact"; + const effectiveCollapseChrome = collapseChrome && !isTaskCenterChrome; const groupClassName = cn( toolbarGroupClassName, - isWorkspaceCompact && "rounded-[18px] p-1", + (isWorkspaceCompact || effectiveCollapseChrome) && "rounded-[18px] p-1", + effectiveCollapseChrome && + "border-slate-200/70 bg-white shadow-sm shadow-slate-950/4 backdrop-blur-0", ); const dividerClassName = cn( toolbarDividerClassName, - isWorkspaceCompact && "mx-1 h-5", + (isWorkspaceCompact || effectiveCollapseChrome) && "mx-1 h-5", ); const embeddedButtonClassName = cn( toolbarEmbeddedButtonClassName, - isWorkspaceCompact && "h-8 rounded-[18px] px-3", + (isWorkspaceCompact || effectiveCollapseChrome) && "h-8 rounded-[18px] px-3", ); const ghostIconButtonClassName = cn( toolbarGhostIconButtonClassName, - isWorkspaceCompact && "h-8 w-8 rounded-[18px]", + (isWorkspaceCompact || effectiveCollapseChrome) && "h-8 w-8 rounded-[18px]", ); const showStatusTools = showHarnessToggle || showContextCompactionAction; const showNavigationTools = + !effectiveCollapseChrome && !isWorkspaceCompact && (Boolean(onBackHome) || Boolean(onBackToResources) || Boolean(onBackToProjectManagement)); - const showWorkspaceTools = showHistoryToggle || showCanvasToggle; - const showProjectSelector = !isWorkspaceCompact; + const showWorkspaceTools = + !effectiveCollapseChrome && (showHistoryToggle || showCanvasToggle); + const showProjectSelector = !isWorkspaceCompact && !isTaskCenterChrome; const showCompactSettingsButton = - isWorkspaceCompact && Boolean(onToggleSettings); - const compactProjectSelectorClassName = isWorkspaceCompact - ? "min-w-[184px] max-w-[248px]" - : "min-w-[196px] max-w-[280px]"; + isWorkspaceCompact && !isTaskCenterChrome && Boolean(onToggleSettings); + const compactProjectSelectorClassName = + isWorkspaceCompact || effectiveCollapseChrome + ? "min-w-[184px] max-w-[248px]" + : "min-w-[196px] max-w-[280px]"; const showEntryContext = Boolean(entryContextLabel); + if (isTaskCenterChrome) { + return ( + +
+
+ onProjectChange?.(nextProjectId)} + open={workspaceSelectorOpen} + onOpenChange={setWorkspaceSelectorOpen} + passiveTrigger + workspaceType={workspaceType} + placeholder="选择工作区" + dropdownSide="bottom" + dropdownAlign="start" + enableManagement={workspaceType === "general"} + density="compact" + chrome="workspace-tab" + className="w-auto max-w-[280px]" + /> +
+ +
+
+ +
+ {showContextCompactionAction ? ( + + ) : null} + + {showHarnessToggle ? ( + + ) : null} + + {onToggleSettings ? ( + + ) : null} +
+
+
+ ); + } + return ( - +
{showNavigationTools ? (
@@ -214,7 +358,7 @@ export const ChatNavbar: React.FC = ({ )} >
- + {entryContextLabel} {!isWorkspaceCompact && entryContextHint ? ( @@ -244,17 +388,21 @@ export const ChatNavbar: React.FC = ({ chrome="embedded" className={compactProjectSelectorClassName} /> -
= ({ data-testid="chat-sidebar-scroll-area" >
- {contextVariant === "task-center" && - taskCenterContinuationState.primary ? ( -
-
-
-
- 继续最近会话 -
-

- 最近一次停在哪、当前在等什么,这里会直接带你回到现场。 -

-
- - 上下文已保留 - -
- - - - {taskCenterContinuationState.related.length > 0 ? ( -
- {taskCenterContinuationState.related.map((item) => ( - - ))} -
- ) : null} -
- ) : null} - {shouldShowTeamSection ? (
= ({
-
-
-
- {taskHeadingLabel} + {isTaskCenter ? ( + {taskHeadingLabel} + ) : ( +
+
+
+ {taskHeadingLabel} +
+ {taskHeadingHint ? ( +

+ {taskHeadingHint} +

+ ) : null} +
+
+ {taskCountLabel}
- {taskHeadingHint ? ( -

- {taskHeadingHint} -

- ) : null}
-
- {searchKeyword.trim() - ? `${filteredTaskItems.length} 个结果` - : `${topics.length} 个任务`} -
-
+ )}
{!hasAnyTasks ? ( @@ -1463,10 +1378,10 @@ export const ChatSidebar: React.FC = ({ ) : !hasFilteredResults ? (
- 没有匹配的任务 + {noResultsTitle}

- 试试搜索标题、执行摘要或状态关键词。 + {noResultsDescription}

) : ( @@ -1550,8 +1465,12 @@ export const ChatSidebar: React.FC = ({ ? "border-amber-200/80 bg-[linear-gradient(180deg,rgba(255,251,235,0.9)_0%,rgba(255,255,255,0.96)_100%)] shadow-sm shadow-amber-950/5 dark:border-amber-500/20 dark:bg-white/10" : "", item.isCurrent - ? "border-emerald-200/60 bg-white/98 ring-1 ring-emerald-50 dark:border-white/15 dark:bg-white/10" - : "border-slate-200/60 bg-white/72 hover:border-emerald-200/60 hover:bg-white/92 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/10 dark:hover:bg-white/5", + ? isTaskCenter + ? "border-slate-300 bg-white ring-1 ring-slate-100 dark:border-white/15 dark:bg-white/10" + : "border-emerald-200/60 bg-white/98 ring-1 ring-emerald-50 dark:border-white/15 dark:bg-white/10" + : isTaskCenter + ? "border-slate-200 bg-white hover:border-slate-300 hover:bg-white dark:border-white/10 dark:bg-white/5 dark:hover:border-white/10 dark:hover:bg-white/5" + : "border-slate-200/60 bg-white/72 hover:border-emerald-200/60 hover:bg-white/92 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/10 dark:hover:bg-white/5", )} >
diff --git a/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx b/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx index fe6d8befd..f3fe62075 100644 --- a/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx +++ b/src/components/agent/chat/components/CuratedTaskLauncherDialog.test.tsx @@ -115,7 +115,7 @@ describe("CuratedTaskLauncherDialog", () => { ); }); - it("应在参考对象卡片上显式区分灵感库与项目结果来源", async () => { + it("应把参考对象来源收成轻量元信息,而不是标签墙", async () => { const task = findCuratedTaskTemplateById("account-project-review"); expect(task).not.toBeNull(); mockListUnifiedMemories.mockResolvedValue([ @@ -165,13 +165,14 @@ describe("CuratedTaskLauncherDialog", () => { }); const dialog = document.body.querySelector('[role="dialog"]'); - expect(dialog?.textContent).toContain("项目结果"); - expect(dialog?.textContent).toContain("灵感库"); + expect(dialog?.textContent).toContain("项目结果 · 成果"); + expect(dialog?.textContent).toContain("灵感库 · 风格"); expect(dialog?.textContent).toContain("AI 内容周报"); expect(dialog?.textContent).toContain("品牌风格样本"); + expect(dialog?.textContent).toContain("相关线索:科技蓝、留白"); }); - it("命中最近复盘偏好的结果模板时,应在 launcher 内显影复盘提示", async () => { + it("命中最近判断偏好的结果模板时,应在 launcher 内显影判断提示", async () => { const task = findCuratedTaskTemplateById("account-project-review"); expect(task).not.toBeNull(); @@ -206,12 +207,12 @@ describe("CuratedTaskLauncherDialog", () => { const banner = document.body.querySelector( '[data-testid="curated-task-launcher-review-feedback-banner"]', ); - expect(banner?.textContent).toContain("围绕最近复盘"); - expect(banner?.textContent).toContain("最近复盘已更新:短视频编排 · 补证据"); + expect(banner?.textContent).toContain("围绕最近判断"); + expect(banner?.textContent).toContain("最近判断已更新:短视频编排 · 补证据"); expect(banner?.textContent).toContain("这轮结果还缺证据"); }); - it("当前 launcher 不是最近复盘首选时,应允许直接切到推荐模板", async () => { + it("当前 launcher 不是最近判断首选时,应允许直接切到推荐模板", async () => { const task = findCuratedTaskTemplateById("daily-trend-briefing"); expect(task).not.toBeNull(); const onApplyReviewSuggestion = vi.fn(); @@ -256,8 +257,8 @@ describe("CuratedTaskLauncherDialog", () => { '[data-testid="curated-task-launcher-review-feedback-banner-action"]', ) as HTMLButtonElement | null; - expect(banner?.textContent).toContain("最近复盘已更新:短视频编排 · 补证据"); - expect(banner?.textContent).toContain("这轮复盘更适合先回到「复盘这个账号/项目」"); + expect(banner?.textContent).toContain("最近判断已更新:短视频编排 · 补证据"); + expect(banner?.textContent).toContain("这轮判断更建议优先回到「复盘这个账号/项目」"); expect(actionButton?.textContent).toContain("改用「复盘这个账号/项目」"); await act(async () => { @@ -303,7 +304,7 @@ describe("CuratedTaskLauncherDialog", () => { "account-project-review": { project_goal: "AI 内容周报", existing_results: - "这轮运行已产出项目结果 当前卡点:复核阻塞 当前判断:先补复核与修复 经营动作:优先准备周会复盘包,再决定是否继续放大。 更适合去向:周会复盘", + "这轮运行已产出项目结果 当前卡点:复核阻塞 当前判断:先补复核与修复 经营动作:优先准备结果对齐包,再决定是否继续放大。 更适合去向:结果对齐", }, }, }, @@ -322,7 +323,7 @@ describe("CuratedTaskLauncherDialog", () => { expect(baselineCard?.textContent).toContain("AI 内容周报"); expect(baselineCard?.textContent).toContain("当前判断:先补复核与修复"); expect(baselineCard?.textContent).toContain("当前卡点:复核阻塞"); - expect(baselineCard?.textContent).toContain("更适合去向:周会复盘"); + expect(baselineCard?.textContent).toContain("更适合去向:结果对齐"); expect(document.body.textContent).toContain( "下面的 账号或项目目标 / 已有结果或数据 已按这轮结果自动带入", ); @@ -350,7 +351,7 @@ describe("CuratedTaskLauncherDialog", () => { "account-project-review": { project_goal: "AI 内容周报", existing_results: - "这轮运行已产出项目结果 当前卡点:复核阻塞 当前判断:先补复核与修复 经营动作:优先准备周会复盘包,再决定是否继续放大。 更适合去向:周会复盘", + "这轮运行已产出项目结果 当前卡点:复核阻塞 当前判断:先补复核与修复 经营动作:优先准备结果对齐包,再决定是否继续放大。 更适合去向:结果对齐", }, }, }, @@ -369,7 +370,7 @@ describe("CuratedTaskLauncherDialog", () => { expect(baselineCard?.textContent).toContain("AI 内容周报"); expect(baselineCard?.textContent).toContain("当前判断:先补复核与修复"); expect(baselineCard?.textContent).toContain("当前卡点:复核阻塞"); - expect(baselineCard?.textContent).toContain("更适合去向:周会复盘"); + expect(baselineCard?.textContent).toContain("更适合去向:结果对齐"); }); it("成果类灵感 reference 进入下游结果模板时,也应带出基线和默认预填", async () => { diff --git a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx index 5c7bf0eda..69fa6413f 100644 --- a/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx +++ b/src/components/agent/chat/components/CuratedTaskLauncherDialog.tsx @@ -13,6 +13,9 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { + summarizeCuratedTaskFollowUpActions, + summarizeCuratedTaskOutputContract, + summarizeCuratedTaskRequiredInputs, findCuratedTaskTemplateById, hasFilledAllCuratedTaskRequiredInputs, resolveCuratedTaskInputValues, @@ -272,6 +275,19 @@ export function CuratedTaskLauncherDialog({ return `我会先给你 ${task.outputHint}。`; }, [task]); + const launcherStarterContract = useMemo(() => { + if (!task) { + return null; + } + + return { + requiredSummary: + summarizeCuratedTaskRequiredInputs(task) || "当前无必填信息", + outputSummary: + summarizeCuratedTaskOutputContract(task) || task.outputHint, + followUpSummary: summarizeCuratedTaskFollowUpActions(task), + }; + }, [task]); const latestReviewTaskSignal = useMemo( () => @@ -344,7 +360,7 @@ export function CuratedTaskLauncherDialog({ } if (task.id === "account-project-review") { - return `下面的 ${carriedFields.join(" / ")} 已按这轮结果自动带入,你可以直接改成这次真正想复盘的版本。`; + return `下面的 ${carriedFields.join(" / ")} 已按这轮结果自动带入,你可以直接改成这次真正想判断的版本。`; } return `下面的 ${carriedFields.join(" / ")} 已按这轮结果自动带入,你可以直接改成这次真正想推进的版本。`; @@ -425,6 +441,26 @@ export function CuratedTaskLauncherDialog({
{launcherOutcomeSummary}
+ {launcherStarterContract ? ( +
+
+ 你先给: + {launcherStarterContract.requiredSummary} +
+
+ 会拿到: + {launcherStarterContract.outputSummary} +
+ {launcherStarterContract.followUpSummary ? ( +
+ + 接着可做: + + {launcherStarterContract.followUpSummary} +
+ ) : null} +
+ ) : null}
@@ -444,10 +480,10 @@ export function CuratedTaskLauncherDialog({ >
- 围绕最近复盘 + 围绕最近判断
- 最近复盘已更新:{reviewFeedbackProjection.signal.title} + 最近判断已更新:{reviewFeedbackProjection.signal.title}
@@ -455,7 +491,7 @@ export function CuratedTaskLauncherDialog({
{primarySuggestedTask - ? `这轮复盘更适合先回到「${primarySuggestedTask.title}」,切过去后我会继续带着当前参考对象。` + ? `这轮判断更建议优先回到「${primarySuggestedTask.title}」,切过去后我会继续带着当前参考对象。` : reviewFeedbackProjection.suggestionText}
{primarySuggestedTask && onApplyReviewSuggestion ? ( @@ -646,21 +682,15 @@ export function CuratedTaskLauncherDialog({ >
-
- - {getCuratedTaskReferenceSourceLabel(entry)} - - - {entry.categoryLabel} - - {entry.tags.slice(0, 2).map((tag) => ( - - {tag} - - ))} +
+ {[ + getCuratedTaskReferenceSourceLabel(entry), + entry.categoryLabel, + ] + .filter((segment): segment is string => + Boolean(segment && segment.trim()), + ) + .join(" · ")}
{entry.title} @@ -668,6 +698,11 @@ export function CuratedTaskLauncherDialog({
{entry.summary}
+ {entry.tags.length > 0 ? ( +
+ 相关线索:{entry.tags.slice(0, 2).join("、")} +
+ ) : null} {entryReviewHighlights.length > 0 ? (
diff --git a/src/components/agent/chat/components/EmptyState.test.tsx b/src/components/agent/chat/components/EmptyState.test.tsx index 43f58bdff..d75e7f527 100644 --- a/src/components/agent/chat/components/EmptyState.test.tsx +++ b/src/components/agent/chat/components/EmptyState.test.tsx @@ -468,6 +468,7 @@ describe("EmptyState", () => { expect(container.textContent).toContain( "跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。", ); + expect(container.textContent).not.toContain("通用对话"); expect(container.textContent).not.toContain("新建任务"); }); @@ -482,17 +483,17 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("从这里开始"); + expect(container.textContent).toContain("先开始这一轮"); expect(container.textContent).toContain( - "先拿一个结果开工,后面继续补参考、改方向、扩成更多版本,都还在同一轮里推进。", + "先选一个结果起手;后面的补参考、改方向和续做,都留在这一轮里继续推进。", ); - expect(container.textContent).toContain("我建议先做这个"); - expect(container.textContent).toContain("也可以换个结果开始"); + expect(container.textContent).toContain("先从这个开始"); + expect(container.textContent).toContain("其他起手结果"); expect(container.textContent).toContain( - "已经知道做法时,也可以直接这样开工", + "也可以直接按做法开工", ); expect(container.textContent).toContain( - "本轮产出会先写回当前任务,跑通过的方法会继续留在这里。", + "本轮产出会先写回当前任务,后续继续也会优先从这里接回。", ); expect(container.textContent).toContain("每日趋势摘要"); expect(container.textContent).toContain("内容主稿生成"); @@ -507,6 +508,7 @@ describe("EmptyState", () => { expect(container.textContent).not.toContain("快捷做法"); expect(container.textContent).not.toContain("任务拆分冒烟测试"); expect(container.textContent).not.toContain("推荐方案"); + expect(container.textContent).not.toContain("通用对话"); const recommendationItems = Array.from( container.querySelectorAll( @@ -516,7 +518,7 @@ describe("EmptyState", () => { expect(recommendationItems).toHaveLength(8); expect(recommendationItems[0]).toContain("每日趋势摘要"); - expect(recommendationItems[0]).toContain("我建议先做这个"); + expect(recommendationItems[0]).toContain("先从这个开始"); expect(recommendationItems[0]).toContain("趋势摘要、3 个优先选题"); expect(recommendationItems[0]).toContain( "趋势摘要会先写回当前内容,方便继续展开选题和主稿。", @@ -533,7 +535,7 @@ describe("EmptyState", () => { ).toBeNull(); }); - it("保存复盘结论后首页应围绕最近复盘重排结果模板", async () => { + it("保存判断结论后首页应围绕最近判断重排结果模板", async () => { const projectId = "project-review-recommendation"; const container = renderEmptyState({ activeTheme: "general", @@ -546,7 +548,7 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).not.toContain("围绕最近复盘"); + expect(container.textContent).not.toContain("围绕最近判断"); expect( container.querySelector('[data-testid="entry-review-feedback-banner"]'), ).toBeNull(); @@ -592,14 +594,14 @@ describe("EmptyState", () => { ); expect(reviewCard?.textContent).toContain("复盘这个账号/项目"); - expect(reviewCard?.textContent).toContain("复盘:短视频编排"); + expect(reviewCard?.textContent).toContain("判断:短视频编排"); expect(breakdownCard?.textContent).toContain("拆解一条爆款内容"); - expect(breakdownCard?.textContent).toContain("复盘:短视频编排"); - expect(reviewBanner?.textContent).toContain("最近复盘已更新"); + expect(breakdownCard?.textContent).toContain("判断:短视频编排"); + expect(reviewBanner?.textContent).toContain("最近判断已更新"); expect(reviewBanner?.textContent).toContain("短视频编排 · 补证据"); expect(reviewBanner?.textContent).toContain("这轮结果还缺证据"); expect(reviewBanner?.textContent).toContain( - "这轮复盘更建议优先回到「复盘这个账号/项目」或「拆解一条爆款内容」", + "这轮判断更建议优先回到「复盘这个账号/项目」或「拆解一条爆款内容」", ); expect(reviewBanner?.textContent).toContain( "更适合继续:复盘这个账号/项目 / 拆解一条爆款内容", @@ -704,12 +706,14 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("已经知道做法时,也可以直接这样开工"); + expect(container.textContent).toContain("也可以直接按做法开工"); const trendCard = container.querySelector( '[data-testid="entry-service-skill-daily-trend-briefing"]', ); expect(trendCard?.textContent).toContain("每日趋势摘要"); + expect(trendCard?.textContent).not.toContain("云目录"); + expect(trendCard?.textContent).not.toContain("最近使用"); expect(trendCard?.textContent).toContain("需要:当前无必填信息"); expect(trendCard?.textContent).toContain("交付:趋势摘要 + 调度建议"); @@ -953,7 +957,7 @@ describe("EmptyState", () => { metadata: { lime_when_to_use: "当你需要继续复用这套内容主稿方法时使用。", lime_argument_hint: "主题、受众与复盘约束", - lime_output_hint: "带着这套内容主稿方法进入生成主执行面", + lime_output_hint: "带着这套内容主稿方法进入生成", }, }; @@ -984,7 +988,7 @@ describe("EmptyState", () => { "需要:主题、受众与复盘约束", ); expect(installedSkillButton?.textContent).toContain( - "交付:带着这套内容主稿方法进入生成主执行面", + "交付:带着这套内容主稿方法进入生成", ); await act(async () => { @@ -993,7 +997,7 @@ describe("EmptyState", () => { }); expect(setInput).toHaveBeenCalledWith("继续优化这套内容主稿"); - expect(container.textContent).toContain("已挂载 内容主稿方法"); + expect(container.textContent).not.toContain("已挂载 内容主稿方法"); }); it("同页内新增本地 skill 使用记录后,首页继续上次做法应即时刷新", async () => { @@ -1283,7 +1287,7 @@ describe("EmptyState", () => { ); }); - it("当前激活模板不是最近复盘首选时,输入区 badge 应可直接切到推荐模板", async () => { + it("当前激活模板不是最近判断首选时,输入区 badge 应可直接切到推荐模板", async () => { const projectId = "project-active-badge-review-switch"; const container = renderEmptyState({ activeTheme: "general", @@ -1356,7 +1360,7 @@ describe("EmptyState", () => { expect(document.body.textContent).toContain("复盘这个账号/项目"); expect(document.body.textContent).toContain( - "已按最近复盘切到更适合的结果模板", + "已按最近判断切到更适合的结果模板", ); }); @@ -1604,10 +1608,13 @@ describe("EmptyState", () => { mockCharacterMention.mock.calls.length - 1 ][0]; expect(latestCall.serviceSkills).toEqual(serviceSkills); - expect(typeof latestCall.onSelectServiceSkill).toBe("function"); + expect(typeof latestCall.onSelectInputCapability).toBe("function"); act(() => { - latestCall.onSelectServiceSkill?.(serviceSkills[0]!); + latestCall.onSelectInputCapability?.({ + kind: "service_skill", + skill: serviceSkills[0]!, + }); }); expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkills[0]!); @@ -1657,7 +1664,8 @@ describe("EmptyState", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("已挂载 canvas-design"); + expect(container.textContent).toContain("canvas-design"); + expect(container.textContent).not.toContain("已挂载 canvas-design"); const sendButton = container.querySelector( 'button[aria-label="发送"]', @@ -2047,7 +2055,7 @@ describe("EmptyState", () => { "account-project-review": { project_goal: "AI 内容周报", existing_results: - "这轮运行已产出项目结果,但仍需补齐复核意见。 当前卡点:复核阻塞 当前判断:先补复核与修复 经营动作:优先准备周会复盘包,再决定是否继续放大。 更适合去向:周会复盘", + "这轮运行已产出项目结果,但仍需补齐复核意见。 当前卡点:复核阻塞 当前判断:先补复核与修复 经营动作:优先准备结果对齐包,再决定是否继续放大。 更适合去向:结果对齐", }, }, }, @@ -2060,7 +2068,7 @@ describe("EmptyState", () => { expect(templateButton).toBeTruthy(); expect(container.textContent).toContain("当前判断:先补复核与修复"); expect(container.textContent).toContain("当前卡点:复核阻塞"); - expect(container.textContent).toContain("更适合去向:周会复盘"); + expect(container.textContent).toContain("更适合去向:结果对齐"); act(() => { templateButton?.click(); @@ -2110,7 +2118,7 @@ describe("EmptyState", () => { "account-project-review": { project_goal: "AI 内容周报", existing_results: - "这轮运行已产出项目结果,但仍需补齐复核意见。 当前卡点:复核阻塞 当前判断:先补复核与修复 经营动作:优先准备周会复盘包,再决定是否继续放大。 更适合去向:周会复盘", + "这轮运行已产出项目结果,但仍需补齐复核意见。 当前卡点:复核阻塞 当前判断:先补复核与修复 经营动作:优先准备结果对齐包,再决定是否继续放大。 更适合去向:结果对齐", }, }, }, @@ -2125,7 +2133,7 @@ describe("EmptyState", () => { expect(templateButton?.textContent).toContain("当前结果基线:AI 内容周报"); expect(templateButton?.textContent).toContain("当前判断:先补复核与修复"); expect(templateButton?.textContent).toContain("当前卡点:复核阻塞"); - expect(templateButton?.textContent).toContain("更适合去向:周会复盘"); + expect(templateButton?.textContent).toContain("更适合去向:结果对齐"); }); it("当前带入参考灵感时,结果模板推荐应显式标记为围绕当前参考", async () => { @@ -2385,6 +2393,22 @@ describe("EmptyState", () => { ); }); + it("通用对话默认占位文案应强调本轮任务起手", async () => { + const container = renderEmptyState({ + activeTheme: "general", + serviceSkills: [], + }); + + await act(async () => { + await Promise.resolve(); + }); + + const textarea = container.querySelector("textarea"); + expect(textarea?.getAttribute("placeholder")).toContain( + "先说这轮要做什么", + ); + }); + it("点击高级设置中的地球按钮应切换联网搜索开关", async () => { const onWebSearchEnabledChange = vi.fn<(enabled: boolean) => void>(); const container = renderEmptyState({ @@ -2544,8 +2568,8 @@ describe("EmptyState", () => { expect(container.textContent).not.toContain("编排模式已开启"); expect(container.textContent).not.toContain("联网搜索已开启"); - expect(container.textContent).toContain("从这里开始"); - expect(container.textContent).toContain("我建议先做这个"); + expect(container.textContent).toContain("先开始这一轮"); + expect(container.textContent).toContain("先从这个开始"); }); it("通用首页发送时不应自动注入任何历史默认 skill", async () => { @@ -2720,7 +2744,10 @@ describe("EmptyState", () => { }); act(() => { - latestCall.onSelectServiceSkill?.(serviceSkill); + latestCall.onSelectInputCapability?.({ + kind: "service_skill", + skill: serviceSkill, + }); }); expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkill); diff --git a/src/components/agent/chat/components/EmptyState.tsx b/src/components/agent/chat/components/EmptyState.tsx index 449dda9dd..428629056 100644 --- a/src/components/agent/chat/components/EmptyState.tsx +++ b/src/components/agent/chat/components/EmptyState.tsx @@ -6,7 +6,6 @@ import { } from "lucide-react"; import { getConfig } from "@/lib/api/appConfig"; import type { CreationMode } from "./types"; -import { CREATION_MODE_CONFIG } from "./constants"; import { Button } from "@/components/ui/button"; import { ProjectSelector } from "@/components/projects/ProjectSelector"; import { toast } from "sonner"; @@ -69,9 +68,6 @@ import { getClipboardImageCandidates, readImageAttachment, } from "../utils/imageAttachments"; -import { - getActiveSkillDisplayLabel, -} from "../skill-selection/skillSelectionDisplay"; import { resolveInputCapabilityDispatch, type InputCapabilitySelection, @@ -117,6 +113,18 @@ const PageContainer = styled.div.attrs({ className: EMPTY_STATE_PAGE_CONTAINER_CLASSNAME, })` isolation: isolate; + background: + radial-gradient( + circle at 8% 12%, + rgba(132, 204, 22, 0.08), + rgba(132, 204, 22, 0) 28% + ), + radial-gradient( + circle at 76% 16%, + rgba(186, 230, 253, 0.16), + rgba(186, 230, 253, 0) 30% + ), + linear-gradient(180deg, #f8fcf7 0%, #f9fbf8 36%, #f5faf7 100%); `; const ContentWrapper = styled.div.attrs({ @@ -126,6 +134,7 @@ const ContentWrapper = styled.div.attrs({ flex: 1 1 auto; min-height: 100%; animation: ${contentReveal} 560ms cubic-bezier(0.22, 1, 0.36, 1) both; + padding: 0.45rem 0.25rem 1.25rem; @media (prefers-reduced-motion: reduce) { animation: none; @@ -136,8 +145,12 @@ const RecommendationShelf = styled.div` display: flex; min-width: 0; flex-direction: column; - gap: 0.28rem; - padding: 0 0.2rem 0.05rem; + gap: 0.88rem; + border-radius: 30px; + border: 1px solid rgba(226, 232, 240, 0.92); + background: linear-gradient(180deg, #fcfffb 0%, #f8fcfa 100%); + padding: 1rem 1.05rem 0.95rem; + box-shadow: 0 22px 42px -38px rgba(15, 23, 42, 0.16); `; const RecommendationShelfHeader = styled.div` @@ -158,16 +171,16 @@ const RecommendationShelfHeaderBody = styled.div` `; const RecommendationShelfHeaderTitle = styled.div` - font-size: 11px; + font-size: 13px; font-weight: 600; letter-spacing: 0.02em; - color: rgb(100 116 139); + color: rgb(71 85 105); `; const RecommendationShelfHeaderDescription = styled.div` - font-size: 11px; - line-height: 1.45; - color: rgb(148 163 184); + font-size: 13px; + line-height: 1.55; + color: rgb(100 116 139); `; const RecommendationShelfInlineBadge = styled.span` @@ -251,17 +264,17 @@ const RecommendationLeadCard = styled.button` min-width: 0; flex-direction: column; align-items: flex-start; - gap: 0.45rem; - border-radius: 22px; - border: 1px solid rgb(226 232 240); + gap: 0.72rem; + border-radius: 30px; + border: 1px solid rgba(191, 219, 254, 0.92); background: radial-gradient( circle at top right, - rgba(186, 230, 253, 0.42), + rgba(186, 230, 253, 0.48), rgba(255, 255, 255, 0) 42% ), - linear-gradient(180deg, rgb(255 255 255), rgb(248 250 252)); - padding: 1rem 1rem 0.95rem; + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(240, 249, 255, 0.94)); + padding: 1.22rem 1.25rem 1.18rem; text-align: left; color: rgb(51 65 85); transition: @@ -270,8 +283,8 @@ const RecommendationLeadCard = styled.button` transform 180ms ease; &:hover { - border-color: rgb(186 230 253); - box-shadow: 0 16px 40px -32px rgba(15, 23, 42, 0.28); + border-color: rgb(147 197 253); + box-shadow: 0 18px 42px -30px rgba(59, 130, 246, 0.22); transform: translateY(-1px); } `; @@ -287,27 +300,27 @@ const RecommendationLeadEyebrowRow = styled.div` `; const RecommendationLeadEyebrow = styled.span` - font-size: 11px; + font-size: 12px; font-weight: 600; line-height: 1.4; - color: rgb(14 116 144); + color: rgb(3 105 161); `; const RecommendationLeadTitle = styled.div` - font-size: 17px; + font-size: 18px; font-weight: 600; line-height: 1.4; color: rgb(15 23 42); `; const RecommendationLeadSummary = styled.div` - font-size: 12px; + font-size: 13px; line-height: 1.65; color: rgb(71 85 105); `; const RecommendationLeadMeta = styled.div` - font-size: 11px; + font-size: 12px; line-height: 1.6; color: rgb(100 116 139); `; @@ -316,19 +329,12 @@ const RecommendationLeadFooter = styled.div` display: inline-flex; align-items: center; gap: 0.38rem; - padding-top: 0.1rem; - font-size: 11px; + padding-top: 0.2rem; + font-size: 12px; font-weight: 600; color: rgb(15 23 42); `; -const RecommendationAssistStrip = styled.div` - display: flex; - min-width: 0; - flex-direction: column; - gap: 0.55rem; -`; - const RecommendationAssistGroup = styled.div` display: flex; min-width: 0; @@ -337,20 +343,25 @@ const RecommendationAssistGroup = styled.div` `; const RecommendationAssistLabel = styled.div` - font-size: 11px; + font-size: 13px; font-weight: 600; line-height: 1.45; - color: rgb(100 116 139); + color: rgb(71 85 105); `; const RecommendationAssistList = styled.div` display: grid; - gap: 0.45rem; + gap: 0.55rem; grid-template-columns: minmax(0, 1fr); + align-items: stretch; @media (min-width: 768px) { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + @media (min-width: 1240px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } `; const RecommendationAssistCard = styled.button` @@ -358,11 +369,12 @@ const RecommendationAssistCard = styled.button` min-width: 0; flex-direction: column; align-items: flex-start; - gap: 0.25rem; - border-radius: 16px; - border: 1px solid rgb(226 232 240); - background: rgb(248 250 252); - padding: 0.72rem 0.78rem; + gap: 0.35rem; + border-radius: 22px; + border: 1px solid rgba(219, 234, 254, 0.98); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.92)); + padding: 0.96rem 1rem; text-align: left; color: rgb(51 65 85); transition: @@ -371,7 +383,7 @@ const RecommendationAssistCard = styled.button` transform 180ms ease; &:hover { - border-color: rgb(203 213 225); + border-color: rgb(147 197 253); background: rgb(255 255 255); transform: translateY(-1px); } @@ -387,20 +399,20 @@ const RecommendationAssistCardHeader = styled.div` `; const RecommendationAssistCardTitle = styled.div` - font-size: 12px; + font-size: 15px; font-weight: 600; line-height: 1.45; color: rgb(15 23 42); `; const RecommendationAssistCardSummary = styled.div` - font-size: 11px; + font-size: 12px; line-height: 1.55; color: rgb(100 116 139); `; const RecommendationAssistFootnote = styled.div` - font-size: 11px; + font-size: 12px; line-height: 1.55; color: rgb(148 163 184); `; @@ -409,12 +421,13 @@ const RecommendationSupplementalPanel = styled.div` display: flex; min-width: 0; flex-direction: column; - gap: 0.42rem; - padding: 0.2rem 0.2rem 0; + gap: 0.56rem; + padding-top: 0.2rem; + border-top: 1px solid rgba(226, 232, 240, 0.88); `; const RecommendationSupplementalLabel = styled.div` - font-size: 11px; + font-size: 12px; line-height: 1.5; color: rgb(148 163 184); `; @@ -433,8 +446,8 @@ const RecommendationSupplementalLink = styled.button` border-radius: 9999px; border: 1px solid rgb(226 232 240); background: rgb(255 255 255); - padding: 0.34rem 0.62rem; - font-size: 11px; + padding: 0.42rem 0.78rem; + font-size: 12px; font-weight: 500; line-height: 1.2; color: rgb(71 85 105); @@ -450,6 +463,106 @@ const RecommendationSupplementalLink = styled.button` } `; +const RecommendationTabsRow = styled.div` + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +`; + +const RecommendationTabsRail = styled.div` + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem; +`; + +const RecommendationTabButton = styled.button<{ $active: boolean }>` + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-height: 36px; + border-radius: 9999px; + border: 1px solid + ${({ $active }) => + $active ? "rgba(134, 239, 172, 0.72)" : "rgba(226, 232, 240, 0.94)"}; + background: ${({ $active }) => + $active + ? "linear-gradient(180deg, rgba(240, 253, 244, 0.98), rgba(220, 252, 231, 0.92))" + : "rgba(255, 255, 255, 0.94)"}; + padding: 0.5rem 0.85rem; + font-size: 12px; + font-weight: 600; + line-height: 1; + color: ${({ $active }) => + $active ? "rgb(22 101 52)" : "rgb(71 85 105)"}; + transition: + border-color 180ms ease, + background-color 180ms ease, + color 180ms ease, + transform 180ms ease, + box-shadow 180ms ease; + + &:hover { + border-color: ${({ $active }) => + $active ? "rgba(110, 231, 183, 0.84)" : "rgba(203, 213, 225, 0.96)"}; + color: rgb(15 23 42); + background: ${({ $active }) => + $active + ? "linear-gradient(180deg, rgba(236, 253, 245, 1), rgba(220, 252, 231, 0.98))" + : "rgb(255 255 255)"}; + box-shadow: 0 14px 28px -28px rgba(15, 23, 42, 0.16); + transform: translateY(-1px); + } +`; + +const RecommendationTabCount = styled.span<{ $active: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.35rem; + height: 1.35rem; + border-radius: 9999px; + background: ${({ $active }) => + $active ? "rgba(255, 255, 255, 0.96)" : "rgba(248, 250, 252, 0.98)"}; + border: 1px solid + ${({ $active }) => + $active ? "rgba(134, 239, 172, 0.64)" : "rgba(226, 232, 240, 0.96)"}; + padding: 0 0.35rem; + font-size: 11px; + font-weight: 700; + line-height: 1; + color: ${({ $active }) => + $active ? "rgb(21 128 61)" : "rgb(100 116 139)"}; +`; + +const RecommendationTabCaption = styled.div` + font-size: 12px; + line-height: 1.6; + color: rgb(100 116 139); +`; + +const RecommendationPanels = styled.div` + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.8rem; +`; + +const RecommendationTabPanel = styled.section` + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.78rem; + + &[hidden] { + display: none; + } +`; + interface EmptyStateProps extends SkillSelectionSourceProps { input: string; @@ -593,7 +706,8 @@ interface ContinuationShelfItem { onSelect: () => void; } -const GENERAL_CATEGORY_LABEL = "通用对话"; +type LaunchDeckTab = "recommended" | "continuation" | "methods"; + // 需要显示创作模式选择器的主题 const CREATION_THEMES: string[] = []; @@ -641,14 +755,14 @@ function compareRecentShelfItems< function resolveResultShelfTitle( creationReplaySurface?: CreationReplaySurfaceModel | null, ): string { - return creationReplaySurface ? "沿着当前上下文继续" : "从这里开始"; + return creationReplaySurface ? "沿着当前上下文继续" : "先开始这一轮"; } function resolveResultShelfDescription( creationReplaySurface?: CreationReplaySurfaceModel | null, ): string { if (!creationReplaySurface) { - return "先拿一个结果开工,后面继续补参考、改方向、扩成更多版本,都还在同一轮里推进。"; + return "先选一个结果起手;后面的补参考、改方向和续做,都留在这一轮里继续推进。"; } if (creationReplaySurface.kind === "skill_scaffold") { @@ -662,7 +776,7 @@ function resolveLeadRecommendationEyebrow( creationReplaySurface?: CreationReplaySurfaceModel | null, ): string { if (!creationReplaySurface) { - return "我建议先做这个"; + return "先从这个开始"; } return creationReplaySurface.kind === "skill_scaffold" @@ -670,6 +784,18 @@ function resolveLeadRecommendationEyebrow( : "先沿着当前参考开工"; } +function resolveLaunchDeckTabCaption(tab: LaunchDeckTab): string { + switch (tab) { + case "continuation": + return "把最近跑过的模板、方法和会话接回这一轮,省掉重复起手。"; + case "methods": + return "不想先选结果模板时,也可以直接沿着现成做法开工。"; + case "recommended": + default: + return "先从结果入口起手,后面的补参考、改方向和续做都留在这一轮里。"; + } +} + function buildRecommendationContextSummary( segments: Array, ): string | undefined { @@ -794,9 +920,14 @@ export const EmptyState: React.FC = ({ }, []); const handleSelectInputCapability = useCallback( (capability: InputCapabilitySelection) => { + if (capability.kind === "service_skill") { + setActiveCapability(null); + onSelectServiceSkill?.(capability.skill); + return; + } setActiveCapability(capability); }, - [], + [onSelectServiceSkill], ); const handleSelectServiceSkill = useCallback( (skill: ServiceSkillHomeItem) => { @@ -818,7 +949,6 @@ export const EmptyState: React.FC = ({ onImportSkill, onRefreshSkills, }); - const activeSkillDisplayLabel = getActiveSkillDisplayLabel(currentSkill); const hasAutoLaunchSiteSkill = hasAutoLaunchableSiteSkill(serviceSkills); const siteSkillAutoLaunchExample = getSiteSkillAutoLaunchExample(serviceSkills); @@ -1071,7 +1201,7 @@ export const EmptyState: React.FC = ({ const getPlaceholder = () => { return hasAutoLaunchSiteSkill ? `直接说一句话,例如:${siteSkillAutoLaunchExample}` - : "有什么我可以帮你的?"; + : "先说这轮要做什么,目标、对象或限制都可以。"; }; const handleApplyRecommendation = useCallback( @@ -1166,7 +1296,7 @@ export const EmptyState: React.FC = ({ options.referenceSelection.referenceEntries, ); setCuratedTaskLauncherPrefillHint( - `已按最近复盘切到更适合的结果模板,你可以继续改后再进入生成。`, + `已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。`, ); }, [], @@ -1251,38 +1381,6 @@ export const EmptyState: React.FC = ({ ], ); - const workspaceBadges = useMemo(() => { - const badges: Array<{ - key: string; - label: string; - tone?: "slate" | "sky" | "emerald" | "amber" | "lime"; - }> = [ - { - key: "theme", - label: GENERAL_CATEGORY_LABEL, - tone: "lime", - }, - ]; - - if (showCreationModeSelector) { - badges.push({ - key: "creation-mode", - label: CREATION_MODE_CONFIG[creationMode].name, - tone: "lime", - }); - } - - if (activeSkillDisplayLabel) { - badges.push({ - key: "skill", - label: activeSkillDisplayLabel, - tone: "lime", - }); - } - - return badges.slice(0, 5); - }, [creationMode, showCreationModeSelector, activeSkillDisplayLabel]); - const recommendationShelfItems = useMemo(() => { const curatedTemplateRecommendations = listFeaturedHomeCuratedTaskTemplates( curatedTaskTemplates, @@ -1364,9 +1462,8 @@ export const EmptyState: React.FC = ({ summary: skill.summary?.trim() || resolveServiceSkillEntryDescription(skill), - badge: skill.isRecent ? "最近使用" : skill.badge, hint: requiresSlots - ? "对话内补参后开始" + ? "补齐这一步后开始" : `${skill.actionLabel} · 当前对话继续`, meta: buildServiceSkillCapabilityDescription(skill, { includeSummary: false, @@ -1432,7 +1529,7 @@ export const EmptyState: React.FC = ({ signal: latestReviewRecommendationSignal, }); const highlightedRecommendations = recommendationSolutionItems - .filter((item) => item.reasonLabel === "围绕最近复盘") + .filter((item) => item.reasonLabel === "围绕最近判断") .slice(0, 2); if (highlightedRecommendations.length === 0) { return null; @@ -1602,10 +1699,6 @@ export const EmptyState: React.FC = ({ ]); const directMethodItems = useMemo(() => { - if (continuationShelfItems.length > 0) { - return continuationShelfItems.slice(0, 3); - } - return recommendationServiceSkillItems.slice(0, 3).map((item) => ({ key: item.key, title: item.title, @@ -1614,7 +1707,51 @@ export const EmptyState: React.FC = ({ testId: item.testId, onSelect: item.onSelect, })); - }, [continuationShelfItems, recommendationServiceSkillItems]); + }, [recommendationServiceSkillItems]); + + const preferredLaunchDeckTab = useMemo(() => { + if (leadRecommendationItem) { + return "recommended"; + } + if (continuationShelfItems.length > 0) { + return "continuation"; + } + if (directMethodItems.length > 0) { + return "methods"; + } + return "recommended"; + }, [continuationShelfItems.length, directMethodItems.length, leadRecommendationItem]); + + const [launchDeckTab, setLaunchDeckTab] = + useState(preferredLaunchDeckTab); + + useEffect(() => { + const availableTabs = new Set(); + if (leadRecommendationItem || alternativeRecommendationItems.length > 0) { + availableTabs.add("recommended"); + } + if (continuationShelfItems.length > 0) { + availableTabs.add("continuation"); + } + if (directMethodItems.length > 0) { + availableTabs.add("methods"); + } + + if (availableTabs.size === 0 || availableTabs.has(launchDeckTab)) { + return; + } + + setLaunchDeckTab(preferredLaunchDeckTab); + }, [ + alternativeRecommendationItems.length, + continuationShelfItems.length, + directMethodItems.length, + launchDeckTab, + leadRecommendationItem, + preferredLaunchDeckTab, + ]); + + const launchDeckTabCaption = resolveLaunchDeckTabCaption(launchDeckTab); const shouldShowSceneAppsPanel = sceneAppsLoading || featuredSceneApps.length > 0 || canResumeRecentSceneApp; @@ -1734,7 +1871,7 @@ export const EmptyState: React.FC = ({ effectiveDefaultCuratedTaskReferenceMemoryIds, activeCuratedTaskReferenceEntries || effectiveDefaultCuratedTaskReferenceEntries, - "已按最近复盘切到更适合的结果模板,你可以继续改后再进入生成。", + "已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。", ) : undefined } @@ -1812,7 +1949,7 @@ export const EmptyState: React.FC = ({ - 最近复盘已更新:{reviewFeedbackBanner.title} + 最近判断已更新:{reviewFeedbackBanner.title} @@ -1838,58 +1975,110 @@ export const EmptyState: React.FC = ({ ) : null} - {leadRecommendationItem ? ( - segment.trim().length > 0) - .join(" · ")} - onClick={() => { - leadRecommendationItem.onSelect(); - }} - > - - - {resolveLeadRecommendationEyebrow(creationReplaySurface)} - - {leadRecommendationItem.badge ? ( - - {leadRecommendationItem.badge} - - ) : null} - - - {leadRecommendationItem.title} - - - {leadRecommendationItem.summary} - - {leadRecommendationItem.contextSummary ? ( - - {leadRecommendationItem.contextSummary} - + + + {(leadRecommendationItem || + alternativeRecommendationItems.length > 0) && ( + setLaunchDeckTab("recommended")} + > + 推荐起手 + + {recommendationSolutionItems.length} + + + )} + + {continuationShelfItems.length > 0 ? ( + setLaunchDeckTab("continuation")} + > + 继续上次 + + {continuationShelfItems.length} + + ) : null} - {leadRecommendationItem.meta} - - 开始这一轮 - - - - ) : ( - - 当前还没有可直接推荐的起手结果,你可以直接描述目标,Lime 会先帮你组织这一轮。 - - )} + + {directMethodItems.length > 0 ? ( + setLaunchDeckTab("methods")} + > + 做法入口 + + {directMethodItems.length} + + + ) : null} + + + {launchDeckTabCaption} + + + + + + + + {projectId - ? "本轮产出会沉淀到当前项目,跑通过的方法会继续留在这里。" - : "本轮产出会先写回当前任务,跑通过的方法会继续留在这里。"} + ? "本轮产出会沉淀到当前项目,后续继续也会优先从这里接回。" + : "本轮产出会先写回当前任务,后续继续也会优先从这里接回。"} ); @@ -2066,7 +2290,7 @@ export const EmptyState: React.FC = ({ const headerControls = onProjectChange ? (
-
+
= ({ enableManagement={activeTheme === "general"} density="compact" chrome="embedded" - className="min-w-[180px] max-w-[260px]" + className="min-w-[210px] max-w-[320px]" /> {onOpenSettings ? ( <>
) : null} + {runtimeFactSummary ? ( +
+
+ {runtimeFactSummary.decisionReason ? ( +
+ + 决策原因: + + {runtimeFactSummary.decisionReason} +
+ ) : null} + + {runtimeFactSummary.fallbackChain.length > 0 ? ( +
+ + 回退链: + + {runtimeFactSummary.fallbackChain.join(" → ")} +
+ ) : null} + + {runtimeFactSummary.oemPolicy ? ( +
+
+ {runtimeFactSummary.oemPolicy.locked ? ( + + OEM 托管锁定 + + ) : null} + {runtimeFactSummary.oemPolicy.quotaLow ? ( + + OEM 额度偏低 + + ) : null} + {runtimeFactSummary.oemPolicy.canInvoke === false ? ( + + OEM 当前不可调用 + + ) : null} + {runtimeFactSummary.oemPolicy.fallbackToLocalAllowed === true ? ( + + 允许回退本地 + + ) : null} +
+
+ {runtimeFactSummary.oemPolicy.defaultModel || + runtimeFactSummary.oemPolicy.selectedModel ? ( + + OEM 模型 {runtimeFactSummary.oemPolicy.defaultModel || runtimeFactSummary.oemPolicy.selectedModel} + + ) : null} + {runtimeFactSummary.oemPolicy.quotaStatus ? ( + 额度状态 {runtimeFactSummary.oemPolicy.quotaStatus} + ) : null} + {runtimeFactSummary.oemPolicy.offerState ? ( + 策略状态 {runtimeFactSummary.oemPolicy.offerState} + ) : null} + {runtimeFactSummary.oemPolicy.providerSource ? ( + 来源 {runtimeFactSummary.oemPolicy.providerSource} + ) : null} + {runtimeFactSummary.oemPolicy.providerKey ? ( + Provider Key {runtimeFactSummary.oemPolicy.providerKey} + ) : null} + {runtimeFactSummary.oemPolicy.tenantId ? ( + 租户 {runtimeFactSummary.oemPolicy.tenantId} + ) : null} +
+
+ ) : null} +
+
+ ) : null} + {harnessState.activeFileWrites.length > 0 ? (
{ + if (capability.kind === "service_skill") { + handleSelectServiceSkill(capability.skill); + return; + } setActiveCapability(capability); }; const skillSelection = buildSkillSelectionProps({ diff --git a/src/components/agent/chat/components/Inputbar/index.test.tsx b/src/components/agent/chat/components/Inputbar/index.test.tsx index bb878b477..6ce5d2055 100644 --- a/src/components/agent/chat/components/Inputbar/index.test.tsx +++ b/src/components/agent/chat/components/Inputbar/index.test.tsx @@ -660,7 +660,7 @@ describe("Inputbar", () => { ]); }); - it("应将服务型技能目录与选择回调透传给 CharacterMention", async () => { + it("应将服务型技能目录透传给 CharacterMention,并经统一 capability 触发启动", async () => { const serviceSkills = [ { id: "daily-trend-briefing", @@ -685,10 +685,13 @@ describe("Inputbar", () => { ][0]; expect(latestCall.serviceSkills).toBe(serviceSkills); - expect(latestCall.onSelectServiceSkill).toBeTypeOf("function"); + expect(latestCall.onSelectInputCapability).toBeTypeOf("function"); await act(async () => { - latestCall.onSelectServiceSkill?.(serviceSkills[0] as ServiceSkillHomeItem); + latestCall.onSelectInputCapability?.({ + kind: "service_skill", + skill: serviceSkills[0] as ServiceSkillHomeItem, + }); await Promise.resolve(); }); @@ -1298,7 +1301,7 @@ describe("Inputbar", () => { expect(launcherDialog?.dataset.sessionId).toBe("session-review-chain"); }); - it("输入条已激活结果模板时,应支持从 badge 直接切到最近复盘推荐模板", async () => { + it("输入条已激活结果模板时,应支持从 badge 直接切到最近判断推荐模板", async () => { const setInput = vi.fn(); const onSend = vi.fn(); const initialLaunchInputValues = { @@ -1417,7 +1420,7 @@ describe("Inputbar", () => { ); }); - it("输入条编辑态 launcher 按最近复盘切模板时,应保留参考选择并显示提示", async () => { + it("输入条编辑态 launcher 按最近判断切模板时,应保留参考选择并显示提示", async () => { const initialLaunchInputValues = { theme_target: "AI 内容创作", platform_region: "X 与 TikTok 北美区", @@ -1504,20 +1507,20 @@ describe("Inputbar", () => { '[data-testid="curated-task-launcher-prefill-hint"]', ); expect(prefillHint?.textContent).toContain( - "已按最近复盘切到更适合的结果模板", + "已按最近判断切到更适合的结果模板", ); }); it("输入条已激活复盘模板时,应把 sceneapp 项目结果引用透传给 badge", async () => { renderInputbar({ - input: "请帮我复盘这个账号或项目", + input: "请帮我判断这个账号或项目当前该怎么推进", activeTheme: "general", initialInputCapability: { capabilityRoute: { kind: "curated_task", taskId: "account-project-review", taskTitle: "复盘这个账号/项目", - prompt: "请帮我复盘这个账号或项目", + prompt: "请帮我判断这个账号或项目当前该怎么推进", launchInputValues: { project_goal: "AI 内容周报", existing_results: "当前已有一轮项目结果,可直接作为复盘基线。", @@ -2041,7 +2044,6 @@ describe("Inputbar", () => { ]?.[0]; expect(firstCall?.onSelectInputCapability).toBeTypeOf("function"); - expect(firstCall?.onSelectServiceSkill).toBeTypeOf("function"); await act(async () => { firstCall?.onSelectInputCapability?.({ @@ -2057,7 +2059,10 @@ describe("Inputbar", () => { ]?.[0]; await act(async () => { - secondCall?.onSelectServiceSkill?.(serviceSkill); + secondCall?.onSelectInputCapability?.({ + kind: "service_skill", + skill: serviceSkill, + }); await Promise.resolve(); }); @@ -2719,7 +2724,7 @@ describe("Inputbar", () => { isLoading: true, onStop, workflowSteps: [ - { id: "research", title: "检索项目素材", status: "active" }, + { id: "research", title: "检索项目资料", status: "active" }, { id: "write", title: "编写正文草稿", status: "pending" }, ], }); @@ -2730,7 +2735,7 @@ describe("Inputbar", () => { }); expect(container.textContent).toContain("当前进展"); - expect(container.textContent).toContain("检索项目素材"); + expect(container.textContent).toContain("检索项目资料"); expect(container.textContent).toContain("任务队列"); expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull(); @@ -2749,7 +2754,7 @@ describe("Inputbar", () => { variant: "workspace", isLoading: true, workflowSteps: [ - { id: "research", title: "检索项目素材", status: "active" }, + { id: "research", title: "检索项目资料", status: "active" }, { id: "write", title: "编写正文草稿", status: "pending" }, ], }); @@ -2759,7 +2764,7 @@ describe("Inputbar", () => { await Promise.resolve(); }); - expect(container.textContent).toContain("检索项目素材"); + expect(container.textContent).toContain("检索项目资料"); const collapseButton = Array.from( container.querySelectorAll("button"), @@ -2823,7 +2828,7 @@ describe("Inputbar", () => { isLoading: false, workflowRunState: "auto_running", workflowSteps: [ - { id: "research", title: "检索项目素材", status: "active" }, + { id: "research", title: "检索项目资料", status: "active" }, { id: "write", title: "编写正文草稿", status: "pending" }, ], }); @@ -2834,7 +2839,7 @@ describe("Inputbar", () => { }); expect(container.textContent).toContain("当前进展"); - expect(container.textContent).toContain("检索项目素材"); + expect(container.textContent).toContain("检索项目资料"); expect(container.querySelector('[data-testid="inputbar-core"]')).toBeNull(); }); diff --git a/src/components/agent/chat/components/Inputbar/styles.ts b/src/components/agent/chat/components/Inputbar/styles.ts index 52d593b9c..9017c5ae7 100644 --- a/src/components/agent/chat/components/Inputbar/styles.ts +++ b/src/components/agent/chat/components/Inputbar/styles.ts @@ -33,7 +33,7 @@ export const Container = styled.div` margin: 0; &.floating-composer { - padding: 0 0 4px; + padding: 0 0 6px; } `; @@ -66,13 +66,19 @@ export const InputBarContainer = styled.div` } &.floating-composer { - border-radius: 20px; - padding: 16px 18px 14px 16px; - background: linear-gradient(180deg, #fcfffc 0%, #f6fbf7 100%); - border-color: rgba(110, 231, 183, 0.6); + border-radius: 34px; + padding: 20px 24px 18px 22px; + background: + radial-gradient( + circle at top right, + rgba(220, 252, 231, 0.48), + rgba(255, 255, 255, 0) 34% + ), + linear-gradient(180deg, #ffffff 0%, #f7fcf8 100%); + border-color: rgba(110, 231, 183, 0.84); box-shadow: - 0 16px 32px rgba(22, 101, 52, 0.05), - inset 0 1px 0 rgba(255, 255, 255, 0.85); + 0 28px 56px -38px rgba(22, 101, 52, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.88); } &.floating-composer.floating-collapsed { @@ -85,7 +91,7 @@ export const InputBarContainer = styled.div` border-color: rgba(74, 222, 128, 0.7); box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.25), - 0 18px 34px rgba(22, 101, 52, 0.08), + 0 28px 56px -34px rgba(22, 101, 52, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.88); } `; @@ -93,7 +99,7 @@ export const InputBarContainer = styled.div` export const MainRow = styled.div` display: flex; align-items: flex-start; - gap: 10px; + gap: 12px; min-width: 0; &.floating-composer.floating-collapsed { @@ -126,9 +132,9 @@ export const StyledTextarea = styled.textarea` min-height: 34px; &.floating-composer { - font-size: 16px; - line-height: 1.65; - min-height: 98px; + font-size: 17px; + line-height: 1.78; + min-height: 126px; } &.floating-composer.floating-collapsed { @@ -140,7 +146,7 @@ export const StyledTextarea = styled.textarea` } &.composer-expanded { - min-height: 188px; + min-height: 210px; } &::placeholder { @@ -170,8 +176,8 @@ export const BottomBar = styled.div` border-top: 1px solid rgba(148, 163, 184, 0.22); &.floating-composer { - padding-top: 10px; - margin-top: 10px; + padding-top: 15px; + margin-top: 15px; gap: 10px; } @@ -293,8 +299,8 @@ export const MetaToggleButton = styled.button<{ display: inline-flex; align-items: center; gap: 8px; - min-height: 28px; - padding: 0 12px 0 10px; + min-height: 32px; + padding: 0 13px 0 11px; border-radius: 999px; border: 1px solid ${({ $checked }) => diff --git a/src/components/agent/chat/components/MessageList.test.tsx b/src/components/agent/chat/components/MessageList.test.tsx index dc566497f..c1ff0d712 100644 --- a/src/components/agent/chat/components/MessageList.test.tsx +++ b/src/components/agent/chat/components/MessageList.test.tsx @@ -273,7 +273,7 @@ describe("MessageList", () => { expect(container.textContent).not.toContain("开始一段新的对话吧"); }); - it("生成页空列表时应展示执行面空态而不是普通新对话文案", () => { + it("任务中心空列表时应展示最近对话空态而不是普通新对话文案", () => { const container = render([], { emptyStateVariant: "task-center", }); @@ -281,18 +281,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("对话"); + expect(container.textContent).toContain("最近对话"); expect(container.textContent).toContain( - "这里是 Lime 的生成主执行面,可以继续进行中的创作、回看最近结果和旧历史。", + "这里集中展示最近对话、待继续会话和更早归档,方便你随时回到上一次工作现场。", ); expect(container.textContent).toContain( - "还没开始生成时,从“开始生成”发起也很自然;跑过的结果、做法和中间素材后面都会继续留在这里。", + "还没有对话时,可以先从“新建对话”开始;后续的结果、素材和中间过程都会继续留在这里。", ); - expect(container.textContent).toContain("左侧会继续显示待继续的生成记录"); - expect(container.textContent).toContain("最近结果会继续在这里回访"); + expect(container.textContent).toContain("左侧会优先显示待继续的对话"); + expect(container.textContent).toContain("最近对话和归档会按时间自动整理"); expect(container.textContent).toContain( - "常用做法和恢复中的会话会自动回到这里", + "恢复中的会话会自动回到这里继续", ); expect(container.textContent).not.toContain("开始一段新的对话吧"); }); diff --git a/src/components/agent/chat/components/MessageList.tsx b/src/components/agent/chat/components/MessageList.tsx index bc22a447f..6fdebec55 100644 --- a/src/components/agent/chat/components/MessageList.tsx +++ b/src/components/agent/chat/components/MessageList.tsx @@ -1409,18 +1409,18 @@ const MessageListInner: React.FC = ({
- 创作 + 对话

- 生成 + 最近对话

- 这里是 Lime 的生成主执行面,可以继续进行中的创作、回看最近结果和旧历史。 + 这里集中展示最近对话、待继续会话和更早归档,方便你随时回到上一次工作现场。

- 还没开始生成时,从“开始生成”发起也很自然;跑过的结果、做法和中间素材后面都会继续留在这里。 + 还没有对话时,可以先从“新建对话”开始;后续的结果、素材和中间过程都会继续留在这里。

@@ -1428,13 +1428,13 @@ const MessageListInner: React.FC = ({
- 左侧会继续显示待继续的生成记录 + 左侧会优先显示待继续的对话 - 最近结果会继续在这里回访 + 最近对话和归档会按时间自动整理 - 常用做法和恢复中的会话会自动回到这里继续生成 + 恢复中的会话会自动回到这里继续
diff --git a/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx b/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx new file mode 100644 index 000000000..a49b4acc7 --- /dev/null +++ b/src/components/agent/chat/components/TaskCenterTabStrip.test.tsx @@ -0,0 +1,196 @@ +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 { + TaskCenterTabStrip, + type TaskCenterTabItem, +} from "./TaskCenterTabStrip"; + +const mountedRoots: Array<{ root: Root; container: HTMLDivElement }> = []; + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + while (mountedRoots.length > 0) { + const mounted = mountedRoots.pop(); + if (!mounted) break; + act(() => { + mounted.root.unmount(); + }); + mounted.container.remove(); + } + vi.clearAllMocks(); +}); + +function renderTabStrip( + props?: Partial>, +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const items: TaskCenterTabItem[] = [ + { + id: "topic-a", + title: "任务 A", + status: "running", + updatedAt: new Date("2026-04-24T10:00:00.000Z"), + isActive: true, + hasUnread: true, + isPinned: false, + }, + { + id: "topic-b", + title: "任务 B", + status: "done", + updatedAt: new Date("2026-04-24T09:00:00.000Z"), + isActive: false, + hasUnread: false, + isPinned: true, + }, + ]; + + const defaultProps: React.ComponentProps = { + items, + onSelectTask: vi.fn(), + onCloseTask: vi.fn(), + onCreateTask: vi.fn(), + }; + + act(() => { + root.render(); + }); + + mountedRoots.push({ root, container }); + return { container, props: { ...defaultProps, ...props } }; +} + +describe("TaskCenterTabStrip", () => { + it("应渲染第二层会话 tabs 和加号入口", () => { + const { container } = renderTabStrip(); + + expect( + container.querySelector('[data-testid="task-center-tab-strip"]'), + ).toBeTruthy(); + expect(container.textContent).toContain("任务 A"); + expect(container.textContent).toContain("任务 B"); + expect( + container.querySelector('[data-testid="task-center-tab-create-button"]'), + ).toBeTruthy(); + + const activeTab = container.querySelector( + '[data-testid="task-center-tab-topic-a"]', + ) as HTMLElement | null; + expect(activeTab?.getAttribute("data-active")).toBe("true"); + expect( + container.querySelector('[data-testid="task-center-tab-unread-topic-a"]'), + ).toBeTruthy(); + expect( + container + .querySelector('[data-testid="task-center-tab-close-topic-b"]') + ?.className, + ).toContain("group-hover:opacity-100"); + }); + + it("点击标签应触发任务切换", () => { + const onSelectTask = vi.fn(); + const { container } = renderTabStrip({ onSelectTask }); + + act(() => { + ( + container.querySelector( + '[data-testid="task-center-tab-topic-b"] button[title]', + ) as HTMLButtonElement | null + )?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSelectTask).toHaveBeenCalledWith("topic-b"); + }); + + it("关闭标签时不应触发切换", () => { + const onSelectTask = vi.fn(); + const onCloseTask = vi.fn(); + const { container } = renderTabStrip({ onSelectTask, onCloseTask }); + + act(() => { + ( + container.querySelector( + '[data-testid="task-center-tab-close-topic-b"]', + ) as HTMLButtonElement | null + )?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onCloseTask).toHaveBeenCalledWith("topic-b"); + expect(onSelectTask).not.toHaveBeenCalled(); + }); + + it("点击加号应触发新建对话回调", () => { + const onCreateTask = vi.fn(); + const { container } = renderTabStrip({ onCreateTask }); + + act(() => { + ( + container.querySelector( + '[data-testid="task-center-tab-create-button"]', + ) as HTMLButtonElement | null + )?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onCreateTask).toHaveBeenCalledTimes(1); + }); + + it("没有打开会话时也应保留第二层 tabs 壳和加号入口", () => { + const { container } = renderTabStrip({ items: [] }); + + const strip = container.querySelector( + '[data-testid="task-center-tab-strip"]', + ) as HTMLElement | null; + const createButton = container.querySelector( + '[data-testid="task-center-tab-create-button"]', + ) as HTMLButtonElement | null; + + expect(strip).not.toBeNull(); + expect(createButton).not.toBeNull(); + expect(container.textContent).not.toContain("新对话"); + }); + + it("应在右侧保留轻量工具按钮", () => { + const onToggleHistory = vi.fn(); + const onToggleCanvas = vi.fn(); + const { container } = renderTabStrip({ + showHistoryToggle: true, + onToggleHistory, + showCanvasToggle: true, + isCanvasOpen: true, + onToggleCanvas, + }); + + act(() => { + ( + container.querySelector( + '[data-testid="task-center-tab-history"]', + ) as HTMLButtonElement | null + )?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + act(() => { + ( + container.querySelector( + '[data-testid="task-center-tab-canvas"]', + ) as HTMLButtonElement | null + )?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onToggleHistory).toHaveBeenCalledTimes(1); + expect(onToggleCanvas).toHaveBeenCalledTimes(1); + expect( + container.querySelector('[data-testid="task-center-tab-toolbar"]'), + ).toBeTruthy(); + }); +}); diff --git a/src/components/agent/chat/components/TaskCenterTabStrip.tsx b/src/components/agent/chat/components/TaskCenterTabStrip.tsx new file mode 100644 index 000000000..f094da51d --- /dev/null +++ b/src/components/agent/chat/components/TaskCenterTabStrip.tsx @@ -0,0 +1,224 @@ +import { + Box, + MessageSquareText, + PanelRightClose, + PanelRightOpen, + Pin, + Plus, + X, +} from "lucide-react"; +import type { TaskStatus } from "../hooks/agentChatShared"; +import { cn } from "@/lib/utils"; + +const TASK_CENTER_TAB_STATUS_META: Record< + TaskStatus, + { label: string; iconClassName: string } +> = { + draft: { + label: "待补充", + iconClassName: "text-slate-500", + }, + running: { + label: "进行中", + iconClassName: "text-sky-500", + }, + waiting: { + label: "待继续", + iconClassName: "text-amber-500", + }, + done: { + label: "已完成", + iconClassName: "text-emerald-500", + }, + failed: { + label: "有异常", + iconClassName: "text-rose-500", + }, +}; + +export interface TaskCenterTabItem { + id: string; + title: string; + status: TaskStatus; + updatedAt: Date; + isActive: boolean; + hasUnread: boolean; + isPinned: boolean; +} + +interface TaskCenterTabStripProps { + items: TaskCenterTabItem[]; + onSelectTask: (taskId: string) => void | Promise; + onCloseTask: (taskId: string) => void | Promise; + onCreateTask: () => void; + showHistoryToggle?: boolean; + onToggleHistory?: () => void; + showCanvasToggle?: boolean; + isCanvasOpen?: boolean; + onToggleCanvas?: () => void; +} + +function formatTaskTabTitle(item: TaskCenterTabItem): string { + const statusMeta = + TASK_CENTER_TAB_STATUS_META[item.status] ?? TASK_CENTER_TAB_STATUS_META.done; + return `${item.title} · ${statusMeta.label} · 更新于 ${item.updatedAt.toLocaleString( + "zh-CN", + )}`; +} + +const conversationTabShellClassName = + "group flex h-8 items-center gap-0.5 rounded-[14px] border px-0.5 transition-[border-color,background-color,box-shadow,color]"; + +const activeConversationTabClassName = + "border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] shadow-[0_8px_20px_-24px_rgba(15,23,42,0.18)]"; + +const inactiveConversationTabClassName = + "border-transparent bg-transparent hover:bg-slate-50/90"; + +const conversationTabButtonClassName = + "flex min-w-0 items-center gap-1.5 rounded-[10px] px-2.5 py-1 text-left"; + +const tabUtilityButtonClassName = + "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[10px] border border-transparent bg-transparent text-slate-500 shadow-none transition-[background-color,color] hover:bg-slate-100 hover:text-slate-900"; + +export function TaskCenterTabStrip({ + items, + onSelectTask, + onCloseTask, + onCreateTask, + showHistoryToggle = false, + onToggleHistory, + showCanvasToggle = false, + isCanvasOpen = false, + onToggleCanvas, +}: TaskCenterTabStripProps) { + const showToolbarActions = showHistoryToggle || showCanvasToggle; + + return ( +
+
+
+
+ {items.map((item) => { + const statusMeta = + TASK_CENTER_TAB_STATUS_META[item.status] ?? + TASK_CENTER_TAB_STATUS_META.done; + + return ( +
+ + +
+ ); + })} + + +
+
+ + {showToolbarActions ? ( +
+ {showHistoryToggle ? ( + + ) : null} + {showCanvasToggle ? ( + + ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx index 580c81adc..de1f448ad 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.test.tsx @@ -72,7 +72,7 @@ describe("TeamWorkbenchSummaryPanel", () => { }, }); - expect(container.textContent).toContain("生成工作台"); + expect(container.textContent).toContain("生成"); expect(container.textContent).toContain("任务记忆影子"); expect(container.textContent).toContain("/workspace/lime"); expect(container.textContent).toContain("当前分工方案"); diff --git a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx index ff3f909a6..2dc2f6486 100644 --- a/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx +++ b/src/components/agent/chat/components/TeamWorkbenchSummaryPanel.tsx @@ -176,7 +176,7 @@ export function TeamWorkbenchSummaryPanel({
- 生成工作台 + 生成 {runtimeTeamLabel ? ( {runtimeTeamLabel} @@ -196,7 +196,7 @@ export function TeamWorkbenchSummaryPanel({
{executionSummary.statusTitle || runtimeFormationDisplay.panelHeadline || - "生成工作台已就绪,等待主线程开始编排"} + "生成已就绪,等待主线程开始编排"}

{runtimeFormationDisplay.panelDescription} diff --git a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx index 4427083fe..5652fac5f 100644 --- a/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceBoard.test.tsx @@ -233,7 +233,7 @@ describe("TeamWorkspaceBoard", () => { childSubagentSessions: [], }); - expect(container.textContent).toContain("生成工作台"); + expect(container.textContent).toContain("生成"); expect(container.textContent).toContain("还没有任务接手"); expect(container.textContent).toContain("查看当前进展"); expect(container.textContent).not.toContain("任务进行时"); @@ -399,7 +399,7 @@ describe("TeamWorkspaceBoard", () => { ], }); - expect(container.textContent).toContain("生成工作台"); + expect(container.textContent).toContain("生成"); expect(container.textContent).toContain( "任务进行中 · 1 项处理中 / 1 项稍后开始", ); @@ -2079,7 +2079,7 @@ describe("TeamWorkspaceBoard", () => { expect(header).toBeTruthy(); expect(header?.className).toContain("sticky"); expect(header?.className).toContain("top-0"); - expect(header?.textContent).toContain("生成工作台"); + expect(header?.textContent).toContain("生成"); expect(header?.textContent).toContain("任务进行中"); expect(header?.textContent).not.toContain("任务进行时"); }); diff --git a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx index ebab932b8..a35d02272 100644 --- a/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx +++ b/src/components/agent/chat/components/TeamWorkspaceDock.test.tsx @@ -184,7 +184,7 @@ describe("TeamWorkspaceDock", () => { '[data-testid="team-workspace-empty-card"]', ); expect(emptyCard).toBeTruthy(); - expect(emptyCard?.textContent).toContain("生成工作台"); + expect(emptyCard?.textContent).toContain("生成"); expect(emptyCard?.textContent).not.toContain("任务进行时"); expect( document.body.querySelector( diff --git a/src/components/agent/chat/hooks/agentChatShared.ts b/src/components/agent/chat/hooks/agentChatShared.ts index fe4740158..21b310619 100644 --- a/src/components/agent/chat/hooks/agentChatShared.ts +++ b/src/components/agent/chat/hooks/agentChatShared.ts @@ -501,7 +501,7 @@ export const mapSessionToTopic = (session: AsterSessionInfo): Topic => { statusReason: "default", lastPreview: messagesCount > 0 - ? `已记录 ${messagesCount} 条消息,可继续补充或复盘。` + ? `已记录 ${messagesCount} 条消息,可继续补充或接着推进。` : "等待你补充任务需求后开始执行。", isPinned: false, hasUnread: false, diff --git a/src/components/agent/chat/hooks/agentSessionScopedStorage.test.ts b/src/components/agent/chat/hooks/agentSessionScopedStorage.test.ts new file mode 100644 index 000000000..fe1fd2034 --- /dev/null +++ b/src/components/agent/chat/hooks/agentSessionScopedStorage.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { AgentThreadItem, AgentThreadTurn, Message } from "../types"; +import { + loadAgentSessionCachedSnapshot, + saveAgentSessionCachedSnapshot, +} from "./agentSessionScopedStorage"; + +function createMessage(index: number): Message { + return { + id: `message-${index}`, + role: index % 2 === 0 ? "assistant" : "user", + content: `message-${index}`, + timestamp: new Date(`2026-04-24T00:${String(index % 60).padStart(2, "0")}:00.000Z`), + }; +} + +function createTurn(index: number): AgentThreadTurn { + return { + id: `turn-${index}`, + thread_id: "thread-1", + status: "completed", + prompt_text: `turn-${index}`, + started_at: "2026-04-24T00:00:00.000Z", + completed_at: "2026-04-24T00:00:01.000Z", + created_at: "2026-04-24T00:00:00.000Z", + updated_at: "2026-04-24T00:00:01.000Z", + }; +} + +function createItem(index: number): AgentThreadItem { + return { + id: `item-${index}`, + thread_id: "thread-1", + turn_id: `turn-${index}`, + sequence: index, + type: "agent_message", + text: `item-${index}`, + status: "completed", + started_at: "2026-04-24T00:00:00.000Z", + completed_at: "2026-04-24T00:00:01.000Z", + updated_at: "2026-04-24T00:00:01.000Z", + } as AgentThreadItem; +} + +describe("agentSessionScopedStorage", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("保存会话快照时应只保留最近一段 tail,避免恢复时内存峰值过高", () => { + const workspaceId = "ws-session-snapshot-trim"; + const sessionId = "topic-heavy"; + + saveAgentSessionCachedSnapshot(workspaceId, sessionId, { + messages: Array.from({ length: 48 }, (_, index) => createMessage(index)), + threadTurns: Array.from({ length: 36 }, (_, index) => createTurn(index)), + threadItems: Array.from({ length: 120 }, (_, index) => createItem(index)), + currentTurnId: "turn-35", + }); + + const restored = loadAgentSessionCachedSnapshot(workspaceId, sessionId); + + expect(restored).not.toBeNull(); + expect(restored?.messages).toHaveLength(32); + expect(restored?.messages[0]?.id).toBe("message-16"); + expect(restored?.threadTurns).toHaveLength(24); + expect(restored?.threadTurns[0]?.id).toBe("turn-12"); + expect(restored?.threadItems).toHaveLength(24); + expect(restored?.threadItems[0]?.id).toBe("item-12"); + expect(restored?.currentTurnId).toBe("turn-35"); + }); +}); diff --git a/src/components/agent/chat/hooks/agentSessionScopedStorage.ts b/src/components/agent/chat/hooks/agentSessionScopedStorage.ts index 079f9e87c..98b45890e 100644 --- a/src/components/agent/chat/hooks/agentSessionScopedStorage.ts +++ b/src/components/agent/chat/hooks/agentSessionScopedStorage.ts @@ -28,6 +28,46 @@ interface AgentSessionCachedSnapshotRecord } const MAX_CACHED_SESSION_SNAPSHOTS = 12; +const MAX_CACHED_SESSION_MESSAGES = 32; +const MAX_CACHED_SESSION_TURNS = 24; +const MAX_CACHED_SESSION_ITEMS = 96; + +function trimCachedSnapshot( + snapshot: AgentSessionCachedSnapshot, +): AgentSessionCachedSnapshot { + const messages = normalizeHistoryMessages( + snapshot.messages.slice(-MAX_CACHED_SESSION_MESSAGES), + ); + const threadTurns = snapshot.threadTurns.slice(-MAX_CACHED_SESSION_TURNS); + const retainedTurnIds = new Set( + threadTurns + .map((turn) => (typeof turn.id === "string" ? turn.id.trim() : "")) + .filter(Boolean), + ); + const threadItems = filterConversationThreadItems( + normalizeLegacyThreadItems( + snapshot.threadItems + .filter((item) => { + const turnId = + typeof item.turn_id === "string" ? item.turn_id.trim() : ""; + return !turnId || retainedTurnIds.has(turnId); + }) + .slice(-MAX_CACHED_SESSION_ITEMS), + ), + ); + const currentTurnId = + typeof snapshot.currentTurnId === "string" && + retainedTurnIds.has(snapshot.currentTurnId) + ? snapshot.currentTurnId + : null; + + return { + messages, + threadTurns, + threadItems, + currentTurnId, + }; +} function normalizeCachedMessages(value: unknown): Message[] { if (!Array.isArray(value)) { @@ -87,12 +127,16 @@ function normalizeCachedSnapshotRecord( } const record = value as Record; - return { + const snapshot = trimCachedSnapshot({ messages: normalizeCachedMessages(record.messages), threadTurns: normalizeCachedThreadTurns(record.threadTurns), threadItems: normalizeCachedThreadItems(record.threadItems), currentTurnId: typeof record.currentTurnId === "string" ? record.currentTurnId : null, + }); + + return { + ...snapshot, updatedAt: typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt) ? record.updatedAt @@ -127,10 +171,11 @@ export function saveAgentSessionCachedSnapshot( ): void { const cacheKey = getScopedStorageKey(workspaceId, "aster_session_snapshots"); const currentMap = loadTransient>(cacheKey, {}); + const trimmedSnapshot = trimCachedSnapshot(snapshot); const nextMap = { ...currentMap, [sessionId]: { - ...snapshot, + ...trimmedSnapshot, updatedAt: Date.now(), } satisfies AgentSessionCachedSnapshotRecord, }; diff --git a/src/components/agent/chat/hooks/agentSessionState.test.ts b/src/components/agent/chat/hooks/agentSessionState.test.ts index 4cc41f351..baea98a64 100644 --- a/src/components/agent/chat/hooks/agentSessionState.test.ts +++ b/src/components/agent/chat/hooks/agentSessionState.test.ts @@ -7,6 +7,7 @@ import { createEmptyAgentSessionSnapshot, hasSessionHydrationActivity, resolveRestorableTopicSessionId, + shouldDeferSessionDetailHydration, } from "./agentSessionState"; function createMessage(overrides: Partial = {}): Message { @@ -93,6 +94,68 @@ describe("agentSessionState", () => { expect(resolved).toBe("session-active"); }); + it("切到其他会话且命中有效本地快照时应允许延后 detail hydration", () => { + expect( + shouldDeferSessionDetailHydration({ + currentSessionId: "topic-current", + topicId: "topic-target", + cachedSnapshot: { + messages: [ + createMessage({ + id: "cached-message", + role: "assistant", + content: "本地快照里的最近消息", + }), + ], + threadTurns: [], + threadItems: [], + currentTurnId: null, + }, + }), + ).toBe(true); + }); + + it("当前会话、强制刷新或 resume hook 场景不应延后 detail hydration", () => { + expect( + shouldDeferSessionDetailHydration({ + currentSessionId: "topic-target", + topicId: "topic-target", + cachedSnapshot: { + messages: [createMessage()], + threadTurns: [], + threadItems: [], + currentTurnId: null, + }, + }), + ).toBe(false); + expect( + shouldDeferSessionDetailHydration({ + currentSessionId: "topic-current", + topicId: "topic-target", + forceRefresh: true, + cachedSnapshot: { + messages: [createMessage()], + threadTurns: [], + threadItems: [], + currentTurnId: null, + }, + }), + ).toBe(false); + expect( + shouldDeferSessionDetailHydration({ + currentSessionId: "topic-current", + topicId: "topic-target", + resumeSessionStartHooks: true, + cachedSnapshot: { + messages: [createMessage()], + threadTurns: [], + threadItems: [], + currentTurnId: null, + }, + }), + ).toBe(false); + }); + it("同会话 hydrate 且后端缺失 execution_runtime 时应保留本地运行态", () => { const currentMessages = [ createMessage({ @@ -189,7 +252,7 @@ describe("agentSessionState", () => { toolCall: { id: "tool-site-1", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/1\"}", + arguments: '{"url":"https://x.com/example/article/1"}', status: "completed", startTime: now, endTime: now, @@ -208,7 +271,7 @@ describe("agentSessionState", () => { { id: "tool-site-1", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/1\"}", + arguments: '{"url":"https://x.com/example/article/1"}', status: "completed", startTime: now, endTime: now, @@ -283,7 +346,7 @@ describe("agentSessionState", () => { toolCall: { id: "tool-site-2", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/2\"}", + arguments: '{"url":"https://x.com/example/article/2"}', status: "completed", startTime: new Date("2026-04-08T10:00:01.000Z"), endTime: now, @@ -302,7 +365,7 @@ describe("agentSessionState", () => { { id: "tool-site-2", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/2\"}", + arguments: '{"url":"https://x.com/example/article/2"}', status: "completed", startTime: new Date("2026-04-08T10:00:01.000Z"), endTime: now, @@ -369,7 +432,7 @@ describe("agentSessionState", () => { toolCall: { id: "tool-site-restore", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/3\"}", + arguments: '{"url":"https://x.com/example/article/3"}', status: "completed", startTime: new Date("2026-04-08T10:00:01.000Z"), endTime: now, @@ -384,7 +447,7 @@ describe("agentSessionState", () => { { id: "tool-site-restore", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/3\"}", + arguments: '{"url":"https://x.com/example/article/3"}', status: "completed", startTime: new Date("2026-04-08T10:00:01.000Z"), endTime: now, @@ -478,7 +541,7 @@ describe("agentSessionState", () => { { id: "tool-site-target", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/4\"}", + arguments: '{"url":"https://x.com/example/article/4"}', status: "completed", startTime: new Date("2026-04-08T10:00:01.000Z"), endTime: now, @@ -494,7 +557,7 @@ describe("agentSessionState", () => { toolCall: { id: "tool-site-target", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/4\"}", + arguments: '{"url":"https://x.com/example/article/4"}', status: "completed", startTime: new Date("2026-04-08T10:00:01.000Z"), endTime: now, @@ -549,7 +612,7 @@ describe("agentSessionState", () => { toolCall: { id: "tool-site-earlier", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/earlier\"}", + arguments: '{"url":"https://x.com/example/article/earlier"}', status: "completed", startTime: new Date("2026-04-08T10:00:00.100Z"), endTime: new Date("2026-04-08T10:00:00.500Z"), @@ -564,7 +627,7 @@ describe("agentSessionState", () => { { id: "tool-site-earlier", name: "site_run_adapter", - arguments: "{\"url\":\"https://x.com/example/article/earlier\"}", + arguments: '{"url":"https://x.com/example/article/earlier"}', status: "completed", startTime: new Date("2026-04-08T10:00:00.100Z"), endTime: new Date("2026-04-08T10:00:00.500Z"), diff --git a/src/components/agent/chat/hooks/agentSessionState.ts b/src/components/agent/chat/hooks/agentSessionState.ts index 60ae01356..abe02cda9 100644 --- a/src/components/agent/chat/hooks/agentSessionState.ts +++ b/src/components/agent/chat/hooks/agentSessionState.ts @@ -11,6 +11,7 @@ import { normalizeQueuedTurnSnapshots } from "@/lib/api/queuedTurn"; import { resolveRestorableSessionId } from "@/lib/asterSessionRecovery"; import type { AgentThreadItem, AgentThreadTurn, Message } from "../types"; import type { Topic } from "./agentChatShared"; +import type { AgentSessionCachedSnapshot } from "./agentSessionScopedStorage"; import { normalizeLegacyThreadItems } from "@/lib/api/agentTextNormalization"; import { hydrateSessionDetailMessages, @@ -72,6 +73,42 @@ export function hasSessionHydrationActivity(options: { ); } +export function shouldDeferSessionDetailHydration(options: { + currentSessionId: string | null; + topicId: string; + forceRefresh?: boolean; + resumeSessionStartHooks?: boolean; + cachedSnapshot?: AgentSessionCachedSnapshot | null; +}) { + const { + currentSessionId, + topicId, + forceRefresh = false, + resumeSessionStartHooks = false, + cachedSnapshot, + } = options; + + if ( + forceRefresh || + resumeSessionStartHooks || + !cachedSnapshot || + !currentSessionId || + currentSessionId === topicId + ) { + return false; + } + + return ( + cachedSnapshot.messages.length > 0 || + hasSessionHydrationActivity({ + currentTurnId: cachedSnapshot.currentTurnId, + threadTurnsCount: cachedSnapshot.threadTurns.length, + threadItemsCount: cachedSnapshot.threadItems.length, + queuedTurnsCount: 0, + }) + ); +} + export function resolveRestorableTopicSessionId( candidateSessionId: string | null | undefined, topics: Topic[], @@ -135,10 +172,9 @@ export function buildHydratedAgentSessionSnapshot( } = options; const effectiveCurrentSessionId = localSnapshotOverride?.sessionId ?? currentSessionId; - const effectiveCurrentMessages = - localSnapshotOverride - ? normalizeHistoricalTopicSnapshotMessages(localSnapshotOverride.messages) - : currentMessages; + const effectiveCurrentMessages = localSnapshotOverride + ? normalizeHistoricalTopicSnapshotMessages(localSnapshotOverride.messages) + : currentMessages; const effectiveCurrentThreadTurns = localSnapshotOverride?.threadTurns ?? currentThreadTurns; const effectiveCurrentThreadItems = diff --git a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts index 03e7d4010..c4edb93c8 100644 --- a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts +++ b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.test.ts @@ -297,6 +297,123 @@ describe("agentStreamTurnEventBinding", () => { expect(setIsSending).toHaveBeenCalledWith(false); }); + it("收到未知但结构合法的运行时事件时,应保留流活跃态并继续等待后续进度", async () => { + vi.useFakeTimers(); + + let messages: Message[] = [ + { + id: "assistant-unknown-event", + role: "assistant", + content: "", + timestamp: new Date("2026-04-14T10:00:00.000Z"), + isThinking: true, + }, + ]; + let streamActivated = false; + let streamHandler: ((event: { payload: unknown }) => void) | null = null; + const setMessages = vi.fn( + (value: Message[] | ((prev: Message[]) => Message[])) => { + messages = typeof value === "function" ? value(messages) : value; + }, + ); + const clearActiveStreamIfMatch = vi.fn(() => true); + const disposeListener = vi.fn(); + const runtime = { + listenToTurnEvents: vi.fn(async (_eventName, handler) => { + streamHandler = handler; + return vi.fn(); + }), + } as unknown as AgentRuntimeAdapter; + const requestState: StreamRequestState = { + accumulatedContent: "", + requestLogId: null, + requestStartedAt: 0, + requestFinished: false, + queuedTurnId: null, + }; + + await registerAgentStreamTurnEventBinding({ + runtime, + eventName: "event-unknown-heartbeat", + requestState, + skipUserMessage: false, + effectiveProviderType: "openai", + effectiveModel: "gpt-5.4", + effectiveExecutionStrategy: "react", + content: "继续处理图片任务", + expectingQueue: false, + activeSessionId: "session-unknown-heartbeat", + resolvedWorkspaceId: "workspace-unknown-heartbeat", + assistantMsgId: "assistant-unknown-event", + pendingTurnKey: "pending-turn-unknown-event", + pendingItemKey: "pending-item-unknown-event", + effectiveWaitingRuntimeStatus: { + phase: "preparing", + title: "处理中", + detail: "正在准备执行上下文", + }, + warnedKeysRef: { current: new Set() }, + actionLoggedKeys: new Set(), + toolLogIdByToolId: new Map(), + toolStartedAtByToolId: new Map(), + toolNameByToolId: new Map(), + callbacks: { + activateStream: () => { + streamActivated = true; + }, + isStreamActivated: () => streamActivated, + clearOptimisticItem: () => {}, + clearOptimisticTurn: () => {}, + disposeListener, + removeQueuedDraftMessages: () => {}, + clearActiveStreamIfMatch, + upsertQueuedTurn: (_queuedTurn: QueuedTurnSnapshot) => {}, + removeQueuedTurnState: () => {}, + }, + sounds: { + playToolcallSound: () => {}, + playTypewriterSound: () => {}, + }, + appendThinkingToParts: (parts) => parts, + setMessages: setMessages as never, + setPendingActions: noopDispatch(), + setThreadItems: noopDispatch(), + setThreadTurns: noopDispatch(), + setCurrentTurnId: noopDispatch(), + setExecutionRuntime: noopDispatch(), + setIsSending: noopDispatch(), + }); + + if (!streamHandler) { + throw new Error("expected stream handler to be registered"); + } + + const activeStreamHandler = streamHandler as (event: { + payload: unknown; + }) => void; + + activeStreamHandler({ + payload: { + type: "runtime_projection_bootstrap", + detail: "正在准备运行时投影", + }, + }); + + await vi.advanceTimersByTimeAsync(12_100); + + expect(messages[0]?.content).toBe(""); + expect(streamActivated).toBe(true); + expect(disposeListener).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(45_100); + + expect(messages[0]?.content).toContain("执行失败:执行已中断"); + expect(clearActiveStreamIfMatch).toHaveBeenCalledWith( + "event-unknown-heartbeat", + ); + expect(disposeListener).toHaveBeenCalled(); + }); + it("首包后长时间没有新事件时,应把助手消息收口为失败态", async () => { vi.useFakeTimers(); diff --git a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts index 052b0d137..6902cefc3 100644 --- a/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts +++ b/src/components/agent/chat/hooks/agentStreamTurnEventBinding.ts @@ -99,6 +99,15 @@ interface RegisterAgentStreamTurnEventBindingOptions { setIsSending: Dispatch>; } +function extractRuntimeEventType(payload: unknown): string | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + + const type = (payload as { type?: unknown }).type; + return typeof type === "string" && type.trim() ? type : null; +} + export async function registerAgentStreamTurnEventBinding( options: RegisterAgentStreamTurnEventBindingOptions, ) { @@ -163,6 +172,7 @@ export async function registerAgentStreamTurnEventBinding( let firstEventReceived = false; let lastEventReceivedAt = 0; + const warnedUnknownEventTypes = new Set(); let inactivityWatchdogId: ReturnType | null = null; const clearInactivityWatchdog = () => { if (inactivityWatchdogId) { @@ -312,7 +322,24 @@ export async function registerAgentStreamTurnEventBinding( eventName, (event: { payload: unknown }) => { const data = parseAgentEvent(event.payload); + const eventType = extractRuntimeEventType(event.payload); if (!data) { + if (!eventType) { + return; + } + if (!firstEventReceived) { + firstEventReceived = true; + clearFirstEventWatchdog(); + } + lastEventReceivedAt = Date.now(); + callbacks.activateStream(activeSessionId, effectiveWaitingRuntimeStatus); + if (!warnedUnknownEventTypes.has(eventType)) { + warnedUnknownEventTypes.add(eventType); + console.warn( + `[AsterChat] 收到未识别的运行时事件,已保留流活跃态: ${eventName} · ${eventType}`, + ); + } + scheduleInactivityWatchdog(); return; } if (!firstEventReceived) { diff --git a/src/components/agent/chat/hooks/useAgentSession.ts b/src/components/agent/chat/hooks/useAgentSession.ts index fcb72e6a8..006f19e30 100644 --- a/src/components/agent/chat/hooks/useAgentSession.ts +++ b/src/components/agent/chat/hooks/useAgentSession.ts @@ -1,4 +1,5 @@ import { + startTransition, useCallback, useEffect, useMemo, @@ -62,6 +63,7 @@ import { createEmptyAgentSessionSnapshot, hasSessionHydrationActivity, resolveRestorableTopicSessionId, + shouldDeferSessionDetailHydration, type AgentSessionSnapshot, } from "./agentSessionState"; import { @@ -260,6 +262,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { const hydratedSessionRef = useRef(null); const skipAutoRestoreRef = useRef(false); const sessionSwitchRequestVersionRef = useRef(0); + const deferredSessionHydrationCancelRef = useRef<(() => void) | null>(null); const createFreshSessionPromiseRef = useRef | null>( null, ); @@ -294,23 +297,33 @@ export function useAgentSession(options: UseAgentSessionOptions) { ); const invalidatePendingSessionSwitches = useCallback(() => { + deferredSessionHydrationCancelRef.current?.(); + deferredSessionHydrationCancelRef.current = null; sessionSwitchRequestVersionRef.current += 1; return sessionSwitchRequestVersionRef.current; }, []); - const applySessionSnapshot = useCallback((snapshot: AgentSessionSnapshot) => { - setSessionId(snapshot.sessionId); - setMessages(snapshot.messages); - setThreadTurns(snapshot.threadTurns); - setThreadItems(snapshot.threadItems); - setCurrentTurnId(snapshot.currentTurnId); - setQueuedTurns(snapshot.queuedTurns); - setThreadRead(snapshot.threadRead); - setExecutionRuntime(snapshot.executionRuntime); - setTodoItems(snapshot.todoItems); - setChildSubagentSessions(snapshot.childSubagentSessions); - setSubagentParentContext(snapshot.subagentParentContext); - }, []); + const applySessionSnapshot = useCallback( + (snapshot: AgentSessionSnapshot) => { + sessionIdRef.current = snapshot.sessionId; + messagesRef.current = snapshot.messages; + threadTurnsRef.current = snapshot.threadTurns; + threadItemsRef.current = snapshot.threadItems; + executionRuntimeRef.current = snapshot.executionRuntime; + setSessionId(snapshot.sessionId); + setMessages(snapshot.messages); + setThreadTurns(snapshot.threadTurns); + setThreadItems(snapshot.threadItems); + setCurrentTurnId(snapshot.currentTurnId); + setQueuedTurns(snapshot.queuedTurns); + setThreadRead(snapshot.threadRead); + setExecutionRuntime(snapshot.executionRuntime); + setTodoItems(snapshot.todoItems); + setChildSubagentSessions(snapshot.childSubagentSessions); + setSubagentParentContext(snapshot.subagentParentContext); + }, + [sessionIdRef], + ); const applyReadModelSnapshot = useCallback( (snapshot: { @@ -412,6 +425,14 @@ export function useAgentSession(options: UseAgentSessionOptions) { executionRuntimeRef.current = executionRuntime; }, [executionRuntime]); + useEffect( + () => () => { + deferredSessionHydrationCancelRef.current?.(); + deferredSessionHydrationCancelRef.current = null; + }, + [], + ); + useEffect(() => { const resolvedWorkspaceId = workspaceId?.trim(); if ( @@ -853,12 +874,369 @@ export function useAgentSession(options: UseAgentSessionOptions) { ], ); + const applyCachedTopicSnapshot = useCallback( + ( + topicId: string, + cachedSnapshot: ReturnType, + ) => { + if (!cachedSnapshot) { + return false; + } + + const selectedTopic = topics.find((topic) => topic.id === topicId); + hydratedSessionRef.current = topicId; + applySessionSnapshot({ + ...createEmptyAgentSessionSnapshot(), + sessionId: topicId, + messages: cachedSnapshot.messages, + threadTurns: cachedSnapshot.threadTurns, + threadItems: cachedSnapshot.threadItems, + currentTurnId: cachedSnapshot.currentTurnId, + }); + setExecutionStrategyState( + normalizeExecutionStrategy( + selectedTopic?.executionStrategy || executionStrategy, + ), + ); + return true; + }, + [ + applySessionSnapshot, + executionStrategy, + setExecutionStrategyState, + topics, + ], + ); + + const applyCachedTopicChromeState = useCallback( + (topicId: string) => { + const topicPreference = loadSessionModelPreference(topicId); + if (topicPreference) { + applySessionModelPreference(topicId, topicPreference); + } + + const shadowAccessMode = loadSessionAccessMode(topicId); + if (shadowAccessMode) { + setAccessModeState(shadowAccessMode); + } else { + setAccessModeState(resolvePersistedAccessMode(workspaceId)); + } + }, + [ + applySessionModelPreference, + loadSessionAccessMode, + loadSessionModelPreference, + setAccessModeState, + workspaceId, + ], + ); + + const finalizeResolvedTopicDetail = useCallback( + (params: { + topicId: string; + detail: Awaited>; + startedAt: number; + localSnapshotOverride?: { + sessionId: string; + messages: Message[]; + threadTurns: AgentThreadTurn[]; + threadItems: AgentThreadItem[]; + } | null; + switchRequestVersion: number; + useTransition?: boolean; + }) => { + const { + topicId, + detail, + startedAt, + localSnapshotOverride = null, + switchRequestVersion, + useTransition = false, + } = params; + + if (sessionSwitchRequestVersionRef.current !== switchRequestVersion) { + logAgentDebug( + "useAgentSession", + "switchTopic.staleResultIgnored", + { + currentSessionId: sessionIdRef.current, + switchRequestVersion, + topicId, + workspaceId, + }, + { throttleMs: 1000 }, + ); + return false; + } + + const runtimePreference = + createSessionModelPreferenceFromExecutionRuntime( + detail.execution_runtime, + ); + const runtimeAccessMode = createSessionAccessModeFromExecutionRuntime( + detail.execution_runtime, + ); + const runtimeWorkspaceId = normalizeProjectId(detail.workspace_id); + const selectedTopic = topics.find((topic) => topic.id === topicId); + const topicWorkspaceId = normalizeProjectId(selectedTopic?.workspaceId); + const shadowWorkspaceId = normalizeProjectId( + loadStoredSessionWorkspaceIdRaw(topicId), + ); + const resolvedWorkspaceId = normalizeProjectId(workspaceId); + const knownWorkspaceId = + runtimeWorkspaceId || topicWorkspaceId || shadowWorkspaceId; + + if ( + resolvedWorkspaceId && + knownWorkspaceId && + knownWorkspaceId !== resolvedWorkspaceId + ) { + console.warn("[AsterChat] 检测到跨工作区会话恢复,已忽略", { + topicId, + currentWorkspaceId: resolvedWorkspaceId, + knownWorkspaceId, + }); + persistSessionRestoreCandidate(null); + return false; + } + + const runtimeExecutionStrategy = detail.execution_strategy + ? normalizeExecutionStrategy(detail.execution_strategy) + : null; + const topicExecutionStrategy = selectedTopic?.executionStrategy + ? normalizeExecutionStrategy(selectedTopic.executionStrategy) + : null; + const executionStrategyStorageKey = + getExecutionStrategyStorageKey(workspaceId); + const persistedExecutionStrategy = + executionStrategyStorageKey && + loadPersistedString(executionStrategyStorageKey); + const shadowExecutionStrategyFallback = + runtimeExecutionStrategy || topicExecutionStrategy + ? null + : persistedExecutionStrategy + ? resolvePersistedExecutionStrategy(workspaceId) + : null; + const topicPreference = + runtimePreference || loadSessionModelPreference(topicId); + const shadowAccessMode = loadSessionAccessMode(topicId); + + persistSessionRestoreCandidate(topicId); + const applyResolvedDetail = () => { + applySessionDetail(topicId, detail, { + localSnapshotOverride, + syncSessionId: true, + executionStrategyOverride: + runtimeExecutionStrategy || + topicExecutionStrategy || + shadowExecutionStrategyFallback || + "react", + }); + }; + + if (useTransition) { + startTransition(applyResolvedDetail); + } else { + applyResolvedDetail(); + } + + if (runtimeExecutionStrategy) { + markSessionExecutionStrategySynced(topicId, runtimeExecutionStrategy); + } + + if (runtimeAccessMode) { + setAccessModeState(runtimeAccessMode); + persistSessionAccessMode(topicId, runtimeAccessMode); + } else if (shadowAccessMode) { + setAccessModeState(shadowAccessMode); + void runtime + .setSessionAccessMode?.(topicId, shadowAccessMode) + .catch((error) => { + console.warn( + "[AsterChat] 迁移会话 accessMode fallback 失败:", + error, + ); + }); + } else { + const workspaceDefaultAccessMode = + resolvePersistedAccessMode(workspaceId); + setAccessModeState(workspaceDefaultAccessMode); + persistSessionAccessMode(topicId, workspaceDefaultAccessMode); + void runtime + .setSessionAccessMode?.(topicId, workspaceDefaultAccessMode) + .catch((error) => { + console.warn("[AsterChat] 回填会话默认 accessMode 失败:", error); + }); + } + + logAgentDebug("useAgentSession", "switchTopic.success", { + durationMs: Date.now() - startedAt, + executionStrategySource: runtimeExecutionStrategy + ? "session_detail" + : topicExecutionStrategy + ? "topics_snapshot" + : shadowExecutionStrategyFallback + ? "shadow_cache" + : "default", + itemsCount: detail.items?.length ?? 0, + messagesCount: detail.messages.length, + modelPreferenceSource: runtimePreference + ? "execution_runtime" + : topicPreference + ? "session_storage" + : null, + accessModeSource: runtimeAccessMode + ? "execution_runtime" + : shadowAccessMode + ? "session_storage" + : "workspace_default", + queuedTurnsCount: detail.queued_turns?.length ?? 0, + topicId, + turnsCount: detail.turns?.length ?? 0, + workspaceId, + }); + + const persistedWorkspaceId = runtimeWorkspaceId || resolvedWorkspaceId; + if (persistedWorkspaceId) { + savePersistedSessionWorkspaceId(topicId, persistedWorkspaceId); + } + + if (runtimeWorkspaceId) { + setTopics((prev) => + prev.map((topic) => + topic.id === topicId + ? { ...topic, workspaceId: runtimeWorkspaceId } + : topic, + ), + ); + } + + if (topicPreference) { + applySessionModelPreference(topicId, topicPreference); + if (!runtimePreference) { + void runtime + .setSessionProviderSelection( + topicId, + topicPreference.providerType, + topicPreference.model, + ) + .then(() => { + markSessionModelPreferenceSynced( + topicId, + topicPreference.providerType, + topicPreference.model, + ); + }) + .catch((error) => { + console.warn( + "[AsterChat] 迁移会话 provider/model fallback 失败:", + error, + ); + }); + } + } + + if (shadowExecutionStrategyFallback) { + void runtime + .setSessionExecutionStrategy(topicId, shadowExecutionStrategyFallback) + .then(() => { + markSessionExecutionStrategySynced( + topicId, + shadowExecutionStrategyFallback, + ); + setTopics((prev) => + prev.map((topic) => + topic.id === topicId + ? { + ...topic, + executionStrategy: shadowExecutionStrategyFallback, + } + : topic, + ), + ); + }) + .catch((error) => { + console.warn( + "[AsterChat] 迁移会话 executionStrategy fallback 失败:", + error, + ); + }); + } + + setIsAutoRestoringSession(false); + return true; + }, + [ + applySessionDetail, + applySessionModelPreference, + loadSessionAccessMode, + loadSessionModelPreference, + markSessionExecutionStrategySynced, + markSessionModelPreferenceSynced, + persistSessionAccessMode, + persistSessionRestoreCandidate, + runtime, + sessionIdRef, + setAccessModeState, + topics, + workspaceId, + ], + ); + + const handleSwitchTopicError = useCallback( + ( + error: unknown, + topicId: string, + options?: { preserveCurrentSnapshot?: boolean }, + ) => { + console.error("[AsterChat] 切换话题失败:", error); + console.error("[AsterChat] 错误详情:", JSON.stringify(error, null, 2)); + logAgentDebug( + "useAgentSession", + "switchTopic.error", + { + error, + topicId, + workspaceId, + }, + { level: "error" }, + ); + + if (isAsterSessionNotFoundError(error)) { + applySessionSnapshot(createEmptyAgentSessionSnapshot()); + persistSessionRestoreCandidate(null); + hydratedSessionRef.current = null; + void loadTopics(); + setIsAutoRestoringSession(false); + return; + } + + if (!options?.preserveCurrentSnapshot) { + applySessionSnapshot(createEmptyAgentSessionSnapshot()); + persistSessionRestoreCandidate(null); + hydratedSessionRef.current = null; + } + + setIsAutoRestoringSession(false); + toast.error( + `加载对话历史失败: ${error instanceof Error ? error.message : String(error)}`, + ); + }, + [ + applySessionSnapshot, + loadTopics, + persistSessionRestoreCandidate, + workspaceId, + ], + ); + const switchTopic = useCallback( async ( topicId: string, options?: { forceRefresh?: boolean; resumeSessionStartHooks?: boolean; + restoreSource?: "auto"; }, ) => { if ( @@ -880,6 +1258,9 @@ export function useAgentSession(options: UseAgentSessionOptions) { skipAutoRestoreRef.current = false; const switchRequestVersion = invalidatePendingSessionSwitches(); + if (options?.restoreSource !== "auto") { + setIsAutoRestoringSession(false); + } try { const startedAt = Date.now(); const cachedTargetSnapshot = @@ -893,265 +1274,120 @@ export function useAgentSession(options: UseAgentSessionOptions) { topicId, workspaceId, }); + if (currentSessionId !== topicId) { + applyCachedTopicSnapshot(topicId, cachedTargetSnapshot); + } + const shouldDeferDetailHydration = shouldDeferSessionDetailHydration({ + currentSessionId, + topicId, + forceRefresh: options?.forceRefresh, + resumeSessionStartHooks: options?.resumeSessionStartHooks, + cachedSnapshot: cachedTargetSnapshot, + }); + + if (shouldDeferDetailHydration) { + applyCachedTopicChromeState(topicId); + persistSessionRestoreCandidate(topicId); + setIsAutoRestoringSession(false); + logAgentDebug("useAgentSession", "switchTopic.deferHydration", { + cachedLocalMessagesCount: + cachedTargetSnapshot?.messages.length ?? 0, + currentSessionId, + topicId, + workspaceId, + }); + deferredSessionHydrationCancelRef.current = + scheduleMinimumDelayIdleTask( + () => { + deferredSessionHydrationCancelRef.current = null; + void (async () => { + try { + const detail = await runtime.getSession(topicId); + finalizeResolvedTopicDetail({ + topicId, + detail, + startedAt, + localSnapshotOverride: + cachedTargetSnapshot && + sessionIdRef.current === topicId && + messagesRef.current === cachedTargetSnapshot.messages && + threadTurnsRef.current === + cachedTargetSnapshot.threadTurns && + threadItemsRef.current === + cachedTargetSnapshot.threadItems + ? { + sessionId: topicId, + messages: cachedTargetSnapshot.messages, + threadTurns: cachedTargetSnapshot.threadTurns, + threadItems: cachedTargetSnapshot.threadItems, + } + : null, + switchRequestVersion, + useTransition: true, + }); + } catch (error) { + if ( + sessionSwitchRequestVersionRef.current !== + switchRequestVersion + ) { + return; + } + handleSwitchTopicError(error, topicId, { + preserveCurrentSnapshot: true, + }); + } + })(); + }, + { + minimumDelayMs: 0, + idleTimeoutMs: 1_500, + }, + ); + return; + } + const detail = options?.resumeSessionStartHooks === true ? await runtime.getSession(topicId, { resumeSessionStartHooks: true, }) : await runtime.getSession(topicId); - if (sessionSwitchRequestVersionRef.current !== switchRequestVersion) { - logAgentDebug( - "useAgentSession", - "switchTopic.staleResultIgnored", - { - currentSessionId: sessionIdRef.current, - switchRequestVersion, - topicId, - workspaceId, - }, - { throttleMs: 1000 }, - ); - return; - } - const runtimePreference = - createSessionModelPreferenceFromExecutionRuntime( - detail.execution_runtime, - ); - const runtimeAccessMode = createSessionAccessModeFromExecutionRuntime( - detail.execution_runtime, - ); - const runtimeWorkspaceId = normalizeProjectId(detail.workspace_id); - const selectedTopic = topics.find((topic) => topic.id === topicId); - const topicWorkspaceId = normalizeProjectId(selectedTopic?.workspaceId); - const shadowWorkspaceId = normalizeProjectId( - loadStoredSessionWorkspaceIdRaw(topicId), - ); - const resolvedWorkspaceId = normalizeProjectId(workspaceId); - const knownWorkspaceId = - runtimeWorkspaceId || topicWorkspaceId || shadowWorkspaceId; - if ( - resolvedWorkspaceId && - knownWorkspaceId && - knownWorkspaceId !== resolvedWorkspaceId - ) { - console.warn("[AsterChat] 检测到跨工作区会话恢复,已忽略", { - topicId, - currentWorkspaceId: resolvedWorkspaceId, - knownWorkspaceId, - }); - persistSessionRestoreCandidate(null); - return; - } - const runtimeExecutionStrategy = detail.execution_strategy - ? normalizeExecutionStrategy(detail.execution_strategy) - : null; - const topicExecutionStrategy = selectedTopic?.executionStrategy - ? normalizeExecutionStrategy(selectedTopic.executionStrategy) - : null; - const executionStrategyStorageKey = - getExecutionStrategyStorageKey(workspaceId); - const persistedExecutionStrategy = - executionStrategyStorageKey && - loadPersistedString(executionStrategyStorageKey); - const shadowExecutionStrategyFallback = - runtimeExecutionStrategy || topicExecutionStrategy - ? null - : persistedExecutionStrategy - ? resolvePersistedExecutionStrategy(workspaceId) - : null; - const topicPreference = - runtimePreference || loadSessionModelPreference(topicId); - - persistSessionRestoreCandidate(topicId); - applySessionDetail(topicId, detail, { - localSnapshotOverride: cachedTargetSnapshot - ? { - sessionId: topicId, - messages: cachedTargetSnapshot.messages, - threadTurns: cachedTargetSnapshot.threadTurns, - threadItems: cachedTargetSnapshot.threadItems, - } - : null, - syncSessionId: true, - executionStrategyOverride: - runtimeExecutionStrategy || - topicExecutionStrategy || - shadowExecutionStrategyFallback || - "react", - }); - if (runtimeExecutionStrategy) { - markSessionExecutionStrategySynced(topicId, runtimeExecutionStrategy); - } - const shadowAccessMode = loadSessionAccessMode(topicId); - if (runtimeAccessMode) { - setAccessModeState(runtimeAccessMode); - persistSessionAccessMode(topicId, runtimeAccessMode); - } else if (shadowAccessMode) { - setAccessModeState(shadowAccessMode); - void runtime - .setSessionAccessMode?.(topicId, shadowAccessMode) - .catch((error) => { - console.warn( - "[AsterChat] 迁移会话 accessMode fallback 失败:", - error, - ); - }); - } else { - const workspaceDefaultAccessMode = - resolvePersistedAccessMode(workspaceId); - setAccessModeState(workspaceDefaultAccessMode); - persistSessionAccessMode(topicId, workspaceDefaultAccessMode); - void runtime - .setSessionAccessMode?.(topicId, workspaceDefaultAccessMode) - .catch((error) => { - console.warn("[AsterChat] 回填会话默认 accessMode 失败:", error); - }); - } - logAgentDebug("useAgentSession", "switchTopic.success", { - durationMs: Date.now() - startedAt, - executionStrategySource: runtimeExecutionStrategy - ? "session_detail" - : topicExecutionStrategy - ? "topics_snapshot" - : shadowExecutionStrategyFallback - ? "shadow_cache" - : "default", - itemsCount: detail.items?.length ?? 0, - messagesCount: detail.messages.length, - modelPreferenceSource: runtimePreference - ? "execution_runtime" - : topicPreference - ? "session_storage" - : null, - accessModeSource: runtimeAccessMode - ? "execution_runtime" - : shadowAccessMode - ? "session_storage" - : "workspace_default", - queuedTurnsCount: detail.queued_turns?.length ?? 0, + finalizeResolvedTopicDetail({ topicId, - turnsCount: detail.turns?.length ?? 0, - workspaceId, + detail, + startedAt, + localSnapshotOverride: + cachedTargetSnapshot && + sessionIdRef.current === topicId && + messagesRef.current === cachedTargetSnapshot.messages && + threadTurnsRef.current === cachedTargetSnapshot.threadTurns && + threadItemsRef.current === cachedTargetSnapshot.threadItems + ? { + sessionId: topicId, + messages: cachedTargetSnapshot.messages, + threadTurns: cachedTargetSnapshot.threadTurns, + threadItems: cachedTargetSnapshot.threadItems, + } + : null, + switchRequestVersion, }); - - const persistedWorkspaceId = runtimeWorkspaceId || resolvedWorkspaceId; - if (persistedWorkspaceId) { - savePersistedSessionWorkspaceId(topicId, persistedWorkspaceId); - } - - if (runtimeWorkspaceId) { - setTopics((prev) => - prev.map((topic) => - topic.id === topicId - ? { ...topic, workspaceId: runtimeWorkspaceId } - : topic, - ), - ); - } - - if (topicPreference) { - applySessionModelPreference(topicId, topicPreference); - if (!runtimePreference) { - void runtime - .setSessionProviderSelection( - topicId, - topicPreference.providerType, - topicPreference.model, - ) - .then(() => { - markSessionModelPreferenceSynced( - topicId, - topicPreference.providerType, - topicPreference.model, - ); - }) - .catch((error) => { - console.warn( - "[AsterChat] 迁移会话 provider/model fallback 失败:", - error, - ); - }); - } - } - - if (shadowExecutionStrategyFallback) { - void runtime - .setSessionExecutionStrategy( - topicId, - shadowExecutionStrategyFallback, - ) - .then(() => { - markSessionExecutionStrategySynced( - topicId, - shadowExecutionStrategyFallback, - ); - setTopics((prev) => - prev.map((topic) => - topic.id === topicId - ? { - ...topic, - executionStrategy: shadowExecutionStrategyFallback, - } - : topic, - ), - ); - }) - .catch((error) => { - console.warn( - "[AsterChat] 迁移会话 executionStrategy fallback 失败:", - error, - ); - }); - } - setIsAutoRestoringSession(false); } catch (error) { - console.error("[AsterChat] 切换话题失败:", error); - console.error("[AsterChat] 错误详情:", JSON.stringify(error, null, 2)); - logAgentDebug( - "useAgentSession", - "switchTopic.error", - { - error, - topicId, - workspaceId, - }, - { level: "error" }, - ); - if (isAsterSessionNotFoundError(error)) { - applySessionSnapshot(createEmptyAgentSessionSnapshot()); - persistSessionRestoreCandidate(null); - void loadTopics(); - setIsAutoRestoringSession(false); - return; - } - applySessionSnapshot(createEmptyAgentSessionSnapshot()); - persistSessionRestoreCandidate(null); - setIsAutoRestoringSession(false); - toast.error( - `加载对话历史失败: ${error instanceof Error ? error.message : String(error)}`, - ); + handleSwitchTopicError(error, topicId); } }, [ - applySessionDetail, - applySessionSnapshot, - applySessionModelPreference, - loadSessionModelPreference, - loadTopics, - loadSessionAccessMode, - markSessionModelPreferenceSynced, - markSessionExecutionStrategySynced, + applyCachedTopicChromeState, + finalizeResolvedTopicDetail, + handleSwitchTopicError, messages.length, modelRef, + applyCachedTopicSnapshot, invalidatePendingSessionSwitches, persistSessionModelPreference, persistSessionRestoreCandidate, - persistSessionAccessMode, providerTypeRef, runtime, sessionIdRef, - setAccessModeState, - topics, workspaceId, ], ); @@ -1319,6 +1555,7 @@ export function useAgentSession(options: UseAgentSessionOptions) { }); switchTopic(targetSessionId, { resumeSessionStartHooks: true, + restoreSource: "auto", }) .catch((error) => { console.warn("[AsterChat] 自动恢复会话失败:", error); diff --git a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx index 88ced0c7e..48df9aef4 100644 --- a/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx +++ b/src/components/agent/chat/hooks/useAsterAgentChat.test.tsx @@ -3601,7 +3601,9 @@ describe("useAsterAgentChat slash skill 执行链路", () => { }, ]; - mockListAgentRuntimeSessions.mockImplementation(async () => currentSessions); + mockListAgentRuntimeSessions.mockImplementation( + async () => currentSessions, + ); mockCreateAgentRuntimeSession.mockImplementation(async () => { currentSessions = [ { @@ -3659,7 +3661,14 @@ describe("useAsterAgentChat slash skill 执行链路", () => { await act(async () => { await harness .getValue() - .sendMessage("继续发送,必须留在新会话", [], false, false, false, "react"); + .sendMessage( + "继续发送,必须留在新会话", + [], + false, + false, + false, + "react", + ); }); expect(mockSubmitAgentRuntimeTurn).toHaveBeenCalledWith( @@ -3673,6 +3682,74 @@ describe("useAsterAgentChat slash skill 执行链路", () => { } }); + it("手动切换话题时不应继续停留在自动恢复占位态", async () => { + const workspaceId = "ws-manual-topic-switch-hide-auto-restore"; + const listSessionsDeferred = createDeferred< + Array<{ + id: string; + name: string; + created_at: number; + messages_count: number; + workspace_id: string; + }> + >(); + const topicDetailDeferred = createDeferred<{ + id: string; + messages: []; + execution_strategy: "react"; + }>(); + + sessionStorage.setItem( + `aster_curr_sessionId_${workspaceId}`, + JSON.stringify("topic-auto-restore"), + ); + mockListAgentRuntimeSessions.mockImplementation( + async () => listSessionsDeferred.promise, + ); + mockGetAgentRuntimeSession.mockImplementation(async (topicId: string) => { + if (topicId === "topic-manual") { + return topicDetailDeferred.promise; + } + + return { + id: topicId, + messages: [], + execution_strategy: "react" as const, + }; + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + expect(harness.getValue().isAutoRestoringSession).toBe(true); + + let switchPromise: Promise | null = null; + await act(async () => { + switchPromise = harness.getValue().switchTopic("topic-manual"); + await Promise.resolve(); + }); + + expect(harness.getValue().isAutoRestoringSession).toBe(false); + + await act(async () => { + listSessionsDeferred.resolve([]); + topicDetailDeferred.resolve({ + id: "topic-manual", + messages: [], + execution_strategy: "react", + }); + await switchPromise; + }); + await flushEffects(); + + expect(harness.getValue().sessionId).toBe("topic-manual"); + expect(harness.getValue().isAutoRestoringSession).toBe(false); + } finally { + harness.unmount(); + } + }); + it("命中 /review 时应转换为预置 prompt 后走 chat_stream", async () => { const workspaceId = "ws-slash-review"; const harness = mountHook(workspaceId); @@ -5893,6 +5970,11 @@ describe("useAsterAgentChat 偏好持久化", () => { await act(async () => { await harness.getValue().switchTopic("topic-a"); }); + await act(async () => { + await new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); + }); await flushEffects(); const value = harness.getValue(); @@ -6127,7 +6209,19 @@ describe("useAsterAgentChat 偏好持久化", () => { await act(async () => { await harness.getValue().switchTopic("topic-a"); }); - await flushEffects(); + for ( + let attempt = 0; + attempt < 3 && + harness.getValue().messages[1]?.thinkingContent !== undefined; + attempt += 1 + ) { + await act(async () => { + await new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); + }); + await flushEffects(); + } const value = harness.getValue(); expect(value.messages).toHaveLength(2); @@ -6148,6 +6242,129 @@ describe("useAsterAgentChat 偏好持久化", () => { } }); + it("切换到命中过往快照的话题时应先立即回放本地 tail,再等待远端详情补全", async () => { + const workspaceId = "ws-topic-history-warm-restore"; + const createdAt = Math.floor(Date.now() / 1000); + const deferredTopicDetail = createDeferred<{ + id: string; + messages: Array<{ + role: "assistant" | "user"; + timestamp: number; + content: Array<{ type: "text"; text: string }>; + }>; + turns: []; + items: []; + queued_turns: []; + execution_strategy: "react"; + }>(); + + seedSessionSnapshots(workspaceId, { + "topic-a": { + messages: [ + { + id: "topic-a-cached-user", + role: "user", + content: "继续完善上一个方案", + timestamp: "2026-04-24T10:00:00.000Z", + }, + { + id: "topic-a-cached-assistant", + role: "assistant", + content: "这是本地快照里的最近结果。", + timestamp: "2026-04-24T10:00:02.000Z", + }, + ], + threadTurns: [], + threadItems: [], + currentTurnId: null, + updatedAt: Date.now(), + }, + }); + mockListAgentRuntimeSessions.mockResolvedValue([ + { + id: "topic-a", + name: "历史任务 A", + created_at: createdAt, + messages_count: 2, + }, + ]); + mockGetAgentRuntimeSession.mockImplementation(async (topicId: string) => { + if (topicId === "topic-a") { + return deferredTopicDetail.promise; + } + + return { + id: topicId, + messages: [], + turns: [], + items: [], + queued_turns: [], + execution_strategy: "react" as const, + }; + }); + + const harness = mountHook(workspaceId); + + try { + await flushEffects(); + await flushEffects(); + + let switchPromise: Promise | null = null; + await act(async () => { + switchPromise = harness.getValue().switchTopic("topic-a"); + await Promise.resolve(); + }); + + expect(harness.getValue().sessionId).toBe("topic-a"); + expect(harness.getValue().messages).toHaveLength(2); + expect(harness.getValue().messages[1]?.content).toBe( + "这是本地快照里的最近结果。", + ); + await expect(switchPromise).resolves.toBeUndefined(); + + await act(async () => { + await new Promise((resolve) => { + window.setTimeout(resolve, 0); + }); + }); + + expect(mockGetAgentRuntimeSession).toHaveBeenCalledTimes(1); + + await act(async () => { + deferredTopicDetail.resolve({ + id: "topic-a", + messages: [ + { + role: "user", + timestamp: Math.floor( + new Date("2026-04-24T10:00:01.000Z").getTime() / 1000, + ), + content: [{ type: "text", text: "继续完善上一个方案" }], + }, + { + role: "assistant", + timestamp: Math.floor( + new Date("2026-04-24T10:00:05.000Z").getTime() / 1000, + ), + content: [{ type: "text", text: "这是远端补全后的最终结果。" }], + }, + ], + turns: [], + items: [], + queued_turns: [], + execution_strategy: "react", + }); + }); + await flushEffects(); + + expect(harness.getValue().messages.at(-1)?.content).toBe( + "这是远端补全后的最终结果。", + ); + } finally { + harness.unmount(); + } + }); + it("切换话题时应优先从 execution_runtime 恢复 provider/model", async () => { const workspaceId = "ws-topic-runtime-priority"; const topicId = "topic-runtime-priority"; diff --git a/src/components/agent/chat/index.test.tsx b/src/components/agent/chat/index.test.tsx index 4ae6d2376..e54014fe8 100644 --- a/src/components/agent/chat/index.test.tsx +++ b/src/components/agent/chat/index.test.tsx @@ -474,7 +474,7 @@ vi.mock("./components/TeamWorkspaceDock", () => ({ onActivateWorkbench(); }} > - 打开生成工作台 + 打开生成 ) : null}

@@ -3809,7 +3809,6 @@ describe("AgentChatPage 通用工作台", { timeout: 20_000 }, () => { await flushEffects(12); expect(sharedSwitchTopicMock).toHaveBeenCalledWith("topic-a", { - forceRefresh: true, resumeSessionStartHooks: true, }); expect(onNavigate).not.toHaveBeenCalled(); diff --git a/src/components/agent/chat/service-skills/runtimeSceneBinding.ts b/src/components/agent/chat/service-skills/runtimeSceneBinding.ts index 94740a1d8..a138d7975 100644 --- a/src/components/agent/chat/service-skills/runtimeSceneBinding.ts +++ b/src/components/agent/chat/service-skills/runtimeSceneBinding.ts @@ -5,6 +5,7 @@ interface RuntimeSceneCommandLike { key?: string | null; commandPrefix?: string | null; aliases?: string[] | null; + linkedSkillId?: string | null; } interface RuntimeSceneBindingLike { @@ -49,6 +50,7 @@ function buildRuntimeSceneCommandTokenSet( return buildRuntimeSceneTokenSet([ command.key, command.commandPrefix, + command.linkedSkillId, ...(command.aliases ?? []), ]); } diff --git a/src/components/agent/chat/service-skills/skillPresentation.test.ts b/src/components/agent/chat/service-skills/skillPresentation.test.ts index b4635c4bf..fd8602001 100644 --- a/src/components/agent/chat/service-skills/skillPresentation.test.ts +++ b/src/components/agent/chat/service-skills/skillPresentation.test.ts @@ -29,8 +29,8 @@ function createServiceSkill( } describe("skillPresentation", () => { - it("本地即时技能的动作文案应指向对话内补参,而不是旧弹窗心智", () => { - expect(getServiceSkillActionLabel(createServiceSkill())).toBe("对话内补参"); + it("本地即时技能的动作文案应指向开始这一步,而不是旧工作区心智", () => { + expect(getServiceSkillActionLabel(createServiceSkill())).toBe("开始这一步"); }); it("service skill 的必填输入摘要应优先使用槽位标签,并在无必填项时给出稳定兜底", () => { @@ -87,7 +87,7 @@ describe("skillPresentation", () => { ).toBe("需要:当前无必填信息 · 交付:研究摘要"); }); - it("带必填槽位的站点技能应指向对话内补参", () => { + it("带必填槽位的站点技能应提示先补齐这一步", () => { expect( getServiceSkillActionLabel( createServiceSkill({ @@ -108,10 +108,10 @@ describe("skillPresentation", () => { }, }), ), - ).toBe("对话内补参"); + ).toBe("补齐这一步"); }); - it("无必填槽位的站点技能仍应保留开始执行语义", () => { + it("无必填槽位的站点技能应直接提示接着继续", () => { expect( getServiceSkillActionLabel( createServiceSkill({ @@ -124,7 +124,7 @@ describe("skillPresentation", () => { }, }), ), - ).toBe("开始执行"); + ).toBe("接着继续"); }); it("站点做法的运行语义应改成接着浏览器继续,而不是暴露采集与登录态术语", () => { @@ -142,7 +142,7 @@ describe("skillPresentation", () => { expect(getServiceSkillRunnerLabel(siteSkill)).toBe("接着浏览器继续"); expect(getServiceSkillRunnerDescription(siteSkill)).toBe( - "会接着当前浏览器里的已登录页面把这一步做完,并把结果带回当前工作区。", + "会接着当前浏览器里已经打开的页面把这一步做完,并把结果带回生成。", ); expect(listServiceSkillDependencies(siteSkill)).toContain( "需要当前浏览器里已经打开并登录对应站点。", diff --git a/src/components/agent/chat/service-skills/skillPresentation.ts b/src/components/agent/chat/service-skills/skillPresentation.ts index 02b218000..64106c75d 100644 --- a/src/components/agent/chat/service-skills/skillPresentation.ts +++ b/src/components/agent/chat/service-skills/skillPresentation.ts @@ -9,9 +9,9 @@ import type { } from "./types"; const RUNNER_LABELS: Record = { - instant: "立即开始", - scheduled: "定时执行", - managed: "持续跟踪", + instant: "先做这一轮", + scheduled: "按时继续", + managed: "持续跟进", }; const RUNNER_TONES: Record = { @@ -21,15 +21,15 @@ const RUNNER_TONES: Record = { }; const RUNNER_DESCRIPTIONS: Record = { - instant: "会直接在当前工作区生成首版结果,方便继续补充与改写。", - scheduled: "会先生成首轮结果,再按设定时间持续回流到生成工作台。", - managed: "会先生成首轮策略,再持续跟踪并回流后续结果与提醒。", + instant: "会先给出这一轮结果,接着就能继续改。", + scheduled: "会先给出第一轮结果,后面按设定时间继续带回来。", + managed: "会先给出这轮判断,后面持续带回新的结果和提醒。", }; const LOCAL_ACTION_LABELS: Record = { - instant: "对话内补参", - scheduled: "创建任务", - managed: "创建跟踪", + instant: "开始这一步", + scheduled: "开始持续", + managed: "开始跟进", }; const SERVICE_SKILL_TYPE_LABELS: Record = { @@ -191,17 +191,18 @@ export function getServiceSkillRunnerDescription( item: ServiceSkillItem, ): string { if (resolveServiceSkillType(item) === "site") { - return "会接着当前浏览器里的已登录页面把这一步做完,并把结果带回当前工作区。"; + return "会接着当前浏览器里已经打开的页面把这一步做完,并把结果带回生成。"; } return RUNNER_DESCRIPTIONS[item.runnerType]; } export function getServiceSkillActionLabel(item: ServiceSkillItem): string { + if (hasRequiredSlots(item)) { + return "补齐这一步"; + } + if (resolveServiceSkillType(item) === "site") { - if (hasRequiredSlots(item)) { - return "对话内补参"; - } - return "开始执行"; + return "接着继续"; } return LOCAL_ACTION_LABELS[item.runnerType]; } @@ -223,19 +224,19 @@ export function getServiceSkillOutputDestination( if (isServiceSkillExecutableAsSiteAdapter(item)) { return item.siteCapabilityBinding.saveMode === "project_resource" - ? "结果会沉淀为当前项目资源,方便后续复用。" - : "结果会优先写回当前内容,继续在当前工作区整理。"; + ? "结果会收进当前项目资料,后面还能继续拿来用。" + : "结果会先回到当前内容里,方便接着往下改。"; } if (item.runnerType === "scheduled") { - return "首轮结果会进入当前工作区;后续结果会同步到生成工作台。"; + return "第一轮结果会先回到生成,后面按时间继续接回来。"; } if (item.runnerType === "managed") { - return "首轮策略会进入当前工作区;后续跟踪结果会持续回流。"; + return "这轮判断会先回到生成,后面新的结果和提醒也会继续带回来。"; } - return "结果会写回当前工作区,方便继续编辑。"; + return "结果会回到生成,方便接着改。"; } export function listServiceSkillDependencies(item: ServiceSkillItem): string[] { @@ -264,13 +265,7 @@ export function getServiceSkillPrimaryActionLabel( canCreateAutomation: boolean, ): string { if (canCreateAutomation) { - return "创建任务并进入工作区"; + return "开始持续"; } - if (resolveServiceSkillType(skill) === "site") { - if (hasRequiredSlots(skill)) { - return "对话内补参"; - } - return "开始执行"; - } - return "进入工作区"; + return getServiceSkillActionLabel(skill); } diff --git a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx index e4334b004..7df4dbaac 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.test.tsx +++ b/src/components/agent/chat/service-skills/useServiceSkills.test.tsx @@ -233,7 +233,7 @@ describe("useServiceSkills", () => { expect(harness.getValue().catalogMeta).toEqual( expect.objectContaining({ tenantId: "local-seeded", - sourceLabel: "本地 Seeded 目录", + sourceLabel: "起步做法", isSeeded: true, }), ); @@ -275,18 +275,18 @@ describe("useServiceSkills", () => { ]), ); expect(harness.getValue().skills[0]?.id).toBe("tenant-daily-briefing"); - expect(harness.getValue().skills[0]?.badge).toBe("云目录"); + expect(harness.getValue().skills[0]?.badge).toBe("现成做法"); expect( harness .getValue() .skills.find((skill) => skill.id === "local-playbook-template") ?.badge, - ).toBe("本地技能"); + ).toBe("现成做法"); expect(harness.getValue().catalogMeta).toEqual( expect.objectContaining({ tenantId: "tenant-demo", version: "tenant-2026-03-29", - sourceLabel: "租户技能目录", + sourceLabel: "已同步做法", isSeeded: false, }), ); @@ -303,7 +303,7 @@ describe("useServiceSkills", () => { expect(harness.getValue().catalogMeta).toEqual( expect.objectContaining({ tenantId: "local-seeded", - sourceLabel: "本地 Seeded 目录", + sourceLabel: "起步做法", isSeeded: true, }), ); @@ -371,7 +371,7 @@ describe("useServiceSkills", () => { expect(harness.getValue().catalogMeta).toEqual( expect.objectContaining({ tenantId: "local-seeded", - sourceLabel: "本地 Seeded 目录", + sourceLabel: "起步做法", isSeeded: true, }), ); @@ -399,7 +399,7 @@ describe("useServiceSkills", () => { expect.objectContaining({ tenantId: "tenant-demo", version: "tenant-2026-03-29", - sourceLabel: "租户技能目录", + sourceLabel: "已同步做法", isSeeded: false, }), ); @@ -425,7 +425,7 @@ describe("useServiceSkills", () => { .getValue() .skills.find((skill) => skill.id === "cloud-video-dubbing") ?.runnerLabel, - ).toBe("立即开始"); + ).toBe("先做这一轮"); } finally { harness.unmount(); } diff --git a/src/components/agent/chat/service-skills/useServiceSkills.ts b/src/components/agent/chat/service-skills/useServiceSkills.ts index c5a706f1a..3966bb759 100644 --- a/src/components/agent/chat/service-skills/useServiceSkills.ts +++ b/src/components/agent/chat/service-skills/useServiceSkills.ts @@ -42,10 +42,14 @@ function getSkillBadge(item: SkillCatalogItem, isRecent: boolean): string { if (isRecent) { return "最近使用"; } - if (item.source === "cloud_catalog") { - return "云目录"; + if ( + item.defaultExecutorBinding === "browser_assist" || + item.execution.kind === "site_adapter" + ) { + return "浏览器继续"; } - return "本地技能"; + + return "现成做法"; } function buildHomeItems( @@ -101,7 +105,7 @@ function buildCatalogMeta(catalog: SkillCatalog): ServiceSkillCatalogMeta { syncedAt: catalog.syncedAt, itemCount: catalog.items.length, groupCount: catalog.groups.length, - sourceLabel: isSeeded ? "本地 Seeded 目录" : "租户技能目录", + sourceLabel: isSeeded ? "起步做法" : "已同步做法", isSeeded, }; } diff --git a/src/components/agent/chat/skill-selection/CharacterMention.test.tsx b/src/components/agent/chat/skill-selection/CharacterMention.test.tsx index 669cd36f7..c0f4f7f5d 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.test.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.test.tsx @@ -18,10 +18,6 @@ import type { ServiceSkillHomeItem, } from "@/components/agent/chat/service-skills/types"; import { recordServiceSkillUsage } from "@/components/agent/chat/service-skills/storage"; -import type { - BuiltinInputCommand, - RuntimeSceneSlashCommand, -} from "./builtinCommands"; import type { InputCapabilitySelection } from "./inputCapabilitySelection"; import { recordMentionEntryUsage } from "./mentionEntryUsage"; import { recordSlashEntryUsage } from "./slashEntryUsage"; @@ -310,20 +306,10 @@ interface HarnessProps { syncValue?: boolean; onNavigateToSettings?: () => void; onChangeSpy?: (value: string) => void; - onSelectBuiltinCommand?: ( - command: BuiltinInputCommand, - options?: { replayText?: string }, - ) => void; onSelectInputCapability?: ( capability: InputCapabilitySelection, options?: { replayText?: string }, ) => void; - onSelectSkill?: (skill: Skill) => void; - onSelectSceneCommand?: ( - command: RuntimeSceneSlashCommand, - options?: { replayText?: string }, - ) => void; - onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; projectId?: string | null; sessionId?: string | null; defaultCuratedTaskReferenceMemoryIds?: string[]; @@ -339,11 +325,7 @@ const Harness: React.FC = ({ syncValue = true, onNavigateToSettings, onChangeSpy, - onSelectBuiltinCommand, onSelectInputCapability, - onSelectSkill, - onSelectSceneCommand, - onSelectServiceSkill, projectId = null, sessionId = null, defaultCuratedTaskReferenceMemoryIds = [], @@ -378,11 +360,7 @@ const Harness: React.FC = ({ setValue(next); } }} - onSelectBuiltinCommand={onSelectBuiltinCommand} onSelectInputCapability={onSelectInputCapability} - onSelectSkill={onSelectSkill} - onSelectSceneCommand={onSelectSceneCommand} - onSelectServiceSkill={onSelectServiceSkill} projectId={projectId} sessionId={sessionId} defaultCuratedTaskReferenceMemoryIds={ @@ -726,9 +704,11 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); + expect(document.body.textContent).toContain("统一调用注册表"); + expect(document.body.textContent).toContain("先调命令,再补做法"); expect(document.body.textContent).toContain("生成 / 表达"); expect(document.body.textContent).toContain("搜索 / 读取"); - expect(document.body.textContent).toContain("浏览器 / 执行"); + expect(document.body.textContent).toContain("浏览器 / 编排"); expect(document.body.textContent).toContain("@配图"); expect(document.body.textContent).toContain("@封面"); expect(document.body.textContent).toContain("@海报"); @@ -746,33 +726,6 @@ describe("CharacterMention", () => { expect(document.body.textContent).toContain("@浏览器"); }); - it("提供 onSelectBuiltinCommand 时,选择配图命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@配图")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "image_generate", - commandPrefix: "@配图", - }), - ); - }); - it("提供 onSelectInputCapability 时,选择配图命令应走统一 capability 回调", async () => { const onChangeSpy = vi.fn<(value: string) => void>(); const onSelectInputCapability = vi.fn< @@ -811,627 +764,6 @@ describe("CharacterMention", () => { expect(onChangeSpy).toHaveBeenLastCalledWith(""); }); - it("提供 onSelectBuiltinCommand 时,选择海报命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@海报")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "poster_generate", - commandPrefix: "@海报", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择渠道预览命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@渠道预览")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "channel_preview_runtime", - commandPrefix: "@渠道预览", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择上传命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@上传")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "upload_runtime", - commandPrefix: "@上传", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择发布合规命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@发布合规")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "publish_compliance", - commandPrefix: "@发布合规", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择封面命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@封面")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "cover_generate", - commandPrefix: "@封面", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择修图命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@修图")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "image_edit", - commandPrefix: "@修图", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择重绘命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@重绘")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "image_variation", - commandPrefix: "@重绘", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择视频命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@视频")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "video_generate", - commandPrefix: "@视频", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择播报命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@播报")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "broadcast_generate", - commandPrefix: "@播报", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择素材命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@素材")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "modal_resource_search", - commandPrefix: "@素材", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择搜索命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@搜索")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "research", - commandPrefix: "@搜索", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择深搜命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@深搜")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "deep_search", - commandPrefix: "@深搜", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择研报命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@研报")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "research_report", - commandPrefix: "@研报", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择竞品命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@竞品")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "competitor_research", - commandPrefix: "@竞品", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择站点搜索命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@站点搜索")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "site_search", - commandPrefix: "@站点搜索", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择读PDF命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@读PDF")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "read_pdf", - commandPrefix: "@读PDF", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择总结命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@总结")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "summary", - commandPrefix: "@总结", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择翻译命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@翻译")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "translation", - commandPrefix: "@翻译", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择分析命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@分析")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "analysis", - commandPrefix: "@分析", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择转写命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@转写")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "transcription_generate", - commandPrefix: "@转写", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择链接解析命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@链接解析")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "url_parse", - commandPrefix: "@链接解析", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择抓取命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@抓取")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "web_scrape", - commandPrefix: "@抓取", - }), - ); - }); - - it("提供 onSelectBuiltinCommand 时,选择网页读取命令应交给父组件接管", async () => { - const onSelectBuiltinCommand = - vi.fn<(command: BuiltinInputCommand) => void>(); - const container = renderHarness({ - onSelectBuiltinCommand, - }); - const textarea = getTextarea(container); - - await typeAtAndWait(textarea); - - const builtinButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("@网页读取")); - expect(builtinButton).toBeTruthy(); - - act(() => { - builtinButton?.click(); - }); - - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "webpage_read", - commandPrefix: "@网页读取", - }), - ); - }); - it("服务技能应出现在 @ 面板里", async () => { const container = renderHarness({ serviceSkills: [ @@ -1466,12 +798,16 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); - expect(document.body.textContent).toContain("推荐做法"); + expect(document.body.textContent).toContain("场景做法"); expect(document.body.textContent).toContain("每日趋势摘要"); expect(document.body.textContent).toContain("GitHub 仓库雷达"); expect(document.body.textContent).toContain("需要:当前无必填信息"); expect(document.body.textContent).toContain("交付:趋势摘要 + 调度建议"); expect(document.body.textContent).toContain("需要:仓库关键词"); + const bodyText = document.body.textContent ?? ""; + expect(bodyText.indexOf("搜索 / 读取")).toBeLessThan( + bodyText.indexOf("场景做法"), + ); }); it("最近使用的服务技能应优先显示在独立分组,且不在技能组里重复", async () => { @@ -1518,8 +854,8 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); - expect(document.body.textContent).toContain("最近使用"); - expect(document.body.textContent).toContain("推荐做法"); + expect(document.body.textContent).toContain("最近调用"); + expect(document.body.textContent).toContain("场景做法"); expect(document.body.textContent).toContain( "上次填写:关注平台=X + TikTok;关键词=AI 内容创作", ); @@ -1535,7 +871,7 @@ describe("CharacterMention", () => { expect(regularButtons).toHaveLength(1); }); - it("@ 空查询时应优先显示最近使用的内建命令,且不在内建命令分组重复", async () => { + it("@ 空查询时应优先显示最近调用的内建命令,且不在内建命令分组重复", async () => { act(() => { recordMentionEntryUsage({ kind: "builtin_command", @@ -1549,7 +885,7 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); - expect(document.body.textContent).toContain("最近使用"); + expect(document.body.textContent).toContain("最近调用"); const recentCommandButtons = Array.from( document.body.querySelectorAll("button"), @@ -1557,13 +893,13 @@ describe("CharacterMention", () => { expect(recentCommandButtons).toHaveLength(1); }); - it("@ 面板打开后新增内建命令 recent usage 时,应即时刷新最近使用分组", async () => { + it("@ 面板打开后新增内建命令 recent usage 时,应即时刷新最近调用分组", async () => { const container = renderHarness(); const textarea = getTextarea(container); await typeAtAndWait(textarea); - expect(document.body.textContent).not.toContain("最近使用"); + expect(document.body.textContent).not.toContain("最近调用"); await act(async () => { recordMentionEntryUsage({ @@ -1575,7 +911,7 @@ describe("CharacterMention", () => { await Promise.resolve(); }); - expect(document.body.textContent).toContain("最近使用"); + expect(document.body.textContent).toContain("最近调用"); expect(document.body.textContent).toContain("@搜索"); expect(document.body.textContent).toContain("上次输入:关键词:AI Agent 融资"); }); @@ -1592,16 +928,16 @@ describe("CharacterMention", () => { }); const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectBuiltinCommand = + const onSelectInputCapability = vi.fn< ( - command: BuiltinInputCommand, + capability: InputCapabilitySelection, options?: { replayText?: string }, ) => void >(); const container = renderHarness({ onChangeSpy, - onSelectBuiltinCommand, + onSelectInputCapability, }); const textarea = getTextarea(container); @@ -1620,9 +956,12 @@ describe("CharacterMention", () => { }); expect(onChangeSpy).toHaveBeenLastCalledWith(replayText); - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( + expect(onSelectInputCapability).toHaveBeenCalledWith( expect.objectContaining({ - key: "research", + kind: "builtin_command", + command: expect.objectContaining({ + key: "research", + }), }), expect.objectContaining({ replayText, @@ -1643,16 +982,16 @@ describe("CharacterMention", () => { }); const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectBuiltinCommand = + const onSelectInputCapability = vi.fn< ( - command: BuiltinInputCommand, + capability: InputCapabilitySelection, options?: { replayText?: string }, ) => void >(); const container = renderHarness({ onChangeSpy, - onSelectBuiltinCommand, + onSelectInputCapability, }); const textarea = getTextarea(container); @@ -1673,10 +1012,13 @@ describe("CharacterMention", () => { }); expect(onChangeSpy).toHaveBeenLastCalledWith(replayText); - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( + expect(onSelectInputCapability).toHaveBeenCalledWith( expect.objectContaining({ - key: "research", - commandPrefix: "@搜索", + kind: "builtin_command", + command: expect.objectContaining({ + key: "research", + commandPrefix: "@搜索", + }), }), expect.objectContaining({ replayText, @@ -1704,16 +1046,16 @@ describe("CharacterMention", () => { }); const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectBuiltinCommand = + const onSelectInputCapability = vi.fn< ( - command: BuiltinInputCommand, + capability: InputCapabilitySelection, options?: { replayText?: string }, ) => void >(); const container = renderHarness({ onChangeSpy, - onSelectBuiltinCommand, + onSelectInputCapability, }); const textarea = getTextarea(container); @@ -1734,10 +1076,13 @@ describe("CharacterMention", () => { }); expect(onChangeSpy).toHaveBeenLastCalledWith(replayText); - expect(onSelectBuiltinCommand).toHaveBeenCalledWith( + expect(onSelectInputCapability).toHaveBeenCalledWith( expect.objectContaining({ - key: "research", - commandPrefix: "@搜索", + kind: "builtin_command", + command: expect.objectContaining({ + key: "research", + commandPrefix: "@搜索", + }), }), expect.objectContaining({ replayText, @@ -1745,7 +1090,7 @@ describe("CharacterMention", () => { ); }); - it("@ 搜索时不应显示最近使用,而应回到普通命令结果", async () => { + it("@ 搜索时不应显示最近调用,而应回到普通命令结果", async () => { act(() => { recordMentionEntryUsage({ kind: "builtin_command", @@ -1772,7 +1117,7 @@ describe("CharacterMention", () => { await Promise.resolve(); }); - expect(document.body.textContent).not.toContain("最近使用"); + expect(document.body.textContent).not.toContain("最近调用"); expect(document.body.textContent).toContain("搜索 / 读取"); expect(document.body.textContent).toContain("@搜索"); }); @@ -1804,7 +1149,7 @@ describe("CharacterMention", () => { expect(document.body.textContent).toContain("写作助手"); expect(document.body.textContent).toContain("当你需要复用本地写作方法时使用。"); expect(document.body.textContent).toContain("需要:主题、受众与语气要求"); - expect(document.body.textContent).toContain("交付:带着该方法进入生成主执行面"); + expect(document.body.textContent).toContain("交付:带着该方法进入生成"); }); it("只有最近使用的服务技能时,不应同时出现空态文案", async () => { @@ -1822,7 +1167,7 @@ describe("CharacterMention", () => { await typeAtAndWait(textarea); - expect(document.body.textContent).toContain("最近使用"); + expect(document.body.textContent).toContain("最近调用"); expect(document.body.textContent).toContain("最近趋势摘要"); expect(document.body.textContent).not.toContain("暂无可用角色或技能"); }); @@ -1863,8 +1208,8 @@ describe("CharacterMention", () => { await Promise.resolve(); }); - expect(document.body.textContent).not.toContain("最近使用"); - expect(document.body.textContent).not.toContain("推荐技能"); + expect(document.body.textContent).not.toContain("最近调用"); + expect(document.body.textContent).not.toContain("场景做法"); expect(document.body.textContent).toContain("GitHub"); expect(document.body.textContent).toContain("GitHub 仓库雷达"); }); @@ -2003,7 +1348,7 @@ describe("CharacterMention", () => { await Promise.resolve(); }); - expect(document.body.textContent).toContain("浏览器 / 执行"); + expect(document.body.textContent).toContain("浏览器 / 编排"); expect(document.body.textContent).toContain("@代码"); }); @@ -2166,7 +1511,7 @@ describe("CharacterMention", () => { await Promise.resolve(); }); - expect(document.body.textContent).toContain("浏览器 / 执行"); + expect(document.body.textContent).toContain("浏览器 / 编排"); expect(document.body.textContent).toContain("@浏览器"); }); @@ -2263,13 +1608,18 @@ describe("CharacterMention", () => { expect(filtered[0]?.id).toBe("carousel-post-replication"); }); - it("提供 onSelectServiceSkill 时,选择服务技能应交给父组件接管", async () => { - const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); + it("提供统一 capability 回调时,选择服务技能应走 current 主链", async () => { + const onSelectInputCapability = vi.fn< + ( + capability: InputCapabilitySelection, + options?: { replayText?: string }, + ) => void + >(); const onChangeSpy = vi.fn<(value: string) => void>(); const serviceSkill = createServiceSkill(); const container = renderHarness({ serviceSkills: [serviceSkill], - onSelectServiceSkill, + onSelectInputCapability, onChangeSpy, }); const textarea = getTextarea(container); @@ -2286,10 +1636,13 @@ describe("CharacterMention", () => { }); expect(onChangeSpy).toHaveBeenCalledWith(""); - expect(onSelectServiceSkill).toHaveBeenCalledWith(serviceSkill); + expect(onSelectInputCapability).toHaveBeenCalledWith({ + kind: "service_skill", + skill: serviceSkill, + }); }); - it("未提供 onSelectSkill 时,选择已安装技能应回填到输入框", async () => { + it("未提供统一 capability 回调时,选择已安装技能应回填到输入框", async () => { const onChangeSpy = vi.fn<(value: string) => void>(); const container = renderHarness({ skills: [createSkill("技能A", "skill-a", true)], @@ -2356,7 +1709,8 @@ describe("CharacterMention", () => { await typeSlashAndWait(textarea, "/camp"); expect(document.body.textContent).toContain("结果模板"); - expect(document.body.textContent).toContain("/campaign-launch"); + expect(document.body.textContent).toContain("新品发布场景"); + expect(document.body.textContent).not.toContain("/campaign-launch"); expect(document.body.textContent).toContain( "把链接解析、配图和封面串成一条产品链路。", ); @@ -2729,7 +2083,7 @@ describe("CharacterMention", () => { expect(reviewIndex).toBeLessThan(trendIndex); }); - it("slash 面板的结果模板分组应显影最近复盘横幅", async () => { + it("slash 面板的结果模板分组应显影最近判断横幅", async () => { recordCuratedTaskRecommendationSignalFromReviewDecision( { session_id: "session-review-needs-evidence", @@ -2756,9 +2110,9 @@ describe("CharacterMention", () => { const banner = document.body.querySelector( '[data-testid="input-capability-section-banner-result-templates"]', ); - expect(banner?.textContent).toContain("最近复盘已更新:短视频编排 · 补证据"); + expect(banner?.textContent).toContain("最近判断已更新:短视频编排 · 补证据"); expect(banner?.textContent).toContain("这轮结果还缺证据"); - expect(banner?.textContent).toContain("这轮复盘更建议优先回到"); + expect(banner?.textContent).toContain("这轮判断更建议优先回到"); expect(banner?.textContent).toContain("复盘这个账号/项目"); expect(banner?.textContent).toContain("拆解一条爆款内容"); expect(banner?.textContent).toContain( @@ -2853,13 +2207,15 @@ describe("CharacterMention", () => { const sceneButtons = Array.from( document.body.querySelectorAll("button"), - ).filter((button) => button.textContent?.includes("/campaign-launch")); + ).filter((button) => button.textContent?.includes("新品发布场景")); expect(sceneButtons).toHaveLength(1); + expect(document.body.textContent).not.toContain("/campaign-launch"); const skillButtons = Array.from( document.body.querySelectorAll("button"), ).filter((button) => button.textContent?.includes("技能A")); expect(skillButtons).toHaveLength(1); + expect(document.body.textContent).not.toContain("/skill-a"); const curatedTaskButtons = Array.from( document.body.querySelectorAll("button"), @@ -2910,7 +2266,7 @@ describe("CharacterMention", () => { expect(document.body.textContent).toContain( "优先接着已经跑过的方法,通常比重新挑一条更省重来成本。", ); - expect(document.body.textContent).toContain("/skill-writer"); + expect(document.body.textContent).not.toContain("/skill-writer"); expect(document.body.textContent).toContain( "写作助手 · 当你需要复用本地写作方法时使用。", ); @@ -2919,7 +2275,7 @@ describe("CharacterMention", () => { ); expect(document.body.textContent).toContain("脚本助手"); expect(document.body.textContent).toContain("需要:主题、受众与语气要求"); - expect(document.body.textContent).toContain("交付:带着该方法进入生成主执行面"); + expect(document.body.textContent).toContain("交付:带着该方法进入生成"); }); it("slash 面板打开后新增本地 skill 使用记录时,应即时刷新继续上次做法分组", async () => { @@ -2950,7 +2306,7 @@ describe("CharacterMention", () => { }); expect(document.body.textContent).toContain("继续上次做法"); - expect(document.body.textContent).toContain("/skill-writer"); + expect(document.body.textContent).not.toContain("/skill-writer"); expect(document.body.textContent).toContain("写作助手"); }); @@ -3003,10 +2359,8 @@ describe("CharacterMention", () => { ]); const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); const container = renderHarness({ onChangeSpy, - onSelectServiceSkill, }); const textarea = getTextarea(container); @@ -3014,7 +2368,7 @@ describe("CharacterMention", () => { const recentSceneButton = Array.from( document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("/x文章转存")); + ).find((button) => button.textContent?.includes("X文章转存")); expect(recentSceneButton).toBeTruthy(); act(() => { @@ -3024,7 +2378,6 @@ describe("CharacterMention", () => { expect(onChangeSpy).toHaveBeenLastCalledWith( "/x文章转存 https://x.com/GoogleCloudTech/article/2033953579824758855", ); - expect(onSelectServiceSkill).not.toHaveBeenCalled(); }); it("选择最近使用的结果模板时应预填上次启动参数与引用", async () => { @@ -3088,90 +2441,6 @@ describe("CharacterMention", () => { ); }); - it("提供 onSelectSkill 时,最近使用的 slash skill 应回填 replayText 并切到 active capability", async () => { - act(() => { - recordSlashEntryUsage({ - kind: "skill", - entryId: "skill-a", - usedAt: 1_712_345_678_900, - replayText: "整理最近发布计划", - }); - }); - - const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectSkill = vi.fn<(skill: Skill) => void>(); - const container = renderHarness({ - skills: [createSkill("技能A", "skill-a", true)], - onChangeSpy, - onSelectSkill, - }); - const textarea = getTextarea(container); - - await typeSlashAndWait(textarea); - - const recentSkillButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("/skill-a")); - expect(recentSkillButton).toBeTruthy(); - - act(() => { - recentSkillButton?.click(); - }); - - expect(onSelectSkill).toHaveBeenCalledWith( - expect.objectContaining({ - key: "skill-a", - name: "技能A", - }), - ); - expect(onChangeSpy).toHaveBeenLastCalledWith("整理最近发布计划"); - }); - - it("提供 onSelectSceneCommand 时,最近使用的 scene 应回填 replayText 并切到 active capability", async () => { - act(() => { - saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync"); - recordSlashEntryUsage({ - kind: "scene", - entryId: "campaign-launch", - usedAt: 1_712_345_678_900, - replayText: "帮我做一版新品活动启动方案", - }); - }); - - const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectSceneCommand = vi.fn< - ( - command: RuntimeSceneSlashCommand, - options?: { replayText?: string }, - ) => void - >(); - const container = renderHarness({ - onChangeSpy, - onSelectSceneCommand, - }); - const textarea = getTextarea(container); - - await typeSlashAndWait(textarea); - - const recentSceneButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("/campaign-launch")); - expect(recentSceneButton).toBeTruthy(); - - act(() => { - recentSceneButton?.click(); - }); - - expect(onSelectSceneCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "campaign-launch", - commandPrefix: "/campaign-launch", - }), - { replayText: "帮我做一版新品活动启动方案" }, - ); - expect(onChangeSpy).toHaveBeenLastCalledWith("帮我做一版新品活动启动方案"); - }); - it("slash 搜索时不应显示最近使用,而应回到搜索结果分组", async () => { act(() => { recordSlashEntryUsage({ @@ -3227,51 +2496,17 @@ describe("CharacterMention", () => { const sceneButton = Array.from( document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("/campaign-launch")); + ).find((button) => button.textContent?.includes("新品发布场景")); expect(sceneButton).toBeTruthy(); - act(() => { + await act(async () => { sceneButton?.click(); + await Promise.resolve(); }); expect(onChangeSpy).toHaveBeenCalledWith("/campaign-launch "); }); - it("提供 onSelectSceneCommand 时,slash 面板选择 scene 应切换为 active capability 而不是回填命令前缀", async () => { - act(() => { - saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync"); - }); - - const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectSceneCommand = vi.fn< - (command: RuntimeSceneSlashCommand) => void - >(); - const container = renderHarness({ - onChangeSpy, - onSelectSceneCommand, - }); - const textarea = getTextarea(container); - - await typeSlashAndWait(textarea, "/camp"); - - const sceneButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("/campaign-launch")); - expect(sceneButton).toBeTruthy(); - - act(() => { - sceneButton?.click(); - }); - - expect(onSelectSceneCommand).toHaveBeenCalledWith( - expect.objectContaining({ - key: "campaign-launch", - commandPrefix: "/campaign-launch", - }), - ); - expect(onChangeSpy).toHaveBeenCalledWith(""); - }); - it("提供 onSelectInputCapability 时,slash 面板选择 scene 应走统一 capability 回调", async () => { act(() => { saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync"); @@ -3294,7 +2529,7 @@ describe("CharacterMention", () => { const sceneButton = Array.from( document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("/campaign-launch")); + ).find((button) => button.textContent?.includes("新品发布场景")); expect(sceneButton).toBeTruthy(); act(() => { @@ -3314,6 +2549,54 @@ describe("CharacterMention", () => { expect(onChangeSpy).toHaveBeenCalledWith(""); }); + it("提供 onSelectInputCapability 时,最近使用的 scene 应带 replayText 走统一 capability 回调", async () => { + act(() => { + saveSkillCatalog(buildCatalogWithSceneEntry(), "bootstrap_sync"); + recordSlashEntryUsage({ + kind: "scene", + entryId: "campaign-launch", + usedAt: 1_712_345_678_900, + replayText: "帮我做一版新品活动启动方案", + }); + }); + + const onChangeSpy = vi.fn<(value: string) => void>(); + const onSelectInputCapability = vi.fn< + ( + capability: InputCapabilitySelection, + options?: { replayText?: string }, + ) => void + >(); + const container = renderHarness({ + onChangeSpy, + onSelectInputCapability, + }); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea); + + const recentSceneButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("新品发布场景")); + expect(recentSceneButton).toBeTruthy(); + + act(() => { + recentSceneButton?.click(); + }); + + expect(onSelectInputCapability).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "runtime_scene", + command: expect.objectContaining({ + key: "campaign-launch", + commandPrefix: "/campaign-launch", + }), + }), + { replayText: "帮我做一版新品活动启动方案" }, + ); + expect(onChangeSpy).toHaveBeenLastCalledWith("帮我做一版新品活动启动方案"); + }); + it("slash 面板选择带必填参数的 scene 时应交给父层 A2UI 补参接管", async () => { act(() => { saveSkillCatalog(buildCatalogWithXSceneEntry(), "bootstrap_sync"); @@ -3323,10 +2606,15 @@ describe("CharacterMention", () => { ]); const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectServiceSkill = vi.fn<(skill: ServiceSkillHomeItem) => void>(); + const onSelectInputCapability = vi.fn< + ( + capability: InputCapabilitySelection, + options?: { replayText?: string }, + ) => void + >(); const container = renderHarness({ onChangeSpy, - onSelectServiceSkill, + onSelectInputCapability, }); const textarea = getTextarea(container); @@ -3334,7 +2622,7 @@ describe("CharacterMention", () => { const sceneButton = Array.from( document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("/x文章转存")); + ).find((button) => button.textContent?.includes("X文章转存")); expect(sceneButton).toBeTruthy(); await act(async () => { @@ -3342,12 +2630,13 @@ describe("CharacterMention", () => { await Promise.resolve(); }); - expect(onSelectServiceSkill).toHaveBeenCalledWith( - expect.objectContaining({ + expect(onSelectInputCapability).toHaveBeenCalledWith({ + kind: "service_skill", + skill: expect.objectContaining({ id: "x-article-export", title: "X 文章转存", }), - ); + }); expect(onChangeSpy).not.toHaveBeenCalled(); }); @@ -3373,36 +2662,6 @@ describe("CharacterMention", () => { expect(onChangeSpy).toHaveBeenCalledWith("/skill-a "); }); - it("提供 onSelectSkill 时,slash 面板选择已安装技能应切换为 active capability 而不是回填 slash skill", async () => { - const onChangeSpy = vi.fn<(value: string) => void>(); - const onSelectSkill = vi.fn<(skill: Skill) => void>(); - const container = renderHarness({ - skills: [createSkill("技能A", "skill-a", true)], - onChangeSpy, - onSelectSkill, - }); - const textarea = getTextarea(container); - - await typeSlashAndWait(textarea, "/ski"); - - const skillButton = Array.from( - document.body.querySelectorAll("button"), - ).find((button) => button.textContent?.includes("技能A")); - expect(skillButton).toBeTruthy(); - - act(() => { - skillButton?.click(); - }); - - expect(onSelectSkill).toHaveBeenCalledWith( - expect.objectContaining({ - key: "skill-a", - name: "技能A", - }), - ); - expect(onChangeSpy).toHaveBeenCalledWith(""); - }); - it("提供 onSelectInputCapability 时,slash 面板选择已安装技能应走统一 capability 回调", async () => { const onChangeSpy = vi.fn<(value: string) => void>(); const onSelectInputCapability = vi.fn< @@ -3442,6 +2701,54 @@ describe("CharacterMention", () => { expect(onChangeSpy).toHaveBeenCalledWith(""); }); + it("提供 onSelectInputCapability 时,最近使用的已安装技能应带 replayText 走统一 capability 回调", async () => { + act(() => { + recordSlashEntryUsage({ + kind: "skill", + entryId: "skill-a", + usedAt: 1_712_345_678_900, + replayText: "整理最近发布计划", + }); + }); + + const onChangeSpy = vi.fn<(value: string) => void>(); + const onSelectInputCapability = vi.fn< + ( + capability: InputCapabilitySelection, + options?: { replayText?: string }, + ) => void + >(); + const container = renderHarness({ + skills: [createSkill("技能A", "skill-a", true)], + onChangeSpy, + onSelectInputCapability, + }); + const textarea = getTextarea(container); + + await typeSlashAndWait(textarea); + + const recentSkillButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("技能A")); + expect(recentSkillButton).toBeTruthy(); + + act(() => { + recentSkillButton?.click(); + }); + + expect(onSelectInputCapability).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "installed_skill", + skill: expect.objectContaining({ + key: "skill-a", + name: "技能A", + }), + }), + { replayText: "整理最近发布计划" }, + ); + expect(onChangeSpy).toHaveBeenLastCalledWith("整理最近发布计划"); + }); + it("提及面板应锚定在输入框正上方,并禁止自动翻转到下方", async () => { const container = renderHarness(); const textarea = getTextarea(container); diff --git a/src/components/agent/chat/skill-selection/CharacterMention.tsx b/src/components/agent/chat/skill-selection/CharacterMention.tsx index f87a64a38..957b509b2 100644 --- a/src/components/agent/chat/skill-selection/CharacterMention.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMention.tsx @@ -50,6 +50,7 @@ import { LazyCharacterMentionPanel, preloadCharacterMentionPanel, } from "./characterMentionPanelLoader"; +import type { InputCapabilityDescriptor } from "./inputCapabilitySections"; import { recordSlashEntryUsage, subscribeSlashEntryUsageChanged, @@ -94,15 +95,8 @@ interface CharacterMentionProps { onChange: (value: string) => void; /** 选择角色回调 */ onSelectCharacter?: (character: Character) => void; - /** 选择已安装技能回调 */ - onSelectSkill?: (skill: Skill) => void; /** 统一选择输入能力回调 */ onSelectInputCapability?: SelectInputCapabilityHandler; - /** 选择运行时场景命令回调 */ - onSelectSceneCommand?: ( - command: RuntimeSceneSlashCommand, - options?: InputCapabilityActivationOptions, - ) => void; /** 选择结果模板回调 */ onSelectCuratedTask?: ( task: CuratedTaskTemplateItem, @@ -113,19 +107,12 @@ interface CharacterMentionProps { launcherPrefillHint?: string; }, ) => void; - /** 选择技能目录项回调 */ - onSelectServiceSkill?: (skill: ServiceSkillHomeItem) => void; projectId?: string | null; sessionId?: string | null; /** 当前默认带入的灵感引用 */ defaultCuratedTaskReferenceMemoryIds?: string[]; /** 当前默认带入的灵感引用对象 */ defaultCuratedTaskReferenceEntries?: CuratedTaskReferenceEntry[]; - /** 选择内建命令回调 */ - onSelectBuiltinCommand?: ( - command: BuiltinInputCommand, - options?: InputCapabilityActivationOptions, - ) => void; /** 跳转到设置页安装技能 */ onNavigateToSettings?: () => void; /** 是否启用输入自动补全与联想面板 */ @@ -239,7 +226,7 @@ function mergeTriggerSelectionText(params: { function toServiceSkillHomeItem(skill: ServiceSkillItem): ServiceSkillHomeItem { return { ...skill, - badge: skill.source === "cloud_catalog" ? "云目录" : "本地技能", + badge: "", recentUsedAt: null, isRecent: false, runnerLabel: getServiceSkillRunnerLabel(skill), @@ -259,16 +246,12 @@ export function CharacterMention({ value, onChange, onSelectCharacter, - onSelectSkill, onSelectInputCapability, - onSelectSceneCommand, onSelectCuratedTask, - onSelectServiceSkill, projectId, sessionId, defaultCuratedTaskReferenceMemoryIds = [], defaultCuratedTaskReferenceEntries = [], - onSelectBuiltinCommand, onNavigateToSettings, inputCompletionEnabled = true, }: CharacterMentionProps) { @@ -517,7 +500,7 @@ export function CharacterMention({ if (activeTrigger.mode === "slash") { const replayText = normalizeReplayText(options?.replayText); - if (onSelectInputCapability || onSelectSkill) { + if (onSelectInputCapability) { const leadingText = currentValue.slice(0, activeTrigger.triggerIndex); const nextSelection = replayText ? mergeTriggerSelectionText({ @@ -534,17 +517,13 @@ export function CharacterMention({ onChange(normalizedValue); setShowMentions(false); - if (onSelectInputCapability) { - onSelectInputCapability( - { - kind: "installed_skill", - skill, - }, - replayText ? { replayText } : undefined, - ); - } else { - onSelectSkill?.(skill); - } + onSelectInputCapability( + { + kind: "installed_skill", + skill, + }, + replayText ? { replayText } : undefined, + ); setTimeout(() => { textarea.focus(); @@ -586,19 +565,15 @@ export function CharacterMention({ return; } - if (onSelectInputCapability || onSelectSkill) { + if (onSelectInputCapability) { const newValue = currentValue.slice(0, activeTrigger.triggerIndex) + textAfterCursor; onChange(newValue.trimEnd() === "" ? "" : newValue); setShowMentions(false); - if (onSelectInputCapability) { - onSelectInputCapability({ - kind: "installed_skill", - skill, - }); - } else { - onSelectSkill?.(skill); - } + onSelectInputCapability({ + kind: "installed_skill", + skill, + }); setTimeout(() => { textarea.focus(); @@ -653,7 +628,7 @@ export function CharacterMention({ const leadingText = currentValue.slice(0, activeTrigger.triggerIndex); const replayText = normalizeReplayText(options?.replayText); - if (onSelectInputCapability || onSelectBuiltinCommand) { + if (onSelectInputCapability) { const nextSelection = replayText ? mergeTriggerSelectionText({ leadingText, @@ -669,19 +644,13 @@ export function CharacterMention({ onChange(normalizedValue); setShowMentions(false); - if (onSelectInputCapability) { - onSelectInputCapability( - { - kind: "builtin_command", - command, - }, - replayText ? { replayText } : undefined, - ); - } else if (options?.replayText) { - onSelectBuiltinCommand?.(command, options); - } else { - onSelectBuiltinCommand?.(command); - } + onSelectInputCapability( + { + kind: "builtin_command", + command, + }, + replayText ? { replayText } : undefined, + ); setTimeout(() => { textarea.focus(); @@ -728,12 +697,15 @@ export function CharacterMention({ return; } - if (onSelectServiceSkill) { + if (onSelectInputCapability) { const newValue = currentValue.slice(0, activeTrigger.triggerIndex) + textAfterCursor; onChange(newValue.trimEnd() === "" ? "" : newValue); setShowMentions(false); - onSelectServiceSkill(skill); + onSelectInputCapability({ + kind: "service_skill", + skill, + }); setTimeout(() => { textarea.focus(); @@ -811,10 +783,15 @@ export function CharacterMention({ ) => { const replayText = normalizeReplayText(options?.replayText); - if (!replayText && onSelectServiceSkill) { - const matchedVisibleSkill = serviceSkills.find((skill) => - matchesRuntimeSceneCommandToServiceSkill(skill, command), - ); + const matchedVisibleSkill = serviceSkills.find((skill) => + matchesRuntimeSceneCommandToServiceSkill(skill, command), + ); + const shouldResolveServiceSkill = + !replayText && + onSelectInputCapability && + (matchedVisibleSkill || command.linkedSkillId); + + if (shouldResolveServiceSkill) { const matchedSkill = matchedVisibleSkill || (await (async () => { @@ -835,7 +812,10 @@ export function CharacterMention({ matchedSkill && matchedSkill.slotSchema.some((slot) => slot.required) ) { - onSelectServiceSkill(matchedSkill); + onSelectInputCapability({ + kind: "service_skill", + skill: matchedSkill, + }); setShowMentions(false); recordSlashEntryUsage({ kind: "scene", @@ -856,7 +836,7 @@ export function CharacterMention({ return; } - if (onSelectInputCapability || onSelectSceneCommand) { + if (onSelectInputCapability) { const leadingText = currentValue.slice(0, activeTrigger.triggerIndex); const nextSelection = replayText ? mergeTriggerSelectionText({ @@ -873,19 +853,13 @@ export function CharacterMention({ onChange(normalizedValue); setShowMentions(false); - if (onSelectInputCapability) { - onSelectInputCapability( - { - kind: "runtime_scene", - command, - }, - replayText ? { replayText } : undefined, - ); - } else if (replayText) { - onSelectSceneCommand?.(command, { replayText }); - } else { - onSelectSceneCommand?.(command); - } + onSelectInputCapability( + { + kind: "runtime_scene", + command, + }, + replayText ? { replayText } : undefined, + ); setTimeout(() => { textarea.focus(); @@ -974,6 +948,52 @@ export function CharacterMention({ }); }; + const handleSelectCapabilityDescriptor = ( + item: InputCapabilityDescriptor, + ) => { + switch (item.kind) { + case "builtin_command": + handleSelectBuiltinCommand(item.command, { + replayText: item.replayText, + }); + return; + case "service_skill": + handleSelectServiceSkill(item.skill); + return; + case "slash_command": + handleSelectSlashCommand(item.command, { + replayText: item.replayText, + }); + return; + case "scene_command": + void handleSelectRuntimeSceneCommand(item.command, { + replayText: item.replayText, + }); + return; + case "curated_task": + handleSelectCuratedTask(item.task, { + launchInputValues: item.launchInputValues, + referenceMemoryIds: item.referenceMemoryIds, + referenceEntries: item.referenceEntries, + launcherPrefillHint: item.launcherPrefillHint, + }); + return; + case "character": + handleSelectCharacter(item.character); + return; + case "installed_skill": + handleSelectInstalledSkill(item.skill, { + replayText: item.replayText, + }); + return; + case "available_skill": + handleSelectAvailableSkill(item.skill); + return; + default: + return; + } + }; + const handleCuratedTaskLauncherOpenChange = useCallback( (open: boolean) => { if (open) { @@ -1024,7 +1044,7 @@ export function CharacterMention({ options.referenceSelection.referenceEntries, ), launcherPrefillHint: - "已按最近复盘切到更适合的结果模板,你可以继续改后再发。", + "已按最近判断切到更适合的结果模板,你可以继续改后再发。", }; }); }, @@ -1199,14 +1219,7 @@ export function CharacterMention({ slashEntryUsageVersion={slashEntryUsageVersion} commandRef={commandRef} onQueryChange={setMentionQuery} - onSelectBuiltinCommand={handleSelectBuiltinCommand} - onSelectServiceSkill={handleSelectServiceSkill} - onSelectSlashCommand={handleSelectSlashCommand} - onSelectSceneCommand={handleSelectRuntimeSceneCommand} - onSelectCuratedTask={handleSelectCuratedTask} - onSelectCharacter={handleSelectCharacter} - onSelectInstalledSkill={handleSelectInstalledSkill} - onSelectAvailableSkill={handleSelectAvailableSkill} + onSelectCapability={handleSelectCapabilityDescriptor} onNavigateToSettings={ onNavigateToSettings ? () => { diff --git a/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx b/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx index 0eb6ac551..eeadf8a5f 100644 --- a/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx +++ b/src/components/agent/chat/skill-selection/CharacterMentionPanel.tsx @@ -25,10 +25,6 @@ import type { BuiltinInputCommand, RuntimeSceneSlashCommand, } from "./builtinCommands"; -import type { - CuratedTaskInputValues, - CuratedTaskTemplateItem, -} from "../utils/curatedTaskTemplates"; import { buildInputCapabilitySections, type InputCapabilityDescriptor, @@ -55,34 +51,7 @@ interface CharacterMentionPanelProps { slashEntryUsageVersion?: number; commandRef: React.RefObject; onQueryChange: (query: string) => void; - onSelectBuiltinCommand: ( - command: BuiltinInputCommand, - options?: { replayText?: string }, - ) => void; - onSelectServiceSkill: (skill: ServiceSkillHomeItem) => void; - onSelectSlashCommand: ( - command: CodexSlashCommandDefinition, - options?: { replayText?: string }, - ) => void; - onSelectSceneCommand: ( - command: RuntimeSceneSlashCommand, - options?: { replayText?: string }, - ) => void; - onSelectCuratedTask?: ( - task: CuratedTaskTemplateItem, - options?: { - launchInputValues?: CuratedTaskInputValues; - referenceMemoryIds?: string[]; - referenceEntries?: CuratedTaskReferenceEntry[]; - launcherPrefillHint?: string; - }, - ) => void; - onSelectCharacter: (character: Character) => void; - onSelectInstalledSkill: ( - skill: Skill, - options?: { replayText?: string }, - ) => void; - onSelectAvailableSkill: (skill: Skill) => void; + onSelectCapability: (item: InputCapabilityDescriptor) => void; onNavigateToSettings?: () => void; } @@ -106,14 +75,7 @@ export const CharacterMentionPanel: React.FC = ({ slashEntryUsageVersion = 0, commandRef, onQueryChange, - onSelectBuiltinCommand, - onSelectServiceSkill, - onSelectSlashCommand, - onSelectSceneCommand, - onSelectCuratedTask, - onSelectCharacter, - onSelectInstalledSkill, - onSelectAvailableSkill, + onSelectCapability, onNavigateToSettings, }) => { const sections = React.useMemo( @@ -160,17 +122,38 @@ export const CharacterMentionPanel: React.FC = ({ ); const hasFilteredResults = sections.some((section) => section.items.length > 0); const isEmptySlashQuery = mode === "slash" && mentionQuery.trim().length === 0; + const isEmptyMentionQuery = mode === "mention" && mentionQuery.trim().length === 0; + const isRegistryLanding = isEmptySlashQuery || isEmptyMentionQuery; const resolveSectionTone = (sectionKey: string) => { if (sectionKey === "result-templates") { return "primary" as const; } + if (sectionKey === "recent-mention") { + return "continuation" as const; + } + if (sectionKey === "recent-slash-continuations") { return "continuation" as const; } - if (sectionKey === "installed-skills") { + if (sectionKey.startsWith("builtin-commands:")) { + return "primary" as const; + } + + if ( + sectionKey === "featured-service-skills" || + sectionKey.startsWith("service-skill-group:") || + sectionKey === "characters" + ) { + return "supporting" as const; + } + + if ( + sectionKey === "installed-skills" || + sectionKey === "available-skills" + ) { return "methods" as const; } @@ -185,6 +168,28 @@ export const CharacterMentionPanel: React.FC = ({ }; const resolveSectionHelperText = (sectionKey: string): string | null => { + if (isEmptyMentionQuery) { + if (sectionKey === "recent-mention") { + return "优先继续刚调过的命令或做法,仍然回到当前生成线程。"; + } + + if (sectionKey === "featured-service-skills") { + return "需要一套现成起手时,再从这里进入,不替代上面的 @命令。"; + } + + if (sectionKey === "installed-skills") { + return "自己的固定方法也能通过 @ 直接接回生成,但优先级仍在命令之后。"; + } + + if (sectionKey === "available-skills") { + return "还没沉淀成固定入口的做法放在这里,优先级低于上面的命令和已沉淀方法。"; + } + + if (sectionKey === "characters") { + return "需要点名协作对象时再用,不会替代上面的命令入口。"; + } + } + if (!isEmptySlashQuery) { return null; } @@ -212,45 +217,37 @@ export const CharacterMentionPanel: React.FC = ({ isEmptySlashQuery && sectionKey === "supported-slash-commands:workspace-action"; const shouldSubdueMethodItems = (sectionKey: string): boolean => - isEmptySlashQuery && sectionKey === "installed-skills"; + (isEmptySlashQuery && sectionKey === "installed-skills") || + (isEmptyMentionQuery && + ([ + "featured-service-skills", + "installed-skills", + "available-skills", + "characters", + ].includes(sectionKey) || + sectionKey.startsWith("service-skill-group:"))); const shouldHighlightContinuationItems = (sectionKey: string): boolean => - isEmptySlashQuery && sectionKey === "recent-slash-continuations"; + (isEmptySlashQuery && sectionKey === "recent-slash-continuations") || + (isEmptyMentionQuery && sectionKey === "recent-mention"); + + const resolveVisibleKindLabel = ( + item: InputCapabilityDescriptor, + ): string | null => { + if (item.kind !== "slash_command") { + return null; + } + + const normalizedKindLabel = item.kindLabel?.trim(); + if (!normalizedKindLabel) { + return null; + } + + return normalizedKindLabel === item.title.trim() ? null : normalizedKindLabel; + }; const handleSelectCapability = (item: InputCapabilityDescriptor) => { - switch (item.kind) { - case "builtin_command": - onSelectBuiltinCommand(item.command, { replayText: item.replayText }); - return; - case "service_skill": - onSelectServiceSkill(item.skill); - return; - case "slash_command": - onSelectSlashCommand(item.command, { replayText: item.replayText }); - return; - case "scene_command": - onSelectSceneCommand(item.command, { replayText: item.replayText }); - return; - case "curated_task": - onSelectCuratedTask?.(item.task, { - launchInputValues: item.launchInputValues, - referenceMemoryIds: item.referenceMemoryIds, - referenceEntries: item.referenceEntries, - launcherPrefillHint: item.launcherPrefillHint, - }); - return; - case "character": - onSelectCharacter(item.character); - return; - case "installed_skill": - onSelectInstalledSkill(item.skill, { replayText: item.replayText }); - return; - case "available_skill": - onSelectAvailableSkill(item.skill); - return; - default: - return; - } + onSelectCapability(item); }; const renderItemIcon = ( @@ -284,7 +281,7 @@ export const CharacterMentionPanel: React.FC = ({ placeholder={ mode === "slash" ? "搜索结果模板、做法或操作..." - : "搜索角色或技能..." + : "搜索 @命令、做法或协作角色..." } value={mentionQuery} onValueChange={onQueryChange} @@ -295,7 +292,7 @@ export const CharacterMentionPanel: React.FC = ({
{mode === "slash" ? "暂无可用结果模板、做法或操作" - : "暂无可用角色或技能"} + : "暂无可用 @命令、做法或协作角色"}
{onNavigateToSettings ? ( ) : null} {onSaveAsInspiration ? ( @@ -752,7 +752,7 @@ export function SceneAppExecutionSummaryCard({ data-testid="sceneapp-execution-summary-open-detail" onClick={onOpenSceneAppDetail} > - 回生成准备 + 回补这轮信息 ) : null} {onOpenSceneAppGovernance ? ( @@ -762,7 +762,7 @@ export function SceneAppExecutionSummaryCard({ data-testid="sceneapp-execution-summary-open-governance" onClick={onOpenSceneAppGovernance} > - 去做法复盘 + 看最近结果 ) : null} {humanReviewAvailable ? ( @@ -800,7 +800,7 @@ export function SceneAppExecutionSummaryCard({ ) : null} {latestPackResultUsesFallback ? (

- 当前主运行还在继续,做法复盘会优先定位到最近一轮已交付样本,方便直接延续结果消费与放量判断。 + 当前主运行还在继续,最近结果会优先定位到最近一轮已交付样本,方便直接延续结果消费与下一步判断。

) : null} {humanReviewAvailable && quickReviewActions.length ? ( @@ -836,10 +836,10 @@ export function SceneAppExecutionSummaryCard({ data-testid="sceneapp-execution-summary-orchestration" >
- 生成后动作编排 + 结果后的下一步

- 同一轮结果现在可以直接进入复盘、复盘材料准备或底层运行入口恢复,不再只是回页面找下一步。 + 同一轮结果现在可以直接继续看结果、补结果材料或恢复到对应入口,不再只是回页面找下一步。

{followupDestinations.length ? ( @@ -984,7 +984,7 @@ export function SceneAppExecutionSummaryCard({ 最近发布产物

- 当前会话里刚整理出来的发布稿、渠道预览稿和上传稿,会直接回流到这里继续复核,不需要离开生成主执行面。 + 当前会话里刚整理出来的发布稿、渠道预览稿和上传稿,会直接回流到这里继续复核,不需要离开生成。

{contentPostEntries.map((entry) => ( @@ -1037,7 +1037,7 @@ export function SceneAppExecutionSummaryCard({ {latestPackResultDetailView.governanceArtifactEntries.length ? (
- 基础复盘材料 + 基础结果材料
{latestPackResultDetailView.governanceArtifactEntries.map( diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx index 09f103df3..0db6b783f 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.test.tsx @@ -4,6 +4,10 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WorkspaceConversationScene } from "./WorkspaceConversationScene"; +const { mockWorkspaceMainArea } = vi.hoisted(() => ({ + mockWorkspaceMainArea: vi.fn(), +})); + vi.mock("../components/CanvasWorkbenchLayout", () => ({ CanvasWorkbenchLayout: () =>
, })); @@ -29,15 +33,31 @@ vi.mock("../components/TeamWorkspaceDock", () => ({ vi.mock("./WorkspaceMainArea", () => ({ WorkspaceMainArea: ({ navbarNode, + taskCenterTabsNode, chatContent, canvasContent, + ...rest }: { navbarNode?: React.ReactNode; + taskCenterTabsNode?: React.ReactNode; chatContent?: React.ReactNode; canvasContent?: React.ReactNode; + [key: string]: unknown; }) => ( -
+
{ + mockWorkspaceMainArea({ + navbarNode, + taskCenterTabsNode, + chatContent, + canvasContent, + ...rest, + }); + }} + > {navbarNode} + {taskCenterTabsNode} {chatContent} {canvasContent}
@@ -205,10 +225,11 @@ afterEach(() => { }); mounted.container.remove(); } + vi.clearAllMocks(); }); describe("WorkspaceConversationScene", () => { - it("生成主执行面应显示当前带入的灵感横条", () => { + it("生成应显示当前带入的灵感横条", () => { const container = renderScene({ creationReplaySurface: { kind: "memory_entry", @@ -235,4 +256,18 @@ describe("WorkspaceConversationScene", () => { expect(container.textContent).toContain("品牌风格样本"); expect(container.textContent).toContain("后续结果模板会默认把它一起带入。"); }); + + it("任务中心场景应固定展示顶部导航,不再传入自动隐藏开关", () => { + renderScene({ + navbarVisible: true, + navbarChrome: "workspace-compact", + navbarContextVariant: "task-center", + taskCenterTabsNode:
tabs
, + }); + + expect(mockWorkspaceMainArea).toHaveBeenCalled(); + expect( + mockWorkspaceMainArea.mock.calls.at(-1)?.[0]?.autoHideTaskCenterNavbar, + ).toBeUndefined(); + }); }); diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx index 0b33b5dbd..aaec64672 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -362,6 +362,7 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps { onOpenChannels?: ComponentProps["onOpenChannels"]; onOpenChromeRelay?: ComponentProps["onOpenChromeRelay"]; onOpenOpenClaw?: ComponentProps["onOpenOpenClaw"]; + taskCenterTabsNode?: ReactNode; navbarVisible: boolean; isRunning: boolean; navbarChrome: ComponentProps["chrome"]; @@ -489,6 +490,7 @@ export function WorkspaceConversationScene({ onOpenChannels, onOpenChromeRelay, onOpenOpenClaw, + taskCenterTabsNode, navbarVisible, isRunning, navbarChrome, @@ -618,7 +620,6 @@ export function WorkspaceConversationScene({ !contextWorkspaceEnabled && !shouldHideGeneralWorkbenchInputForTheme, inputbarNode, }); - const chatNavbarProps = buildWorkspaceNavbarProps({ visible: navbarVisible, isRunning, @@ -693,6 +694,7 @@ export function WorkspaceConversationScene({ { )?.dataset.effectiveMode, ).toBe("chat"); }); + + it("任务中心自动隐藏顶栏时应通过小图标展开完整顶部工具", () => { + const { container } = mountHarness( + WorkspaceMainAreaHarness, + { + autoHideTaskCenterNavbar: true, + navbarNode:
navbar
, + taskCenterTabsNode:
tabs
, + }, + mountedRoots, + ); + + const shell = container.querySelector( + '[data-testid="workspace-navbar-auto-hide-shell"]', + ); + const panel = container.querySelector( + '[data-testid="workspace-navbar-auto-hide-panel"]', + ); + const backdrop = container.querySelector( + '[data-testid="workspace-navbar-backdrop"]', + ); + expect(shell?.dataset.visible).toBe("false"); + expect(panel?.dataset.visible).toBe("false"); + expect(backdrop?.dataset.visible).toBe("false"); + + const currentHandle = container.querySelector( + '[data-testid="workspace-navbar-reveal-handle"]', + ); + + act(() => { + currentHandle?.click(); + }); + + expect(shell?.dataset.visible).toBe("true"); + expect(panel?.dataset.visible).toBe("true"); + expect(backdrop?.dataset.visible).toBe("true"); + + act(() => { + backdrop?.click(); + }); + + expect(shell?.dataset.visible).toBe("false"); + expect(panel?.dataset.visible).toBe("false"); + expect(backdrop?.dataset.visible).toBe("false"); + }); }); diff --git a/src/components/agent/chat/workspace/WorkspaceMainArea.tsx b/src/components/agent/chat/workspace/WorkspaceMainArea.tsx index 603d39c87..ad98f1794 100644 --- a/src/components/agent/chat/workspace/WorkspaceMainArea.tsx +++ b/src/components/agent/chat/workspace/WorkspaceMainArea.tsx @@ -1,6 +1,11 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; import type { LayoutMode } from "@/lib/workspace/workbenchContract"; import { + AutoHideNavbarBackdrop, + AutoHideNavbarHandle, + AutoHideNavbarHost, + AutoHideNavbarPanel, LayoutTransitionRenderGate, MainArea, GeneralWorkbenchInputOverlay, @@ -10,6 +15,8 @@ import { interface WorkspaceMainAreaProps { compactChrome: boolean; navbarNode: ReactNode; + autoHideTaskCenterNavbar?: boolean; + taskCenterTabsNode?: ReactNode; contentSyncNoticeNode: ReactNode; shellBottomInset: string; layoutMode: LayoutMode; @@ -28,6 +35,8 @@ interface WorkspaceMainAreaProps { export function WorkspaceMainArea({ compactChrome, navbarNode, + autoHideTaskCenterNavbar = false, + taskCenterTabsNode, contentSyncNoticeNode, shellBottomInset, layoutMode, @@ -42,15 +51,63 @@ export function WorkspaceMainArea({ hasPendingA2UIForm, inputbarNode, }: WorkspaceMainAreaProps) { + const [navbarOpen, setNavbarOpen] = useState(false); const effectiveLayoutMode = hasPendingA2UIForm ? "chat" : forceCanvasMode ? "canvas" : layoutMode; + const shouldAutoHideNavbar = + autoHideTaskCenterNavbar && Boolean(navbarNode) && Boolean(taskCenterTabsNode); + const isAutoHideNavbarVisible = shouldAutoHideNavbar && navbarOpen; + const shouldRenderRevealHandle = + shouldAutoHideNavbar && !isAutoHideNavbarVisible; return ( - {navbarNode} + {shouldAutoHideNavbar ? ( + { + setNavbarOpen(false); + }} + /> + ) : null} + {shouldAutoHideNavbar ? ( + + {shouldRenderRevealHandle ? ( + { + setNavbarOpen(true); + }} + > + + ) : null} + + {navbarNode} + + + ) : ( + navbarNode + )} + {taskCenterTabsNode} {contentSyncNoticeNode} ["currentTopicId"]; topics: ComponentProps["topics"]; onNewChat: ComponentProps["onNewChat"]; + onOpenTaskCenterHome?: ComponentProps["onOpenTaskCenterHome"]; + onOpenSkillsPage?: ComponentProps["onOpenSkillsPage"]; + onOpenMemoryPage?: ComponentProps["onOpenMemoryPage"]; onSwitchTopic: ComponentProps["onSwitchTopic"]; onResumeTask: ComponentProps["onResumeTask"]; onDeleteTopic: ComponentProps["onDeleteTopic"]; @@ -57,6 +60,9 @@ export function WorkspaceShellScene({ currentTopicId, topics, onNewChat, + onOpenTaskCenterHome, + onOpenSkillsPage, + onOpenMemoryPage, onSwitchTopic, onResumeTask, onDeleteTopic, @@ -72,11 +78,19 @@ export function WorkspaceShellScene({ onOpenSubagentSession, onReturnToParentSession, }: WorkspaceShellSceneProps) { + const shouldRenderChatSidebar = + !isThemeWorkbench && + showChatPanel && + showSidebar && + sidebarContextVariant !== "task-center"; const chatSidebarProps = - !isThemeWorkbench && showChatPanel && showSidebar + shouldRenderChatSidebar ? buildWorkspaceChatSidebarProps({ contextVariant: sidebarContextVariant, onNewChat, + onOpenTaskCenterHome, + onOpenSkillsPage, + onOpenMemoryPage, topics, currentTopicId, onSwitchTopic, @@ -100,7 +114,7 @@ export function WorkspaceShellScene({ {isThemeWorkbench ? ( generalWorkbenchSidebarNode - ) : showChatPanel && showSidebar && chatSidebarProps ? ( + ) : shouldRenderChatSidebar && chatSidebarProps ? ( ) : null} {showGeneralWorkbenchLeftExpandButton ? ( diff --git a/src/components/agent/chat/workspace/WorkspaceStyles.tsx b/src/components/agent/chat/workspace/WorkspaceStyles.tsx index 0c94f3253..1d58f3ed9 100644 --- a/src/components/agent/chat/workspace/WorkspaceStyles.tsx +++ b/src/components/agent/chat/workspace/WorkspaceStyles.tsx @@ -47,6 +47,85 @@ export const MainArea = styled.div<{ $compact?: boolean }>` box-shadow: none; `; +export const AutoHideNavbarHost = styled.div` + position: absolute; + top: 2px; + left: 12px; + right: 12px; + z-index: 35; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + pointer-events: none; +`; + +export const AutoHideNavbarBackdrop = styled.button<{ $visible: boolean }>` + position: absolute; + inset: 0; + z-index: 32; + border: none; + padding: 0; + margin: 0; + background: linear-gradient( + 180deg, + rgba(248, 250, 252, 0.34) 0%, + rgba(248, 250, 252, 0.52) 26%, + rgba(248, 250, 252, 0.6) 100% + ); + backdrop-filter: blur(18px) saturate(1.04); + opacity: ${({ $visible }) => ($visible ? 1 : 0)}; + pointer-events: ${({ $visible }) => ($visible ? "auto" : "none")}; + transition: opacity 0.18s ease; +`; + +export const AutoHideNavbarHandle = styled.button<{ $visible: boolean }>` + display: inline-flex; + align-items: center; + align-self: flex-end; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid rgba(226, 232, 240, 0.95); + border-radius: 999px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.98) 0%, + rgba(248, 250, 252, 0.94) 100% + ); + color: ${({ $visible }) => ($visible ? "#0f172a" : "#64748b")}; + box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.22); + pointer-events: auto; + transition: + color 0.16s ease, + border-color 0.16s ease, + box-shadow 0.16s ease, + transform 0.16s ease; + + &:hover { + color: #0f172a; + border-color: rgba(148, 163, 184, 0.9); + transform: translateY(-1px); + } +`; + +export const AutoHideNavbarPanel = styled.div<{ $visible: boolean }>` + width: 100%; + max-width: 100%; + overflow: hidden; + max-height: ${({ $visible }) => ($visible ? "180px" : "0")}; + opacity: ${({ $visible }) => ($visible ? 1 : 0)}; + transform: ${({ $visible }) => + $visible ? "translateY(0)" : "translateY(-10px) scale(0.98)"}; + transform-origin: top right; + pointer-events: ${({ $visible }) => ($visible ? "auto" : "none")}; + transition: + max-height 0.18s ease, + opacity 0.18s ease, + transform 0.18s ease; +`; + function resolveContentSyncTone(status: SyncStatus): { text: string; background: string; diff --git a/src/components/agent/chat/workspace/chatSurfaceProps.test.ts b/src/components/agent/chat/workspace/chatSurfaceProps.test.ts new file mode 100644 index 000000000..a2a9f3689 --- /dev/null +++ b/src/components/agent/chat/workspace/chatSurfaceProps.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildWorkspaceNavbarProps } from "./chatSurfaceProps"; + +function createNavbarParams( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + visible: true, + isRunning: false, + chrome: "workspace-compact", + navbarContextVariant: "task-center", + onToggleHistory: vi.fn(), + showHistoryToggle: true, + showCanvasToggle: false, + isCanvasOpen: false, + projectId: null, + showHarnessToggle: false, + harnessPanelVisible: false, + harnessPendingCount: 0, + harnessAttentionLevel: "idle", + ...overrides, + }; +} + +describe("buildWorkspaceNavbarProps", () => { + it("任务中心空态应切到专用 task-center 顶栏", () => { + const props = buildWorkspaceNavbarProps(createNavbarParams()); + + expect(props?.contextVariant).toBe("task-center"); + expect(props?.entryContextLabel).toBeUndefined(); + expect(props?.entryContextHint).toBeUndefined(); + }); + + it("任务中心显式收起入口上下文时应回退默认顶栏语义", () => { + const props = buildWorkspaceNavbarProps( + createNavbarParams({ + collapseChrome: true, + collapseEntryContext: true, + }), + ); + + expect(props?.collapseChrome).toBe(true); + expect(props?.contextVariant).toBe("default"); + expect(props?.entryContextLabel).toBeUndefined(); + expect(props?.entryContextHint).toBeUndefined(); + }); +}); diff --git a/src/components/agent/chat/workspace/chatSurfaceProps.ts b/src/components/agent/chat/workspace/chatSurfaceProps.ts index d7006f003..cbda7959a 100644 --- a/src/components/agent/chat/workspace/chatSurfaceProps.ts +++ b/src/components/agent/chat/workspace/chatSurfaceProps.ts @@ -335,7 +335,9 @@ interface BuildWorkspaceNavbarPropsParams { visible: boolean; isRunning: boolean; chrome: ComponentProps["chrome"]; + collapseChrome?: boolean; navbarContextVariant?: "default" | "task-center"; + collapseEntryContext?: boolean; onToggleHistory: NonNullable< ComponentProps["onToggleHistory"] >; @@ -375,7 +377,9 @@ export function buildWorkspaceNavbarProps({ visible, isRunning, chrome, + collapseChrome = false, navbarContextVariant = "default", + collapseEntryContext = false, onToggleHistory, showHistoryToggle, onBackToProjectManagement, @@ -402,18 +406,14 @@ export function buildWorkspaceNavbarProps({ return null; } - const entryContextCopy = - navbarContextVariant === "task-center" - ? { - entryContextLabel: "生成", - entryContextHint: "在这里继续推进当前创作、回看最近结果和旧历史。", - } - : {}; - return { isRunning, chrome, - ...entryContextCopy, + collapseChrome, + contextVariant: + navbarContextVariant === "task-center" && !collapseEntryContext + ? "task-center" + : "default", onToggleHistory, showHistoryToggle, onToggleFullscreen: () => undefined, diff --git a/src/components/agent/chat/workspace/imageSkillLaunch.ts b/src/components/agent/chat/workspace/imageSkillLaunch.ts index 1dd1086e4..c9277128b 100644 --- a/src/components/agent/chat/workspace/imageSkillLaunch.ts +++ b/src/components/agent/chat/workspace/imageSkillLaunch.ts @@ -1,4 +1,5 @@ import { toast } from "sonner"; +import type { AgentRuntimeGeneratedTitleResult } from "@/lib/api/agentRuntime"; import type { MessageImage } from "../types"; import type { ParsedImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; import { readMessageImageFromDataUrl } from "../utils/imageAttachments"; @@ -18,6 +19,7 @@ interface ResolveImageWorkbenchSkillRequestParams { parsedCommand: ParsedImageWorkbenchCommand; images: MessageImage[]; title?: string; + titleGenerationResult?: AgentRuntimeGeneratedTitleResult | null; currentImageWorkbenchState: SessionImageWorkbenchState; imageWorkbenchSelectedModelId?: string; imageWorkbenchSelectedProviderId?: string; @@ -225,6 +227,7 @@ export function resolveImageWorkbenchSkillRequest( kind: "image_task", image_task: { title: collapseWhitespace(params.title) || undefined, + title_generation_result: params.titleGenerationResult ?? undefined, mode: parsedCommand.mode, prompt: effectivePrompt, raw_text: rawText, diff --git a/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.test.tsx index 626673e67..9f3ac779d 100644 --- a/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.test.tsx @@ -107,6 +107,12 @@ beforeEach(() => { ).IS_REACT_ACT_ENVIRONMENT = true; }); +async function flushAsyncWork(times = 4) { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } +} + afterEach(() => { while (mountedRoots.length > 0) { const mounted = mountedRoots.pop(); @@ -334,4 +340,160 @@ describe("useWorkspaceArtifactPreviewActions", () => { error: null, }); }); + + it("点击占位任务文件时应按需读取会话内容并打开通用画布", async () => { + const readSessionFile = vi.fn(async () => "# 会话主稿\n\n按需恢复"); + const setTaskFiles = vi.fn(); + const setGeneralCanvasState = vi.fn(); + const setSelectedArtifactId = vi.fn(); + const setLayoutMode = vi.fn(); + const suppressBrowserAssistCanvasAutoOpen = vi.fn(); + const placeholderFile = { + id: "session-file:content-posts/draft.md", + name: "content-posts/draft.md", + type: "document" as const, + version: 1, + createdAt: 100, + updatedAt: 100, + }; + const { render, getValue } = renderHook({ + taskFiles: [placeholderFile], + sessionFiles: [ + { + name: "content-posts/draft.md", + fileType: "document", + metadata: { + contentPostIntent: "draft", + }, + size: 20, + createdAt: 100, + updatedAt: 200, + }, + ], + readSessionFile, + setTaskFiles, + setGeneralCanvasState, + setSelectedArtifactId, + setLayoutMode, + suppressBrowserAssistCanvasAutoOpen, + }); + + await render(); + + await act(async () => { + getValue().handleTaskFileClick(placeholderFile); + await flushAsyncWork(); + }); + + expect(readSessionFile).toHaveBeenCalledWith("content-posts/draft.md"); + expect(setTaskFiles).toHaveBeenCalledTimes(1); + + const taskFilesUpdater = setTaskFiles.mock.calls[0]?.[0]; + expect(typeof taskFilesUpdater).toBe("function"); + expect(taskFilesUpdater([placeholderFile])).toEqual([ + expect.objectContaining({ + id: "session-file:content-posts/draft.md", + name: "content-posts/draft.md", + type: "document", + content: "# 会话主稿\n\n按需恢复", + metadata: expect.objectContaining({ + contentPostIntent: "draft", + }), + createdAt: 100, + updatedAt: 200, + }), + ]); + expect(suppressBrowserAssistCanvasAutoOpen).toHaveBeenCalledTimes(1); + expect(setSelectedArtifactId).toHaveBeenCalledWith(null); + expect(setLayoutMode).toHaveBeenCalledWith("chat-canvas"); + expect(setGeneralCanvasState).toHaveBeenCalledWith( + expect.objectContaining({ + isOpen: true, + filename: "content-posts/draft.md", + content: "# 会话主稿\n\n按需恢复", + contentType: "markdown", + }), + ); + }); + + it("点击占位任务文件时应按需读取会话内容并更新主题工作台画布", async () => { + const readSessionFile = vi.fn(async () => "# 主题工作台内容"); + const setTaskFiles = vi.fn(); + const setSelectedFileId = vi.fn(); + const setCanvasState = vi.fn(); + const setLayoutMode = vi.fn(); + const placeholderFile = { + id: "session-file:result.md", + name: "result.md", + type: "document" as const, + version: 1, + createdAt: 10, + updatedAt: 10, + }; + const { render, getValue } = renderHook({ + activeTheme: "article", + mappedTheme: "general", + layoutMode: "chat", + isThemeWorkbench: true, + taskFiles: [placeholderFile], + sessionFiles: [ + { + name: "result.md", + fileType: "document", + size: 20, + createdAt: 10, + updatedAt: 30, + }, + ], + readSessionFile, + setTaskFiles, + setSelectedFileId, + setCanvasState, + setLayoutMode, + }); + + await render(); + + await act(async () => { + getValue().handleTaskFileClick(placeholderFile); + await flushAsyncWork(); + }); + + expect(readSessionFile).toHaveBeenCalledWith("result.md"); + expect(setSelectedFileId).toHaveBeenCalledWith( + "session-file:result.md", + ); + expect(setLayoutMode).toHaveBeenCalledWith("chat-canvas"); + + const taskFilesUpdater = setTaskFiles.mock.calls[0]?.[0]; + expect(typeof taskFilesUpdater).toBe("function"); + expect(taskFilesUpdater([placeholderFile])).toEqual([ + expect.objectContaining({ + id: "session-file:result.md", + name: "result.md", + content: "# 主题工作台内容", + updatedAt: 30, + }), + ]); + + const canvasStateUpdater = setCanvasState.mock.calls[0]?.[0]; + expect(typeof canvasStateUpdater).toBe("function"); + expect( + canvasStateUpdater({ + type: "document", + content: "旧内容", + platform: "markdown", + versions: [], + currentVersionId: "version-1", + isEditing: true, + }), + ).toEqual( + expect.objectContaining({ + type: "document", + content: "# 主题工作台内容", + platform: "markdown", + currentVersionId: "version-1", + }), + ); + }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts b/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts index 82746f084..d05d46a83 100644 --- a/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceArtifactPreviewActions.ts @@ -23,6 +23,7 @@ import type { ApplyArtifactViewMode } from "./useWorkspaceArtifactViewModeContro import { isRenderableTaskFile, looksLikeSocialPublishPayload, + normalizeSessionTaskFileType, resolveTaskFileType, } from "./generalWorkbenchHelpers"; import { doesWorkspaceFileCandidateMatch } from "./workspaceFilePathMatch"; @@ -338,6 +339,62 @@ export function useWorkspaceArtifactPreviewActions({ ], ); + const hydrateTaskFileContent = useCallback( + async (file: TaskFile): Promise => { + if (typeof file.content === "string") { + return file; + } + + const matchedSessionFile = sessionFiles.find((candidate) => + doesWorkspaceFileCandidateMatch(candidate.name, file.name), + ); + if (!matchedSessionFile) { + return file; + } + + const content = await readSessionFile(matchedSessionFile.name); + if (content === null) { + return null; + } + + const hydratedFile: TaskFile = { + ...file, + name: matchedSessionFile.name, + type: normalizeSessionTaskFileType( + matchedSessionFile.fileType ?? file.type, + matchedSessionFile.name, + content, + ), + content, + metadata: file.metadata ?? matchedSessionFile.metadata, + createdAt: file.createdAt || matchedSessionFile.createdAt || Date.now(), + updatedAt: + matchedSessionFile.updatedAt || + file.updatedAt || + matchedSessionFile.createdAt || + Date.now(), + }; + + setTaskFiles((previous) => { + const matchedIndex = previous.findIndex( + (item) => + item.id === file.id || + doesWorkspaceFileCandidateMatch(item.name, matchedSessionFile.name), + ); + if (matchedIndex < 0) { + return [...previous, hydratedFile]; + } + + const next = [...previous]; + next[matchedIndex] = hydratedFile; + return next; + }); + + return hydratedFile; + }, + [readSessionFile, sessionFiles, setTaskFiles], + ); + const handleCodeBlockClick = useCallback( (language: string, code: string) => { console.log("[AgentChatPage] 代码块点击:", language); @@ -389,40 +446,49 @@ export function useWorkspaceArtifactPreviewActions({ const handleTaskFileClick = useCallback( (file: TaskFile) => { - if (activeTheme === "general") { - if (!file.content?.trim()) { + void (async () => { + const resolvedFile = await hydrateTaskFileContent(file); + if (!resolvedFile) { + toast.error("读取会话文件失败,请稍后重试"); + return; + } + + if (activeTheme === "general") { + if (!resolvedFile.content?.trim()) { + toast.info("该文件为辅助产物,暂不在主稿画布渲染"); + return; + } + + suppressBrowserAssistCanvasAutoOpen(); + setSelectedArtifactId(null); + setGeneralCanvasState( + buildGeneralCanvasStateFromWorkspaceFile( + resolvedFile.name, + resolvedFile.content ?? "", + ), + ); + openCanvasForReason("user_open_file", setLayoutMode); + return; + } + + setSelectedFileId(resolvedFile.id); + + if ( + !isRenderableTaskFile(resolvedFile, isThemeWorkbench) || + looksLikeSocialPublishPayload(resolvedFile.content || "") || + !resolvedFile.content?.trim() + ) { toast.info("该文件为辅助产物,暂不在主稿画布渲染"); return; } - suppressBrowserAssistCanvasAutoOpen(); - setSelectedArtifactId(null); - setGeneralCanvasState( - buildGeneralCanvasStateFromWorkspaceFile( - file.name, - file.content ?? "", - ), - ); - openCanvasForReason("user_open_file", setLayoutMode); - return; - } - - setSelectedFileId(file.id); - - if ( - !isRenderableTaskFile(file, isThemeWorkbench) || - looksLikeSocialPublishPayload(file.content || "") || - !file.content?.trim() - ) { - toast.info("该文件为辅助产物,暂不在主稿画布渲染"); - return; - } - - applyContentToCanvas(file.content ?? ""); + applyContentToCanvas(resolvedFile.content ?? ""); + })(); }, [ activeTheme, applyContentToCanvas, + hydrateTaskFileContent, isThemeWorkbench, setGeneralCanvasState, setLayoutMode, diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.test.ts b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.test.ts index 502977dd2..125066052 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.test.ts +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.test.ts @@ -119,6 +119,6 @@ describe("buildCanvasTeamWorkbenchView", () => { value: "2 项", detail: "等待结果超时,建议重新检查任务状态。", }); - expect(view.panelCopy?.emptyText).toBe("当前没有可展示的生成工作台。"); + expect(view.panelCopy?.emptyText).toBe("当前没有可展示的生成结果。"); }); }); diff --git a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx index 9af070441..3055245d4 100644 --- a/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceCanvasSceneRuntime.tsx @@ -403,7 +403,7 @@ export function buildCanvasTeamWorkbenchView({ const headerBadges: CanvasWorkbenchHeaderBadge[] = [ { key: "team-runtime", - label: "生成工作台", + label: "生成", tone: "accent", }, ]; @@ -482,7 +482,7 @@ export function buildCanvasTeamWorkbenchView({ dispatchPreviewState?.label?.trim() || dispatchPreviewState?.blueprint?.label?.trim() || surfaceProps.selectedTeamLabel || - "团队工作台", + "生成", tabLabel: surfaceProps.selectedTeamLabel?.trim() || undefined, tabBadge: triggerState?.label?.trim() || undefined, tabBadgeTone: @@ -498,7 +498,7 @@ export function buildCanvasTeamWorkbenchView({ badges: headerBadges, summaryStats, panelCopy: { - emptyText: "当前没有可展示的生成工作台。", + emptyText: "当前没有可展示的生成结果。", }, renderPreview: (options?: { stackedWorkbenchTrigger?: ReactNode }) => renderTeamWorkbenchPreview(options?.stackedWorkbenchTrigger), diff --git a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx index c142712ad..4b2ad0ce5 100644 --- a/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx @@ -284,6 +284,7 @@ interface UseWorkspaceConversationSceneRuntimeParams { handleResumeRecentSession?: ConversationScenePresentationParams["scene"]["onResumeRecentSession"]; handleOpenSceneAppsDirectory?: ConversationScenePresentationParams["scene"]["onOpenSceneAppsDirectory"]; projectId: string | null; + taskCenterTabsNode?: ConversationScenePresentationParams["scene"]["taskCenterTabsNode"]; hideHistoryToggle: boolean; showChatPanel: boolean; topBarChrome: ConversationScenePresentationParams["scene"]["navbarChrome"]; @@ -431,6 +432,7 @@ export function useWorkspaceConversationSceneRuntime({ handleResumeRecentSession, handleOpenSceneAppsDirectory, projectId, + taskCenterTabsNode, hideHistoryToggle, showChatPanel, topBarChrome, @@ -826,10 +828,22 @@ export function useWorkspaceConversationSceneRuntime({ onLaunchSceneApp: handleLaunchSceneApp, canResumeRecentSceneApp, onResumeRecentSceneApp: handleResumeRecentSceneApp, - recentSessionTitle, - recentSessionSummary, - recentSessionActionLabel, - onResumeRecentSession: handleResumeRecentSession, + recentSessionTitle: + messageListEmptyStateVariant === "task-center" + ? undefined + : recentSessionTitle, + recentSessionSummary: + messageListEmptyStateVariant === "task-center" + ? undefined + : recentSessionSummary, + recentSessionActionLabel: + messageListEmptyStateVariant === "task-center" + ? undefined + : recentSessionActionLabel, + onResumeRecentSession: + messageListEmptyStateVariant === "task-center" + ? undefined + : handleResumeRecentSession, onOpenSceneAppsDirectory: handleOpenSceneAppsDirectory, projectId, sessionId, @@ -837,6 +851,7 @@ export function useWorkspaceConversationSceneRuntime({ onOpenSettings: navigationActions.handleOpenAppearanceSettings, runtimeToolAvailability: inputbarScene.runtimeToolAvailability, runtimeTaskCard, + taskCenterTabsNode, onOpenMemoryWorkbench: () => navigationActions.handleOpenRuntimeMemoryWorkbench({ sessionId, diff --git a/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx index 7c49debb8..65e0e4f23 100644 --- a/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceHarnessInventoryRuntime.test.tsx @@ -229,7 +229,7 @@ describe("useWorkspaceHarnessInventoryRuntime", () => { } }); - it("开关关闭时不应继续读取工具库存,也不应生成工作台摘要", async () => { + it("开关关闭时不应继续读取工具库存,也不应生成摘要", async () => { const harness = mountHook({ enabled: false, harnessPanelVisible: true, diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx index cd70ab181..471cc564c 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.test.tsx @@ -28,7 +28,7 @@ vi.mock("@/lib/api/agentRuntime", async (importOriginal) => { return { ...actual, - generateAgentRuntimeTitle: (...args: unknown[]) => + generateAgentRuntimeTitleResult: (...args: unknown[]) => mockGenerateAgentRuntimeTitle(...args), }; }); @@ -167,7 +167,18 @@ beforeEach(() => { ).IS_REACT_ACT_ENVIRONMENT = true; mockGenerateAgentRuntimeTitle.mockReset(); - mockGenerateAgentRuntimeTitle.mockResolvedValue("城市夜景主视觉"); + mockGenerateAgentRuntimeTitle.mockResolvedValue({ + title: "城市夜景主视觉", + sessionId: "title-session-1", + executionRuntime: { + route: "auxiliary.generate_title", + task_profile: { + task_kind: "artifact", + }, + }, + usedFallback: false, + fallbackReason: null, + }); toast.error.mockReset(); toast.info.mockReset(); toast.success.mockReset(); @@ -217,6 +228,10 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => { kind: "image_task", image_task: expect.objectContaining({ title: "城市夜景主视觉", + title_generation_result: expect.objectContaining({ + title: "城市夜景主视觉", + sessionId: "title-session-1", + }), mode: "generate", prompt: "城市夜景主视觉", size: "1024x1024", @@ -229,6 +244,7 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => { }), }); expect(mockGenerateAgentRuntimeTitle).toHaveBeenCalledWith({ + sessionId: "session-1", previewText: "城市夜景主视觉", titleKind: "image_task", }); @@ -264,6 +280,10 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => { }), }), ); + expect(mockGenerateAgentRuntimeTitle).toHaveBeenCalledWith({ + previewText: "城市夜景主视觉", + titleKind: "image_task", + }); }); it("应把编辑命令解析为统一的 skillRequest 上下文", async () => { @@ -468,6 +488,15 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => { anchor_hint: "section_end", anchor_section_title: "技术亮点", anchor_text: "这里是技术亮点段落。", + title_generation_result: { + title: "城市夜景主视觉", + sessionId: "title-session-1", + executionRuntime: { + route: "auxiliary.generate_title", + }, + usedFallback: false, + fallbackReason: null, + }, reference_images: [], }, status: "cancelled", @@ -517,6 +546,15 @@ describe("useWorkspaceImageWorkbenchActionRuntime", () => { projectRootPath: "/workspace/project-1", prompt: "城市夜景主视觉", title: "城市夜景主视觉", + titleGenerationResult: { + title: "城市夜景主视觉", + sessionId: "title-session-1", + executionRuntime: { + route: "auxiliary.generate_title", + }, + usedFallback: false, + fallbackReason: null, + }, mode: "generate", rawText: "@配图 生成 城市夜景主视觉", size: "1024x1024", diff --git a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts index 56a5d070f..645704824 100644 --- a/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts +++ b/src/components/agent/chat/workspace/useWorkspaceImageWorkbenchActionRuntime.ts @@ -12,13 +12,15 @@ import type { MediaTaskArtifactOutput, MediaTaskLookupRequest, } from "@/lib/api/mediaTasks"; -import { generateAgentRuntimeTitle } from "@/lib/api/agentRuntime"; +import type { AgentRuntimeGeneratedTitleResult } from "@/lib/api/agentRuntime"; +import { generateAgentRuntimeTitleResult } from "@/lib/api/agentRuntime"; import { emitCanvasImageInsertRequest } from "@/lib/canvasImageInsertBus"; import { onImageWorkbenchTaskAction } from "@/lib/imageWorkbenchEvents"; import type { MessageImage } from "../types"; import { parseImageWorkbenchCommand } from "../utils/imageWorkbenchCommand"; import { buildImageWorkbenchSessionTitle, + isLocalImageWorkbenchSessionKey, resolveImageWorkbenchSkillRequest, } from "./imageSkillLaunch"; import { buildImageTaskLookupRequest } from "./imageTaskLocator"; @@ -191,6 +193,53 @@ function readTaskPayloadStringArray( return []; } +function readTaskPayloadTitleGenerationResult( + payload: Record, +): AgentRuntimeGeneratedTitleResult | undefined { + const result = + asRecord(payload.title_generation_result) || + asRecord(payload.titleGenerationResult); + if (!result) { + return undefined; + } + + const title = + (typeof result.title === "string" && result.title.trim()) || undefined; + if (!title) { + return undefined; + } + + const sessionId = + (typeof result.sessionId === "string" && result.sessionId.trim()) || + (typeof result.session_id === "string" && result.session_id.trim()) || + null; + const executionRuntime = + result.executionRuntime ?? + result.execution_runtime ?? + null; + const usedFallback = + typeof result.usedFallback === "boolean" + ? result.usedFallback + : typeof result.used_fallback === "boolean" + ? result.used_fallback + : false; + const fallbackReason = + (typeof result.fallbackReason === "string" && result.fallbackReason) || + (typeof result.fallback_reason === "string" && result.fallback_reason) || + null; + + return { + title, + sessionId, + executionRuntime: + executionRuntime === null + ? null + : (executionRuntime as AgentRuntimeGeneratedTitleResult["executionRuntime"]), + usedFallback, + fallbackReason, + }; +} + function resolveReplayMode( value: unknown, ): CreateImageGenerationTaskArtifactRequest["mode"] { @@ -425,11 +474,19 @@ export function useWorkspaceImageWorkbenchActionRuntime({ originalTask.record, ); const anchorText = resolveTaskRecordAnchorText(originalTask.record); + const titleGenerationResult = readTaskPayloadTitleGenerationResult( + payload, + ); await createImageGenerationTask({ projectRootPath: originalTaskLookup.projectRootPath, prompt, - title: readTaskPayloadString(payload, ["title"]) || prompt, + title: + readTaskPayloadString(payload, ["title"]) || + originalTask.record.title?.trim() || + titleGenerationResult?.title || + prompt, + titleGenerationResult, mode: resolveReplayMode(payload.mode ?? payload.task_mode), rawText: readTaskPayloadString(payload, ["raw_text", "rawText"]) || prompt, @@ -756,21 +813,28 @@ export function useWorkspaceImageWorkbenchActionRuntime({ params.parsedCommand.mode === "generate" ? effectivePrompt : `${params.parsedCommand.mode === "edit" ? "修图" : "重绘"}:${effectivePrompt}`; - const resolvedTaskTitle = await generateAgentRuntimeTitle({ + const titleGenerationResult = await generateAgentRuntimeTitleResult({ + sessionId: + resolvedSessionKey && + !isLocalImageWorkbenchSessionKey(resolvedSessionKey) + ? resolvedSessionKey + : undefined, previewText: titlePreviewText, titleKind: "image_task", - }).catch(() => + }).catch(() => null); + const resolvedTaskTitle = + titleGenerationResult?.title || buildImageWorkbenchSessionTitle( params.parsedCommand.mode, effectivePrompt, - ), - ); + ); const skillRequest = resolveImageWorkbenchSkillRequest({ rawText: params.rawText, parsedCommand: params.parsedCommand, images: params.images, title: resolvedTaskTitle, + titleGenerationResult, currentImageWorkbenchState, imageWorkbenchSelectedModelId: imageWorkbenchRequestModelId, imageWorkbenchSelectedProviderId: imageWorkbenchRequestProviderId, diff --git a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts b/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts index df334d546..67840184c 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts @@ -137,7 +137,7 @@ export function useWorkspaceSceneAppEntryActions({ async (sceneappId: string) => { const descriptor = descriptorMap.get(sceneappId); if (!descriptor) { - toast.error("当前做法目录尚未就绪,请稍后重试"); + toast.error("当前全部做法页尚未就绪,请稍后重试"); return; } diff --git a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx index 8a5db3ea6..504a2a5f1 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSendActions.test.tsx @@ -1669,14 +1669,14 @@ describe("useWorkspaceSendActions", () => { } }); - it("@素材 应把资源库提词重写配置注入到 metadata 与发送覆盖", async () => { + it("@素材 应把项目资料提词重写配置注入到 metadata 与发送覆盖", async () => { const harness = mountHook({ input: "@素材 类型:图片 关键词:咖啡馆木桌背景 用途:公众号头图 数量:8", serviceModels: { resource_prompt_rewrite: { preferredProviderId: "resource-provider", preferredModelId: "resource-model", - customPrompt: "请先结合资料库语义补齐检索意图", + customPrompt: "请先结合项目资料语义补齐检索意图", }, }, }); @@ -1695,7 +1695,7 @@ describe("useWorkspaceSendActions", () => { resource_search_skill_launch: { resource_search_task: { prompt: - "请先结合资料库语义补齐检索意图\n\n咖啡馆木桌背景 公众号头图", + "请先结合项目资料语义补齐检索意图\n\n咖啡馆木桌背景 公众号头图", }, }, }, diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx index 435123047..0005c9e2b 100644 --- a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.test.tsx @@ -200,7 +200,7 @@ function createScheduledServiceSkill(): ServiceSkillHomeItem { isRecent: false, runnerLabel: "本地计划任务", runnerTone: "sky", - runnerDescription: "可直接创建本地定时任务,并回流到生成工作台与工作区。", + runnerDescription: "可直接创建本地定时任务,并回流到生成与工作区。", actionLabel: "创建任务", automationStatus: null, }; diff --git a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts index 26804fbbd..b9c48a33f 100644 --- a/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts +++ b/src/components/agent/chat/workspace/useWorkspaceServiceSkillEntryActions.ts @@ -673,7 +673,7 @@ export function useWorkspaceServiceSkillEntryActions({ if (skill.runnerType !== "instant") { toast.info( - "当前先进入工作区生成首版方案,下一阶段再接本地自动化任务。", + "这轮会先回到生成起第一版,后面再按设定继续带回来。", ); } @@ -983,7 +983,7 @@ export function useWorkspaceServiceSkillEntryActions({ const entered = navigateToServiceSkillWorkspace(workspacePayload); if (!entered) { - toast.error("自动化任务已创建,但进入工作区失败,请稍后手动打开。"); + toast.error("自动化已创建,但没能回到生成,请稍后手动打开。"); } } catch (error) { toast.error(`创建本地自动化任务失败:${getErrorMessage(error)}`); diff --git a/src/components/agent/chat/workspace/useWorkspaceSessionRestore.test.tsx b/src/components/agent/chat/workspace/useWorkspaceSessionRestore.test.tsx index bb0687853..6e6e43fa9 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSessionRestore.test.tsx +++ b/src/components/agent/chat/workspace/useWorkspaceSessionRestore.test.tsx @@ -66,11 +66,6 @@ describe("useWorkspaceSessionRestore", () => { const setActiveTheme = vi.fn(); const setCreationMode = vi.fn(); const setTaskFiles = vi.fn(); - const readSessionFile = vi.fn(async (name: string) => - name === "content-posts/restored-preview.md" - ? "# 春日咖啡活动\n\n首屏预览" - : null, - ); const { render } = renderHook({ sessionId: "session-content-preview", sessionMeta: { @@ -93,7 +88,6 @@ describe("useWorkspaceSessionRestore", () => { updatedAt: 200, }, ], - readSessionFile, taskFilesLength: 0, setActiveTheme, setCreationMode, @@ -103,16 +97,13 @@ describe("useWorkspaceSessionRestore", () => { await render(); await flushEffects(); - expect(readSessionFile).toHaveBeenCalledWith( - "content-posts/restored-preview.md", - ); expect(setCreationMode).toHaveBeenCalledWith("guided"); expect(setTaskFiles).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ + id: "session-file:content-posts/restored-preview.md", name: "content-posts/restored-preview.md", type: "document", - content: "# 春日咖啡活动\n\n首屏预览", metadata: expect.objectContaining({ contentPostIntent: "preview", contentPostLabel: "渠道预览稿", diff --git a/src/components/agent/chat/workspace/useWorkspaceSessionRestore.ts b/src/components/agent/chat/workspace/useWorkspaceSessionRestore.ts index 667997b50..11de0baf3 100644 --- a/src/components/agent/chat/workspace/useWorkspaceSessionRestore.ts +++ b/src/components/agent/chat/workspace/useWorkspaceSessionRestore.ts @@ -28,20 +28,32 @@ interface UseWorkspaceSessionRestoreParams { lockTheme: boolean; initialTheme?: string; sessionFiles: SessionFileSummary[]; - readSessionFile: (name: string) => Promise; taskFilesLength: number; setActiveTheme: (theme: ThemeType) => void; setCreationMode: (mode: CreationMode) => void; setTaskFiles: (files: TaskFile[]) => void; } +function buildSessionTaskFilePlaceholders( + sessionFiles: SessionFileSummary[], +): TaskFile[] { + return sessionFiles.map((file) => ({ + id: `session-file:${file.name}`, + name: file.name, + type: normalizeSessionTaskFileType(file.fileType ?? "other", file.name), + version: 1, + createdAt: file.createdAt ?? Date.now(), + updatedAt: file.updatedAt ?? file.createdAt ?? Date.now(), + metadata: file.metadata, + })); +} + export function useWorkspaceSessionRestore({ sessionId, sessionMeta, lockTheme, initialTheme, sessionFiles, - readSessionFile, taskFilesLength, setActiveTheme, setCreationMode, @@ -115,53 +127,19 @@ export function useWorkspaceSessionRestore({ return; } - console.log( - "[AgentChatPage] 开始恢复文件:", - sessionId, - sessionFiles.length, - "个文件", - ); + const restoredFiles = buildSessionTaskFilePlaceholders(sessionFiles); + if (restoredFiles.length > 0) { + console.log( + "[AgentChatPage] 从持久化存储恢复文件清单:", + sessionId, + restoredFiles.length, + "个文件", + ); + setTaskFiles(restoredFiles); + } - const restoreFiles = async () => { - const restoredFiles: TaskFile[] = []; - - for (const file of sessionFiles) { - try { - const content = await readSessionFile(file.name); - if (content) { - restoredFiles.push({ - id: crypto.randomUUID(), - name: file.name, - type: normalizeSessionTaskFileType( - file.fileType ?? "other", - file.name, - content, - ), - content, - version: 1, - createdAt: file.createdAt ?? Date.now(), - updatedAt: file.updatedAt ?? file.createdAt ?? Date.now(), - metadata: file.metadata, - }); - } - } catch (err) { - console.error("[AgentChatPage] 恢复文件失败:", file.name, err); - } - } - - if (restoredFiles.length > 0) { - console.log( - "[AgentChatPage] 从持久化存储恢复", - restoredFiles.length, - "个文件", - ); - setTaskFiles(restoredFiles); - } - restoredFilesSessionIdRef.current = sessionId; - }; - - void restoreFiles(); - }, [readSessionFile, sessionFiles, sessionId, setTaskFiles, taskFilesLength]); + restoredFilesSessionIdRef.current = sessionId; + }, [sessionFiles, sessionId, setTaskFiles, taskFilesLength]); return { resetRestoredSessionState, diff --git a/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts b/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts index da7a95239..156e88ad3 100644 --- a/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts +++ b/src/components/agent/chat/workspace/useWorkspaceTopicSwitch.ts @@ -121,15 +121,35 @@ export function useWorkspaceTopicSwitch({ } try { + const currentProjectId = normalizeProjectId(projectId); + const topicBoundProjectId = normalizeProjectId( + loadTopicBoundProjectId(topicId), + ); logAgentDebug("AgentChatPage", "switchTopic.start", { - currentProjectId: projectId ?? null, + currentProjectId: currentProjectId ?? null, externalProjectId: externalProjectId ?? null, forceRefresh: options?.forceRefresh === true, topicId, }); + + if ( + !externalProjectId && + currentProjectId && + topicBoundProjectId === currentProjectId + ) { + rememberProjectId(currentProjectId); + logAgentDebug("AgentChatPage", "switchTopic.fastPathCurrentProject", { + currentProjectId, + forceRefresh: options?.forceRefresh === true, + topicId, + }); + await runTopicSwitch(topicId, options); + return; + } + const decision = await resolveTopicSwitchProject({ lockedProjectId: externalProjectId ?? null, - topicBoundProjectId: loadTopicBoundProjectId(topicId), + topicBoundProjectId, lastProjectId: getRememberedProjectId(), loadProjectById: async (candidateProjectId) => { const project = await getProject(candidateProjectId); @@ -173,7 +193,6 @@ export function useWorkspaceTopicSwitch({ toast.info("未找到可用项目,已自动创建默认项目"); } - const currentProjectId = normalizeProjectId(projectId); if (currentProjectId !== targetProjectId) { deferTopicSwitch(topicId, targetProjectId, options); logAgentDebug("AgentChatPage", "switchTopic.deferUntilProjectReady", { diff --git a/src/components/channels/ImConfigPage.test.tsx b/src/components/channels/ImConfigPage.test.tsx index f962c994d..aea685a90 100644 --- a/src/components/channels/ImConfigPage.test.tsx +++ b/src/components/channels/ImConfigPage.test.tsx @@ -302,33 +302,38 @@ describe("ImConfigPage", () => { const text = document.body.textContent ?? ""; expect(text).toContain("消息渠道"); + expect(text).toContain("系统入口"); expect(text).toContain("Telegram"); expect(text).toContain("飞书"); expect(text).toContain("微信"); expect(text).toContain("即将开放"); expect(text).toContain("Discord"); expect(text).toContain("钉钉"); - expect(text).toContain("高级排障"); + expect(text).toContain("日志与检查"); expect(text).not.toContain("调试配置"); }); - it("应把首页说明和高级排障说明收进 tips", async () => { + it("应把首页说明和日志与检查说明收进 tips", async () => { renderPage(); await flushEffects(); expect(getBodyText()).not.toContain( - "首页只放重点入口。Telegram、飞书、微信在这里直达;联调检查放进各自配置弹窗,网关和日志统一收到下方高级区。", + "把 Telegram、飞书、微信接进 Lime,让消息接收、回复和结果投递继续留在熟悉客户端;接入检查放在各自配置里,日志和运行检查按需展开。", + ); + expect(getBodyText()).not.toContain( + "接入日志和运行检查都在这里,平时不用一直展开。", ); - expect(getBodyText()).not.toContain("网关、日志和运行状态都收在这里。"); const introTip = await hoverTip("消息渠道说明"); expect(getBodyText()).toContain( - "首页只放重点入口。Telegram、飞书、微信在这里直达;联调检查放进各自配置弹窗,网关和日志统一收到下方高级区。", + "把 Telegram、飞书、微信接进 Lime,让消息接收、回复和结果投递继续留在熟悉客户端;接入检查放在各自配置里,日志和运行检查按需展开。", ); await leaveTip(introTip); - const debugTip = await hoverTip("高级排障说明"); - expect(getBodyText()).toContain("网关、日志和运行状态都收在这里。"); + const debugTip = await hoverTip("日志与检查说明"); + expect(getBodyText()).toContain( + "接入日志和运行检查都在这里,平时不用一直展开。", + ); await leaveTip(debugTip); }); @@ -341,8 +346,8 @@ describe("ImConfigPage", () => { expect(document.body.textContent).toContain("Telegram 配置"); expect(document.body.textContent).toContain("机器人 Token"); - expect(document.body.textContent).toContain("联调检查"); - expect(document.body.textContent).toContain("查看日志排查"); + expect(document.body.textContent).toContain("接入检查"); + expect(document.body.textContent).toContain("查看日志与检查"); await clickButton(findButtonByText("保存")); await flushEffects(); @@ -418,7 +423,7 @@ describe("ImConfigPage", () => { ).not.toBeNull(); expect(document.body.textContent).toContain("群组策略"); expect(document.body.textContent).toContain("账号参数"); - expect(document.body.textContent).toContain("联调检查"); + expect(document.body.textContent).toContain("接入检查"); expect(document.body.textContent).toContain("展开账号"); expect(document.body.textContent).not.toContain("账号名称"); @@ -431,16 +436,16 @@ describe("ImConfigPage", () => { expect(document.body.textContent).toContain("账号名称"); }); - it("配置弹窗里的日志排查入口应展开旧的高级工作台", async () => { + it("配置弹窗里的日志与检查入口应展开旧的高级工作台", async () => { renderPage(); await flushEffects(); await clickButton(findButtonByTestId("telegram-config-button")); await flushEffects(); - await clickButton(findButtonByText("查看日志排查")); + await clickButton(findButtonByText("查看日志与检查")); await flushEffects(); - expect(document.body.textContent).toContain("高级排障"); + expect(document.body.textContent).toContain("日志与检查"); expect( document.body.querySelector('[data-testid="channels-debug-workbench"]'), ).not.toBeNull(); @@ -450,7 +455,7 @@ describe("ImConfigPage", () => { renderPage(); await flushEffects(); - await clickButton(findButtonByText("高级排障")); + await clickButton(findButtonByText("日志与检查")); await flushEffects(); expect( diff --git a/src/components/channels/ImConfigPage.tsx b/src/components/channels/ImConfigPage.tsx index 8f2ebbaa7..300fde3d0 100644 --- a/src/components/channels/ImConfigPage.tsx +++ b/src/components/channels/ImConfigPage.tsx @@ -660,7 +660,7 @@ function ProbePanel({
-
联调检查
+
接入检查

{description}

@@ -668,7 +668,7 @@ function ProbePanel({ {busy ? "检测中..." : "检测连接"} {onOpenAdvanced ? ( - 查看日志排查 + 查看日志与检查 ) : null}
@@ -889,7 +889,7 @@ function TelegramConfigDialog({ setDraft((current) => ({ ...current, enabled: checked })) } title="启用 Telegram" - description="机器人 Token、用户范围和联调都在这里。" + description="机器人 Token、用户范围和接入检查都在这里。" />

{hasCredentials - ? "凭证已就绪,可直接联调。" + ? "凭证已就绪,可直接检查接入。" : "先填应用 ID 和应用密钥。"}

@@ -2539,27 +2539,27 @@ export function ImConfigPage() {
- 能力 / 消息渠道 + 系统入口

- 把常用 IM 接到 Lime + 消息渠道

- 让 Telegram、飞书、微信等外部客户端也能连到 Lime。 + 把外部消息接进 Lime,让消息接收、回复和结果投递继续留在熟悉客户端。

- 3 个主入口 + 3 个常用渠道 已启用 {enabledChannelCount} 个 @@ -2568,7 +2568,7 @@ export function ImConfigPage() { 微信 {wechatAccountCount} 个账号 - 排障在下方高级区 + 日志与检查可展开
@@ -2716,33 +2716,35 @@ export function ImConfigPage() { ref={advancedToolsRef} className="rounded-[28px] border border-slate-200 bg-white shadow-sm shadow-slate-950/5" > - -
- {debugToolsOpen ? ( - - ) : ( - - )} - - + {debugToolsOpen ? ( + + ) : ( + + )} + + + +
- 正在加载排障工具... + 正在加载日志与检查...
} > diff --git a/src/components/image-gen/README.md b/src/components/image-gen/README.md index 0ea4a5f07..e4cc047a2 100644 --- a/src/components/image-gen/README.md +++ b/src/components/image-gen/README.md @@ -19,10 +19,10 @@ - 独立“插图”页面已经下线,不再作为产品入口 - 联网图片搜索已经迁到 Claw `@素材` -- 本地图片与“我的图片库”已经迁到资料库图片视图 +- 本地图片与“我的图片库”已经迁到项目资料图片视图 ## 使用方式 1. 在 Claw 工作台触发 AI 生图能力 2. 由 `useImageGen` 统一管理生成、保存与插图动作 -3. 本地图片上传与图库浏览统一走资料库 +3. 本地图片上传与图库浏览统一走项目资料图片视图 diff --git a/src/components/input-kit/BaseComposer.test.tsx b/src/components/input-kit/BaseComposer.test.tsx index 7725a8731..38809578f 100644 --- a/src/components/input-kit/BaseComposer.test.tsx +++ b/src/components/input-kit/BaseComposer.test.tsx @@ -31,6 +31,7 @@ afterEach(() => { mounted.container.remove(); } vi.clearAllMocks(); + vi.unstubAllGlobals(); }); interface HarnessProps { @@ -157,7 +158,7 @@ describe("BaseComposer", () => { expect(onSend).not.toHaveBeenCalled(); }); - it("输入法合成阶段按 Enter 不应触发发送", () => { + it("输入法合成阶段按 Enter 不应立即触发发送", () => { const { container, onSend } = renderHarness({ initialText: "你好" }); const textarea = getTextarea(container); @@ -177,6 +178,42 @@ describe("BaseComposer", () => { expect(onSend).not.toHaveBeenCalled(); }); + it("输入法用 Enter 确认合成后应自动发送", () => { + vi.stubGlobal( + "requestAnimationFrame", + (callback: (timestamp: number) => void) => { + callback(0); + return 1; + }, + ); + + const { container, onSend } = renderHarness({ initialText: "你好" }); + const textarea = getTextarea(container); + + const imeEnterEvent = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + }); + Object.defineProperty(imeEnterEvent, "isComposing", { + value: true, + configurable: true, + }); + + act(() => { + textarea.dispatchEvent(imeEnterEvent); + }); + + expect(onSend).not.toHaveBeenCalled(); + + act(() => { + textarea.dispatchEvent( + new CompositionEvent("compositionend", { bubbles: true }), + ); + }); + + expect(onSend).toHaveBeenCalledTimes(1); + }); + it("生成中点击主按钮应触发停止", () => { const { container, onSend, onStop } = renderHarness({ initialText: "hello", diff --git a/src/components/input-kit/BaseComposer.tsx b/src/components/input-kit/BaseComposer.tsx index a96143b91..e26e1e6f0 100644 --- a/src/components/input-kit/BaseComposer.tsx +++ b/src/components/input-kit/BaseComposer.tsx @@ -59,6 +59,9 @@ export const BaseComposer: React.FC = ({ const internalTextareaRef = useRef(null); const textareaRef = externalTextareaRef || internalTextareaRef; const textareaId = useId(); + const pendingImeSendRef = useRef(false); + const canSendRef = useRef(false); + const onSendRef = useRef(onSend); const hasContent = useMemo(() => { return allowEmptySend || text.trim().length > 0 || hasAdditionalContent; @@ -69,6 +72,14 @@ export const BaseComposer: React.FC = ({ const isPrimaryDisabled = isLoading && !allowSendWhileLoading ? false : !canSend; + useEffect(() => { + canSendRef.current = canSend; + }, [canSend]); + + useEffect(() => { + onSendRef.current = onSend; + }, [onSend]); + useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -127,7 +138,11 @@ export const BaseComposer: React.FC = ({ return; } - if (isImeComposing(event)) { + const composing = isImeComposing(event); + if (composing) { + if (event.key === "Enter" && sendOnEnter && !event.shiftKey) { + pendingImeSendRef.current = true; + } return; } @@ -154,12 +169,30 @@ export const BaseComposer: React.FC = ({ ], ); + const handleCompositionEnd = useCallback(() => { + if (!pendingImeSendRef.current) { + return; + } + + pendingImeSendRef.current = false; + if (!sendOnEnter) { + return; + } + + window.requestAnimationFrame(() => { + if (canSendRef.current) { + onSendRef.current(); + } + }); + }, [sendOnEnter]); + const textareaProps: React.TextareaHTMLAttributes = { id: textareaId, name: "agent-chat-message", value: text, onChange: (event) => setText(event.target.value), onKeyDown: handleKeyDown, + onCompositionEnd: handleCompositionEnd, onPaste, placeholder, disabled, diff --git a/src/components/layout/AppCrashBoundary.tsx b/src/components/layout/AppCrashBoundary.tsx index fcfcaec5c..eef0da02c 100644 --- a/src/components/layout/AppCrashBoundary.tsx +++ b/src/components/layout/AppCrashBoundary.tsx @@ -1,5 +1,10 @@ import React from "react"; import { reportFrontendError } from "@/lib/crashReporting"; +import { getRuntimeAppVersion } from "@/lib/appVersion"; +import { + isModuleImportFailureErrorMessage, + prepareModuleImportAutoReload, +} from "./CrashRecoveryPanel.helpers"; import { CrashRecoveryPanel } from "./CrashRecoveryPanel"; interface AppCrashBoundaryProps { @@ -40,14 +45,32 @@ export class AppCrashBoundary extends React.Component< const componentStack = info.componentStack || ""; this.setState({ componentStack }); + const pageUrl = + typeof window !== "undefined" ? window.location.href : "unknown"; + const shouldAutoReload = + typeof window !== "undefined" && + isModuleImportFailureErrorMessage(error.message); + const autoReloadUrl = + shouldAutoReload && typeof window !== "undefined" + ? prepareModuleImportAutoReload( + window.location.href, + getRuntimeAppVersion(), + window.sessionStorage, + ) + : null; + void reportFrontendError(error, { source: "app-crash-boundary", workflow_step: "root_render", component: "AppCrashBoundary", component_stack: componentStack, - page_url: - typeof window !== "undefined" ? window.location.href : "unknown", + page_url: pageUrl, + auto_resource_reload_triggered: Boolean(autoReloadUrl), }); + + if (autoReloadUrl && typeof window !== "undefined") { + window.location.replace(autoReloadUrl); + } } private handleRetry = () => { diff --git a/src/components/layout/CrashRecoveryPanel.helpers.ts b/src/components/layout/CrashRecoveryPanel.helpers.ts index 337b1adef..942f3c8d9 100644 --- a/src/components/layout/CrashRecoveryPanel.helpers.ts +++ b/src/components/layout/CrashRecoveryPanel.helpers.ts @@ -3,6 +3,18 @@ const MODULE_IMPORT_FAILURE_PATTERNS = [ "Failed to fetch dynamically imported module", ] as const; const RESOURCE_RELOAD_PARAM = "__lime_resource_reload"; +const MODULE_IMPORT_AUTO_RELOAD_SESSION_KEY = + "lime_module_import_auto_reload_v1"; + +interface SessionStorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +interface HistoryLike { + replaceState(data: unknown, unused: string, url?: string | URL | null): void; +} export function isModuleImportFailureErrorMessage(message: string): boolean { return MODULE_IMPORT_FAILURE_PATTERNS.some((pattern) => @@ -10,6 +22,20 @@ export function isModuleImportFailureErrorMessage(message: string): boolean { ); } +function normalizeModuleImportRecoveryLocation(currentHref: string): string { + const normalizedUrl = new URL(currentHref); + normalizedUrl.searchParams.delete(RESOURCE_RELOAD_PARAM); + return normalizedUrl.toString(); +} + +function resolveModuleImportRecoveryFingerprint( + currentHref: string, + appVersion: string, +): string { + const version = appVersion.trim() || "unknown"; + return `${version}::${normalizeModuleImportRecoveryLocation(currentHref)}`; +} + export function buildCrashRecoveryReloadUrl( currentHref: string, cacheBust: string, @@ -18,3 +44,67 @@ export function buildCrashRecoveryReloadUrl( nextUrl.searchParams.set(RESOURCE_RELOAD_PARAM, cacheBust); return nextUrl.toString(); } + +export function stripCrashRecoveryReloadUrl(currentHref: string): string { + const nextUrl = new URL(currentHref); + nextUrl.searchParams.delete(RESOURCE_RELOAD_PARAM); + return nextUrl.toString(); +} + +export function prepareModuleImportAutoReload( + currentHref: string, + appVersion: string, + storage: SessionStorageLike, +): string | null { + const fingerprint = resolveModuleImportRecoveryFingerprint( + currentHref, + appVersion, + ); + + try { + if ( + storage.getItem(MODULE_IMPORT_AUTO_RELOAD_SESSION_KEY) === fingerprint + ) { + return null; + } + + storage.setItem(MODULE_IMPORT_AUTO_RELOAD_SESSION_KEY, fingerprint); + } catch { + return null; + } + + return buildCrashRecoveryReloadUrl(currentHref, `${Date.now()}`); +} + +export function finalizeModuleImportAutoReload( + currentHref: string, + appVersion: string, + storage: SessionStorageLike, + history: HistoryLike, +): void { + const fingerprint = resolveModuleImportRecoveryFingerprint( + currentHref, + appVersion, + ); + + try { + if ( + storage.getItem(MODULE_IMPORT_AUTO_RELOAD_SESSION_KEY) === fingerprint + ) { + storage.removeItem(MODULE_IMPORT_AUTO_RELOAD_SESSION_KEY); + } + } catch { + // 忽略 sessionStorage 访问失败,避免影响正常启动 + } + + const cleanUrl = stripCrashRecoveryReloadUrl(currentHref); + if (cleanUrl === currentHref) { + return; + } + + try { + history.replaceState(null, "", cleanUrl); + } catch { + // 忽略 history 写入失败,最多保留 query 参数,不阻断页面渲染 + } +} diff --git a/src/components/layout/CrashRecoveryPanel.test.tsx b/src/components/layout/CrashRecoveryPanel.test.tsx index 0a0af4b54..bc4442bae 100644 --- a/src/components/layout/CrashRecoveryPanel.test.tsx +++ b/src/components/layout/CrashRecoveryPanel.test.tsx @@ -3,7 +3,10 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildCrashRecoveryReloadUrl, + finalizeModuleImportAutoReload, isModuleImportFailureErrorMessage, + prepareModuleImportAutoReload, + stripCrashRecoveryReloadUrl, } from "./CrashRecoveryPanel.helpers"; vi.mock("@/lib/api/appConfig", () => ({ @@ -128,6 +131,77 @@ describe("CrashRecoveryPanel", () => { expect(reloadUrl).toContain("__lime_resource_reload=654321"); }); + it("应在同一页面同一版本下只允许自动强制刷新一次", () => { + const storage = { + state: new Map(), + getItem(key: string) { + return this.state.get(key) ?? null; + }, + setItem(key: string, value: string) { + this.state.set(key, value); + }, + removeItem(key: string) { + this.state.delete(key); + }, + }; + + const firstReloadUrl = prepareModuleImportAutoReload( + "http://127.0.0.1:1420/agent?tab=chat", + "1.19.0", + storage, + ); + const secondReloadUrl = prepareModuleImportAutoReload( + "http://127.0.0.1:1420/agent?tab=chat", + "1.19.0", + storage, + ); + + expect(firstReloadUrl).toContain("__lime_resource_reload="); + expect(secondReloadUrl).toBeNull(); + }); + + it("成功启动后应移除自动刷新标记并清理 URL 参数", () => { + const storage = { + state: new Map(), + getItem(key: string) { + return this.state.get(key) ?? null; + }, + setItem(key: string, value: string) { + this.state.set(key, value); + }, + removeItem(key: string) { + this.state.delete(key); + }, + }; + const replaceState = vi.fn(); + const reloadUrl = prepareModuleImportAutoReload( + "http://127.0.0.1:1420/settings?tab=providers", + "1.19.0", + storage, + ); + + expect(reloadUrl).toContain("__lime_resource_reload="); + finalizeModuleImportAutoReload( + reloadUrl!, + "1.19.0", + storage, + { replaceState }, + ); + + expect(replaceState).toHaveBeenCalledWith( + null, + "", + stripCrashRecoveryReloadUrl(reloadUrl!), + ); + expect( + prepareModuleImportAutoReload( + "http://127.0.0.1:1420/settings?tab=providers", + "1.19.0", + storage, + ), + ).toContain("__lime_resource_reload="); + }); + it("模块导入失败时应展示强制刷新资源入口", () => { const { container } = renderPanel( new Error("Importing a module script failed."), diff --git a/src/components/memory/MemoryPage.test.tsx b/src/components/memory/MemoryPage.test.tsx index 4c442ae53..4be4dc8e0 100644 --- a/src/components/memory/MemoryPage.test.tsx +++ b/src/components/memory/MemoryPage.test.tsx @@ -347,6 +347,33 @@ describe("MemoryPage", () => { expect(bodyText).toContain("夏日短视频语气"); }); + it("应在灵感库首屏显式提供项目资料辅助入口", async () => { + const onNavigate = vi.fn(); + renderPage({ onNavigate }); + await flushPageEffects(); + + const bodyText = document.body.textContent ?? ""; + expect(bodyText).toContain("项目资料作为辅助页保留在这里"); + expect(bodyText).toContain( + "默认资料导航现在优先看灵感库;需要回看项目资料、导入内容和外部资料时,从这里打开项目资料。", + ); + expect(bodyText).toContain("打开项目资料"); + + const openResourcesButton = Array.from( + document.body.querySelectorAll("button"), + ).find((element) => element.textContent?.includes("打开项目资料")); + expect(openResourcesButton).toBeTruthy(); + + await act(async () => { + openResourcesButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await Promise.resolve(); + }); + + expect(onNavigate).toHaveBeenCalledWith("resources"); + }); + it("应支持从灵感库推荐结果模板进入共享 launcher,再带着参考对象进入生成", async () => { const onNavigate = vi.fn(); renderPage({ onNavigate }); @@ -397,7 +424,7 @@ describe("MemoryPage", () => { agentEntry: "new-task", projectId: "project-42", entryBannerMessage: - "已从灵感库推荐“内容主稿生成”带着启动信息进入生成,可继续补充后发送。", + "已带着灵感库推荐“内容主稿生成”的启动信息回到生成,接着把这轮做下去就行。", initialInputCapability: expect.objectContaining({ capabilityRoute: expect.objectContaining({ kind: "curated_task", @@ -490,7 +517,7 @@ describe("MemoryPage", () => { const bodyText = document.body.textContent ?? ""; expect(bodyText).toContain("复盘这个账号/项目"); - expect(bodyText).toContain("已按最近复盘切到更适合的结果模板"); + expect(bodyText).toContain("已按最近判断切到更适合的结果模板"); expect(bodyText).toContain("已选择 1 条参考对象"); }); @@ -803,7 +830,7 @@ describe("MemoryPage", () => { ); const sceneButton = Array.from(document.body.querySelectorAll("button")).find( - (element) => element.textContent?.includes("去做法目录"), + (element) => element.textContent?.includes("去全部做法"), ); expect(sceneButton).toBeTruthy(); diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx index 95bb3dd98..c61b4fc95 100644 --- a/src/components/memory/MemoryPage.tsx +++ b/src/components/memory/MemoryPage.tsx @@ -7,6 +7,7 @@ import { type ReactNode, } from "react"; import { + ArrowRight, BrainCircuit, Database, FolderKanban, @@ -1570,6 +1571,10 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { [buildMemoryPageParams, onNavigate], ); + const handleOpenProjectResources = useCallback(() => { + onNavigate("resources"); + }, [onNavigate]); + function handleBringToCreation(entry: UnifiedMemory) { const categoryLabel = CATEGORY_LABELS[entry.category]; const entryBannerMessage = `已从灵感库带入“${categoryLabel}”条目,可继续改写后发送。`; @@ -1640,7 +1645,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ), ); setMemoryLauncherPrefillHintOverride( - "已按最近复盘切到更适合的结果模板,你可以继续改后再进入生成。", + "已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。", ); }, [], @@ -1709,7 +1714,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { }, requestKey: Date.now(), }, - entryBannerMessage: `已从灵感库推荐“${task.title}”带着启动信息进入生成,可继续补充后发送。`, + entryBannerMessage: `已带着灵感库推荐“${task.title}”的启动信息回到生成,接着把这轮做下去就行。`, }), ); }, @@ -1992,6 +1997,38 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {
+
+
+
+
+ + 辅助入口 + +

+ 项目资料作为辅助页保留在这里 +

+
+

+ 默认资料导航现在优先看灵感库;需要回看项目资料、导入内容和外部资料时,从这里打开项目资料。 +

+
+ +
+
+ - 去做法目录 + 去全部做法
@@ -3441,7 +3478,7 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { diff --git a/src/components/projects/ProjectSelector.tsx b/src/components/projects/ProjectSelector.tsx index 9ce160a68..346013ed8 100644 --- a/src/components/projects/ProjectSelector.tsx +++ b/src/components/projects/ProjectSelector.tsx @@ -58,6 +58,12 @@ export interface ProjectSelectorProps { value: string | null; /** 选择变化回调 */ onChange: (projectId: string) => void; + /** 受控展开状态 */ + open?: boolean; + /** 展开状态变化回调 */ + onOpenChange?: (open: boolean) => void; + /** 是否仅作被动展示,不响应点击 */ + passiveTrigger?: boolean; /** 按主题类型筛选(可选,不传则显示所有项目) */ workspaceType?: string; /** 占位符文本 */ @@ -75,7 +81,7 @@ export interface ProjectSelectorProps { /** 显示密度 */ density?: "default" | "compact"; /** 外观表面 */ - chrome?: "default" | "embedded"; + chrome?: "default" | "embedded" | "workspace-tab"; /** 是否跳过默认项目目录健康检查 */ skipDefaultWorkspaceReadyCheck?: boolean; /** 是否延后完整项目列表加载到展开时 */ @@ -158,6 +164,9 @@ function getProjectSummaryText( export function ProjectSelector({ value, onChange, + open: openProp, + onOpenChange, + passiveTrigger = false, workspaceType, placeholder = "选择项目", disabled = false, @@ -184,7 +193,7 @@ export function ProjectSelector({ skipDefaultWorkspaceReadyCheck, autoLoad: !deferProjectListLoad, }); - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [renameDialogOpen, setRenameDialogOpen] = useState(false); @@ -198,6 +207,18 @@ export function ProjectSelector({ const [projectListHydrating, setProjectListHydrating] = useState(false); const [hasLoadedProjectList, setHasLoadedProjectList] = useState(!deferProjectListLoad); + const compact = density === "compact"; + const embedded = chrome === "embedded"; + const workspaceTab = chrome === "workspace-tab"; + const compactLikeTrigger = compact || embedded || workspaceTab; + const compactPanel = compact || workspaceTab; + const open = openProp ?? internalOpen; + const entityLabel = workspaceTab ? "工作区" : "项目"; + const createEntityLabel = workspaceTab ? "新建工作区" : "新建项目"; + const managementTitle = workspaceTab ? "工作区管理" : "项目管理"; + const managementDescription = workspaceTab + ? "当前只管理可见工作区,不影响本地目录与已有文件。" + : "当前只管理可见项目,不影响本地目录与已有文件。"; const projectSource = workspaceType === "general" ? generalProjects : projects; @@ -386,7 +407,10 @@ export function ProjectSelector({ ]); const handleOpenChange = (nextOpen: boolean) => { - setOpen(nextOpen); + if (openProp === undefined) { + setInternalOpen(nextOpen); + } + onOpenChange?.(nextOpen); if (!nextOpen || !deferProjectListLoad || hasLoadedProjectList || loading) { return; @@ -406,7 +430,7 @@ export function ProjectSelector({ if (projectId !== value) { onChange(projectId); } - setOpen(false); + handleOpenChange(false); }; const handleCreateProject = async (name: string, type: ProjectType) => { @@ -415,8 +439,8 @@ export function ProjectSelector({ workspaceType: type, }); onChange(project.id); - setOpen(false); - toast.success("项目已创建"); + handleOpenChange(false); + toast.success(`${entityLabel}已创建`); }; const handleOpenRename = () => { @@ -427,7 +451,7 @@ export function ProjectSelector({ setRenameTargetId(currentSelectedProject.id); setRenameName(currentSelectedProject.name); - setOpen(false); + handleOpenChange(false); setRenameDialogOpen(true); }; @@ -438,7 +462,7 @@ export function ProjectSelector({ const nextName = renameName.trim(); if (!nextName) { - toast.error("项目名称不能为空"); + toast.error(`${entityLabel}名称不能为空`); return; } @@ -447,7 +471,7 @@ export function ProjectSelector({ await rename(renameTarget.id, nextName); setRenameDialogOpen(false); setRenameTargetId(null); - toast.success("项目名称已更新"); + toast.success(`${entityLabel}名称已更新`); } catch (error) { toast.error( `重命名失败: ${error instanceof Error ? error.message : String(error)}`, @@ -464,7 +488,7 @@ export function ProjectSelector({ } setDeleteTargetId(currentSelectedProject.id); - setOpen(false); + handleOpenChange(false); setDeleteDialogOpen(true); }; @@ -493,7 +517,7 @@ export function ProjectSelector({ setDeleteDialogOpen(false); setDeleteTargetId(null); - toast.success("项目已删除,本地目录未删除"); + toast.success(`${entityLabel}已删除,本地目录未删除`); } catch (error) { toast.error( `删除失败: ${error instanceof Error ? error.message : String(error)}`, @@ -505,15 +529,13 @@ export function ProjectSelector({ const managementHint = selectedProject && !canRenameProject(selectedProject) - ? "默认项目不可重命名或删除" + ? `默认${entityLabel}不可重命名或删除` : null; - const compact = density === "compact"; - const embedded = chrome === "embedded"; const projectSummaryText = getProjectSummaryText(selectedProject); - const popoverWidthClass = compact ? "w-[380px]" : "w-[420px]"; - const headerPaddingClass = compact ? "px-4 py-3" : "px-4 py-4"; - const bodyPaddingClass = compact ? "px-4 py-3" : "px-4 py-4"; - const managementPaddingClass = compact ? "px-4 py-3" : "px-4 py-4"; + const popoverWidthClass = compactPanel ? "w-[392px]" : "w-[420px]"; + const headerPaddingClass = compactPanel ? "px-4 py-3" : "px-4 py-4"; + const bodyPaddingClass = compactPanel ? "px-4 py-3" : "px-4 py-4"; + const managementPaddingClass = compactPanel ? "px-4 py-3" : "px-4 py-4"; const displayLoading = loading || projectListHydrating; return ( @@ -524,14 +546,19 @@ export function ProjectSelector({ variant="outline" size="sm" className={cn( - embedded + workspaceTab + ? "-mb-px h-[36px] min-w-[164px] max-w-[280px] justify-start gap-2 rounded-t-[16px] rounded-b-[8px] border border-b-transparent border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.96)_64%,rgba(240,249,255,0.88)_100%)] px-3 pb-1.5 pt-1 text-left shadow-none transition-[background-color,border-color] focus-visible:ring-1 focus-visible:ring-slate-300 focus-visible:ring-offset-0" + : embedded ? "h-10 min-w-[180px] max-w-[280px] justify-between gap-2 rounded-full border-transparent bg-transparent px-1.5 py-0 text-left shadow-none hover:bg-slate-50/80 focus-visible:ring-1 focus-visible:ring-slate-300 focus-visible:ring-offset-0" : compact ? "h-10 min-w-[180px] max-w-[280px] justify-between gap-2 rounded-full border-slate-200/80 bg-white/94 px-2.5 py-0 text-left shadow-sm shadow-slate-950/5 transition-[border-color,box-shadow,background-color] hover:border-slate-300/80 hover:bg-white" : "h-11 min-w-[220px] max-w-[320px] justify-between gap-3 rounded-2xl border-slate-200/80 bg-white/92 px-3 py-0 text-left shadow-sm shadow-slate-950/5 transition-[border-color,box-shadow,background-color] hover:border-slate-300/80 hover:bg-white hover:shadow-md hover:shadow-slate-950/8", className, + passiveTrigger && + "pointer-events-none cursor-default select-none focus-visible:ring-0", )} disabled={disabled || displayLoading} + tabIndex={passiveTrigger ? -1 : undefined} title={ selectedProject ? `${selectedProject.name}\n${selectedProject.rootPath}` @@ -541,49 +568,72 @@ export function ProjectSelector({ - - {selectedProject?.icon ? ( - - {selectedProject.icon} - - ) : ( - - )} - - {compact || embedded ? ( - - - {selectedProject?.name || placeholder} - - {selectedProject?.isDefault ? ( - - 默认 - - ) : null} - - ) : ( - - - + {workspaceTab ? ( + <> + + + {selectedProject?.name || placeholder} - {selectedProject?.isDefault ? ( - - 默认 - - ) : null} - + + ) : ( + <> + + {selectedProject?.icon ? ( + + {selectedProject.icon} + + ) : ( + + )} + + {compactLikeTrigger ? ( + + + {selectedProject?.name || placeholder} + + {selectedProject?.isDefault ? ( + + 默认 + + ) : null} + + ) : ( + + + + {selectedProject?.name || placeholder} + + {selectedProject?.isDefault ? ( + + 默认 + + ) : null} + + + )} + )} - - - + {!workspaceTab ? ( + + + + ) : null}
- 选择项目 + 选择{entityLabel}
- 在这里切换项目、搜索项目,并管理当前可见项目列表。 + 在这里切换{entityLabel}、搜索{entityLabel},并管理当前可见{entityLabel}列表。
- {filteredProjects.length} 个项目 + {filteredProjects.length} 个{entityLabel} - {!compact && projectSummaryText ? ( + {!compactPanel && projectSummaryText ? (
- 当前项目: + 当前{entityLabel}: {projectSummaryText}
@@ -640,9 +690,9 @@ export function ProjectSelector({ setSearchQuery(event.target.value)} - placeholder="搜索项目" + placeholder={`搜索${entityLabel}`} className={cn( - compact ? "h-9" : "h-10", + compactPanel ? "h-9" : "h-10", "border-slate-200/80 bg-white/85 pl-9 focus-visible:border-slate-300 focus-visible:ring-1 focus-visible:ring-slate-300 focus-visible:ring-offset-0", )} /> @@ -652,10 +702,10 @@ export function ProjectSelector({
{displayLoading ? (
@@ -774,10 +824,10 @@ export function ProjectSelector({ >
- 项目管理 + {managementTitle}
- 当前只管理可见项目,不影响本地目录与已有文件。 + {managementDescription}
@@ -790,12 +840,12 @@ export function ProjectSelector({ "gap-1.5 rounded-full border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] text-white shadow-sm shadow-emerald-950/15 hover:opacity-95", )} onClick={() => { - setOpen(false); + handleOpenChange(false); setCreateDialogOpen(true); }} > - 新建项目 + {createEntityLabel} diff --git a/src/components/projects/ProjectSelector.ui.test.tsx b/src/components/projects/ProjectSelector.ui.test.tsx index 711d1a122..7823553e3 100644 --- a/src/components/projects/ProjectSelector.ui.test.tsx +++ b/src/components/projects/ProjectSelector.ui.test.tsx @@ -354,6 +354,36 @@ describe("ProjectSelector 组件", () => { expect(findButton(container, "删除")).toBeNull(); }); + it("workspace-tab 模式应使用工作区文案", () => { + const defaultProject = createProject({ + id: "default", + name: "默认工作区", + isDefault: true, + workspaceType: "general", + }); + + mockUseProjects.mockReturnValue( + createUseProjectsResult({ + projects: [defaultProject], + generalProjects: [defaultProject], + defaultProject, + }), + ); + + const container = renderProjectSelector({ + value: defaultProject.id, + workspaceType: "general", + enableManagement: true, + chrome: "workspace-tab", + placeholder: "选择工作区", + }); + + expect(container.textContent).toContain("选择工作区"); + expect(container.textContent).toContain("工作区管理"); + expect(findButton(container, "新建工作区")).not.toBeNull(); + expect(findButton(container, "新建项目")).toBeNull(); + }); + it("延后列表加载时应先用项目摘要渲染当前项目", async () => { const selectedProject = createProject({ id: "general-1", diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.test.ts b/src/components/provider-pool/api-key/ProviderConfigForm.test.ts index 482d76849..bbf49a997 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.test.ts +++ b/src/components/provider-pool/api-key/ProviderConfigForm.test.ts @@ -275,6 +275,39 @@ describe("模型辅助函数", () => { ).toBe("gpt-5.3-codex, gpt-5.2"); }); + test("parseCustomModelsValue 应规范化 MiMo 展示名与历史别名", () => { + expect( + parseCustomModelsValue( + "MiMo-V2.5-Pro, mimo-v2-pro, mimo-v2.5, mimo-v2-flash", + { + providerId: "custom-mimo", + providerType: "anthropic-compatible", + apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic", + }, + ), + ).toEqual(["mimo-v2.5-pro", "mimo-v2-flash"]); + }); + + test("serializeCustomModels 应输出规范化后的 MiMo 模型 ID", () => { + expect( + serializeCustomModels(["MiMo-V2.5-Pro", "mimo-v2-pro", "mimo-v2.5"], { + providerId: "custom-mimo", + providerType: "anthropic-compatible", + apiHost: "https://token-plan-cn.xiaomimimo.com/anthropic", + }), + ).toBe("mimo-v2.5-pro"); + }); + + test("非 Xiaomi Provider 不应误改写模型 ID", () => { + expect( + parseCustomModelsValue("mimo-v2-pro", { + providerId: "openai", + providerType: "openai", + apiHost: "https://api.openai.com/v1", + }), + ).toEqual(["mimo-v2-pro"]); + }); + test("sortSelectableModels 应优先最新和带发布日期的模型", () => { const models = [ createModel({ diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.tsx b/src/components/provider-pool/api-key/ProviderConfigForm.tsx index d082bbb7a..e82647e70 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.tsx +++ b/src/components/provider-pool/api-key/ProviderConfigForm.tsx @@ -180,7 +180,11 @@ export const ProviderConfigForm = forwardRef< project: provider.project || "", location: provider.location || "", region: provider.region || "", - customModels: (provider.custom_models || []).join(", "), + customModels: serializeCustomModels(provider.custom_models || [], { + providerId: provider.id, + providerType: provider.type as ProviderType, + apiHost: provider.api_host, + }), }); // 保存状态 @@ -192,9 +196,21 @@ export const ProviderConfigForm = forwardRef< // 防抖定时器 const debounceTimerRef = useRef | null>(null); + const providerModelNormalizationOptions = useMemo( + () => ({ + providerId: provider.id, + providerType: formState.providerType, + apiHost: formState.apiHost, + }), + [formState.apiHost, formState.providerType, provider.id], + ); const selectedModels = useMemo( - () => parseCustomModelsValue(formState.customModels), - [formState.customModels], + () => + parseCustomModelsValue( + formState.customModels, + providerModelNormalizationOptions, + ), + [formState.customModels, providerModelNormalizationOptions], ); const enabledApiKeyCount = useMemo( () => provider.api_keys.filter((apiKey) => apiKey.enabled).length, @@ -359,7 +375,11 @@ export const ProviderConfigForm = forwardRef< project: provider.project || "", location: provider.location || "", region: provider.region || "", - customModels: (provider.custom_models || []).join(", "), + customModels: serializeCustomModels(provider.custom_models || [], { + providerId: provider.id, + providerType: provider.type as ProviderType, + apiHost: provider.api_host, + }), }); setSaveError(null); setModelDraft(""); @@ -386,10 +406,11 @@ export const ProviderConfigForm = forwardRef< try { // 解析自定义模型列表(逗号分隔) - const customModels = state.customModels - .split(",") - .map((m) => m.trim()) - .filter((m) => m.length > 0); + const customModels = parseCustomModelsValue(state.customModels, { + providerId: provider.id, + providerType: state.providerType, + apiHost: state.apiHost, + }); const request: UpdateProviderRequest = { type: state.providerType, @@ -463,9 +484,12 @@ export const ProviderConfigForm = forwardRef< const applyCustomModels = useCallback( (models: string[]) => { - handleFieldChange("customModels", serializeCustomModels(models)); + handleFieldChange( + "customModels", + serializeCustomModels(models, providerModelNormalizationOptions), + ); }, - [handleFieldChange], + [handleFieldChange, providerModelNormalizationOptions], ); const setDefaultModel = useCallback( diff --git a/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts b/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts index 8d87b6940..22fa260d1 100644 --- a/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts +++ b/src/components/provider-pool/api-key/ProviderConfigForm.utils.ts @@ -8,6 +8,7 @@ import type { ProviderDeclaredPromptCacheMode, ProviderType, } from "@/lib/types/provider"; +import { canonicalizeKnownProviderModelId } from "@/lib/model/xiaomiModelNormalization"; import { getProviderPromptCacheMode } from "@/lib/model/providerPromptCacheSupport"; import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; @@ -156,17 +157,64 @@ export function dedupeModelIds(modelIds: string[]): string[] { return result; } -export function parseCustomModelsValue(value: string): string[] { +interface ProviderModelNormalizationOptions { + providerId?: string | null; + providerType?: string | null; + apiHost?: string | null; +} + +function normalizeProviderModelId( + modelId: string, + options?: ProviderModelNormalizationOptions, +): string { + const trimmed = modelId.trim(); + if (!trimmed) { + return ""; + } + + if (!options) { + return trimmed; + } + + return ( + canonicalizeKnownProviderModelId({ + providerId: options.providerId, + providerType: options.providerType, + apiHost: options.apiHost, + modelId: trimmed, + }) || trimmed + ); +} + +function canonicalizeProviderModelIds( + modelIds: string[], + options?: ProviderModelNormalizationOptions, +): string[] { return dedupeModelIds( + modelIds + .map((modelId) => normalizeProviderModelId(modelId, options)) + .filter((modelId) => modelId.length > 0), + ); +} + +export function parseCustomModelsValue( + value: string, + options?: ProviderModelNormalizationOptions, +): string[] { + return canonicalizeProviderModelIds( value .split(",") .map((item) => item.trim()) .filter((item) => item.length > 0), + options, ); } -export function serializeCustomModels(models: string[]): string { - return dedupeModelIds(models).join(", "); +export function serializeCustomModels( + models: string[], + options?: ProviderModelNormalizationOptions, +): string { + return canonicalizeProviderModelIds(models, options).join(", "); } export function sortSelectableModels( diff --git a/src/components/resources/ResourcesImageWorkbench.test.tsx b/src/components/resources/ResourcesImageWorkbench.test.tsx index d8b07a5f1..1d36c37ae 100644 --- a/src/components/resources/ResourcesImageWorkbench.test.tsx +++ b/src/components/resources/ResourcesImageWorkbench.test.tsx @@ -140,9 +140,12 @@ afterEach(() => { }); describe("ResourcesImageWorkbench", () => { - it("未选择项目时应提示先选择资料库", () => { + it("未选择项目时应提示先选择项目", () => { const container = renderWorkbench(null); - expect(container.textContent).toContain("先选择资料库"); + expect(container.textContent).toContain("先选择项目"); + expect(container.textContent).toContain( + "选择项目后,这里会承接本地图片上传、图片浏览,以及插入当前画布。", + ); }); it("应支持选中后插入当前画布", async () => { @@ -168,6 +171,7 @@ describe("ResourcesImageWorkbench", () => { previewUrl: "asset://city.jpg", contentUrl: "asset://city.jpg", title: "城市夜景", + attributionName: "项目资料", }), }), ); diff --git a/src/components/resources/ResourcesImageWorkbench.tsx b/src/components/resources/ResourcesImageWorkbench.tsx index 7eebfb73f..00ce3fe38 100644 --- a/src/components/resources/ResourcesImageWorkbench.tsx +++ b/src/components/resources/ResourcesImageWorkbench.tsx @@ -179,7 +179,7 @@ export function ResourcesImageWorkbench({ title: material.name, width: material.metadata?.width, height: material.metadata?.height, - attributionName: "项目素材库", + attributionName: "项目资料", provider: "gallery", }, }); @@ -220,10 +220,10 @@ export function ResourcesImageWorkbench({

- 先选择资料库 + 先选择项目

- 选择项目后,这里会统一承接本地图片上传、图片库浏览,以及插入当前画布。 + 选择项目后,这里会承接本地图片上传、图片浏览,以及插入当前画布。

diff --git a/src/components/resources/ResourcesPage.test.tsx b/src/components/resources/ResourcesPage.test.tsx index 51bb4439f..533f3a0e5 100644 --- a/src/components/resources/ResourcesPage.test.tsx +++ b/src/components/resources/ResourcesPage.test.tsx @@ -1,4 +1,4 @@ -import { act } from "react"; +import { act, type ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupMountedRoots, @@ -61,13 +61,13 @@ const { projects: [ { id: "project-1", - name: "默认资料库", + name: "默认项目", isArchived: false, }, ], defaultProject: { id: "project-1", - name: "默认资料库", + name: "默认项目", isArchived: false, }, }; @@ -136,8 +136,10 @@ vi.mock("./store", () => ({ const mountedRoots: MountedRoot[] = []; -function renderPage() { - return renderIntoDom(, mountedRoots).container; +function renderPage( + props?: Partial>, +) { + return renderIntoDom(, mountedRoots).container; } function getBodyText() { @@ -178,13 +180,13 @@ describe("ResourcesPage", () => { resourcesProjectsState.projects = [ { id: "project-1", - name: "默认资料库", + name: "默认项目", isArchived: false, }, ]; resourcesProjectsState.defaultProject = { id: "project-1", - name: "默认资料库", + name: "默认项目", isArchived: false, }; resourcesState.projectId = "project-1"; @@ -202,30 +204,75 @@ describe("ResourcesPage", () => { cleanupMountedRoots(mountedRoots); }); - it("应把资料库首屏说明收进 tips", async () => { + it("应把项目资料页说明收进 tips", async () => { renderPage(); await flushEffects(); expect(getBodyText()).not.toContain( - "集中管理导入资源、项目资料和外部素材;先把内容放进资料库,再决定哪些值得继续沉淀。", + "集中查看当前项目里的文档、图片和导入内容;继续开工时回生成,需要沉淀线索时去灵感库。", ); expect(getBodyText()).not.toContain( "在目录浏览和跨目录分类视图之间切换,快速定位不同类型内容。", ); - const heroTip = await hoverTip("资料库工作台说明"); + const heroTip = await hoverTip("项目资料页说明"); expect(getBodyText()).toContain( - "集中管理导入资源、项目资料和外部素材;先把内容放进资料库,再决定哪些值得继续沉淀。", + "集中查看当前项目里的文档、图片和导入内容;继续开工时回生成,需要沉淀线索时去灵感库。", ); await leaveTip(heroTip); - const categoryTip = await hoverTip("资料分类说明"); + const categoryTip = await hoverTip("内容分类说明"); expect(getBodyText()).toContain( "在目录浏览和跨目录分类视图之间切换,快速定位不同类型内容。", ); await leaveTip(categoryTip); }); + it("应显式提供回生成和灵感库的迁移按钮", async () => { + const onNavigate = vi.fn(); + const container = renderPage({ onNavigate }); + await flushEffects(); + + const callout = container.querySelector( + '[data-testid="resources-migration-callout"]', + ) as HTMLDivElement | null; + expect(callout).toBeTruthy(); + expect(callout?.textContent).toContain("项目资料只负责浏览、补图和整理"); + + const backToAgentButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("回生成")); + const backToMemoryButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("去灵感库")); + + expect(backToAgentButton).toBeTruthy(); + expect(backToMemoryButton).toBeTruthy(); + + await act(async () => { + backToAgentButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await Promise.resolve(); + }); + + await act(async () => { + backToMemoryButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + await Promise.resolve(); + }); + + expect(onNavigate).toHaveBeenNthCalledWith( + 1, + "agent", + expect.objectContaining({ + agentEntry: "new-task", + }), + ); + expect(onNavigate).toHaveBeenNthCalledWith(2, "memory"); + }); + it("切到图片分类后应挂载图片工作台", async () => { const container = renderPage(); await flushEffects(); @@ -251,7 +298,7 @@ describe("ResourcesPage", () => { resourcesProjectsState.projects = [ { id: "project-1", - name: "默认资料库", + name: "默认项目", isArchived: false, }, { @@ -288,7 +335,7 @@ describe("ResourcesPage", () => { }); expect(getBodyText()).toContain( - "当前资料库暂无图片,检测到「参考项目」包含 1 个图片。", + "当前项目暂无图片,检测到「参考项目」包含 1 个图片。", ); expect(getBodyText()).toContain("切换查看"); diff --git a/src/components/resources/ResourcesPage.tsx b/src/components/resources/ResourcesPage.tsx index 6b91f9e35..dc4e6b058 100644 --- a/src/components/resources/ResourcesPage.tsx +++ b/src/components/resources/ResourcesPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { + ArrowRight, ArrowUp, File, FileText, @@ -556,6 +557,10 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { onNavigate?.("agent", buildHomeAgentParams()); }, [onNavigate]); + const handleOpenInspiration = useCallback(() => { + onNavigate?.("memory"); + }, [onNavigate]); + const activeCategoryLabel = resourceCategoryLabelMap[viewCategory]; const ActiveCategoryIcon = resourceCategoryIconMap[viewCategory]; @@ -566,10 +571,10 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { const defaultScopeStatusDescription = useMemo(() => { if (!projectId) { - return "先在左侧选择一个项目资料库。"; + return "先在左侧选择一个项目。"; } if (!isFolderMode) { - return `跨目录查看当前资料库内的${activeCategoryLabel}内容。`; + return `跨目录查看当前项目里的${activeCategoryLabel}内容。`; } if (currentFolder && breadcrumbs.length > 0) { return `路径:根目录 / ${breadcrumbs.map((folder) => folder.name).join(" / ")}`; @@ -593,7 +598,7 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) { } const categoryLabel = mediaCategoryLabelMap[crossProjectMediaHint.category]; - return `当前资料库暂无${categoryLabel},检测到「${crossProjectMediaHint.projectName}」包含 ${crossProjectMediaHint.count} 个${categoryLabel}。`; + return `当前项目暂无${categoryLabel},检测到「${crossProjectMediaHint.projectName}」包含 ${crossProjectMediaHint.count} 个${categoryLabel}。`; }, [crossProjectMediaHint, defaultScopeStatusDescription]); const projectSummaryLabel = useMemo(() => { @@ -615,7 +620,7 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {
- +
@@ -625,11 +630,11 @@ export function ResourcesPage({ onNavigate }: ResourcesPageProps) {

- 资料库 + 项目资料

- {selectedProject?.name ?? "未选择资料库"} + {selectedProject?.name ?? "未选择项目"} {projectSummaryLabel && (
+
+
+
+
+ + 当前定位 + +

+ 项目资料只负责浏览、补图和整理 +

+
+

+ 继续开工时回生成,需要沉淀线索时去灵感库;这里专注当前项目内容的查看与切换。 +

+
+
+ + {onNavigate ? ( + + ) : null} +
+
+
+