mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
release: v1.19.0
This commit is contained in:
+46
-51
@@ -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`
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 697 KiB |
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 676 KiB |
@@ -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<AgentRuntimeListSessionsRequest>`
|
||||
- 该错误发生在 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 偶发卡顿,而不是本轮 `@命令` 收口引入的新结构性失败
|
||||
|
||||
@@ -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. 行为验收
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
@@ -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
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "1.18.0",
|
||||
"version": "1.19.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@limecloud/lime-cli",
|
||||
"version": "1.18.0",
|
||||
"version": "1.19.0",
|
||||
"description": "Lime 官方任务 CLI",
|
||||
"bin": {
|
||||
"lime": "scripts/run.js"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
Total messages: 59 (Errors: 0, Warnings: 1)
|
||||
Returning 0 messages for level "error"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 667 KiB |
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Generated
+18
-18
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) -> Option<String> {
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub provider_key: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offer_state: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quota_status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fallback_to_local_allowed: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub can_invoke: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionExecutionRuntimeSummary {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_count: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub routing_mode: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decision_source: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decision_reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub fallback_chain: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub estimated_cost_class: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub estimated_total_cost: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit_status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit_event_kind: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit_event_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capability_gap: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub single_candidate_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oem_locked: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quota_low: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionExecutionRuntime {
|
||||
pub session_id: String,
|
||||
@@ -446,6 +505,10 @@ pub struct SessionExecutionRuntime {
|
||||
pub cost_state: Option<SessionExecutionRuntimeCostState>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit_event: Option<SessionExecutionRuntimeLimitEvent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oem_policy: Option<SessionExecutionRuntimeOemPolicy>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_summary: Option<SessionExecutionRuntimeSummary>,
|
||||
}
|
||||
|
||||
fn resolve_session_token_usage(session: &Session) -> Option<crate::protocol::AgentTokenUsage> {
|
||||
@@ -903,6 +966,43 @@ fn extract_limit_event_from_metadata(
|
||||
Some(limit_event)
|
||||
}
|
||||
|
||||
fn extract_oem_policy_from_metadata(
|
||||
metadata: &std::collections::HashMap<String, Value>,
|
||||
) -> Option<SessionExecutionRuntimeOemPolicy> {
|
||||
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<String, Value>,
|
||||
) -> Option<SessionExecutionRuntimeSummary> {
|
||||
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]
|
||||
|
||||
@@ -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<i64>,
|
||||
pub messages_count: usize,
|
||||
pub execution_strategy: Option<String>,
|
||||
pub model: Option<String>,
|
||||
@@ -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<Vec<SessionInfo>, String> {
|
||||
pub fn list_sessions_sync(
|
||||
db: &DbConnection,
|
||||
include_archived: bool,
|
||||
) -> Result<Vec<SessionInfo>, 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();
|
||||
|
||||
Generated
+1
-1
@@ -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 = [
|
||||
|
||||
+1
-1
@@ -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 <filename>`\n - List files that contain a regex: `rg '<regex>' -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 <filename>`\n - List files that contain a regex: `rg '<regex>' -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}}
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct SessionRecordOverview {
|
||||
pub title: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub archived_at: Option<String>,
|
||||
pub working_dir: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub execution_strategy: Option<String>,
|
||||
@@ -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<Vec<SessionRecordOverview>, String> {
|
||||
AgentDao::list_session_overviews(conn)
|
||||
pub fn list_session_overviews(
|
||||
conn: &Connection,
|
||||
include_archived: bool,
|
||||
) -> Result<Vec<SessionRecordOverview>, 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,
|
||||
|
||||
@@ -468,6 +468,7 @@ pub struct AgentModelUsageRow {
|
||||
pub struct AgentSessionOverviewRow {
|
||||
pub session: AgentSession,
|
||||
pub messages_count: usize,
|
||||
pub archived_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -587,22 +588,26 @@ impl AgentDao {
|
||||
|
||||
pub fn list_session_overviews(
|
||||
conn: &Connection,
|
||||
include_archived: bool,
|
||||
) -> Result<Vec<AgentSessionOverviewRow>, 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<Option<AgentSessionOverviewRow>, 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::<String>::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();
|
||||
|
||||
@@ -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 消息表
|
||||
// 存储每个会话的消息历史
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -68,8 +68,11 @@ impl AsterAgentWrapper {
|
||||
}
|
||||
|
||||
/// 列出所有会话
|
||||
pub fn list_sessions_sync(db: &DbConnection) -> Result<Vec<SessionInfo>, String> {
|
||||
lime_agent::list_sessions_sync(db)
|
||||
pub fn list_sessions_sync(
|
||||
db: &DbConnection,
|
||||
include_archived: bool,
|
||||
) -> Result<Vec<SessionInfo>, 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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<AgentRuntimeListSessionsRequest>,
|
||||
) -> Result<Vec<SessionInfo>, 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()
|
||||
|
||||
@@ -603,14 +603,24 @@ pub struct AgentRuntimeThreadReadModel {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decision_source: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub decision_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_count: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fallback_chain: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub capability_gap: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub estimated_cost_class: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub single_candidate_only: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oem_policy: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_summary: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auxiliary_task_runtime: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub limit_state: Option<lime_agent::SessionExecutionRuntimeLimitState>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cost_state: Option<lime_agent::SessionExecutionRuntimeCostState>,
|
||||
@@ -618,6 +628,154 @@ pub struct AgentRuntimeThreadReadModel {
|
||||
pub limit_event: Option<lime_agent::SessionExecutionRuntimeLimitEvent>,
|
||||
}
|
||||
|
||||
fn extract_oem_policy_summary(
|
||||
runtime: Option<&lime_agent::SessionExecutionRuntime>,
|
||||
) -> Option<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<Vec<serde_json::Value>> {
|
||||
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::<serde_json::Value>(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::<Vec<_>>();
|
||||
|
||||
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<serde_json::Value>>,
|
||||
path: &[&str],
|
||||
) -> Option<String> {
|
||||
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<serde_json::Value>>,
|
||||
path: &[&str],
|
||||
) -> Option<u32> {
|
||||
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<serde_json::Value>>,
|
||||
path: &[&str],
|
||||
) -> Option<Vec<String>> {
|
||||
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::<Vec<_>>();
|
||||
(!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<AgentRuntimeActionScope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeListSessionsRequest {
|
||||
#[serde(default, alias = "includeArchived")]
|
||||
pub include_archived: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentRuntimeUpdateSessionRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
@@ -1864,6 +2157,8 @@ pub struct AgentRuntimeUpdateSessionRequest {
|
||||
pub model_name: Option<String>,
|
||||
#[serde(default, alias = "executionStrategy")]
|
||||
pub execution_strategy: Option<AsterExecutionStrategy>,
|
||||
#[serde(default, alias = "isArchived")]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(default, alias = "recentAccessMode")]
|
||||
pub recent_access_mode: Option<lime_agent::SessionExecutionRuntimeAccessMode>,
|
||||
#[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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<RuntimeAge
|
||||
}
|
||||
}
|
||||
|
||||
fn is_runtime_model_permission_denied_error(message: &str) -> 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<serde_json::Value> {
|
||||
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<F>(
|
||||
agent: &Agent,
|
||||
app: &AppHandle,
|
||||
state: &AsterAgentState,
|
||||
db: &DbConnection,
|
||||
request: &AsterChatRequest,
|
||||
timeline_recorder: &Arc<Mutex<AgentTimelineRecorder>>,
|
||||
@@ -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<Option<String>, 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::<ApiKeyProviderServiceState>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<SessionInfo>, String> {
|
||||
tracing::info!("[AsterAgent] 列出会话");
|
||||
AsterAgentWrapper::list_sessions_sync(db)
|
||||
AsterAgentWrapper::list_sessions_sync(db, include_archived)
|
||||
}
|
||||
|
||||
pub(crate) fn rename_runtime_session_internal(
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -480,6 +480,7 @@ fn canonicalize_image_task_alias_fields(record: &mut serde_json::Map<String, ser
|
||||
("anchorHint", "anchor_hint"),
|
||||
("anchorSectionTitle", "anchor_section_title"),
|
||||
("anchorText", "anchor_text"),
|
||||
("titleGenerationResult", "title_generation_result"),
|
||||
("targetOutputId", "target_output_id"),
|
||||
("targetOutputRefId", "target_output_ref_id"),
|
||||
("referenceImages", "reference_images"),
|
||||
@@ -525,6 +526,8 @@ struct ImageTaskInput {
|
||||
prompt: String,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default, alias = "title_generation_result")]
|
||||
title_generation_result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
mode: Option<String>,
|
||||
#[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",
|
||||
|
||||
@@ -44,6 +44,8 @@ pub struct CreateImageGenerationTaskArtifactRequest {
|
||||
pub prompt: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default, alias = "title_generation_result")]
|
||||
pub title_generation_result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -220,6 +222,15 @@ fn normalize_reference_images(reference_images: Vec<String>) -> Vec<String> {
|
||||
normalized
|
||||
}
|
||||
|
||||
fn normalize_optional_json_object(value: Option<serde_json::Value>) -> Option<serde_json::Value> {
|
||||
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<ImageStoryboardSlotInput>,
|
||||
) -> Vec<ImageStoryboardSlotInput> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
prompt: String,
|
||||
) -> Result<GeneratedPersonaCommandResult, String> {
|
||||
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
|
||||
|
||||
@@ -175,11 +175,15 @@ pub(super) async fn try_handle(
|
||||
)?
|
||||
}
|
||||
"agent_runtime_list_sessions" => {
|
||||
let args = args_or_default(args);
|
||||
let request: Option<crate::commands::aster_agent_cmd::AgentRuntimeListSessionsRequest> =
|
||||
parse_optional_nested_arg(&args, "request")?;
|
||||
let db = app_handle.state::<crate::database::DbConnection>();
|
||||
let logs = app_handle.state::<crate::app::LogState>();
|
||||
|
||||
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" => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
failure_modes: Vec<String>,
|
||||
suite_tags: Vec<String>,
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
PersonaGeneration {
|
||||
persona: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuxiliaryRuntimeProjectionInput {
|
||||
pub parent_session_id: String,
|
||||
pub auxiliary_session_id: String,
|
||||
pub execution_runtime: Option<SessionExecutionRuntime>,
|
||||
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<String>) -> Option<String> {
|
||||
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<String> {
|
||||
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<String, Value> {
|
||||
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<Option<String>, 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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::<Vec<_>>(),
|
||||
"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<String> {
|
||||
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("已完成后端导出链路"));
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
<AppSidebar
|
||||
currentPage="agent"
|
||||
currentPageParams={
|
||||
{
|
||||
agentEntry: "claw",
|
||||
projectId: "project-2",
|
||||
} as AgentPageParams
|
||||
}
|
||||
onNavigate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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,
|
||||
|
||||
+708
-218
File diff suppressed because it is too large
Load Diff
@@ -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<LayoutMode>(
|
||||
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<TaskCenterWorkspaceTabMap>(() =>
|
||||
normalizeTaskCenterWorkspaceTabMap(
|
||||
loadPersisted<unknown>(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<TaskCenterTabItem[]>(
|
||||
() =>
|
||||
taskCenterOpenTabIds
|
||||
.map((topicId) => topics.find((topic) => topic.id === topicId))
|
||||
.filter((topic): topic is NonNullable<typeof topic> => 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 (
|
||||
<TaskCenterTabStrip
|
||||
items={taskCenterTabItems}
|
||||
onSelectTask={(topicId) => {
|
||||
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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runtimeDecisionReason || fallbackChain.length > 0 || oemPolicy ? (
|
||||
<div className="mt-4 rounded-2xl border border-sky-200 bg-sky-50/80 px-4 py-3">
|
||||
<div className="text-sm font-medium text-foreground">当前路由事实</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-slate-700">
|
||||
{runtimeDecisionReason ? (
|
||||
<div>
|
||||
<span className="font-medium text-foreground">决策原因:</span>
|
||||
{runtimeDecisionReason}
|
||||
</div>
|
||||
) : null}
|
||||
{fallbackChain.length > 0 ? (
|
||||
<div>
|
||||
<span className="font-medium text-foreground">回退链:</span>
|
||||
{fallbackChain.join(" → ")}
|
||||
</div>
|
||||
) : null}
|
||||
{oemPolicy ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(oemPolicy as { locked?: boolean | null }).locked ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-300 bg-white text-amber-700"
|
||||
>
|
||||
OEM 托管锁定
|
||||
</Badge>
|
||||
) : null}
|
||||
{(oemPolicy as { quotaLow?: boolean | null }).quotaLow ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 bg-white text-orange-700"
|
||||
>
|
||||
OEM 额度偏低
|
||||
</Badge>
|
||||
) : null}
|
||||
{((oemPolicy as { defaultModel?: string | null }).defaultModel ||
|
||||
(oemPolicy as { selectedModel?: string | null }).selectedModel) ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
OEM 模型 {((oemPolicy as { defaultModel?: string | null }).defaultModel ||
|
||||
(oemPolicy as { selectedModel?: string | null }).selectedModel)!}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(canInterrupt && onInterruptCurrentTurn) ||
|
||||
(view.pendingRequests.length > 0 && onReplayPendingRequest) ||
|
||||
(view.nextQueuedTurn && onResumeThread) ||
|
||||
|
||||
@@ -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: () => <div>unused-team-preview</div>,
|
||||
|
||||
@@ -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({
|
||||
<div data-testid="canvas-workbench-panel-team" className="p-5">
|
||||
<div className={WORKBENCH_MUTED_PANEL_CLASSNAME}>
|
||||
{teamView?.panelCopy?.emptyText ||
|
||||
"当前没有可展示的生成工作台。"}
|
||||
"当前没有可展示的生成结果。"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 面板显隐", () => {
|
||||
|
||||
@@ -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<ChatNavbarProps> = ({
|
||||
isRunning: _isRunning,
|
||||
chrome = "full",
|
||||
collapseChrome = false,
|
||||
contextVariant = "default",
|
||||
entryContextLabel,
|
||||
entryContextHint,
|
||||
onToggleHistory,
|
||||
@@ -85,40 +99,170 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
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 (
|
||||
<Navbar
|
||||
$compact
|
||||
$collapsed={false}
|
||||
$taskCenter
|
||||
data-testid="task-center-workspace-bar"
|
||||
>
|
||||
<div className={taskCenterChromeShellClassName}>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<ProjectSelector
|
||||
value={projectId}
|
||||
onChange={(nextProjectId) => 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]"
|
||||
/>
|
||||
<div className="ml-1 flex h-7 items-center border-l border-slate-200/80 pl-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={taskCenterIconButtonClassName}
|
||||
onClick={() => {
|
||||
setWorkspaceSelectorOpen((current) => !current);
|
||||
}}
|
||||
aria-label={workspaceSelectorOpen ? "收起工作区菜单" : "展开工作区菜单"}
|
||||
aria-expanded={workspaceSelectorOpen}
|
||||
title={workspaceSelectorOpen ? "收起工作区菜单" : "展开工作区菜单"}
|
||||
data-testid="task-center-workspace-menu-trigger"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0">
|
||||
{showContextCompactionAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={taskCenterIconButtonClassName}
|
||||
onClick={onCompactContext}
|
||||
disabled={contextCompactionRunning}
|
||||
aria-label={
|
||||
contextCompactionRunning ? "正在压缩上下文" : "压缩上下文"
|
||||
}
|
||||
title={contextCompactionRunning ? "正在压缩上下文" : "压缩上下文"}
|
||||
>
|
||||
<Box size={15} />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{showHarnessToggle ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
taskCenterPillButtonClassName,
|
||||
"gap-1 px-2.5",
|
||||
harnessPanelVisible && "bg-white text-slate-900",
|
||||
harnessAttentionLevel === "warning" &&
|
||||
!harnessPanelVisible &&
|
||||
"bg-amber-50/90 text-amber-800 hover:bg-amber-100 hover:text-amber-900",
|
||||
)}
|
||||
onClick={onToggleHarnessPanel}
|
||||
aria-label={
|
||||
harnessPanelVisible
|
||||
? `收起${harnessToggleLabel}`
|
||||
: `展开${harnessToggleLabel}`
|
||||
}
|
||||
aria-expanded={harnessPanelVisible}
|
||||
title={
|
||||
harnessPanelVisible
|
||||
? `收起${harnessToggleLabel}`
|
||||
: `展开${harnessToggleLabel}`
|
||||
}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
<span>{harnessToggleLabel}</span>
|
||||
{harnessPendingCount > 0 ? (
|
||||
<span className="rounded-full border border-emerald-200 bg-white px-1.5 py-0.5 text-[10px] font-medium leading-none text-emerald-700">
|
||||
{harnessPendingCount > 99 ? "99+" : harnessPendingCount}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 transition-transform",
|
||||
harnessPanelVisible && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{onToggleSettings ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={taskCenterIconButtonClassName}
|
||||
onClick={onToggleSettings}
|
||||
aria-label="打开设置"
|
||||
title="打开设置"
|
||||
>
|
||||
<Settings2 size={16} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Navbar $compact={isWorkspaceCompact}>
|
||||
<Navbar $compact={isWorkspaceCompact} $collapsed={effectiveCollapseChrome}>
|
||||
<div className="flex items-center gap-2">
|
||||
{showNavigationTools ? (
|
||||
<div className={groupClassName}>
|
||||
@@ -214,7 +358,7 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="inline-flex w-fit items-center rounded-full border border-slate-200/80 bg-white px-3 py-1 text-[11px] font-medium text-slate-600 shadow-sm shadow-slate-950/5">
|
||||
<span className="inline-flex w-fit items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-[11px] font-medium text-slate-600">
|
||||
{entryContextLabel}
|
||||
</span>
|
||||
{!isWorkspaceCompact && entryContextHint ? (
|
||||
@@ -244,17 +388,21 @@ export const ChatNavbar: React.FC<ChatNavbarProps> = ({
|
||||
chrome="embedded"
|
||||
className={compactProjectSelectorClassName}
|
||||
/>
|
||||
<div className={dividerClassName} aria-hidden="true" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ghostIconButtonClassName}
|
||||
onClick={onToggleSettings}
|
||||
aria-label="打开设置"
|
||||
title="打开设置"
|
||||
>
|
||||
<Settings2 size={18} />
|
||||
</Button>
|
||||
{onToggleSettings ? (
|
||||
<>
|
||||
<div className={dividerClassName} aria-hidden="true" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ghostIconButtonClassName}
|
||||
onClick={onToggleSettings}
|
||||
aria-label="打开设置"
|
||||
title="打开设置"
|
||||
>
|
||||
<Settings2 size={18} />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -5,17 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import type { Topic } from "../hooks/agentChatShared";
|
||||
import type { Message } from "../types";
|
||||
import type { Project } from "@/lib/api/project";
|
||||
|
||||
const { mockGetProject } = vi.hoisted(() => ({
|
||||
mockGetProject: vi.fn<(projectId: string) => Promise<Project | null>>(
|
||||
async () => null,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/project", () => ({
|
||||
getProject: (projectId: string) => mockGetProject(projectId),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/badge", () => ({
|
||||
Badge: ({
|
||||
@@ -61,8 +50,6 @@ beforeEach(() => {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
}
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
mockGetProject.mockReset();
|
||||
mockGetProject.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -86,7 +73,7 @@ const defaultTopics: Topic[] = [
|
||||
messagesCount: 2,
|
||||
executionStrategy: "auto",
|
||||
status: "done",
|
||||
lastPreview: "已记录 2 条消息,可继续补充或复盘。",
|
||||
lastPreview: "已记录 2 条消息,可继续补充或接着推进。",
|
||||
isPinned: false,
|
||||
hasUnread: false,
|
||||
tag: null,
|
||||
@@ -144,54 +131,80 @@ describe("ChatSidebar", () => {
|
||||
expect(container.textContent).toContain("任务一");
|
||||
});
|
||||
|
||||
it("生成侧栏空态应展示执行面文案", () => {
|
||||
it("任务中心侧栏空态应展示最近对话文案和新建入口", () => {
|
||||
const container = renderSidebar({
|
||||
contextVariant: "task-center",
|
||||
topics: [],
|
||||
currentTopicId: null,
|
||||
});
|
||||
const searchInput = container.querySelector(
|
||||
'input[placeholder="搜索生成标题、结果或摘要"]',
|
||||
'input[placeholder="搜索对话标题或摘要"]',
|
||||
) as HTMLInputElement | null;
|
||||
|
||||
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(searchInput).toBeTruthy();
|
||||
expect(container.textContent).toContain("开始生成");
|
||||
expect(container.textContent).toContain("全部记录");
|
||||
expect(container.textContent).toContain("仅看待继续");
|
||||
expect(container.textContent).toContain("还没有待继续的生成记录");
|
||||
expect(
|
||||
container.querySelector('button[aria-label="新建对话"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("全部对话");
|
||||
expect(container.textContent).toContain("待继续");
|
||||
expect(container.textContent).toContain("还没有最近对话");
|
||||
expect(container.textContent).toContain(
|
||||
"从“开始生成”发起也很自然,开始后会回到这里继续生成。",
|
||||
"从“新建对话”开始后,最近对话会自动出现在这里。",
|
||||
);
|
||||
});
|
||||
|
||||
it("生成侧栏应在顶部展示最近会话恢复与最近结果入口", async () => {
|
||||
mockGetProject.mockImplementation(async (projectId: string) => {
|
||||
if (projectId === "project-waiting") {
|
||||
return {
|
||||
id: projectId,
|
||||
name: "品牌项目",
|
||||
} as Project;
|
||||
}
|
||||
if (projectId === "project-recent") {
|
||||
return {
|
||||
id: projectId,
|
||||
name: "活动项目",
|
||||
} as Project;
|
||||
}
|
||||
return null;
|
||||
it("任务中心导航块应支持入口跳转", () => {
|
||||
const onOpenTaskCenterHome = vi.fn();
|
||||
const onOpenSkillsPage = vi.fn();
|
||||
const onOpenMemoryPage = vi.fn();
|
||||
const container = renderSidebar({
|
||||
contextVariant: "task-center",
|
||||
onOpenTaskCenterHome,
|
||||
onOpenSkillsPage,
|
||||
onOpenMemoryPage,
|
||||
});
|
||||
|
||||
const onResumeTask = vi.fn();
|
||||
const onSwitchTopic = vi.fn();
|
||||
act(() => {
|
||||
(
|
||||
Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("新建任务"),
|
||||
) as HTMLButtonElement | undefined
|
||||
)?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
(
|
||||
Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("我的方法"),
|
||||
) as HTMLButtonElement | undefined
|
||||
)?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
(
|
||||
Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("灵感库"),
|
||||
) as HTMLButtonElement | undefined
|
||||
)?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onOpenTaskCenterHome).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenSkillsPage).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenMemoryPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("任务中心侧栏不应再在顶部重复展示继续最近会话卡", () => {
|
||||
const now = Date.now();
|
||||
const container = renderSidebar({
|
||||
contextVariant: "task-center",
|
||||
onResumeTask,
|
||||
onSwitchTopic,
|
||||
currentTopicId: null,
|
||||
topics: [
|
||||
{
|
||||
@@ -208,58 +221,23 @@ describe("ChatSidebar", () => {
|
||||
{
|
||||
...defaultTopics[0],
|
||||
id: "topic-recent",
|
||||
title: "最近回访任务",
|
||||
title: "最近对话任务",
|
||||
updatedAt: new Date(now - 2_000),
|
||||
status: "done",
|
||||
lastPreview: "首版结果已经产出,可继续补充和复盘。",
|
||||
workspaceId: "project-recent",
|
||||
sourceSessionId: "topic-recent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const panel = container.querySelector(
|
||||
'[data-testid="task-center-continuation-panel"]',
|
||||
) as HTMLElement | null;
|
||||
expect(panel).toBeTruthy();
|
||||
expect(panel?.textContent).toContain("继续最近会话");
|
||||
expect(panel?.textContent).toContain(
|
||||
"最近一次停在哪、当前在等什么,这里会直接带你回到现场。",
|
||||
);
|
||||
expect(panel?.textContent).toContain("待继续任务");
|
||||
expect(panel?.textContent).toContain("品牌项目");
|
||||
expect(panel?.textContent).toContain("最近回访任务");
|
||||
expect(panel?.textContent).toContain("活动项目");
|
||||
expect(panel?.textContent).toContain("继续最近会话");
|
||||
|
||||
const primaryButton = container.querySelector(
|
||||
'[data-testid="task-center-primary-continuation"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(primaryButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
primaryButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onResumeTask).toHaveBeenCalledWith("topic-waiting", "user_action");
|
||||
|
||||
const relatedButton = container.querySelector(
|
||||
'[data-testid="task-center-related-topic-recent"]',
|
||||
) as HTMLButtonElement | null;
|
||||
expect(relatedButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
relatedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSwitchTopic).toHaveBeenCalledWith("topic-recent");
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-center-continuation-panel"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("待继续任务");
|
||||
expect(container.textContent).toContain("最近对话任务");
|
||||
});
|
||||
|
||||
it("生成侧栏应使用回访型任务分组标题", () => {
|
||||
it("任务中心侧栏应使用对话与归档分组标题", () => {
|
||||
const now = Date.now();
|
||||
const container = renderSidebar({
|
||||
contextVariant: "task-center",
|
||||
@@ -285,7 +263,7 @@ describe("ChatSidebar", () => {
|
||||
{
|
||||
...defaultTopics[0],
|
||||
id: "topic-recent",
|
||||
title: "最近回访任务",
|
||||
title: "最近对话任务",
|
||||
updatedAt: new Date(now - 2_000),
|
||||
status: "done",
|
||||
sourceSessionId: "topic-recent",
|
||||
@@ -301,13 +279,13 @@ describe("ChatSidebar", () => {
|
||||
],
|
||||
});
|
||||
|
||||
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("归档");
|
||||
});
|
||||
|
||||
it("生成侧栏 continuation fallback 应使用打开最近会话口径", async () => {
|
||||
it("任务中心侧栏不应再显示 continuation fallback 文案", async () => {
|
||||
const container = renderSidebar({
|
||||
contextVariant: "task-center",
|
||||
currentTopicId: null,
|
||||
@@ -328,7 +306,7 @@ describe("ChatSidebar", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("打开最近会话");
|
||||
expect(container.textContent).not.toContain("打开最近会话");
|
||||
});
|
||||
|
||||
it("子任务和任务列表应处于同一滚动区域", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowUpLeft,
|
||||
BrainCircuit,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
@@ -13,7 +14,9 @@ import {
|
||||
PinOff,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -24,7 +27,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getProject } from "@/lib/api/project";
|
||||
import type {
|
||||
AsterSubagentParentContext,
|
||||
AsterSubagentSessionInfo,
|
||||
@@ -32,7 +34,6 @@ import type {
|
||||
import {
|
||||
deriveTaskLiveState,
|
||||
extractTaskPreviewFromMessages,
|
||||
resolveRecentTopicActionLabel,
|
||||
type Topic,
|
||||
type TaskStatus,
|
||||
type TaskStatusReason,
|
||||
@@ -118,14 +119,12 @@ interface TaskSection {
|
||||
items: TaskCardViewModel[];
|
||||
}
|
||||
|
||||
interface TaskCenterContinuationState {
|
||||
primary: TaskCardViewModel | null;
|
||||
related: TaskCardViewModel[];
|
||||
}
|
||||
|
||||
interface ChatSidebarProps {
|
||||
contextVariant?: ChatSidebarContextVariant;
|
||||
onNewChat: () => void;
|
||||
onOpenTaskCenterHome?: () => void;
|
||||
onOpenSkillsPage?: () => void;
|
||||
onOpenMemoryPage?: () => void;
|
||||
topics: Topic[];
|
||||
currentTopicId: string | null;
|
||||
onSwitchTopic: (topicId: string) => void | Promise<void>;
|
||||
@@ -147,6 +146,19 @@ interface ChatSidebarProps {
|
||||
onReturnToParentSession?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface TaskCenterNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface TaskCenterNavSection {
|
||||
id: string;
|
||||
title: string;
|
||||
items: TaskCenterNavItem[];
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date) {
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60)));
|
||||
@@ -198,78 +210,6 @@ function sortTaskItems(items: TaskCardViewModel[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function isResumableTask(item: Pick<TaskCardViewModel, "status" | "statusReason">) {
|
||||
return (
|
||||
item.status === "waiting" ||
|
||||
(item.status === "failed" && item.statusReason === "workspace_error")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTaskCenterContinuationState(
|
||||
items: TaskCardViewModel[],
|
||||
currentTopicId: string | null,
|
||||
): TaskCenterContinuationState {
|
||||
const sortedItems = sortTaskItems(items);
|
||||
const resumableItems = sortedItems.filter((item) => isResumableTask(item));
|
||||
const recentDoneItems = sortedItems.filter((item) => item.status === "done");
|
||||
const currentItem =
|
||||
sortedItems.find((item) => item.id === currentTopicId) ?? null;
|
||||
const primary =
|
||||
(currentItem && isResumableTask(currentItem) ? currentItem : null) ??
|
||||
resumableItems[0] ??
|
||||
recentDoneItems[0] ??
|
||||
currentItem ??
|
||||
sortedItems[0] ??
|
||||
null;
|
||||
|
||||
const related = [...resumableItems, ...recentDoneItems]
|
||||
.filter((item) => item.id !== primary?.id)
|
||||
.filter(
|
||||
(item, index, array) =>
|
||||
array.findIndex((candidate) => candidate.id === item.id) === index,
|
||||
)
|
||||
.slice(0, 3);
|
||||
|
||||
return {
|
||||
primary,
|
||||
related,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTaskCenterContinuationBadge(
|
||||
item: TaskCardViewModel,
|
||||
): string {
|
||||
if (isResumableTask(item)) {
|
||||
return "等你继续";
|
||||
}
|
||||
if (item.status === "done") {
|
||||
return "最近结果";
|
||||
}
|
||||
if (item.status === "running") {
|
||||
return "正在推进";
|
||||
}
|
||||
return "生成现场";
|
||||
}
|
||||
|
||||
function resolveTaskCenterContinuationActionLabel(
|
||||
item: TaskCardViewModel,
|
||||
): string {
|
||||
return resolveRecentTopicActionLabel(item);
|
||||
}
|
||||
|
||||
function areProjectNameMapsEqual(
|
||||
left: Record<string, string>,
|
||||
right: Record<string, string>,
|
||||
): boolean {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return leftKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
function loadPinnedTaskIds() {
|
||||
if (typeof window === "undefined") {
|
||||
return [] as string[];
|
||||
@@ -396,10 +336,10 @@ function buildTaskSections(
|
||||
const titleSet =
|
||||
contextVariant === "task-center"
|
||||
? {
|
||||
running: "正在推进",
|
||||
waiting: "等你继续",
|
||||
recent: "最近回访",
|
||||
older: "更早记录",
|
||||
running: "进行中",
|
||||
waiting: "待继续",
|
||||
recent: "最近对话",
|
||||
older: "归档",
|
||||
}
|
||||
: {
|
||||
running: "进行中",
|
||||
@@ -571,6 +511,9 @@ function buildCollapsedTeamSummary(
|
||||
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
contextVariant = "default",
|
||||
onNewChat,
|
||||
onOpenTaskCenterHome,
|
||||
onOpenSkillsPage,
|
||||
onOpenMemoryPage,
|
||||
topics,
|
||||
currentTopicId,
|
||||
onSwitchTopic,
|
||||
@@ -677,34 +620,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
() => taskItems.find((item) => item.id === currentTopicId) ?? null,
|
||||
[currentTopicId, taskItems],
|
||||
);
|
||||
const taskCenterContinuationState = useMemo(
|
||||
() =>
|
||||
contextVariant === "task-center"
|
||||
? resolveTaskCenterContinuationState(taskItems, currentTopicId)
|
||||
: { primary: null, related: [] },
|
||||
[contextVariant, currentTopicId, taskItems],
|
||||
);
|
||||
const continuationProjectIds = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
[
|
||||
taskCenterContinuationState.primary,
|
||||
...taskCenterContinuationState.related,
|
||||
]
|
||||
.map((item) => item?.workspaceId?.trim())
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
),
|
||||
[taskCenterContinuationState],
|
||||
);
|
||||
const continuationProjectIdsKey = useMemo(
|
||||
() => continuationProjectIds.join("|"),
|
||||
[continuationProjectIds],
|
||||
);
|
||||
const [continuationProjectNames, setContinuationProjectNames] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const sortedChildSubagentSessions = useMemo(
|
||||
() => sortSubagentSessionsByPriority(childSubagentSessions),
|
||||
[childSubagentSessions],
|
||||
@@ -799,36 +714,87 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
() => buildTaskSections(filteredTaskItems, contextVariant),
|
||||
[contextVariant, filteredTaskItems],
|
||||
);
|
||||
const isTaskCenter = contextVariant === "task-center";
|
||||
const hasAnyTasks = topics.length > 0;
|
||||
const hasFilteredResults = filteredTaskItems.length > 0;
|
||||
const taskHeadingLabel =
|
||||
contextVariant === "task-center" ? "生成现场" : "任务";
|
||||
const taskHeadingLabel = isTaskCenter ? "最近对话" : "任务";
|
||||
const taskHeadingHint =
|
||||
contextVariant === "task-center"
|
||||
? "在这里继续推进当前创作、回看最近结果和旧历史。"
|
||||
: null;
|
||||
isTaskCenter ? "继续最近对话,待处理会话会优先显示在前面。" : null;
|
||||
const searchPlaceholder =
|
||||
contextVariant === "task-center"
|
||||
? "搜索生成标题、结果或摘要"
|
||||
: "搜索任务标题或摘要";
|
||||
const newChatLabel =
|
||||
contextVariant === "task-center" ? "开始生成" : "新建任务";
|
||||
const allTasksLabel =
|
||||
contextVariant === "task-center" ? "全部记录" : "全部任务";
|
||||
const activeTasksLabel =
|
||||
contextVariant === "task-center" ? "仅看待继续" : "仅看进行中";
|
||||
isTaskCenter ? "搜索对话标题或摘要" : "搜索任务标题或摘要";
|
||||
const newChatLabel = isTaskCenter ? "新建对话" : "新建任务";
|
||||
const allTasksLabel = isTaskCenter ? "全部对话" : "全部任务";
|
||||
const activeTasksLabel = isTaskCenter ? "待继续" : "仅看进行中";
|
||||
const emptyStateTitle =
|
||||
contextVariant === "task-center"
|
||||
? "还没有待继续的生成记录"
|
||||
: "还没有任务";
|
||||
isTaskCenter ? "还没有最近对话" : "还没有任务";
|
||||
const emptyStateDescription =
|
||||
contextVariant === "task-center"
|
||||
? "从“开始生成”发起也很自然,开始后会回到这里继续生成。"
|
||||
isTaskCenter
|
||||
? "从“新建对话”开始后,最近对话会自动出现在这里。"
|
||||
: "从“新建任务”开始输入需求,创建后会出现在这里。";
|
||||
const noResultsTitle = isTaskCenter ? "没有匹配的对话" : "没有匹配的任务";
|
||||
const noResultsDescription = isTaskCenter
|
||||
? "试试搜索对话标题、摘要或状态关键词。"
|
||||
: "试试搜索标题、执行摘要或状态关键词。";
|
||||
const olderSectionMoreLabel =
|
||||
contextVariant === "task-center"
|
||||
? "查看更多旧生成历史"
|
||||
: "查看更多历史任务";
|
||||
isTaskCenter ? "查看更多归档对话" : "查看更多历史任务";
|
||||
const taskCountLabel = searchKeyword.trim()
|
||||
? `${filteredTaskItems.length} 个结果`
|
||||
: `${topics.length} 个${isTaskCenter ? "对话" : "任务"}`;
|
||||
const sidebarShellClassName = isTaskCenter
|
||||
? "w-[308px] shrink-0 overflow-hidden rounded-[30px] border border-slate-200 bg-[linear-gradient(180deg,#fbfcfd_0%,#f3f6f8_100%)] shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-[#111318]"
|
||||
: "w-[308px] shrink-0 overflow-hidden rounded-[30px] border border-emerald-200/40 bg-[linear-gradient(180deg,rgba(252,254,252,0.98)_0%,rgba(247,251,248,0.92)_100%)] shadow-sm shadow-slate-950/5 backdrop-blur dark:border-white/10 dark:bg-[#111318]";
|
||||
const searchInputClassName = isTaskCenter
|
||||
? "h-11 w-full rounded-[18px] border border-slate-200 bg-white pl-9 pr-3 text-sm text-slate-700 shadow-sm shadow-slate-950/5 outline-none transition focus:border-slate-300 focus:ring-2 focus:ring-slate-100 dark:border-white/10 dark:bg-white/5 dark:text-slate-100 dark:focus:border-white/20 dark:focus:ring-white/10"
|
||||
: "h-11 w-full rounded-[18px] border border-emerald-200/40 bg-white/90 pl-9 pr-3 text-sm text-slate-700 shadow-sm shadow-slate-950/5 outline-none transition focus:border-emerald-300/60 focus:ring-2 focus:ring-emerald-100/50 dark:border-white/10 dark:bg-white/5 dark:text-slate-100 dark:focus:border-white/20 dark:focus:ring-white/10";
|
||||
const taskCenterHeaderCardClassName =
|
||||
"rounded-[24px] border border-slate-200 bg-white px-3.5 py-3.5 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5";
|
||||
const filterGroupClassName = isTaskCenter
|
||||
? "flex flex-wrap items-center gap-2 rounded-[20px] border border-slate-200 bg-slate-50/80 p-2 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5"
|
||||
: "flex flex-wrap items-center gap-2 rounded-[22px] border border-white/85 bg-white/72 p-2 shadow-sm shadow-slate-950/5";
|
||||
const inactiveFilterClassName = isTaskCenter
|
||||
? "border-slate-200 bg-white text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300"
|
||||
: "border-slate-200/80 bg-white/90 text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300";
|
||||
const taskCenterNavSections = useMemo<TaskCenterNavSection[]>(
|
||||
() => [
|
||||
{
|
||||
id: "tasks",
|
||||
title: "任务",
|
||||
items: [
|
||||
{
|
||||
id: "new-task",
|
||||
label: "新建任务",
|
||||
icon: Plus,
|
||||
onClick: onOpenTaskCenterHome ?? onNewChat,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "capabilities",
|
||||
title: "能力",
|
||||
items: [
|
||||
{
|
||||
id: "skills",
|
||||
label: "我的方法",
|
||||
icon: Sparkles,
|
||||
onClick: onOpenSkillsPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knowledge",
|
||||
title: "资料",
|
||||
items: [
|
||||
{
|
||||
id: "memory",
|
||||
label: "灵感库",
|
||||
icon: BrainCircuit,
|
||||
onClick: onOpenMemoryPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[onNewChat, onOpenMemoryPage, onOpenSkillsPage, onOpenTaskCenterHome],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingTopicId && editInputRef.current) {
|
||||
@@ -872,55 +838,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
);
|
||||
}, [pinnedTaskIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
contextVariant !== "task-center" ||
|
||||
continuationProjectIds.length === 0
|
||||
) {
|
||||
setContinuationProjectNames((current) =>
|
||||
Object.keys(current).length > 0 ? {} : current,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadProjectNames = async () => {
|
||||
const entries = await Promise.all(
|
||||
continuationProjectIds.map(async (projectId) => {
|
||||
try {
|
||||
const project = await getProject(projectId);
|
||||
return [projectId, project?.name?.trim() || null] as const;
|
||||
} catch {
|
||||
return [projectId, null] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextProjectNames = Object.fromEntries(
|
||||
entries.filter(
|
||||
(entry): entry is readonly [string, string] => Boolean(entry[1]),
|
||||
),
|
||||
);
|
||||
|
||||
setContinuationProjectNames((current) =>
|
||||
areProjectNameMapsEqual(current, nextProjectNames)
|
||||
? current
|
||||
: nextProjectNames,
|
||||
);
|
||||
};
|
||||
|
||||
void loadProjectNames();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [contextVariant, continuationProjectIds, continuationProjectIdsKey]);
|
||||
|
||||
const handleDeleteClick = (topicId: string) => {
|
||||
onDeleteTopic(topicId);
|
||||
};
|
||||
@@ -947,24 +864,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
void onSwitchTopic(item.id);
|
||||
};
|
||||
|
||||
const handleOpenContinuationTask = (item: TaskCardViewModel) => {
|
||||
if (isResumableTask(item)) {
|
||||
handleResumeTask(item);
|
||||
return;
|
||||
}
|
||||
|
||||
void onSwitchTopic(item.id);
|
||||
};
|
||||
|
||||
const resolveTaskProjectLabel = (item: TaskCardViewModel) => {
|
||||
const projectId = item.workspaceId?.trim();
|
||||
if (!projectId) {
|
||||
return "通用任务";
|
||||
}
|
||||
|
||||
return continuationProjectNames[projectId] || "当前项目";
|
||||
};
|
||||
|
||||
const handleJumpToTaskSection = () => {
|
||||
setTeamSectionCollapsedOverride(true);
|
||||
setShowAllChildSubagents(false);
|
||||
@@ -1071,7 +970,7 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="w-[308px] shrink-0 overflow-hidden rounded-[30px] border border-emerald-200/40 bg-[linear-gradient(180deg,rgba(252,254,252,0.98)_0%,rgba(247,251,248,0.92)_100%)] shadow-sm shadow-slate-950/5 backdrop-blur dark:border-white/10 dark:bg-[#111318]"
|
||||
className={sidebarShellClassName}
|
||||
data-testid="chat-sidebar"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
|
||||
@@ -1083,45 +982,145 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
value={searchKeyword}
|
||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-11 w-full rounded-[18px] border border-emerald-200/40 bg-white/90 pl-9 pr-3 text-sm text-slate-700 shadow-sm shadow-slate-950/5 outline-none transition focus:border-emerald-300/60 focus:ring-2 focus:ring-emerald-100/50 dark:border-white/10 dark:bg-white/5 dark:text-slate-100 dark:focus:border-white/20 dark:focus:ring-white/10"
|
||||
className={searchInputClassName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className={CHAT_SIDEBAR_PRIMARY_ACTION_BUTTON_CLASSNAME}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{newChatLabel}
|
||||
</button>
|
||||
{isTaskCenter ? (
|
||||
<>
|
||||
<section className={taskCenterHeaderCardClassName}>
|
||||
{taskCenterNavSections.map((section, sectionIndex) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className={cn(
|
||||
sectionIndex > 0
|
||||
? "mt-3 border-t border-slate-200/80 pt-3 dark:border-white/10"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<div className="px-1 text-[11px] font-semibold tracking-[0.12em] text-slate-500 dark:text-slate-400">
|
||||
{section.title}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{section.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => item.onClick?.()}
|
||||
disabled={!item.onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2.5 text-left text-sm font-medium text-slate-700 transition dark:border-white/10 dark:bg-white/5 dark:text-slate-200",
|
||||
item.onClick
|
||||
? "hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:hover:bg-white/10 dark:hover:text-slate-100"
|
||||
: "cursor-default opacity-70",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-[22px] border border-white/85 bg-white/72 p-2 shadow-sm shadow-slate-950/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter("all")}
|
||||
className={cn(
|
||||
"inline-flex h-9 flex-1 items-center justify-center rounded-2xl border px-2 text-xs font-medium transition",
|
||||
statusFilter === "all"
|
||||
? CHAT_SIDEBAR_ACTIVE_FILTER_CLASSNAME
|
||||
: "border-slate-200/80 bg-white/90 text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{allTasksLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter("active")}
|
||||
className={cn(
|
||||
"inline-flex h-9 flex-1 items-center justify-center rounded-2xl border px-2 text-xs font-medium transition",
|
||||
statusFilter === "active"
|
||||
? CHAT_SIDEBAR_ACTIVE_FILTER_CLASSNAME
|
||||
: "border-slate-200/80 bg-white/90 text-slate-500 hover:border-slate-300 hover:bg-white hover:text-slate-800 dark:border-white/10 dark:bg-white/5 dark:text-slate-300",
|
||||
)}
|
||||
>
|
||||
{activeTasksLabel}
|
||||
</button>
|
||||
</div>
|
||||
<section className={taskCenterHeaderCardClassName}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||
{taskHeadingLabel}
|
||||
</div>
|
||||
{taskHeadingHint ? (
|
||||
<p className="mt-1 text-[11px] leading-5 text-slate-500 dark:text-slate-400">
|
||||
{taskHeadingHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={newChatLabel}
|
||||
title={newChatLabel}
|
||||
onClick={onNewChat}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-600 transition hover:border-slate-300 hover:bg-white hover:text-slate-900 dark:border-white/10 dark:bg-white/10 dark:text-slate-200 dark:hover:bg-white/15"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={cn("mt-3", filterGroupClassName)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter("all")}
|
||||
className={cn(
|
||||
"inline-flex h-9 flex-1 items-center justify-center rounded-2xl border px-2 text-xs font-medium transition",
|
||||
statusFilter === "all"
|
||||
? CHAT_SIDEBAR_ACTIVE_FILTER_CLASSNAME
|
||||
: inactiveFilterClassName,
|
||||
)}
|
||||
>
|
||||
{allTasksLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter("active")}
|
||||
className={cn(
|
||||
"inline-flex h-9 flex-1 items-center justify-center rounded-2xl border px-2 text-xs font-medium transition",
|
||||
statusFilter === "active"
|
||||
? CHAT_SIDEBAR_ACTIVE_FILTER_CLASSNAME
|
||||
: inactiveFilterClassName,
|
||||
)}
|
||||
>
|
||||
{activeTasksLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between px-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
<span>
|
||||
{statusFilter === "active" ? activeTasksLabel : allTasksLabel}
|
||||
</span>
|
||||
<span>{taskCountLabel}</span>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className={CHAT_SIDEBAR_PRIMARY_ACTION_BUTTON_CLASSNAME}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{newChatLabel}
|
||||
</button>
|
||||
|
||||
<div className={filterGroupClassName}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter("all")}
|
||||
className={cn(
|
||||
"inline-flex h-9 flex-1 items-center justify-center rounded-2xl border px-2 text-xs font-medium transition",
|
||||
statusFilter === "all"
|
||||
? CHAT_SIDEBAR_ACTIVE_FILTER_CLASSNAME
|
||||
: inactiveFilterClassName,
|
||||
)}
|
||||
>
|
||||
{allTasksLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusFilter("active")}
|
||||
className={cn(
|
||||
"inline-flex h-9 flex-1 items-center justify-center rounded-2xl border px-2 text-xs font-medium transition",
|
||||
statusFilter === "active"
|
||||
? CHAT_SIDEBAR_ACTIVE_FILTER_CLASSNAME
|
||||
: inactiveFilterClassName,
|
||||
)}
|
||||
>
|
||||
{activeTasksLabel}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1129,92 +1128,6 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
data-testid="chat-sidebar-scroll-area"
|
||||
>
|
||||
<div className="space-y-4 pb-1">
|
||||
{contextVariant === "task-center" &&
|
||||
taskCenterContinuationState.primary ? (
|
||||
<section
|
||||
className="rounded-[24px] border border-emerald-200/70 bg-white px-3.5 py-3.5 shadow-sm shadow-slate-950/5"
|
||||
data-testid="task-center-continuation-panel"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold tracking-[0.12em] text-emerald-700">
|
||||
继续最近会话
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-5 text-slate-500">
|
||||
最近一次停在哪、当前在等什么,这里会直接带你回到现场。
|
||||
</p>
|
||||
</div>
|
||||
<Badge className="border border-emerald-200 bg-emerald-50 text-emerald-700">
|
||||
上下文已保留
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="task-center-primary-continuation"
|
||||
onClick={() =>
|
||||
handleOpenContinuationTask(taskCenterContinuationState.primary!)
|
||||
}
|
||||
className="mt-3 w-full rounded-[20px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.94)_100%)] px-3.5 py-3 text-left transition hover:border-emerald-200/80 hover:bg-white"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700">
|
||||
{resolveTaskCenterContinuationBadge(
|
||||
taskCenterContinuationState.primary,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400">
|
||||
{resolveTaskProjectLabel(
|
||||
taskCenterContinuationState.primary,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-900">
|
||||
{taskCenterContinuationState.primary.title}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-slate-500">
|
||||
{taskCenterContinuationState.primary.lastPreview}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-[11px] text-slate-400">
|
||||
<span>
|
||||
更新于{" "}
|
||||
{formatRelativeTime(
|
||||
taskCenterContinuationState.primary.updatedAt,
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium text-slate-600">
|
||||
{resolveTaskCenterContinuationActionLabel(
|
||||
taskCenterContinuationState.primary,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{taskCenterContinuationState.related.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{taskCenterContinuationState.related.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-testid={`task-center-related-${item.id}`}
|
||||
onClick={() => handleOpenContinuationTask(item)}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-left text-[11px] text-slate-600 transition hover:border-slate-300 hover:bg-white hover:text-slate-900"
|
||||
title={`${item.title} · ${item.lastPreview}`}
|
||||
>
|
||||
<span className="shrink-0 rounded-full bg-white px-1.5 py-0.5 text-[10px] text-slate-500">
|
||||
{resolveTaskCenterContinuationBadge(item)}
|
||||
</span>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<span className="shrink-0 text-slate-400">
|
||||
{resolveTaskProjectLabel(item)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{shouldShowTeamSection ? (
|
||||
<section
|
||||
className="rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.94)_0%,rgba(248,250,252,0.9)_100%)] px-3.5 py-3.5 shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5"
|
||||
@@ -1426,26 +1339,28 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
|
||||
<div
|
||||
ref={taskSectionAnchorRef}
|
||||
className="px-1"
|
||||
className={isTaskCenter ? "sr-only" : "px-1"}
|
||||
data-testid="task-section-heading"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
|
||||
{taskHeadingLabel}
|
||||
{isTaskCenter ? (
|
||||
<span>{taskHeadingLabel}</span>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold tracking-[0.12em] text-slate-500">
|
||||
{taskHeadingLabel}
|
||||
</div>
|
||||
{taskHeadingHint ? (
|
||||
<p className="mt-1 text-[11px] leading-5 text-slate-400 dark:text-slate-400">
|
||||
{taskHeadingHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 pt-0.5 text-xs text-slate-400">
|
||||
{taskCountLabel}
|
||||
</div>
|
||||
{taskHeadingHint ? (
|
||||
<p className="mt-1 text-[11px] leading-5 text-slate-400 dark:text-slate-400">
|
||||
{taskHeadingHint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 pt-0.5 text-xs text-slate-400">
|
||||
{searchKeyword.trim()
|
||||
? `${filteredTaskItems.length} 个结果`
|
||||
: `${topics.length} 个任务`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasAnyTasks ? (
|
||||
@@ -1463,10 +1378,10 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
) : !hasFilteredResults ? (
|
||||
<div className="rounded-[26px] border border-dashed border-slate-200/90 bg-white/82 px-4 py-8 text-center shadow-sm shadow-slate-950/5 dark:border-white/10 dark:bg-white/5">
|
||||
<div className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
没有匹配的任务
|
||||
{noResultsTitle}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-6 text-slate-500 dark:text-slate-400">
|
||||
试试搜索标题、执行摘要或状态关键词。
|
||||
{noResultsDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1550,8 +1465,12 @@ export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
? "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",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
<div className="rounded-[18px] border border-slate-200 bg-white/90 px-3.5 py-3 text-xs leading-5 text-slate-500">
|
||||
{launcherOutcomeSummary}
|
||||
</div>
|
||||
{launcherStarterContract ? (
|
||||
<div className="rounded-[18px] border border-slate-200 bg-slate-50 px-3.5 py-3 text-xs leading-5 text-slate-600">
|
||||
<div>
|
||||
<span className="font-medium text-slate-700">你先给:</span>
|
||||
{launcherStarterContract.requiredSummary}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="font-medium text-slate-700">会拿到:</span>
|
||||
{launcherStarterContract.outputSummary}
|
||||
</div>
|
||||
{launcherStarterContract.followUpSummary ? (
|
||||
<div className="mt-1">
|
||||
<span className="font-medium text-slate-700">
|
||||
接着可做:
|
||||
</span>
|
||||
{launcherStarterContract.followUpSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -444,10 +480,10 @@ export function CuratedTaskLauncherDialog({
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-emerald-200 bg-white px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
围绕最近复盘
|
||||
围绕最近判断
|
||||
</span>
|
||||
<div className="text-xs font-semibold leading-5 text-slate-900">
|
||||
最近复盘已更新:{reviewFeedbackProjection.signal.title}
|
||||
最近判断已更新:{reviewFeedbackProjection.signal.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 text-xs leading-5 text-slate-600">
|
||||
@@ -455,7 +491,7 @@ export function CuratedTaskLauncherDialog({
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-5 text-slate-600">
|
||||
{primarySuggestedTask
|
||||
? `这轮复盘更适合先回到「${primarySuggestedTask.title}」,切过去后我会继续带着当前参考对象。`
|
||||
? `这轮判断更建议优先回到「${primarySuggestedTask.title}」,切过去后我会继续带着当前参考对象。`
|
||||
: reviewFeedbackProjection.suggestionText}
|
||||
</div>
|
||||
{primarySuggestedTask && onApplyReviewSuggestion ? (
|
||||
@@ -646,21 +682,15 @@ export function CuratedTaskLauncherDialog({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
{getCuratedTaskReferenceSourceLabel(entry)}
|
||||
</span>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||
{entry.categoryLabel}
|
||||
</span>
|
||||
{entry.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={`${entry.id}-${tag}`}
|
||||
className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] text-slate-500"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
<div className="text-[11px] leading-5 text-slate-500">
|
||||
{[
|
||||
getCuratedTaskReferenceSourceLabel(entry),
|
||||
entry.categoryLabel,
|
||||
]
|
||||
.filter((segment): segment is string =>
|
||||
Boolean(segment && segment.trim()),
|
||||
)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{entry.title}
|
||||
@@ -668,6 +698,11 @@ export function CuratedTaskLauncherDialog({
|
||||
<div className="text-xs leading-5 text-slate-600">
|
||||
{entry.summary}
|
||||
</div>
|
||||
{entry.tags.length > 0 ? (
|
||||
<div className="text-[11px] leading-5 text-slate-500">
|
||||
相关线索:{entry.tags.slice(0, 2).join("、")}
|
||||
</div>
|
||||
) : null}
|
||||
{entryReviewHighlights.length > 0 ? (
|
||||
<div className="rounded-[14px] border border-emerald-200 bg-emerald-50/80 px-3 py-2 text-xs leading-5 text-emerald-900">
|
||||
<div className="font-medium text-emerald-900">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 | null | undefined>,
|
||||
): string | undefined {
|
||||
@@ -794,9 +920,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
}, []);
|
||||
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<EmptyStateProps> = ({
|
||||
onImportSkill,
|
||||
onRefreshSkills,
|
||||
});
|
||||
const activeSkillDisplayLabel = getActiveSkillDisplayLabel(currentSkill);
|
||||
const hasAutoLaunchSiteSkill = hasAutoLaunchableSiteSkill(serviceSkills);
|
||||
const siteSkillAutoLaunchExample =
|
||||
getSiteSkillAutoLaunchExample(serviceSkills);
|
||||
@@ -1071,7 +1201,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
const getPlaceholder = () => {
|
||||
return hasAutoLaunchSiteSkill
|
||||
? `直接说一句话,例如:${siteSkillAutoLaunchExample}`
|
||||
: "有什么我可以帮你的?";
|
||||
: "先说这轮要做什么,目标、对象或限制都可以。";
|
||||
};
|
||||
|
||||
const handleApplyRecommendation = useCallback(
|
||||
@@ -1166,7 +1296,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
options.referenceSelection.referenceEntries,
|
||||
);
|
||||
setCuratedTaskLauncherPrefillHint(
|
||||
`已按最近复盘切到更适合的结果模板,你可以继续改后再进入生成。`,
|
||||
`已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。`,
|
||||
);
|
||||
},
|
||||
[],
|
||||
@@ -1251,38 +1381,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
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<RecommendationShelfItem[]>(() => {
|
||||
const curatedTemplateRecommendations = listFeaturedHomeCuratedTaskTemplates(
|
||||
curatedTaskTemplates,
|
||||
@@ -1364,9 +1462,8 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
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<EmptyStateProps> = ({
|
||||
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<EmptyStateProps> = ({
|
||||
]);
|
||||
|
||||
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<EmptyStateProps> = ({
|
||||
testId: item.testId,
|
||||
onSelect: item.onSelect,
|
||||
}));
|
||||
}, [continuationShelfItems, recommendationServiceSkillItems]);
|
||||
}, [recommendationServiceSkillItems]);
|
||||
|
||||
const preferredLaunchDeckTab = useMemo<LaunchDeckTab>(() => {
|
||||
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<LaunchDeckTab>(preferredLaunchDeckTab);
|
||||
|
||||
useEffect(() => {
|
||||
const availableTabs = new Set<LaunchDeckTab>();
|
||||
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<EmptyStateProps> = ({
|
||||
effectiveDefaultCuratedTaskReferenceMemoryIds,
|
||||
activeCuratedTaskReferenceEntries ||
|
||||
effectiveDefaultCuratedTaskReferenceEntries,
|
||||
"已按最近复盘切到更适合的结果模板,你可以继续改后再进入生成。",
|
||||
"已按最近判断切到更适合的结果模板,你可以继续改后再进入生成。",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
@@ -1812,7 +1949,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
<RecommendationSignalBanner data-testid="entry-review-feedback-banner">
|
||||
<RecommendationSignalBannerHeader>
|
||||
<RecommendationSignalBannerTitle>
|
||||
最近复盘已更新:{reviewFeedbackBanner.title}
|
||||
最近判断已更新:{reviewFeedbackBanner.title}
|
||||
</RecommendationSignalBannerTitle>
|
||||
</RecommendationSignalBannerHeader>
|
||||
<RecommendationSignalBannerSummary>
|
||||
@@ -1838,58 +1975,110 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
</RecommendationSignalBanner>
|
||||
) : null}
|
||||
|
||||
{leadRecommendationItem ? (
|
||||
<RecommendationLeadCard
|
||||
type="button"
|
||||
data-testid={leadRecommendationItem.testId}
|
||||
title={[leadRecommendationItem.summary, leadRecommendationItem.meta]
|
||||
.filter((segment) => segment.trim().length > 0)
|
||||
.join(" · ")}
|
||||
onClick={() => {
|
||||
leadRecommendationItem.onSelect();
|
||||
}}
|
||||
>
|
||||
<RecommendationLeadEyebrowRow>
|
||||
<RecommendationLeadEyebrow>
|
||||
{resolveLeadRecommendationEyebrow(creationReplaySurface)}
|
||||
</RecommendationLeadEyebrow>
|
||||
{leadRecommendationItem.badge ? (
|
||||
<RecommendationShelfInlineBadge>
|
||||
{leadRecommendationItem.badge}
|
||||
</RecommendationShelfInlineBadge>
|
||||
) : null}
|
||||
</RecommendationLeadEyebrowRow>
|
||||
<RecommendationLeadTitle>
|
||||
{leadRecommendationItem.title}
|
||||
</RecommendationLeadTitle>
|
||||
<RecommendationLeadSummary>
|
||||
{leadRecommendationItem.summary}
|
||||
</RecommendationLeadSummary>
|
||||
{leadRecommendationItem.contextSummary ? (
|
||||
<RecommendationLeadMeta>
|
||||
{leadRecommendationItem.contextSummary}
|
||||
</RecommendationLeadMeta>
|
||||
<RecommendationTabsRow>
|
||||
<RecommendationTabsRail role="tablist" aria-label="首页起手入口">
|
||||
{(leadRecommendationItem ||
|
||||
alternativeRecommendationItems.length > 0) && (
|
||||
<RecommendationTabButton
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={launchDeckTab === "recommended"}
|
||||
data-testid="entry-launch-tab-recommended"
|
||||
$active={launchDeckTab === "recommended"}
|
||||
onClick={() => setLaunchDeckTab("recommended")}
|
||||
>
|
||||
推荐起手
|
||||
<RecommendationTabCount $active={launchDeckTab === "recommended"}>
|
||||
{recommendationSolutionItems.length}
|
||||
</RecommendationTabCount>
|
||||
</RecommendationTabButton>
|
||||
)}
|
||||
|
||||
{continuationShelfItems.length > 0 ? (
|
||||
<RecommendationTabButton
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={launchDeckTab === "continuation"}
|
||||
data-testid="entry-launch-tab-continuation"
|
||||
$active={launchDeckTab === "continuation"}
|
||||
onClick={() => setLaunchDeckTab("continuation")}
|
||||
>
|
||||
继续上次
|
||||
<RecommendationTabCount $active={launchDeckTab === "continuation"}>
|
||||
{continuationShelfItems.length}
|
||||
</RecommendationTabCount>
|
||||
</RecommendationTabButton>
|
||||
) : null}
|
||||
<RecommendationLeadMeta>{leadRecommendationItem.meta}</RecommendationLeadMeta>
|
||||
<RecommendationLeadFooter>
|
||||
开始这一轮
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</RecommendationLeadFooter>
|
||||
</RecommendationLeadCard>
|
||||
) : (
|
||||
<RecommendationShelfEmptyState>
|
||||
当前还没有可直接推荐的起手结果,你可以直接描述目标,Lime 会先帮你组织这一轮。
|
||||
</RecommendationShelfEmptyState>
|
||||
)}
|
||||
|
||||
{directMethodItems.length > 0 ? (
|
||||
<RecommendationTabButton
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={launchDeckTab === "methods"}
|
||||
data-testid="entry-launch-tab-methods"
|
||||
$active={launchDeckTab === "methods"}
|
||||
onClick={() => setLaunchDeckTab("methods")}
|
||||
>
|
||||
做法入口
|
||||
<RecommendationTabCount $active={launchDeckTab === "methods"}>
|
||||
{directMethodItems.length}
|
||||
</RecommendationTabCount>
|
||||
</RecommendationTabButton>
|
||||
) : null}
|
||||
</RecommendationTabsRail>
|
||||
|
||||
<RecommendationTabCaption>{launchDeckTabCaption}</RecommendationTabCaption>
|
||||
</RecommendationTabsRow>
|
||||
|
||||
<RecommendationPanels>
|
||||
<RecommendationTabPanel hidden={launchDeckTab !== "recommended"}>
|
||||
{leadRecommendationItem ? (
|
||||
<RecommendationLeadCard
|
||||
type="button"
|
||||
data-testid={leadRecommendationItem.testId}
|
||||
title={[leadRecommendationItem.summary, leadRecommendationItem.meta]
|
||||
.filter((segment) => segment.trim().length > 0)
|
||||
.join(" · ")}
|
||||
onClick={() => {
|
||||
leadRecommendationItem.onSelect();
|
||||
}}
|
||||
>
|
||||
<RecommendationLeadEyebrowRow>
|
||||
<RecommendationLeadEyebrow>
|
||||
{resolveLeadRecommendationEyebrow(creationReplaySurface)}
|
||||
</RecommendationLeadEyebrow>
|
||||
{leadRecommendationItem.badge ? (
|
||||
<RecommendationShelfInlineBadge>
|
||||
{leadRecommendationItem.badge}
|
||||
</RecommendationShelfInlineBadge>
|
||||
) : null}
|
||||
</RecommendationLeadEyebrowRow>
|
||||
<RecommendationLeadTitle>
|
||||
{leadRecommendationItem.title}
|
||||
</RecommendationLeadTitle>
|
||||
<RecommendationLeadSummary>
|
||||
{leadRecommendationItem.summary}
|
||||
</RecommendationLeadSummary>
|
||||
{leadRecommendationItem.contextSummary ? (
|
||||
<RecommendationLeadMeta>
|
||||
{leadRecommendationItem.contextSummary}
|
||||
</RecommendationLeadMeta>
|
||||
) : null}
|
||||
<RecommendationLeadMeta>{leadRecommendationItem.meta}</RecommendationLeadMeta>
|
||||
<RecommendationLeadFooter>
|
||||
开始这一轮
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</RecommendationLeadFooter>
|
||||
</RecommendationLeadCard>
|
||||
) : (
|
||||
<RecommendationShelfEmptyState>
|
||||
当前还没有可直接推荐的起手结果,你可以直接描述目标,Lime 会先帮你组织这一轮。
|
||||
</RecommendationShelfEmptyState>
|
||||
)}
|
||||
|
||||
{(alternativeRecommendationItems.length > 0 ||
|
||||
directMethodItems.length > 0) && (
|
||||
<RecommendationAssistStrip>
|
||||
{alternativeRecommendationItems.length > 0 ? (
|
||||
<RecommendationAssistGroup>
|
||||
<RecommendationAssistLabel>
|
||||
也可以换个结果开始
|
||||
</RecommendationAssistLabel>
|
||||
<RecommendationAssistLabel>其他起手结果</RecommendationAssistLabel>
|
||||
<RecommendationAssistList>
|
||||
{alternativeRecommendationItems.map((item) => (
|
||||
<RecommendationAssistCard
|
||||
@@ -1923,16 +2112,14 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
</RecommendationAssistList>
|
||||
</RecommendationAssistGroup>
|
||||
) : null}
|
||||
</RecommendationTabPanel>
|
||||
|
||||
<RecommendationTabPanel hidden={launchDeckTab !== "continuation"}>
|
||||
<RecommendationAssistGroup>
|
||||
<RecommendationAssistLabel>
|
||||
{continuationShelfItems.length > 0
|
||||
? "继续上次做法"
|
||||
: "已经知道做法时,也可以直接这样开工"}
|
||||
</RecommendationAssistLabel>
|
||||
{directMethodItems.length > 0 ? (
|
||||
<RecommendationAssistLabel>继续上次做法</RecommendationAssistLabel>
|
||||
{continuationShelfItems.length > 0 ? (
|
||||
<RecommendationAssistList>
|
||||
{directMethodItems.map((item) => (
|
||||
{continuationShelfItems.map((item) => (
|
||||
<RecommendationAssistCard
|
||||
key={item.key}
|
||||
type="button"
|
||||
@@ -1962,13 +2149,50 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
</RecommendationShelfEmptyState>
|
||||
)}
|
||||
</RecommendationAssistGroup>
|
||||
</RecommendationAssistStrip>
|
||||
)}
|
||||
</RecommendationTabPanel>
|
||||
|
||||
<RecommendationTabPanel hidden={launchDeckTab !== "methods"}>
|
||||
<RecommendationAssistGroup>
|
||||
<RecommendationAssistLabel>也可以直接按做法开工</RecommendationAssistLabel>
|
||||
{directMethodItems.length > 0 ? (
|
||||
<RecommendationAssistList>
|
||||
{directMethodItems.map((item) => (
|
||||
<RecommendationAssistCard
|
||||
key={item.key}
|
||||
type="button"
|
||||
data-testid={item.testId}
|
||||
title={item.summary}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
<RecommendationAssistCardHeader>
|
||||
<RecommendationAssistCardTitle>
|
||||
{item.title}
|
||||
</RecommendationAssistCardTitle>
|
||||
{item.badge ? (
|
||||
<RecommendationShelfBadge>{item.badge}</RecommendationShelfBadge>
|
||||
) : null}
|
||||
</RecommendationAssistCardHeader>
|
||||
<RecommendationAssistCardSummary>
|
||||
{item.summary}
|
||||
</RecommendationAssistCardSummary>
|
||||
</RecommendationAssistCard>
|
||||
))}
|
||||
</RecommendationAssistList>
|
||||
) : (
|
||||
<RecommendationShelfEmptyState>
|
||||
当前还没有可直接复用的做法,先选一个结果起手也可以。
|
||||
</RecommendationShelfEmptyState>
|
||||
)}
|
||||
</RecommendationAssistGroup>
|
||||
</RecommendationTabPanel>
|
||||
</RecommendationPanels>
|
||||
|
||||
<RecommendationAssistFootnote data-testid="entry-result-destination-hint">
|
||||
{projectId
|
||||
? "本轮产出会沉淀到当前项目,跑通过的方法会继续留在这里。"
|
||||
: "本轮产出会先写回当前任务,跑通过的方法会继续留在这里。"}
|
||||
? "本轮产出会沉淀到当前项目,后续继续也会优先从这里接回。"
|
||||
: "本轮产出会先写回当前任务,后续继续也会优先从这里接回。"}
|
||||
</RecommendationAssistFootnote>
|
||||
</RecommendationShelf>
|
||||
);
|
||||
@@ -2066,7 +2290,7 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
|
||||
const headerControls = onProjectChange ? (
|
||||
<div className="flex w-full justify-start sm:w-auto sm:justify-end">
|
||||
<div className="inline-flex max-w-full items-center rounded-[24px] border border-slate-200/80 bg-white p-1 shadow-sm shadow-slate-950/5">
|
||||
<div className="inline-flex max-w-full items-center rounded-[28px] border border-slate-200/90 bg-white/96 p-1.5 shadow-[0_18px_40px_-30px_rgba(15,23,42,0.25)]">
|
||||
<ProjectSelector
|
||||
value={projectId ?? null}
|
||||
onChange={onProjectChange}
|
||||
@@ -2077,19 +2301,19 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
enableManagement={activeTheme === "general"}
|
||||
density="compact"
|
||||
chrome="embedded"
|
||||
className="min-w-[180px] max-w-[260px]"
|
||||
className="min-w-[210px] max-w-[320px]"
|
||||
/>
|
||||
{onOpenSettings ? (
|
||||
<>
|
||||
<div
|
||||
className="mx-1 h-6 w-px shrink-0 bg-slate-200/80"
|
||||
className="mx-1.5 h-7 w-px shrink-0 bg-slate-200/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-[18px] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
className="h-10 w-10 rounded-[20px] text-slate-500 hover:bg-slate-100 hover:text-slate-900"
|
||||
onClick={onOpenSettings}
|
||||
aria-label="打开设置"
|
||||
title="打开设置"
|
||||
@@ -2111,7 +2335,6 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
slogan="青柠一下,灵感即来"
|
||||
description={workbenchCopy.description}
|
||||
supportingDescription={workbenchCopy.supportingDescription}
|
||||
badges={workspaceBadges}
|
||||
cards={[]}
|
||||
prioritySlot={composerPanel}
|
||||
supportingSlot={
|
||||
|
||||
@@ -351,6 +351,47 @@ describe("EmptyStateComposerPanel", () => {
|
||||
expect(container.textContent).toContain("品牌风格样本");
|
||||
});
|
||||
|
||||
it("首页输入区应直接聚焦输入主路径,不再展示额外起始卡片", () => {
|
||||
const container = renderPanel();
|
||||
const textarea = container.querySelector("textarea");
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="empty-state-kickoff-guide"]'),
|
||||
).toBeNull();
|
||||
expect(textarea).toBeTruthy();
|
||||
});
|
||||
|
||||
it("带入项目参考时,应继续在输入区顶部展示参考对象", () => {
|
||||
const container = renderPanel({
|
||||
projectId: "project-1",
|
||||
creationReplaySurface: {
|
||||
kind: "memory_entry",
|
||||
eyebrow: "当前带入灵感",
|
||||
badgeLabel: "参考",
|
||||
title: "品牌风格样本",
|
||||
summary: "保留轻盈但专业的表达。",
|
||||
hint: "后续结果模板会默认把它一起带入。",
|
||||
defaultReferenceMemoryIds: ["memory-1"],
|
||||
defaultReferenceEntries: [
|
||||
{
|
||||
id: "memory-1",
|
||||
title: "品牌风格样本",
|
||||
summary: "保留轻盈但专业的表达。",
|
||||
category: "context",
|
||||
categoryLabel: "参考",
|
||||
tags: ["品牌", "语气"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="empty-state-kickoff-guide"]'),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("参考");
|
||||
expect(container.textContent).toContain("品牌风格样本");
|
||||
});
|
||||
|
||||
it("首页输入区已激活复盘模板时,应把 sceneapp 项目结果引用继续透传给 badge", () => {
|
||||
const container = renderPanel({
|
||||
activeCapability: {
|
||||
@@ -358,15 +399,15 @@ describe("EmptyStateComposerPanel", () => {
|
||||
task: {
|
||||
id: "account-project-review",
|
||||
title: "复盘这个账号/项目",
|
||||
summary: "围绕已有结果做一次复盘。",
|
||||
outputHint: "复盘摘要 + 下一步建议",
|
||||
resultDestination: "复盘摘要会先回到当前内容。",
|
||||
categoryLabel: "复盘与优化",
|
||||
prompt: "请帮我复盘这个账号或项目",
|
||||
summary: "围绕已有结果判断当前该怎么推进。",
|
||||
outputHint: "判断摘要 + 下一步建议",
|
||||
resultDestination: "判断摘要会先回到当前内容。",
|
||||
categoryLabel: "判断与优化",
|
||||
prompt: "请帮我判断这个账号或项目当前该怎么推进",
|
||||
requiredInputs: ["账号或项目目标", "已有结果或数据"],
|
||||
requiredInputFields: [],
|
||||
optionalReferences: ["最近内容链接"],
|
||||
outputContract: ["复盘摘要", "下一轮动作建议"],
|
||||
outputContract: ["判断摘要", "下一轮动作建议"],
|
||||
followUpActions: ["继续做趋势摘要"],
|
||||
badge: "结果模板",
|
||||
actionLabel: "进入生成",
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { ReactNode } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { WorkbenchInfoTip } from "@/components/media/WorkbenchInfoTip";
|
||||
import {
|
||||
EMPTY_STATE_BADGE_BASE_CLASSNAME,
|
||||
EMPTY_STATE_BADGE_TONE_CLASSNAMES,
|
||||
EMPTY_STATE_CARD_SURFACE_CLASSNAME,
|
||||
EMPTY_STATE_ICON_TONE_CLASSNAMES,
|
||||
type EmptyStateTone,
|
||||
@@ -48,14 +46,14 @@ const HeroContent = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: clamp(560px, calc(100dvh - 190px), 820px);
|
||||
min-height: clamp(620px, calc(100dvh - 168px), 900px);
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
padding: 0.35rem 0.45rem 0.55rem;
|
||||
gap: 1.35rem;
|
||||
padding: 0.45rem 0.55rem 0.85rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
gap: 1.1rem;
|
||||
padding: 0.45rem 0.6rem 0.7rem;
|
||||
gap: 1.55rem;
|
||||
padding: 0.5rem 0.7rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-height: 940px) {
|
||||
@@ -72,7 +70,7 @@ const LeadColumn = styled.div`
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
max-width: min(1180px, 100%);
|
||||
max-width: min(1320px, 100%);
|
||||
`;
|
||||
|
||||
const LeadBlock = styled.div`
|
||||
@@ -95,7 +93,7 @@ const LeadTopRow = styled.div`
|
||||
const EyebrowText = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -113,20 +111,8 @@ const LeadTextGroup = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
max-width: 58rem;
|
||||
`;
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
gap: 0.78rem;
|
||||
max-width: 70rem;
|
||||
`;
|
||||
|
||||
const SloganLine = styled.p`
|
||||
@@ -134,9 +120,8 @@ const SloganLine = styled.p`
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 1.3rem;
|
||||
gap: 1.6rem;
|
||||
|
||||
/* lime gradient with scan effect */
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#10b981 0%,
|
||||
@@ -149,18 +134,17 @@ const SloganLine = styled.p`
|
||||
color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: ${shimmer} 3.5s ease-in-out infinite;
|
||||
|
||||
font-size: clamp(44px, 5.3vw, 80px);
|
||||
font-size: clamp(54px, 6vw, 92px);
|
||||
line-height: 0.94;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
letter-spacing: -0.05em;
|
||||
text-shadow: 0 14px 28px rgba(101, 163, 13, 0.12);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 0.92rem;
|
||||
height: 0.92rem;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
@@ -168,28 +152,28 @@ const SloganLine = styled.p`
|
||||
rgba(132, 204, 22, 0.9),
|
||||
rgba(16, 185, 129, 0.75)
|
||||
);
|
||||
box-shadow: 0 0 0 12px rgba(132, 204, 22, 0.12);
|
||||
box-shadow: 0 0 0 14px rgba(132, 204, 22, 0.12);
|
||||
}
|
||||
`;
|
||||
|
||||
const LeadDescriptionText = styled.p`
|
||||
margin: 0;
|
||||
max-width: 42rem;
|
||||
font-size: 15px;
|
||||
max-width: 46rem;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.72;
|
||||
line-height: 1.78;
|
||||
color: rgb(51 65 85);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const LeadSupportingText = styled.p`
|
||||
margin: 0;
|
||||
max-width: 50rem;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.72;
|
||||
max-width: 58rem;
|
||||
font-size: 14px;
|
||||
line-height: 1.76;
|
||||
color: rgb(100 116 139);
|
||||
`;
|
||||
|
||||
@@ -312,12 +296,6 @@ const CardEyebrow = styled.span`
|
||||
color: rgb(148 163 184);
|
||||
`;
|
||||
|
||||
export interface EmptyStateHeroBadge {
|
||||
key: string;
|
||||
label: string;
|
||||
tone?: EmptyStateTone;
|
||||
}
|
||||
|
||||
export interface EmptyStateHeroCard {
|
||||
key: string;
|
||||
eyebrow: string;
|
||||
@@ -340,7 +318,6 @@ interface EmptyStateHeroProps {
|
||||
slogan?: string;
|
||||
description: string;
|
||||
supportingDescription?: string;
|
||||
badges: EmptyStateHeroBadge[];
|
||||
cards: EmptyStateHeroCard[];
|
||||
prioritySlot?: ReactNode;
|
||||
supportingSlot?: ReactNode;
|
||||
@@ -353,7 +330,6 @@ export function EmptyStateHero({
|
||||
slogan,
|
||||
description,
|
||||
supportingDescription,
|
||||
badges,
|
||||
cards,
|
||||
prioritySlot,
|
||||
supportingSlot,
|
||||
@@ -385,23 +361,6 @@ export function EmptyStateHero({
|
||||
</LeadSupportingText>
|
||||
) : null}
|
||||
</LeadTextGroup>
|
||||
|
||||
{badges.length > 0 ? (
|
||||
<div className="flex max-w-[44rem] flex-wrap gap-2">
|
||||
{badges.map((badge) => (
|
||||
<span
|
||||
key={badge.key}
|
||||
className={`${EMPTY_STATE_BADGE_BASE_CLASSNAME} ${
|
||||
EMPTY_STATE_BADGE_TONE_CLASSNAMES[
|
||||
badge.tone || "slate"
|
||||
]
|
||||
}`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</LeadBody>
|
||||
</LeadBlock>
|
||||
</LeadColumn>
|
||||
|
||||
@@ -203,7 +203,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
});
|
||||
}
|
||||
|
||||
expect(container.textContent).toContain("生成工作台");
|
||||
expect(container.textContent).toContain("生成侧栏");
|
||||
expect(container.textContent).toContain("聚焦当前结果、下一步与可继续的版本。");
|
||||
expect(container.textContent).toContain("当前进展");
|
||||
expect(container.textContent).toContain("当前焦点");
|
||||
@@ -1197,7 +1197,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("命中最近复盘偏好模板时,应在当前进展里显影复盘建议", () => {
|
||||
it("命中最近判断偏好模板时,应在当前进展里显影判断建议", () => {
|
||||
recordCuratedTaskRecommendationSignalFromReviewDecision(
|
||||
{
|
||||
session_id: "session-review-workflow",
|
||||
@@ -1253,9 +1253,9 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
'[data-testid="workflow-sidebar-review-feedback-banner"]',
|
||||
) as HTMLElement | null;
|
||||
expect(reviewBanner).toBeTruthy();
|
||||
expect(reviewBanner?.textContent).toContain("围绕最近复盘");
|
||||
expect(reviewBanner?.textContent).toContain("围绕最近判断");
|
||||
expect(reviewBanner?.textContent).toContain(
|
||||
"最近复盘已更新:短视频编排 · 补证据",
|
||||
"最近判断已更新:短视频编排 · 补证据",
|
||||
);
|
||||
expect(reviewBanner?.textContent).toContain("这轮结果还缺证据");
|
||||
expect(reviewBanner?.textContent).toContain("围绕「复盘这个账号/项目」继续推进");
|
||||
@@ -1337,7 +1337,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("命中最近复盘偏好模板时,应在当前查看运行里继续显影复盘建议", () => {
|
||||
it("命中最近判断偏好模板时,应在当前查看运行里继续显影判断建议", () => {
|
||||
recordCuratedTaskRecommendationSignalFromReviewDecision(
|
||||
{
|
||||
session_id: "session-review-run-detail",
|
||||
@@ -1402,9 +1402,9 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
'[data-testid="workflow-run-detail-review-feedback-banner"]',
|
||||
) as HTMLElement | null;
|
||||
expect(reviewBanner).toBeTruthy();
|
||||
expect(reviewBanner?.textContent).toContain("围绕最近复盘");
|
||||
expect(reviewBanner?.textContent).toContain("围绕最近判断");
|
||||
expect(reviewBanner?.textContent).toContain(
|
||||
"最近复盘已更新:短视频编排 · 补证据",
|
||||
"最近判断已更新:短视频编排 · 补证据",
|
||||
);
|
||||
expect(reviewBanner?.textContent).toContain("这轮结果还缺证据");
|
||||
expect(reviewBanner?.textContent).toContain("围绕「复盘这个账号/项目」继续推进");
|
||||
@@ -1491,7 +1491,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
expect(onApplyFollowUpAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bannerMessage:
|
||||
"已切到“内容主稿生成”这条下一步,并带着当前结果继续生成。",
|
||||
"已切到“内容主稿生成”这条下一步,并带着当前结果继续进入生成。",
|
||||
capabilityRoute: expect.objectContaining({
|
||||
kind: "curated_task",
|
||||
taskId: "social-post-starter",
|
||||
@@ -1599,7 +1599,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
expect(onApplyFollowUpAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bannerMessage:
|
||||
"已切到“内容主稿生成”这条下一步,并带着当前结果继续生成。",
|
||||
"已切到“内容主稿生成”这条下一步,并带着当前结果继续进入生成。",
|
||||
capabilityRoute: expect.objectContaining({
|
||||
kind: "curated_task",
|
||||
taskId: "social-post-starter",
|
||||
@@ -1761,10 +1761,10 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
expect(onApplyFollowUpAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining(
|
||||
"请承接这轮复盘结论,直接生成下一轮最值得执行的内容方案。",
|
||||
"请承接这轮判断结论,直接生成下一轮最值得执行的内容方案。",
|
||||
),
|
||||
bannerMessage:
|
||||
"已切到“内容主稿生成”这条下一步,并带着这轮结果继续生成。",
|
||||
"已切到“内容主稿生成”这条下一步,并带着这轮结果继续进入生成。",
|
||||
capabilityRoute: expect.objectContaining({
|
||||
kind: "curated_task",
|
||||
taskId: "social-post-starter",
|
||||
@@ -1854,7 +1854,7 @@ describe("GeneralWorkbenchSidebar", () => {
|
||||
expect(onApplyFollowUpAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bannerMessage:
|
||||
"已切到“内容主稿生成”这条下一步,并带着这轮结果继续生成。",
|
||||
"已切到“内容主稿生成”这条下一步,并带着这轮结果继续进入生成。",
|
||||
capabilityRoute: expect.objectContaining({
|
||||
kind: "curated_task",
|
||||
taskId: "social-post-starter",
|
||||
|
||||
@@ -119,7 +119,7 @@ export function GeneralWorkbenchSidebarShell({
|
||||
data-testid="general-workbench-sidebar-header"
|
||||
>
|
||||
<div className={SIDEBAR_HEADER_META_ROW_CLASSNAME}>
|
||||
<div className={SIDEBAR_EYEBROW_CLASSNAME}>生成工作台</div>
|
||||
<div className={SIDEBAR_EYEBROW_CLASSNAME}>生成侧栏</div>
|
||||
{headerActionSlot ? (
|
||||
<div
|
||||
className={SIDEBAR_HEADER_ACTION_SLOT_CLASSNAME}
|
||||
|
||||
@@ -602,7 +602,7 @@ function ReviewFeedbackProjectionCard({
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-emerald-200 bg-white px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
||||
围绕最近复盘
|
||||
围绕最近判断
|
||||
</span>
|
||||
{!projection.matchedCurrentTask &&
|
||||
projection.suggestedTaskTitles.length > 0 ? (
|
||||
@@ -612,7 +612,7 @@ function ReviewFeedbackProjectionCard({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-medium leading-5 text-slate-900">
|
||||
最近复盘已更新:{projection.signal.title}
|
||||
最近判断已更新:{projection.signal.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-5 text-slate-500">
|
||||
{projection.signal.summary}
|
||||
@@ -676,7 +676,7 @@ function buildReviewFeedbackFollowUpActionPayload(params: {
|
||||
|
||||
return {
|
||||
prompt,
|
||||
bannerMessage: `已切到“${targetTask.title}”这条下一步,并带着这轮结果继续生成。`,
|
||||
bannerMessage: `已切到“${targetTask.title}”这条下一步,并带着这轮结果继续进入生成。`,
|
||||
capabilityRoute: {
|
||||
kind: "curated_task",
|
||||
taskId: targetTask.id,
|
||||
@@ -787,7 +787,7 @@ function buildCuratedTaskFollowUpBannerMessage(params: {
|
||||
|
||||
const targetTaskTitle = params.targetTask?.title?.trim();
|
||||
if (targetTaskTitle) {
|
||||
return `已切到“${targetTaskTitle}”这条下一步,并带着这轮结果继续生成。`;
|
||||
return `已切到“${targetTaskTitle}”这条下一步,并带着这轮结果继续进入生成。`;
|
||||
}
|
||||
|
||||
const currentTaskTitle = params.curatedTask?.taskTitle?.trim();
|
||||
|
||||
@@ -782,6 +782,34 @@ describe("HarnessStatusPanel", () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("存在 thread_read runtime facts 时应展示运行时事实区块", () => {
|
||||
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 个兼容候选,已按连续性、能力与成本优选。",
|
||||
fallbackChain: ["openai:gpt-5.4", "openai:gpt-5.4-mini"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("运行时事实");
|
||||
expect(document.body.textContent).toContain("决策原因");
|
||||
expect(document.body.textContent).toContain("回退链");
|
||||
expect(document.body.textContent).toContain("OEM 托管锁定");
|
||||
expect(document.body.textContent).toContain("OEM 额度偏低");
|
||||
expect(document.body.textContent).toContain("claude-sonnet-4");
|
||||
});
|
||||
|
||||
it("存在 sessionId 时应支持导出交接制品并展示产物列表", async () => {
|
||||
exportAgentRuntimeHandoffBundleMock.mockResolvedValue({
|
||||
session_id: "session-handoff-1",
|
||||
|
||||
@@ -208,6 +208,7 @@ type HarnessSectionKey =
|
||||
| "runtime"
|
||||
| "handoff"
|
||||
| "reliability"
|
||||
| "runtime-facts"
|
||||
| "inventory"
|
||||
| "approvals"
|
||||
| "writes"
|
||||
@@ -2074,6 +2075,47 @@ export function HarnessStatusPanel({
|
||||
turns,
|
||||
],
|
||||
);
|
||||
const runtimeFactSummary = useMemo(() => {
|
||||
const decisionReason =
|
||||
threadRead?.decision_reason ||
|
||||
((threadRead?.runtime_summary as { decisionReason?: string | null } | null)
|
||||
?.decisionReason ??
|
||||
null);
|
||||
const fallbackChain = Array.isArray(threadRead?.fallback_chain)
|
||||
? threadRead?.fallback_chain || []
|
||||
: Array.isArray(
|
||||
(threadRead?.runtime_summary as { fallbackChain?: string[] | null } | null)
|
||||
?.fallbackChain,
|
||||
)
|
||||
? ((threadRead?.runtime_summary as { fallbackChain?: string[] | null })
|
||||
.fallbackChain || [])
|
||||
: [];
|
||||
const oemPolicy = threadRead?.oem_policy as
|
||||
| {
|
||||
locked?: boolean | null;
|
||||
quotaLow?: boolean | null;
|
||||
defaultModel?: string | null;
|
||||
selectedModel?: string | null;
|
||||
quotaStatus?: string | null;
|
||||
offerState?: string | null;
|
||||
providerSource?: string | null;
|
||||
providerKey?: string | null;
|
||||
fallbackToLocalAllowed?: boolean | null;
|
||||
canInvoke?: boolean | null;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
| null;
|
||||
|
||||
if (!decisionReason && fallbackChain.length === 0 && !oemPolicy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
decisionReason,
|
||||
fallbackChain,
|
||||
oemPolicy,
|
||||
};
|
||||
}, [threadRead]);
|
||||
|
||||
const fileFilterOptions = useMemo(
|
||||
() =>
|
||||
@@ -4554,6 +4596,97 @@ export function HarnessStatusPanel({
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{runtimeFactSummary ? (
|
||||
<Section
|
||||
sectionKey="runtime-facts"
|
||||
title="运行时事实"
|
||||
badge="current"
|
||||
registerRef={registerSectionRef}
|
||||
>
|
||||
<div className="space-y-3 rounded-2xl border border-sky-200 bg-sky-50/80 px-4 py-4">
|
||||
{runtimeFactSummary.decisionReason ? (
|
||||
<div className="text-sm text-slate-700">
|
||||
<span className="font-medium text-foreground">
|
||||
决策原因:
|
||||
</span>
|
||||
{runtimeFactSummary.decisionReason}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runtimeFactSummary.fallbackChain.length > 0 ? (
|
||||
<div className="text-sm text-slate-700">
|
||||
<span className="font-medium text-foreground">
|
||||
回退链:
|
||||
</span>
|
||||
{runtimeFactSummary.fallbackChain.join(" → ")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runtimeFactSummary.oemPolicy ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{runtimeFactSummary.oemPolicy.locked ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-300 bg-white text-amber-700"
|
||||
>
|
||||
OEM 托管锁定
|
||||
</Badge>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.quotaLow ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 bg-white text-orange-700"
|
||||
>
|
||||
OEM 额度偏低
|
||||
</Badge>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.canInvoke === false ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-rose-300 bg-white text-rose-700"
|
||||
>
|
||||
OEM 当前不可调用
|
||||
</Badge>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.fallbackToLocalAllowed === true ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-300 bg-white text-emerald-700"
|
||||
>
|
||||
允许回退本地
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{runtimeFactSummary.oemPolicy.defaultModel ||
|
||||
runtimeFactSummary.oemPolicy.selectedModel ? (
|
||||
<span>
|
||||
OEM 模型 {runtimeFactSummary.oemPolicy.defaultModel || runtimeFactSummary.oemPolicy.selectedModel}
|
||||
</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.quotaStatus ? (
|
||||
<span>额度状态 {runtimeFactSummary.oemPolicy.quotaStatus}</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.offerState ? (
|
||||
<span>策略状态 {runtimeFactSummary.oemPolicy.offerState}</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.providerSource ? (
|
||||
<span>来源 {runtimeFactSummary.oemPolicy.providerSource}</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.providerKey ? (
|
||||
<span>Provider Key {runtimeFactSummary.oemPolicy.providerKey}</span>
|
||||
) : null}
|
||||
{runtimeFactSummary.oemPolicy.tenantId ? (
|
||||
<span>租户 {runtimeFactSummary.oemPolicy.tenantId}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{harnessState.activeFileWrites.length > 0 ? (
|
||||
<Section
|
||||
sectionKey="writes"
|
||||
|
||||
@@ -446,7 +446,7 @@ export function useInputbarController({
|
||||
};
|
||||
});
|
||||
setCuratedTaskEditorPrefillHint(
|
||||
"已按最近复盘切到更适合的结果模板,你可以继续改后再发。",
|
||||
"已按最近判断切到更适合的结果模板,你可以继续改后再发。",
|
||||
);
|
||||
},
|
||||
[],
|
||||
@@ -515,6 +515,10 @@ export function useInputbarController({
|
||||
const handleSelectInputCapability = (
|
||||
capability: InputCapabilitySelection,
|
||||
) => {
|
||||
if (capability.kind === "service_skill") {
|
||||
handleSelectServiceSkill(capability.skill);
|
||||
return;
|
||||
}
|
||||
setActiveCapability(capability);
|
||||
};
|
||||
const skillSelection = buildSkillSelectionProps({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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("开始一段新的对话吧");
|
||||
});
|
||||
|
||||
@@ -1409,18 +1409,18 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600">
|
||||
创作
|
||||
对话
|
||||
</span>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<h2 className="text-[32px] font-semibold tracking-tight text-slate-900 md:text-[36px]">
|
||||
生成
|
||||
最近对话
|
||||
</h2>
|
||||
<p className="max-w-[48rem] text-[15px] leading-7 text-slate-600">
|
||||
这里是 Lime 的生成主执行面,可以继续进行中的创作、回看最近结果和旧历史。
|
||||
这里集中展示最近对话、待继续会话和更早归档,方便你随时回到上一次工作现场。
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-slate-500">
|
||||
还没开始生成时,从“开始生成”发起也很自然;跑过的结果、做法和中间素材后面都会继续留在这里。
|
||||
还没有对话时,可以先从“新建对话”开始;后续的结果、素材和中间过程都会继续留在这里。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1428,13 +1428,13 @@ const MessageListInner: React.FC<MessageListProps> = ({
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
|
||||
左侧会继续显示待继续的生成记录
|
||||
左侧会优先显示待继续的对话
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
|
||||
最近结果会继续在这里回访
|
||||
最近对话和归档会按时间自动整理
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full border border-slate-200/80 bg-white px-3 py-1.5 text-xs text-slate-500">
|
||||
常用做法和恢复中的会话会自动回到这里继续生成
|
||||
恢复中的会话会自动回到这里继续
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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<React.ComponentProps<typeof TaskCenterTabStrip>>,
|
||||
) {
|
||||
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<typeof TaskCenterTabStrip> = {
|
||||
items,
|
||||
onSelectTask: vi.fn(),
|
||||
onCloseTask: vi.fn(),
|
||||
onCreateTask: vi.fn(),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(<TaskCenterTabStrip {...defaultProps} {...props} />);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
onCloseTask: (taskId: string) => void | Promise<void>;
|
||||
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 (
|
||||
<section
|
||||
className="mx-2 mb-0.5 shrink-0"
|
||||
data-testid="task-center-tab-strip"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded-b-[18px] border border-t-0 border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98)_0%,rgba(248,250,252,0.96)_66%,rgba(240,249,255,0.86)_100%)] px-2 pb-1 pt-1">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto [scrollbar-width:none]">
|
||||
<div className="flex min-w-max items-center gap-0.5 pr-1.5">
|
||||
{items.map((item) => {
|
||||
const statusMeta =
|
||||
TASK_CENTER_TAB_STATUS_META[item.status] ??
|
||||
TASK_CENTER_TAB_STATUS_META.done;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
conversationTabShellClassName,
|
||||
item.isActive
|
||||
? activeConversationTabClassName
|
||||
: inactiveConversationTabClassName,
|
||||
)}
|
||||
data-testid={`task-center-tab-${item.id}`}
|
||||
data-active={item.isActive ? "true" : "false"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={conversationTabButtonClassName}
|
||||
aria-current={item.isActive ? "page" : undefined}
|
||||
title={formatTaskTabTitle(item)}
|
||||
onClick={() => {
|
||||
void onSelectTask(item.id);
|
||||
}}
|
||||
>
|
||||
<MessageSquareText
|
||||
className={cn("h-3.5 w-3.5 shrink-0", statusMeta.iconClassName)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate text-[11px] font-medium text-slate-800">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.hasUnread ? (
|
||||
<span
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-sky-500"
|
||||
data-testid={`task-center-tab-unread-${item.id}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{item.isPinned ? (
|
||||
<Pin
|
||||
className="h-2.5 w-2.5 shrink-0 text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"mr-0.5 rounded-full p-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:text-slate-700",
|
||||
item.isActive
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
aria-label={`关闭 ${item.title}`}
|
||||
data-testid={`task-center-tab-close-${item.id}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onCloseTask(item.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(tabUtilityButtonClassName, "ml-0.5")}
|
||||
data-testid="task-center-tab-create-button"
|
||||
aria-label="新建对话"
|
||||
title="新建对话"
|
||||
onClick={onCreateTask}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showToolbarActions ? (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5 border-l border-slate-200/80 pl-1"
|
||||
data-testid="task-center-tab-toolbar"
|
||||
>
|
||||
{showHistoryToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
className={tabUtilityButtonClassName}
|
||||
data-testid="task-center-tab-history"
|
||||
aria-label="切换历史"
|
||||
title="切换历史"
|
||||
onClick={onToggleHistory}
|
||||
>
|
||||
<Box className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{showCanvasToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
className={tabUtilityButtonClassName}
|
||||
data-testid="task-center-tab-canvas"
|
||||
aria-label={isCanvasOpen ? "折叠画布" : "展开画布"}
|
||||
title={isCanvasOpen ? "折叠画布" : "展开画布"}
|
||||
onClick={onToggleCanvas}
|
||||
>
|
||||
{isCanvasOpen ? (
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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("当前分工方案");
|
||||
|
||||
@@ -176,7 +176,7 @@ export function TeamWorkbenchSummaryPanel({
|
||||
<section className="rounded-[24px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5">
|
||||
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
<Workflow className="h-3.5 w-3.5" />
|
||||
<span>生成工作台</span>
|
||||
<span>生成</span>
|
||||
{runtimeTeamLabel ? (
|
||||
<span className="rounded-full border border-sky-200 bg-sky-50 px-2 py-0.5 text-[10px] font-medium tracking-normal text-sky-700 normal-case">
|
||||
{runtimeTeamLabel}
|
||||
@@ -196,7 +196,7 @@ export function TeamWorkbenchSummaryPanel({
|
||||
<div className="mt-2 text-sm font-semibold text-slate-900">
|
||||
{executionSummary.statusTitle ||
|
||||
runtimeFormationDisplay.panelHeadline ||
|
||||
"生成工作台已就绪,等待主线程开始编排"}
|
||||
"生成已就绪,等待主线程开始编排"}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-500">
|
||||
{runtimeFormationDisplay.panelDescription}
|
||||
|
||||
@@ -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("任务进行时");
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -501,7 +501,7 @@ export const mapSessionToTopic = (session: AsterSessionInfo): Topic => {
|
||||
statusReason: "default",
|
||||
lastPreview:
|
||||
messagesCount > 0
|
||||
? `已记录 ${messagesCount} 条消息,可继续补充或复盘。`
|
||||
? `已记录 ${messagesCount} 条消息,可继续补充或接着推进。`
|
||||
: "等待你补充任务需求后开始执行。",
|
||||
isPinned: false,
|
||||
hasUnread: false,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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<Record<string, unknown>>(cacheKey, {});
|
||||
const trimmedSnapshot = trimCachedSnapshot(snapshot);
|
||||
const nextMap = {
|
||||
...currentMap,
|
||||
[sessionId]: {
|
||||
...snapshot,
|
||||
...trimmedSnapshot,
|
||||
updatedAt: Date.now(),
|
||||
} satisfies AgentSessionCachedSnapshotRecord,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createEmptyAgentSessionSnapshot,
|
||||
hasSessionHydrationActivity,
|
||||
resolveRestorableTopicSessionId,
|
||||
shouldDeferSessionDetailHydration,
|
||||
} from "./agentSessionState";
|
||||
|
||||
function createMessage(overrides: Partial<Message> = {}): 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"),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<string>() },
|
||||
actionLoggedKeys: new Set<string>(),
|
||||
toolLogIdByToolId: new Map<string, string>(),
|
||||
toolStartedAtByToolId: new Map<string, number>(),
|
||||
toolNameByToolId: new Map<string, string>(),
|
||||
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<ActionRequired[]>(),
|
||||
setThreadItems: noopDispatch<AgentThreadItem[]>(),
|
||||
setThreadTurns: noopDispatch<AgentThreadTurn[]>(),
|
||||
setCurrentTurnId: noopDispatch<string | null>(),
|
||||
setExecutionRuntime: noopDispatch<AsterSessionExecutionRuntime | null>(),
|
||||
setIsSending: noopDispatch<boolean>(),
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -99,6 +99,15 @@ interface RegisterAgentStreamTurnEventBindingOptions {
|
||||
setIsSending: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
let inactivityWatchdogId: ReturnType<typeof setTimeout> | 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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user