diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1c2267c75..50da78be1 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,28 +1,32 @@ -## Lime v1.12.3 +## Lime v1.13.0 ### 主要更新 -- 将 macOS 应用 Bundle Identifier 从 `com.lime.app` 切换为 `com.limecloud.lime`,与 Apple Developer 后台新注册的 App ID 对齐 -- 同步更新 Homebrew Cask 的 macOS 清理路径,避免卸载逻辑继续引用旧的 `com.lime.app` -- 统一同步应用、Rust workspace、Tauri 配置和 CLI npm wrapper 版本到 `1.12.3` +- 收口 `SceneApp / Context Layer` 当前 latest-only 产品面,`detail / scorecard / run detail / governance` 统一改为消费同一份 `planResult / contextBaseline` 事实源 +- SceneApp planning、runtime、Tauri mock 与前端运行时已补齐显式灵感引用、项目级 Context Snapshot 基线、人工复核与轻量反馈沉淀链路 +- 修复 macOS 发布工作流:notarization 所需 secrets 缺失时直接阻断发布,不再继续产出 signed-only 但未 notarize 的安装包 +- 调整 macOS 发布校验顺序:在上传产物前增加 `.app` 的 `spctl` 和 `xcrun stapler validate` 检查,确保发布资产符合签名与公证预期 +- 统一同步应用、Rust workspace、Tauri 配置、锁文件和 CLI npm wrapper 版本到 `1.13.0` ### 版本与发布同步 - 应用版本入口已对齐到 `package.json`、`src-tauri/Cargo.toml`、`src-tauri/tauri.conf.json`、`src-tauri/tauri.conf.headless.json` -- CLI npm wrapper 版本与 README 发布示例已同步到 `1.12.3` -- macOS Bundle Identifier 已切换为 `com.limecloud.lime` -- 本次发布目标 tag 为 `v1.12.3` +- `package-lock.json`、`src-tauri/Cargo.lock`、`packages/lime-cli-npm/package.json` 与 `packages/lime-cli-npm/README.md` 已同步到 `1.13.0` +- 本次发布目标 tag 为 `v1.13.0` -### 计划执行校验 +### 已执行校验 -- `npm run verify:app-version` -- `npm run verify:gui-smoke` +- `npm run verify:app-version`:通过 +- `cargo fmt --manifest-path "src-tauri/Cargo.toml" --all`:已执行 +- `cargo test --manifest-path "src-tauri/Cargo.toml"`:通过,主测试集 `975` 个测试通过,额外 `2` 个定向集成测试通过;另有 `2` 个真实联网用例按默认配置保持 `ignored` +- `cargo clippy --manifest-path "src-tauri/Cargo.toml"`:通过,当前有 `1` 条非阻塞告警,位于 `src-tauri/crates/skills/src/lime_llm_provider.rs:255`,规则为 `clippy::too_many_arguments` +- `npm run lint`:通过 ### 发布说明 -- 这是一次以 macOS 应用身份对齐为主的补丁版本,目标是让仓库配置、Apple Developer App ID 和发布产物使用同一 Bundle Identifier -- 若 Apple Developer 协议已完成更新,本次 Release 可用于验证 notarization 是否恢复正常 +- 这是一次以 `SceneApp / Context Layer` 主线收口和发布链路修复为主的小版本发布,既覆盖场景规划与复盘事实源统一,也继续补齐 macOS 发版门禁 +- 若 macOS notarization 再次失败,CI 现在会直接失败并阻止发布,需要先修复签名或公证环境后再重新发版 --- -**完整变更**: `v1.12.2` -> `v1.12.3` +**完整变更**: `v1.12.3` -> `v1.13.0` diff --git a/docs/aiprompts/README.md b/docs/aiprompts/README.md index 46518ef6a..985f8b07f 100644 --- a/docs/aiprompts/README.md +++ b/docs/aiprompts/README.md @@ -49,7 +49,6 @@ - `services.md` - Rust 服务层 - `server.md` - HTTP Server 与接口边界 - `mcp.md` - MCP 服务器与工具管理 -- `terminal.md` - 内置终端与 PTY - `plugins.md` - 插件系统 - `aster-integration.md` - Aster Agent 集成 diff --git a/docs/aiprompts/commands.md b/docs/aiprompts/commands.md index 600c06dff..af985e744 100644 --- a/docs/aiprompts/commands.md +++ b/docs/aiprompts/commands.md @@ -102,6 +102,7 @@ - `sceneapp_list_catalog` - `sceneapp_get_descriptor` - `sceneapp_plan_launch` +- `sceneapp_save_context_baseline` - `sceneapp_create_automation_job` - `sceneapp_list_runs` - `sceneapp_get_run_summary` @@ -110,6 +111,8 @@ 固定约束: - `SceneApp` 命令当前属于目录查询、运行前规划与治理摘要主链,不应在页面组件里直接裸 `invoke` +- `sceneapp_plan_launch` 只负责 preview 当前 planning,并读取已有项目级 Context Snapshot;不要再把它当成自动落盘命令 +- 显式把当前灵感对象、输入摘要与风格基线写回项目目录时,只能走 `sceneapp_save_context_baseline` - 新的 SceneApp UI 先消费 `SceneAppDescriptor / SceneAppPlanResult / SceneAppScorecard`,不要重新从 `serviceSkills.ts`、`skillCatalog.ts`、卡片配置和 selector 里各自拼语义 - 真正的执行仍应通过 runtime adapter 继续挂回现有 `agent turn / browser_assist / automation_job / cloud_scene` 主链;`sceneapp_*` 不用于重新发明第二套 runtime taxonomy @@ -530,7 +533,9 @@ npm run verify:local - **Team runtime 工具主链**:当前协作工具面继续收敛到 `Agent / TeamCreate / TeamDelete / SendMessage / ListPeers`;不要把已删除的 `SubAgentTask` compat 工具重新接回新的多代理主路径 - **用户可见消息工具主链**:继续收敛到 `SendUserMessage`,用于把回复、进度同步、主动提醒和附件送到用户主可见消息面;不要再把这类能力拆到其它平行工具名或旁路协议里 - **会话状态回写主链**:继续收敛到 `agent_runtime_update_session`,用于名称、执行策略、session provider/model、`recent_access_mode`、`recent_preferences` 以及 `recent_team_selection` 的轻量持久化回写 -- **会话权限主链**:`agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` 是正式 turn context 权限协议;`getSession` 返回的 `execution_runtime.recent_access_mode` 负责承接会话最近一次 accessMode。当前端已命中同一 steady-state 权限时,不应继续依赖 `harness.access_mode` 作为唯一事实源 +- **会话权限主链**:`agent_runtime_submit_turn.turn_config.approval_policy / sandbox_policy` 是正式 turn context 权限协议;`getSession` 返回的 `execution_runtime.recent_access_mode` 负责承接会话最近一次 accessMode。当前端已命中同一 steady-state 权限时,不应继续依赖 `harness.access_mode` 作为唯一事实源;聊天发送主路径默认也不再主动发这个 compat 字段 +- **会话权限默认值**:当前默认 accessMode 固定收敛到 `full-access -> never + danger-full-access`;`current -> on-request + workspace-write` 与 `read-only -> on-request + read-only` 只允许来自用户显式切换、会话恢复或边界层迁移,不允许把 formal policy 留空后再让工具层自行猜默认;provider / thread-start 适配层也不得私自回退为 `workspace-write` +- **自动化 `agent_turn` 权限主链**:自动化任务 payload 也应直接写 `approval_policy / sandbox_policy`;`request_metadata.harness.access_mode` 只允许作为 legacy / compat 输入兜底,不应继续由新的自动化编辑入口主动写回 - **运行时 Provider 能力快照主链**:`agent_runtime_submit_turn.turn_config.provider_config` 允许携带 `model_capabilities / tool_call_strategy / toolshim_model` 这组三个运行时字段;后端会在真正发起 turn 前刷新它们,尤其是 `ollama` 会根据当前模型真实能力在原生 tools 与 `tool_shim` 之间做最终决策。前端不得把模型目录里的静态 tools 标记当作唯一真相 - **运行时交接导出主链**:继续收敛到 `agent_runtime_export_handoff_bundle`;前端统一通过 `src/lib/api/agentRuntime.ts` 网关进入,当前 GUI 入口位于 `HarnessStatusPanel` - **运行时证据导出主链**:继续收敛到 `agent_runtime_export_evidence_pack`,用于把 runtime / timeline / artifacts 打包成最小问题证据 diff --git a/docs/aiprompts/components.md b/docs/aiprompts/components.md index 5d6f9fa8d..6c2df71a4 100644 --- a/docs/aiprompts/components.md +++ b/docs/aiprompts/components.md @@ -12,7 +12,7 @@ src/components/ ├── provider-pool/ # 凭证池管理 ├── flow-monitor/ # 流量监控 ├── general-chat/ # 兼容画布桥接(非对话主入口) -├── terminal/ # 内置终端 +├── terminal/ # 已移除的历史终端页面路径(不要恢复) ├── mcp/ # MCP 服务器 ├── settings/ # 设置页面 └── AppSidebar.tsx # 全局侧边栏 diff --git a/docs/aiprompts/overview.md b/docs/aiprompts/overview.md index e14b397d7..48bc4538b 100644 --- a/docs/aiprompts/overview.md +++ b/docs/aiprompts/overview.md @@ -70,7 +70,7 @@ lime/ | 模块 | 说明 | |------|------| | `src/features/browser-runtime/` | 浏览器协助运行时与调试工作区 | -| `src-tauri/src/terminal/` | 内置终端与 PTY 会话 | +| `终端能力(已移除)` | 不属于当前能力层;仅在治理文档中保留退役说明与历史检索语义 | | `src-tauri/src/services/automation_service/` + `src-tauri/src/app/scheduler_service.rs` | 自动化任务、后台轮询与兼容调度触发壳 | | `src-tauri/src/plugin/` | 插件系统 | | `src-tauri/src/services/mcp_service.rs` | MCP 服务器与工具管理 | @@ -103,7 +103,7 @@ lime/ | `services/` | 心跳、OpenClaw、浏览器窗口、MCP 等业务服务 | | `converter/` | 协议兼容与转换 | | `server/` | HTTP Server 与 REST 能力 | -| `terminal/` | 终端与 PTY | +| `terminal/` | 已移除;不要恢复旧终端/PTTY 模块 | | `plugin/` | 插件加载与运行时 | | `voice/` | 语音输入输出与 ASR 流程 | @@ -228,7 +228,6 @@ lime/ - [state-history-telemetry.md](state-history-telemetry.md) - session/thread/request/evidence/history 的状态读模型主链 - [skill-standard.md](skill-standard.md) - 统一技能标准、目录与运行边界 - [../../src-tauri/src/skills/README.md](../../src-tauri/src/skills/README.md) - Skills 标准与集成 -- [terminal.md](terminal.md) - 终端能力 - [mcp.md](mcp.md) - MCP 服务器 - [plugins.md](plugins.md) - 插件系统 - [aster-integration.md](aster-integration.md) - Agent Runtime 集成 diff --git a/docs/aiprompts/terminal.md b/docs/aiprompts/terminal.md index c1c526567..2b9b6be62 100644 --- a/docs/aiprompts/terminal.md +++ b/docs/aiprompts/terminal.md @@ -1,73 +1,41 @@ -# 终端底层能力 +# 终端能力归档说明(已移除) + +本页仅用于治理归档与历史检索,不作为 current 模块入口。 ## 概述 -Lime 仍保留终端底层能力,用于复用运行时、诊断与会话管理;独立前端 `terminal / sysinfo / files / web` 页面已经下线,旧 `src/components/terminal/` 页面模块与 `src/lib/terminal/` 状态侧链都已删除。 +`lime-terminal` 已从当前仓库移除,Lime 不再提供内置 PTY / SSH / WSL 终端会话能力。独立前端 `terminal / sysinfo / files / web` 页面更早已下线;本轮收口后,旧 `src-tauri/src/terminal/`、`terminal_cmd`、`connection_cmd` 与 `src/lib/api/terminal.ts` 也不再作为 current surface 保留。 -## 目录结构 +## 当前结论 -``` -src-tauri/src/terminal/ -├── mod.rs # 模块入口 -├── pty.rs # PTY 管理 -├── session.rs # 会话管理 -└── commands.rs # 终端命令 +当前与“terminal”仍相关的能力只剩: -src/lib/api/terminal.ts -``` +- Windows 启动诊断里的默认 shell 判定 +- Aster runtime 中的 `SessionType::Terminal` 会话类型与相关读模型语义 +- 与 Agent / 执行工作区相关的 `latest_terminal / recent_terminals` 运行结果摘要字段 +- 文档与治理目录册中的历史痕迹扫描 -## PTY 管理 +这些都不是内置终端产品面,不应再被扩展回 PTY 会话、连接管理或终端页面。 -```rust -pub struct PtyManager { - sessions: HashMap, -} +## 治理分类 -pub struct PtySession { - id: String, - master: PtyMaster, - child: Child, -} +- `dead` + - `src-tauri/src/terminal/*` + - `src-tauri/src/commands/terminal_cmd.rs` + - `src-tauri/src/commands/connection_cmd.rs` + - `src/lib/api/terminal.ts` +- `current` + - `src/lib/api/serverRuntime.ts`、`crashDiagnostic.ts` 等非终端产品面的运行时诊断入口 + - Aster runtime 会话读模型里的 `SessionType::Terminal` + - 执行工作区中的 `latest_terminal / recent_terminals` 运行结果摘要 -impl PtyManager { - /// 创建新会话 - pub fn create_session(&mut self, shell: &str) -> Result; - - /// 写入数据 - pub fn write(&self, session_id: &str, data: &[u8]) -> Result<()>; - - /// 读取输出 - pub fn read(&self, session_id: &str) -> Result>; - - /// 调整大小 - pub fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<()>; - - /// 关闭会话 - pub fn close_session(&mut self, session_id: &str) -> Result<()>; -} -``` +## 约束 -## 前端边界 - -前端当前只允许通过 `src/lib/api/terminal.ts` 复用终端会话与事件能力;旧 `src/lib/terminal/*` 状态、VDOM、贴纸侧链已删除,不再恢复。 - -## Tauri 命令 - -```rust -#[tauri::command] -async fn terminal_create(shell: Option) -> Result; - -#[tauri::command] -async fn terminal_write(session_id: String, data: String) -> Result<(), String>; - -#[tauri::command] -async fn terminal_resize(session_id: String, cols: u16, rows: u16) -> Result<(), String>; - -#[tauri::command] -async fn terminal_close(session_id: String) -> Result<(), String>; -``` +- 不再新增任何 `terminal_*` / `connection_*` Tauri 命令 +- 不再恢复终端页面、终端 API 网关或 PTY 管理层 +- 如未来确有需求,必须重新定义新的 current surface,而不是复活旧 crate / 旧命令 / 旧页面 ## 相关文档 - [commands.md](commands.md) - Tauri 命令 -- [components.md](components.md) - 组件系统 +- [governance.md](governance.md) - 治理判断 diff --git a/docs/exec-plans/limenext-plan.md b/docs/exec-plans/limenext-plan.md index 3312e91ac..e94b15cfc 100644 --- a/docs/exec-plans/limenext-plan.md +++ b/docs/exec-plans/limenext-plan.md @@ -1,7 +1,7 @@ # LimeNext 总实施计划 > 状态:进行中 -> 更新时间:2026-04-16 +> 更新时间:2026-04-18 > 上位总纲:`docs/roadmap/limenext/` > 目标:把 LimeNext 从“上位平台定义”推进到“可持续实施的主线计划”,统一产品对象、运行时骨架、远程入口、长时执行、证据治理与场景选品的推进顺序。 @@ -76,7 +76,7 @@ LimeNext 当前不是缺一篇愿景文档,而是缺一条能持续推进的 - 当前会把 `run + scorecard + evidence / review` 翻译成业务向状态,而不是只展示底层技术字段 - 目录主视图已经能判断这条场景当前更适合: - 周会复盘 - - 任务中心 / 看板消费 + - 生成 / 看板消费 - 自动化任务跟进 - 结果编辑 / 发布 - 页面级治理动作继续复用 `src/lib/api/sceneapp.ts` 与既有文件打开链,不新增平行协议 @@ -88,6 +88,37 @@ LimeNext 当前不是缺一篇愿景文档,而是缺一条能持续推进的 - `detail / governance` 已补齐分页级空态与回退动作,不再让用户停在无下一步的空白页 - 顶部已新增业务向工作流导轨,明确表达 `选场景 -> 补启动 -> 看治理` 的跨页顺序 - 这条规则已经同步写回 `docs/aiprompts/design-language.md`,作为后续复杂工作台默认设计约束 +25. `创作场景执行摘要` 已从启动瞬间静态摘要继续推进到生成页运行态回流: + - `AgentChatWorkspace` 当前会按 `sessionId -> sceneapp run` 回查最近运行 + - 生成页顶部摘要卡已开始展示 `delivery completion / runtime evidence / governance artifact / observed failure signal` + - `AppPageContent` 已修正 `sceneapps` keep-alive 树位,`创作场景 -> 持续流程 -> 创作场景` 往返不会再触发目录页重挂载 +26. `创作场景 -> 持续流程 / 自动化` 的事实源已经开始统一,而不是继续各讲各的: + - `AutomationJobDetailsDialog` 当前会识别 `sceneapp` 派生任务的 metadata,并回查同一条 `descriptor / project pack plan / run summary / scorecard` + - 自动化详情里已新增 `创作场景闭环` 摘要块,可直接回到 `创作场景` 准备页或治理复盘页 + - 自动化详情里的结果文件、治理文件与治理动作,当前已复用 `SceneAppRunDetailViewModel + prepare_run_governance_artifact` 主链,不再额外发明 automation 专用 pack/gov 协议 +27. `创作场景 -> 场景目录` 当前也已开始接到同一份经营事实源,而不是继续停留在静态标题目录: + - `SceneAppsCatalogPanel` 目录卡片已开始同时展示 `delivery contract / latest run / scorecard action / top failure signal / operating summary` + - `useSceneAppsPageRuntime` 会按全量 descriptor 聚合最近运行与 scorecard,并把它们回流到同一份目录读模型 + - 目录卡片、生成页顶部执行摘要与自动化详情当前共享同一组 `descriptor + latest run + scorecard` 语义,不再各自发明一套“当前状态”解释 +28. `project pack` 已开始从治理复盘单点入口继续扩到 `生成准备 / 经营评分` 的结果消费入口: + - `SceneAppDetailPanel` 与 `SceneAppScorecardPanel` 当前都会展示最近可消费的 `Project Pack` 结果入口,而不再只停留在 `viewerLabel` + - 当最新运行仍在执行或尚未带回结果文件时,页面会自动回退到最近一轮已交付样本,而不是让准备页与评分页只剩规划文案 + - 结果入口仍继续复用现有 Agent 文件预览打开链,不新增新的 `Project Pack viewer` 协议 +29. `project pack` 已继续扩到 `生成主执行面` 的直接结果消费入口: + - `useSceneAppExecutionSummaryRuntime` 当前不再只回流 `runtimeBackflow` 摘要,还会同步回流最近可消费的 `Project Pack` 结果样本 + - `SceneAppExecutionSummaryCard` 已新增 `最近可消费结果` 区块,生成页可以直接打开结果文件,不再只停留在顶部摘要和 `viewerLabel` + - `生成主执行面` 与 `创作场景` 页面当前共享同一条 `findLatestSceneAppPackResultRun + deliveryArtifactEntries + Agent 文件预览打开链` current 主链,不再各自维护一套结果入口逻辑 +30. `生成主执行面` 已开始补齐回闭环动作,而不再让用户自己在左侧重新找入口: + - 执行摘要卡当前已新增 `回生成准备 / 去治理复盘` 深链动作 + - `去治理复盘` 会优先带到最近可消费样本对应的 run,而不是只回当前可能仍在执行中的 run + - 执行摘要卡当前还可直接触发 `填写人工复核 / 可继续复用 / 继续观察 / 补证据 / 先别继续` + - 这些动作继续复用 `review decision` 主链与 `RuntimeReviewDecisionDialog`,生成页不再复制平行治理协议 +31. `生成主执行面` 已开始补齐第一批 `生成后动作编排`,而不再只是“看结果 + 跳页面”: + - 执行摘要卡当前会直接展示 `周会复盘 / 生成 / 看板 / 持续流程 / 自动化 / 结果编辑 / 发布` 的推荐去向 + - 生成页当前可直接执行 `准备周会复盘包 / 准备结构化治理包 / 打开基础治理材料 / 恢复底层运行入口` + - 生成页当前还可在同一会话里直接触发 `补齐缺失部件 / 发布前检查 / 进入发布整理 / 生成渠道预览稿 / 整理上传稿`,继续复用 `@发布合规 / @发布 / @渠道预览 / @上传` 与当前 turn 提交主链,而不新增新的发布协议 + - `AgentChatWorkspace` 与 `SceneAppsPage` 当前共享 `resolveSceneAppRunEntryNavigationTarget + prepareSceneAppRunGovernanceArtifact(s)` current 主链,不再各自维护一份 run entry 恢复逻辑 + - 这一步把 `生成` 从“结果页旁边的跳转入口”推进成了第一批真正的统一编排面 尚未完成: @@ -101,7 +132,7 @@ LimeNext 当前不是缺一篇愿景文档,而是缺一条能持续推进的 8. 让基础设置包的 schema 继续下沉到 bootstrap / seeded catalog 可消费的目录投影。 9. 把 `composition blueprint` 从文档口径继续收口到可校验、可投影的装配对象。 10. 继续把 `project pack` 的结构化读模型从当前已接通的 `session / evidence + deliveryArtifactRefs` 优先聚合,扩展到更完整的 `artifact validator / request telemetry / evidence summary` 主链。 -11. 把 `project pack` 的治理闭环从当前已接通的“页面级治理看板 + 周会复盘包 / 结构化治理包”继续扩展到自动化任务中心、场景选品与更强的 evidence/review 闭环。 +11. 把 `project pack` 的治理闭环从当前已接通的“生成主执行面 + 场景目录 + 生成准备 + 治理复盘深链 + 经营评分 + 页面级治理看板 + 自动化详情 + 周会复盘包 / 结构化治理包 + 同聊补件 / 发布前检查 / 进入发布整理 / 渠道预览稿 / 上传稿”继续扩展到更完整的正式投放动作与更强的 evidence/review 闭环。 12. 把装配包从已接通的 `service_skill_catalog + scene_catalog` 继续扩到 `command_catalog` 投影代码。 13. 决定第一版是“客户端内编译 package”还是“服务端下发预编译 projection,客户端只做 gate + fallback”。 14. 把剩余 seeded 来源继续收口,重点转到 `command` 与 automation 相关入口,不再保留新的手写 `local_custom` 补丁。 @@ -241,7 +272,8 @@ LimeNext 的实施总目标固定为: - `project pack` 已开始在治理入口点击时自动补生成 evidence / review 文件,而不是让用户先回到 Agent 手动导出 - `project pack` 已开始把治理面升级成业务动作,当前可直接准备周会复盘包与结构化治理包 - `project pack` 已开始把治理面抬到 SceneApp 页面级看板,而不再只停留在单次运行详情里 -- 下一步不再只是补页面,而是要把这套治理看板继续接到任务中心、选品口径和后续自动治理主链 +- `project pack` 已开始把最近可消费结果入口接到 `生成主执行面`,不再只停留在 `创作场景` 子页消费 +- 下一步不再只是补页面,而是要把这套治理看板继续接到更完整的发布/复盘动作、选品口径和后续自动治理主链 ## 5. 默认样板链路 @@ -358,8 +390,8 @@ LimeNext 的实施总目标固定为: 当前下一步转为: 1. 决定客户端编译 vs 服务端预编译 gate 的最终定版位置 -2. 把多模态组合样板的 `project pack` 从已接通的真实 artifact + 页面级治理看板继续扩到任务中心与自动治理消费入口 -3. 把 `SceneScorecard` 与周会模板、页面级治理看板和任务中心口径继续打通 +2. `创作场景 -> 生成` 的执行摘要、`自动化详情`、`场景目录`、`生成准备` 和 `经营评分` 都已经接通运行态回流;下一步把同一份 `Project Pack + governance` 基线继续扩到生成主执行面的直接结果消费与更完整的治理消费入口 +3. 把 `SceneScorecard` 与周会模板、页面级治理看板和生成口径继续打通 4. 继续让 automation detail / SceneApp run / scorecard 聚合消费同一组 base-setup refs,而不是回退到运行时猜测 这样 LimeNext 才会从“目录、启动、复盘、恢复、durable 装配都闭环”继续推进到“装配 gate 定版、组合交付与经营聚合也闭环”。 diff --git a/docs/exec-plans/limenext-progress.md b/docs/exec-plans/limenext-progress.md index 39db31fd6..84e51a809 100644 --- a/docs/exec-plans/limenext-progress.md +++ b/docs/exec-plans/limenext-progress.md @@ -1,9 +1,504 @@ # LimeNext 推进日志 +> 注:自 2026-04-17 起,LimeNext current 前台主词固定为 `创作场景 / 灵感库 / 生成 / Project Pack / 复盘`。历史日志里若继续出现 `SceneApp / 场景应用 / 记忆工作台` 等词,默认按当时实现阶段或 compat 旧称理解,不再代表 current 产品口径。 +> +> 补充说明:2026-04-17 中段几次关于“纯标题目录 / 无图场景墙”的尝试,当前都已被后续 `经营信号目录卡片` 方案取代。阅读本日志时,凡旧条目仍写“纯标题目录”,都按已废弃试探理解;current 以最新“目录卡片经营信号回流”条目为准。 + +## 2026-04-18 + +### 已完成 + +- 把 `生成主执行面` 的 `同聊推进` 从“发布前整理”继续扩到现有 `渠道预览 / 上传稿` 主链: + - 已更新: + - `src/lib/sceneapp/executionPromptActions.ts` + - `src/lib/sceneapp/executionPromptActions.test.ts` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx` + - `docs/exec-plans/limenext-plan.md` + - `docs/exec-plans/limenext-progress.md` + - `docs/roadmap/limenext/README.md` + - 当前统一结论: + - `生成` 页执行摘要卡当前不再只停在 `补齐缺失部件 / 发布前检查 / 进入发布整理`,而是继续补到了 `生成渠道预览稿 / 整理上传稿` + - 这两条动作继续复用仓库里已经存在的 `@渠道预览 / @上传` 命令面与 `content_post_with_cover` 主链,不新增新的投放协议或平行发布系统 + - `生成渠道预览稿 / 整理上传稿` 与 `进入发布整理` 一样,当前都会按缺件和失败信号自动禁用并显式提示阻塞原因 + - 当前已确认通过: + - `npm exec vitest run "src/lib/sceneapp/executionPromptActions.test.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx"` + - `npx eslint "src/lib/sceneapp/executionPromptActions.ts" "src/lib/sceneapp/executionPromptActions.test.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/components/agent/chat/AgentChatWorkspace.tsx"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + - 当前补充说明: + - 本轮第一次 `verify:gui-smoke` 在 `smoke:agent-runtime-tool-surface-page` 遇到一次 `bridge cooldown active` 环境抖动;未改代码重跑后完整 GUI smoke 已通过 + - 因此这次结论按“重跑后的完整 smoke 通过”记录,不把第一次环境性抖动误记成主线回归 + +- 把 `生成主执行面` 的 `生成后动作编排` 继续补到“同聊推进”层,而不再停在治理材料和页面跳转: + - 已更新: + - `src/lib/sceneapp/executionPromptActions.ts` + - `src/lib/sceneapp/executionPromptActions.test.ts` + - `src/lib/sceneapp/index.ts` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx` + - `src/components/agent/chat/AgentChatWorkspace.tsx` + - `docs/exec-plans/limenext-plan.md` + - `docs/exec-plans/limenext-progress.md` + - `docs/roadmap/limenext/README.md` + - 当前统一结论: + - `生成` 页执行摘要卡当前已新增 `同聊推进` 分组,可直接触发 `补齐缺失部件 / 发布前检查 / 进入发布整理 / 生成渠道预览稿 / 整理上传稿` + - `发布前检查 / 进入发布整理 / 生成渠道预览稿 / 整理上传稿` 继续复用现有 `@发布合规 / @发布 / @渠道预览 / @上传` 主链,`AgentChatWorkspace` 直接把动作 prompt 发送到当前会话,不新增新的 viewer 或发布协议 + - `进入发布整理 / 生成渠道预览稿 / 整理上传稿` 当前都会按缺件和失败信号自动禁用,并显式提示阻塞原因;因此“先治理、先预览还是先整理上传稿”不再只靠用户自己判断 + - 旧结论里若继续写“生成后动作编排还停在准备治理材料 + 恢复底层入口”,当前都按已过时理解 + - 当前已确认通过: + - `npm exec vitest run "src/lib/sceneapp/executionPromptActions.test.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx"` + - `npx eslint "src/lib/sceneapp/executionPromptActions.ts" "src/lib/sceneapp/executionPromptActions.test.ts" "src/lib/sceneapp/index.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/components/agent/chat/AgentChatWorkspace.tsx"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + - 当前补充说明: + - 本轮第一次 `verify:gui-smoke` 曾被旧的本地 `lime` 调试进程占住 `3030/8999` 端口,导致新的 headless Tauri 半启动、workspace smoke 长时间挂起;清掉残留验证进程并重跑后,完整 GUI smoke 已通过 + - 因此这次交付结论以“重跑后的完整 GUI smoke 通过”为准,而不是以第一次被本地残留进程污染的结果为准 + +- 把 `生成主执行面` 继续补成第一批 `生成后动作编排`,不再只是“打开结果文件 + 跳回创作场景”: + - 已更新: + - `src/lib/sceneapp/runEntryNavigation.ts` + - `src/lib/sceneapp/runEntryNavigation.test.ts` + - `src/lib/sceneapp/index.ts` + - `src/lib/sceneapp/product.ts` + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/components/agent/chat/AgentChatWorkspace.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx` + - `docs/exec-plans/limenext-plan.md` + - `docs/exec-plans/limenext-progress.md` + - `docs/roadmap/limenext/README.md` + - 当前统一结论: + - `生成` 页执行摘要卡当前会直接展示第一批推荐去向:`周会复盘 / 生成 / 看板 / 持续流程 / 自动化 / 结果编辑 / 发布` + - 生成页当前可直接执行 `准备周会复盘包 / 准备结构化治理包 / 打开基础治理材料 / 恢复底层运行入口` + - 上面这组动作当前已不再是终点;同一张摘要卡里已经继续补上 `补齐缺失部件 / 发布前检查 / 进入发布整理` + - `AgentChatWorkspace` 与 `SceneAppsPage` 当前共享 `resolveSceneAppRunEntryNavigationTarget + prepareSceneAppRunGovernanceArtifact(s)` current 主链,不再各自维护一套 run entry 恢复逻辑 + - 这一步把 `生成` 从“摘要页 + 导航中转站”推进为第一批真正的统一编排面,也把之前“只支持页面跳转”的旧判断标记为已过时 + - 当前已确认通过: + - `npm exec vitest run "src/lib/sceneapp/runEntryNavigation.test.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx"` + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npx eslint "src/lib/sceneapp/runEntryNavigation.ts" "src/lib/sceneapp/runEntryNavigation.test.ts" "src/components/sceneapps/useSceneAppsPageRuntime.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/components/agent/chat/AgentChatWorkspace.tsx" "src/lib/sceneapp/product.ts"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + - 当前补充说明: + - 本轮 `verify:gui-smoke` 已完整通过,说明这次 `生成后动作编排` 与共享 run entry 导航 helper 没有把 GUI 主路径打断 + - 旧日志里若继续出现“生成页当前还只支持页面跳转”之类说法,当前都按已被本条替代的过时阶段理解 + +- 把 `生成主执行面` 正式接到 current `Project Pack` 最近结果入口,而不再只停留在顶部摘要文案: + - 已更新: + - `src/lib/sceneapp/runtimeResults.ts` + - `src/lib/sceneapp/index.ts` + - `src/lib/sceneapp/product.ts` + - `src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.ts` + - `src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx` + - `src/components/agent/chat/AgentChatWorkspace.tsx` + - `src/components/sceneapps/SceneAppProjectPackRuntimePanel.tsx` + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `docs/exec-plans/limenext-plan.md` + - `docs/roadmap/limenext/README.md` + - 当前统一结论: + - `useSceneAppExecutionSummaryRuntime` 当前不再只回流 `runtimeBackflow`,还会同步回流最近可消费的 `Project Pack` 结果样本,以及“当前 run 无文件时回退到最近已交付样本”的状态 + - `SceneAppExecutionSummaryCard` 已新增 `最近可消费结果` 区块,`生成` 页可以直接打开结果文件,不再逼用户先跳回 `创作场景` 子页 + - `SceneAppsPage` 与 `生成主执行面` 当前共享同一套 `findLatestSceneAppPackResultRun + resolveSceneAppRuntimeArtifactOpenTarget + deliveryArtifactEntries` current 打开链,不再各自维护一份结果入口协议 + - 这一步已经把 `创作场景 -> 生成 -> Project Pack -> 复盘` 里的“结果消费”真正补到 `生成` 主舞台,而不是继续停在准备页、评分页和治理页的旁路消费 + - 当前已确认通过: + - `npm exec vitest run "src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx"` + - `npm exec vitest run "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx"` + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx" "src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx" "src/components/sceneapps/useSceneAppsPageRuntime.ts" "src/components/sceneapps/SceneAppProjectPackRuntimePanel.tsx" "src/lib/sceneapp/product.ts" "src/lib/sceneapp/runtimeResults.ts"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + - 当前补充说明: + - `npm run verify:gui-smoke` 这轮已实际通过,说明 `生成主执行面` 与 `SceneApps` 共用的 current 结果入口链没有把 GUI 主路径打断 + - `npm run verify:local` 这轮未能整体通过,但失败点来自仓库里与本轮无关的既有 lint 问题: + - `src/components/agent/chat/workspace/useWorkspaceSendActions.ts` + - `src/components/agent/chat/workspace/workspaceSendHelpers.ts` + - 当前没有证据表明这些失败与本轮 `Project Pack` 结果入口改动相关,因此主线交付结论以 `定向回归 + typecheck + GUI smoke` 为准 + +- 把 `生成主执行面` 继续补成“结果消费 + 回闭环动作”入口,而不再让用户自己找回 `创作场景` 页: + - 已更新: + - `src/components/agent/chat/AgentChatWorkspace.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx` + - `docs/exec-plans/limenext-plan.md` + - `docs/exec-plans/limenext-progress.md` + - `docs/roadmap/limenext/README.md` + - 当前统一结论: + - 生成页执行摘要卡当前已新增 `回生成准备 / 去治理复盘` 两个继续动作 + - `去治理复盘` 当前会优先带到最近可消费样本对应的 run,而不是只盯着当前可能仍在执行的 run;因此结果消费与人工复核、轻量判断、治理动作已经开始接上同一条闭环 + - 生成页当前还能直接触发 `填写人工复核 / 可继续复用 / 继续观察 / 补证据 / 先别继续`,继续复用现有 `review decision` 主链与 `RuntimeReviewDecisionDialog` + - 这一步继续复用 `SceneAppsPage` 既有准备页与治理页,以及已有 `review decision` 协议,不在 `claw` 内再复制一套平行治理 UI,保持 current 事实源单一 + - 当前已确认通过: + - `npm exec vitest run "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx"` + - `npm exec vitest run "src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx"` + - 定向 `eslint` + - 当前补充说明: + - 这一步主要补的是 `生成 -> 创作场景` 的回流动作层与直接复盘动作,没有新增新的运行态协议或页面对象 + - `npm run typecheck` 这轮未能整体通过,但失败点来自仓库里与本轮无关的既有问题: + - `src/components/onboarding/steps/VoiceShortcutTestStep.test.tsx` + - `npm run verify:gui-smoke` 这轮也未能完成,阻塞点仍是已有 headless 构建环境里的 `libsqlite3-sys` 临时 target `bindgen.rs` 缺失,不是这轮 `生成 -> 创作场景` 深链动作回归 + +## 2026-04-17 + +### 已完成 + +- 把 `Project Pack` 的结果消费入口从 `治理复盘 / 运行详情` 继续扩到 `生成准备 / 经营评分`: + - 已更新: + - `src/components/sceneapps/SceneAppProjectPackRuntimePanel.tsx` + - `src/components/sceneapps/SceneAppDetailPanel.tsx` + - `src/components/sceneapps/SceneAppScorecardPanel.tsx` + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `docs/roadmap/limenext/README.md` + - `docs/exec-plans/limenext-plan.md` + - 当前统一结论: + - `生成准备` 与 `经营评分` 当前不再只展示 `Project Pack` 的查看方式文案,而会直接回流最近可消费样本的结果入口 + - 当最新运行仍在执行、或没有带回结果文件路径时,页面会自动回退到最近一轮已交付样本,避免准备页和评分页只剩静态规划信息 + - 结果入口继续复用 `SceneAppRunDetailViewModel.deliveryArtifactEntries -> Agent 文件预览打开链`,没有新增新的 `Project Pack viewer` 协议 + - 当前已确认通过: + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npx eslint "src/components/sceneapps/SceneAppProjectPackRuntimePanel.tsx" "src/components/sceneapps/SceneAppDetailPanel.tsx" "src/components/sceneapps/SceneAppScorecardPanel.tsx" "src/components/sceneapps/SceneAppsPage.tsx" "src/components/sceneapps/useSceneAppsPageRuntime.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm run typecheck` + - GUI 续测结论: + - `npm run verify:gui-smoke` 当前不再卡 `libsqlite3-sys`;本轮失败点转为复用环境下的 `smoke:browser-runtime` 清理旧 `session_id` 时 `close_cdp_session` 返回“未找到 session”,属于浏览器运行时清理链问题,不是 `sceneapps` 主路径回归 + - 已改用真实页面续测补一刀:打开 `http://127.0.0.1:1420/`,进入 `创作场景 -> 生成准备 / 治理复盘`,确认 `最近结果入口 / 最近可消费结果` 面板已出现,控制台无新增 error,仅保留既有 `i18n` warn + - 当前 seed 运行样本在真实 GUI 环境里还没有带回可打开的结果文件路径,所以真实页面续测主要证明入口渲染与回退语义;“点击主稿文件即可打开”继续由新增的定向 Vitest 回归断言兜底 + +- 把 `创作场景` 目录分页正式接到 current `descriptor + latest run + scorecard` 同一事实源: + - 已更新: + - `src/lib/sceneapp/product.ts` + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/components/sceneapps/SceneAppsCatalogPanel.tsx` + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `docs/roadmap/limenext/README.md` + - `docs/exec-plans/limenext-plan.md` + - 当前统一结论: + - 场景目录卡片当前不再只是静态标题列表,而会同时回流 `delivery contract / latest run / scorecard action / top failure signal / operating summary` + - `useSceneAppsPageRuntime` 当前会在目录层聚合所有 descriptor 对应的 `latest run + scorecard`,并把这些经营信号压到同一份 `SceneAppCatalogCardViewModel` + - 目录卡片、生成页顶部执行摘要与自动化详情当前共享同一组 `descriptor + latest run + scorecard` 语义,不再各讲各的“当前状态” + - 这意味着 `创作场景` 目录已经从“选一个标题进去”推进到“先看哪条场景值得继续推进、该先补什么”这一层产品判断 + - 当前已确认通过: + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx" "src/lib/sceneapp/product.test.ts"` + - `npx eslint "src/components/sceneapps/SceneAppsCatalogPanel.tsx" "src/components/sceneapps/SceneAppsPage.tsx" "src/components/sceneapps/useSceneAppsPageRuntime.ts" "src/lib/sceneapp/product.ts" "src/components/sceneapps/SceneAppsPage.test.tsx" "src/lib/sceneapp/product.test.ts"` + - `npm run typecheck` + - 当前仍待继续: + - 本条能力本身已被后续更完整的 GUI 续测覆盖;当前整套 `verify:gui-smoke` 的最新阻塞已不再是 `libsqlite3-sys`,而是复用环境下 `smoke:browser-runtime` 清理旧会话失败 + +- 把 `持续流程 / 自动化` 详情页正式接到 current `创作场景 -> 生成 -> Project Pack -> 复盘` 同一事实源: + - 已更新: + - `src/lib/sceneapp/automation.ts` + - `src/lib/sceneapp/automation.test.ts` + - `src/components/settings-v2/system/automation/useAutomationSceneAppRuntime.ts` + - `src/components/settings-v2/system/automation/index.tsx` + - `src/components/settings-v2/system/automation/index.test.tsx` + - `src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx` + - `src/components/automation/AutomationPage.tsx` + - `docs/roadmap/limenext/README.md` + - `docs/exec-plans/limenext-plan.md` + - 当前统一结论: + - 自动化详情弹窗当前不再只看 `调度状态 / payload / run history`;如果任务来自创作场景派生,它会按 job metadata 回查同一条 `SceneApp descriptor / project pack plan / run summary / scorecard` + - 弹窗里已新增 `创作场景闭环` 摘要块,能直接回到 `创作场景准备` 或 `治理复盘` + - 结果文件、治理文件与治理动作当前已复用 `SceneAppRunDetailViewModel + sceneapp_prepare_run_governance_artifact(s)` 主链,不再额外发明 automation 专用 pack / governance 协议 + - `SceneApp` 在这条链里继续只承担内部画像与运行时合同;自动化前台看到的仍是 `创作场景` 闭环,而不是新的对外主词 + - 当前已确认通过: + - `npm exec vitest run "src/lib/sceneapp/automation.test.ts" "src/components/settings-v2/system/automation/index.test.tsx"` + - `npm exec vitest run "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.test.tsx"` + - `npx eslint "src/lib/sceneapp/automation.ts" "src/lib/sceneapp/automation.test.ts" "src/components/settings-v2/system/automation/useAutomationSceneAppRuntime.ts" "src/components/settings-v2/system/automation/index.tsx" "src/components/settings-v2/system/automation/index.test.tsx" "src/components/settings-v2/system/automation/AutomationJobDetailsDialog.tsx" "src/components/automation/AutomationPage.tsx"` + - `npm run typecheck` + - 当前仍待继续: + - `npm run verify:gui-smoke` 本轮连续两次都卡在本地 headless Tauri Rust 构建环境,`libsqlite3-sys` 在临时 target 目录缺失 `bindgen.rs`,导致 DevBridge 未就绪;这次阻塞发生在 GUI smoke 启动期,而不是自动化 / 创作场景业务断链 + - `npm run verify:local` 本轮未继续执行,因为当前会被同一条 GUI smoke 环境故障阻塞 +- 把生成页顶部的 `创作场景执行摘要` 从启动静态卡继续补成“启动基线 + 运行态回流”的 current 主链: + - 已更新: + - `src/lib/sceneapp/product.ts` + - `src/lib/sceneapp/product.test.ts` + - `src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.ts` + - `src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx` + - `src/components/agent/chat/AgentChatWorkspace.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx` + - `src/components/AppPageContent.tsx` + - `src/components/AppPageContent.test.tsx` + - `docs/roadmap/limenext/README.md` + - `docs/exec-plans/limenext-plan.md` + - 当前统一结论: + - `生成` 页顶部的执行摘要当前不再只展示 `sceneapp_plan_launch` 时刻的 planning 快照,还会按当前 `sessionId -> sceneapp run` 回查最近运行,并把交付完成度、证据链、治理材料与失败信号继续回流到同一张摘要卡 + - `Project Pack / scorecard / governance` 这条运行态回流当前已开始直接进入 `生成主执行面`,用户不用再先跳回 `创作场景 / 复盘` 才能看到最近一次运行的主链状态 + - 为了守住 keep-alive 主路径,[AppPageContent.tsx](/Users/coso/Documents/dev/ai/aiclientproxy/lime/src/components/AppPageContent.tsx) 当前已把 `sceneapps` 常驻节点固定在稳定树位,`创作场景 -> 持续流程 -> 创作场景` 往返时不会再重挂载目录页 + - 当前已确认通过: + - `npm exec vitest run "src/lib/sceneapp/product.test.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx" "src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx" "src/components/AppPageContent.test.tsx"` + - `npx eslint "src/components/agent/chat/AgentChatWorkspace.tsx" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx" "src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.ts" "src/components/agent/chat/workspace/useSceneAppExecutionSummaryRuntime.test.tsx" "src/lib/sceneapp/product.ts" "src/lib/sceneapp/product.test.ts" "src/components/AppPageContent.tsx" "src/components/AppPageContent.test.tsx"` + - `npm run typecheck` + - `npm run verify:gui-smoke` + +- 把 `创作场景 -> 生成` 的执行摘要真正接进 current `生成主执行面`,不再只把一段 prompt 丢给 `claw`: + - 已更新: + - `src/lib/sceneapp/product.ts` + - `src/lib/sceneapp/launch.ts` + - `src/types/page.ts` + - `src/components/AppPageContent.tsx` + - `src/components/agent/chat/AgentChatWorkspace.tsx` + - `src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.tsx` + - `src/components/agent/chat/workspace/WorkspaceConversationScene.tsx` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.tsx` + - `src/lib/sceneapp/launch.test.ts` + - `src/components/agent/chat/workspaceEntry.test.ts` + - `src/components/AppPageContent.test.tsx` + - `src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts` + - `src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx` + - `docs/roadmap/limenext/README.md` + - `docs/exec-plans/limenext-plan.md` + - 当前统一结论: + - `创作场景` 进入 `生成` 时,`Project Pack / scorecard / context baseline` 已通过 `initialSceneAppExecutionSummary` 进入生成页顶部,而不是再混进 runtime request metadata + - `initialAutoSendRequestMetadata` 当前也已显式透传到 `AgentChatPage`,`cloud_scene / browser_assist` 这类自动发送首刀不再丢失 metadata 主链 + - `生成` 页顶部现在会先展示“创作场景执行摘要”,再进入 step progress、timeline 与继续执行,用户可以直接看见当前场景的执行主链、上下文分层、Project Pack 合同与 scorecard 口径 + - 当前已确认通过: + - `npm exec vitest run "src/lib/sceneapp/launch.test.ts" "src/components/agent/chat/workspaceEntry.test.ts" "src/components/AppPageContent.test.tsx" "src/components/agent/chat/workspace/useWorkspaceConversationSceneRuntime.test.ts" "src/components/agent/chat/workspace/SceneAppExecutionSummaryCard.test.tsx"` + +- 修掉 `治理复盘` 带 `runId` 重新进入时会在 runs 列表加载阶段回退到首条运行的状态抖动: + - 已更新: + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `src/components/AppPageContent.test.tsx` + - 当前统一结论: + - 目录刚加载完、场景选择还未落稳的过渡帧,runtime 不会再提前清空已有 `selectedRunId` + - 带 `runId` 重进 `治理复盘` 时,右侧运行详情会继续稳定指向该 run,而不是被 runs 列表里的首条记录覆盖 + - `AppPageContent` 的页面挂载回归也已同步修正,`verify:gui-smoke` 不会再在 `agent-service-skill-entry` 阶段被旧测试写法误伤 + - 当前已确认通过: + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx" -t "带 runId 重新进入治理页时不应在运行列表加载阶段回退到首条运行"` + - `npm exec vitest run "src/components/AppPageContent.test.tsx"` + - `npm run verify:gui-smoke` + +- 把 `agentEntry=claw` 的 current 前台产品定义收口为 `生成主执行面`,不再继续用 `任务中心` 作为主叙事: + - 已更新: + - `src/lib/navigation/sidebarNav.ts` + - `src/types/page.ts` + - `src/components/agent/chat/workspace/chatSurfaceProps.ts` + - `src/components/agent/chat/components/MessageList.tsx` + - `src/components/agent/chat/components/ChatSidebar.tsx` + - `src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx` + - `src/components/agent/chat/service-skills/skillPresentation.ts` + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/SceneAppDetailPanel.tsx` + - `src/lib/sceneapp/presentation.ts` + - `docs/roadmap/limenext/README.md` + - `docs/roadmap/limenext/prd.md` + - `docs/roadmap/limenext/architecture.md` + - `docs/roadmap/limenext/context-layer.md` + - `docs/roadmap/limenext/sceneapp-business-flows.md` + - `docs/roadmap/limenext/sceneapp-fullstack-implementation-plan.md` + - `docs/roadmap/ribbi/product-positioning-and-ia.md` + - 当前统一结论: + - 侧边栏与前台 current 主词现在固定为 `新建任务 / 生成 / 我的方法 / 创作场景 / 消息渠道 / 资料库 / 灵感库` + - `agentEntry=claw` 继续保留为内部实现 route,但前台已统一按 `生成` 理解 + - `创作场景` 页当前明确退回执行前选路、planning 和上下文基线装配页,不再承担长期主执行面职责 + - `生成` 当前是 Lime 创作闭环唯一主执行面,继续承接执行过程、继续旧历史、最近结果与后续结果回流 + +- 把 `sceneapp_plan_launch` 与 `sceneapp_save_context_baseline` 的语义彻底拆开,收口成 latest-only 的显式场景基线主链: + - 已更新: + - `src-tauri/src/sceneapp/application.rs` + - `src-tauri/src/commands/sceneapp_cmd.rs` + - `src-tauri/src/app/runner.rs` + - `src-tauri/src/dev_bridge/dispatcher/sceneapp.rs` + - `src-tauri/src/dev_bridge/dispatcher.rs` + - `src/lib/api/sceneapp.ts` + - `src/lib/governance/agentCommandCatalog.json` + - `src/lib/dev-bridge/mockPriorityCommands.ts` + - `src/lib/tauri-mock/core.ts` + - `src/lib/tauri-mock/core.test.ts` + - `src/lib/api/sceneapp.test.ts` + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/components/sceneapps/SceneAppDetailPanel.tsx` + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `docs/aiprompts/commands.md` + - `docs/roadmap/limenext/README.md` + - `docs/roadmap/limenext/context-layer.md` + - `docs/roadmap/limenext/inspiration-library-and-taste-profile.md` + - `docs/roadmap/limenext/sceneapp-fullstack-implementation-plan.md` + - `docs/roadmap/limenext/sceneapp-runtime-contract.md` + - 当前统一结论: + - `sceneapp_plan_launch` 现在只负责 preview 当前 planning,并读取已有项目级 Context Snapshot + - `sceneapp_save_context_baseline` 才代表把当前灵感对象、输入摘要与风格偏好显式写回 `.lime/sceneapp/context/.json` + - `创作场景` 详情页已新增“写入当前场景基线”按钮,不再把 preview planning 伪装成自动资产沉淀 + - 浏览器 mock、DevBridge、Tauri 注册、治理目录册与前端 API 网关都已同步到同一条 current 命令链 + - 当前已确认通过: + - `cargo test --manifest-path "src-tauri/Cargo.toml" sceneapp::` + - `npm exec vitest run "src/lib/api/sceneapp.test.ts" "src/lib/tauri-mock/core.test.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` + +- 把 `创作场景治理页 / 运行详情 -> 轻量反馈按钮` 接回 current `review decision` 主链: + - 已更新: + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/SceneAppGovernancePanel.tsx` + - `src/components/sceneapps/SceneAppRunDetailPanel.tsx` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - `docs/roadmap/limenext/README.md` + - `docs/roadmap/limenext/inspiration-library-and-taste-profile.md` + - `docs/roadmap/limenext/metrics-and-selection.md` + - `docs/roadmap/limenext/sceneapp-fullstack-implementation-plan.md` + - `docs/roadmap/limenext/sceneapp-runtime-contract.md` + - 当前统一结论: + - `可继续复用 / 继续观察 / 补证据 / 先别继续` 四个轻量判断按钮,当前已经落到治理看板与运行详情 + - 这些按钮不会新造 `sceneapp_feedback_*` 协议,而是先复用 `agent_runtime_export_review_decision_template / agent_runtime_save_review_decision` + - 保存后仍会沿用同一条 `review decision -> Context Snapshot -> planning 刷新` 主链 + - 完整人工复核入口继续保留;轻量判断只负责把高频结论更快写回 current 资产链 + - 当前已确认通过: + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm run typecheck` + +- 补齐 `SceneApp current 命令 -> DevBridge browser 开发模式` 的最后一段桥接,修掉创作场景页在浏览器模式下的 `unknown command` 断链: + - 已更新: + - `src-tauri/src/dev_bridge/dispatcher/sceneapp.rs` + - `src-tauri/src/dev_bridge/dispatcher.rs` + - 当前统一结论: + - `sceneapp_list_catalog / sceneapp_get_descriptor / sceneapp_plan_launch / sceneapp_save_context_baseline / sceneapp_create_automation_job / sceneapp_list_runs / sceneapp_get_run_summary / sceneapp_prepare_run_governance_artifact / sceneapp_get_scorecard` 在 current 不只注册在 Tauri `generate_handler!`,也已显式接入 DevBridge `dispatcher` + - 浏览器开发模式下的 `创作场景` 页面已不再出现 `sceneapp_plan_launch / sceneapp_list_runs / sceneapp_get_scorecard` 的 `未知命令` + - current `sceneapp_*` 现在在桌面壳与浏览器开发模式下共享同一条 SceneApp 真相链,不再出现“合同存在但 DevBridge 少桥一半”的断层 + - 当前已确认通过: + - `cargo test --manifest-path "src-tauri/Cargo.toml" sceneapp_` + - `npm run test:contracts` + - 直接打开 `http://127.0.0.1:1420/`,进入 `创作场景` 页面后,控制台只剩 `i18n` warning,不再出现 `sceneapp_*` unknown command + - `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` + - 本轮额外确认: + - 中途曾出现一次 `smoke:agent-service-skill-entry` 串联超时,表面报错是 `src/components/agent/chat/index.test.tsx` 的 `beforeAll` hook timeout + - 随后定向复跑 `node scripts/agent-service-skill-entry-smoke.mjs` 与整套 `verify:gui-smoke` 已恢复通过,说明这次更像运行时抖动,而不是 SceneApp / DevBridge current 主链回归 + +- 把 `review decision -> 项目级 Context Snapshot` 的显式人工复核入口接回 current 主链: + - 已更新: + - `src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs` + - `src-tauri/src/sceneapp/application.rs` + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/components/sceneapps/SceneAppsPage.tsx` + - `src/components/sceneapps/SceneAppGovernancePanel.tsx` + - `src/components/sceneapps/SceneAppRunDetailPanel.tsx` + - `src/lib/sceneapp/product.ts` + - `src/lib/sceneapp/product.test.ts` + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - 当前统一结论: + - `agent_runtime_save_review_decision` 保存完成后,会尝试按 `session_id -> sceneapp run` 反查当前运行,并把人工复核结论同步回 `.lime/sceneapp/context/.json` + - `ReferenceItem.lastFeedbackLabel` 当前可直接承接 `人工接受 / 人工延后 / 人工否决 / 人工补证据` 等显式人工结论 + - `TasteProfile.feedbackSummary / feedbackSignals / lastFeedbackAt` 当前会同时承接自动反馈与人工复核,不再额外新增 `sceneapp_feedback_*` 协议 + - 创作场景治理页已经复用现有 `RuntimeReviewDecisionDialog`,保存后会刷新当前 planning 基线 + - 当前已确认通过: + - `cargo test --manifest-path "src-tauri/Cargo.toml" sceneapp::` + - `npm exec vitest run "src/lib/sceneapp/product.test.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm run typecheck` + - `npm run test:contracts` + - 当前仍待继续: + - GUI smoke 当前被复用中的 `DevBridge health` 阻塞,尚未完成这轮 GUI 交付验证 + +- 把 `灵感库 -> 创作场景` 的正式引用对象链补齐到 current 主链: + - 已更新: + - `src/components/memory/MemoryPage.tsx` + - `src/components/sceneapps/useSceneAppsPageRuntime.ts` + - `src/lib/sceneapp/navigation.ts` + - `src/lib/sceneapp/launch.ts` + - `src/lib/sceneapp/launcher.ts` + - `src-tauri/src/sceneapp/application.rs` + - `src-tauri/src/sceneapp/context/compiler.rs` + - `src-tauri/src/sceneapp/runtime.rs` + - `src-tauri/src/sceneapp/adapters.rs` + - `src/lib/tauri-mock/core.ts` + - 当前统一结论: + - `referenceMemoryIds` 已成为 `灵感库 -> 创作场景` 的 current 正式入口 + - `prefillIntent` 继续保留,但只做输入补充,不再承担“唯一灵感对象载体” + - `sceneapp_plan_launch` 已能按 `reference_memory_ids` 读取统一记忆条目,并编译成 `contextOverlay.snapshot.referenceItems` + - 编译结果会继续进入 `TasteProfile`、`projectPackPlan` 与 runtime adapter metadata / launch payload;显式落盘当前改由 `sceneapp_save_context_baseline` + - 浏览器 mock 已与 Rust current 合同对齐,不再只在真实后端链路里有 `reference_memory_ids` + - 当前已确认通过: + - `npm exec vitest run "src/lib/tauri-mock/core.test.ts" "src/lib/api/sceneapp.test.ts" "src/components/memory/MemoryPage.test.tsx" "src/components/sceneapps/SceneAppsPage.test.tsx" "src/lib/sceneapp/storage.test.ts" "src/lib/sceneapp/launch.test.ts"` + - `cargo test --manifest-path "src-tauri/Cargo.toml" sceneapp::` + - 当前仍待继续: + - 如何让自动反馈回写与前台显式操作进一步统一成稳定资产链 + - provider 裁剪和更稳定的跨场景 taste 经营链 + +- 把 `运行 / scorecard / governance -> 项目级 Context Snapshot` 的自动反馈回写补齐到 current 主链: + - 已更新: + - `src-tauri/src/sceneapp/application.rs` + - `src-tauri/src/sceneapp/context/dto.rs` + - `src-tauri/src/sceneapp/context/compiler.rs` + - `src-tauri/src/sceneapp/context/store.rs` + - `src/lib/context-layer/types.ts` + - `src/lib/sceneapp/product.ts` + - `src/components/sceneapps/SceneAppDetailPanel.tsx` + - `src/components/sceneapps/SceneAppScorecardPanel.tsx` + - `src/components/sceneapps/SceneAppGovernancePanel.tsx` + - `src/components/sceneapps/SceneAppRunDetailPanel.tsx` + - 当前统一结论: + - `ReferenceItem` 已直接承接 `usageCount / lastUsedAt / lastFeedbackLabel` + - `TasteProfile` 已直接承接 `feedbackSummary / feedbackSignals / lastFeedbackAt` + - `sceneapp_get_run_summary / sceneapp_prepare_run_governance_artifact / sceneapp_get_scorecard` 会把最近运行的交付缺口、失败信号与治理判断自动回写到 `.lime/sceneapp/context/.json` + - 详情、评分、运行复盘、治理面当前都会继续消费同一套 `Planning 基线 + 最近反馈` 事实源 + - V2 current first cut 已不再依赖“像我 / 不像我”按钮才能形成反馈闭环;当前显式人工复核继续复用 review decision 主链补到同一份 snapshot + - 当前已确认通过: + - `cargo test --manifest-path "src-tauri/Cargo.toml" sceneapp::` + - `npm exec vitest run "src/lib/sceneapp/product.test.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm run typecheck` + - `npm run test:contracts` + - 当前仍待继续: + - provider 裁剪和更稳定的跨场景 taste 经营链 + +- 把 LimeNext V2 的 current 文档事实源收口到 `创作场景 / 灵感库` 主线: + - 已更新: + - `docs/roadmap/limenext/README.md` + - `docs/roadmap/limenext/inspiration-library-and-taste-profile.md` + - `docs/roadmap/limenext/context-layer.md` + - `docs/roadmap/limenext/sceneapp-fullstack-implementation-plan.md` + - 当前统一结论: + - 前台主词固定为 `创作场景 / 灵感库 / 生成 / Project Pack / 复盘` + - `SceneApp` 继续保留为内部场景画像与运行时合同,不再作为前台主叙事 + - `灵感库` 当前是 `Memory Layer + Taste Layer` 的前台投影层,底层事实源仍在 `Memory Layer` + - `灵感库 -> 创作场景` 已进入 current 前台主链:灵感条目可带着 `referenceMemoryIds + prefillIntent` 进入场景 planning + - 当前真正缺口已从“有没有前台入口”收口为“如何把前台操作正式回写到 `ReferenceItem / TasteProfile`,以及如何补齐 provider 裁剪与反馈编译” + - 本轮额外沉淀: + - V2 不应再把“灵感库”与“记忆工作台”混成同一层;前台要讲投影层,底层要讲事实源与诊断层 + - `SceneApp` 这个词保留工程价值,但不应继续主导对外命名 + +- 把 compat 旧文档继续标注清理,避免旧图示覆盖 current 口径: + - 已更新: + - `docs/roadmap/limenext/sequences.md` + - `docs/roadmap/limenext/flowcharts.md` + - 当前固定规则: + - 这两份文档继续保留为 `compat reference` + - 文中若继续出现 `场景应用`、旧“场景工厂”等词,只按 compat 旧称理解,不能覆盖 current 的 `创作场景 / 灵感库 / Project Pack / 复盘` + +- 收掉 `SceneAppsPage` 剩余两条旧文案测试断言,避免 current 命名继续被旧 `SceneApp` 文案拖回去: + - 已更新: + - `src/components/sceneapps/SceneAppsPage.test.tsx` + - 当前已确认通过: + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm run typecheck` + ## 2026-04-16 ### 已完成 +- 收口 `SceneApp / Context Layer` 当前 latest-only 产品面,不再给旧 `projectPackPlan` 单独消费模型留历史包袱: + - `src/lib/sceneapp/product.ts` 已把 `contextPlan` 改成显式复用 `contextBaseline` 组装,清掉依赖隐式 spread 的旧空值心智 + - `src/lib/sceneapp/product.test.ts` 已统一改到 `planResult` 主签名,并补齐: + - `detail / scorecard / run detail / governance` 四个消费面的 `contextBaseline` 断言 + - `referenceItems / tasteKeywords / avoidKeywords / scopeLabel` 断言 + - `src/components/sceneapps/SceneAppsPage.test.tsx` 已补齐当前页面事实源断言: + - 详情页 `reference items / taste / avoid` + - 评分卡 `context baseline` + - 治理页 `context baseline` + - 运行详情 `context baseline` + - 顺手清掉一个测试历史噪音: + - “继续最近”用例已补 `act + flushEffects`,不再在定向回归里持续刷 React `act(...)` 警告 + - 当前已确认通过: + - `npm exec vitest run "src/lib/sceneapp/product.test.ts"` + - `npm exec vitest run "src/components/sceneapps/SceneAppsPage.test.tsx"` + - `npm run typecheck` + - `cargo test --manifest-path "src-tauri/Cargo.toml" sceneapp::` + - `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` + - 当前额外确认: + - `npm run verify:local` 在仓库全量 Rust 阶段失败于未改动模块 `services::site_capability_service::tests::should_report_requires_browser_runtime_when_no_attached_session_exists` + - 这轮 `SceneApp / Context Layer` 主线的前端、GUI 与 `sceneapp::` 定向 Rust 校验已通过,当前阻塞不是本轮 latest-only 收口引入 + - 收口 `SceneApp / Context Layer` 这轮尾部校验,把最终阻塞从“实现错误”纠偏为“测试契约与 smoke 串联稳定性”: - `src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts` 已补齐 `normalizeFetchProviderModelsSource` mock,避免 provider API fallback 分支被旧测试契约静默吞掉 - 之前 `verify:local` 首次失败里的两类红灯现已分别确认: @@ -31,7 +526,7 @@ - `npm test -- "src/components/sceneapps/SceneAppsPage.test.tsx"` - `npx eslint --no-warn-ignored "src/components/sceneapps/useSceneAppsPageRuntime.ts" "src/components/sceneapps/SceneAppsPage.test.tsx"` -- 把 `SceneApps` 目录页从“无图场景墙”进一步纠偏到“纯标题目录”: +- [已被后续经营信号目录卡片方案替代] 把 `SceneApps` 目录页从“无图场景墙”进一步纠偏到“纯标题目录”: - 删除目录分页里大外框、大卡片和“场景选品墙”式陈列 - 最近访问入口继续保留,但已压缩成同一块目录里的纯文字标题入口 - 场景目录当前只保留标题点击,不再在目录页展示产出、模式、基础设施与 CTA 块 @@ -93,7 +588,7 @@ - 页级 runtime smoke 不应继续被旧 session / 孤儿 profile 清理策略拖垮 - `browser runtime` 的事实源应围绕 `profile_key` 当前活跃会话,而不是某一次 launcher 生命周期 -- 把 `SceneApps` 目录页从“最近场景 + 目录卡片”双块结构收口成统一的无图场景选品墙: +- [已被 current 目录经营卡片方案替代] 把 `SceneApps` 目录页从“最近场景 + 目录卡片”双块结构收口成统一的无图场景选品墙: - 最近访问入口不再单独占一块面板,而是并入 `SceneAppsCatalogPanel` - 目录卡片已补充: - `产出` @@ -225,7 +720,7 @@ - `run summary` - `scorecard` - `evidence / review` - 收口成同一份业务向治理视图 + 收口成同一份业务向治理视图 - 页面级治理看板当前已经能表达: - 当前治理状态 - 当前适合投放到哪里 @@ -392,7 +887,7 @@ - 批量治理仍继续经由 `src/lib/api/sceneapp.ts` 网关串行调用当前 `sceneapp_prepare_run_governance_artifact`,不让页面直接耦合底层 runtime export 命令名 - 运行详情文案已开始改成业务向表达: - 什么动作适合周会复盘 - - 什么动作适合任务中心 / 看板 / 后续自动治理消费 + - 什么动作适合生成 / 看板 / 后续自动治理消费 - 已同步更新: - `src/lib/api/sceneapp.ts` - `src/lib/api/sceneapp.test.ts` @@ -469,7 +964,7 @@ - `required parts` - `composition steps` - `failure signals` - 现在都能从编译层一路进入业务向详情面板和经营评分面板 + 现在都能从编译层一路进入业务向详情面板和经营评分面板 - 目录页不再只展示技术 ref,而会明确表达: - 最终交付什么项目包 - 这条链分几步完成 @@ -760,7 +1255,7 @@ - 目录页 - 详情页 - 未来更多入口 - 都不需要各写一份 `SceneApp` 文案、seed、project gate、automation metadata merge + 都不需要各写一份 `SceneApp` 文案、seed、project gate、automation metadata merge - 把 `SceneApp` 目录页入口继续收口成可恢复的跨入口协议: - `src/lib/sceneapp/entry.ts` - `src/lib/sceneapp/storage.ts` @@ -807,7 +1302,7 @@ - detail panel copy - scorecard view - run list / run detail - 都统一收口到 `SceneApp product view model` + 都统一收口到 `SceneApp product view model` - 这让后续首页推荐位、独立详情页、经营面板可以复用同一层业务解释,而不是重复从 `descriptor / run / scorecard` 重新拼 UI - 新增产品视图模型回归: - `src/lib/sceneapp/product.test.ts` @@ -966,7 +1461,7 @@ - 最近访问的 SceneApp 恢复 - 可复制的目录页深链接 - 从聊天页/推荐位/运营面板跳回目录的状态保持 - 都可以继续复用当前参数协议 + 都可以继续复用当前参数协议 - 把 SceneApp 前台继续接到 `Base Setup Package` 的 scorecard / composition 装配层: - `src/lib/sceneapp/catalog.ts` - `src/lib/sceneapp/product.ts` @@ -1001,7 +1496,7 @@ - 侧边栏空参进入后的恢复入口 - 首页推荐位跳转后的回流继续 - 场景运营面板的最近活跃列表 - 都可以复用同一套 `SceneApp page state + recent visit` 协议 + 都可以复用同一套 `SceneApp page state + recent visit` 协议 ### 当前判断 @@ -1059,7 +1554,7 @@ - durable 场景的 automation projection / fallback 已收口到装配主链,下一步转为: - 基于 `base-setup-implementation.md` 决定第一版真实实现路线:客户端编译,还是服务端预编译 + 客户端 gate - 把多模态组合样板的 `project pack` 接到真实 artifact / viewer / scorecard 聚合入口 - - 把 `SceneScorecard` 与周会模板、场景看板和任务中心口径继续打通 + - 把 `SceneScorecard` 与周会模板、场景看板和生成口径继续打通 - 继续把 automation detail / SceneApp run / scorecard 聚合层消费同一组 base-setup refs,而不是各自回退到运行时猜测 - `SceneApp` 恢复协议当前已稳定收口到: - `sourceRef` @@ -1317,7 +1812,7 @@ ### 下一刀 -- 把 `SceneScorecard` 聚合对象继续接到任务中心 / 场景看板 / 周会模板 +- 把 `SceneScorecard` 聚合对象继续接到生成 / 场景看板 / 周会模板 - 把三条样板的经营信号进一步映射到真实数据源与产品入口 - 继续把结果卡文案从“技术摘要”升级成“业务可理解的交付说明” - 把基础设置包收口成更明确的 schema 与校验规则 diff --git a/docs/research/nextbrowser-features-analysis.md b/docs/research/nextbrowser-features-analysis.md index 87607afbb..20fbcfff0 100644 --- a/docs/research/nextbrowser-features-analysis.md +++ b/docs/research/nextbrowser-features-analysis.md @@ -1670,7 +1670,7 @@ Lime 已经具备了 CDP 基础设施,采用**渐进式增强**策略: | 位置定制 | 未见浏览器级代理、地理位置、时区、语言、UA、指纹预设 | 未实现 | | 证书 | 未见浏览器级客户端证书/站点证书选择与存储模型 | 未实现 | | 自动验证码求解器 | 仅支持人工接管,没有 solver 抽象与供应商接入 | 未实现 | -| 连接 | 现有 `connection_cmd` 是终端/SSH/WSL 连接,不是外部业务连接器 | 未实现截图语义下的连接器 | +| 连接 | 当前没有截图语义下的连接器;仓库历史上虽有已下线的终端连接面,但它不属于 current surface | 仍未实现截图语义下的连接器 | ### 9.3 架构判断:先不要直接堆功能页 diff --git a/eslint.config.js b/eslint.config.js index 079f85ae2..90e1ca5a6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -123,10 +123,6 @@ const generalChatRestrictedPaths = [ message: "agent.ts 已删除;请直接使用 @/lib/api/agentRuntime 或 @/lib/api/agentStream。", }, - { - name: "@/lib/terminal-api", - message: "terminal-api 现在只是兼容门面;新代码请改用 @/lib/api/terminal。", - }, { name: "@/hooks/useTauri", message: @@ -644,19 +640,6 @@ const systemSupportCommandSelectors = [ "系统支持类后端命令请统一通过对应 API 网关(`src/lib/api/notification.ts` / `autoFix.ts` / `frontendCrash.ts`)调用,避免在 lib / hook 中继续直接拼接命令名。", })); -const terminalCommandSelectors = [ - "terminal_create_session", - "terminal_write", - "terminal_resize", - "terminal_close", - "terminal_list_sessions", - "terminal_get_session", -].map((command) => ({ - selector: `CallExpression[callee.name='safeInvoke'][arguments.0.value='${command}'], CallExpression[callee.name='invoke'][arguments.0.value='${command}']`, - message: - "终端相关后端命令请统一通过 `src/lib/api/terminal.ts` 暴露的网关函数调用,避免在其他模块中继续直接拼接命令名。", -})); - const serverRuntimeCommandSelectors = [ "start_server", "stop_server", @@ -1081,7 +1064,6 @@ export default [ ...appUpdateCommandSelectors, ...screenshotChatCommandSelectors, ...systemSupportCommandSelectors, - ...terminalCommandSelectors, ...serverRuntimeCommandSelectors, ...logCommandSelectors, ...appConfigCommandSelectors, @@ -1174,7 +1156,6 @@ export default [ "src/lib/api/frontendCrash.ts", "src/lib/api/memoryFeedback.ts", "src/lib/api/toolHooks.ts", - "src/lib/api/terminal.ts", "src/lib/api/unifiedMemory.ts", "src/lib/api/serverRuntime.ts", "src/lib/api/logs.ts", diff --git a/package-lock.json b/package-lock.json index ef7fb9151..e09f6b076 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lime", - "version": "1.12.3", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lime", - "version": "1.12.3", + "version": "1.13.0", "dependencies": { "@babel/standalone": "^7.29.0", "@fabianlars/tauri-plugin-oauth": "^2", diff --git a/package.json b/package.json index 602f4e92e..6ff43d678 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "1.12.3", + "version": "1.13.0", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/packages/lime-cli-npm/README.md b/packages/lime-cli-npm/README.md index a2ef232e9..5d3d23995 100644 --- a/packages/lime-cli-npm/README.md +++ b/packages/lime-cli-npm/README.md @@ -112,7 +112,7 @@ npm run build:release -- \ ```bash npm run build:release -- \ --target-triple "aarch64-apple-darwin" \ - --version "1.12.3" \ + --version "1.13.0" \ --out-dir "./dist" ``` diff --git a/packages/lime-cli-npm/package.json b/packages/lime-cli-npm/package.json index 6b8bd26b9..62d0e3c60 100644 --- a/packages/lime-cli-npm/package.json +++ b/packages/lime-cli-npm/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime-cli", - "version": "1.12.3", + "version": "1.13.0", "description": "Lime 官方任务 CLI", "bin": { "lime": "scripts/run.js" diff --git a/playwright-after-automation.yml b/playwright-after-automation.yml new file mode 100644 index 000000000..2764f424f --- /dev/null +++ b/playwright-after-automation.yml @@ -0,0 +1,41 @@ +- generic [ref=e2]: + - generic [ref=e14]: + - complementary [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - button "Lime Lime" [ref=e18] [cursor=pointer] + - button "折叠导航栏" [ref=e22] [cursor=pointer] + - 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] + - button "生成" [ref=e37] [cursor=pointer] + - generic [ref=e41]: + - generic [ref=e42]: 能力 + - button "我的方法" [ref=e43] [cursor=pointer] + - button "创作场景" [ref=e47] [cursor=pointer] + - button "持续流程" [active] [ref=e53] [cursor=pointer] + - button "消息渠道" [ref=e57] [cursor=pointer] + - generic [ref=e62]: + - generic [ref=e63]: 资料 + - button "资料库" [ref=e64] [cursor=pointer] + - button "灵感库" [ref=e68] [cursor=pointer] + - generic [ref=e82]: + - generic [ref=e83]: + - generic [ref=e84]: 系统 + - button "OpenClaw" [ref=e85] [cursor=pointer] + - button "设置" [ref=e90] [cursor=pointer] + - button "切换到深色模式" [ref=e96] [cursor=pointer]: + - img [ref=e97] + - main [ref=e103]: + - generic [ref=e630]: + - generic [ref=e632]: + - generic [ref=e633] + - generic [ref=e653] + - tabpanel [ref=e664]: + - generic [ref=e665] + - generic [ref=e734] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-automation-after-open.md b/playwright-automation-after-open.md new file mode 100644 index 000000000..7ee4d08a3 --- /dev/null +++ b/playwright-automation-after-open.md @@ -0,0 +1,328 @@ +- 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=e42]: 创作场景 + - button "持续流程" [active] [ref=e43] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e46]: 持续流程 + - button "消息渠道" [ref=e47] [cursor=pointer]: + - img [ref=e48] + - generic [ref=e51]: 消息渠道 + - generic [ref=e52]: + - generic [ref=e53]: 资料 + - button "资料库" [ref=e54] [cursor=pointer]: + - img [ref=e55] + - generic [ref=e57]: 资料库 + - button "灵感库" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e71]: 灵感库 + - generic [ref=e72]: + - generic [ref=e73]: + - generic [ref=e74]: 系统 + - button "OpenClaw" [ref=e75] [cursor=pointer]: + - img [ref=e76] + - generic [ref=e79]: OpenClaw + - button "设置" [ref=e80] [cursor=pointer]: + - img [ref=e81] + - generic [ref=e84]: 设置 + - button "切换到深色模式" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - main [ref=e93]: + - generic [ref=e589]: + - generic [ref=e590]: + - generic [ref=e591]: + - generic [ref=e592]: + - generic [ref=e593]: CREATE SCENES + - heading "按创作场景组织完整结果链" [level=1] [ref=e594] + - paragraph [ref=e595]: 这里前台讲的是创作场景,不是内部 SceneApp 名词。每条场景都会带上参考素材、风格摘要、工具能力和交付合同,帮助你先完成选路与装配,再进入生成、结果交付和复盘。 + - button "刷新目录" [ref=e596] [cursor=pointer] + - generic [ref=e597]: + - generic [ref=e598]: + - button "场景目录" [ref=e599] [cursor=pointer] + - button "生成准备" [ref=e600] [cursor=pointer] + - button "治理复盘" [ref=e601] [cursor=pointer] + - paragraph [ref=e602]: 集中查看交付合同、上下文基线和进入生成前的准备。 + - generic [ref=e603]: + - generic [ref=e604]: 当前场景: + - generic [ref=e605]: 短视频编排 + - generic [ref=e606]: 把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。 + - button "查看准备" [ref=e607] [cursor=pointer] + - button "查看治理" [ref=e608] [cursor=pointer] + - generic [ref=e609]: + - generic [ref=e611]: + - generic [ref=e612]: + - generic [ref=e613]: + - generic [ref=e614]: 多模态组合 + - heading "短视频编排" [level=2] [ref=e615] + - paragraph [ref=e616]: 把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。 + - generic [ref=e617]: + - generic [ref=e618]: 多能力组合 + - generic [ref=e619]: 项目资料包 + - generic [ref=e620]: + - generic [ref=e621]: 从一句主题串起脚本、线框图、配乐方向和短视频草稿。 + - generic [ref=e622]: + - generic [ref=e623]: 产出:短视频项目包 + - generic [ref=e624]: 执行主链:云端 Scene + - generic [ref=e625]: 来源包:lime-core-sceneapps + - generic [ref=e626]: 版本:2026-04-15 + - generic [ref=e627]: + - generic [ref=e628]: + - generic [ref=e629]: 设计模式 + - generic [ref=e630]: + - generic [ref=e631]: 步骤链 + - generic [ref=e632]: 先补上下文 + - generic [ref=e633]: 结果生成 + - generic [ref=e634]: 质量复核 + - generic [ref=e635]: + - generic [ref=e636]: 启动前置 + - generic [ref=e637]: + - generic [ref=e638]: 需要主题、风格或脚本线索作为场景输入。 + - generic [ref=e639]: 需要项目目录承接线框图、脚本和媒体结果。 + - generic [ref=e640]: 需要可用的云端运行时来完成多模态媒体处理。 + - generic [ref=e641]: + - generic [ref=e642]: + - generic [ref=e643]: 交付合同 + - paragraph [ref=e644]: 这条 SceneApp 会把结果收口成一份可继续编辑的项目资料包,至少包含 任务简报、分镜 / 线框图、剧本、配乐建议、短视频草稿、复核意见。 + - generic [ref=e645]: + - generic [ref=e646]: 主结果:短视频项目包 + - generic [ref=e647]: 默认主件:任务简报 + - generic [ref=e648]: 查看方式:结果包查看 + - generic [ref=e649]: Artifact:story-video-artifacts + - generic [ref=e650]: + - generic [ref=e651]: 任务简报 + - generic [ref=e652]: 分镜 / 线框图 + - generic [ref=e653]: 剧本 + - generic [ref=e654]: 配乐建议 + - generic [ref=e655]: 短视频草稿 + - generic [ref=e656]: 复核意见 + - generic [ref=e657]: + - generic [ref=e658]: 组合步骤 + - generic [ref=e659]: + - generic [ref=e660]: 蓝图:story-video-blueprint + - generic [ref=e661]: 阶段数:6 + - generic [ref=e662]: + - generic [ref=e663]: + - generic [ref=e664]: 第 1 步 · 任务简报 + - generic [ref=e665]: 执行面:本机技能 + - generic [ref=e666]: + - generic [ref=e667]: 第 2 步 · 分镜 / 线框图 + - generic [ref=e668]: 执行面:本机技能 + - generic [ref=e669]: + - generic [ref=e670]: 第 3 步 · 剧本 + - generic [ref=e671]: 执行面:本机技能 + - generic [ref=e672]: + - generic [ref=e673]: 第 4 步 · 配乐建议 + - generic [ref=e674]: 执行面:云端 Scene + - generic [ref=e675]: + - generic [ref=e676]: 第 5 步 · 短视频草稿 + - generic [ref=e677]: 执行面:云端 Scene + - generic [ref=e678]: + - generic [ref=e679]: 第 6 步 · 复核意见 + - generic [ref=e680]: 执行面:本机技能 + - generic [ref=e681]: + - generic [ref=e682]: 评分口径 + - paragraph [ref=e683]: 经营上优先看整包是否交齐、是否通过复核,以及结果是否继续被打开编辑或发布。 + - generic [ref=e684]: Profile:story-video-scorecard + - generic [ref=e685]: + - generic [ref=e686]: 整包交付率 + - generic [ref=e687]: 复核通过率 + - generic [ref=e688]: 发布转化率 + - generic [ref=e689]: + - generic [ref=e690]: 重点关注 + - generic [ref=e691]: + - generic [ref=e692]: 整包不完整 + - generic [ref=e693]: 复核阻塞 + - generic [ref=e694]: 发布卡点 + - generic [ref=e695]: + - generic [ref=e696]: + - generic [ref=e697]: + - generic [ref=e698]: 执行预规划 + - generic [ref=e699]: 待补 1 项 + - paragraph [ref=e700]: 当前预规划已生成,但还有 1 项前置待补齐;补齐后会继续沿 云端 Scene 执行。 + - generic [ref=e701]: + - generic [ref=e702]: 仍待补齐 + - generic [ref=e704]: 需要可用的云端运行时来完成多模态媒体处理。 + - generic [ref=e705]: + - generic [ref=e706]: + - generic [ref=e707]: 活跃层 + - generic [ref=e708]: + - generic [ref=e709]: Skill + - generic [ref=e710]: Tool + - generic [ref=e711]: Memory + - generic [ref=e712]: Reference + - generic [ref=e713]: Taste + - generic [ref=e714]: 参考注入:1 条 + - generic [ref=e715]: 作用域:项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e716]: + - generic [ref=e717]: 场景绑定 + - generic [ref=e718]: + - generic [ref=e719]: Story video suite + - generic [ref=e720]: Sceneapp service story video + - generic [ref=e721]: Story video blueprint + - generic [ref=e722]: + - generic [ref=e723]: 记忆引用 + - generic [ref=e724]: + - generic [ref=e725]: Workspace · 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e726]: Project · 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e727]: Memory profile · user_input + - generic [ref=e728]: + - generic [ref=e729]: 工具开放面 + - generic [ref=e730]: + - generic [ref=e731]: Cloud scene + - generic [ref=e732]: Native skill + - generic [ref=e733]: Workspace storage + - generic [ref=e734]: Artifact viewer + - generic [ref=e735]: + - generic [ref=e736]: 参考条目 + - generic [ref=e738]: + - generic [ref=e739]: + - generic [ref=e740]: 用户输入 + - generic [ref=e741]: 用户输入 + - generic [ref=e742]: 文本 + - generic [ref=e743]: 请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。 + - generic [ref=e744]: 风格摘要:当前 TasteProfile 已在项目沉淀基础上,结合 1 条参考输入更新启发式摘要。 + - generic [ref=e745]: + - generic [ref=e746]: 偏好关键词 + - generic [ref=e747]: + - generic [ref=e748]: story-video + - generic [ref=e749]: mv-pipeline + - generic [ref=e750]: 短视频项目包 + - generic [ref=e751]: + - generic [ref=e752]: 避免方向 + - generic [ref=e754]: 偏离参考素材 + - generic [ref=e755]: + - generic [ref=e756]: 已装配 3 条 memory 引用。 + - generic [ref=e757]: 已从项目上下文恢复 1 条历史参考。 + - generic [ref=e758]: 本次新增 1 条参考输入,当前 planning 共带上 1 条参考。 + - generic [ref=e759]: 当前已复用项目级 TasteProfile,并按最新输入继续更新。 + - generic [ref=e761]: 当前 SceneApp 仍有未满足的启动前置条件。 + - generic [ref=e762]: + - generic [ref=e763]: Project Pack 规划 + - generic [ref=e764]: + - generic [ref=e765]: 交付形态:项目资料包 + - generic [ref=e766]: 完成策略:按必含部件判断整包完成度 + - generic [ref=e767]: 主件:任务简报 + - generic [ref=e768]: 查看方式:结果包查看 + - generic [ref=e769]: + - generic [ref=e770]: 任务简报 + - generic [ref=e771]: 分镜 / 线框图 + - generic [ref=e772]: 剧本 + - generic [ref=e773]: 配乐建议 + - generic [ref=e774]: 短视频草稿 + - generic [ref=e775]: 复核意见 + - generic [ref=e776]: + - generic [ref=e777]: 当前 SceneApp 以结果包作为默认交付单位。 + - generic [ref=e778]: 完整度将按 6 个必含部件判断。 + - generic [ref=e779]: 结果会优先回写到项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4,便于继续编辑与复盘。 + - generic [ref=e780]: 结果包会连同参考与风格快照一起进入后续治理链。 + - generic [ref=e781]: 结果包可继续复用当前 viewer 主链打开与检查。 + - generic [ref=e782]: + - generic [ref=e783]: 启动输入 + - paragraph [ref=e784]: 这一步只负责补齐场景意图和上下文基线;真正执行会进入生成主执行面,继续复用 Agent 或自动化主链。 + - generic [ref=e785]: + - generic [ref=e786]: 项目工作区 + - button "📁 默认项目 默认" [ref=e787] [cursor=pointer]: + - generic [ref=e788]: + - generic [ref=e790]: 📁 + - generic [ref=e792]: + - generic [ref=e793]: 默认项目 + - generic [ref=e794]: 默认 + - img [ref=e796] + - generic [ref=e798]: + - generic [ref=e799]: 启动意图 + - textbox "请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。" [ref=e800] + - generic [ref=e801]: + - generic [ref=e802]: 将使用内置起步稿启动 + - generic [ref=e803]: 请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。 + - paragraph [ref=e804]: 写入当前场景基线会把这次显式带入的灵感对象、输入摘要和风格偏好沉淀到项目目录,供后续 planning 继续复用。 + - paragraph [ref=e805]: 先带入灵感对象或启动输入,再写入当前场景基线 + - generic [ref=e807]: + - button "写入当前场景基线" [disabled] + - button "进入生成" [ref=e808] [cursor=pointer] + - generic [ref=e809]: + - generic [ref=e810]: + - generic [ref=e811]: + - generic [ref=e812]: 经营评分 + - paragraph [ref=e813]: 用统一 scorecard 判断这个场景交付的结果是否值得继续放大、优化还是收口。 + - generic [ref=e814]: 适合继续启动 + - paragraph [ref=e815]: 基于最近 1 次真实运行,多模态组合场景的整包交付率为 0.0% ,复核通过率为 0.0% ,发布转化率为 100.0% 。当前主要阻塞集中在复核阻塞。当前样本已经以终态运行记录为主。 目前更适合继续扩大样本。 + - generic [ref=e816]: + - generic [ref=e817]: 当前经营口径 + - paragraph [ref=e818]: 经营上优先看整包是否交齐、是否通过复核,以及结果是否继续被打开编辑或发布。 + - generic [ref=e819]: 交付合同:项目资料包 + - generic [ref=e820]: 查看方式:结果包查看 + - generic [ref=e821]: 完成口径:按必含部件判断整包完成度 + - generic [ref=e822]: + - generic [ref=e823]: 任务简报 + - generic [ref=e824]: 分镜 / 线框图 + - generic [ref=e825]: 剧本 + - generic [ref=e826]: 配乐建议 + - generic [ref=e827]: 短视频草稿 + - generic [ref=e828]: 复核意见 + - generic [ref=e829]: Profile:story-video-scorecard + - generic [ref=e830]: + - generic [ref=e831]: 整包交付率 + - generic [ref=e832]: 复核通过率 + - generic [ref=e833]: 发布转化率 + - generic [ref=e834]: + - generic [ref=e835]: 整包不完整 + - generic [ref=e836]: 复核阻塞 + - generic [ref=e837]: 发布卡点 + - generic [ref=e838]: 当前主要阻塞:复核阻塞 + - generic [ref=e839]: + - generic [ref=e840]: 规划备注 + - generic [ref=e841]: + - generic [ref=e842]: 当前 SceneApp 以结果包作为默认交付单位。 + - generic [ref=e843]: 完整度将按 6 个必含部件判断。 + - generic [ref=e844]: 结果会优先回写到项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4,便于继续编辑与复盘。 + - generic [ref=e845]: 结果包会连同参考与风格快照一起进入后续治理链。 + - generic [ref=e846]: 结果包可继续复用当前 viewer 主链打开与检查。 + - generic [ref=e847]: + - generic [ref=e848]: Planning 基线 + - generic [ref=e849]: 参考注入:1 条 + - generic [ref=e850]: 作用域:项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e852]: 用户输入 · 复核阻塞 + - generic [ref=e853]: 风格摘要:当前 TasteProfile 已在项目沉淀基础上,结合 1 条参考输入更新启发式摘要。 + - generic [ref=e854]: 最近反馈:当前主要卡点是复核阻塞。经营上建议继续补样本。 + - generic [ref=e856]: 复核阻塞 + - generic [ref=e858]: 复核阻塞 + - generic [ref=e859]: + - article [ref=e860]: + - generic [ref=e861]: + - generic [ref=e862]: 整包交付率 + - generic [ref=e863]: "0" + - article [ref=e864]: + - generic [ref=e865]: + - generic [ref=e866]: 复核通过率 + - generic [ref=e867]: "0" + - article [ref=e868]: + - generic [ref=e869]: + - generic [ref=e870]: 发布转化率 + - generic [ref=e871]: "100" + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-console-errors-after-reload.txt b/playwright-console-errors-after-reload.txt new file mode 100644 index 000000000..d8f14e168 --- /dev/null +++ b/playwright-console-errors-after-reload.txt @@ -0,0 +1,87 @@ +Total messages: 51 (Errors: 10, Warnings: 3) +Returning 10 messages for level "error" + +[ERROR] [DevBridge] HTTP 调用失败: get_config Error: Failed to fetch (bridge health check failed) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:151:11) + at async invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:5) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:343:22) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 +[ERROR] [DevBridge] HTTP 调用失败: get_config Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts:5753:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts:13:10) + at async safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:363:24) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 +[ERROR] [DevBridge] HTTP 调用失败: workspace_list Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:343:28) + at listProjects (http://127.0.0.1:1420/src/lib/api/project.ts:37:26) + at http://127.0.0.1:1420/src/hooks/useProjects.ts:41:9 + at http://127.0.0.1:1420/src/hooks/useProjects.ts:145:5 + at commitHookEffectListMount (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:16915:34) + at commitPassiveMountOnFiber (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:18156:19) + at commitPassiveMountEffects_complete (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:18129:17) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 +[ERROR] [DevBridge] HTTP 调用失败: workspace_get_default Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:343:28) + at getDefaultProject (http://127.0.0.1:1420/src/lib/api/project.ts:45:25) + at http://127.0.0.1:1420/src/hooks/useProjects.ts:42:9 + at http://127.0.0.1:1420/src/hooks/useProjects.ts:145:5 + at commitHookEffectListMount (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:16915:34) + at commitPassiveMountOnFiber (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:18156:19) + at commitPassiveMountEffects_complete (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:18129:17) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 +[ERROR] [DevBridge] HTTP 调用失败: sceneapp_list_catalog Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:343:28) + at listSceneAppCatalog (http://127.0.0.1:1420/src/lib/api/sceneapp.ts:3:10) + at http://127.0.0.1:1420/src/components/agent/chat/workspace/useWorkspaceSceneAppEntryActions.ts?t=1776433812565:40:10 + at commitHookEffectListMount (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:16915:34) + at commitPassiveMountOnFiber (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:18156:19) + at commitPassiveMountEffects_complete (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:18129:17) + at commitPassiveMountEffects_begin (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:18119:15) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 +[ERROR] [DevBridge] HTTP 调用失败: get_or_create_default_project Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:343:28) + at getOrCreateDefaultProject (http://127.0.0.1:1420/src/lib/api/project.ts:71:25) + at http://127.0.0.1:1420/src/hooks/useProjects.ts:137:27 + at ensureSelection (http://127.0.0.1:1420/src/components/projects/ProjectSelector.tsx:267:40) + at http://127.0.0.1:1420/src/components/projects/ProjectSelector.tsx:275:12 + at commitHookEffectListMount (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:16915:34) + at commitPassiveMountOnFiber (http://127.0.0.1:1420/node_modules/.vite-tauri/deps/chunk-5B54REGN.js?v=d1d1c67b:18156:19) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 +[ERROR] [DevBridge] HTTP 调用失败: get_or_create_default_project Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:11) + at invoke (http://127.0.0.1:1420/src/lib/tauri-mock/core.ts:5753:20) + at invokeExplicitMock (http://127.0.0.1:1420/src/lib/dev-bridge/explicitMockFallback.ts:13:10) @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 +[ERROR] 创建默认项目失败: Error: [DevBridge] 浏览器模式无法连接后端桥接,命令 "get_or_create_default_project" 执行失败。请先启动 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:168:12) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:348:31) + at async getOrCreateDefaultProject (http://127.0.0.1:1420/src/lib/api/project.ts:71:19) + at async http://127.0.0.1:1420/src/hooks/useProjects.ts:137:21 + at async ensureSelection (http://127.0.0.1:1420/src/components/projects/ProjectSelector.tsx:267:34) @ http://127.0.0.1:1420/src/components/projects/ProjectSelector.tsx:271 +[ERROR] [DevBridge] HTTP 调用失败: workspace_list Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:343:28) + at listProjects (http://127.0.0.1:1420/src/lib/api/project.ts:37:26) + at http://127.0.0.1:1420/src/hooks/useProjects.ts:41:9 + at http://127.0.0.1:1420/src/hooks/useProjects.ts:158:12 @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 +[ERROR] [DevBridge] HTTP 调用失败: workspace_get_default Error: Failed to fetch (bridge cooldown active) + at createBridgeConnectionFailureError (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:85:10) + at ensureBridgeReachable (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:112:11) + at invokeViaHttp (http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:324:11) + at safeInvoke (http://127.0.0.1:1420/src/lib/dev-bridge/safeInvoke.ts:343:28) + at getDefaultProject (http://127.0.0.1:1420/src/lib/api/project.ts:45:25) + at http://127.0.0.1:1420/src/hooks/useProjects.ts:42:9 + at http://127.0.0.1:1420/src/hooks/useProjects.ts:158:12 @ http://127.0.0.1:1420/src/lib/dev-bridge/http-client.ts:349 \ No newline at end of file diff --git a/playwright-network-sceneapps-before-return.txt b/playwright-network-sceneapps-before-return.txt new file mode 100644 index 000000000..e69de29bb diff --git a/playwright-sceneapps-after-automation.yml b/playwright-sceneapps-after-automation.yml new file mode 100644 index 000000000..5788408dd --- /dev/null +++ b/playwright-sceneapps-after-automation.yml @@ -0,0 +1,42 @@ +- generic [ref=e2]: + - generic [ref=e14]: + - complementary [ref=e15]: + - generic [ref=e16]: + - generic [ref=e17]: + - button "Lime Lime" [ref=e18] [cursor=pointer] + - button "折叠导航栏" [ref=e22] [cursor=pointer] + - 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] + - button "生成" [ref=e37] [cursor=pointer] + - generic [ref=e41]: + - generic [ref=e42]: 能力 + - button "我的方法" [ref=e43] [cursor=pointer] + - button "创作场景" [active] [ref=e47] [cursor=pointer] + - button "持续流程" [ref=e53] [cursor=pointer] + - button "消息渠道" [ref=e57] [cursor=pointer] + - generic [ref=e62]: + - generic [ref=e63]: 资料 + - button "资料库" [ref=e64] [cursor=pointer] + - button "灵感库" [ref=e68] [cursor=pointer] + - generic [ref=e82]: + - generic [ref=e83]: + - generic [ref=e84]: 系统 + - button "OpenClaw" [ref=e85] [cursor=pointer] + - button "设置" [ref=e90] [cursor=pointer] + - button "切换到深色模式" [ref=e96] [cursor=pointer]: + - img [ref=e97] + - main [ref=e103]: + - generic [ref=e796]: + - generic [ref=e797]: + - generic [ref=e798] + - generic [ref=e804] + - generic [ref=e816]: + - generic [ref=e817] + - generic [ref=e835] + - generic [ref=e838] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-sceneapps-after-fix-1.yml b/playwright-sceneapps-after-fix-1.yml new file mode 100644 index 000000000..de4706bf9 --- /dev/null +++ b/playwright-sceneapps-after-fix-1.yml @@ -0,0 +1,79 @@ +- 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=e42]: 创作场景 + - button "持续流程" [ref=e43] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e46]: 持续流程 + - button "消息渠道" [ref=e47] [cursor=pointer]: + - img [ref=e48] + - generic [ref=e51]: 消息渠道 + - generic [ref=e52]: + - generic [ref=e53]: 资料 + - button "资料库" [ref=e54] [cursor=pointer]: + - img [ref=e55] + - generic [ref=e57]: 资料库 + - button "灵感库" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e71]: 灵感库 + - generic [ref=e72]: + - generic [ref=e73]: + - generic [ref=e74]: 系统 + - button "OpenClaw" [ref=e75] [cursor=pointer]: + - img [ref=e76] + - generic [ref=e79]: OpenClaw + - button "设置" [ref=e80] [cursor=pointer]: + - img [ref=e81] + - generic [ref=e84]: 设置 + - button "切换到深色模式" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - main [ref=e93]: + - generic [ref=e303]: + - generic [ref=e304]: + - generic [ref=e305]: + - generic [ref=e306] + - button "刷新目录" [ref=e310] [cursor=pointer] + - generic [ref=e311]: + - generic [ref=e312] + - paragraph [ref=e316]: 集中查看交付合同、上下文基线和进入生成前的准备。 + - generic [ref=e317] + - generic [ref=e323]: + - generic [ref=e325]: + - generic [ref=e326] + - generic [ref=e334] + - generic [ref=e341] + - generic [ref=e355] + - generic [ref=e409] + - generic [ref=e496] + - generic [ref=e523]: + - generic [ref=e524] + - paragraph [ref=e529]: 基于最近 1 次真实运行,多模态组合场景的整包交付率为 0.0% ,复核通过率为 0.0% ,发布转化率为 100.0% 。当前主要阻塞集中在复核阻塞。当前样本已经以终态运行记录为主。 目前更适合继续扩大样本。 + - generic [ref=e530] + - generic [ref=e573] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-sceneapps-after-fix-path.yml b/playwright-sceneapps-after-fix-path.yml new file mode 100644 index 000000000..8a927c224 --- /dev/null +++ b/playwright-sceneapps-after-fix-path.yml @@ -0,0 +1,79 @@ +- 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 "创作场景" [active] [ref=e37] [cursor=pointer]: + - img [ref=e38] + - 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]: 消息渠道 + - generic [ref=e52]: + - generic [ref=e53]: 资料 + - button "资料库" [ref=e54] [cursor=pointer]: + - img [ref=e55] + - generic [ref=e57]: 资料库 + - button "灵感库" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e71]: 灵感库 + - generic [ref=e72]: + - generic [ref=e73]: + - generic [ref=e74]: 系统 + - button "OpenClaw" [ref=e75] [cursor=pointer]: + - img [ref=e76] + - generic [ref=e79]: OpenClaw + - button "设置" [ref=e80] [cursor=pointer]: + - img [ref=e81] + - generic [ref=e84]: 设置 + - button "切换到深色模式" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - main [ref=e93]: + - generic [ref=e751]: + - generic [ref=e752]: + - generic [ref=e753]: + - generic [ref=e754] + - button "刷新目录" [ref=e758] [cursor=pointer] + - generic [ref=e759]: + - generic [ref=e760] + - paragraph [ref=e764]: 集中查看交付合同、上下文基线和进入生成前的准备。 + - generic [ref=e765] + - generic [ref=e771]: + - generic [ref=e773]: + - generic [ref=e774] + - generic [ref=e782] + - generic [ref=e789] + - generic [ref=e803] + - generic [ref=e857] + - generic [ref=e944] + - generic [ref=e971]: + - generic [ref=e972] + - paragraph [ref=e977]: 基于最近 1 次真实运行,多模态组合场景的整包交付率为 0.0% ,复核通过率为 0.0% ,发布转化率为 100.0% 。当前主要阻塞集中在复核阻塞。当前样本已经以终态运行记录为主。 目前更适合继续扩大样本。 + - generic [ref=e978] + - generic [ref=e1021] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-sceneapps-after-open.md b/playwright-sceneapps-after-open.md new file mode 100644 index 000000000..02d99970e --- /dev/null +++ b/playwright-sceneapps-after-open.md @@ -0,0 +1,328 @@ +- 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 "创作场景" [active] [ref=e37] [cursor=pointer]: + - img [ref=e38] + - 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]: 消息渠道 + - generic [ref=e52]: + - generic [ref=e53]: 资料 + - button "资料库" [ref=e54] [cursor=pointer]: + - img [ref=e55] + - generic [ref=e57]: 资料库 + - button "灵感库" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e71]: 灵感库 + - generic [ref=e72]: + - generic [ref=e73]: + - generic [ref=e74]: 系统 + - button "OpenClaw" [ref=e75] [cursor=pointer]: + - img [ref=e76] + - generic [ref=e79]: OpenClaw + - button "设置" [ref=e80] [cursor=pointer]: + - img [ref=e81] + - generic [ref=e84]: 设置 + - button "切换到深色模式" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - main [ref=e93]: + - generic [ref=e304]: + - generic [ref=e305]: + - generic [ref=e306]: + - generic [ref=e307]: + - generic [ref=e308]: CREATE SCENES + - heading "按创作场景组织完整结果链" [level=1] [ref=e309] + - paragraph [ref=e310]: 这里前台讲的是创作场景,不是内部 SceneApp 名词。每条场景都会带上参考素材、风格摘要、工具能力和交付合同,帮助你先完成选路与装配,再进入生成、结果交付和复盘。 + - button "刷新目录" [ref=e311] [cursor=pointer] + - generic [ref=e312]: + - generic [ref=e313]: + - button "场景目录" [ref=e314] [cursor=pointer] + - button "生成准备" [ref=e315] [cursor=pointer] + - button "治理复盘" [ref=e316] [cursor=pointer] + - paragraph [ref=e317]: 集中查看交付合同、上下文基线和进入生成前的准备。 + - generic [ref=e318]: + - generic [ref=e319]: 当前场景: + - generic [ref=e320]: 短视频编排 + - generic [ref=e321]: 把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。 + - button "查看准备" [ref=e322] [cursor=pointer] + - button "查看治理" [ref=e323] [cursor=pointer] + - generic [ref=e324]: + - generic [ref=e326]: + - generic [ref=e327]: + - generic [ref=e328]: + - generic [ref=e329]: 多模态组合 + - heading "短视频编排" [level=2] [ref=e330] + - paragraph [ref=e331]: 把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。 + - generic [ref=e332]: + - generic [ref=e333]: 多能力组合 + - generic [ref=e334]: 项目资料包 + - generic [ref=e335]: + - generic [ref=e336]: 从一句主题串起脚本、线框图、配乐方向和短视频草稿。 + - generic [ref=e337]: + - generic [ref=e338]: 产出:短视频项目包 + - generic [ref=e339]: 执行主链:云端 Scene + - generic [ref=e340]: 来源包:lime-core-sceneapps + - generic [ref=e341]: 版本:2026-04-15 + - generic [ref=e342]: + - generic [ref=e343]: + - generic [ref=e344]: 设计模式 + - generic [ref=e345]: + - generic [ref=e346]: 步骤链 + - generic [ref=e347]: 先补上下文 + - generic [ref=e348]: 结果生成 + - generic [ref=e349]: 质量复核 + - generic [ref=e350]: + - generic [ref=e351]: 启动前置 + - generic [ref=e352]: + - generic [ref=e353]: 需要主题、风格或脚本线索作为场景输入。 + - generic [ref=e354]: 需要项目目录承接线框图、脚本和媒体结果。 + - generic [ref=e355]: 需要可用的云端运行时来完成多模态媒体处理。 + - generic [ref=e356]: + - generic [ref=e357]: + - generic [ref=e358]: 交付合同 + - paragraph [ref=e359]: 这条 SceneApp 会把结果收口成一份可继续编辑的项目资料包,至少包含 任务简报、分镜 / 线框图、剧本、配乐建议、短视频草稿、复核意见。 + - generic [ref=e360]: + - generic [ref=e361]: 主结果:短视频项目包 + - generic [ref=e362]: 默认主件:任务简报 + - generic [ref=e363]: 查看方式:结果包查看 + - generic [ref=e364]: Artifact:story-video-artifacts + - generic [ref=e365]: + - generic [ref=e366]: 任务简报 + - generic [ref=e367]: 分镜 / 线框图 + - generic [ref=e368]: 剧本 + - generic [ref=e369]: 配乐建议 + - generic [ref=e370]: 短视频草稿 + - generic [ref=e371]: 复核意见 + - generic [ref=e372]: + - generic [ref=e373]: 组合步骤 + - generic [ref=e374]: + - generic [ref=e375]: 蓝图:story-video-blueprint + - generic [ref=e376]: 阶段数:6 + - generic [ref=e377]: + - generic [ref=e378]: + - generic [ref=e379]: 第 1 步 · 任务简报 + - generic [ref=e380]: 执行面:本机技能 + - generic [ref=e381]: + - generic [ref=e382]: 第 2 步 · 分镜 / 线框图 + - generic [ref=e383]: 执行面:本机技能 + - generic [ref=e384]: + - generic [ref=e385]: 第 3 步 · 剧本 + - generic [ref=e386]: 执行面:本机技能 + - generic [ref=e387]: + - generic [ref=e388]: 第 4 步 · 配乐建议 + - generic [ref=e389]: 执行面:云端 Scene + - generic [ref=e390]: + - generic [ref=e391]: 第 5 步 · 短视频草稿 + - generic [ref=e392]: 执行面:云端 Scene + - generic [ref=e393]: + - generic [ref=e394]: 第 6 步 · 复核意见 + - generic [ref=e395]: 执行面:本机技能 + - generic [ref=e396]: + - generic [ref=e397]: 评分口径 + - paragraph [ref=e398]: 经营上优先看整包是否交齐、是否通过复核,以及结果是否继续被打开编辑或发布。 + - generic [ref=e399]: Profile:story-video-scorecard + - generic [ref=e400]: + - generic [ref=e401]: 整包交付率 + - generic [ref=e402]: 复核通过率 + - generic [ref=e403]: 发布转化率 + - generic [ref=e404]: + - generic [ref=e405]: 重点关注 + - generic [ref=e406]: + - generic [ref=e407]: 整包不完整 + - generic [ref=e408]: 复核阻塞 + - generic [ref=e409]: 发布卡点 + - generic [ref=e410]: + - generic [ref=e411]: + - generic [ref=e412]: + - generic [ref=e413]: 执行预规划 + - generic [ref=e414]: 待补 1 项 + - paragraph [ref=e415]: 当前预规划已生成,但还有 1 项前置待补齐;补齐后会继续沿 云端 Scene 执行。 + - generic [ref=e416]: + - generic [ref=e417]: 仍待补齐 + - generic [ref=e419]: 需要可用的云端运行时来完成多模态媒体处理。 + - generic [ref=e420]: + - generic [ref=e421]: + - generic [ref=e422]: 活跃层 + - generic [ref=e423]: + - generic [ref=e424]: Skill + - generic [ref=e425]: Tool + - generic [ref=e426]: Memory + - generic [ref=e427]: Reference + - generic [ref=e428]: Taste + - generic [ref=e429]: 参考注入:1 条 + - generic [ref=e430]: 作用域:项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e431]: + - generic [ref=e432]: 场景绑定 + - generic [ref=e433]: + - generic [ref=e434]: Story video suite + - generic [ref=e435]: Sceneapp service story video + - generic [ref=e436]: Story video blueprint + - generic [ref=e437]: + - generic [ref=e438]: 记忆引用 + - generic [ref=e439]: + - generic [ref=e440]: Workspace · 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e441]: Project · 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e442]: Memory profile · user_input + - generic [ref=e443]: + - generic [ref=e444]: 工具开放面 + - generic [ref=e445]: + - generic [ref=e446]: Cloud scene + - generic [ref=e447]: Native skill + - generic [ref=e448]: Workspace storage + - generic [ref=e449]: Artifact viewer + - generic [ref=e450]: + - generic [ref=e451]: 参考条目 + - generic [ref=e453]: + - generic [ref=e454]: + - generic [ref=e455]: 用户输入 + - generic [ref=e456]: 用户输入 + - generic [ref=e457]: 文本 + - generic [ref=e458]: 请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。 + - generic [ref=e459]: 风格摘要:当前 TasteProfile 已在项目沉淀基础上,结合 1 条参考输入更新启发式摘要。 + - generic [ref=e460]: + - generic [ref=e461]: 偏好关键词 + - generic [ref=e462]: + - generic [ref=e463]: story-video + - generic [ref=e464]: mv-pipeline + - generic [ref=e465]: 短视频项目包 + - generic [ref=e466]: + - generic [ref=e467]: 避免方向 + - generic [ref=e469]: 偏离参考素材 + - generic [ref=e470]: + - generic [ref=e471]: 已装配 3 条 memory 引用。 + - generic [ref=e472]: 已从项目上下文恢复 1 条历史参考。 + - generic [ref=e473]: 本次新增 1 条参考输入,当前 planning 共带上 1 条参考。 + - generic [ref=e474]: 当前已复用项目级 TasteProfile,并按最新输入继续更新。 + - generic [ref=e476]: 当前 SceneApp 仍有未满足的启动前置条件。 + - generic [ref=e477]: + - generic [ref=e478]: Project Pack 规划 + - generic [ref=e479]: + - generic [ref=e480]: 交付形态:项目资料包 + - generic [ref=e481]: 完成策略:按必含部件判断整包完成度 + - generic [ref=e482]: 主件:任务简报 + - generic [ref=e483]: 查看方式:结果包查看 + - generic [ref=e484]: + - generic [ref=e485]: 任务简报 + - generic [ref=e486]: 分镜 / 线框图 + - generic [ref=e487]: 剧本 + - generic [ref=e488]: 配乐建议 + - generic [ref=e489]: 短视频草稿 + - generic [ref=e490]: 复核意见 + - generic [ref=e491]: + - generic [ref=e492]: 当前 SceneApp 以结果包作为默认交付单位。 + - generic [ref=e493]: 完整度将按 6 个必含部件判断。 + - generic [ref=e494]: 结果会优先回写到项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4,便于继续编辑与复盘。 + - generic [ref=e495]: 结果包会连同参考与风格快照一起进入后续治理链。 + - generic [ref=e496]: 结果包可继续复用当前 viewer 主链打开与检查。 + - generic [ref=e497]: + - generic [ref=e498]: 启动输入 + - paragraph [ref=e499]: 这一步只负责补齐场景意图和上下文基线;真正执行会进入生成主执行面,继续复用 Agent 或自动化主链。 + - generic [ref=e500]: + - generic [ref=e501]: 项目工作区 + - button "📁 默认项目 默认" [ref=e502] [cursor=pointer]: + - generic [ref=e503]: + - generic [ref=e505]: 📁 + - generic [ref=e507]: + - generic [ref=e508]: 默认项目 + - generic [ref=e509]: 默认 + - img [ref=e511] + - generic [ref=e513]: + - generic [ref=e514]: 启动意图 + - textbox "请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。" [ref=e515] + - generic [ref=e516]: + - generic [ref=e517]: 将使用内置起步稿启动 + - generic [ref=e518]: 请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。 + - paragraph [ref=e519]: 写入当前场景基线会把这次显式带入的灵感对象、输入摘要和风格偏好沉淀到项目目录,供后续 planning 继续复用。 + - paragraph [ref=e520]: 先带入灵感对象或启动输入,再写入当前场景基线 + - generic [ref=e522]: + - button "写入当前场景基线" [disabled] + - button "进入生成" [ref=e523] [cursor=pointer] + - generic [ref=e524]: + - generic [ref=e525]: + - generic [ref=e526]: + - generic [ref=e527]: 经营评分 + - paragraph [ref=e528]: 用统一 scorecard 判断这个场景交付的结果是否值得继续放大、优化还是收口。 + - generic [ref=e529]: 适合继续启动 + - paragraph [ref=e530]: 基于最近 1 次真实运行,多模态组合场景的整包交付率为 0.0% ,复核通过率为 0.0% ,发布转化率为 100.0% 。当前主要阻塞集中在复核阻塞。当前样本已经以终态运行记录为主。 目前更适合继续扩大样本。 + - generic [ref=e531]: + - generic [ref=e532]: 当前经营口径 + - paragraph [ref=e533]: 经营上优先看整包是否交齐、是否通过复核,以及结果是否继续被打开编辑或发布。 + - generic [ref=e534]: 交付合同:项目资料包 + - generic [ref=e535]: 查看方式:结果包查看 + - generic [ref=e536]: 完成口径:按必含部件判断整包完成度 + - generic [ref=e537]: + - generic [ref=e538]: 任务简报 + - generic [ref=e539]: 分镜 / 线框图 + - generic [ref=e540]: 剧本 + - generic [ref=e541]: 配乐建议 + - generic [ref=e542]: 短视频草稿 + - generic [ref=e543]: 复核意见 + - generic [ref=e544]: Profile:story-video-scorecard + - generic [ref=e545]: + - generic [ref=e546]: 整包交付率 + - generic [ref=e547]: 复核通过率 + - generic [ref=e548]: 发布转化率 + - generic [ref=e549]: + - generic [ref=e550]: 整包不完整 + - generic [ref=e551]: 复核阻塞 + - generic [ref=e552]: 发布卡点 + - generic [ref=e553]: 当前主要阻塞:复核阻塞 + - generic [ref=e554]: + - generic [ref=e555]: 规划备注 + - generic [ref=e556]: + - generic [ref=e557]: 当前 SceneApp 以结果包作为默认交付单位。 + - generic [ref=e558]: 完整度将按 6 个必含部件判断。 + - generic [ref=e559]: 结果会优先回写到项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4,便于继续编辑与复盘。 + - generic [ref=e560]: 结果包会连同参考与风格快照一起进入后续治理链。 + - generic [ref=e561]: 结果包可继续复用当前 viewer 主链打开与检查。 + - generic [ref=e562]: + - generic [ref=e563]: Planning 基线 + - generic [ref=e564]: 参考注入:1 条 + - generic [ref=e565]: 作用域:项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e567]: 用户输入 · 复核阻塞 + - generic [ref=e568]: 风格摘要:当前 TasteProfile 已在项目沉淀基础上,结合 1 条参考输入更新启发式摘要。 + - generic [ref=e569]: 最近反馈:当前主要卡点是复核阻塞。经营上建议继续补样本。 + - generic [ref=e571]: 复核阻塞 + - generic [ref=e573]: 复核阻塞 + - generic [ref=e574]: + - article [ref=e575]: + - generic [ref=e576]: + - generic [ref=e577]: 整包交付率 + - generic [ref=e578]: "0" + - article [ref=e579]: + - generic [ref=e580]: + - generic [ref=e581]: 复核通过率 + - generic [ref=e582]: "0" + - article [ref=e583]: + - generic [ref=e584]: + - generic [ref=e585]: 发布转化率 + - generic [ref=e586]: "100" + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-sceneapps-after-return.md b/playwright-sceneapps-after-return.md new file mode 100644 index 000000000..fead3c403 --- /dev/null +++ b/playwright-sceneapps-after-return.md @@ -0,0 +1,328 @@ +- 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 "创作场景" [active] [ref=e37] [cursor=pointer]: + - img [ref=e38] + - 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]: 消息渠道 + - generic [ref=e52]: + - generic [ref=e53]: 资料 + - button "资料库" [ref=e54] [cursor=pointer]: + - img [ref=e55] + - generic [ref=e57]: 资料库 + - button "灵感库" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e71]: 灵感库 + - generic [ref=e72]: + - generic [ref=e73]: + - generic [ref=e74]: 系统 + - button "OpenClaw" [ref=e75] [cursor=pointer]: + - img [ref=e76] + - generic [ref=e79]: OpenClaw + - button "设置" [ref=e80] [cursor=pointer]: + - img [ref=e81] + - generic [ref=e84]: 设置 + - button "切换到深色模式" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - main [ref=e93]: + - generic [ref=e589]: + - generic [ref=e590]: + - generic [ref=e591]: + - generic [ref=e592]: + - generic [ref=e593]: CREATE SCENES + - heading "按创作场景组织完整结果链" [level=1] [ref=e594] + - paragraph [ref=e595]: 这里前台讲的是创作场景,不是内部 SceneApp 名词。每条场景都会带上参考素材、风格摘要、工具能力和交付合同,帮助你先完成选路与装配,再进入生成、结果交付和复盘。 + - button "刷新目录" [ref=e596] [cursor=pointer] + - generic [ref=e597]: + - generic [ref=e598]: + - button "场景目录" [ref=e599] [cursor=pointer] + - button "生成准备" [ref=e600] [cursor=pointer] + - button "治理复盘" [ref=e601] [cursor=pointer] + - paragraph [ref=e602]: 集中查看交付合同、上下文基线和进入生成前的准备。 + - generic [ref=e603]: + - generic [ref=e604]: 当前场景: + - generic [ref=e605]: 短视频编排 + - generic [ref=e606]: 把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。 + - button "查看准备" [ref=e607] [cursor=pointer] + - button "查看治理" [ref=e608] [cursor=pointer] + - generic [ref=e609]: + - generic [ref=e611]: + - generic [ref=e612]: + - generic [ref=e613]: + - generic [ref=e614]: 多模态组合 + - heading "短视频编排" [level=2] [ref=e615] + - paragraph [ref=e616]: 把文本、线框图、配乐、剧本和短视频草稿收口成一条多模态结果链。 + - generic [ref=e617]: + - generic [ref=e618]: 多能力组合 + - generic [ref=e619]: 项目资料包 + - generic [ref=e620]: + - generic [ref=e621]: 从一句主题串起脚本、线框图、配乐方向和短视频草稿。 + - generic [ref=e622]: + - generic [ref=e623]: 产出:短视频项目包 + - generic [ref=e624]: 执行主链:云端 Scene + - generic [ref=e625]: 来源包:lime-core-sceneapps + - generic [ref=e626]: 版本:2026-04-15 + - generic [ref=e627]: + - generic [ref=e628]: + - generic [ref=e629]: 设计模式 + - generic [ref=e630]: + - generic [ref=e631]: 步骤链 + - generic [ref=e632]: 先补上下文 + - generic [ref=e633]: 结果生成 + - generic [ref=e634]: 质量复核 + - generic [ref=e635]: + - generic [ref=e636]: 启动前置 + - generic [ref=e637]: + - generic [ref=e638]: 需要主题、风格或脚本线索作为场景输入。 + - generic [ref=e639]: 需要项目目录承接线框图、脚本和媒体结果。 + - generic [ref=e640]: 需要可用的云端运行时来完成多模态媒体处理。 + - generic [ref=e641]: + - generic [ref=e642]: + - generic [ref=e643]: 交付合同 + - paragraph [ref=e644]: 这条 SceneApp 会把结果收口成一份可继续编辑的项目资料包,至少包含 任务简报、分镜 / 线框图、剧本、配乐建议、短视频草稿、复核意见。 + - generic [ref=e645]: + - generic [ref=e646]: 主结果:短视频项目包 + - generic [ref=e647]: 默认主件:任务简报 + - generic [ref=e648]: 查看方式:结果包查看 + - generic [ref=e649]: Artifact:story-video-artifacts + - generic [ref=e650]: + - generic [ref=e651]: 任务简报 + - generic [ref=e652]: 分镜 / 线框图 + - generic [ref=e653]: 剧本 + - generic [ref=e654]: 配乐建议 + - generic [ref=e655]: 短视频草稿 + - generic [ref=e656]: 复核意见 + - generic [ref=e657]: + - generic [ref=e658]: 组合步骤 + - generic [ref=e659]: + - generic [ref=e660]: 蓝图:story-video-blueprint + - generic [ref=e661]: 阶段数:6 + - generic [ref=e662]: + - generic [ref=e663]: + - generic [ref=e664]: 第 1 步 · 任务简报 + - generic [ref=e665]: 执行面:本机技能 + - generic [ref=e666]: + - generic [ref=e667]: 第 2 步 · 分镜 / 线框图 + - generic [ref=e668]: 执行面:本机技能 + - generic [ref=e669]: + - generic [ref=e670]: 第 3 步 · 剧本 + - generic [ref=e671]: 执行面:本机技能 + - generic [ref=e672]: + - generic [ref=e673]: 第 4 步 · 配乐建议 + - generic [ref=e674]: 执行面:云端 Scene + - generic [ref=e675]: + - generic [ref=e676]: 第 5 步 · 短视频草稿 + - generic [ref=e677]: 执行面:云端 Scene + - generic [ref=e678]: + - generic [ref=e679]: 第 6 步 · 复核意见 + - generic [ref=e680]: 执行面:本机技能 + - generic [ref=e681]: + - generic [ref=e682]: 评分口径 + - paragraph [ref=e683]: 经营上优先看整包是否交齐、是否通过复核,以及结果是否继续被打开编辑或发布。 + - generic [ref=e684]: Profile:story-video-scorecard + - generic [ref=e685]: + - generic [ref=e686]: 整包交付率 + - generic [ref=e687]: 复核通过率 + - generic [ref=e688]: 发布转化率 + - generic [ref=e689]: + - generic [ref=e690]: 重点关注 + - generic [ref=e691]: + - generic [ref=e692]: 整包不完整 + - generic [ref=e693]: 复核阻塞 + - generic [ref=e694]: 发布卡点 + - generic [ref=e695]: + - generic [ref=e696]: + - generic [ref=e697]: + - generic [ref=e698]: 执行预规划 + - generic [ref=e699]: 待补 1 项 + - paragraph [ref=e700]: 当前预规划已生成,但还有 1 项前置待补齐;补齐后会继续沿 云端 Scene 执行。 + - generic [ref=e701]: + - generic [ref=e702]: 仍待补齐 + - generic [ref=e704]: 需要可用的云端运行时来完成多模态媒体处理。 + - generic [ref=e705]: + - generic [ref=e706]: + - generic [ref=e707]: 活跃层 + - generic [ref=e708]: + - generic [ref=e709]: Skill + - generic [ref=e710]: Tool + - generic [ref=e711]: Memory + - generic [ref=e712]: Reference + - generic [ref=e713]: Taste + - generic [ref=e714]: 参考注入:1 条 + - generic [ref=e715]: 作用域:项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e716]: + - generic [ref=e717]: 场景绑定 + - generic [ref=e718]: + - generic [ref=e719]: Story video suite + - generic [ref=e720]: Sceneapp service story video + - generic [ref=e721]: Story video blueprint + - generic [ref=e722]: + - generic [ref=e723]: 记忆引用 + - generic [ref=e724]: + - generic [ref=e725]: Workspace · 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e726]: Project · 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e727]: Memory profile · user_input + - generic [ref=e728]: + - generic [ref=e729]: 工具开放面 + - generic [ref=e730]: + - generic [ref=e731]: Cloud scene + - generic [ref=e732]: Native skill + - generic [ref=e733]: Workspace storage + - generic [ref=e734]: Artifact viewer + - generic [ref=e735]: + - generic [ref=e736]: 参考条目 + - generic [ref=e738]: + - generic [ref=e739]: + - generic [ref=e740]: 用户输入 + - generic [ref=e741]: 用户输入 + - generic [ref=e742]: 文本 + - generic [ref=e743]: 请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。 + - generic [ref=e744]: 风格摘要:当前 TasteProfile 已在项目沉淀基础上,结合 1 条参考输入更新启发式摘要。 + - generic [ref=e745]: + - generic [ref=e746]: 偏好关键词 + - generic [ref=e747]: + - generic [ref=e748]: story-video + - generic [ref=e749]: mv-pipeline + - generic [ref=e750]: 短视频项目包 + - generic [ref=e751]: + - generic [ref=e752]: 避免方向 + - generic [ref=e754]: 偏离参考素材 + - generic [ref=e755]: + - generic [ref=e756]: 已装配 3 条 memory 引用。 + - generic [ref=e757]: 已从项目上下文恢复 1 条历史参考。 + - generic [ref=e758]: 本次新增 1 条参考输入,当前 planning 共带上 1 条参考。 + - generic [ref=e759]: 当前已复用项目级 TasteProfile,并按最新输入继续更新。 + - generic [ref=e761]: 当前 SceneApp 仍有未满足的启动前置条件。 + - generic [ref=e762]: + - generic [ref=e763]: Project Pack 规划 + - generic [ref=e764]: + - generic [ref=e765]: 交付形态:项目资料包 + - generic [ref=e766]: 完成策略:按必含部件判断整包完成度 + - generic [ref=e767]: 主件:任务简报 + - generic [ref=e768]: 查看方式:结果包查看 + - generic [ref=e769]: + - generic [ref=e770]: 任务简报 + - generic [ref=e771]: 分镜 / 线框图 + - generic [ref=e772]: 剧本 + - generic [ref=e773]: 配乐建议 + - generic [ref=e774]: 短视频草稿 + - generic [ref=e775]: 复核意见 + - generic [ref=e776]: + - generic [ref=e777]: 当前 SceneApp 以结果包作为默认交付单位。 + - generic [ref=e778]: 完整度将按 6 个必含部件判断。 + - generic [ref=e779]: 结果会优先回写到项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4,便于继续编辑与复盘。 + - generic [ref=e780]: 结果包会连同参考与风格快照一起进入后续治理链。 + - generic [ref=e781]: 结果包可继续复用当前 viewer 主链打开与检查。 + - generic [ref=e782]: + - generic [ref=e783]: 启动输入 + - paragraph [ref=e784]: 这一步只负责补齐场景意图和上下文基线;真正执行会进入生成主执行面,继续复用 Agent 或自动化主链。 + - generic [ref=e785]: + - generic [ref=e786]: 项目工作区 + - button "📁 默认项目 默认" [ref=e787] [cursor=pointer]: + - generic [ref=e788]: + - generic [ref=e790]: 📁 + - generic [ref=e792]: + - generic [ref=e793]: 默认项目 + - generic [ref=e794]: 默认 + - img [ref=e796] + - generic [ref=e798]: + - generic [ref=e799]: 启动意图 + - textbox "请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。" [ref=e800] + - generic [ref=e801]: + - generic [ref=e802]: 将使用内置起步稿启动 + - generic [ref=e803]: 请围绕一个产品卖点,生成一版 30 秒短视频方案:先给脚本方向、镜头结构、线框图提示和配乐情绪。 + - paragraph [ref=e804]: 写入当前场景基线会把这次显式带入的灵感对象、输入摘要和风格偏好沉淀到项目目录,供后续 planning 继续复用。 + - paragraph [ref=e805]: 先带入灵感对象或启动输入,再写入当前场景基线 + - generic [ref=e807]: + - button "写入当前场景基线" [disabled] + - button "进入生成" [ref=e808] [cursor=pointer] + - generic [ref=e809]: + - generic [ref=e810]: + - generic [ref=e811]: + - generic [ref=e812]: 经营评分 + - paragraph [ref=e813]: 用统一 scorecard 判断这个场景交付的结果是否值得继续放大、优化还是收口。 + - generic [ref=e814]: 适合继续启动 + - paragraph [ref=e815]: 基于最近 1 次真实运行,多模态组合场景的整包交付率为 0.0% ,复核通过率为 0.0% ,发布转化率为 100.0% 。当前主要阻塞集中在复核阻塞。当前样本已经以终态运行记录为主。 目前更适合继续扩大样本。 + - generic [ref=e816]: + - generic [ref=e817]: 当前经营口径 + - paragraph [ref=e818]: 经营上优先看整包是否交齐、是否通过复核,以及结果是否继续被打开编辑或发布。 + - generic [ref=e819]: 交付合同:项目资料包 + - generic [ref=e820]: 查看方式:结果包查看 + - generic [ref=e821]: 完成口径:按必含部件判断整包完成度 + - generic [ref=e822]: + - generic [ref=e823]: 任务简报 + - generic [ref=e824]: 分镜 / 线框图 + - generic [ref=e825]: 剧本 + - generic [ref=e826]: 配乐建议 + - generic [ref=e827]: 短视频草稿 + - generic [ref=e828]: 复核意见 + - generic [ref=e829]: Profile:story-video-scorecard + - generic [ref=e830]: + - generic [ref=e831]: 整包交付率 + - generic [ref=e832]: 复核通过率 + - generic [ref=e833]: 发布转化率 + - generic [ref=e834]: + - generic [ref=e835]: 整包不完整 + - generic [ref=e836]: 复核阻塞 + - generic [ref=e837]: 发布卡点 + - generic [ref=e838]: 当前主要阻塞:复核阻塞 + - generic [ref=e839]: + - generic [ref=e840]: 规划备注 + - generic [ref=e841]: + - generic [ref=e842]: 当前 SceneApp 以结果包作为默认交付单位。 + - generic [ref=e843]: 完整度将按 6 个必含部件判断。 + - generic [ref=e844]: 结果会优先回写到项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4,便于继续编辑与复盘。 + - generic [ref=e845]: 结果包会连同参考与风格快照一起进入后续治理链。 + - generic [ref=e846]: 结果包可继续复用当前 viewer 主链打开与检查。 + - generic [ref=e847]: + - generic [ref=e848]: Planning 基线 + - generic [ref=e849]: 参考注入:1 条 + - generic [ref=e850]: 作用域:项目 849e36ff-8f64-45ed-ba51-aab6b8e182e4 + - generic [ref=e852]: 用户输入 · 复核阻塞 + - generic [ref=e853]: 风格摘要:当前 TasteProfile 已在项目沉淀基础上,结合 1 条参考输入更新启发式摘要。 + - generic [ref=e854]: 最近反馈:当前主要卡点是复核阻塞。经营上建议继续补样本。 + - generic [ref=e856]: 复核阻塞 + - generic [ref=e858]: 复核阻塞 + - generic [ref=e859]: + - article [ref=e860]: + - generic [ref=e861]: + - generic [ref=e862]: 整包交付率 + - generic [ref=e863]: "0" + - article [ref=e864]: + - generic [ref=e865]: + - generic [ref=e866]: 复核通过率 + - generic [ref=e867]: "0" + - article [ref=e868]: + - generic [ref=e869]: + - generic [ref=e870]: 发布转化率 + - generic [ref=e871]: "100" + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-sceneapps-current-deep.yml b/playwright-sceneapps-current-deep.yml new file mode 100644 index 000000000..2656b5670 --- /dev/null +++ b/playwright-sceneapps-current-deep.yml @@ -0,0 +1,178 @@ +- 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=e42]: 创作场景 + - button "持续流程" [ref=e43] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e46]: 持续流程 + - button "消息渠道" [ref=e47] [cursor=pointer]: + - img [ref=e48] + - generic [ref=e51]: 消息渠道 + - generic [ref=e52]: + - generic [ref=e53]: 资料 + - button "资料库" [ref=e54] [cursor=pointer]: + - img [ref=e55] + - generic [ref=e57]: 资料库 + - button "灵感库" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e71]: 灵感库 + - generic [ref=e72]: + - generic [ref=e73]: + - generic [ref=e74]: 系统 + - button "OpenClaw" [ref=e75] [cursor=pointer]: + - img [ref=e76] + - generic [ref=e79]: OpenClaw + - button "设置" [ref=e80] [cursor=pointer]: + - img [ref=e81] + - generic [ref=e84]: 设置 + - button "切换到深色模式" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - main [ref=e93]: + - generic [ref=e107]: + - generic [ref=e110]: + - generic [ref=e111]: + - generic [ref=e112]: 创作 + - generic [ref=e114]: + - button "📁 默认项目 默认" [ref=e115] [cursor=pointer]: + - generic [ref=e116] + - img [ref=e123] + - button "打开设置" [ref=e126] [cursor=pointer]: + - img [ref=e127] + - generic [ref=e130]: + - generic [ref=e131]: + - paragraph [ref=e132]: 青柠一下,灵感即来 + - paragraph [ref=e133]: 说一句目标,剩下的交给 Lime。 + - paragraph [ref=e134]: 文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。 + - generic [ref=e135]: + - generic [ref=e136]: 通用对话 + - generic [ref=e137]: 对话内开工 + - generic [ref=e140]: + - generic [ref=e141]: + - button "添加图片" [ref=e142] [cursor=pointer]: + - img [ref=e143] + - textbox "有什么我可以帮你的?" [ref=e148] + - generic [ref=e149]: + - button "展开输入框" [ref=e150] [cursor=pointer]: + - img [ref=e151] + - button "开始语音输入" [ref=e153] [cursor=pointer]: + - img [ref=e154] + - button "发送" [disabled] [ref=e157]: + - img [ref=e158] + - generic [ref=e162]: + - button "展开高级设置" [ref=e163] [cursor=pointer]: + - img [ref=e166] + - generic [ref=e169]: 高级设置 + - img [ref=e170] + - generic "当前模型:Custom-a32774c6-6fd0-433b-8b81-e95340e08793 / glm-5.1" [ref=e172]: + - generic [ref=e173]: 当前模型 + - generic [ref=e174]: glm-5.1 + - generic [ref=e175]: + - generic [ref=e176]: + - generic [ref=e177]: + - generic [ref=e178]: + - generic [ref=e179]: 创作场景 + - paragraph [ref=e180]: 不是单个技能,而是一条完整结果链。先挑一个场景,Lime 会把合适的能力和交付合同组合起来。 + - generic [ref=e181]: + - button "继续最近场景" [ref=e182] [cursor=pointer] + - button "查看全部场景" [ref=e183] [cursor=pointer] + - generic [ref=e184]: + - generic [ref=e185]: + - button "短视频编排" [ref=e186] [cursor=pointer] + - generic [ref=e187]: / + - button "每日趋势摘要" [ref=e189] [cursor=pointer] + - generic [ref=e190]: + - generic [ref=e192]: + - generic [ref=e193]: 继续上次做法 + - generic [ref=e194]: 最近跑通过的结果模板和常用做法会留在这里,下一次不用重新开始。 + - generic [ref=e195]: 你最近跑通过的结果模板和方法,会出现在这里。 + - generic [ref=e196]: + - generic [ref=e198]: + - generic [ref=e199]: 结果模板 + - generic [ref=e200]: 先选你想拿到什么结果,具体由 Lime 在当前任务里分发对应能力。 + - generic [ref=e201]: + - button "网页研究简报" [ref=e203] [cursor=pointer]: + - generic [ref=e204]: 网页研究简报 + - generic [ref=e205]: + - generic [ref=e206]: "|" + - button "内容主稿生成" [ref=e207] [cursor=pointer] + - generic [ref=e209]: + - generic [ref=e210]: "|" + - button "演示提纲草案" [ref=e211] [cursor=pointer] + - generic [ref=e213]: + - generic [ref=e214]: "|" + - button "网页登录与采集" [ref=e215] [cursor=pointer] + - generic [ref=e217]: + - generic [ref=e218]: "|" + - button "复制轮播帖" [ref=e219] [cursor=pointer] + - generic [ref=e221]: + - generic [ref=e222]: "|" + - button "复制视频脚本" [ref=e223] [cursor=pointer] + - generic [ref=e225]: + - article [ref=e226]: + - generic [ref=e227]: + - img [ref=e229] + - generic [ref=e231]: 沉淀能力 + - generic [ref=e232]: + - generic [ref=e233]: + - generic [ref=e234]: 我的方法 + - button "我的方法说明" [ref=e236] + - generic [ref=e240]: 29 套做法可直接复用 + - img "方法能力卡占位图" [ref=e243] + - article [ref=e244]: + - generic [ref=e245]: + - img [ref=e247] + - generic [ref=e250]: 沉淀能力 + - generic [ref=e251]: + - generic [ref=e252]: + - generic [ref=e253]: 持续流程 + - button "持续流程说明" [ref=e255] + - generic [ref=e259]: 重复任务可持续复用 + - img "持续流程能力卡占位图" [ref=e262] + - article [ref=e263]: + - generic [ref=e264]: + - img [ref=e266] + - generic [ref=e270]: 支撑能力 + - generic [ref=e271]: + - generic [ref=e272]: + - generic [ref=e273]: 任务拆分 + - button "任务拆分说明" [ref=e275] + - generic [ref=e279]: 复杂任务可拆分并行推进 + - img "任务拆分能力卡占位图" [ref=e282] + - article [ref=e283]: + - generic [ref=e284]: + - img [ref=e286] + - generic [ref=e289]: 支撑能力 + - generic [ref=e290]: + - generic [ref=e291]: + - generic [ref=e292]: 浏览器接入 + - button "浏览器接入说明" [ref=e294] + - generic [ref=e298]: 网页登录与网页执行 + - button "连接浏览器" [ref=e300] [cursor=pointer]: + - img "浏览器接入能力卡占位图" [ref=e301] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-sceneapps-home.yml b/playwright-sceneapps-home.yml new file mode 100644 index 000000000..542dc9243 --- /dev/null +++ b/playwright-sceneapps-home.yml @@ -0,0 +1,100 @@ +- 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=e52]: 创作场景 + - button "持续流程" [ref=e53] [cursor=pointer]: + - img [ref=e54] + - generic [ref=e56]: 持续流程 + - button "消息渠道" [ref=e57] [cursor=pointer]: + - img [ref=e58] + - generic [ref=e61]: 消息渠道 + - generic [ref=e62]: + - generic [ref=e63]: 资料 + - button "资料库" [ref=e64] [cursor=pointer]: + - img [ref=e65] + - generic [ref=e67]: 资料库 + - button "灵感库" [ref=e68] [cursor=pointer]: + - img [ref=e69] + - generic [ref=e81]: 灵感库 + - generic [ref=e82]: + - generic [ref=e83]: + - generic [ref=e84]: 系统 + - button "OpenClaw" [ref=e85] [cursor=pointer]: + - img [ref=e86] + - generic [ref=e89]: OpenClaw + - button "设置" [ref=e90] [cursor=pointer]: + - img [ref=e91] + - generic [ref=e94]: 设置 + - button "切换到深色模式" [ref=e96] [cursor=pointer]: + - img [ref=e97] + - main [ref=e103]: + - generic [ref=e117]: + - generic [ref=e120]: + - generic [ref=e121]: + - generic [ref=e122]: 创作 + - generic [ref=e124] + - generic [ref=e140]: + - generic [ref=e141] + - generic [ref=e145] + - generic [ref=e150]: + - generic [ref=e151]: + - button "添加图片" [ref=e152] [cursor=pointer] + - textbox "有什么我可以帮你的?" [ref=e158] + - generic [ref=e159] + - generic [ref=e172]: + - button "展开高级设置" [ref=e173] [cursor=pointer] + - generic "当前模型:Custom-a32774c6-6fd0-433b-8b81-e95340e08793 / glm-5.1" [ref=e182] + - generic [ref=e185]: + - generic [ref=e186]: + - generic [ref=e187] + - generic [ref=e194] + - generic [ref=e200]: + - generic [ref=e202] + - generic [ref=e205]: 你最近跑通过的结果模板和方法,会出现在这里。 + - generic [ref=e206]: + - generic [ref=e208] + - generic [ref=e211] + - generic [ref=e235]: + - article [ref=e236]: + - generic [ref=e237] + - generic [ref=e242] + - img "方法能力卡占位图" [ref=e253] + - article [ref=e254]: + - generic [ref=e255] + - generic [ref=e261] + - img "持续流程能力卡占位图" [ref=e272] + - article [ref=e273]: + - generic [ref=e274] + - generic [ref=e281] + - img "任务拆分能力卡占位图" [ref=e292] + - article [ref=e293]: + - generic [ref=e294] + - generic [ref=e300] + - button "连接浏览器" [ref=e310] [cursor=pointer] + - region "Notifications alt+T" \ No newline at end of file diff --git a/playwright-snapshot-sceneapps-return-before.md b/playwright-snapshot-sceneapps-return-before.md new file mode 100644 index 000000000..59157b337 --- /dev/null +++ b/playwright-snapshot-sceneapps-return-before.md @@ -0,0 +1,191 @@ +- 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=e42]: 创作场景 + - button "持续流程" [ref=e43] [cursor=pointer]: + - img [ref=e44] + - generic [ref=e46]: 持续流程 + - button "消息渠道" [ref=e47] [cursor=pointer]: + - img [ref=e48] + - generic [ref=e51]: 消息渠道 + - generic [ref=e52]: + - generic [ref=e53]: 资料 + - button "资料库" [ref=e54] [cursor=pointer]: + - img [ref=e55] + - generic [ref=e57]: 资料库 + - button "灵感库" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - generic [ref=e71]: 灵感库 + - generic [ref=e72]: + - generic [ref=e73]: + - generic [ref=e74]: 系统 + - button "OpenClaw" [ref=e75] [cursor=pointer]: + - img [ref=e76] + - generic [ref=e79]: OpenClaw + - button "设置" [ref=e80] [cursor=pointer]: + - img [ref=e81] + - generic [ref=e84]: 设置 + - button "切换到深色模式" [ref=e86] [cursor=pointer]: + - img [ref=e87] + - main [ref=e93]: + - generic [ref=e107]: + - generic [ref=e110]: + - generic [ref=e111]: + - generic [ref=e112]: 创作 + - generic [ref=e114]: + - button "📁 默认项目 默认" [ref=e115] [cursor=pointer]: + - generic [ref=e116]: + - generic [ref=e118]: 📁 + - generic [ref=e119]: + - generic [ref=e120]: 默认项目 + - generic [ref=e121]: 默认 + - img [ref=e123] + - button "打开设置" [ref=e126] [cursor=pointer]: + - img [ref=e127] + - generic [ref=e130]: + - generic [ref=e131]: + - paragraph [ref=e132]: 青柠一下,灵感即来 + - paragraph [ref=e133]: 说一句目标,剩下的交给 Lime。 + - paragraph [ref=e134]: 文案、图片、视频、搜索和网页任务,会围绕同一个目标持续推进。跑通过的方法会沉淀成常用做法、偏好和项目上下文,下次不用重新开始。 + - generic [ref=e135]: + - generic [ref=e136]: 通用对话 + - generic [ref=e137]: 对话内开工 + - generic [ref=e140]: + - generic [ref=e141]: + - button "添加图片" [ref=e142] [cursor=pointer]: + - img [ref=e143] + - textbox "有什么我可以帮你的?" [ref=e148] + - generic [ref=e149]: + - button "展开输入框" [ref=e150] [cursor=pointer]: + - img [ref=e151] + - button "开始语音输入" [ref=e153] [cursor=pointer]: + - img [ref=e154] + - button "发送" [disabled] [ref=e157]: + - img [ref=e158] + - generic [ref=e162]: + - button "展开高级设置" [ref=e163] [cursor=pointer]: + - img [ref=e166] + - generic [ref=e169]: 高级设置 + - img [ref=e170] + - generic "当前模型:Custom-a32774c6-6fd0-433b-8b81-e95340e08793 / glm-5.1" [ref=e172]: + - generic [ref=e173]: 当前模型 + - generic [ref=e174]: glm-5.1 + - generic [ref=e175]: + - generic [ref=e176]: + - generic [ref=e177]: + - generic [ref=e178]: + - generic [ref=e179]: 创作场景 + - paragraph [ref=e180]: 不是单个技能,而是一条完整结果链。先挑一个场景,Lime 会把合适的能力和交付合同组合起来。 + - generic [ref=e181]: + - button "继续最近场景" [ref=e182] [cursor=pointer] + - button "查看全部场景" [ref=e183] [cursor=pointer] + - generic [ref=e184]: + - generic [ref=e185]: + - button "短视频编排" [ref=e186] [cursor=pointer] + - generic [ref=e187]: / + - button "每日趋势摘要" [ref=e189] [cursor=pointer] + - generic [ref=e190]: + - generic [ref=e192]: + - generic [ref=e193]: 继续上次做法 + - generic [ref=e194]: 最近跑通过的结果模板和常用做法会留在这里,下一次不用重新开始。 + - generic [ref=e195]: 你最近跑通过的结果模板和方法,会出现在这里。 + - generic [ref=e196]: + - generic [ref=e198]: + - generic [ref=e199]: 结果模板 + - generic [ref=e200]: 先选你想拿到什么结果,具体由 Lime 在当前任务里分发对应能力。 + - generic [ref=e201]: + - button "网页研究简报" [ref=e203] [cursor=pointer]: + - generic [ref=e204]: 网页研究简报 + - generic [ref=e205]: + - generic [ref=e206]: "|" + - button "内容主稿生成" [ref=e207] [cursor=pointer]: + - generic [ref=e208]: 内容主稿生成 + - generic [ref=e209]: + - generic [ref=e210]: "|" + - button "演示提纲草案" [ref=e211] [cursor=pointer]: + - generic [ref=e212]: 演示提纲草案 + - generic [ref=e213]: + - generic [ref=e214]: "|" + - button "网页登录与采集" [ref=e215] [cursor=pointer]: + - generic [ref=e216]: 网页登录与采集 + - generic [ref=e217]: + - generic [ref=e218]: "|" + - button "复制轮播帖" [ref=e219] [cursor=pointer]: + - generic [ref=e220]: 复制轮播帖 + - generic [ref=e221]: + - generic [ref=e222]: "|" + - button "复制视频脚本" [ref=e223] [cursor=pointer]: + - generic [ref=e224]: 复制视频脚本 + - generic [ref=e225]: + - article [ref=e226]: + - generic [ref=e227]: + - img [ref=e229] + - generic [ref=e231]: 沉淀能力 + - generic [ref=e232]: + - generic [ref=e233]: + - generic [ref=e234]: 我的方法 + - button "我的方法说明" [ref=e236]: + - img [ref=e237] + - generic [ref=e240]: 29 套做法可直接复用 + - img "方法能力卡占位图" [ref=e243] + - article [ref=e244]: + - generic [ref=e245]: + - img [ref=e247] + - generic [ref=e250]: 沉淀能力 + - generic [ref=e251]: + - generic [ref=e252]: + - generic [ref=e253]: 持续流程 + - button "持续流程说明" [ref=e255]: + - img [ref=e256] + - generic [ref=e259]: 重复任务可持续复用 + - img "持续流程能力卡占位图" [ref=e262] + - article [ref=e263]: + - generic [ref=e264]: + - img [ref=e266] + - generic [ref=e270]: 支撑能力 + - generic [ref=e271]: + - generic [ref=e272]: + - generic [ref=e273]: 任务拆分 + - button "任务拆分说明" [ref=e275]: + - img [ref=e276] + - generic [ref=e279]: 复杂任务可拆分并行推进 + - img "任务拆分能力卡占位图" [ref=e282] + - article [ref=e283]: + - generic [ref=e284]: + - img [ref=e286] + - generic [ref=e289]: 支撑能力 + - generic [ref=e290]: + - generic [ref=e291]: + - generic [ref=e292]: 浏览器接入 + - button "浏览器接入说明" [ref=e294]: + - img [ref=e295] + - generic [ref=e298]: 网页登录与网页执行 + - button "连接浏览器" [ref=e300] [cursor=pointer]: + - img "浏览器接入能力卡占位图" [ref=e301] + - region "Notifications alt+T" \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9582d6272..3ac84b2ed 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5101,7 +5101,7 @@ dependencies = [ [[package]] name = "lime" -version = "1.12.3" +version = "1.13.0" dependencies = [ "anyhow", "arboard", @@ -5146,7 +5146,6 @@ dependencies = [ "lime-server-utils", "lime-services", "lime-skills", - "lime-terminal", "lime-websocket", "md5", "mouse_position", @@ -5206,7 +5205,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.12.3" +version = "1.13.0" dependencies = [ "anyhow", "aster-core", @@ -5235,7 +5234,7 @@ dependencies = [ [[package]] name = "lime-browser-runtime" -version = "1.12.3" +version = "1.13.0" dependencies = [ "chrono", "futures", @@ -5252,7 +5251,7 @@ dependencies = [ [[package]] name = "lime-cli" -version = "1.12.3" +version = "1.13.0" dependencies = [ "clap", "lime-core", @@ -5264,7 +5263,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.12.3" +version = "1.13.0" dependencies = [ "async-trait", "lime-core", @@ -5280,7 +5279,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.12.3" +version = "1.13.0" dependencies = [ "aster-models", "async-trait", @@ -5320,7 +5319,7 @@ dependencies = [ [[package]] name = "lime-credential" -version = "1.12.3" +version = "1.13.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -5355,7 +5354,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.12.3" +version = "1.13.0" dependencies = [ "aes", "axum 0.7.9", @@ -5385,7 +5384,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.12.3" +version = "1.13.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -5405,7 +5404,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.12.3" +version = "1.13.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5421,7 +5420,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.12.3" +version = "1.13.0" dependencies = [ "axum 0.7.9", "chrono", @@ -5452,7 +5451,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.12.3" +version = "1.13.0" dependencies = [ "async-trait", "lime-core", @@ -5471,7 +5470,7 @@ dependencies = [ [[package]] name = "lime-providers" -version = "1.12.3" +version = "1.13.0" dependencies = [ "anyhow", "async-stream", @@ -5526,7 +5525,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.12.3" +version = "1.13.0" dependencies = [ "aster-core", "async-stream", @@ -5571,7 +5570,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.12.3" +version = "1.13.0" dependencies = [ "axum 0.7.9", "futures", @@ -5586,7 +5585,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.12.3" +version = "1.13.0" dependencies = [ "anyhow", "aster-core", @@ -5628,7 +5627,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.12.3" +version = "1.13.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -5644,36 +5643,9 @@ dependencies = [ "tracing", ] -[[package]] -name = "lime-terminal" -version = "1.12.3" -dependencies = [ - "async-trait", - "base64 0.22.1", - "chrono", - "dashmap 5.5.3", - "dirs 5.0.1", - "lime-core", - "parking_lot", - "portable-pty", - "proptest", - "rusqlite", - "scopeguard", - "serde", - "serde_json", - "sha2", - "ssh2", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", - "uuid", - "whoami", -] - [[package]] name = "lime-websocket" -version = "1.12.3" +version = "1.13.0" dependencies = [ "axum 0.7.9", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 08fde32c2..f57f25d17 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/aster", "crates/aster-models", "crates/aster-rust"] resolver = "2" [workspace.package] -version = "1.12.3" +version = "1.13.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -17,7 +17,6 @@ lime-config = { path = "crates/config" } lime-infra = { path = "crates/infra" } lime-providers = { path = "crates/providers" } lime-services = { path = "crates/services" } -lime-terminal = { path = "crates/terminal" } lime-credential = { path = "crates/credential" } lime-websocket = { path = "crates/websocket" } lime-processor = { path = "crates/processor" } @@ -189,7 +188,7 @@ version = "2.4" [package] name = "lime" -version = "1.12.3" +version = "1.13.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" @@ -211,7 +210,6 @@ lime-config.workspace = true lime-infra.workspace = true lime-providers.workspace = true lime-services.workspace = true -lime-terminal.workspace = true lime-credential.workspace = true lime-websocket.workspace = true lime-processor.workspace = true diff --git a/src-tauri/crates/agent/src/session_execution_runtime.rs b/src-tauri/crates/agent/src/session_execution_runtime.rs index b43bf514d..e927ee2c7 100644 --- a/src-tauri/crates/agent/src/session_execution_runtime.rs +++ b/src-tauri/crates/agent/src/session_execution_runtime.rs @@ -41,6 +41,10 @@ impl ExtensionState for SessionExecutionRuntimeAccessMode { } impl SessionExecutionRuntimeAccessMode { + pub fn default_for_session() -> Self { + Self::FullAccess + } + pub fn as_str(&self) -> &'static str { match self { Self::ReadOnly => "read-only", @@ -1052,6 +1056,14 @@ mod tests { ); } + #[test] + fn default_session_access_mode_is_full_access() { + assert_eq!( + SessionExecutionRuntimeAccessMode::default_for_session(), + SessionExecutionRuntimeAccessMode::FullAccess + ); + } + #[test] fn keeps_recent_team_selection_from_latest_turn_metadata() { let now = Utc::now(); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/providers/codex_app_server.rs b/src-tauri/crates/aster-rust/crates/aster/src/providers/codex_app_server.rs index e350c9092..1998f6e1f 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/providers/codex_app_server.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/providers/codex_app_server.rs @@ -54,7 +54,7 @@ fn resolve_codex_runtime_policies() -> (String, String) { let sandbox_policy = turn_context .as_ref() .and_then(|context| context.sandbox_policy.clone()) - .unwrap_or_else(|| "workspace-write".to_string()); + .unwrap_or_else(|| "danger-full-access".to_string()); (approval_policy, sandbox_policy) } @@ -999,7 +999,7 @@ mod tests { assert_eq!(params["threadId"], json!("thread-1")); assert_eq!(params["approvalPolicy"], json!("never")); - assert_eq!(params["sandboxPolicy"]["type"], json!("workspaceWrite")); + assert_eq!(params["sandboxPolicy"]["type"], json!("dangerFullAccess")); assert_eq!(params["model"], json!("gpt-5.3-codex")); assert_eq!(params["effort"], json!("high")); assert_eq!(params["input"][0]["type"], json!("text")); diff --git a/src-tauri/crates/aster-rust/crates/aster/src/tools/web.rs b/src-tauri/crates/aster-rust/crates/aster/src/tools/web.rs index 964cc8e80..73a6eb4ca 100644 --- a/src-tauri/crates/aster-rust/crates/aster/src/tools/web.rs +++ b/src-tauri/crates/aster-rust/crates/aster/src/tools/web.rs @@ -129,6 +129,30 @@ const WEB_FETCH_PREAPPROVED_HOSTS: &[&str] = &[ "httpd.apache.org", ]; +fn current_turn_metadata_bool(keys: &[&str]) -> bool { + crate::session_context::current_turn_context() + .as_ref() + .and_then(|turn_context| { + keys.iter() + .find_map(|key| turn_context.metadata.get(*key)) + .and_then(serde_json::Value::as_bool) + }) + .unwrap_or(false) +} + +fn current_turn_approval_policy_is_never() -> bool { + crate::session_context::current_turn_context() + .as_ref() + .and_then(|turn_context| turn_context.approval_policy.as_deref()) + .map(str::trim) + .is_some_and(|policy| policy.eq_ignore_ascii_case("never")) +} + +fn current_turn_allows_web_tools_without_confirmation() -> bool { + current_turn_metadata_bool(&["web_search_enabled", "webSearchEnabled"]) + || current_turn_approval_policy_is_never() +} + /// 缓存内容结构 #[derive(Debug, Clone)] struct CachedContent { @@ -1241,6 +1265,10 @@ impl Tool for WebFetchTool { params: &serde_json::Value, _context: &ToolContext, ) -> PermissionCheckResult { + if current_turn_allows_web_tools_without_confirmation() { + return PermissionCheckResult::allow(); + } + let parsed_url = serde_json::from_value::(params.clone()) .ok() .and_then(|input| Url::parse(&input.url).ok()); @@ -2180,6 +2208,10 @@ impl Tool for WebSearchTool { _params: &serde_json::Value, _context: &ToolContext, ) -> PermissionCheckResult { + if current_turn_allows_web_tools_without_confirmation() { + return PermissionCheckResult::allow(); + } + PermissionCheckResult::ask("WebSearch 将联网搜索最新信息,请确认后继续。") } @@ -2441,9 +2473,22 @@ pub fn clear_web_caches(cache: &WebCache) { #[cfg(test)] mod tests { use super::*; + use crate::session::TurnContextOverride; use crate::tools::base::PermissionBehavior; use std::collections::HashMap; + fn turn_context_with_metadata( + entries: impl IntoIterator, + ) -> TurnContextOverride { + TurnContextOverride { + metadata: entries + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect(), + ..TurnContextOverride::default() + } + } + #[tokio::test] async fn test_web_fetch_tool_creation() { let tool = WebFetchTool::new(); @@ -2637,6 +2682,32 @@ mod tests { assert!(result.message.is_none()); } + #[tokio::test] + async fn test_web_fetch_permissions_allow_when_turn_web_search_enabled() { + let tool = WebFetchTool::new(); + + let result = crate::session_context::with_turn_context( + Some(turn_context_with_metadata([( + "web_search_enabled", + serde_json::json!(true), + )])), + async { + tool.check_permissions( + &serde_json::json!({ + "url": "https://example.com/docs", + "prompt": "总结内容" + }), + &ToolContext::default(), + ) + .await + }, + ) + .await; + + assert_eq!(result.behavior, PermissionBehavior::Allow); + assert!(result.message.is_none()); + } + #[test] fn test_web_fetch_preapproved_path_prefix_matches_exact_scope() { assert!(is_preapproved_web_fetch_host( @@ -2689,6 +2760,56 @@ mod tests { ); } + #[tokio::test] + async fn test_web_search_permissions_allow_when_turn_web_search_enabled() { + let tool = WebSearchTool::new(); + + let result = crate::session_context::with_turn_context( + Some(turn_context_with_metadata([( + "webSearchEnabled", + serde_json::json!(true), + )])), + async { + tool.check_permissions( + &serde_json::json!({ + "query": "latest ai news" + }), + &ToolContext::default(), + ) + .await + }, + ) + .await; + + assert_eq!(result.behavior, PermissionBehavior::Allow); + assert!(result.message.is_none()); + } + + #[tokio::test] + async fn test_web_search_permissions_allow_when_turn_approval_policy_is_never() { + let tool = WebSearchTool::new(); + + let result = crate::session_context::with_turn_context( + Some(TurnContextOverride { + approval_policy: Some("never".to_string()), + ..TurnContextOverride::default() + }), + async { + tool.check_permissions( + &serde_json::json!({ + "query": "latest ai news" + }), + &ToolContext::default(), + ) + .await + }, + ) + .await; + + assert_eq!(result.behavior, PermissionBehavior::Allow); + assert!(result.message.is_none()); + } + #[tokio::test] async fn test_web_search_rejects_short_query() { let tool = WebSearchTool::new(); diff --git a/src-tauri/crates/core/src/config/types.rs b/src-tauri/crates/core/src/config/types.rs index cb144cd13..7d296852e 100644 --- a/src-tauri/crates/core/src/config/types.rs +++ b/src-tauri/crates/core/src/config/types.rs @@ -722,16 +722,7 @@ pub struct NavigationConfig { } fn default_enabled_nav_items() -> Vec { - vec![ - "home-general".to_string(), - "claw".to_string(), - "video".to_string(), - "image-gen".to_string(), - "automation".to_string(), - "openclaw".to_string(), - "resources".to_string(), - "memory".to_string(), - ] + Vec::new() } impl Default for NavigationConfig { @@ -767,6 +758,28 @@ const LEGACY_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[ &["home-general", "video", "image-gen", "terminal", "plugins"], ]; +const STALE_DEFAULT_NAV_ITEM_SETS: &[&[&str]] = &[ + &[ + "home-general", + "claw", + "video", + "image-gen", + "automation", + "openclaw", + "resources", + "memory", + ], + &[ + "home-general", + "claw", + "video", + "automation", + "openclaw", + "resources", + "memory", + ], +]; + fn has_same_members(items: &[String], expected: &[&str]) -> bool { if items.len() != expected.len() { return false; @@ -776,6 +789,12 @@ fn has_same_members(items: &[String], expected: &[&str]) -> bool { expected.iter().all(|item| item_set.contains(item)) } +fn has_stale_default_nav_items(items: &[String]) -> bool { + STALE_DEFAULT_NAV_ITEM_SETS + .iter() + .any(|stale_items| has_same_members(items, stale_items)) +} + fn should_upgrade_legacy_navigation_defaults(items: &[String]) -> bool { if items.is_empty() { return true; @@ -2201,6 +2220,11 @@ impl Config { changed = true; } + if has_stale_default_nav_items(&self.navigation.enabled_items) { + self.navigation.enabled_items = default_enabled_nav_items(); + changed = true; + } + if self.workspace_preferences.schema_version < current_version { self.workspace_preferences.schema_version = current_version; changed = true; @@ -2748,19 +2772,7 @@ mod unit_tests { assert!(!config.crash_reporting.send_pii); assert_eq!(config.workspace_preferences.schema_version, 1); assert_eq!(config.navigation.schema_version, 1); - assert_eq!( - config.navigation.enabled_items, - vec![ - "home-general".to_string(), - "claw".to_string(), - "video".to_string(), - "image-gen".to_string(), - "automation".to_string(), - "openclaw".to_string(), - "resources".to_string(), - "memory".to_string(), - ] - ); + assert!(config.navigation.enabled_items.is_empty()); assert!(config.agent.tool_execution.tool_overrides.is_empty()); } @@ -2844,19 +2856,7 @@ mod unit_tests { assert!(changed); assert_eq!(config.workspace_preferences.schema_version, 1); assert_eq!(config.navigation.schema_version, 1); - assert_eq!( - config.navigation.enabled_items, - vec![ - "home-general".to_string(), - "claw".to_string(), - "video".to_string(), - "image-gen".to_string(), - "automation".to_string(), - "openclaw".to_string(), - "resources".to_string(), - "memory".to_string(), - ] - ); + assert!(config.navigation.enabled_items.is_empty()); } #[test] @@ -2887,6 +2887,26 @@ mod unit_tests { ); } + #[test] + fn test_normalize_workspace_preferences_clears_stale_sidebar_defaults_on_current_schema() { + let mut config = Config::default(); + config.navigation.enabled_items = vec![ + "home-general".to_string(), + "claw".to_string(), + "video".to_string(), + "image-gen".to_string(), + "automation".to_string(), + "openclaw".to_string(), + "resources".to_string(), + "memory".to_string(), + ]; + + let changed = config.normalize_workspace_preferences(); + + assert!(changed); + assert!(config.navigation.enabled_items.is_empty()); + } + #[test] fn test_normalize_workspace_preferences_removes_api_server_entry_even_on_current_schema() { let mut config = Config::default(); diff --git a/src-tauri/crates/terminal/Cargo.toml b/src-tauri/crates/terminal/Cargo.toml deleted file mode 100644 index b142f1757..000000000 --- a/src-tauri/crates/terminal/Cargo.toml +++ /dev/null @@ -1,51 +0,0 @@ -[package] -name = "lime-terminal" -version.workspace = true -edition.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true - -[dependencies] -# 项目内 crate -lime-core = { path = "../core" } - -# 序列化 -serde.workspace = true -serde_json.workspace = true - -# 异步运行时 -tokio.workspace = true -async-trait.workspace = true - -# 错误处理 -thiserror.workspace = true - -# 日志 -tracing.workspace = true - -# 数据库 -rusqlite.workspace = true - -# 时间和 UUID -chrono.workspace = true -uuid.workspace = true - -# 工具库 -dirs.workspace = true -parking_lot.workspace = true -base64.workspace = true -sha2.workspace = true -dashmap.workspace = true -whoami.workspace = true -scopeguard.workspace = true - -# 终端 -portable-pty.workspace = true - -# SSH -ssh2.workspace = true - -[dev-dependencies] -proptest.workspace = true -tempfile.workspace = true diff --git a/src-tauri/crates/terminal/README.md b/src-tauri/crates/terminal/README.md deleted file mode 100644 index 2e697eeb7..000000000 --- a/src-tauri/crates/terminal/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# lime-terminal - - - -## 架构说明 - -终端核心模块独立 crate,采用**后端预创建 PTY**架构(参考 WaveTerm)。 -通过 `TerminalEventEmit` trait 抽象事件发射,不直接依赖 Tauri。 - -**核心原则:** -- 后端是会话的唯一真相来源 -- PTY 使用默认大小 (24x80) 预创建 -- 前端连接后通过 resize 同步实际大小 -- 统一的 BlockController 抽象层支持多种连接类型 -- 通过 trait 抽象与 Tauri 解耦 - -## Trait 设计(两层抽象) - -- `TerminalEventEmit`:基础 trait(dyn 兼容,不要求 Clone) -- `TerminalEventEmitter`:扩展 trait = `TerminalEventEmit + Clone`(blanket impl) -- `DynEmitter`:`Arc` newtype,自动获得 `TerminalEventEmitter` -- `NoOpEmitter`:空实现,用于测试 - -## 文件索引 - -- `src/lib.rs` - 模块声明和类型重导出 -- `src/emitter.rs` - 事件发射器 trait 定义 -- `src/emit_helper.rs` - 事件发射辅助函数 -- `src/error.rs` - 错误类型定义 -- `src/events.rs` - 事件定义 -- `src/pty_session.rs` - PTY 会话封装 -- `src/session_manager.rs` - 会话管理器 -- `src/tests.rs` - 单元测试(187 个) -- `src/block_controller/` - 块控制器模块 -- `src/connections/` - 连接模块(本地 PTY、SSH、WSL) -- `src/integration/` - 集成模块(OSC 解析、Shell 集成、状态重同步) -- `src/persistence/` - 持久化存储模块 - -## 更新提醒 - -任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/crates/terminal/src/block_controller/mod.rs b/src-tauri/crates/terminal/src/block_controller/mod.rs deleted file mode 100644 index 4f2ce110d..000000000 --- a/src-tauri/crates/terminal/src/block_controller/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! 块控制器模块 -//! -//! 提供统一的控制器抽象层,支持不同类型的终端连接(本地 Shell、SSH、WSL、命令执行)。 -//! -//! ## 模块结构 -//! - `traits` - BlockController trait 定义 -//! - `registry` - 控制器注册表 -//! - `shell_controller` - Shell/Cmd 控制器实现 -//! -//! ## 功能 -//! - 定义统一的 BlockController trait 接口 -//! - 管理控制器生命周期(start、stop、send_input) -//! - 提供控制器注册表,支持按 block_id 查找 - -mod registry; -mod shell_controller; -mod traits; - -pub use registry::ControllerRegistry; -pub use shell_controller::{ControllerStatusEvent, ShellController, CONTROLLER_STATUS_EVENT}; -pub use traits::{ - BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts, - TermSize, -}; diff --git a/src-tauri/crates/terminal/src/block_controller/registry.rs b/src-tauri/crates/terminal/src/block_controller/registry.rs deleted file mode 100644 index a4d6ff656..000000000 --- a/src-tauri/crates/terminal/src/block_controller/registry.rs +++ /dev/null @@ -1,311 +0,0 @@ -//! 控制器注册表 -//! -//! 管理所有块控制器的注册和查找。 -//! -//! ## 功能 -//! - 按 block_id 注册控制器 -//! - 按 block_id 查找控制器 -//! - 删除控制器 -//! - 列出所有控制器 -//! -//! ## Requirements -//! - 1.6: 维护控制器注册表,支持按 block_id 查找控制器 - -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; - -use super::traits::BlockController; - -/// 控制器注册表 -/// -/// 使用 HashMap + RwLock 实现线程安全的控制器管理。 -pub struct ControllerRegistry { - /// 控制器映射表: block_id -> BlockController - #[allow(clippy::type_complexity)] - controllers: RwLock>>>>, -} - -impl Default for ControllerRegistry { - fn default() -> Self { - Self::new() - } -} - -impl ControllerRegistry { - /// 创建新的控制器注册表 - pub fn new() -> Self { - Self { - controllers: RwLock::new(HashMap::new()), - } - } - - /// 注册控制器 - /// - /// # 参数 - /// - `block_id`: 块 ID - /// - `controller`: 控制器实例 - /// - /// # 返回 - /// 如果已存在同 block_id 的控制器,返回旧控制器 - pub async fn register( - &self, - block_id: String, - controller: Box, - ) -> Option>>> { - let mut controllers = self.controllers.write().await; - controllers.insert(block_id, Arc::new(RwLock::new(controller))) - } - - /// 获取控制器 - /// - /// # 参数 - /// - `block_id`: 块 ID - /// - /// # 返回 - /// 如果存在返回控制器的 Arc 引用,否则返回 None - pub async fn get(&self, block_id: &str) -> Option>>> { - let controllers = self.controllers.read().await; - controllers.get(block_id).cloned() - } - - /// 删除控制器 - /// - /// # 参数 - /// - `block_id`: 块 ID - /// - /// # 返回 - /// 如果存在返回被删除的控制器,否则返回 None - pub async fn remove(&self, block_id: &str) -> Option>>> { - let mut controllers = self.controllers.write().await; - controllers.remove(block_id) - } - - /// 检查控制器是否存在 - /// - /// # 参数 - /// - `block_id`: 块 ID - /// - /// # 返回 - /// 存在返回 true,否则返回 false - pub async fn contains(&self, block_id: &str) -> bool { - let controllers = self.controllers.read().await; - controllers.contains_key(block_id) - } - - /// 获取所有块 ID - /// - /// # 返回 - /// 所有已注册的块 ID 列表 - pub async fn list_block_ids(&self) -> Vec { - let controllers = self.controllers.read().await; - controllers.keys().cloned().collect() - } - - /// 获取控制器数量 - /// - /// # 返回 - /// 已注册的控制器数量 - pub async fn len(&self) -> usize { - let controllers = self.controllers.read().await; - controllers.len() - } - - /// 检查注册表是否为空 - /// - /// # 返回 - /// 为空返回 true,否则返回 false - pub async fn is_empty(&self) -> bool { - let controllers = self.controllers.read().await; - controllers.is_empty() - } - - /// 清空所有控制器 - pub async fn clear(&self) { - let mut controllers = self.controllers.write().await; - controllers.clear(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::block_controller::traits::{ - BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts, - }; - use crate::error::TerminalError; - use async_trait::async_trait; - - /// 测试用的 Mock 控制器 - struct MockController { - block_id: String, - controller_type: String, - } - - impl MockController { - fn new(block_id: &str, controller_type: &str) -> Self { - Self { - block_id: block_id.to_string(), - controller_type: controller_type.to_string(), - } - } - } - - #[async_trait] - impl BlockController for MockController { - async fn start( - &mut self, - _block_meta: BlockMeta, - _rt_opts: Option, - _force: bool, - ) -> Result<(), TerminalError> { - Ok(()) - } - - async fn stop( - &mut self, - _graceful: bool, - _new_status: String, - ) -> Result<(), TerminalError> { - Ok(()) - } - - fn get_runtime_status(&self) -> BlockControllerRuntimeStatus { - BlockControllerRuntimeStatus::new(self.block_id.clone()) - } - - async fn send_input(&self, _input: &BlockInputUnion) -> Result<(), TerminalError> { - Ok(()) - } - - fn controller_type(&self) -> &str { - &self.controller_type - } - } - - #[tokio::test] - async fn test_registry_register_and_get() { - let registry = ControllerRegistry::new(); - let controller = MockController::new("block-1", "shell"); - - // 注册控制器 - let old = registry - .register("block-1".to_string(), Box::new(controller)) - .await; - assert!(old.is_none()); - - // 获取控制器 - let ctrl = registry.get("block-1").await; - assert!(ctrl.is_some()); - - // 验证控制器类型 - let ctrl = ctrl.unwrap(); - let ctrl_guard = ctrl.read().await; - assert_eq!(ctrl_guard.controller_type(), "shell"); - } - - #[tokio::test] - async fn test_registry_remove() { - let registry = ControllerRegistry::new(); - let controller = MockController::new("block-2", "cmd"); - - registry - .register("block-2".to_string(), Box::new(controller)) - .await; - assert!(registry.contains("block-2").await); - - // 删除控制器 - let removed = registry.remove("block-2").await; - assert!(removed.is_some()); - assert!(!registry.contains("block-2").await); - - // 再次删除应返回 None - let removed_again = registry.remove("block-2").await; - assert!(removed_again.is_none()); - } - - #[tokio::test] - async fn test_registry_list_and_len() { - let registry = ControllerRegistry::new(); - - assert!(registry.is_empty().await); - assert_eq!(registry.len().await, 0); - - registry - .register( - "block-a".to_string(), - Box::new(MockController::new("block-a", "shell")), - ) - .await; - registry - .register( - "block-b".to_string(), - Box::new(MockController::new("block-b", "cmd")), - ) - .await; - - assert!(!registry.is_empty().await); - assert_eq!(registry.len().await, 2); - - let ids = registry.list_block_ids().await; - assert!(ids.contains(&"block-a".to_string())); - assert!(ids.contains(&"block-b".to_string())); - } - - #[tokio::test] - async fn test_registry_clear() { - let registry = ControllerRegistry::new(); - - registry - .register( - "block-x".to_string(), - Box::new(MockController::new("block-x", "shell")), - ) - .await; - registry - .register( - "block-y".to_string(), - Box::new(MockController::new("block-y", "shell")), - ) - .await; - - assert_eq!(registry.len().await, 2); - - registry.clear().await; - - assert!(registry.is_empty().await); - assert_eq!(registry.len().await, 0); - } - - #[tokio::test] - async fn test_registry_replace_controller() { - let registry = ControllerRegistry::new(); - - // 注册第一个控制器 - let ctrl1 = MockController::new("block-z", "shell"); - registry - .register("block-z".to_string(), Box::new(ctrl1)) - .await; - - // 验证类型 - { - let ctrl = registry.get("block-z").await.unwrap(); - let guard = ctrl.read().await; - assert_eq!(guard.controller_type(), "shell"); - } - - // 替换为新控制器 - let ctrl2 = MockController::new("block-z", "cmd"); - let old = registry - .register("block-z".to_string(), Box::new(ctrl2)) - .await; - assert!(old.is_some()); - - // 验证新类型 - { - let ctrl = registry.get("block-z").await.unwrap(); - let guard = ctrl.read().await; - assert_eq!(guard.controller_type(), "cmd"); - } - } -} diff --git a/src-tauri/crates/terminal/src/block_controller/shell_controller.rs b/src-tauri/crates/terminal/src/block_controller/shell_controller.rs deleted file mode 100644 index 66fcb1715..000000000 --- a/src-tauri/crates/terminal/src/block_controller/shell_controller.rs +++ /dev/null @@ -1,615 +0,0 @@ -//! ShellController 实现 -//! -//! 实现 BlockController trait,管理本地和远程 Shell 进程。 -//! -//! ## 功能 -//! - 实现 BlockController trait 接口 -//! - 管理 Shell 进程生命周期(init、running、done) -//! - 支持 "shell" 和 "cmd" 两种控制器类型 -//! - 状态更新事件广播 -//! -//! ## Requirements -//! - 1.2: 创建本地终端时实例化 Shell_Controller 并设置 controller_type 为 "shell" -//! - 1.3: 创建命令执行终端时实例化 Shell_Controller 并设置 controller_type 为 "cmd" -//! - 2.7: 会话状态变更时通过事件广播状态更新到所有订阅者 - -use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; -use std::sync::Arc; - -use async_trait::async_trait; -use tokio::sync::{mpsc, RwLock}; - -use super::traits::{ - BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, RuntimeOpts, -}; -use crate::connections::ShellProc; -use crate::emit_helper; -use crate::emitter::TerminalEventEmitter; -use crate::error::TerminalError; -use crate::persistence::BlockFile; - -/// 控制器状态事件名称 -pub const CONTROLLER_STATUS_EVENT: &str = "controller:status"; - -/// 控制器状态事件 -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct ControllerStatusEvent { - /// 块 ID - pub block_id: String, - /// 状态版本号 - pub version: i32, - /// Shell 进程状态 - pub shell_proc_status: String, - /// 连接名称 - pub shell_proc_conn_name: Option, - /// 退出码 - pub shell_proc_exit_code: i32, -} - -impl From for ControllerStatusEvent { - fn from(status: BlockControllerRuntimeStatus) -> Self { - Self { - block_id: status.block_id, - version: status.version, - shell_proc_status: status.shell_proc_status, - shell_proc_conn_name: status.shell_proc_conn_name, - shell_proc_exit_code: status.shell_proc_exit_code, - } - } -} - -/// Shell 控制器 -/// -/// 实现 BlockController trait,管理本地和远程 Shell 进程。 -/// 支持 "shell"(交互式 Shell)和 "cmd"(命令执行)两种模式。 -/// -/// ## Cmd 模式配置选项 -/// - `cmd_run_on_start`: 启动时自动运行命令 -/// - `cmd_run_once`: 仅运行一次(不自动重启) -/// - `cmd_clear_on_start`: 启动前清空输出 -/// - `cmd_close_on_exit`: 退出后自动关闭 -pub struct ShellController { - /// 控制器类型: "shell" | "cmd" - controller_type: String, - /// Tab ID - tab_id: String, - /// Block ID - block_id: String, - /// 运行锁,防止并发启动 - run_lock: AtomicBool, - /// 进程状态: "init" | "running" | "done" - proc_status: RwLock, - /// 进程退出码 - proc_exit_code: AtomicI32, - /// 状态版本号,每次状态变更递增 - status_version: AtomicI32, - /// 连接名称(用于 SSH/WSL) - conn_name: RwLock>, - /// Shell 进程 - shell_proc: RwLock>>, - /// Shell 输入发送器 - shell_input_tx: RwLock>>, - /// 事件发射器 - app_handle: E, - /// 块文件存储 - block_file: Option>, - /// 是否已运行过(用于 cmd:runonce) - has_run: AtomicBool, - /// 当前块元数据(用于重启) - current_meta: RwLock>, -} - -impl ShellController { - /// 创建新的 ShellController - /// - /// # 参数 - /// - `tab_id`: Tab ID - /// - `block_id`: Block ID - /// - `controller_type`: 控制器类型 ("shell" | "cmd") - /// - `app_handle`: 事件发射器 - /// - /// # 返回 - /// 新的 ShellController 实例 - /// - /// _Requirements: 1.2, 1.3_ - pub fn new(tab_id: String, block_id: String, controller_type: String, app_handle: E) -> Self { - tracing::info!( - "[ShellController] 创建控制器: block_id={}, type={}", - block_id, - controller_type - ); - - Self { - controller_type, - tab_id, - block_id, - run_lock: AtomicBool::new(false), - proc_status: RwLock::new("init".to_string()), - proc_exit_code: AtomicI32::new(0), - status_version: AtomicI32::new(0), - conn_name: RwLock::new(None), - shell_proc: RwLock::new(None), - shell_input_tx: RwLock::new(None), - app_handle, - block_file: None, - has_run: AtomicBool::new(false), - current_meta: RwLock::new(None), - } - } - - /// 创建带块文件的 ShellController - /// - /// # 参数 - /// - `tab_id`: Tab ID - /// - `block_id`: Block ID - /// - `controller_type`: 控制器类型 - /// - `app_handle`: 事件发射器 - /// - `block_file`: 块文件存储 - pub fn with_block_file( - tab_id: String, - block_id: String, - controller_type: String, - app_handle: E, - block_file: Arc, - ) -> Self { - let mut controller = Self::new(tab_id, block_id, controller_type, app_handle); - controller.block_file = Some(block_file); - controller - } - - /// 获取 Tab ID - pub fn tab_id(&self) -> &str { - &self.tab_id - } - - /// 获取 Block ID - pub fn block_id(&self) -> &str { - &self.block_id - } - - /// 设置块文件 - pub fn set_block_file(&mut self, block_file: Arc) { - self.block_file = Some(block_file); - } - - /// 获取块文件引用 - pub fn block_file(&self) -> Option<&Arc> { - self.block_file.as_ref() - } - - /// 更新进程状态 - /// - /// 更新状态并递增版本号,然后广播状态更新事件。 - /// - /// # 参数 - /// - `new_status`: 新状态 ("init" | "running" | "done") - /// - /// _Requirements: 2.7_ - async fn set_status(&self, new_status: &str) { - { - let mut status = self.proc_status.write().await; - *status = new_status.to_string(); - } - self.status_version.fetch_add(1, Ordering::SeqCst); - self.send_status_update().await; - } - - /// 设置退出码 - /// - /// # 参数 - /// - `exit_code`: 进程退出码 - fn set_exit_code(&self, exit_code: i32) { - self.proc_exit_code.store(exit_code, Ordering::SeqCst); - } - - /// 设置连接名称 - /// - /// # 参数 - /// - `conn_name`: 连接名称 - async fn set_conn_name(&self, conn_name: Option) { - let mut name = self.conn_name.write().await; - *name = conn_name; - } - - /// 发送状态更新事件 - /// - /// 通过 Tauri 事件系统广播控制器状态更新。 - /// - /// _Requirements: 2.7_ - async fn send_status_update(&self) { - let status = self.get_runtime_status(); - let event = ControllerStatusEvent::from(status); - - if let Err(e) = emit_helper::emit(&self.app_handle, CONTROLLER_STATUS_EVENT, &event) { - tracing::error!( - "[ShellController] 发送状态更新事件失败: block_id={}, error={}", - self.block_id, - e - ); - } else { - tracing::debug!( - "[ShellController] 状态更新: block_id={}, status={}, version={}", - self.block_id, - event.shell_proc_status, - event.version - ); - } - } - - /// 尝试获取运行锁 - /// - /// # 返回 - /// 成功获取返回 true,已被占用返回 false - fn try_acquire_run_lock(&self) -> bool { - self.run_lock - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - } - - /// 释放运行锁 - fn release_run_lock(&self) { - self.run_lock.store(false, Ordering::SeqCst); - } - - /// 检查是否正在运行 - pub async fn is_running(&self) -> bool { - let status = self.proc_status.read().await; - *status == "running" - } - - /// 检查是否已完成 - pub async fn is_done(&self) -> bool { - let status = self.proc_status.read().await; - *status == "done" - } - - /// 检查是否为初始状态 - pub async fn is_init(&self) -> bool { - let status = self.proc_status.read().await; - *status == "init" - } - - /// 检查是否应该运行(考虑 cmd:runonce) - /// - /// _Requirements: 16.6_ - fn should_run(&self, block_meta: &BlockMeta) -> bool { - // 如果是 cmd 模式且设置了 runonce,检查是否已运行过 - if self.controller_type == "cmd" - && block_meta.cmd_run_once.unwrap_or(false) - && self.has_run.load(Ordering::SeqCst) - { - tracing::debug!( - "[ShellController] cmd:runonce 已运行过,跳过: block_id={}", - self.block_id - ); - return false; - } - true - } - - /// 处理 cmd:clearonstart 配置 - /// - /// _Requirements: 16.7_ - async fn handle_clear_on_start(&self, block_meta: &BlockMeta) { - if self.controller_type == "cmd" && block_meta.cmd_clear_on_start.unwrap_or(false) { - if let Some(ref bf) = self.block_file { - if let Err(e) = bf.truncate() { - tracing::warn!( - "[ShellController] cmd:clearonstart 清空块文件失败: block_id={}, error={}", - self.block_id, - e - ); - } else { - tracing::debug!( - "[ShellController] cmd:clearonstart 已清空块文件: block_id={}", - self.block_id - ); - } - } - } - } - - /// 检查是否应该自动运行(cmd:runonstart) - /// - /// _Requirements: 16.5_ - fn should_auto_run(&self, block_meta: &BlockMeta) -> bool { - if self.controller_type == "cmd" { - // 默认 cmd 模式自动运行,除非明确设置为 false - block_meta.cmd_run_on_start.unwrap_or(true) - } else { - // shell 模式总是自动运行 - true - } - } - - /// 获取当前块元数据 - pub async fn get_current_meta(&self) -> Option { - self.current_meta.read().await.clone() - } - - /// 重启控制器(用于 cmd 模式重新运行) - /// - /// _Requirements: 16.9_ - pub async fn restart(&mut self) -> Result<(), TerminalError> { - let meta = self.current_meta.read().await.clone(); - if let Some(block_meta) = meta { - // 重置 has_run 标志以允许重新运行 - self.has_run.store(false, Ordering::SeqCst); - self.start(block_meta, None, true).await - } else { - Err(TerminalError::Internal("没有保存的块元数据".to_string())) - } - } -} - -#[async_trait] -impl BlockController for ShellController { - /// 启动控制器 - /// - /// 根据 block_meta 配置启动 Shell 或命令执行进程。 - /// - /// # 参数 - /// - `block_meta`: 块元数据配置 - /// - `rt_opts`: 运行时选项(终端大小等) - /// - `force`: 是否强制重启 - /// - /// # 返回 - /// 成功返回 Ok(()), 失败返回错误 - /// - /// _Requirements: 1.2, 1.3, 16.5, 16.6, 16.7_ - async fn start( - &mut self, - block_meta: BlockMeta, - rt_opts: Option, - force: bool, - ) -> Result<(), TerminalError> { - // 尝试获取运行锁 - if !self.try_acquire_run_lock() { - tracing::warn!( - "[ShellController] 控制器已在运行中: block_id={}", - self.block_id - ); - return Ok(()); - } - - // 确保在函数退出时释放锁 - let _lock_guard = scopeguard::guard((), |_| { - self.release_run_lock(); - }); - - // 检查当前状态 - let current_status = self.proc_status.read().await.clone(); - if current_status == "running" && !force { - tracing::debug!( - "[ShellController] 控制器已在运行: block_id={}", - self.block_id - ); - return Ok(()); - } - - // 检查是否应该运行(cmd:runonce) - if !self.should_run(&block_meta) { - return Ok(()); - } - - // 检查是否应该自动运行(cmd:runonstart) - if !self.should_auto_run(&block_meta) && !force { - tracing::debug!( - "[ShellController] cmd:runonstart=false,跳过自动启动: block_id={}", - self.block_id - ); - return Ok(()); - } - - // 如果强制重启,先停止现有进程 - if force && current_status == "running" { - tracing::info!( - "[ShellController] 强制重启控制器: block_id={}", - self.block_id - ); - // 停止现有进程 - let mut shell_proc = self.shell_proc.write().await; - if let Some(proc) = shell_proc.take() { - proc.kill().await; - } - } - - // 处理 cmd:clearonstart - self.handle_clear_on_start(&block_meta).await; - - // 保存块元数据 - { - let mut meta = self.current_meta.write().await; - *meta = Some(block_meta.clone()); - } - - // 更新连接名称 - let conn_name = block_meta.connection.clone(); - self.set_conn_name(conn_name.clone()).await; - - // 获取终端大小 - let term_size = rt_opts - .as_ref() - .map(|opts| opts.term_size) - .unwrap_or_default(); - - tracing::info!( - "[ShellController] 启动控制器: block_id={}, type={}, conn={:?}, size={}x{}", - self.block_id, - self.controller_type, - conn_name, - term_size.cols, - term_size.rows - ); - - // 创建输入通道 - let (input_tx, input_rx) = mpsc::channel::(256); - { - let mut tx = self.shell_input_tx.write().await; - *tx = Some(input_tx); - } - - // 创建 Shell 进程 - let shell_proc = ShellProc::new( - self.block_id.clone(), - self.controller_type.clone(), - term_size.rows, - term_size.cols, - self.app_handle.clone(), - block_meta.clone(), - input_rx, - self.block_file.clone(), - ) - .await?; - - // 保存进程引用 - { - let mut proc = self.shell_proc.write().await; - *proc = Some(shell_proc); - } - - // 标记已运行(用于 cmd:runonce) - self.has_run.store(true, Ordering::SeqCst); - - // 更新状态为运行中 - self.set_status("running").await; - self.set_exit_code(0); - - tracing::info!("[ShellController] 控制器已启动: block_id={}", self.block_id); - - Ok(()) - } - - /// 停止控制器 - /// - /// 停止 Shell 进程并更新状态。 - /// - /// # 参数 - /// - `graceful`: 是否优雅停止(发送 SIGTERM 而非 SIGKILL) - /// - `new_status`: 停止后的新状态 - /// - /// # 返回 - /// 成功返回 Ok(()), 失败返回错误 - async fn stop(&mut self, graceful: bool, new_status: String) -> Result<(), TerminalError> { - tracing::info!( - "[ShellController] 停止控制器: block_id={}, graceful={}, new_status={}", - self.block_id, - graceful, - new_status - ); - - // 关闭输入通道 - { - let mut tx = self.shell_input_tx.write().await; - *tx = None; - } - - // 停止 Shell 进程 - let exit_code = { - let mut shell_proc = self.shell_proc.write().await; - if let Some(proc) = shell_proc.take() { - if graceful { - proc.terminate().await - } else { - proc.kill().await; - -1 - } - } else { - 0 - } - }; - - // 更新退出码和状态 - self.set_exit_code(exit_code); - self.set_status(&new_status).await; - - tracing::info!( - "[ShellController] 控制器已停止: block_id={}, exit_code={}", - self.block_id, - exit_code - ); - - Ok(()) - } - - /// 获取运行时状态 - /// - /// # 返回 - /// 当前控制器的运行时状态 - fn get_runtime_status(&self) -> BlockControllerRuntimeStatus { - // 使用 try_read 避免死锁,如果无法获取锁则返回默认值 - let status = self - .proc_status - .try_read() - .map(|s| s.clone()) - .unwrap_or_else(|_| "init".to_string()); - - let conn_name = self.conn_name.try_read().map(|n| n.clone()).unwrap_or(None); - - BlockControllerRuntimeStatus { - block_id: self.block_id.clone(), - version: self.status_version.load(Ordering::SeqCst), - shell_proc_status: status, - shell_proc_conn_name: conn_name, - shell_proc_exit_code: self.proc_exit_code.load(Ordering::SeqCst), - } - } - - /// 发送输入到控制器 - /// - /// 将输入数据发送到 Shell 进程。 - /// - /// # 参数 - /// - `input`: 输入数据(键盘输入、信号、终端大小调整) - /// - /// # 返回 - /// 成功返回 Ok(()), 失败返回错误 - async fn send_input(&self, input: &BlockInputUnion) -> Result<(), TerminalError> { - let tx = self.shell_input_tx.read().await; - if let Some(sender) = tx.as_ref() { - sender - .send(input.clone()) - .await - .map_err(|e| TerminalError::WriteFailed(format!("发送输入失败: {e}")))?; - Ok(()) - } else { - Err(TerminalError::SessionClosed) - } - } - - /// 获取控制器类型 - /// - /// # 返回 - /// 控制器类型字符串: "shell" | "cmd" - fn controller_type(&self) -> &str { - &self.controller_type - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // 注意:完整的测试需要 Tauri 运行时环境,这里只测试基本逻辑 - - #[test] - fn test_controller_status_event_from() { - let status = BlockControllerRuntimeStatus { - block_id: "test-block".to_string(), - version: 5, - shell_proc_status: "running".to_string(), - shell_proc_conn_name: Some("ssh://user@host".to_string()), - shell_proc_exit_code: 0, - }; - - let event = ControllerStatusEvent::from(status); - assert_eq!(event.block_id, "test-block"); - assert_eq!(event.version, 5); - assert_eq!(event.shell_proc_status, "running"); - assert_eq!( - event.shell_proc_conn_name, - Some("ssh://user@host".to_string()) - ); - assert_eq!(event.shell_proc_exit_code, 0); - } -} diff --git a/src-tauri/crates/terminal/src/block_controller/traits.rs b/src-tauri/crates/terminal/src/block_controller/traits.rs deleted file mode 100644 index 547c0940a..000000000 --- a/src-tauri/crates/terminal/src/block_controller/traits.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! BlockController trait 定义 -//! -//! 定义统一的块控制器接口,所有控制器类型(Shell、Cmd、SSH、WSL)都必须实现此 trait。 -//! -//! ## 功能 -//! - 定义 BlockController trait 接口 -//! - 定义 BlockControllerRuntimeStatus 运行时状态结构 -//! - 定义 BlockInputUnion 输入联合类型 -//! -//! ## Requirements -//! - 1.1: 定义统一的 trait 接口 -//! - 1.8: 提供 get_runtime_status 方法 - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use crate::error::TerminalError; - -/// 终端大小 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub struct TermSize { - /// 行数 - pub rows: u16, - /// 列数 - pub cols: u16, -} - -impl Default for TermSize { - fn default() -> Self { - Self { rows: 24, cols: 80 } - } -} - -/// 块控制器运行时状态 -/// -/// 包含控制器的当前状态信息,用于前端显示和状态同步。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BlockControllerRuntimeStatus { - /// 块 ID - pub block_id: String, - /// 状态版本号,每次状态变更递增 - pub version: i32, - /// Shell 进程状态: "init" | "running" | "done" - pub shell_proc_status: String, - /// Shell 进程连接名称(用于 SSH/WSL) - pub shell_proc_conn_name: Option, - /// Shell 进程退出码 - pub shell_proc_exit_code: i32, -} - -impl BlockControllerRuntimeStatus { - /// 创建初始状态 - pub fn new(block_id: String) -> Self { - Self { - block_id, - version: 0, - shell_proc_status: "init".to_string(), - shell_proc_conn_name: None, - shell_proc_exit_code: 0, - } - } - - /// 检查是否为初始状态 - pub fn is_init(&self) -> bool { - self.shell_proc_status == "init" - } - - /// 检查是否正在运行 - pub fn is_running(&self) -> bool { - self.shell_proc_status == "running" - } - - /// 检查是否已完成 - pub fn is_done(&self) -> bool { - self.shell_proc_status == "done" - } -} - -/// 块控制器输入联合类型 -/// -/// 封装发送给控制器的各种输入类型。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BlockInputUnion { - /// 输入数据(键盘输入等) - pub input_data: Option>, - /// 信号名称(如 "SIGINT", "SIGTERM") - pub sig_name: Option, - /// 终端大小调整 - pub term_size: Option, -} - -impl BlockInputUnion { - /// 创建数据输入 - pub fn data(data: Vec) -> Self { - Self { - input_data: Some(data), - sig_name: None, - term_size: None, - } - } - - /// 创建信号输入 - pub fn signal(sig_name: &str) -> Self { - Self { - input_data: None, - sig_name: Some(sig_name.to_string()), - term_size: None, - } - } - - /// 创建终端大小调整输入 - pub fn resize(rows: u16, cols: u16) -> Self { - Self { - input_data: None, - sig_name: None, - term_size: Some(TermSize { rows, cols }), - } - } -} - -/// 块元数据 -/// -/// 存储块的配置信息。 -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct BlockMeta { - /// 控制器类型: "shell" | "cmd" - pub controller: Option, - /// 连接名称(用于 SSH/WSL) - pub connection: Option, - /// 命令字符串 - pub cmd: Option, - /// 命令参数 - pub cmd_args: Option>, - /// 工作目录 - pub cmd_cwd: Option, - /// 环境变量 - pub cmd_env: Option>, - /// 启动时自动运行 - pub cmd_run_on_start: Option, - /// 仅运行一次 - pub cmd_run_once: Option, - /// 启动前清空输出 - pub cmd_clear_on_start: Option, - /// 退出后自动关闭 - pub cmd_close_on_exit: Option, - /// 终端模式: "term" | "vdom" - pub term_mode: Option, - /// 终端主题 - pub term_theme: Option, - /// 终端字体大小 - pub term_font_size: Option, - /// 终端滚动缓冲区大小 - pub term_scrollback: Option, -} - -impl BlockMeta { - /// 获取字符串字段 - pub fn get_string(&self, key: &str) -> String { - match key { - "controller" => self.controller.clone().unwrap_or_default(), - "connection" => self.connection.clone().unwrap_or_default(), - "cmd" => self.cmd.clone().unwrap_or_default(), - "cmd_cwd" => self.cmd_cwd.clone().unwrap_or_default(), - "term_mode" => self.term_mode.clone().unwrap_or_else(|| "term".to_string()), - "term_theme" => self.term_theme.clone().unwrap_or_default(), - _ => String::new(), - } - } - - /// 获取清洗后的工作目录(去除 `\0`、首尾空白) - pub fn sanitized_cmd_cwd(&self) -> Option { - let cwd = self.cmd_cwd.as_deref()?; - let cleaned = cwd.split('\0').next().unwrap_or_default().trim(); - (!cleaned.is_empty()).then_some(cleaned.to_string()) - } -} - -/// 运行时选项 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct RuntimeOpts { - /// 终端大小 - pub term_size: TermSize, -} - -/// 块控制器 trait -/// -/// 所有控制器类型(Shell、Cmd、SSH、WSL)都必须实现此 trait。 -/// 提供统一的接口用于管理终端会话的生命周期。 -#[async_trait] -pub trait BlockController: Send + Sync { - /// 启动控制器 - /// - /// # 参数 - /// - `block_meta`: 块元数据配置 - /// - `rt_opts`: 运行时选项(终端大小等) - /// - `force`: 是否强制重启 - /// - /// # 返回 - /// 成功返回 Ok(()), 失败返回错误 - async fn start( - &mut self, - block_meta: BlockMeta, - rt_opts: Option, - force: bool, - ) -> Result<(), TerminalError>; - - /// 停止控制器 - /// - /// # 参数 - /// - `graceful`: 是否优雅停止(发送 SIGTERM 而非 SIGKILL) - /// - `new_status`: 停止后的新状态 - /// - /// # 返回 - /// 成功返回 Ok(()), 失败返回错误 - async fn stop(&mut self, graceful: bool, new_status: String) -> Result<(), TerminalError>; - - /// 获取运行时状态 - /// - /// # 返回 - /// 当前控制器的运行时状态 - fn get_runtime_status(&self) -> BlockControllerRuntimeStatus; - - /// 发送输入到控制器 - /// - /// # 参数 - /// - `input`: 输入数据(键盘输入、信号、终端大小调整) - /// - /// # 返回 - /// 成功返回 Ok(()), 失败返回错误 - async fn send_input(&self, input: &BlockInputUnion) -> Result<(), TerminalError>; - - /// 获取控制器类型 - /// - /// # 返回 - /// 控制器类型字符串: "shell" | "cmd" - fn controller_type(&self) -> &str; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_term_size_default() { - let size = TermSize::default(); - assert_eq!(size.rows, 24); - assert_eq!(size.cols, 80); - } - - #[test] - fn test_runtime_status_new() { - let status = BlockControllerRuntimeStatus::new("test-block".to_string()); - assert_eq!(status.block_id, "test-block"); - assert_eq!(status.version, 0); - assert_eq!(status.shell_proc_status, "init"); - assert!(status.is_init()); - assert!(!status.is_running()); - assert!(!status.is_done()); - } - - #[test] - fn test_block_input_union_data() { - let input = BlockInputUnion::data(vec![0x1b, 0x5b, 0x41]); // ESC [ A - assert!(input.input_data.is_some()); - assert!(input.sig_name.is_none()); - assert!(input.term_size.is_none()); - } - - #[test] - fn test_block_input_union_signal() { - let input = BlockInputUnion::signal("SIGINT"); - assert!(input.input_data.is_none()); - assert_eq!(input.sig_name, Some("SIGINT".to_string())); - assert!(input.term_size.is_none()); - } - - #[test] - fn test_block_input_union_resize() { - let input = BlockInputUnion::resize(30, 100); - assert!(input.input_data.is_none()); - assert!(input.sig_name.is_none()); - assert_eq!( - input.term_size, - Some(TermSize { - rows: 30, - cols: 100 - }) - ); - } - - #[test] - fn test_block_meta_get_string() { - let meta = BlockMeta { - controller: Some("shell".to_string()), - connection: Some("ssh://user@host".to_string()), - ..Default::default() - }; - assert_eq!(meta.get_string("controller"), "shell"); - assert_eq!(meta.get_string("connection"), "ssh://user@host"); - assert_eq!(meta.get_string("cmd"), ""); - assert_eq!(meta.get_string("term_mode"), "term"); - } - - #[test] - fn test_block_meta_sanitized_cmd_cwd() { - let meta = BlockMeta { - cmd_cwd: Some(" /tmp/demo\0ignored ".to_string()), - ..Default::default() - }; - assert_eq!(meta.sanitized_cmd_cwd(), Some("/tmp/demo".to_string())); - - let empty = BlockMeta { - cmd_cwd: Some(" \0 ".to_string()), - ..Default::default() - }; - assert!(empty.sanitized_cmd_cwd().is_none()); - } -} diff --git a/src-tauri/crates/terminal/src/connections/connection_config.rs b/src-tauri/crates/terminal/src/connections/connection_config.rs deleted file mode 100644 index 13d5a5bd9..000000000 --- a/src-tauri/crates/terminal/src/connections/connection_config.rs +++ /dev/null @@ -1,583 +0,0 @@ -//! 连接配置管理 -//! -//! 管理用户保存的连接配置,支持本地和 SSH 连接。 -//! 配置存储在 `~/.config/lime/connections.json`。 -//! -//! ## 功能 -//! - 加载/保存连接配置文件 -//! - 读取系统 SSH 配置 (~/.ssh/config) 中的 Host 列表 -//! - 合并用户配置和系统 SSH 配置 -//! -//! ## 配置文件格式 -//! ```json -//! { -//! "connections": { -//! "my-server": { -//! "type": "ssh", -//! "user": "root", -//! "host": "192.168.1.100", -//! "port": 22, -//! "identityFile": "~/.ssh/id_rsa" -//! } -//! } -//! } -//! ``` - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; - -use super::SSHConfigParser; - -/// 连接类型 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] -#[serde(rename_all = "lowercase")] -pub enum ConnectionConfigType { - /// 本地终端 - #[default] - Local, - /// SSH 远程连接 - Ssh, - /// WSL 连接 - Wsl, -} - -/// 单个连接配置 -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ConnectionConfig { - /// 连接类型 - #[serde(rename = "type", default)] - pub conn_type: ConnectionConfigType, - - /// SSH 用户名 - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option, - - /// SSH 主机名或 IP - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, - - /// SSH 端口(默认 22) - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// 身份文件路径(私钥) - #[serde(skip_serializing_if = "Option::is_none")] - pub identity_file: Option, - - /// 身份文件列表(多个私钥) - #[serde(skip_serializing_if = "Option::is_none")] - pub identity_files: Option>, - - /// 跳板机配置 (ProxyJump) - #[serde(skip_serializing_if = "Option::is_none")] - pub proxy_jump: Option, - - /// 显示顺序 - #[serde(skip_serializing_if = "Option::is_none")] - pub display_order: Option, - - /// 是否隐藏 - #[serde(skip_serializing_if = "Option::is_none")] - pub hidden: Option, - - /// WSL 发行版名称 - #[serde(skip_serializing_if = "Option::is_none")] - pub wsl_distro: Option, -} - -impl Default for ConnectionConfig { - fn default() -> Self { - Self { - conn_type: ConnectionConfigType::Local, - user: None, - host: None, - port: None, - identity_file: None, - identity_files: None, - proxy_jump: None, - display_order: None, - hidden: None, - wsl_distro: None, - } - } -} - -impl ConnectionConfig { - /// 创建本地连接配置 - pub fn local() -> Self { - Self { - conn_type: ConnectionConfigType::Local, - ..Default::default() - } - } - - /// 创建 SSH 连接配置 - pub fn ssh(host: impl Into) -> Self { - Self { - conn_type: ConnectionConfigType::Ssh, - host: Some(host.into()), - ..Default::default() - } - } - - /// 设置用户名 - pub fn with_user(mut self, user: impl Into) -> Self { - self.user = Some(user.into()); - self - } - - /// 设置端口 - pub fn with_port(mut self, port: u16) -> Self { - self.port = Some(port); - self - } - - /// 设置身份文件 - pub fn with_identity_file(mut self, path: impl Into) -> Self { - self.identity_file = Some(path.into()); - self - } -} - -/// 连接配置文件结构 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ConnectionsFile { - /// 连接配置映射(名称 -> 配置) - #[serde(default)] - pub connections: HashMap, -} - -impl ConnectionsFile { - /// 创建空的配置文件 - pub fn new() -> Self { - Self::default() - } - - /// 添加连接 - pub fn add(&mut self, name: impl Into, config: ConnectionConfig) { - self.connections.insert(name.into(), config); - } - - /// 移除连接 - pub fn remove(&mut self, name: &str) -> Option { - self.connections.remove(name) - } - - /// 获取连接 - pub fn get(&self, name: &str) -> Option<&ConnectionConfig> { - self.connections.get(name) - } - - /// 获取所有连接名称 - pub fn names(&self) -> Vec { - self.connections.keys().cloned().collect() - } -} - -/// 连接配置管理器 -pub struct ConnectionConfigManager { - /// 配置文件路径 - config_path: PathBuf, -} - -impl ConnectionConfigManager { - /// 创建配置管理器 - pub fn new() -> Self { - Self { - config_path: Self::default_config_path(), - } - } - - /// 使用自定义路径创建 - pub fn with_path(path: PathBuf) -> Self { - Self { config_path: path } - } - - /// 获取默认配置文件路径 - pub fn default_config_path() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("lime") - .join("connections.json") - } - - /// 获取配置文件路径 - pub fn config_path(&self) -> &PathBuf { - &self.config_path - } - - /// 加载连接配置 - pub fn load(&self) -> Result { - if !self.config_path.exists() { - tracing::info!( - "[ConnectionConfig] 配置文件不存在,返回空配置: {:?}", - self.config_path - ); - return Ok(ConnectionsFile::new()); - } - - let content = - fs::read_to_string(&self.config_path).map_err(|e| format!("读取配置文件失败: {e}"))?; - - serde_json::from_str(&content).map_err(|e| format!("解析配置文件失败: {e}")) - } - - /// 保存连接配置 - pub fn save(&self, config: &ConnectionsFile) -> Result<(), String> { - // 确保父目录存在 - if let Some(parent) = self.config_path.parent() { - if !parent.exists() { - fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?; - } - } - - let content = - serde_json::to_string_pretty(config).map_err(|e| format!("序列化配置失败: {e}"))?; - - fs::write(&self.config_path, content).map_err(|e| format!("写入配置文件失败: {e}"))?; - - tracing::info!("[ConnectionConfig] 配置已保存: {:?}", self.config_path); - Ok(()) - } - - /// 保存原始 JSON 内容 - pub fn save_raw(&self, content: &str) -> Result<(), String> { - // 先验证 JSON 格式 - let _: ConnectionsFile = - serde_json::from_str(content).map_err(|e| format!("无效的 JSON 格式: {e}"))?; - - // 确保父目录存在 - if let Some(parent) = self.config_path.parent() { - if !parent.exists() { - fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败: {e}"))?; - } - } - - fs::write(&self.config_path, content).map_err(|e| format!("写入配置文件失败: {e}"))?; - - tracing::info!("[ConnectionConfig] 原始配置已保存: {:?}", self.config_path); - Ok(()) - } - - /// 获取原始配置文件内容 - pub fn load_raw(&self) -> Result { - if !self.config_path.exists() { - // 返回默认空配置 - return Ok(r#"{ - "connections": {} -}"# - .to_string()); - } - - fs::read_to_string(&self.config_path).map_err(|e| format!("读取配置文件失败: {e}")) - } - - /// 从系统 SSH 配置读取 Host 列表 - pub fn load_ssh_hosts(&self) -> Vec { - let config_path = match SSHConfigParser::default_config_path() { - Some(path) => path, - None => return vec![], - }; - - if !config_path.exists() { - return vec![]; - } - - let content = match fs::read_to_string(&config_path) { - Ok(c) => c, - Err(e) => { - tracing::warn!("[ConnectionConfig] 读取 SSH 配置失败: {}", e); - return vec![]; - } - }; - - Self::parse_ssh_config_hosts(&content) - } - - /// 解析 SSH 配置文件中的 Host 列表 - fn parse_ssh_config_hosts(content: &str) -> Vec { - let mut hosts = Vec::new(); - let mut current_host: Option = None; - - for line in content.lines() { - let line = line.trim(); - - // 跳过空行和注释 - if line.is_empty() || line.starts_with('#') { - continue; - } - - // 分割 key value - let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect(); - if parts.len() < 2 { - continue; - } - - let key = parts[0].to_lowercase(); - let value = parts[1].trim(); - - match key.as_str() { - "host" => { - // 保存之前的 Host - if let Some(host) = current_host.take() { - // 排除通配符 Host - if !host.pattern.contains('*') && !host.pattern.contains('?') { - hosts.push(host); - } - } - - // 开始新的 Host - current_host = Some(SSHHostEntry { - pattern: value.to_string(), - hostname: None, - user: None, - port: None, - identity_file: None, - }); - } - "hostname" => { - if let Some(ref mut host) = current_host { - host.hostname = Some(value.to_string()); - } - } - "user" => { - if let Some(ref mut host) = current_host { - host.user = Some(value.to_string()); - } - } - "port" => { - if let Some(ref mut host) = current_host { - host.port = value.parse().ok(); - } - } - "identityfile" => { - if let Some(ref mut host) = current_host { - host.identity_file = Some(value.to_string()); - } - } - _ => {} - } - } - - // 保存最后一个 Host - if let Some(host) = current_host { - if !host.pattern.contains('*') && !host.pattern.contains('?') { - hosts.push(host); - } - } - - hosts - } - - /// 获取所有可用连接(用户配置 + SSH 配置) - pub fn list_all_connections(&self) -> Result, String> { - let mut entries = Vec::new(); - - // 获取本地系统信息 - let local_user = whoami::username(); - let local_host = whoami::fallible::hostname().unwrap_or_else(|_| "localhost".to_string()); - let local_label = format!("{local_user}@{local_host}"); - - // 添加本地连接 - entries.push(ConnectionListEntry { - name: "local".to_string(), - conn_type: ConnectionConfigType::Local, - label: local_label, - source: ConnectionSource::BuiltIn, - host: Some(local_host), - user: Some(local_user), - port: None, - }); - - // 加载用户配置 - let config = self.load()?; - for (name, conn) in config.connections { - if conn.hidden == Some(true) { - continue; - } - - let label = match conn.conn_type { - ConnectionConfigType::Ssh => { - let user = conn.user.as_deref().unwrap_or("user"); - let host = conn.host.as_deref().unwrap_or("unknown"); - let port = conn.port.unwrap_or(22); - if port == 22 { - format!("{user}@{host}") - } else { - format!("{user}@{host}:{port}") - } - } - ConnectionConfigType::Wsl => { - let distro = conn.wsl_distro.as_deref().unwrap_or("default"); - format!("WSL: {distro}") - } - ConnectionConfigType::Local => "Local".to_string(), - }; - - entries.push(ConnectionListEntry { - name: name.clone(), - conn_type: conn.conn_type.clone(), - label, - source: ConnectionSource::UserConfig, - host: conn.host, - user: conn.user, - port: conn.port, - }); - } - - // 加载 SSH 配置 - let ssh_hosts = self.load_ssh_hosts(); - for host in ssh_hosts { - // 检查是否已在用户配置中 - let already_exists = entries.iter().any(|e| e.name == host.pattern); - if already_exists { - continue; - } - - let label = if let Some(ref user) = host.user { - if let Some(ref hostname) = host.hostname { - format!("{user}@{hostname}") - } else { - format!("{}@{}", user, host.pattern) - } - } else if let Some(ref hostname) = host.hostname { - hostname.clone() - } else { - host.pattern.clone() - }; - - entries.push(ConnectionListEntry { - name: host.pattern.clone(), - conn_type: ConnectionConfigType::Ssh, - label, - source: ConnectionSource::SSHConfig, - host: host.hostname, - user: host.user, - port: host.port, - }); - } - - Ok(entries) - } -} - -impl Default for ConnectionConfigManager { - fn default() -> Self { - Self::new() - } -} - -/// SSH 配置中的 Host 条目 -#[derive(Debug, Clone)] -pub struct SSHHostEntry { - /// Host 模式 - pub pattern: String, - /// 实际主机名 - pub hostname: Option, - /// 用户名 - pub user: Option, - /// 端口 - pub port: Option, - /// 身份文件 - pub identity_file: Option, -} - -/// 连接来源 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "lowercase")] -pub enum ConnectionSource { - /// 内置连接 - BuiltIn, - /// 用户配置文件 - UserConfig, - /// SSH 配置文件 - SSHConfig, -} - -/// 连接列表条目(用于前端显示) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ConnectionListEntry { - /// 连接名称/标识 - pub name: String, - /// 连接类型 - #[serde(rename = "type")] - pub conn_type: ConnectionConfigType, - /// 显示标签 - pub label: String, - /// 配置来源 - pub source: ConnectionSource, - /// 主机名 - #[serde(skip_serializing_if = "Option::is_none")] - pub host: Option, - /// 用户名 - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option, - /// 端口 - #[serde(skip_serializing_if = "Option::is_none")] - pub port: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_connection_config_serialization() { - let config = ConnectionConfig::ssh("192.168.1.100") - .with_user("root") - .with_port(22); - - let json = serde_json::to_string(&config).unwrap(); - assert!(json.contains("\"type\":\"ssh\"")); - assert!(json.contains("\"user\":\"root\"")); - assert!(json.contains("\"host\":\"192.168.1.100\"")); - } - - #[test] - fn test_connections_file() { - let mut file = ConnectionsFile::new(); - file.add("test-server", ConnectionConfig::ssh("test.example.com")); - - assert_eq!(file.names().len(), 1); - assert!(file.get("test-server").is_some()); - - file.remove("test-server"); - assert!(file.get("test-server").is_none()); - } - - #[test] - fn test_parse_ssh_config_hosts() { - let content = r#" -Host my-server - HostName 192.168.1.100 - User root - Port 22 - -Host dev-* - User developer - -Host github.com - HostName github.com - User git - IdentityFile ~/.ssh/github_key -"#; - - let hosts = ConnectionConfigManager::parse_ssh_config_hosts(content); - - // 应该有 2 个主机(排除了通配符 dev-*) - assert_eq!(hosts.len(), 2); - - let my_server = hosts.iter().find(|h| h.pattern == "my-server").unwrap(); - assert_eq!(my_server.hostname.as_deref(), Some("192.168.1.100")); - assert_eq!(my_server.user.as_deref(), Some("root")); - assert_eq!(my_server.port, Some(22)); - - let github = hosts.iter().find(|h| h.pattern == "github.com").unwrap(); - assert_eq!(github.user.as_deref(), Some("git")); - assert!(github.identity_file.is_some()); - } -} diff --git a/src-tauri/crates/terminal/src/connections/connection_router.rs b/src-tauri/crates/terminal/src/connections/connection_router.rs deleted file mode 100644 index d0ea5ecfa..000000000 --- a/src-tauri/crates/terminal/src/connections/connection_router.rs +++ /dev/null @@ -1,429 +0,0 @@ -//! 连接类型路由模块 -//! -//! 根据连接名称自动选择连接类型,实现连接工厂模式。 -//! -//! ## 功能 -//! - 根据连接名称自动路由到正确的连接类型 -//! - 提供连接工厂函数创建相应的连接 -//! - 支持本地 PTY、SSH、WSL 三种连接类型 -//! -//! ## Requirements -//! - 1.4: 创建 SSH 终端时使用 SSH_Connection 建立远程连接 -//! - 1.5: 创建 WSL 终端时使用 WSL_Connection 建立连接 - -use serde::{Deserialize, Serialize}; - -use super::{is_local_conn_name, is_ssh_conn_name, is_wsl_conn_name}; -use crate::error::TerminalError; - -// ============================================================================ -// 连接类型枚举 -// ============================================================================ - -/// 连接类型 -/// -/// 表示终端会话可以使用的连接类型。 -/// -/// _Requirements: 1.4, 1.5_ -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ConnectionType { - /// 本地 PTY 连接 - #[default] - Local, - /// SSH 远程连接 - SSH, - /// WSL 连接(仅 Windows) - WSL, -} - -impl std::fmt::Display for ConnectionType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Local => write!(f, "local"), - Self::SSH => write!(f, "ssh"), - Self::WSL => write!(f, "wsl"), - } - } -} - -impl std::str::FromStr for ConnectionType { - type Err = TerminalError; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "local" | "" => Ok(Self::Local), - "ssh" => Ok(Self::SSH), - "wsl" => Ok(Self::WSL), - _ => Err(TerminalError::InvalidConnectionType(s.to_string())), - } - } -} - -// ============================================================================ -// 连接路由器 -// ============================================================================ - -/// 连接路由器 -/// -/// 根据连接名称自动选择连接类型。 -/// -/// ## 路由规则 -/// 1. 空字符串或 "local" → Local -/// 2. 以 "wsl://" 开头或等于 "wsl" → WSL -/// 3. 以 "ssh://" 开头、包含 "@" 或其他非本地/WSL 格式 → SSH -/// -/// _Requirements: 1.4, 1.5_ -pub struct ConnectionRouter; - -impl ConnectionRouter { - /// 根据连接名称确定连接类型 - /// - /// # 参数 - /// - `conn_name`: 连接名称 - /// - /// # 返回 - /// 对应的连接类型 - /// - /// # 示例 - /// ```ignore - /// use lime_terminal::connections::{ConnectionRouter, ConnectionType}; - /// - /// assert_eq!(ConnectionRouter::route(""), ConnectionType::Local); - /// assert_eq!(ConnectionRouter::route("local"), ConnectionType::Local); - /// assert_eq!(ConnectionRouter::route("wsl://Ubuntu"), ConnectionType::WSL); - /// assert_eq!(ConnectionRouter::route("user@host"), ConnectionType::SSH); - /// ``` - /// - /// _Requirements: 1.4, 1.5_ - pub fn route(conn_name: &str) -> ConnectionType { - let conn_name = conn_name.trim(); - - // 1. 检查是否为本地连接 - if is_local_conn_name(conn_name) { - return ConnectionType::Local; - } - - // 2. 检查是否为 WSL 连接 - if is_wsl_conn_name(conn_name) { - return ConnectionType::WSL; - } - - // 3. 检查是否为 SSH 连接 - if is_ssh_conn_name(conn_name) { - return ConnectionType::SSH; - } - - // 4. 默认为本地连接 - ConnectionType::Local - } - - /// 验证连接名称格式是否有效 - /// - /// # 参数 - /// - `conn_name`: 连接名称 - /// - /// # 返回 - /// - `Ok(ConnectionType)`: 连接名称有效,返回对应的连接类型 - /// - `Err(TerminalError)`: 连接名称无效 - pub fn validate(conn_name: &str) -> Result { - let conn_name = conn_name.trim(); - let conn_type = Self::route(conn_name); - - // 对于 SSH 连接,验证格式 - if conn_type == ConnectionType::SSH { - // 尝试解析 SSH 连接字符串 - use super::SSHOpts; - SSHOpts::parse(conn_name)?; - } - - // 对于 WSL 连接,验证格式 - if conn_type == ConnectionType::WSL { - use super::WSLOpts; - WSLOpts::parse(conn_name)?; - } - - Ok(conn_type) - } - - /// 检查连接类型是否在当前平台上可用 - /// - /// # 参数 - /// - `conn_type`: 连接类型 - /// - /// # 返回 - /// 连接类型是否可用 - pub fn is_available(conn_type: ConnectionType) -> bool { - match conn_type { - ConnectionType::Local => true, - ConnectionType::SSH => true, // SSH 在所有平台上可用 - ConnectionType::WSL => cfg!(target_os = "windows"), // WSL 仅在 Windows 上可用 - } - } - - /// 获取连接类型的描述 - /// - /// # 参数 - /// - `conn_type`: 连接类型 - /// - /// # 返回 - /// 连接类型的人类可读描述 - pub fn description(conn_type: ConnectionType) -> &'static str { - match conn_type { - ConnectionType::Local => "本地终端", - ConnectionType::SSH => "SSH 远程连接", - ConnectionType::WSL => "Windows Subsystem for Linux", - } - } -} - -// ============================================================================ -// 连接信息 -// ============================================================================ - -/// 连接信息 -/// -/// 包含解析后的连接详情。 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectionInfo { - /// 原始连接名称 - pub conn_name: String, - /// 连接类型 - pub conn_type: ConnectionType, - /// 是否在当前平台可用 - pub available: bool, - /// 连接描述 - pub description: String, -} - -impl ConnectionInfo { - /// 从连接名称创建连接信息 - /// - /// # 参数 - /// - `conn_name`: 连接名称 - /// - /// # 返回 - /// 连接信息 - pub fn from_conn_name(conn_name: &str) -> Self { - let conn_type = ConnectionRouter::route(conn_name); - Self { - conn_name: conn_name.to_string(), - conn_type, - available: ConnectionRouter::is_available(conn_type), - description: ConnectionRouter::description(conn_type).to_string(), - } - } -} - -// ============================================================================ -// 测试 -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - // ======================================================================== - // ConnectionType 测试 - // ======================================================================== - - mod connection_type_tests { - use super::*; - - #[test] - fn test_default() { - assert_eq!(ConnectionType::default(), ConnectionType::Local); - } - - #[test] - fn test_display() { - assert_eq!(ConnectionType::Local.to_string(), "local"); - assert_eq!(ConnectionType::SSH.to_string(), "ssh"); - assert_eq!(ConnectionType::WSL.to_string(), "wsl"); - } - - #[test] - fn test_from_str() { - assert_eq!( - "local".parse::().unwrap(), - ConnectionType::Local - ); - assert_eq!("".parse::().unwrap(), ConnectionType::Local); - assert_eq!( - "ssh".parse::().unwrap(), - ConnectionType::SSH - ); - assert_eq!( - "SSH".parse::().unwrap(), - ConnectionType::SSH - ); - assert_eq!( - "wsl".parse::().unwrap(), - ConnectionType::WSL - ); - assert_eq!( - "WSL".parse::().unwrap(), - ConnectionType::WSL - ); - } - - #[test] - fn test_from_str_invalid() { - assert!("invalid".parse::().is_err()); - } - } - - // ======================================================================== - // ConnectionRouter 测试 - // ======================================================================== - - mod connection_router_tests { - use super::*; - - #[test] - fn test_route_local() { - assert_eq!(ConnectionRouter::route(""), ConnectionType::Local); - assert_eq!(ConnectionRouter::route("local"), ConnectionType::Local); - assert_eq!(ConnectionRouter::route(" "), ConnectionType::Local); - assert_eq!(ConnectionRouter::route(" local "), ConnectionType::Local); - } - - #[test] - fn test_route_wsl() { - assert_eq!(ConnectionRouter::route("wsl://Ubuntu"), ConnectionType::WSL); - assert_eq!(ConnectionRouter::route("wsl://Debian"), ConnectionType::WSL); - assert_eq!(ConnectionRouter::route("wsl://"), ConnectionType::WSL); - assert_eq!(ConnectionRouter::route("wsl"), ConnectionType::WSL); - assert_eq!(ConnectionRouter::route("WSL"), ConnectionType::WSL); - assert_eq!( - ConnectionRouter::route(" wsl://Ubuntu "), - ConnectionType::WSL - ); - } - - #[test] - fn test_route_ssh() { - assert_eq!(ConnectionRouter::route("user@host"), ConnectionType::SSH); - assert_eq!(ConnectionRouter::route("user@host:22"), ConnectionType::SSH); - assert_eq!( - ConnectionRouter::route("ssh://user@host"), - ConnectionType::SSH - ); - assert_eq!(ConnectionRouter::route("ssh://host"), ConnectionType::SSH); - assert_eq!(ConnectionRouter::route("example.com"), ConnectionType::SSH); - assert_eq!(ConnectionRouter::route("192.168.1.1"), ConnectionType::SSH); - } - - #[test] - fn test_route_with_whitespace() { - assert_eq!( - ConnectionRouter::route(" user@host "), - ConnectionType::SSH - ); - assert_eq!( - ConnectionRouter::route(" wsl://Ubuntu "), - ConnectionType::WSL - ); - assert_eq!(ConnectionRouter::route(" local "), ConnectionType::Local); - } - - #[test] - fn test_validate_local() { - assert_eq!( - ConnectionRouter::validate("").unwrap(), - ConnectionType::Local - ); - assert_eq!( - ConnectionRouter::validate("local").unwrap(), - ConnectionType::Local - ); - } - - #[test] - fn test_validate_ssh() { - assert_eq!( - ConnectionRouter::validate("user@host").unwrap(), - ConnectionType::SSH - ); - assert_eq!( - ConnectionRouter::validate("user@host:22").unwrap(), - ConnectionType::SSH - ); - } - - #[test] - fn test_validate_wsl() { - assert_eq!( - ConnectionRouter::validate("wsl://Ubuntu").unwrap(), - ConnectionType::WSL - ); - assert_eq!( - ConnectionRouter::validate("wsl").unwrap(), - ConnectionType::WSL - ); - } - - #[test] - fn test_is_available() { - assert!(ConnectionRouter::is_available(ConnectionType::Local)); - assert!(ConnectionRouter::is_available(ConnectionType::SSH)); - // WSL 可用性取决于平台 - #[cfg(target_os = "windows")] - assert!(ConnectionRouter::is_available(ConnectionType::WSL)); - #[cfg(not(target_os = "windows"))] - assert!(!ConnectionRouter::is_available(ConnectionType::WSL)); - } - - #[test] - fn test_description() { - assert_eq!( - ConnectionRouter::description(ConnectionType::Local), - "本地终端" - ); - assert_eq!( - ConnectionRouter::description(ConnectionType::SSH), - "SSH 远程连接" - ); - assert_eq!( - ConnectionRouter::description(ConnectionType::WSL), - "Windows Subsystem for Linux" - ); - } - } - - // ======================================================================== - // ConnectionInfo 测试 - // ======================================================================== - - mod connection_info_tests { - use super::*; - - #[test] - fn test_from_conn_name_local() { - let info = ConnectionInfo::from_conn_name(""); - assert_eq!(info.conn_name, ""); - assert_eq!(info.conn_type, ConnectionType::Local); - assert!(info.available); - } - - #[test] - fn test_from_conn_name_ssh() { - let info = ConnectionInfo::from_conn_name("user@host"); - assert_eq!(info.conn_name, "user@host"); - assert_eq!(info.conn_type, ConnectionType::SSH); - assert!(info.available); - } - - #[test] - fn test_from_conn_name_wsl() { - let info = ConnectionInfo::from_conn_name("wsl://Ubuntu"); - assert_eq!(info.conn_name, "wsl://Ubuntu"); - assert_eq!(info.conn_type, ConnectionType::WSL); - // WSL 可用性取决于平台 - #[cfg(target_os = "windows")] - assert!(info.available); - #[cfg(not(target_os = "windows"))] - assert!(!info.available); - } - } -} diff --git a/src-tauri/crates/terminal/src/connections/local_pty.rs b/src-tauri/crates/terminal/src/connections/local_pty.rs deleted file mode 100644 index 459f1c6bf..000000000 --- a/src-tauri/crates/terminal/src/connections/local_pty.rs +++ /dev/null @@ -1,730 +0,0 @@ -//! 本地 PTY 连接实现 -//! -//! 封装本地 PTY 进程,提供 ShellProc 结构体供 ShellController 使用。 -//! -//! ## 功能 -//! - 创建和管理本地 PTY 子进程 -//! - 异步读取 PTY 输出并通过 Tauri Event 推送 -//! - 处理 PTY 输入写入 -//! - 监控进程退出状态 -//! - 支持命令执行模式(cmd) -//! - 支持 Shell 集成脚本加载 -//! -//! ## Requirements -//! - 17.1: 管理 Shell 进程的完整生命周期 -//! - 17.2: 设置正确的环境变量 -//! - 17.3: 支持优雅终止和强制终止 -//! - 17.4: 记录退出码并更新状态 -//! - 17.5: 支持自定义 Shell 路径和参数 -//! - 17.8: zsh 使用 ZDOTDIR 指向集成目录 -//! - 17.9: bash 使用 --rcfile 加载集成脚本 -//! - 17.10: fish 使用 -C 参数 source 集成脚本 - -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; -use std::sync::Arc; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use parking_lot::Mutex; -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use tokio::sync::mpsc; - -use crate::emit_helper; -use crate::emitter::TerminalEventEmitter; - -use crate::block_controller::{BlockInputUnion, BlockMeta}; -use crate::error::TerminalError; -use crate::events::{event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; -use crate::integration::{ShellLaunchBuilder, ShellType}; -use crate::persistence::BlockFile; - -fn resolve_default_shell() -> String { - let shell_from_env = std::env::var("SHELL").ok().and_then(|value| { - let cleaned = value - .split('\0') - .next() - .unwrap_or_default() - .trim() - .to_string(); - (!cleaned.is_empty()).then_some(cleaned) - }); - - #[cfg(target_os = "windows")] - { - let is_valid_windows_shell = |candidate: &str| -> bool { - let cleaned = candidate.trim(); - if cleaned.is_empty() { - return false; - } - - // 拒绝 Unix 风格路径(如 /bin/bash) - if cleaned.starts_with('/') { - return false; - } - - let path = Path::new(cleaned); - if path.is_absolute() { - if !path.exists() { - return false; - } - - let ext = path - .extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()); - - return matches!(ext.as_deref(), Some("exe" | "cmd" | "bat" | "com")); - } - - if cleaned.contains('/') || cleaned.contains('\\') { - return false; - } - - true - }; - - if let Some(shell) = shell_from_env { - if is_valid_windows_shell(&shell) { - return shell; - } - } - - if let Ok(comspec) = std::env::var("COMSPEC") { - let cleaned = comspec - .split('\0') - .next() - .unwrap_or_default() - .trim() - .to_string(); - if is_valid_windows_shell(&cleaned) { - return cleaned; - } - } - - "cmd.exe".to_string() - } - - #[cfg(not(target_os = "windows"))] - { - if let Some(shell) = shell_from_env { - let path = Path::new(&shell); - if path.exists() { - return shell; - } - } - - if Path::new("/bin/bash").exists() { - "/bin/bash".to_string() - } else { - "/bin/sh".to_string() - } - } -} - -fn resolve_working_dir(cwd: Option<&str>) -> Option { - let dir = cwd?; - let cleaned = dir - .split('\0') - .next() - .unwrap_or_default() - .trim() - .to_string(); - if cleaned.is_empty() { - return None; - } - - let expanded = if cleaned.starts_with("~/") { - if let Some(home) = dirs::home_dir() { - home.join(&cleaned[2..]) - } else { - PathBuf::from(&cleaned) - } - } else if cleaned == "~" { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(&cleaned)) - } else { - PathBuf::from(&cleaned) - }; - - (expanded.exists() && expanded.is_dir()).then_some(expanded) -} - -fn shell_command_flag(shell: &str) -> &'static str { - let executable = Path::new(shell) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(shell) - .to_ascii_lowercase(); - - if executable == "cmd" || executable == "cmd.exe" { - "/C" - } else if executable.contains("powershell") || executable == "pwsh" || executable == "pwsh.exe" - { - "-Command" - } else { - "-c" - } -} - -/// Shell 进程封装 -/// -/// 封装 PTY 进程,提供输入输出和生命周期管理。 -pub struct ShellProc { - /// Block ID - block_id: String, - /// 控制器类型 - controller_type: String, - /// PTY 写入器 - writer: Arc>>, - /// PTY Master(用于调整大小) - master: Arc>>, - /// 关闭标志 - shutdown_flag: Arc, - /// 进程退出码 - exit_code: Arc, - /// 是否已退出 - exited: Arc, - /// 泛型标记 - _emitter: std::marker::PhantomData, -} - -impl ShellProc { - /// 创建新的 Shell 进程 - /// - /// # 参数 - /// - `block_id`: Block ID - /// - `controller_type`: 控制器类型 ("shell" | "cmd") - /// - `rows`: 终端行数 - /// - `cols`: 终端列数 - /// - `app_handle`: 事件发射器 - /// - `block_meta`: 块元数据配置 - /// - `input_rx`: 输入接收器 - /// - `block_file`: 块文件存储(可选) - /// - /// # 返回 - /// - `Ok(ShellProc)`: 创建成功 - /// - `Err(TerminalError)`: 创建失败 - /// - /// _Requirements: 17.1, 17.2, 17.8, 17.9, 17.10_ - #[allow(clippy::too_many_arguments)] - pub async fn new( - block_id: String, - controller_type: String, - rows: u16, - cols: u16, - app_handle: E, - block_meta: BlockMeta, - input_rx: mpsc::Receiver, - block_file: Option>, - ) -> Result { - tracing::info!( - "[ShellProc] 创建进程: block_id={}, type={}, size={}x{}", - block_id, - controller_type, - cols, - rows - ); - - let pty_system = native_pty_system(); - - // 创建 PTY - let pair = pty_system - .openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 构建命令(传递 app_handle 和 block_id 用于 Shell 集成) - let cmd = Self::build_command(&controller_type, &block_meta, &app_handle, &block_id)?; - - // 启动子进程 - let _child = pair - .slave - .spawn_command(cmd) - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 获取写入器 - let writer = pair - .master - .take_writer() - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 获取读取器 - let reader = pair - .master - .try_clone_reader() - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 创建共享状态 - let shutdown_flag = Arc::new(AtomicBool::new(false)); - let exit_code = Arc::new(AtomicI32::new(0)); - let exited = Arc::new(AtomicBool::new(false)); - let writer = Arc::new(Mutex::new(writer)); - let master = Arc::new(Mutex::new(pair.master)); - - // 启动输出读取任务 - Self::spawn_output_reader( - block_id.clone(), - reader, - app_handle.clone(), - shutdown_flag.clone(), - exit_code.clone(), - exited.clone(), - block_file, - ); - - // 启动输入处理任务 - Self::spawn_input_handler( - block_id.clone(), - writer.clone(), - master.clone(), - input_rx, - shutdown_flag.clone(), - ); - - tracing::info!("[ShellProc] 进程已创建: block_id={}", block_id); - - Ok(Self { - block_id, - controller_type, - writer, - master, - shutdown_flag, - exit_code, - exited, - _emitter: std::marker::PhantomData, - }) - } - - /// 构建命令 - /// - /// 根据控制器类型和块元数据构建要执行的命令。 - /// - /// # 参数 - /// - `controller_type`: 控制器类型 - /// - `block_meta`: 块元数据 - /// - `app_handle`: 事件发射器 - /// - `block_id`: Block ID - /// - /// # 返回 - /// 构建好的命令 - /// - /// _Requirements: 17.2, 17.5, 17.8, 17.9, 17.10_ - fn build_command( - controller_type: &str, - block_meta: &BlockMeta, - app_handle: &E, - block_id: &str, - ) -> Result { - let mut cmd = if controller_type == "cmd" { - // 命令执行模式 - Self::build_cmd_command(block_meta)? - } else { - // Shell 模式 - 使用集成脚本 - Self::build_shell_command(block_meta, app_handle, block_id)? - }; - - // 设置工作目录 - let sanitized_cwd = block_meta.sanitized_cmd_cwd(); - if let Some(resolved_cwd) = resolve_working_dir(sanitized_cwd.as_deref()) { - cmd.cwd(resolved_cwd); - } else if let Some(raw_cwd) = block_meta.cmd_cwd.as_deref() { - tracing::warn!( - "[ShellProc] 工作目录无效或不存在: {:?}, 使用主目录", - raw_cwd - ); - if let Some(home) = dirs::home_dir() { - cmd.cwd(home); - } - } else if let Some(home) = dirs::home_dir() { - cmd.cwd(home); - } - - Ok(cmd) - } - - /// 构建 Shell 命令 - /// - /// 使用 Shell 集成脚本构建启动命令。 - /// - /// _Requirements: 17.5, 17.7, 17.8, 17.9, 17.10_ - fn build_shell_command( - block_meta: &BlockMeta, - app_handle: &E, - block_id: &str, - ) -> Result { - // 获取用户默认 shell - let shell = resolve_default_shell(); - tracing::info!("[ShellProc] 使用 shell: {}", shell); - - // 获取应用数据目录 - let app_data_dir = app_handle - .app_data_dir() - .map_err(|e| TerminalError::Internal(format!("获取应用数据目录失败: {e}")))?; - - // 使用 ShellLaunchBuilder 构建启动配置 - let builder = ShellLaunchBuilder::new(&app_data_dir, block_id.to_string()); - let launch_config = builder.build(&shell, block_meta.cmd_env.as_ref())?; - - // 构建命令 - let mut cmd = CommandBuilder::new(&launch_config.shell_path); - - // 添加参数 - for arg in &launch_config.args { - cmd.arg(arg); - } - - // 设置环境变量 - for (key, value) in &launch_config.env { - cmd.env(key, value); - } - - // 检测 Shell 类型并记录 - let shell_type = ShellType::from_path(&shell); - tracing::info!( - "[ShellProc] Shell 类型: {:?}, 参数: {:?}", - shell_type, - launch_config.args - ); - - Ok(cmd) - } - - /// 构建命令执行命令 - /// - /// _Requirements: 16.1, 16.2, 16.3, 17.2_ - fn build_cmd_command(block_meta: &BlockMeta) -> Result { - let cmd_str = block_meta - .cmd - .as_ref() - .ok_or_else(|| TerminalError::PtyCreationFailed("cmd 模式需要指定命令".to_string()))?; - - tracing::info!("[ShellProc] 执行命令: {}", cmd_str); - - // 使用 shell 执行命令 - let shell = resolve_default_shell(); - let mut cmd = CommandBuilder::new(&shell); - cmd.arg(shell_command_flag(&shell)); - - // 构建完整命令字符串 - let full_cmd = if let Some(args) = &block_meta.cmd_args { - format!("{} {}", cmd_str, args.join(" ")) - } else { - cmd_str.clone() - }; - - cmd.arg(&full_cmd); - - // 设置通用环境变量 - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); - - // 设置自定义环境变量 - if let Some(env_vars) = &block_meta.cmd_env { - for (key, value) in env_vars { - cmd.env(key, value); - } - } - - Ok(cmd) - } - - /// 启动输出读取任务 - /// - /// 在独立线程中读取 PTY 输出,并通过 Tauri 事件发送到前端。 - fn spawn_output_reader( - block_id: String, - mut reader: Box, - app_handle: E, - shutdown_flag: Arc, - exit_code: Arc, - exited: Arc, - block_file: Option>, - ) { - std::thread::spawn(move || { - let mut buffer = [0u8; 4096]; - - loop { - // 检查关闭标志 - if shutdown_flag.load(Ordering::Relaxed) { - tracing::debug!("[ShellProc] 收到关闭信号: block_id={}", block_id); - break; - } - - // 读取输出 - match reader.read(&mut buffer) { - Ok(0) => { - // EOF,进程已退出 - tracing::info!("[ShellProc] 进程已退出: block_id={}", block_id); - exited.store(true, Ordering::SeqCst); - - // 发送状态事件 - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: block_id.clone(), - status: SessionStatus::Done, - exit_code: Some(exit_code.load(Ordering::SeqCst)), - error: None, - }, - ); - break; - } - Ok(n) => { - let output_data = &buffer[..n]; - - // 保存到块文件 - if let Some(ref bf) = block_file { - if let Err(e) = bf.append_data(output_data) { - tracing::warn!( - "[ShellProc] 写入块文件失败: block_id={}, error={}", - block_id, - e - ); - } - } - - // 发送输出事件 - let data = BASE64.encode(output_data); - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_OUTPUT, - &TerminalOutputEvent { - session_id: block_id.clone(), - data, - }, - ); - } - Err(e) => { - // 检查是否是因为关闭导致的错误 - if shutdown_flag.load(Ordering::Relaxed) { - break; - } - - tracing::error!("[ShellProc] 读取错误: block_id={}, error={}", block_id, e); - exited.store(true, Ordering::SeqCst); - - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: block_id.clone(), - status: SessionStatus::Error, - exit_code: None, - error: Some(e.to_string()), - }, - ); - break; - } - } - } - }); - } - - /// 启动输入处理任务 - /// - /// 在独立任务中处理输入数据,包括键盘输入、信号和终端大小调整。 - fn spawn_input_handler( - block_id: String, - writer: Arc>>, - master: Arc>>, - mut input_rx: mpsc::Receiver, - shutdown_flag: Arc, - ) { - tokio::spawn(async move { - while let Some(input) = input_rx.recv().await { - // 检查关闭标志 - if shutdown_flag.load(Ordering::Relaxed) { - break; - } - - // 处理输入数据 - if let Some(data) = &input.input_data { - let mut w = writer.lock(); - if let Err(e) = w.write_all(data) { - tracing::error!("[ShellProc] 写入失败: block_id={}, error={}", block_id, e); - continue; - } - if let Err(e) = w.flush() { - tracing::error!( - "[ShellProc] Flush 失败: block_id={}, error={}", - block_id, - e - ); - } - } - - // 处理终端大小调整 - if let Some(size) = &input.term_size { - let m = master.lock(); - if let Err(e) = m.resize(PtySize { - rows: size.rows, - cols: size.cols, - pixel_width: 0, - pixel_height: 0, - }) { - tracing::error!( - "[ShellProc] 调整大小失败: block_id={}, error={}", - block_id, - e - ); - } else { - tracing::debug!( - "[ShellProc] 调整大小: block_id={}, size={}x{}", - block_id, - size.cols, - size.rows - ); - } - } - - // 处理信号 - if let Some(sig_name) = &input.sig_name { - tracing::debug!( - "[ShellProc] 收到信号: block_id={}, signal={}", - block_id, - sig_name - ); - // TODO: 实现信号发送 - } - } - - tracing::debug!("[ShellProc] 输入处理任务结束: block_id={}", block_id); - }); - } - - /// 获取 Block ID - pub fn block_id(&self) -> &str { - &self.block_id - } - - /// 获取控制器类型 - pub fn controller_type(&self) -> &str { - &self.controller_type - } - - /// 检查进程是否已退出 - pub fn is_exited(&self) -> bool { - self.exited.load(Ordering::SeqCst) - } - - /// 获取退出码 - pub fn get_exit_code(&self) -> i32 { - self.exit_code.load(Ordering::SeqCst) - } - - /// 写入数据到 PTY - pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> { - let mut writer = self.writer.lock(); - writer - .write_all(data) - .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; - writer - .flush() - .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; - Ok(()) - } - - /// 调整 PTY 大小 - pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> { - let master = self.master.lock(); - master - .resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| TerminalError::ResizeFailed(e.to_string()))?; - tracing::debug!( - "[ShellProc] 调整大小: block_id={}, size={}x{}", - self.block_id, - cols, - rows - ); - Ok(()) - } - - /// 优雅终止进程 - /// - /// 发送 SIGTERM 信号并等待进程退出。 - /// - /// # 返回 - /// 进程退出码 - /// - /// _Requirements: 17.3_ - pub async fn terminate(&self) -> i32 { - tracing::info!("[ShellProc] 优雅终止进程: block_id={}", self.block_id); - - // 设置关闭标志 - self.shutdown_flag.store(true, Ordering::SeqCst); - - // TODO: 发送 SIGTERM 信号 - // 目前 portable_pty 不直接支持发送信号,需要通过其他方式实现 - - // 等待一小段时间让进程退出 - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - self.exit_code.load(Ordering::SeqCst) - } - - /// 强制终止进程 - /// - /// 发送 SIGKILL 信号立即终止进程。 - /// - /// _Requirements: 17.3_ - pub async fn kill(&self) { - tracing::info!("[ShellProc] 强制终止进程: block_id={}", self.block_id); - - // 设置关闭标志 - self.shutdown_flag.store(true, Ordering::SeqCst); - - // TODO: 发送 SIGKILL 信号 - } -} - -impl Drop for ShellProc { - fn drop(&mut self) { - // 确保关闭标志被设置 - self.shutdown_flag.store(true, Ordering::SeqCst); - tracing::debug!("[ShellProc] 进程已销毁: block_id={}", self.block_id); - } -} - -#[cfg(test)] -mod tests { - use super::{resolve_default_shell, resolve_working_dir, shell_command_flag}; - - #[test] - fn resolve_working_dir_should_strip_nul_suffix() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let raw = format!("{}\0", temp_dir.path().to_string_lossy()); - - let resolved = resolve_working_dir(Some(&raw)); - - assert_eq!(resolved.as_deref(), Some(temp_dir.path())); - } - - #[test] - fn resolve_working_dir_should_reject_invalid_path() { - let resolved = resolve_working_dir(Some("/path/not-exists\0")); - assert!(resolved.is_none()); - } - - #[test] - fn resolve_default_shell_should_not_be_empty() { - let shell = resolve_default_shell(); - assert!(!shell.trim().is_empty()); - } - - #[test] - fn shell_command_flag_should_match_common_shells() { - assert_eq!(shell_command_flag("cmd.exe"), "/C"); - assert_eq!(shell_command_flag("pwsh"), "-Command"); - assert_eq!(shell_command_flag("/bin/bash"), "-c"); - } -} diff --git a/src-tauri/crates/terminal/src/connections/mod.rs b/src-tauri/crates/terminal/src/connections/mod.rs deleted file mode 100644 index 1177ea3c9..000000000 --- a/src-tauri/crates/terminal/src/connections/mod.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! 连接模块 -//! -//! 提供不同类型的终端连接实现:本地 PTY、SSH、WSL。 -//! -//! ## 模块结构 -//! - `local_pty` - 本地 PTY 连接 -//! - `ssh_connection` - SSH 远程连接 -//! - `ssh_shell_proc` - SSH 远程 Shell 进程 -//! - `wsl_connection` - WSL 连接(仅 Windows) -//! - `connection_router` - 连接类型路由 -//! - `connection_config` - 连接配置持久化 - -pub mod connection_config; -pub mod connection_router; -pub mod local_pty; -pub mod ssh_connection; -pub mod ssh_shell_proc; -pub mod wsl_connection; - -pub use connection_config::{ - ConnectionConfig, ConnectionConfigManager, ConnectionConfigType, ConnectionListEntry, - ConnectionSource, ConnectionsFile, SSHHostEntry, -}; -pub use connection_router::{ConnectionInfo, ConnectionRouter, ConnectionType}; -pub use local_pty::ShellProc; -pub use ssh_connection::{ - build_default_auth_methods, get_default_identity_files, is_local_conn_name, - is_ssh_agent_available, is_ssh_conn_name, ConnKeywords, ConnStatus, ConnectionState, - HostKeyVerification, NoOpAuthCallback, SSHAuthCallback, SSHAuthMethod, SSHConfigEntry, - SSHConfigParser, SSHConn, SSHOpts, DEFAULT_SSH_PORT, MAX_PROXY_JUMP_DEPTH, -}; -pub use ssh_shell_proc::SSHShellProc; -pub use wsl_connection::{ - is_wsl_conn_name, WSLConn, WSLDistro, WSLDistroState, WSLOpts, WSLShellProc, - DEFAULT_WSL_DISTRO, WSL_CONN_PREFIX, -}; diff --git a/src-tauri/crates/terminal/src/connections/ssh_connection.rs b/src-tauri/crates/terminal/src/connections/ssh_connection.rs deleted file mode 100644 index f5f46e1e7..000000000 --- a/src-tauri/crates/terminal/src/connections/ssh_connection.rs +++ /dev/null @@ -1,3019 +0,0 @@ -//! SSH 远程连接模块 -//! -//! 提供 SSH 远程连接功能,支持连接字符串解析、认证、远程 Shell 进程管理。 -//! -//! ## 功能 -//! - SSH 连接字符串解析(user@host:port 格式) -//! - 连接状态管理(init→connecting→connected/error) -//! - 多种认证方式(公钥、密码、键盘交互) -//! - 远程 PTY 创建和数据转发 -//! - SSH 配置文件解析 -//! - known_hosts 验证 -//! -//! ## Requirements -//! - 4.1: 解析连接字符串 -//! - 4.2: 创建远程 PTY 会话 -//! - 4.3-4.6: 多种认证方式 -//! - 4.7: ProxyJump 支持 -//! - 4.8-4.9: known_hosts 验证 -//! - 4.10: 连接断开处理 -//! - 4.11: 终端大小同步 -//! - 4.12: SSH 配置文件解析 -//! - 7.1-7.7: 连接状态管理 - -use std::collections::HashMap; -use std::fmt; -use std::net::TcpStream; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering}; -use std::sync::Arc; - -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use ssh2::{KeyboardInteractivePrompt as SshKeyboardInteractivePrompt, Session}; - -use crate::emit_helper; -use crate::emitter::TerminalEventEmit; -use crate::error::TerminalError; - -/// 默认 SSH 端口 -pub const DEFAULT_SSH_PORT: u16 = 22; - -/// 最大 ProxyJump 深度 -pub const MAX_PROXY_JUMP_DEPTH: usize = 10; - -// ============================================================================ -// SSH 连接选项 -// ============================================================================ - -/// SSH 连接选项 -/// -/// 存储解析后的 SSH 连接参数。 -/// -/// ## 格式支持 -/// - `host` - 仅主机名 -/// - `user@host` - 用户名和主机名 -/// - `user@host:port` - 完整格式 -/// - `host:port` - 主机名和端口 -/// -/// _Requirements: 4.1_ -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SSHOpts { - /// SSH 主机名或 IP 地址 - pub ssh_host: String, - /// SSH 用户名(可选,默认使用当前用户) - pub ssh_user: Option, - /// SSH 端口(可选,默认 22) - pub ssh_port: Option, -} - -impl SSHOpts { - /// 创建新的 SSH 选项 - pub fn new(host: impl Into) -> Self { - Self { - ssh_host: host.into(), - ssh_user: None, - ssh_port: None, - } - } - - /// 设置用户名 - pub fn with_user(mut self, user: impl Into) -> Self { - self.ssh_user = Some(user.into()); - self - } - - /// 设置端口 - pub fn with_port(mut self, port: u16) -> Self { - self.ssh_port = Some(port); - self - } - - /// 从连接字符串解析 SSH 选项 - /// - /// 支持以下格式: - /// - `host` - /// - `user@host` - /// - `user@host:port` - /// - `host:port` - /// - /// # 参数 - /// - `conn_str`: 连接字符串 - /// - /// # 返回 - /// - `Ok(SSHOpts)`: 解析成功 - /// - `Err(TerminalError)`: 解析失败 - /// - /// _Requirements: 4.1_ - pub fn parse(conn_str: &str) -> Result { - let conn_str = conn_str.trim(); - - if conn_str.is_empty() { - return Err(TerminalError::SSHConnectionFailed( - "连接字符串不能为空".to_string(), - )); - } - - // 移除可能的 ssh:// 前缀 - let conn_str = conn_str.strip_prefix("ssh://").unwrap_or(conn_str); - - let (user_part, host_port_part) = if let Some(at_pos) = conn_str.rfind('@') { - let user = &conn_str[..at_pos]; - let host_port = &conn_str[at_pos + 1..]; - (Some(user), host_port) - } else { - (None, conn_str) - }; - - // 解析主机和端口 - let (host, port) = Self::parse_host_port(host_port_part)?; - - // 验证主机名 - if host.is_empty() { - return Err(TerminalError::SSHConnectionFailed( - "主机名不能为空".to_string(), - )); - } - - Ok(Self { - ssh_host: host, - ssh_user: user_part.map(|s| s.to_string()), - ssh_port: port, - }) - } - - /// 解析主机和端口部分 - /// - /// 支持 IPv6 地址格式:`[::1]:22` - fn parse_host_port(host_port: &str) -> Result<(String, Option), TerminalError> { - // 检查是否是 IPv6 地址格式 [host]:port - if host_port.starts_with('[') { - if let Some(bracket_end) = host_port.find(']') { - let host = &host_port[1..bracket_end]; - let remaining = &host_port[bracket_end + 1..]; - - let port = if let Some(port_str) = remaining.strip_prefix(':') { - Some(Self::parse_port(port_str)?) - } else if remaining.is_empty() { - None - } else { - return Err(TerminalError::SSHConnectionFailed(format!( - "无效的 IPv6 地址格式: {host_port}" - ))); - }; - - return Ok((host.to_string(), port)); - } else { - return Err(TerminalError::SSHConnectionFailed(format!( - "无效的 IPv6 地址格式,缺少 ']': {host_port}" - ))); - } - } - - // 普通格式 host:port 或 host - if let Some(colon_pos) = host_port.rfind(':') { - let host = &host_port[..colon_pos]; - let port_str = &host_port[colon_pos + 1..]; - - // 检查是否可能是 IPv6 地址(包含多个冒号) - if host.contains(':') { - // 这是一个没有方括号的 IPv6 地址,整个字符串都是主机名 - return Ok((host_port.to_string(), None)); - } - - let port = Self::parse_port(port_str)?; - Ok((host.to_string(), Some(port))) - } else { - Ok((host_port.to_string(), None)) - } - } - - /// 解析端口号 - fn parse_port(port_str: &str) -> Result { - port_str - .parse::() - .map_err(|_| TerminalError::SSHConnectionFailed(format!("无效的端口号: {port_str}"))) - } - - /// 获取有效端口(如果未指定则返回默认端口) - pub fn effective_port(&self) -> u16 { - self.ssh_port.unwrap_or(DEFAULT_SSH_PORT) - } - - /// 获取有效用户名(如果未指定则返回当前用户) - pub fn effective_user(&self) -> String { - self.ssh_user.clone().unwrap_or_else(|| { - std::env::var("USER") - .or_else(|_| std::env::var("USERNAME")) - .unwrap_or_else(|_| "root".to_string()) - }) - } - - /// 转换为连接字符串 - /// - /// 生成标准化的连接字符串格式。 - /// - /// _Requirements: 4.1 (Round-Trip)_ - pub fn to_connection_string(&self) -> String { - let mut result = String::new(); - - if let Some(ref user) = self.ssh_user { - result.push_str(user); - result.push('@'); - } - - // 检查是否需要用方括号包裹 IPv6 地址 - if self.ssh_host.contains(':') && !self.ssh_host.starts_with('[') { - result.push('['); - result.push_str(&self.ssh_host); - result.push(']'); - } else { - result.push_str(&self.ssh_host); - } - - if let Some(port) = self.ssh_port { - result.push(':'); - result.push_str(&port.to_string()); - } - - result - } -} - -impl fmt::Display for SSHOpts { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.to_connection_string()) - } -} - -impl std::str::FromStr for SSHOpts { - type Err = TerminalError; - - fn from_str(s: &str) -> Result { - Self::parse(s) - } -} - -// ============================================================================ -// 连接状态 -// ============================================================================ - -/// 连接状态枚举 -/// -/// 表示 SSH 连接的当前状态。 -/// -/// _Requirements: 7.2_ -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ConnectionState { - /// 初始状态 - #[default] - Init, - /// 正在连接 - Connecting, - /// 已连接 - Connected, - /// 已断开 - Disconnected, - /// 错误状态 - Error, -} - -impl fmt::Display for ConnectionState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Init => write!(f, "init"), - Self::Connecting => write!(f, "connecting"), - Self::Connected => write!(f, "connected"), - Self::Disconnected => write!(f, "disconnected"), - Self::Error => write!(f, "error"), - } - } -} - -impl ConnectionState { - /// 检查状态转换是否有效 - /// - /// 有效的状态转换: - /// - init → connecting - /// - connecting → connected - /// - connecting → error - /// - connected → disconnected - /// - disconnected → connecting - /// - error → connecting - /// - /// _Requirements: 7.2_ - pub fn can_transition_to(&self, new_state: ConnectionState) -> bool { - matches!( - (self, new_state), - (Self::Init, Self::Connecting) - | (Self::Connecting, Self::Connected) - | (Self::Connecting, Self::Error) - | (Self::Connected, Self::Disconnected) - | (Self::Disconnected, Self::Connecting) - | (Self::Error, Self::Connecting) - ) - } -} - -/// 连接状态详情 -/// -/// 包含连接的完整状态信息,用于前端显示。 -/// -/// _Requirements: 7.1_ -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnStatus { - /// 状态字符串 - pub status: String, - /// 是否已连接 - pub connected: bool, - /// 连接名称 - pub connection: String, - /// 是否曾经连接成功 - pub has_connected: bool, - /// 活跃连接数 - pub active_conn_num: i32, - /// 错误信息 - pub error: Option, - /// wsh 是否启用 - pub wsh_enabled: bool, - /// wsh 错误信息 - pub wsh_error: Option, - /// 不使用 wsh 的原因 - pub no_wsh_reason: Option, - /// wsh 版本 - pub wsh_version: Option, -} - -impl Default for ConnStatus { - fn default() -> Self { - Self { - status: "init".to_string(), - connected: false, - connection: String::new(), - has_connected: false, - active_conn_num: 0, - error: None, - wsh_enabled: false, - wsh_error: None, - no_wsh_reason: None, - wsh_version: None, - } - } -} - -impl ConnStatus { - /// 创建新的连接状态 - pub fn new(connection: impl Into) -> Self { - Self { - connection: connection.into(), - ..Default::default() - } - } - - /// 设置为连接中状态 - pub fn set_connecting(&mut self) { - self.status = "connecting".to_string(); - self.connected = false; - self.error = None; - } - - /// 设置为已连接状态 - pub fn set_connected(&mut self) { - self.status = "connected".to_string(); - self.connected = true; - self.has_connected = true; - self.error = None; - } - - /// 设置为已断开状态 - pub fn set_disconnected(&mut self) { - self.status = "disconnected".to_string(); - self.connected = false; - } - - /// 设置为错误状态 - pub fn set_error(&mut self, error: impl Into) { - self.status = "error".to_string(); - self.connected = false; - self.error = Some(error.into()); - } -} - -// ============================================================================ -// SSH 认证选项 -// ============================================================================ - -/// SSH 认证方式 -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SSHAuthMethod { - /// 公钥认证 - PublicKey { - /// 私钥文件路径 - key_path: PathBuf, - /// 密钥密码(可选) - passphrase: Option, - }, - /// SSH Agent 认证 - Agent, - /// 密码认证 - Password(String), - /// 键盘交互认证 - KeyboardInteractive, -} - -/// SSH 连接关键字配置 -/// -/// 从 SSH 配置文件或用户输入解析的连接参数。 -/// -/// _Requirements: 4.7, 4.12_ -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ConnKeywords { - /// 主机名(HostName) - pub host: Option, - /// 用户名(User) - pub user: Option, - /// 端口(Port) - pub port: Option, - /// 身份文件路径列表(IdentityFile) - pub identity_file: Option>, - /// ProxyJump 配置(跳板机) - pub proxy_jump: Option, - /// ProxyCommand 配置 - pub proxy_command: Option, - /// 是否批处理模式(BatchMode) - pub batch_mode: Option, - /// 公钥认证(PubkeyAuthentication) - pub pubkey_authentication: Option, - /// 密码认证(PasswordAuthentication) - pub password_authentication: Option, - /// 键盘交互认证(KbdInteractiveAuthentication) - pub kbd_interactive_authentication: Option, - /// 首选认证方式(PreferredAuthentications) - pub preferred_authentications: Option>, - /// 严格主机密钥检查(StrictHostKeyChecking) - pub strict_host_key_checking: Option, - /// 用户 known_hosts 文件(UserKnownHostsFile) - pub user_known_hosts_file: Option, - /// 连接超时(ConnectTimeout) - pub connect_timeout: Option, - /// 服务器存活检测最大次数(ServerAliveCountMax) - pub server_alive_count_max: Option, - /// 服务器存活检测间隔(ServerAliveInterval) - pub server_alive_interval: Option, - /// 转发 Agent(ForwardAgent) - pub forward_agent: Option, - /// 压缩(Compression) - pub compression: Option, - /// 本地端口转发(LocalForward) - pub local_forward: Option>, - /// 远程端口转发(RemoteForward) - pub remote_forward: Option>, - /// 动态端口转发(DynamicForward) - pub dynamic_forward: Option>, - /// 请求 TTY(RequestTTY) - pub request_tty: Option, - /// 远程命令(RemoteCommand) - pub remote_command: Option, - /// 发送环境变量(SendEnv) - pub send_env: Option>, - /// 设置环境变量(SetEnv) - pub set_env: Option>, -} - -// ============================================================================ -// 主机密钥验证 -// ============================================================================ - -/// 主机密钥验证结果 -/// -/// _Requirements: 4.8, 4.9_ -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum HostKeyVerification { - /// 验证成功(密钥匹配) - Verified, - /// 主机密钥未知(首次连接) - Unknown { - host: String, - key_type: String, - fingerprint: String, - }, - /// 主机密钥不匹配(可能的中间人攻击) - Mismatch { - host: String, - key_type: String, - fingerprint: String, - }, -} - -/// known_hosts 检查结果 -#[derive(Debug)] -enum KnownHostsCheckResult { - /// 密钥匹配 - Match, - /// 未找到主机 - NotFound, - /// 密钥不匹配 - Mismatch, - /// 检查错误 - Error(String), -} - -/// 键盘交互认证提示处理器 -/// -/// 实现 ssh2 的 KeyboardInteractivePrompt trait -struct KeyboardInteractivePrompt; - -impl SshKeyboardInteractivePrompt for KeyboardInteractivePrompt { - fn prompt<'a>( - &mut self, - _username: &str, - _instructions: &str, - prompts: &[ssh2::Prompt<'a>], - ) -> Vec { - // 对于每个提示,返回空字符串 - // 完整实现需要 UI 回调来获取用户输入 - tracing::debug!("[KeyboardInteractivePrompt] 收到 {} 个提示", prompts.len()); - prompts.iter().map(|_| String::new()).collect() - } -} - -// ============================================================================ -// SSH 连接管理器 -// ============================================================================ - -/// SSH 连接管理器 -/// -/// 管理单个 SSH 连接的生命周期,包括连接、认证、断开和重连。 -/// -/// _Requirements: 4.10, 7.1-7.7_ -pub struct SSHConn { - /// 连接选项 - opts: SSHOpts, - /// 当前状态 - state: RwLock, - /// SSH 会话 - session: RwLock>, - /// TCP 连接(需要保持活跃) - tcp_stream: RwLock>, - /// wsh 是否启用 - wsh_enabled: AtomicBool, - /// 错误信息 - error: RwLock>, - /// 上次连接时间(Unix 时间戳) - last_connect_time: AtomicI64, - /// 活跃连接数 - active_conn_num: AtomicI32, - /// 是否曾经连接成功 - has_connected: AtomicBool, - /// wsh 版本 - wsh_version: RwLock>, - /// wsh 错误 - wsh_error: RwLock>, - /// 不使用 wsh 的原因 - no_wsh_reason: RwLock>, - /// 事件发射器(用于事件广播) - app_handle: RwLock>>, -} - -impl SSHConn { - /// 创建新的 SSH 连接管理器 - pub fn new(opts: SSHOpts) -> Self { - Self { - opts, - state: RwLock::new(ConnectionState::Init), - session: RwLock::new(None), - tcp_stream: RwLock::new(None), - wsh_enabled: AtomicBool::new(false), - error: RwLock::new(None), - last_connect_time: AtomicI64::new(0), - active_conn_num: AtomicI32::new(0), - has_connected: AtomicBool::new(false), - wsh_version: RwLock::new(None), - wsh_error: RwLock::new(None), - no_wsh_reason: RwLock::new(None), - app_handle: RwLock::new(None), - } - } - - /// 创建带有事件发射器的 SSH 连接管理器 - /// - /// 启用事件广播功能。 - pub fn with_app_handle(opts: SSHOpts, app_handle: impl TerminalEventEmit) -> Self { - let conn = Self::new(opts); - *conn.app_handle.write() = Some(Arc::new(app_handle)); - conn - } - - /// 设置事件发射器 - pub fn set_app_handle(&self, app_handle: impl TerminalEventEmit) { - *self.app_handle.write() = Some(Arc::new(app_handle)); - } - - /// 广播连接状态变更事件 - /// - /// _Requirements: 7.3_ - fn broadcast_conn_change(&self) { - use crate::events::{event_names, ConnChangeEvent}; - - if let Some(ref app_handle) = *self.app_handle.read() { - let status = self.derive_conn_status(); - let event = ConnChangeEvent { - connection: self.opts.to_connection_string(), - status, - }; - - if let Err(e) = emit_helper::emit(app_handle.as_ref(), event_names::CONN_CHANGE, &event) - { - tracing::warn!("[SSHConn] 广播连接状态变更事件失败: {}", e); - } - } - } - - /// 从连接字符串创建 - pub fn from_connection_string(conn_str: &str) -> Result { - let opts = SSHOpts::parse(conn_str)?; - Ok(Self::new(opts)) - } - - /// 获取连接选项 - pub fn opts(&self) -> &SSHOpts { - &self.opts - } - - /// 获取当前状态 - pub fn state(&self) -> ConnectionState { - *self.state.read() - } - - /// 设置状态 - fn set_state(&self, new_state: ConnectionState) { - let mut state = self.state.write(); - *state = new_state; - } - - /// 获取错误信息 - pub fn error(&self) -> Option { - self.error.read().clone() - } - - /// 设置错误信息 - fn set_error(&self, error: Option) { - let mut err = self.error.write(); - *err = error; - } - - /// 检查是否已连接 - pub fn is_connected(&self) -> bool { - self.state() == ConnectionState::Connected - } - - /// 获取 SSH 会话 - pub fn get_session(&self) -> Option { - self.session.read().clone() - } - - /// 派生连接状态 - /// - /// 生成用于前端显示的连接状态详情。 - /// - /// _Requirements: 7.1_ - pub fn derive_conn_status(&self) -> ConnStatus { - ConnStatus { - status: self.state().to_string(), - connected: self.is_connected(), - connection: self.opts.to_connection_string(), - has_connected: self.has_connected.load(Ordering::SeqCst), - active_conn_num: self.active_conn_num.load(Ordering::SeqCst), - error: self.error(), - wsh_enabled: self.wsh_enabled.load(Ordering::SeqCst), - wsh_error: self.wsh_error.read().clone(), - no_wsh_reason: self.no_wsh_reason.read().clone(), - wsh_version: self.wsh_version.read().clone(), - } - } - - /// 连接到远程服务器 - /// - /// _Requirements: 4.10, 7.2_ - pub async fn connect(&self, _conn_flags: &ConnKeywords) -> Result<(), TerminalError> { - // 检查状态转换 - let current_state = self.state(); - if !current_state.can_transition_to(ConnectionState::Connecting) { - return Err(TerminalError::SSHConnectionFailed(format!( - "无法从 {current_state} 状态开始连接" - ))); - } - - self.set_state(ConnectionState::Connecting); - self.set_error(None); - self.broadcast_conn_change(); - - // 构建连接地址 - let addr = format!("{}:{}", self.opts.ssh_host, self.opts.effective_port()); - tracing::info!("[SSHConn] 正在连接到 {}", addr); - - // 建立 TCP 连接 - let tcp = match TcpStream::connect(&addr) { - Ok(stream) => stream, - Err(e) => { - let error_msg = format!("TCP 连接失败: {e}"); - tracing::error!("[SSHConn] {}", error_msg); - self.set_state(ConnectionState::Error); - self.set_error(Some(error_msg.clone())); - self.broadcast_conn_change(); - return Err(TerminalError::SSHConnectionFailed(error_msg)); - } - }; - - // 创建 SSH 会话 - let mut session = Session::new().map_err(|e| { - let error_msg = format!("创建 SSH 会话失败: {e}"); - self.set_state(ConnectionState::Error); - self.set_error(Some(error_msg.clone())); - self.broadcast_conn_change(); - TerminalError::SSHConnectionFailed(error_msg) - })?; - - // 设置 TCP 流 - session.set_tcp_stream(tcp.try_clone().map_err(|e| { - let error_msg = format!("克隆 TCP 流失败: {e}"); - self.set_state(ConnectionState::Error); - self.set_error(Some(error_msg.clone())); - self.broadcast_conn_change(); - TerminalError::SSHConnectionFailed(error_msg) - })?); - - // 执行 SSH 握手 - session.handshake().map_err(|e| { - let error_msg = format!("SSH 握手失败: {e}"); - tracing::error!("[SSHConn] {}", error_msg); - self.set_state(ConnectionState::Error); - self.set_error(Some(error_msg.clone())); - self.broadcast_conn_change(); - TerminalError::SSHConnectionFailed(error_msg) - })?; - - tracing::info!("[SSHConn] SSH 握手成功"); - - // 保存会话和 TCP 流 - { - let mut sess = self.session.write(); - *sess = Some(session); - } - { - let mut stream = self.tcp_stream.write(); - *stream = Some(tcp); - } - - // 注意:认证将在 authenticate 方法中完成 - // 这里只完成连接建立 - - Ok(()) - } - - /// 执行认证 - /// - /// _Requirements: 4.3, 4.4, 4.5, 4.6_ - pub async fn authenticate(&self, auth_methods: &[SSHAuthMethod]) -> Result<(), TerminalError> { - let session = self.session.read(); - let session = session - .as_ref() - .ok_or_else(|| TerminalError::SSHConnectionFailed("未建立 SSH 会话".to_string()))?; - - let username = self.opts.effective_user(); - tracing::info!("[SSHConn] 开始认证,用户: {}", username); - - for method in auth_methods { - match self.try_auth(session, &username, method) { - Ok(()) => { - tracing::info!("[SSHConn] 认证成功"); - self.set_state(ConnectionState::Connected); - self.has_connected.store(true, Ordering::SeqCst); - self.last_connect_time - .store(chrono::Utc::now().timestamp(), Ordering::SeqCst); - self.active_conn_num.fetch_add(1, Ordering::SeqCst); - self.broadcast_conn_change(); - return Ok(()); - } - Err(e) => { - tracing::warn!("[SSHConn] 认证方式失败: {:?}, 错误: {}", method, e); - continue; - } - } - } - - let error_msg = "所有认证方式均失败".to_string(); - self.set_state(ConnectionState::Error); - self.set_error(Some(error_msg.clone())); - self.broadcast_conn_change(); - Err(TerminalError::SSHAuthFailed(error_msg)) - } - - /// 尝试单个认证方式 - fn try_auth( - &self, - session: &Session, - username: &str, - method: &SSHAuthMethod, - ) -> Result<(), TerminalError> { - match method { - SSHAuthMethod::PublicKey { - key_path, - passphrase, - } => { - tracing::debug!("[SSHConn] 尝试公钥认证: {:?}", key_path); - session - .userauth_pubkey_file(username, None, key_path, passphrase.as_deref()) - .map_err(|e| TerminalError::SSHAuthFailed(e.to_string()))?; - } - SSHAuthMethod::Agent => { - tracing::debug!("[SSHConn] 尝试 SSH Agent 认证"); - let mut agent = session.agent().map_err(|e| { - TerminalError::SSHAuthFailed(format!("获取 SSH Agent 失败: {e}")) - })?; - agent.connect().map_err(|e| { - TerminalError::SSHAuthFailed(format!("连接 SSH Agent 失败: {e}")) - })?; - agent - .list_identities() - .map_err(|e| TerminalError::SSHAuthFailed(format!("列出身份失败: {e}")))?; - - let identities: Vec<_> = agent - .identities() - .map_err(|e| TerminalError::SSHAuthFailed(format!("获取身份列表失败: {e}")))?; - - for identity in identities { - if agent.userauth(username, &identity).is_ok() { - return Ok(()); - } - } - return Err(TerminalError::SSHAuthFailed( - "SSH Agent 中没有有效的身份".to_string(), - )); - } - SSHAuthMethod::Password(password) => { - tracing::debug!("[SSHConn] 尝试密码认证"); - session - .userauth_password(username, password) - .map_err(|e| TerminalError::SSHAuthFailed(e.to_string()))?; - } - SSHAuthMethod::KeyboardInteractive => { - tracing::debug!("[SSHConn] 尝试键盘交互认证"); - // 键盘交互认证 - 使用空响应尝试 - // 注意:完整的键盘交互认证需要 UI 回调,这里提供基础支持 - session - .userauth_keyboard_interactive(username, &mut KeyboardInteractivePrompt) - .map_err(|e| TerminalError::SSHAuthFailed(e.to_string()))?; - } - } - - if session.authenticated() { - Ok(()) - } else { - Err(TerminalError::SSHAuthFailed("认证未完成".to_string())) - } - } - - /// 验证远程主机密钥 - /// - /// _Requirements: 4.8, 4.9_ - pub fn verify_host_key(&self) -> Result { - let session = self.session.read(); - let session = session - .as_ref() - .ok_or_else(|| TerminalError::SSHConnectionFailed("未建立 SSH 会话".to_string()))?; - - // 获取远程主机密钥 - let (host_key, host_key_type) = session.host_key().ok_or_else(|| { - TerminalError::HostKeyVerificationFailed("无法获取主机密钥".to_string()) - })?; - - let host_key_fingerprint = Self::compute_fingerprint(host_key); - let host_key_type_str = match host_key_type { - ssh2::HostKeyType::Rsa => "ssh-rsa", - ssh2::HostKeyType::Dss => "ssh-dss", - ssh2::HostKeyType::Ecdsa256 => "ecdsa-sha2-nistp256", - ssh2::HostKeyType::Ecdsa384 => "ecdsa-sha2-nistp384", - ssh2::HostKeyType::Ecdsa521 => "ecdsa-sha2-nistp521", - ssh2::HostKeyType::Ed25519 => "ssh-ed25519", - ssh2::HostKeyType::Unknown => "unknown", - }; - - // 检查 known_hosts - let known_hosts_result = self.check_known_hosts(session, &self.opts.ssh_host, host_key); - - match known_hosts_result { - KnownHostsCheckResult::Match => { - tracing::info!("[SSHConn] 主机密钥验证成功"); - Ok(HostKeyVerification::Verified) - } - KnownHostsCheckResult::NotFound => { - tracing::warn!("[SSHConn] 主机密钥未知: {}", self.opts.ssh_host); - Ok(HostKeyVerification::Unknown { - host: self.opts.ssh_host.clone(), - key_type: host_key_type_str.to_string(), - fingerprint: host_key_fingerprint, - }) - } - KnownHostsCheckResult::Mismatch => { - tracing::error!("[SSHConn] 主机密钥不匹配!可能存在中间人攻击"); - Ok(HostKeyVerification::Mismatch { - host: self.opts.ssh_host.clone(), - key_type: host_key_type_str.to_string(), - fingerprint: host_key_fingerprint, - }) - } - KnownHostsCheckResult::Error(e) => Err(TerminalError::HostKeyVerificationFailed(e)), - } - } - - /// 计算密钥指纹 - fn compute_fingerprint(key: &[u8]) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(key); - let result = hasher.finalize(); - - // 转换为 Base64 格式的指纹 - use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; - format!("SHA256:{}", BASE64.encode(result)) - } - - /// 检查 known_hosts 文件 - fn check_known_hosts( - &self, - session: &Session, - host: &str, - host_key: &[u8], - ) -> KnownHostsCheckResult { - let mut known_hosts = match session.known_hosts() { - Ok(kh) => kh, - Err(e) => return KnownHostsCheckResult::Error(e.to_string()), - }; - - // 读取 known_hosts 文件 - if let Some(known_hosts_path) = Self::get_known_hosts_path() { - if known_hosts_path.exists() { - if let Err(e) = - known_hosts.read_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH) - { - tracing::warn!("[SSHConn] 读取 known_hosts 失败: {}", e); - // 继续执行,视为未找到 - } - } - } - - // 检查主机密钥 - let port = self.opts.effective_port(); - match known_hosts.check_port(host, port, host_key) { - ssh2::CheckResult::Match => KnownHostsCheckResult::Match, - ssh2::CheckResult::NotFound => KnownHostsCheckResult::NotFound, - ssh2::CheckResult::Mismatch => KnownHostsCheckResult::Mismatch, - ssh2::CheckResult::Failure => { - KnownHostsCheckResult::Error("known_hosts 检查失败".to_string()) - } - } - } - - /// 获取 known_hosts 文件路径 - fn get_known_hosts_path() -> Option { - dirs::home_dir().map(|home| home.join(".ssh").join("known_hosts")) - } - - /// 添加主机密钥到 known_hosts - /// - /// _Requirements: 4.8_ - pub fn add_host_to_known_hosts(&self) -> Result<(), TerminalError> { - let session = self.session.read(); - let session = session - .as_ref() - .ok_or_else(|| TerminalError::SSHConnectionFailed("未建立 SSH 会话".to_string()))?; - - let (host_key, host_key_type) = session.host_key().ok_or_else(|| { - TerminalError::HostKeyVerificationFailed("无法获取主机密钥".to_string()) - })?; - - let mut known_hosts = session.known_hosts().map_err(|e| { - TerminalError::HostKeyVerificationFailed(format!("获取 known_hosts 失败: {e}")) - })?; - - // 读取现有的 known_hosts 文件 - let known_hosts_path = Self::get_known_hosts_path().ok_or_else(|| { - TerminalError::HostKeyVerificationFailed("无法获取 known_hosts 路径".to_string()) - })?; - - if known_hosts_path.exists() { - let _ = known_hosts.read_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH); - } - - // 确定密钥类型 - let key_type = match host_key_type { - ssh2::HostKeyType::Rsa => ssh2::KnownHostKeyFormat::SshRsa, - ssh2::HostKeyType::Dss => ssh2::KnownHostKeyFormat::SshDss, - ssh2::HostKeyType::Ecdsa256 - | ssh2::HostKeyType::Ecdsa384 - | ssh2::HostKeyType::Ecdsa521 => ssh2::KnownHostKeyFormat::SshRsa, // 使用 RSA 作为后备 - ssh2::HostKeyType::Ed25519 => ssh2::KnownHostKeyFormat::SshRsa, // 使用 RSA 作为后备 - ssh2::HostKeyType::Unknown => { - return Err(TerminalError::HostKeyVerificationFailed( - "未知的密钥类型".to_string(), - )); - } - }; - - // 添加主机密钥 - let host_with_port = if self.opts.ssh_port.is_some() && self.opts.effective_port() != 22 { - format!("[{}]:{}", self.opts.ssh_host, self.opts.effective_port()) - } else { - self.opts.ssh_host.clone() - }; - - known_hosts - .add(&host_with_port, host_key, "", key_type) - .map_err(|e| { - TerminalError::HostKeyVerificationFailed(format!("添加主机密钥失败: {e}")) - })?; - - // 确保 .ssh 目录存在 - if let Some(ssh_dir) = known_hosts_path.parent() { - if !ssh_dir.exists() { - std::fs::create_dir_all(ssh_dir).map_err(|e| { - TerminalError::HostKeyVerificationFailed(format!("创建 .ssh 目录失败: {e}")) - })?; - } - } - - // 写入 known_hosts 文件 - known_hosts - .write_file(&known_hosts_path, ssh2::KnownHostFileKind::OpenSSH) - .map_err(|e| { - TerminalError::HostKeyVerificationFailed(format!("写入 known_hosts 失败: {e}")) - })?; - - tracing::info!("[SSHConn] 已添加主机密钥到 known_hosts: {}", host_with_port); - Ok(()) - } - - /// 断开连接 - /// - /// _Requirements: 4.10_ - pub async fn close(&self) -> Result<(), TerminalError> { - tracing::info!("[SSHConn] 断开连接: {}", self.opts); - - // 断开 SSH 会话 - { - let mut session = self.session.write(); - if let Some(sess) = session.take() { - let _ = sess.disconnect(None, "Connection closed", None); - } - } - - // 关闭 TCP 连接 - { - let mut stream = self.tcp_stream.write(); - *stream = None; - } - - self.set_state(ConnectionState::Disconnected); - self.active_conn_num.fetch_sub(1, Ordering::SeqCst); - self.broadcast_conn_change(); - - Ok(()) - } - - /// 重新连接 - /// - /// _Requirements: 7.5_ - pub async fn reconnect(&self, conn_flags: &ConnKeywords) -> Result<(), TerminalError> { - tracing::info!("[SSHConn] 重新连接: {}", self.opts); - - // 先断开现有连接 - let _ = self.close().await; - - // 重置状态 - self.set_state(ConnectionState::Init); - self.broadcast_conn_change(); - - // 重新连接 - self.connect(conn_flags).await - } - - /// 使用回调进行认证 - /// - /// 支持交互式认证,通过回调获取用户输入。 - /// - /// _Requirements: 4.3, 4.4, 4.5, 4.6_ - pub async fn authenticate_with_callback( - &self, - auth_methods: &[SSHAuthMethod], - callback: &C, - ) -> Result<(), TerminalError> { - let session = self.session.read(); - let session = session - .as_ref() - .ok_or_else(|| TerminalError::SSHConnectionFailed("未建立 SSH 会话".to_string()))?; - - let username = self.opts.effective_user(); - tracing::info!("[SSHConn] 开始认证(带回调),用户: {}", username); - - for method in auth_methods { - match self.try_auth_with_callback(session, &username, method, callback) { - Ok(()) => { - tracing::info!("[SSHConn] 认证成功"); - // 释放读锁后再修改状态 - self.set_state(ConnectionState::Connected); - self.has_connected.store(true, Ordering::SeqCst); - self.last_connect_time - .store(chrono::Utc::now().timestamp(), Ordering::SeqCst); - self.active_conn_num.fetch_add(1, Ordering::SeqCst); - self.broadcast_conn_change(); - return Ok(()); - } - Err(e) => { - tracing::warn!("[SSHConn] 认证方式失败: {:?}, 错误: {}", method, e); - continue; - } - } - } - - let error_msg = "所有认证方式均失败".to_string(); - // 释放读锁后再修改状态 - self.set_state(ConnectionState::Error); - self.set_error(Some(error_msg.clone())); - self.broadcast_conn_change(); - Err(TerminalError::SSHAuthFailed(error_msg)) - } - - /// 尝试单个认证方式(带回调) - fn try_auth_with_callback( - &self, - session: &Session, - username: &str, - method: &SSHAuthMethod, - callback: &C, - ) -> Result<(), TerminalError> { - match method { - SSHAuthMethod::PublicKey { - key_path, - passphrase, - } => { - tracing::debug!("[SSHConn] 尝试公钥认证: {:?}", key_path); - - // 首先尝试不带密码 - let result = - session.userauth_pubkey_file(username, None, key_path, passphrase.as_deref()); - - match result { - Ok(()) if session.authenticated() => return Ok(()), - Err(e) if e.code() == ssh2::ErrorCode::Session(-16) => { - // 密钥需要密码,通过回调请求 - tracing::debug!("[SSHConn] 密钥需要密码: {:?}", key_path); - if let Some(pass) = callback.request_passphrase(key_path) { - session - .userauth_pubkey_file(username, None, key_path, Some(&pass)) - .map_err(|e| TerminalError::SSHAuthFailed(e.to_string()))?; - } else { - return Err(TerminalError::SSHAuthFailed( - "用户取消输入密钥密码".to_string(), - )); - } - } - Err(e) => return Err(TerminalError::SSHAuthFailed(e.to_string())), - Ok(()) => {} - } - } - SSHAuthMethod::Agent => { - tracing::debug!("[SSHConn] 尝试 SSH Agent 认证"); - let mut agent = session.agent().map_err(|e| { - TerminalError::SSHAuthFailed(format!("获取 SSH Agent 失败: {e}")) - })?; - agent.connect().map_err(|e| { - TerminalError::SSHAuthFailed(format!("连接 SSH Agent 失败: {e}")) - })?; - agent - .list_identities() - .map_err(|e| TerminalError::SSHAuthFailed(format!("列出身份失败: {e}")))?; - - let identities: Vec<_> = agent - .identities() - .map_err(|e| TerminalError::SSHAuthFailed(format!("获取身份列表失败: {e}")))?; - - for identity in identities { - if agent.userauth(username, &identity).is_ok() && session.authenticated() { - return Ok(()); - } - } - return Err(TerminalError::SSHAuthFailed( - "SSH Agent 中没有有效的身份".to_string(), - )); - } - SSHAuthMethod::Password(password) => { - tracing::debug!("[SSHConn] 尝试密码认证"); - - let pwd = if password.is_empty() { - // 通过回调请求密码 - callback - .request_password(username, &self.opts.ssh_host) - .ok_or_else(|| { - TerminalError::SSHAuthFailed("用户取消输入密码".to_string()) - })? - } else { - password.clone() - }; - - session - .userauth_password(username, &pwd) - .map_err(|e| TerminalError::SSHAuthFailed(e.to_string()))?; - } - SSHAuthMethod::KeyboardInteractive => { - tracing::debug!("[SSHConn] 尝试键盘交互认证"); - - // 创建带回调的键盘交互处理器 - let mut handler = CallbackKeyboardInteractivePrompt { - callback, - username: username.to_string(), - }; - - session - .userauth_keyboard_interactive(username, &mut handler) - .map_err(|e| TerminalError::SSHAuthFailed(e.to_string()))?; - } - } - - if session.authenticated() { - Ok(()) - } else { - Err(TerminalError::SSHAuthFailed("认证未完成".to_string())) - } - } - - /// 完整的连接和认证流程 - /// - /// 包括连接、主机密钥验证和认证。 - /// - /// _Requirements: 4.3-4.9_ - pub async fn connect_and_authenticate( - &self, - conn_flags: &ConnKeywords, - auth_methods: &[SSHAuthMethod], - callback: &C, - ) -> Result<(), TerminalError> { - // 1. 建立连接 - self.connect(conn_flags).await?; - - // 2. 验证主机密钥 - match self.verify_host_key()? { - HostKeyVerification::Verified => { - tracing::info!("[SSHConn] 主机密钥已验证"); - } - HostKeyVerification::Unknown { - host, - key_type, - fingerprint, - } => { - tracing::warn!("[SSHConn] 主机密钥未知: {}", host); - if callback.confirm_host_key(&host, &key_type, &fingerprint) { - self.add_host_to_known_hosts()?; - } else { - let _ = self.close().await; - return Err(TerminalError::HostKeyVerificationFailed( - "用户拒绝接受主机密钥".to_string(), - )); - } - } - HostKeyVerification::Mismatch { - host, - key_type, - fingerprint, - } => { - tracing::error!("[SSHConn] 主机密钥不匹配: {}", host); - if !callback.warn_host_key_mismatch(&host, &key_type, &fingerprint) { - let _ = self.close().await; - return Err(TerminalError::HostKeyVerificationFailed( - "主机密钥不匹配,可能存在中间人攻击".to_string(), - )); - } - // 用户选择继续,更新 known_hosts - self.add_host_to_known_hosts()?; - } - } - - // 3. 执行认证 - self.authenticate_with_callback(auth_methods, callback) - .await - } -} - -/// 带回调的键盘交互认证处理器 -struct CallbackKeyboardInteractivePrompt<'a, C: SSHAuthCallback> { - callback: &'a C, - username: String, -} - -impl<'a, C: SSHAuthCallback> SshKeyboardInteractivePrompt - for CallbackKeyboardInteractivePrompt<'a, C> -{ - fn prompt<'b>( - &mut self, - _username: &str, - instructions: &str, - prompts: &[ssh2::Prompt<'b>], - ) -> Vec { - let prompt_data: Vec<(String, bool)> = prompts - .iter() - .map(|p| (p.text.to_string(), p.echo)) - .collect(); - - self.callback - .handle_keyboard_interactive(&self.username, instructions, &prompt_data) - } -} - -// ============================================================================ -// SSH 配置文件解析 -// ============================================================================ - -/// SSH 配置条目 -/// -/// 存储单个 Host 块的配置信息。 -#[derive(Debug, Clone, Default)] -pub struct SSHConfigEntry { - /// Host 模式列表(一个 Host 行可以有多个模式) - pub patterns: Vec, - /// 配置关键字 - pub keywords: ConnKeywords, -} - -/// SSH 配置文件解析器 -/// -/// 解析 ~/.ssh/config 文件,支持: -/// - 多种配置选项(HostName, User, Port, IdentityFile 等) -/// - ProxyJump 跳板机配置 -/// - 通配符模式匹配 -/// - 配置合并(主机特定 + 通配符) -/// -/// _Requirements: 4.7, 4.12_ -pub struct SSHConfigParser; - -impl SSHConfigParser { - /// 获取默认 SSH 配置文件路径 - pub fn default_config_path() -> Option { - dirs::home_dir().map(|home| home.join(".ssh").join("config")) - } - - /// 解析 SSH 配置文件 - /// - /// _Requirements: 4.12_ - pub fn parse_config(path: &PathBuf) -> Result, TerminalError> { - let content = std::fs::read_to_string(path).map_err(|e| { - TerminalError::SSHConnectionFailed(format!("读取 SSH 配置文件失败: {e}")) - })?; - - Self::parse_config_content(&content) - } - - /// 解析配置内容 - pub fn parse_config_content( - content: &str, - ) -> Result, TerminalError> { - let entries = Self::parse_config_entries(content)?; - - // 将条目转换为 HashMap,每个模式一个条目 - let mut hosts: HashMap = HashMap::new(); - for entry in entries { - for pattern in entry.patterns { - hosts.insert(pattern, entry.keywords.clone()); - } - } - - Ok(hosts) - } - - /// 解析配置文件为条目列表 - /// - /// 保留原始顺序,用于正确的配置合并。 - pub fn parse_config_entries(content: &str) -> Result, TerminalError> { - let mut entries: Vec = Vec::new(); - let mut current_entry: Option = None; - - for line in content.lines() { - let line = line.trim(); - - // 跳过空行和注释 - if line.is_empty() || line.starts_with('#') { - continue; - } - - // 解析键值对 - let (key, value) = Self::parse_line(line)?; - - if key.is_empty() { - continue; - } - - if key == "host" { - // 保存之前的条目 - if let Some(entry) = current_entry.take() { - entries.push(entry); - } - - // 解析 Host 模式(可以有多个,用空格分隔) - let patterns: Vec = - value.split_whitespace().map(|s| s.to_string()).collect(); - - current_entry = Some(SSHConfigEntry { - patterns, - keywords: ConnKeywords::default(), - }); - } else if key == "match" { - // Match 块暂不支持,跳过 - if let Some(entry) = current_entry.take() { - entries.push(entry); - } - current_entry = None; - } else if let Some(ref mut entry) = current_entry { - Self::apply_keyword(&mut entry.keywords, &key, &value); - } - } - - // 保存最后一个条目 - if let Some(entry) = current_entry { - entries.push(entry); - } - - Ok(entries) - } - - /// 解析单行配置 - /// - /// 返回 (key, value) 元组,key 已转换为小写。 - fn parse_line(line: &str) -> Result<(String, String), TerminalError> { - // SSH 配置文件支持两种格式: - // 1. Key Value (空格分隔,优先) - // 2. Key=Value (等号分隔,仅当没有空格时) - - let line = line.trim(); - - // 首先尝试用空格分割(这是 SSH 配置的主要格式) - if let Some(space_pos) = line.find(char::is_whitespace) { - let key = line[..space_pos].trim().to_lowercase(); - let value = line[space_pos..].trim().to_string(); - - // 如果 key 不包含 =,则使用空格分割的结果 - if !key.contains('=') { - return Ok((key, Self::unquote(&value))); - } - } - - // 如果没有空格,或者 key 包含 =,尝试用 = 分割 - if let Some(eq_pos) = line.find('=') { - let key = line[..eq_pos].trim().to_lowercase(); - let value = line[eq_pos + 1..].trim().to_string(); - return Ok((key, Self::unquote(&value))); - } - - // 没有分隔符,返回空 - Ok((String::new(), String::new())) - } - - /// 移除值两端的引号 - fn unquote(value: &str) -> String { - let value = value.trim(); - if ((value.starts_with('"') && value.ends_with('"')) - || (value.starts_with('\'') && value.ends_with('\''))) - && value.len() >= 2 - { - return value[1..value.len() - 1].to_string(); - } - value.to_string() - } - - /// 应用配置关键字 - fn apply_keyword(keywords: &mut ConnKeywords, key: &str, value: &str) { - match key { - "hostname" => keywords.host = Some(value.to_string()), - "user" => keywords.user = Some(value.to_string()), - "port" => keywords.port = value.parse().ok(), - "identityfile" => { - let path = Self::expand_path(value); - if let Some(ref mut files) = keywords.identity_file { - files.push(path); - } else { - keywords.identity_file = Some(vec![path]); - } - } - "proxyjump" => keywords.proxy_jump = Some(value.to_string()), - "proxycommand" => keywords.proxy_command = Some(value.to_string()), - "batchmode" => keywords.batch_mode = Some(Self::parse_bool(value)), - "pubkeyauthentication" => { - keywords.pubkey_authentication = Some(Self::parse_bool(value)) - } - "passwordauthentication" => { - keywords.password_authentication = Some(Self::parse_bool(value)) - } - "kbdinteractiveauthentication" | "challengeresponseauthentication" => { - keywords.kbd_interactive_authentication = Some(Self::parse_bool(value)) - } - "preferredauthentications" => { - keywords.preferred_authentications = - Some(value.split(',').map(|s| s.trim().to_string()).collect()); - } - "stricthostkeychecking" => { - keywords.strict_host_key_checking = Some(value.to_string()); - } - "userknownhostsfile" => { - keywords.user_known_hosts_file = Some(Self::expand_path(value)); - } - "connecttimeout" => { - keywords.connect_timeout = value.parse().ok(); - } - "serveralivecountmax" => { - keywords.server_alive_count_max = value.parse().ok(); - } - "serveraliveinterval" => { - keywords.server_alive_interval = value.parse().ok(); - } - "forwardagent" => { - keywords.forward_agent = Some(Self::parse_bool(value)); - } - "compression" => { - keywords.compression = Some(Self::parse_bool(value)); - } - "localforward" => { - if let Some(ref mut forwards) = keywords.local_forward { - forwards.push(value.to_string()); - } else { - keywords.local_forward = Some(vec![value.to_string()]); - } - } - "remoteforward" => { - if let Some(ref mut forwards) = keywords.remote_forward { - forwards.push(value.to_string()); - } else { - keywords.remote_forward = Some(vec![value.to_string()]); - } - } - "dynamicforward" => { - if let Some(ref mut forwards) = keywords.dynamic_forward { - forwards.push(value.to_string()); - } else { - keywords.dynamic_forward = Some(vec![value.to_string()]); - } - } - "requesttty" => { - keywords.request_tty = Some(value.to_string()); - } - "remotecommand" => { - keywords.remote_command = Some(value.to_string()); - } - "sendenv" => { - if let Some(ref mut envs) = keywords.send_env { - envs.push(value.to_string()); - } else { - keywords.send_env = Some(vec![value.to_string()]); - } - } - "setenv" => { - if let Some(ref mut envs) = keywords.set_env { - envs.push(value.to_string()); - } else { - keywords.set_env = Some(vec![value.to_string()]); - } - } - _ => { - // 未知选项,忽略 - tracing::trace!("[SSHConfigParser] 忽略未知选项: {} = {}", key, value); - } - } - } - - /// 解析布尔值 - fn parse_bool(value: &str) -> bool { - matches!(value.to_lowercase().as_str(), "yes" | "true" | "1") - } - - /// 展开路径中的 ~ 符号 - pub fn expand_path(path: &str) -> String { - if path.starts_with("~/") { - if let Some(home) = dirs::home_dir() { - return home.join(&path[2..]).to_string_lossy().to_string(); - } - } else if path == "~" { - if let Some(home) = dirs::home_dir() { - return home.to_string_lossy().to_string(); - } - } - path.to_string() - } - - /// 获取主机配置 - /// - /// 按照 SSH 配置文件的语义,合并所有匹配的配置块。 - /// 第一个匹配的值优先(first match wins)。 - /// - /// _Requirements: 4.12_ - pub fn get_host_config(host: &str) -> Option { - let config_path = Self::default_config_path()?; - if !config_path.exists() { - return None; - } - - Self::get_host_config_from_file(&config_path, host).ok() - } - - /// 从指定文件获取主机配置 - pub fn get_host_config_from_file( - path: &PathBuf, - host: &str, - ) -> Result { - let content = std::fs::read_to_string(path).map_err(|e| { - TerminalError::SSHConnectionFailed(format!("读取 SSH 配置文件失败: {e}")) - })?; - - Self::get_host_config_from_content(&content, host) - } - - /// 从配置内容获取主机配置 - /// - /// 按照 SSH 配置文件的语义合并配置: - /// 1. 按顺序遍历所有 Host 块 - /// 2. 如果模式匹配,合并配置(first match wins) - /// 3. 通配符 * 匹配所有主机 - pub fn get_host_config_from_content( - content: &str, - host: &str, - ) -> Result { - let entries = Self::parse_config_entries(content)?; - let mut merged = ConnKeywords::default(); - - for entry in entries { - // 检查是否有任何模式匹配 - let matches = entry.patterns.iter().any(|p| Self::match_pattern(p, host)); - - if matches { - // 合并配置(first match wins) - Self::merge_keywords(&mut merged, &entry.keywords); - } - } - - Ok(merged) - } - - /// 合并配置关键字 - /// - /// 使用 "first match wins" 语义:只有当目标字段为 None 时才设置。 - fn merge_keywords(target: &mut ConnKeywords, source: &ConnKeywords) { - if target.host.is_none() { - target.host = source.host.clone(); - } - if target.user.is_none() { - target.user = source.user.clone(); - } - if target.port.is_none() { - target.port = source.port; - } - if target.identity_file.is_none() { - target.identity_file = source.identity_file.clone(); - } else if let Some(ref source_files) = source.identity_file { - // IdentityFile 是累加的 - if let Some(ref mut target_files) = target.identity_file { - for file in source_files { - if !target_files.contains(file) { - target_files.push(file.clone()); - } - } - } - } - if target.proxy_jump.is_none() { - target.proxy_jump = source.proxy_jump.clone(); - } - if target.proxy_command.is_none() { - target.proxy_command = source.proxy_command.clone(); - } - if target.batch_mode.is_none() { - target.batch_mode = source.batch_mode; - } - if target.pubkey_authentication.is_none() { - target.pubkey_authentication = source.pubkey_authentication; - } - if target.password_authentication.is_none() { - target.password_authentication = source.password_authentication; - } - if target.kbd_interactive_authentication.is_none() { - target.kbd_interactive_authentication = source.kbd_interactive_authentication; - } - if target.preferred_authentications.is_none() { - target.preferred_authentications = source.preferred_authentications.clone(); - } - if target.strict_host_key_checking.is_none() { - target.strict_host_key_checking = source.strict_host_key_checking.clone(); - } - if target.user_known_hosts_file.is_none() { - target.user_known_hosts_file = source.user_known_hosts_file.clone(); - } - if target.connect_timeout.is_none() { - target.connect_timeout = source.connect_timeout; - } - if target.server_alive_count_max.is_none() { - target.server_alive_count_max = source.server_alive_count_max; - } - if target.server_alive_interval.is_none() { - target.server_alive_interval = source.server_alive_interval; - } - if target.forward_agent.is_none() { - target.forward_agent = source.forward_agent; - } - if target.compression.is_none() { - target.compression = source.compression; - } - if target.local_forward.is_none() { - target.local_forward = source.local_forward.clone(); - } - if target.remote_forward.is_none() { - target.remote_forward = source.remote_forward.clone(); - } - if target.dynamic_forward.is_none() { - target.dynamic_forward = source.dynamic_forward.clone(); - } - if target.request_tty.is_none() { - target.request_tty = source.request_tty.clone(); - } - if target.remote_command.is_none() { - target.remote_command = source.remote_command.clone(); - } - if target.send_env.is_none() { - target.send_env = source.send_env.clone(); - } - if target.set_env.is_none() { - target.set_env = source.set_env.clone(); - } - } - - /// 通配符模式匹配 - /// - /// 支持以下模式: - /// - `*` - 匹配所有 - /// - `*.example.com` - 后缀匹配 - /// - `server*` - 前缀匹配 - /// - `!pattern` - 否定匹配(排除) - /// - `?` - 匹配单个字符 - pub fn match_pattern(pattern: &str, host: &str) -> bool { - // 处理否定模式 - if let Some(negated) = pattern.strip_prefix('!') { - return !Self::match_pattern_inner(negated, host); - } - - Self::match_pattern_inner(pattern, host) - } - - /// 内部模式匹配实现 - fn match_pattern_inner(pattern: &str, host: &str) -> bool { - // 精确匹配 - if pattern == host { - return true; - } - - // 全匹配通配符 - if pattern == "*" { - return true; - } - - // 使用简单的通配符匹配 - Self::glob_match(pattern, host) - } - - /// 简单的 glob 模式匹配 - /// - /// 支持 * 和 ? 通配符。 - fn glob_match(pattern: &str, text: &str) -> bool { - let pattern_chars: Vec = pattern.chars().collect(); - let text_chars: Vec = text.chars().collect(); - - Self::glob_match_recursive(&pattern_chars, &text_chars, 0, 0) - } - - /// 递归 glob 匹配 - fn glob_match_recursive(pattern: &[char], text: &[char], p_idx: usize, t_idx: usize) -> bool { - // 如果模式和文本都已处理完,匹配成功 - if p_idx == pattern.len() && t_idx == text.len() { - return true; - } - - // 如果模式已处理完但文本还有剩余,匹配失败 - if p_idx == pattern.len() { - return false; - } - - let p_char = pattern[p_idx]; - - match p_char { - '*' => { - // * 可以匹配零个或多个字符 - // 尝试匹配零个字符 - if Self::glob_match_recursive(pattern, text, p_idx + 1, t_idx) { - return true; - } - // 尝试匹配一个或多个字符 - if t_idx < text.len() { - return Self::glob_match_recursive(pattern, text, p_idx, t_idx + 1); - } - false - } - '?' => { - // ? 匹配单个字符 - if t_idx < text.len() { - Self::glob_match_recursive(pattern, text, p_idx + 1, t_idx + 1) - } else { - false - } - } - _ => { - // 普通字符,必须精确匹配 - if t_idx < text.len() && p_char == text[t_idx] { - Self::glob_match_recursive(pattern, text, p_idx + 1, t_idx + 1) - } else { - false - } - } - } - } - - /// 解析 ProxyJump 链 - /// - /// ProxyJump 可以是逗号分隔的跳板机列表。 - /// - /// _Requirements: 4.7_ - pub fn parse_proxy_jump_chain(proxy_jump: &str) -> Vec { - if proxy_jump.is_empty() || proxy_jump.to_lowercase() == "none" { - return Vec::new(); - } - - proxy_jump - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - } - - /// 解析 ProxyJump 主机 - /// - /// 支持格式: - /// - `host` - /// - `user@host` - /// - `user@host:port` - /// - /// _Requirements: 4.7_ - pub fn parse_proxy_jump_host(jump_host: &str) -> Result { - SSHOpts::parse(jump_host) - } - - /// 获取完整的 ProxyJump 链配置 - /// - /// 递归解析每个跳板机的配置。 - /// - /// _Requirements: 4.7_ - pub fn resolve_proxy_jump_chain( - proxy_jump: &str, - depth: usize, - ) -> Result, TerminalError> { - if depth > MAX_PROXY_JUMP_DEPTH { - return Err(TerminalError::SSHConnectionFailed(format!( - "ProxyJump 链深度超过最大限制 {MAX_PROXY_JUMP_DEPTH}" - ))); - } - - let jump_hosts = Self::parse_proxy_jump_chain(proxy_jump); - let mut chain = Vec::new(); - - for jump_host in jump_hosts { - let opts = Self::parse_proxy_jump_host(&jump_host)?; - - // 获取跳板机的配置 - let config = Self::get_host_config(&opts.ssh_host).unwrap_or_default(); - - // 如果跳板机也有 ProxyJump,递归解析 - if let Some(ref nested_jump) = config.proxy_jump { - if nested_jump.to_lowercase() != "none" { - let nested_chain = Self::resolve_proxy_jump_chain(nested_jump, depth + 1)?; - chain.extend(nested_chain); - } - } - - chain.push((opts, config)); - } - - Ok(chain) - } - - /// 将配置转换为连接字符串 - /// - /// 用于 Round-Trip 测试。 - pub fn config_to_connection_string(config: &ConnKeywords, alias: &str) -> String { - let mut result = String::new(); - - // 使用 HostName 或别名作为主机 - let host = config.host.as_deref().unwrap_or(alias); - - // 添加用户 - if let Some(ref user) = config.user { - result.push_str(user); - result.push('@'); - } - - result.push_str(host); - - // 添加端口 - if let Some(port) = config.port { - result.push(':'); - result.push_str(&port.to_string()); - } - - result - } -} - -// ============================================================================ -// 辅助函数 -// ============================================================================ - -/// 获取默认身份文件列表 -pub fn get_default_identity_files() -> Vec { - let mut files = Vec::new(); - - if let Some(home) = dirs::home_dir() { - let ssh_dir = home.join(".ssh"); - - // 按优先级排序的默认密钥文件 - let default_keys = ["id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"]; - - for key in default_keys { - let key_path = ssh_dir.join(key); - if key_path.exists() { - files.push(key_path); - } - } - } - - files -} - -/// 构建默认认证方式列表 -/// -/// 根据配置和可用资源构建认证方式列表。 -/// 优先级:SSH Agent > 公钥 > 键盘交互 > 密码 -/// -/// _Requirements: 4.3, 4.4, 4.5, 4.6_ -pub fn build_default_auth_methods( - conn_keywords: &ConnKeywords, - password: Option, -) -> Vec { - let mut methods = Vec::new(); - - // 检查是否禁用了某些认证方式 - let pubkey_enabled = conn_keywords.pubkey_authentication.unwrap_or(true); - let password_enabled = conn_keywords.password_authentication.unwrap_or(true); - let kbd_enabled = conn_keywords.kbd_interactive_authentication.unwrap_or(true); - - // 如果指定了首选认证方式,按指定顺序添加 - if let Some(ref preferred) = conn_keywords.preferred_authentications { - for auth in preferred { - match auth.as_str() { - "publickey" if pubkey_enabled => { - // 先尝试 SSH Agent - if is_ssh_agent_available() { - methods.push(SSHAuthMethod::Agent); - } - // 然后尝试身份文件 - add_identity_file_methods(&mut methods, conn_keywords); - } - "keyboard-interactive" if kbd_enabled => { - methods.push(SSHAuthMethod::KeyboardInteractive); - } - "password" if password_enabled => { - if let Some(ref pwd) = password { - methods.push(SSHAuthMethod::Password(pwd.clone())); - } - } - _ => {} - } - } - return methods; - } - - // 默认顺序:Agent > 公钥文件 > 键盘交互 > 密码 - - // 1. SSH Agent(如果可用) - if pubkey_enabled && is_ssh_agent_available() { - methods.push(SSHAuthMethod::Agent); - } - - // 2. 公钥文件 - if pubkey_enabled { - add_identity_file_methods(&mut methods, conn_keywords); - } - - // 3. 键盘交互认证 - if kbd_enabled { - methods.push(SSHAuthMethod::KeyboardInteractive); - } - - // 4. 密码认证 - if password_enabled { - if let Some(pwd) = password { - methods.push(SSHAuthMethod::Password(pwd)); - } - } - - methods -} - -/// 添加身份文件认证方式 -fn add_identity_file_methods(methods: &mut Vec, conn_keywords: &ConnKeywords) { - // 优先使用配置中指定的身份文件 - if let Some(ref identity_files) = conn_keywords.identity_file { - for file in identity_files { - let path = PathBuf::from(file); - if path.exists() { - methods.push(SSHAuthMethod::PublicKey { - key_path: path, - passphrase: None, - }); - } - } - } - - // 然后添加默认身份文件 - for key_path in get_default_identity_files() { - // 避免重复添加 - let already_added = methods - .iter() - .any(|m| matches!(m, SSHAuthMethod::PublicKey { key_path: p, .. } if p == &key_path)); - - if !already_added { - methods.push(SSHAuthMethod::PublicKey { - key_path, - passphrase: None, - }); - } - } -} - -/// 检查 SSH Agent 是否可用 -/// -/// _Requirements: 4.6_ -pub fn is_ssh_agent_available() -> bool { - std::env::var("SSH_AUTH_SOCK").is_ok() -} - -/// SSH 认证回调 trait -/// -/// 用于在认证过程中与用户交互。 -/// -/// _Requirements: 4.5, 4.6_ -pub trait SSHAuthCallback: Send + Sync { - /// 请求密钥密码 - /// - /// 当私钥需要密码时调用。 - fn request_passphrase(&self, key_path: &Path) -> Option; - - /// 请求密码 - /// - /// 当需要密码认证时调用。 - fn request_password(&self, username: &str, host: &str) -> Option; - - /// 处理键盘交互提示 - /// - /// 返回每个提示的响应。 - fn handle_keyboard_interactive( - &self, - username: &str, - instructions: &str, - prompts: &[(String, bool)], // (提示文本, 是否回显) - ) -> Vec; - - /// 确认主机密钥 - /// - /// 当主机密钥未知时调用,返回是否接受。 - fn confirm_host_key(&self, host: &str, key_type: &str, fingerprint: &str) -> bool; - - /// 警告主机密钥不匹配 - /// - /// 当主机密钥不匹配时调用,返回是否继续。 - fn warn_host_key_mismatch(&self, host: &str, key_type: &str, fingerprint: &str) -> bool; -} - -/// 默认认证回调(无交互) -/// -/// 用于批处理模式或测试。 -pub struct NoOpAuthCallback; - -impl SSHAuthCallback for NoOpAuthCallback { - fn request_passphrase(&self, _key_path: &Path) -> Option { - None - } - - fn request_password(&self, _username: &str, _host: &str) -> Option { - None - } - - fn handle_keyboard_interactive( - &self, - _username: &str, - _instructions: &str, - prompts: &[(String, bool)], - ) -> Vec { - // 返回空响应 - prompts.iter().map(|_| String::new()).collect() - } - - fn confirm_host_key(&self, _host: &str, _key_type: &str, _fingerprint: &str) -> bool { - false // 默认不接受未知主机 - } - - fn warn_host_key_mismatch(&self, _host: &str, _key_type: &str, _fingerprint: &str) -> bool { - false // 默认不继续 - } -} - -/// 检查连接名称是否为本地连接 -/// -/// _Requirements: 1.4, 1.5_ -pub fn is_local_conn_name(conn_name: &str) -> bool { - conn_name.is_empty() || conn_name == "local" -} - -/// 检查连接名称是否为 SSH 连接 -pub fn is_ssh_conn_name(conn_name: &str) -> bool { - if is_local_conn_name(conn_name) { - return false; - } - - // 检查是否以 ssh:// 开头 - if conn_name.starts_with("ssh://") { - return true; - } - - // 检查是否包含 @ 符号(user@host 格式) - if conn_name.contains('@') { - return true; - } - - // 检查是否不是 WSL 连接 - !conn_name.starts_with("wsl://") -} - -// ============================================================================ -// 测试 -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - // ======================================================================== - // SSHOpts 解析测试 - // ======================================================================== - - #[test] - fn test_parse_host_only() { - let opts = SSHOpts::parse("example.com").unwrap(); - assert_eq!(opts.ssh_host, "example.com"); - assert_eq!(opts.ssh_user, None); - assert_eq!(opts.ssh_port, None); - } - - #[test] - fn test_parse_user_at_host() { - let opts = SSHOpts::parse("user@example.com").unwrap(); - assert_eq!(opts.ssh_host, "example.com"); - assert_eq!(opts.ssh_user, Some("user".to_string())); - assert_eq!(opts.ssh_port, None); - } - - #[test] - fn test_parse_user_at_host_port() { - let opts = SSHOpts::parse("user@example.com:2222").unwrap(); - assert_eq!(opts.ssh_host, "example.com"); - assert_eq!(opts.ssh_user, Some("user".to_string())); - assert_eq!(opts.ssh_port, Some(2222)); - } - - #[test] - fn test_parse_host_port() { - let opts = SSHOpts::parse("example.com:2222").unwrap(); - assert_eq!(opts.ssh_host, "example.com"); - assert_eq!(opts.ssh_user, None); - assert_eq!(opts.ssh_port, Some(2222)); - } - - #[test] - fn test_parse_with_ssh_prefix() { - let opts = SSHOpts::parse("ssh://user@example.com:2222").unwrap(); - assert_eq!(opts.ssh_host, "example.com"); - assert_eq!(opts.ssh_user, Some("user".to_string())); - assert_eq!(opts.ssh_port, Some(2222)); - } - - #[test] - fn test_parse_ipv6_address() { - let opts = SSHOpts::parse("[::1]:22").unwrap(); - assert_eq!(opts.ssh_host, "::1"); - assert_eq!(opts.ssh_port, Some(22)); - } - - #[test] - fn test_parse_ipv6_with_user() { - let opts = SSHOpts::parse("user@[2001:db8::1]:22").unwrap(); - assert_eq!(opts.ssh_host, "2001:db8::1"); - assert_eq!(opts.ssh_user, Some("user".to_string())); - assert_eq!(opts.ssh_port, Some(22)); - } - - #[test] - fn test_parse_empty_string() { - let result = SSHOpts::parse(""); - assert!(result.is_err()); - } - - #[test] - fn test_parse_invalid_port() { - let result = SSHOpts::parse("example.com:invalid"); - assert!(result.is_err()); - } - - #[test] - fn test_effective_port() { - let opts = SSHOpts::new("example.com"); - assert_eq!(opts.effective_port(), 22); - - let opts = SSHOpts::new("example.com").with_port(2222); - assert_eq!(opts.effective_port(), 2222); - } - - #[test] - fn test_to_connection_string() { - let opts = SSHOpts::new("example.com") - .with_user("user") - .with_port(2222); - assert_eq!(opts.to_connection_string(), "user@example.com:2222"); - } - - #[test] - fn test_to_connection_string_ipv6() { - let opts = SSHOpts { - ssh_host: "::1".to_string(), - ssh_user: Some("user".to_string()), - ssh_port: Some(22), - }; - assert_eq!(opts.to_connection_string(), "user@[::1]:22"); - } - - // ======================================================================== - // 连接状态测试 - // ======================================================================== - - #[test] - fn test_connection_state_transitions() { - assert!(ConnectionState::Init.can_transition_to(ConnectionState::Connecting)); - assert!(ConnectionState::Connecting.can_transition_to(ConnectionState::Connected)); - assert!(ConnectionState::Connecting.can_transition_to(ConnectionState::Error)); - assert!(ConnectionState::Connected.can_transition_to(ConnectionState::Disconnected)); - assert!(ConnectionState::Disconnected.can_transition_to(ConnectionState::Connecting)); - assert!(ConnectionState::Error.can_transition_to(ConnectionState::Connecting)); - - // 无效转换 - assert!(!ConnectionState::Init.can_transition_to(ConnectionState::Connected)); - assert!(!ConnectionState::Connected.can_transition_to(ConnectionState::Init)); - } - - #[test] - fn test_conn_status_default() { - let status = ConnStatus::default(); - assert_eq!(status.status, "init"); - assert!(!status.connected); - assert!(!status.has_connected); - } - - #[test] - fn test_conn_status_transitions() { - let mut status = ConnStatus::new("user@example.com"); - - status.set_connecting(); - assert_eq!(status.status, "connecting"); - assert!(!status.connected); - - status.set_connected(); - assert_eq!(status.status, "connected"); - assert!(status.connected); - assert!(status.has_connected); - - status.set_disconnected(); - assert_eq!(status.status, "disconnected"); - assert!(!status.connected); - assert!(status.has_connected); // 仍然为 true - } - - // ======================================================================== - // SSH 配置解析测试 - // ======================================================================== - - #[test] - fn test_parse_ssh_config() { - let config = r#" -Host example - HostName example.com - User admin - Port 2222 - IdentityFile ~/.ssh/id_rsa - -Host * - User default_user -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let example = hosts.get("example").unwrap(); - assert_eq!(example.host, Some("example.com".to_string())); - assert_eq!(example.user, Some("admin".to_string())); - assert_eq!(example.port, Some(2222)); - - let wildcard = hosts.get("*").unwrap(); - assert_eq!(wildcard.user, Some("default_user".to_string())); - } - - #[test] - fn test_parse_ssh_config_with_proxyjump() { - let config = r#" -Host target - HostName target.example.com - ProxyJump bastion@jump.example.com -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let target = hosts.get("target").unwrap(); - assert_eq!( - target.proxy_jump, - Some("bastion@jump.example.com".to_string()) - ); - } - - // ======================================================================== - // 连接类型检测测试 - // ======================================================================== - - #[test] - fn test_is_local_conn_name() { - assert!(is_local_conn_name("")); - assert!(is_local_conn_name("local")); - assert!(!is_local_conn_name("user@host")); - assert!(!is_local_conn_name("ssh://host")); - } - - #[test] - fn test_is_ssh_conn_name() { - assert!(is_ssh_conn_name("user@host")); - assert!(is_ssh_conn_name("ssh://host")); - assert!(is_ssh_conn_name("host.example.com")); - assert!(!is_ssh_conn_name("")); - assert!(!is_ssh_conn_name("local")); - assert!(!is_ssh_conn_name("wsl://Ubuntu")); - } - - // ======================================================================== - // SSH 认证测试 - // ======================================================================== - - #[test] - fn test_ssh_auth_method_variants() { - // 测试各种认证方式的创建 - let pubkey = SSHAuthMethod::PublicKey { - key_path: PathBuf::from("/home/user/.ssh/id_rsa"), - passphrase: None, - }; - assert!(matches!(pubkey, SSHAuthMethod::PublicKey { .. })); - - let pubkey_with_pass = SSHAuthMethod::PublicKey { - key_path: PathBuf::from("/home/user/.ssh/id_rsa"), - passphrase: Some("secret".to_string()), - }; - assert!(matches!( - pubkey_with_pass, - SSHAuthMethod::PublicKey { - passphrase: Some(_), - .. - } - )); - - let agent = SSHAuthMethod::Agent; - assert!(matches!(agent, SSHAuthMethod::Agent)); - - let password = SSHAuthMethod::Password("secret".to_string()); - assert!(matches!(password, SSHAuthMethod::Password(_))); - - let kbd = SSHAuthMethod::KeyboardInteractive; - assert!(matches!(kbd, SSHAuthMethod::KeyboardInteractive)); - } - - #[test] - fn test_ssh_conn_creation() { - let opts = SSHOpts::parse("user@example.com:22").unwrap(); - let conn = SSHConn::new(opts); - - assert_eq!(conn.state(), ConnectionState::Init); - assert!(!conn.is_connected()); - assert!(conn.error().is_none()); - } - - #[test] - fn test_ssh_conn_derive_status() { - let opts = SSHOpts::parse("user@example.com").unwrap(); - let conn = SSHConn::new(opts); - - let status = conn.derive_conn_status(); - assert_eq!(status.status, "init"); - assert!(!status.connected); - assert!(!status.has_connected); - assert_eq!(status.connection, "user@example.com"); - } - - #[test] - fn test_host_key_verification_variants() { - // 测试主机密钥验证结果的各种变体 - let verified = HostKeyVerification::Verified; - assert!(matches!(verified, HostKeyVerification::Verified)); - - let unknown = HostKeyVerification::Unknown { - host: "example.com".to_string(), - key_type: "ssh-ed25519".to_string(), - fingerprint: "SHA256:abc123".to_string(), - }; - assert!(matches!(unknown, HostKeyVerification::Unknown { .. })); - - let mismatch = HostKeyVerification::Mismatch { - host: "example.com".to_string(), - key_type: "ssh-rsa".to_string(), - fingerprint: "SHA256:xyz789".to_string(), - }; - assert!(matches!(mismatch, HostKeyVerification::Mismatch { .. })); - } - - // ======================================================================== - // SSH 配置解析扩展测试 - // ======================================================================== - - #[test] - fn test_parse_ssh_config_with_auth_options() { - let config = r#" -Host secure - HostName secure.example.com - User admin - PubkeyAuthentication yes - PasswordAuthentication no - KbdInteractiveAuthentication no - PreferredAuthentications publickey,keyboard-interactive -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let secure = hosts.get("secure").unwrap(); - assert_eq!(secure.pubkey_authentication, Some(true)); - assert_eq!(secure.password_authentication, Some(false)); - assert_eq!(secure.kbd_interactive_authentication, Some(false)); - assert_eq!( - secure.preferred_authentications, - Some(vec![ - "publickey".to_string(), - "keyboard-interactive".to_string() - ]) - ); - } - - #[test] - fn test_parse_ssh_config_with_multiple_identity_files() { - let config = r#" -Host multi - HostName multi.example.com - IdentityFile ~/.ssh/id_ed25519 - IdentityFile ~/.ssh/id_rsa -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let multi = hosts.get("multi").unwrap(); - let identity_files = multi.identity_file.as_ref().unwrap(); - assert_eq!(identity_files.len(), 2); - } - - #[test] - fn test_parse_ssh_config_with_batch_mode() { - let config = r#" -Host batch - HostName batch.example.com - BatchMode yes -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let batch = hosts.get("batch").unwrap(); - assert_eq!(batch.batch_mode, Some(true)); - } - - // ======================================================================== - // 认证方式构建测试 - // ======================================================================== - - #[test] - fn test_build_default_auth_methods_empty() { - let keywords = ConnKeywords::default(); - let methods = build_default_auth_methods(&keywords, None); - - // 应该至少包含键盘交互认证 - assert!(methods - .iter() - .any(|m| matches!(m, SSHAuthMethod::KeyboardInteractive))); - } - - #[test] - fn test_build_default_auth_methods_with_password() { - let keywords = ConnKeywords::default(); - let methods = build_default_auth_methods(&keywords, Some("secret".to_string())); - - // 应该包含密码认证 - assert!(methods - .iter() - .any(|m| matches!(m, SSHAuthMethod::Password(_)))); - } - - #[test] - fn test_build_default_auth_methods_disabled_password() { - let keywords = ConnKeywords { - password_authentication: Some(false), - ..Default::default() - }; - let methods = build_default_auth_methods(&keywords, Some("secret".to_string())); - - // 不应该包含密码认证 - assert!(!methods - .iter() - .any(|m| matches!(m, SSHAuthMethod::Password(_)))); - } - - #[test] - fn test_build_default_auth_methods_disabled_pubkey() { - let keywords = ConnKeywords { - pubkey_authentication: Some(false), - ..Default::default() - }; - let methods = build_default_auth_methods(&keywords, None); - - // 不应该包含公钥认证 - assert!(!methods - .iter() - .any(|m| matches!(m, SSHAuthMethod::PublicKey { .. }))); - assert!(!methods.iter().any(|m| matches!(m, SSHAuthMethod::Agent))); - } - - #[test] - fn test_build_default_auth_methods_preferred_order() { - let keywords = ConnKeywords { - preferred_authentications: Some(vec!["password".to_string(), "publickey".to_string()]), - ..Default::default() - }; - let methods = build_default_auth_methods(&keywords, Some("secret".to_string())); - - // 密码应该在前面 - let password_idx = methods - .iter() - .position(|m| matches!(m, SSHAuthMethod::Password(_))); - assert!(password_idx.is_some()); - } - - // ======================================================================== - // NoOpAuthCallback 测试 - // ======================================================================== - - #[test] - fn test_noop_auth_callback() { - let callback = NoOpAuthCallback; - - // 测试各个方法返回预期值 - assert!(callback - .request_passphrase(&PathBuf::from("/test")) - .is_none()); - assert!(callback.request_password("user", "host").is_none()); - - let responses = callback.handle_keyboard_interactive( - "user", - "instructions", - &[ - ("prompt1".to_string(), true), - ("prompt2".to_string(), false), - ], - ); - assert_eq!(responses.len(), 2); - assert!(responses.iter().all(|r| r.is_empty())); - - assert!(!callback.confirm_host_key("host", "ssh-rsa", "fingerprint")); - assert!(!callback.warn_host_key_mismatch("host", "ssh-rsa", "fingerprint")); - } - - // ======================================================================== - // SSH Agent 可用性测试 - // ======================================================================== - - #[test] - fn test_is_ssh_agent_available() { - // 这个测试依赖于环境,只验证函数不会 panic - let _ = is_ssh_agent_available(); - } - - // ======================================================================== - // SSH 配置文件解析增强测试 - // ======================================================================== - - #[test] - fn test_parse_ssh_config_with_equals_syntax() { - let config = r#" -Host example - HostName=example.com - User=admin - Port=2222 -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let example = hosts.get("example").unwrap(); - assert_eq!(example.host, Some("example.com".to_string())); - assert_eq!(example.user, Some("admin".to_string())); - assert_eq!(example.port, Some(2222)); - } - - #[test] - fn test_parse_ssh_config_with_quoted_values() { - let config = r#" -Host example - HostName "example.com" - User 'admin' -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let example = hosts.get("example").unwrap(); - assert_eq!(example.host, Some("example.com".to_string())); - assert_eq!(example.user, Some("admin".to_string())); - } - - #[test] - fn test_parse_ssh_config_with_multiple_host_patterns() { - let config = r#" -Host server1 server2 server3 - User admin - Port 22 -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - // 每个模式都应该有相同的配置 - for host in &["server1", "server2", "server3"] { - let config = hosts.get(*host).unwrap(); - assert_eq!(config.user, Some("admin".to_string())); - assert_eq!(config.port, Some(22)); - } - } - - #[test] - fn test_parse_ssh_config_with_comments() { - let config = r#" -# This is a comment -Host example - # Another comment - HostName example.com - User admin # Inline comments are not supported, this will be part of the value -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let example = hosts.get("example").unwrap(); - assert_eq!(example.host, Some("example.com".to_string())); - // Note: inline comments are not stripped in this implementation - } - - #[test] - fn test_parse_ssh_config_with_proxy_command() { - let config = r#" -Host target - HostName target.example.com - ProxyCommand ssh -W %h:%p bastion -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let target = hosts.get("target").unwrap(); - assert_eq!( - target.proxy_command, - Some("ssh -W %h:%p bastion".to_string()) - ); - } - - #[test] - fn test_parse_ssh_config_with_connection_options() { - let config = r#" -Host example - HostName example.com - ConnectTimeout 30 - ServerAliveInterval 60 - ServerAliveCountMax 3 - Compression yes - ForwardAgent yes -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let example = hosts.get("example").unwrap(); - assert_eq!(example.connect_timeout, Some(30)); - assert_eq!(example.server_alive_interval, Some(60)); - assert_eq!(example.server_alive_count_max, Some(3)); - assert_eq!(example.compression, Some(true)); - assert_eq!(example.forward_agent, Some(true)); - } - - #[test] - fn test_parse_ssh_config_with_port_forwarding() { - let config = r#" -Host tunnel - HostName tunnel.example.com - LocalForward 8080 localhost:80 - LocalForward 8443 localhost:443 - RemoteForward 9000 localhost:9000 - DynamicForward 1080 -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let tunnel = hosts.get("tunnel").unwrap(); - let local_forwards = tunnel.local_forward.as_ref().unwrap(); - assert_eq!(local_forwards.len(), 2); - assert!(local_forwards.contains(&"8080 localhost:80".to_string())); - - let remote_forwards = tunnel.remote_forward.as_ref().unwrap(); - assert_eq!(remote_forwards.len(), 1); - - let dynamic_forwards = tunnel.dynamic_forward.as_ref().unwrap(); - assert_eq!(dynamic_forwards.len(), 1); - } - - #[test] - fn test_parse_ssh_config_with_env_options() { - let config = r#" -Host example - HostName example.com - SendEnv LANG LC_* - SetEnv FOO=bar -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - - let example = hosts.get("example").unwrap(); - let send_env = example.send_env.as_ref().unwrap(); - assert_eq!(send_env.len(), 1); - assert_eq!(send_env[0], "LANG LC_*"); - - let set_env = example.set_env.as_ref().unwrap(); - assert_eq!(set_env.len(), 1); - assert_eq!(set_env[0], "FOO=bar"); - } - - // ======================================================================== - // 配置合并测试 - // ======================================================================== - - #[test] - fn test_get_host_config_merges_wildcard() { - let config = r#" -Host example - HostName example.com - User admin - -Host * - User default_user - Port 22 - Compression yes -"#; - let merged = SSHConfigParser::get_host_config_from_content(config, "example").unwrap(); - - // 主机特定配置优先 - assert_eq!(merged.host, Some("example.com".to_string())); - assert_eq!(merged.user, Some("admin".to_string())); - // 通配符配置填充缺失值 - assert_eq!(merged.port, Some(22)); - assert_eq!(merged.compression, Some(true)); - } - - #[test] - fn test_get_host_config_first_match_wins() { - let config = r#" -Host example - User first_user - -Host example - User second_user -"#; - let merged = SSHConfigParser::get_host_config_from_content(config, "example").unwrap(); - - // 第一个匹配的值优先 - assert_eq!(merged.user, Some("first_user".to_string())); - } - - #[test] - fn test_get_host_config_identity_files_accumulate() { - let config = r#" -Host example - IdentityFile ~/.ssh/id_example - -Host * - IdentityFile ~/.ssh/id_default -"#; - let merged = SSHConfigParser::get_host_config_from_content(config, "example").unwrap(); - - // IdentityFile 应该累加 - let identity_files = merged.identity_file.unwrap(); - assert_eq!(identity_files.len(), 2); - } - - // ======================================================================== - // 通配符匹配测试 - // ======================================================================== - - #[test] - fn test_pattern_matching_exact() { - assert!(SSHConfigParser::match_pattern("example.com", "example.com")); - assert!(!SSHConfigParser::match_pattern("example.com", "other.com")); - } - - #[test] - fn test_pattern_matching_wildcard_all() { - assert!(SSHConfigParser::match_pattern("*", "anything")); - assert!(SSHConfigParser::match_pattern("*", "")); - } - - #[test] - fn test_pattern_matching_wildcard_suffix() { - assert!(SSHConfigParser::match_pattern( - "*.example.com", - "server.example.com" - )); - assert!(SSHConfigParser::match_pattern( - "*.example.com", - "a.b.example.com" - )); - assert!(!SSHConfigParser::match_pattern( - "*.example.com", - "example.com" - )); - } - - #[test] - fn test_pattern_matching_wildcard_prefix() { - assert!(SSHConfigParser::match_pattern("server*", "server1")); - assert!(SSHConfigParser::match_pattern("server*", "server-prod")); - assert!(!SSHConfigParser::match_pattern("server*", "myserver")); - } - - #[test] - fn test_pattern_matching_question_mark() { - assert!(SSHConfigParser::match_pattern("server?", "server1")); - assert!(SSHConfigParser::match_pattern("server?", "serverA")); - assert!(!SSHConfigParser::match_pattern("server?", "server12")); - } - - #[test] - fn test_pattern_matching_negation() { - assert!(!SSHConfigParser::match_pattern( - "!example.com", - "example.com" - )); - assert!(SSHConfigParser::match_pattern("!example.com", "other.com")); - } - - #[test] - fn test_pattern_matching_complex() { - assert!(SSHConfigParser::match_pattern( - "*.prod.*", - "server.prod.example" - )); - assert!(SSHConfigParser::match_pattern("web-??-*", "web-01-prod")); - } - - // ======================================================================== - // ProxyJump 解析测试 - // ======================================================================== - - #[test] - fn test_parse_proxy_jump_chain_single() { - let chain = SSHConfigParser::parse_proxy_jump_chain("bastion@jump.example.com"); - assert_eq!(chain.len(), 1); - assert_eq!(chain[0], "bastion@jump.example.com"); - } - - #[test] - fn test_parse_proxy_jump_chain_multiple() { - let chain = - SSHConfigParser::parse_proxy_jump_chain("jump1.com, user@jump2.com:2222, jump3.com"); - assert_eq!(chain.len(), 3); - assert_eq!(chain[0], "jump1.com"); - assert_eq!(chain[1], "user@jump2.com:2222"); - assert_eq!(chain[2], "jump3.com"); - } - - #[test] - fn test_parse_proxy_jump_chain_none() { - let chain = SSHConfigParser::parse_proxy_jump_chain("none"); - assert!(chain.is_empty()); - - let chain = SSHConfigParser::parse_proxy_jump_chain("NONE"); - assert!(chain.is_empty()); - - let chain = SSHConfigParser::parse_proxy_jump_chain(""); - assert!(chain.is_empty()); - } - - #[test] - fn test_parse_proxy_jump_host() { - let opts = SSHConfigParser::parse_proxy_jump_host("user@jump.example.com:2222").unwrap(); - assert_eq!(opts.ssh_host, "jump.example.com"); - assert_eq!(opts.ssh_user, Some("user".to_string())); - assert_eq!(opts.ssh_port, Some(2222)); - } - - // ======================================================================== - // 路径展开测试 - // ======================================================================== - - #[test] - fn test_expand_path_tilde() { - let expanded = SSHConfigParser::expand_path("~/test"); - assert!(!expanded.starts_with("~/")); - assert!(expanded.ends_with("test")); - } - - #[test] - fn test_expand_path_no_tilde() { - let path = "/absolute/path"; - let expanded = SSHConfigParser::expand_path(path); - assert_eq!(expanded, path); - } - - // ======================================================================== - // 配置转连接字符串测试 - // ======================================================================== - - #[test] - fn test_config_to_connection_string() { - let config = ConnKeywords { - host: Some("real.example.com".to_string()), - user: Some("admin".to_string()), - port: Some(2222), - ..Default::default() - }; - - let conn_str = SSHConfigParser::config_to_connection_string(&config, "alias"); - assert_eq!(conn_str, "admin@real.example.com:2222"); - } - - #[test] - fn test_config_to_connection_string_uses_alias_when_no_hostname() { - let config = ConnKeywords { - user: Some("admin".to_string()), - ..Default::default() - }; - - let conn_str = SSHConfigParser::config_to_connection_string(&config, "myserver"); - assert_eq!(conn_str, "admin@myserver"); - } - - // ======================================================================== - // 新增 ConnKeywords 字段测试 - // ======================================================================== - - #[test] - fn test_conn_keywords_strict_host_key_checking() { - let config = r#" -Host example - StrictHostKeyChecking ask -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - let example = hosts.get("example").unwrap(); - assert_eq!(example.strict_host_key_checking, Some("ask".to_string())); - } - - #[test] - fn test_conn_keywords_request_tty() { - let config = r#" -Host example - RequestTTY force -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - let example = hosts.get("example").unwrap(); - assert_eq!(example.request_tty, Some("force".to_string())); - } - - #[test] - fn test_conn_keywords_remote_command() { - let config = r#" -Host example - RemoteCommand /bin/bash -l -"#; - let hosts = SSHConfigParser::parse_config_content(config).unwrap(); - let example = hosts.get("example").unwrap(); - assert_eq!(example.remote_command, Some("/bin/bash -l".to_string())); - } -} diff --git a/src-tauri/crates/terminal/src/connections/ssh_shell_proc.rs b/src-tauri/crates/terminal/src/connections/ssh_shell_proc.rs deleted file mode 100644 index e03ff709c..000000000 --- a/src-tauri/crates/terminal/src/connections/ssh_shell_proc.rs +++ /dev/null @@ -1,704 +0,0 @@ -//! SSH 远程 Shell 进程实现 -//! -//! 封装 SSH 远程 PTY 进程,提供 SSHShellProc 结构体供 ShellController 使用。 -//! -//! ## 功能 -//! - 创建和管理远程 PTY 会话 -//! - 异步读取远程 PTY 输出并通过 Tauri Event 推送 -//! - 处理远程 PTY 输入写入 -//! - 监控远程进程退出状态 -//! - 终端大小同步 -//! -//! ## Requirements -//! - 4.2: SSH 连接建立成功时创建远程 PTY 会话 -//! - 4.7: 支持 ProxyJump 配置 -//! - 4.11: 用户调整终端大小时同步调整远程 PTY 大小 - -use std::io::{Read, Write}; -use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; -use std::sync::Arc; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use parking_lot::Mutex; -use ssh2::{Channel, Session}; -use tokio::sync::mpsc; - -use crate::emit_helper; -use crate::emitter::TerminalEventEmitter; - -use crate::block_controller::{BlockInputUnion, BlockMeta, TermSize}; -use crate::error::TerminalError; -use crate::events::{event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; -use crate::persistence::BlockFile; - -use super::ssh_connection::SSHConn; - -/// SSH Shell 进程封装 -/// -/// 封装 SSH 远程 PTY 进程,提供输入输出和生命周期管理。 -/// -/// _Requirements: 4.2, 4.11_ -pub struct SSHShellProc { - /// Block ID - block_id: String, - /// 控制器类型 - controller_type: String, - /// SSH Channel(用于数据传输) - channel: Arc>, - /// 关闭标志 - shutdown_flag: Arc, - /// 进程退出码 - exit_code: Arc, - /// 是否已退出 - exited: Arc, - /// 当前终端大小 - term_size: Arc>, -} - -impl SSHShellProc { - /// 创建新的 SSH Shell 进程 - /// - /// # 参数 - /// - `block_id`: Block ID - /// - `controller_type`: 控制器类型 ("shell" | "cmd") - /// - `session`: SSH 会话 - /// - `rows`: 终端行数 - /// - `cols`: 终端列数 - /// - `app_handle`: Tauri 应用句柄 - /// - `block_meta`: 块元数据配置 - /// - `input_rx`: 输入接收器 - /// - `block_file`: 块文件存储(可选) - /// - /// # 返回 - /// - `Ok(SSHShellProc)`: 创建成功 - /// - `Err(TerminalError)`: 创建失败 - /// - /// _Requirements: 4.2_ - pub async fn new( - block_id: String, - controller_type: String, - session: &Session, - rows: u16, - cols: u16, - app_handle: impl TerminalEventEmitter, - block_meta: BlockMeta, - input_rx: mpsc::Receiver, - block_file: Option>, - ) -> Result { - tracing::info!( - "[SSHShellProc] 创建远程进程: block_id={}, type={}, size={}x{}", - block_id, - controller_type, - cols, - rows - ); - - // 创建 SSH Channel - let mut channel = session.channel_session().map_err(|e| { - TerminalError::SSHConnectionFailed(format!("创建 SSH Channel 失败: {e}")) - })?; - - // 请求 PTY - // 使用 xterm-256color 终端类型 - channel - .request_pty( - "xterm-256color", - None, - Some((cols as u32, rows as u32, 0, 0)), - ) - .map_err(|e| TerminalError::SSHConnectionFailed(format!("请求远程 PTY 失败: {e}")))?; - - // 根据控制器类型启动 Shell 或执行命令 - if controller_type == "cmd" { - // 命令执行模式 - let cmd = Self::build_remote_command(&block_meta)?; - tracing::info!("[SSHShellProc] 执行远程命令: {}", cmd); - channel.exec(&cmd).map_err(|e| { - TerminalError::SSHConnectionFailed(format!("执行远程命令失败: {e}")) - })?; - } else { - // Shell 模式 - 启动交互式 Shell - channel.shell().map_err(|e| { - TerminalError::SSHConnectionFailed(format!("启动远程 Shell 失败: {e}")) - })?; - } - - // 设置非阻塞模式 - session.set_blocking(false); - - // 创建共享状态 - let shutdown_flag = Arc::new(AtomicBool::new(false)); - let exit_code = Arc::new(AtomicI32::new(0)); - let exited = Arc::new(AtomicBool::new(false)); - let channel = Arc::new(Mutex::new(channel)); - let term_size = Arc::new(Mutex::new(TermSize { rows, cols })); - - // 启动输出读取任务 - Self::spawn_output_reader( - block_id.clone(), - channel.clone(), - app_handle.clone(), - shutdown_flag.clone(), - exit_code.clone(), - exited.clone(), - block_file, - ); - - // 启动输入处理任务 - Self::spawn_input_handler( - block_id.clone(), - channel.clone(), - term_size.clone(), - input_rx, - shutdown_flag.clone(), - ); - - tracing::info!("[SSHShellProc] 远程进程已创建: block_id={}", block_id); - - Ok(Self { - block_id, - controller_type, - channel, - shutdown_flag, - exit_code, - exited, - term_size, - }) - } - - /// 从 SSHConn 创建 SSH Shell 进程 - /// - /// 便捷方法,从已连接的 SSHConn 创建远程 Shell 进程。 - /// - /// # 参数 - /// - `block_id`: Block ID - /// - `controller_type`: 控制器类型 - /// - `ssh_conn`: SSH 连接管理器 - /// - `rows`: 终端行数 - /// - `cols`: 终端列数 - /// - `app_handle`: Tauri 应用句柄 - /// - `block_meta`: 块元数据配置 - /// - `input_rx`: 输入接收器 - /// - `block_file`: 块文件存储(可选) - /// - /// # 返回 - /// - `Ok(SSHShellProc)`: 创建成功 - /// - `Err(TerminalError)`: 创建失败 - pub async fn from_ssh_conn( - block_id: String, - controller_type: String, - ssh_conn: &SSHConn, - rows: u16, - cols: u16, - app_handle: impl TerminalEventEmitter, - block_meta: BlockMeta, - input_rx: mpsc::Receiver, - block_file: Option>, - ) -> Result { - let session = ssh_conn - .get_session() - .ok_or_else(|| TerminalError::SSHConnectionFailed("SSH 会话未建立".to_string()))?; - - Self::new( - block_id, - controller_type, - &session, - rows, - cols, - app_handle, - block_meta, - input_rx, - block_file, - ) - .await - } - - /// 构建远程命令 - /// - /// 根据块元数据构建要在远程执行的命令。 - /// - /// _Requirements: 16.1, 16.2, 16.3_ - fn build_remote_command(block_meta: &BlockMeta) -> Result { - let cmd_str = block_meta.cmd.as_ref().ok_or_else(|| { - TerminalError::SSHConnectionFailed("cmd 模式需要指定命令".to_string()) - })?; - - // 构建完整命令字符串 - let mut full_cmd = String::new(); - - // 如果指定了工作目录,先 cd 到该目录 - if let Some(cwd) = block_meta.sanitized_cmd_cwd() { - full_cmd.push_str(&format!("cd {} && ", shell_escape(&cwd))); - } - - // 设置环境变量 - if let Some(env_vars) = &block_meta.cmd_env { - for (key, value) in env_vars { - full_cmd.push_str(&format!("export {}={} && ", key, shell_escape(value))); - } - } - - // 添加命令和参数 - full_cmd.push_str(cmd_str); - if let Some(args) = &block_meta.cmd_args { - for arg in args { - full_cmd.push(' '); - full_cmd.push_str(&shell_escape(arg)); - } - } - - Ok(full_cmd) - } - - /// 启动输出读取任务 - /// - /// 在独立线程中读取远程 PTY 输出,并通过 Tauri 事件发送到前端。 - /// - /// _Requirements: 4.2_ - fn spawn_output_reader( - block_id: String, - channel: Arc>, - app_handle: impl TerminalEventEmitter, - shutdown_flag: Arc, - exit_code: Arc, - exited: Arc, - block_file: Option>, - ) { - std::thread::spawn(move || { - let mut buffer = [0u8; 4096]; - let mut consecutive_empty_reads = 0; - const MAX_EMPTY_READS: u32 = 100; // 防止空循环 - - loop { - // 检查关闭标志 - if shutdown_flag.load(Ordering::Relaxed) { - tracing::debug!("[SSHShellProc] 收到关闭信号: block_id={}", block_id); - break; - } - - // 读取输出 - let read_result = { - let mut ch = channel.lock(); - - // 检查 Channel 是否已关闭 - if ch.eof() { - // 获取退出状态 - let code = ch.exit_status().unwrap_or(0); - exit_code.store(code, Ordering::SeqCst); - exited.store(true, Ordering::SeqCst); - - tracing::info!( - "[SSHShellProc] 远程进程已退出: block_id={}, exit_code={}", - block_id, - code - ); - - // 发送状态事件 - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: block_id.clone(), - status: SessionStatus::Done, - exit_code: Some(code), - error: None, - }, - ); - break; - } - - ch.read(&mut buffer) - }; - - match read_result { - Ok(0) => { - // 没有数据可读,短暂休眠后重试 - consecutive_empty_reads += 1; - if consecutive_empty_reads > MAX_EMPTY_READS { - // 检查 Channel 状态 - let ch = channel.lock(); - if ch.eof() { - let code = ch.exit_status().unwrap_or(0); - exit_code.store(code, Ordering::SeqCst); - exited.store(true, Ordering::SeqCst); - - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: block_id.clone(), - status: SessionStatus::Done, - exit_code: Some(code), - error: None, - }, - ); - break; - } - consecutive_empty_reads = 0; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Ok(n) => { - consecutive_empty_reads = 0; - let output_data = &buffer[..n]; - - // 保存到块文件 - if let Some(ref bf) = block_file { - if let Err(e) = bf.append_data(output_data) { - tracing::warn!( - "[SSHShellProc] 写入块文件失败: block_id={}, error={}", - block_id, - e - ); - } - } - - // 发送输出事件 - let data = BASE64.encode(output_data); - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_OUTPUT, - &TerminalOutputEvent { - session_id: block_id.clone(), - data, - }, - ); - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - // 非阻塞模式下没有数据,短暂休眠 - consecutive_empty_reads += 1; - std::thread::sleep(std::time::Duration::from_millis(10)); - } - Err(e) => { - // 检查是否是因为关闭导致的错误 - if shutdown_flag.load(Ordering::Relaxed) { - break; - } - - tracing::error!( - "[SSHShellProc] 读取错误: block_id={}, error={}", - block_id, - e - ); - exited.store(true, Ordering::SeqCst); - - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: block_id.clone(), - status: SessionStatus::Error, - exit_code: None, - error: Some(e.to_string()), - }, - ); - break; - } - } - } - }); - } - - /// 启动输入处理任务 - /// - /// 在独立任务中处理输入数据,包括键盘输入、信号和终端大小调整。 - /// - /// _Requirements: 4.11_ - fn spawn_input_handler( - block_id: String, - channel: Arc>, - term_size: Arc>, - mut input_rx: mpsc::Receiver, - shutdown_flag: Arc, - ) { - tokio::spawn(async move { - while let Some(input) = input_rx.recv().await { - // 检查关闭标志 - if shutdown_flag.load(Ordering::Relaxed) { - break; - } - - // 处理输入数据 - if let Some(data) = &input.input_data { - let mut ch = channel.lock(); - if let Err(e) = ch.write_all(data) { - tracing::error!( - "[SSHShellProc] 写入失败: block_id={}, error={}", - block_id, - e - ); - continue; - } - if let Err(e) = ch.flush() { - tracing::error!( - "[SSHShellProc] Flush 失败: block_id={}, error={}", - block_id, - e - ); - } - } - - // 处理终端大小调整 - if let Some(size) = &input.term_size { - // 更新本地记录的终端大小 - { - let mut ts = term_size.lock(); - *ts = *size; - } - - // 发送 PTY 大小调整请求到远程 - let mut ch = channel.lock(); - if let Err(e) = - ch.request_pty_size(size.cols as u32, size.rows as u32, Some(0), Some(0)) - { - tracing::error!( - "[SSHShellProc] 调整远程 PTY 大小失败: block_id={}, error={}", - block_id, - e - ); - } else { - tracing::debug!( - "[SSHShellProc] 调整远程 PTY 大小: block_id={}, size={}x{}", - block_id, - size.cols, - size.rows - ); - } - } - - // 处理信号 - if let Some(sig_name) = &input.sig_name { - tracing::debug!( - "[SSHShellProc] 收到信号: block_id={}, signal={}", - block_id, - sig_name - ); - - // SSH 协议支持发送信号,但 ssh2 crate 没有直接暴露此功能 - // 对于 SIGINT,我们可以发送 Ctrl+C (0x03) - // 对于其他信号,记录日志但不执行操作 - match sig_name.as_str() { - "SIGINT" => { - // 发送 Ctrl+C - let mut ch = channel.lock(); - if let Err(e) = ch.write_all(&[0x03]) { - tracing::warn!( - "[SSHShellProc] 发送 Ctrl+C 失败: block_id={}, error={}", - block_id, - e - ); - } - } - "SIGQUIT" => { - // 发送 Ctrl+\ (0x1C) - let mut ch = channel.lock(); - if let Err(e) = ch.write_all(&[0x1C]) { - tracing::warn!( - "[SSHShellProc] 发送 Ctrl+\\ 失败: block_id={}, error={}", - block_id, - e - ); - } - } - _ => { - tracing::warn!( - "[SSHShellProc] 不支持的信号: block_id={}, signal={}", - block_id, - sig_name - ); - } - } - } - } - - tracing::debug!("[SSHShellProc] 输入处理任务结束: block_id={}", block_id); - }); - } - - /// 获取 Block ID - pub fn block_id(&self) -> &str { - &self.block_id - } - - /// 获取控制器类型 - pub fn controller_type(&self) -> &str { - &self.controller_type - } - - /// 检查进程是否已退出 - pub fn is_exited(&self) -> bool { - self.exited.load(Ordering::SeqCst) - } - - /// 获取退出码 - pub fn get_exit_code(&self) -> i32 { - self.exit_code.load(Ordering::SeqCst) - } - - /// 获取当前终端大小 - pub fn get_term_size(&self) -> TermSize { - *self.term_size.lock() - } - - /// 写入数据到远程 PTY - /// - /// _Requirements: 4.2_ - pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> { - let mut channel = self.channel.lock(); - channel - .write_all(data) - .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; - channel - .flush() - .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; - Ok(()) - } - - /// 调整远程 PTY 大小 - /// - /// _Requirements: 4.11_ - pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> { - // 更新本地记录 - { - let mut ts = self.term_size.lock(); - *ts = TermSize { rows, cols }; - } - - // 发送到远程 - let mut channel = self.channel.lock(); - channel - .request_pty_size(cols as u32, rows as u32, Some(0), Some(0)) - .map_err(|e| TerminalError::ResizeFailed(e.to_string()))?; - - tracing::debug!( - "[SSHShellProc] 调整远程 PTY 大小: block_id={}, size={}x{}", - self.block_id, - cols, - rows - ); - Ok(()) - } - - /// 优雅终止进程 - /// - /// 发送 EOF 并等待进程退出。 - /// - /// # 返回 - /// 进程退出码 - pub async fn terminate(&self) -> i32 { - tracing::info!( - "[SSHShellProc] 优雅终止远程进程: block_id={}", - self.block_id - ); - - // 设置关闭标志 - self.shutdown_flag.store(true, Ordering::SeqCst); - - // 发送 Ctrl+C 尝试中断进程 - { - let mut channel = self.channel.lock(); - let _ = channel.write_all(&[0x03]); // Ctrl+C - } - - // 等待一小段时间让进程响应 - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - // 关闭 Channel - { - let mut channel = self.channel.lock(); - let _ = channel.send_eof(); - let _ = channel.wait_close(); - } - - self.exit_code.load(Ordering::SeqCst) - } - - /// 强制终止进程 - /// - /// 关闭 Channel 立即终止连接。 - pub async fn kill(&self) { - tracing::info!( - "[SSHShellProc] 强制终止远程进程: block_id={}", - self.block_id - ); - - // 设置关闭标志 - self.shutdown_flag.store(true, Ordering::SeqCst); - - // 直接关闭 Channel - { - let mut channel = self.channel.lock(); - let _ = channel.send_eof(); - let _ = channel.close(); - } - } - - /// 发送 EOF 到远程 - /// - /// 用于通知远程进程输入已结束。 - pub fn send_eof(&self) -> Result<(), TerminalError> { - let mut channel = self.channel.lock(); - channel - .send_eof() - .map_err(|e| TerminalError::WriteFailed(format!("发送 EOF 失败: {e}")))?; - Ok(()) - } -} - -impl Drop for SSHShellProc { - fn drop(&mut self) { - // 确保关闭标志被设置 - self.shutdown_flag.store(true, Ordering::SeqCst); - - // 尝试关闭 Channel - if let Some(mut channel) = self.channel.try_lock() { - let _ = channel.send_eof(); - let _ = channel.close(); - } - - tracing::debug!("[SSHShellProc] 远程进程已销毁: block_id={}", self.block_id); - } -} - -/// Shell 转义辅助函数 -/// -/// 对字符串进行 Shell 转义,防止命令注入。 -fn shell_escape(s: &str) -> String { - // 如果字符串只包含安全字符,直接返回 - if s.chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.' || c == '/') - { - return s.to_string(); - } - - // 否则用单引号包裹,并转义内部的单引号 - format!("'{}'", s.replace('\'', "'\\''")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_shell_escape_simple() { - assert_eq!(shell_escape("hello"), "hello"); - assert_eq!(shell_escape("hello_world"), "hello_world"); - assert_eq!(shell_escape("/path/to/file"), "/path/to/file"); - assert_eq!(shell_escape("file.txt"), "file.txt"); - } - - #[test] - fn test_shell_escape_special_chars() { - assert_eq!(shell_escape("hello world"), "'hello world'"); - assert_eq!(shell_escape("hello'world"), "'hello'\\''world'"); - assert_eq!(shell_escape("$HOME"), "'$HOME'"); - assert_eq!(shell_escape("a;b"), "'a;b'"); - } - - #[test] - fn test_term_size_default() { - let size = TermSize::default(); - assert_eq!(size.rows, 24); - assert_eq!(size.cols, 80); - } -} diff --git a/src-tauri/crates/terminal/src/connections/wsl_connection.rs b/src-tauri/crates/terminal/src/connections/wsl_connection.rs deleted file mode 100644 index 33ff5a6f7..000000000 --- a/src-tauri/crates/terminal/src/connections/wsl_connection.rs +++ /dev/null @@ -1,1296 +0,0 @@ -//! WSL 连接模块(仅 Windows) -//! -//! 提供 Windows Subsystem for Linux (WSL) 连接功能。 -//! -//! ## 功能 -//! - WSL 发行版列表获取 -//! - WSL PTY 创建和管理 -//! - 连接状态管理 -//! - 终端大小同步 -//! -//! ## Requirements -//! - 5.1: 连接到指定的 WSL 发行版 -//! - 5.2: 创建 PTY 会话 -//! - 5.3: 列出所有可用的 WSL 发行版 -//! - 5.4: 连接断开处理和重连 -//! - 5.6: 终端大小同步 - -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering}; -use std::sync::Arc; - -#[cfg(target_os = "windows")] -use std::io::{Read, Write}; - -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc; - -use crate::block_controller::{BlockInputUnion, BlockMeta}; -use crate::connections::{ConnStatus, ConnectionState}; -use crate::emit_helper; -use crate::emitter::TerminalEventEmit; -#[cfg(target_os = "windows")] -use crate::emitter::TerminalEventEmitter; -use crate::error::TerminalError; -use crate::events::event_names; -#[cfg(target_os = "windows")] -use crate::events::{TerminalOutputEvent, TerminalStatusEvent}; -use crate::persistence::BlockFile; -#[cfg(target_os = "windows")] -use crate::SessionStatus; - -#[cfg(target_os = "windows")] -use base64::engine::general_purpose::STANDARD as BASE64; -#[cfg(target_os = "windows")] -use base64::Engine; - -/// WSL 连接前缀 -pub const WSL_CONN_PREFIX: &str = "wsl://"; - -/// 默认 WSL 发行版 -pub const DEFAULT_WSL_DISTRO: &str = "Ubuntu"; - -// ============================================================================ -// WSL 发行版信息 -// ============================================================================ - -/// WSL 发行版信息 -/// -/// 表示一个已安装的 WSL 发行版。 -/// -/// _Requirements: 5.3_ -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WSLDistro { - /// 发行版名称 - pub name: String, - /// 是否为默认发行版 - pub is_default: bool, - /// WSL 版本(1 或 2) - pub wsl_version: u8, - /// 发行版状态 - pub state: WSLDistroState, -} - -/// WSL 发行版状态 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum WSLDistroState { - /// 已停止 - Stopped, - /// 运行中 - Running, - /// 正在安装 - Installing, - /// 未知状态 - #[default] - Unknown, -} - -impl std::fmt::Display for WSLDistroState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Stopped => write!(f, "stopped"), - Self::Running => write!(f, "running"), - Self::Installing => write!(f, "installing"), - Self::Unknown => write!(f, "unknown"), - } - } -} - -// ============================================================================ -// WSL 连接选项 -// ============================================================================ - -/// WSL 连接选项 -/// -/// 存储解析后的 WSL 连接参数。 -/// -/// ## 格式支持 -/// - `wsl://` - 使用默认发行版 -/// - `wsl://distro_name` - 指定发行版 -/// - `wsl://distro_name/path` - 指定发行版和初始路径 -/// -/// _Requirements: 5.1_ -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct WSLOpts { - /// 发行版名称(None 表示使用默认发行版) - pub distro: Option, - /// 初始工作目录 - pub initial_path: Option, - /// 要执行的命令(None 表示启动交互式 shell) - pub command: Option, -} - -impl WSLOpts { - /// 创建新的 WSL 选项 - pub fn new() -> Self { - Self::default() - } - - /// 设置发行版名称 - pub fn with_distro(mut self, distro: impl Into) -> Self { - self.distro = Some(distro.into()); - self - } - - /// 设置初始路径 - pub fn with_path(mut self, path: impl Into) -> Self { - self.initial_path = Some(path.into()); - self - } - - /// 设置要执行的命令 - pub fn with_command(mut self, command: impl Into) -> Self { - self.command = Some(command.into()); - self - } - - /// 从连接字符串解析 WSL 选项 - /// - /// 支持以下格式: - /// - `wsl://` - /// - `wsl://distro_name` - /// - `wsl://distro_name/path` - /// - /// # 参数 - /// - `conn_str`: 连接字符串 - /// - /// # 返回 - /// - `Ok(WSLOpts)`: 解析成功 - /// - `Err(TerminalError)`: 解析失败 - /// - /// _Requirements: 5.1_ - pub fn parse(conn_str: &str) -> Result { - let conn_str = conn_str.trim(); - - if conn_str.is_empty() { - return Err(TerminalError::WSLConnectionFailed( - "连接字符串不能为空".to_string(), - )); - } - - // 检查并移除 wsl:// 前缀 - let path_part = if let Some(stripped) = conn_str.strip_prefix(WSL_CONN_PREFIX) { - stripped - } else if conn_str.eq_ignore_ascii_case("wsl") { - // 支持简单的 "wsl" 格式 - return Ok(Self::default()); - } else { - return Err(TerminalError::WSLConnectionFailed(format!( - "无效的 WSL 连接字符串,需要以 '{WSL_CONN_PREFIX}' 开头: {conn_str}" - ))); - }; - - // 如果路径部分为空,使用默认发行版 - if path_part.is_empty() { - return Ok(Self::default()); - } - - // 解析发行版名称和路径 - let (distro, initial_path) = if let Some(slash_pos) = path_part.find('/') { - let distro = &path_part[..slash_pos]; - let path = &path_part[slash_pos..]; - ( - if distro.is_empty() { - None - } else { - Some(distro.to_string()) - }, - if path.len() > 1 { - Some(path.to_string()) - } else { - None - }, - ) - } else { - (Some(path_part.to_string()), None) - }; - - Ok(Self { - distro, - initial_path, - command: None, - }) - } - - /// 获取有效的发行版名称 - /// - /// 如果未指定发行版,返回默认发行版名称。 - pub fn effective_distro(&self) -> &str { - self.distro.as_deref().unwrap_or(DEFAULT_WSL_DISTRO) - } - - /// 转换为连接字符串 - /// - /// _Requirements: 5.1 (Round-Trip)_ - pub fn to_connection_string(&self) -> String { - let mut result = WSL_CONN_PREFIX.to_string(); - - if let Some(ref distro) = self.distro { - result.push_str(distro); - } - - if let Some(ref path) = self.initial_path { - if !path.starts_with('/') { - result.push('/'); - } - result.push_str(path); - } - - result - } -} - -impl std::fmt::Display for WSLOpts { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.to_connection_string()) - } -} - -impl std::str::FromStr for WSLOpts { - type Err = TerminalError; - - fn from_str(s: &str) -> Result { - Self::parse(s) - } -} - -// ============================================================================ -// WSL 连接管理器 -// ============================================================================ - -/// WSL 连接管理器 -/// -/// 管理单个 WSL 连接的生命周期,包括连接、断开和重连。 -/// -/// _Requirements: 5.1, 5.2, 5.4, 5.6_ -pub struct WSLConn { - /// 连接选项 - opts: WSLOpts, - /// 当前状态 - state: RwLock, - /// 错误信息 - error: RwLock>, - /// 上次连接时间(Unix 时间戳) - last_connect_time: AtomicI64, - /// 活跃连接数 - active_conn_num: AtomicI32, - /// 是否曾经连接成功 - has_connected: AtomicBool, - /// wsh 是否启用 - wsh_enabled: AtomicBool, - /// wsh 版本 - wsh_version: RwLock>, - /// wsh 错误 - wsh_error: RwLock>, - /// 不使用 wsh 的原因 - no_wsh_reason: RwLock>, - /// 事件发射器(用于事件广播) - app_handle: RwLock>>, -} - -impl WSLConn { - /// 创建新的 WSL 连接管理器 - pub fn new(opts: WSLOpts) -> Self { - Self { - opts, - state: RwLock::new(ConnectionState::Init), - error: RwLock::new(None), - last_connect_time: AtomicI64::new(0), - active_conn_num: AtomicI32::new(0), - has_connected: AtomicBool::new(false), - wsh_enabled: AtomicBool::new(false), - wsh_version: RwLock::new(None), - wsh_error: RwLock::new(None), - no_wsh_reason: RwLock::new(None), - app_handle: RwLock::new(None), - } - } - - /// 创建带有事件发射器的 WSL 连接管理器 - pub fn with_app_handle(opts: WSLOpts, app_handle: impl TerminalEventEmit) -> Self { - let conn = Self::new(opts); - *conn.app_handle.write() = Some(Arc::new(app_handle)); - conn - } - - /// 设置事件发射器 - pub fn set_app_handle(&self, app_handle: impl TerminalEventEmit) { - *self.app_handle.write() = Some(Arc::new(app_handle)); - } - - /// 从连接字符串创建 - pub fn from_connection_string(conn_str: &str) -> Result { - let opts = WSLOpts::parse(conn_str)?; - Ok(Self::new(opts)) - } - - /// 获取连接选项 - pub fn opts(&self) -> &WSLOpts { - &self.opts - } - - /// 获取当前状态 - pub fn state(&self) -> ConnectionState { - *self.state.read() - } - - /// 设置状态 - fn set_state(&self, new_state: ConnectionState) { - let mut state = self.state.write(); - *state = new_state; - } - - /// 获取错误信息 - pub fn error(&self) -> Option { - self.error.read().clone() - } - - /// 设置错误信息 - fn set_error(&self, error: Option) { - let mut err = self.error.write(); - *err = error; - } - - /// 检查是否已连接 - pub fn is_connected(&self) -> bool { - self.state() == ConnectionState::Connected - } - - /// 广播连接状态变更事件 - /// - /// _Requirements: 7.3_ - fn broadcast_conn_change(&self) { - use crate::events::ConnChangeEvent; - - if let Some(ref app_handle) = *self.app_handle.read() { - let status = self.derive_conn_status(); - let event = ConnChangeEvent { - connection: self.opts.to_connection_string(), - status, - }; - - if let Err(e) = emit_helper::emit(app_handle.as_ref(), event_names::CONN_CHANGE, &event) - { - tracing::warn!("[WSLConn] 广播连接状态变更事件失败: {}", e); - } - } - } - - /// 派生连接状态 - /// - /// 生成用于前端显示的连接状态详情。 - /// - /// _Requirements: 7.1_ - pub fn derive_conn_status(&self) -> ConnStatus { - ConnStatus { - status: self.state().to_string(), - connected: self.is_connected(), - connection: self.opts.to_connection_string(), - has_connected: self.has_connected.load(Ordering::SeqCst), - active_conn_num: self.active_conn_num.load(Ordering::SeqCst), - error: self.error(), - wsh_enabled: self.wsh_enabled.load(Ordering::SeqCst), - wsh_error: self.wsh_error.read().clone(), - no_wsh_reason: self.no_wsh_reason.read().clone(), - wsh_version: self.wsh_version.read().clone(), - } - } - - /// 连接到 WSL 发行版 - /// - /// 验证 WSL 是否可用并检查指定的发行版是否存在。 - /// - /// _Requirements: 5.1, 5.4_ - pub async fn connect(&self) -> Result<(), TerminalError> { - // 检查状态转换 - let current_state = self.state(); - if !current_state.can_transition_to(ConnectionState::Connecting) { - return Err(TerminalError::WSLConnectionFailed(format!( - "无法从 {current_state} 状态开始连接" - ))); - } - - self.set_state(ConnectionState::Connecting); - self.set_error(None); - self.broadcast_conn_change(); - - let distro = self.opts.effective_distro(); - tracing::info!("[WSLConn] 正在连接到 WSL 发行版: {}", distro); - - // 检查 WSL 是否可用 - if !Self::is_wsl_available() { - let error_msg = "WSL 不可用,请确保已安装 Windows Subsystem for Linux".to_string(); - tracing::error!("[WSLConn] {}", error_msg); - self.set_state(ConnectionState::Error); - self.set_error(Some(error_msg.clone())); - self.broadcast_conn_change(); - return Err(TerminalError::WSLConnectionFailed(error_msg)); - } - - // 检查发行版是否存在 - let distros = Self::list_distros()?; - let distro_exists = distros.iter().any(|d| d.name.eq_ignore_ascii_case(distro)); - - if !distro_exists { - let available: Vec<_> = distros.iter().map(|d| d.name.as_str()).collect(); - let error_msg = format!("WSL 发行版 '{distro}' 不存在。可用的发行版: {available:?}"); - tracing::error!("[WSLConn] {}", error_msg); - self.set_state(ConnectionState::Error); - self.set_error(Some(error_msg.clone())); - self.broadcast_conn_change(); - return Err(TerminalError::WSLConnectionFailed(error_msg)); - } - - // 连接成功 - self.set_state(ConnectionState::Connected); - self.has_connected.store(true, Ordering::SeqCst); - self.last_connect_time - .store(chrono::Utc::now().timestamp(), Ordering::SeqCst); - self.active_conn_num.fetch_add(1, Ordering::SeqCst); - self.broadcast_conn_change(); - - tracing::info!("[WSLConn] 已连接到 WSL 发行版: {}", distro); - Ok(()) - } - - /// 断开连接 - /// - /// _Requirements: 5.4_ - pub async fn disconnect(&self) -> Result<(), TerminalError> { - tracing::info!("[WSLConn] 断开 WSL 连接: {}", self.opts.effective_distro()); - - self.set_state(ConnectionState::Disconnected); - self.active_conn_num.fetch_sub(1, Ordering::SeqCst); - self.broadcast_conn_change(); - - Ok(()) - } - - /// 检查 WSL 是否可用 - /// - /// 通过执行 `wsl --status` 命令检查 WSL 是否已安装并可用。 - #[cfg(target_os = "windows")] - pub fn is_wsl_available() -> bool { - use std::process::Command; - - match Command::new("wsl").arg("--status").output() { - Ok(output) => output.status.success(), - Err(_) => false, - } - } - - /// 检查 WSL 是否可用(非 Windows 平台始终返回 false) - #[cfg(not(target_os = "windows"))] - pub fn is_wsl_available() -> bool { - false - } - - /// 列出所有可用的 WSL 发行版 - /// - /// _Requirements: 5.3_ - #[cfg(target_os = "windows")] - pub fn list_distros() -> Result, TerminalError> { - use std::process::Command; - - let output = Command::new("wsl") - .args(["--list", "--verbose"]) - .output() - .map_err(|e| { - TerminalError::WSLConnectionFailed(format!("执行 wsl --list 失败: {}", e)) - })?; - - if !output.status.success() { - return Err(TerminalError::WSLConnectionFailed( - "wsl --list 命令执行失败".to_string(), - )); - } - - // 解析输出 - // 输出格式类似: - // NAME STATE VERSION - // * Ubuntu Running 2 - // Debian Stopped 2 - let stdout = String::from_utf8_lossy(&output.stdout); - let mut distros = Vec::new(); - - for line in stdout.lines().skip(1) { - // 跳过标题行 - let line = line.trim(); - if line.is_empty() { - continue; - } - - let is_default = line.starts_with('*'); - let line = line.trim_start_matches('*').trim(); - - // 解析各列 - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 3 { - let name = parts[0].to_string(); - let state = match parts[1].to_lowercase().as_str() { - "running" => WSLDistroState::Running, - "stopped" => WSLDistroState::Stopped, - "installing" => WSLDistroState::Installing, - _ => WSLDistroState::Unknown, - }; - let wsl_version = parts[2].parse().unwrap_or(2); - - distros.push(WSLDistro { - name, - is_default, - wsl_version, - state, - }); - } - } - - Ok(distros) - } - - /// 列出所有可用的 WSL 发行版(非 Windows 平台返回空列表) - #[cfg(not(target_os = "windows"))] - pub fn list_distros() -> Result, TerminalError> { - Ok(Vec::new()) - } - - /// 获取默认 WSL 发行版 - /// - /// _Requirements: 5.3_ - pub fn get_default_distro() -> Result, TerminalError> { - let distros = Self::list_distros()?; - Ok(distros.into_iter().find(|d| d.is_default)) - } -} - -// ============================================================================ -// WSL Shell 进程 -// ============================================================================ - -/// WSL Shell 进程封装 -/// -/// 封装 WSL PTY 进程,提供输入输出和生命周期管理。 -/// -/// _Requirements: 5.2, 5.6_ -pub struct WSLShellProc { - /// Block ID - block_id: String, - /// WSL 选项 - #[allow(dead_code)] - opts: WSLOpts, - /// PTY 写入器 - #[cfg(target_os = "windows")] - writer: Arc>>, - /// PTY Master(用于调整大小) - #[cfg(target_os = "windows")] - master: Arc>>, - /// 关闭标志 - shutdown_flag: Arc, - /// 进程退出码 - exit_code: Arc, - /// 是否已退出 - exited: Arc, -} - -impl WSLShellProc { - /// 创建新的 WSL Shell 进程 - /// - /// # 参数 - /// - `block_id`: Block ID - /// - `opts`: WSL 连接选项 - /// - `rows`: 终端行数 - /// - `cols`: 终端列数 - /// - `app_handle`: Tauri 应用句柄 - /// - `block_meta`: 块元数据配置 - /// - `input_rx`: 输入接收器 - /// - `block_file`: 块文件存储(可选) - /// - /// # 返回 - /// - `Ok(WSLShellProc)`: 创建成功 - /// - `Err(TerminalError)`: 创建失败 - /// - /// _Requirements: 5.2_ - #[cfg(target_os = "windows")] - pub async fn new( - block_id: String, - opts: WSLOpts, - rows: u16, - cols: u16, - app_handle: impl TerminalEventEmitter, - block_meta: BlockMeta, - input_rx: mpsc::Receiver, - block_file: Option>, - ) -> Result { - use portable_pty::{native_pty_system, PtySize}; - - tracing::info!( - "[WSLShellProc] 创建 WSL 进程: block_id={}, distro={}, size={}x{}", - block_id, - opts.effective_distro(), - cols, - rows - ); - - let pty_system = native_pty_system(); - - // 创建 PTY - let pair = pty_system - .openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 构建 WSL 命令 - let cmd = Self::build_wsl_command(&opts, &block_meta)?; - - // 启动子进程 - let _child = pair - .slave - .spawn_command(cmd) - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 获取写入器 - let writer = pair - .master - .take_writer() - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 获取读取器 - let reader = pair - .master - .try_clone_reader() - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 创建共享状态 - let shutdown_flag = Arc::new(AtomicBool::new(false)); - let exit_code = Arc::new(AtomicI32::new(0)); - let exited = Arc::new(AtomicBool::new(false)); - let writer = Arc::new(parking_lot::Mutex::new(writer)); - let master = Arc::new(parking_lot::Mutex::new(pair.master)); - - // 启动输出读取任务 - Self::spawn_output_reader( - block_id.clone(), - reader, - app_handle.clone(), - shutdown_flag.clone(), - exit_code.clone(), - exited.clone(), - block_file, - ); - - // 启动输入处理任务 - Self::spawn_input_handler( - block_id.clone(), - writer.clone(), - master.clone(), - input_rx, - shutdown_flag.clone(), - ); - - tracing::info!("[WSLShellProc] WSL 进程已创建: block_id={}", block_id); - - Ok(Self { - block_id, - opts, - writer, - master, - shutdown_flag, - exit_code, - exited, - }) - } - - /// 创建新的 WSL Shell 进程(非 Windows 平台) - #[cfg(not(target_os = "windows"))] - pub async fn new( - block_id: String, - opts: WSLOpts, - _rows: u16, - _cols: u16, - _app_handle: impl TerminalEventEmit, - _block_meta: BlockMeta, - _input_rx: mpsc::Receiver, - _block_file: Option>, - ) -> Result { - // 在非 Windows 平台上,创建一个占位结构但返回错误 - // 这样可以保持 API 一致性 - let _ = Self { - block_id, - opts, - shutdown_flag: Arc::new(AtomicBool::new(false)), - exit_code: Arc::new(AtomicI32::new(0)), - exited: Arc::new(AtomicBool::new(false)), - }; - Err(TerminalError::WSLConnectionFailed( - "WSL 仅在 Windows 平台上可用".to_string(), - )) - } - - /// 构建 WSL 命令 - /// - /// _Requirements: 5.1, 5.2_ - #[cfg(target_os = "windows")] - fn build_wsl_command( - opts: &WSLOpts, - block_meta: &BlockMeta, - ) -> Result { - use portable_pty::CommandBuilder; - - let mut cmd = CommandBuilder::new("wsl.exe"); - - // 指定发行版 - if let Some(ref distro) = opts.distro { - cmd.arg("-d"); - cmd.arg(distro); - } - - // 指定工作目录 - if let Some(ref path) = opts.initial_path { - cmd.arg("--cd"); - cmd.arg(path); - } else if let Some(cwd) = block_meta.sanitized_cmd_cwd() { - cmd.arg("--cd"); - cmd.arg(cwd); - } - - // 如果有命令要执行 - if let Some(ref command) = opts.command { - cmd.arg("--"); - cmd.arg(command); - } else if let Some(ref meta_cmd) = block_meta.cmd { - cmd.arg("--"); - cmd.arg(meta_cmd); - if let Some(ref args) = block_meta.cmd_args { - for arg in args { - cmd.arg(arg); - } - } - } - - // 设置环境变量 - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); - - if let Some(ref env_vars) = block_meta.cmd_env { - for (key, value) in env_vars { - cmd.env(key, value); - } - } - - Ok(cmd) - } - - /// 启动输出读取任务 - #[cfg(target_os = "windows")] - fn spawn_output_reader( - block_id: String, - mut reader: Box, - app_handle: impl TerminalEventEmit, - shutdown_flag: Arc, - exit_code: Arc, - exited: Arc, - block_file: Option>, - ) { - std::thread::spawn(move || { - let mut buffer = [0u8; 4096]; - - loop { - if shutdown_flag.load(Ordering::Relaxed) { - tracing::debug!("[WSLShellProc] 收到关闭信号: block_id={}", block_id); - break; - } - - match reader.read(&mut buffer) { - Ok(0) => { - tracing::info!("[WSLShellProc] WSL 进程已退出: block_id={}", block_id); - exited.store(true, Ordering::SeqCst); - - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: block_id.clone(), - status: SessionStatus::Done, - exit_code: Some(exit_code.load(Ordering::SeqCst)), - error: None, - }, - ); - break; - } - Ok(n) => { - let output_data = &buffer[..n]; - - if let Some(ref bf) = block_file { - if let Err(e) = bf.append_data(output_data) { - tracing::warn!( - "[WSLShellProc] 写入块文件失败: block_id={}, error={}", - block_id, - e - ); - } - } - - let data = BASE64.encode(output_data); - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_OUTPUT, - &TerminalOutputEvent { - session_id: block_id.clone(), - data, - }, - ); - } - Err(e) => { - if shutdown_flag.load(Ordering::Relaxed) { - break; - } - - tracing::error!( - "[WSLShellProc] 读取错误: block_id={}, error={}", - block_id, - e - ); - exited.store(true, Ordering::SeqCst); - - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: block_id.clone(), - status: SessionStatus::Error, - exit_code: None, - error: Some(e.to_string()), - }, - ); - break; - } - } - } - }); - } - - /// 启动输入处理任务 - #[cfg(target_os = "windows")] - fn spawn_input_handler( - block_id: String, - writer: Arc>>, - master: Arc>>, - mut input_rx: mpsc::Receiver, - shutdown_flag: Arc, - ) { - use portable_pty::PtySize; - - tokio::spawn(async move { - while let Some(input) = input_rx.recv().await { - if shutdown_flag.load(Ordering::Relaxed) { - break; - } - - // 处理输入数据 - if let Some(data) = &input.input_data { - let mut w = writer.lock(); - if let Err(e) = w.write_all(data) { - tracing::error!( - "[WSLShellProc] 写入失败: block_id={}, error={}", - block_id, - e - ); - continue; - } - if let Err(e) = w.flush() { - tracing::error!( - "[WSLShellProc] Flush 失败: block_id={}, error={}", - block_id, - e - ); - } - } - - // 处理终端大小调整 - if let Some(size) = &input.term_size { - let m = master.lock(); - if let Err(e) = m.resize(PtySize { - rows: size.rows, - cols: size.cols, - pixel_width: 0, - pixel_height: 0, - }) { - tracing::error!( - "[WSLShellProc] 调整大小失败: block_id={}, error={}", - block_id, - e - ); - } else { - tracing::debug!( - "[WSLShellProc] 调整大小: block_id={}, size={}x{}", - block_id, - size.cols, - size.rows - ); - } - } - - // 处理信号 - if let Some(sig_name) = &input.sig_name { - tracing::debug!( - "[WSLShellProc] 收到信号: block_id={}, signal={}", - block_id, - sig_name - ); - } - } - - tracing::debug!("[WSLShellProc] 输入处理任务结束: block_id={}", block_id); - }); - } - - /// 获取 Block ID - pub fn block_id(&self) -> &str { - &self.block_id - } - - /// 获取 WSL 选项 - pub fn opts(&self) -> &WSLOpts { - &self.opts - } - - /// 检查进程是否已退出 - pub fn is_exited(&self) -> bool { - self.exited.load(Ordering::SeqCst) - } - - /// 获取退出码 - pub fn get_exit_code(&self) -> i32 { - self.exit_code.load(Ordering::SeqCst) - } - - /// 写入数据到 PTY - #[cfg(target_os = "windows")] - pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> { - let mut writer = self.writer.lock(); - writer - .write_all(data) - .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; - writer - .flush() - .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; - Ok(()) - } - - /// 写入数据到 PTY(非 Windows 平台) - #[cfg(not(target_os = "windows"))] - pub fn write(&self, _data: &[u8]) -> Result<(), TerminalError> { - Err(TerminalError::WSLConnectionFailed( - "WSL 仅在 Windows 平台上可用".to_string(), - )) - } - - /// 调整 PTY 大小 - /// - /// _Requirements: 5.6_ - #[cfg(target_os = "windows")] - pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> { - use portable_pty::PtySize; - - let master = self.master.lock(); - master - .resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| TerminalError::ResizeFailed(e.to_string()))?; - tracing::debug!( - "[WSLShellProc] 调整大小: block_id={}, size={}x{}", - self.block_id, - cols, - rows - ); - Ok(()) - } - - /// 调整 PTY 大小(非 Windows 平台) - #[cfg(not(target_os = "windows"))] - pub fn resize(&self, _rows: u16, _cols: u16) -> Result<(), TerminalError> { - Err(TerminalError::WSLConnectionFailed( - "WSL 仅在 Windows 平台上可用".to_string(), - )) - } - - /// 优雅终止进程 - pub async fn terminate(&self) -> i32 { - tracing::info!( - "[WSLShellProc] 优雅终止 WSL 进程: block_id={}", - self.block_id - ); - self.shutdown_flag.store(true, Ordering::SeqCst); - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - self.exit_code.load(Ordering::SeqCst) - } - - /// 强制终止进程 - pub async fn kill(&self) { - tracing::info!( - "[WSLShellProc] 强制终止 WSL 进程: block_id={}", - self.block_id - ); - self.shutdown_flag.store(true, Ordering::SeqCst); - } -} - -impl Drop for WSLShellProc { - fn drop(&mut self) { - self.shutdown_flag.store(true, Ordering::SeqCst); - tracing::debug!("[WSLShellProc] WSL 进程已销毁: block_id={}", self.block_id); - } -} - -// ============================================================================ -// 辅助函数 -// ============================================================================ - -/// 检查连接名称是否为 WSL 连接 -/// -/// WSL 连接名称以 "wsl://" 开头或等于 "wsl"。 -pub fn is_wsl_conn_name(conn_name: &str) -> bool { - let conn_name = conn_name.trim().to_lowercase(); - conn_name.starts_with(WSL_CONN_PREFIX) || conn_name == "wsl" -} - -// ============================================================================ -// 单元测试 -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - - // WSLOpts 解析测试 - mod wsl_opts_tests { - use super::*; - - #[test] - fn test_parse_empty_wsl() { - let opts = WSLOpts::parse("wsl://").unwrap(); - assert!(opts.distro.is_none()); - assert!(opts.initial_path.is_none()); - assert!(opts.command.is_none()); - } - - #[test] - fn test_parse_simple_wsl() { - let opts = WSLOpts::parse("wsl").unwrap(); - assert!(opts.distro.is_none()); - assert!(opts.initial_path.is_none()); - } - - #[test] - fn test_parse_with_distro() { - let opts = WSLOpts::parse("wsl://Ubuntu").unwrap(); - assert_eq!(opts.distro, Some("Ubuntu".to_string())); - assert!(opts.initial_path.is_none()); - } - - #[test] - fn test_parse_with_distro_and_path() { - let opts = WSLOpts::parse("wsl://Ubuntu/home/user").unwrap(); - assert_eq!(opts.distro, Some("Ubuntu".to_string())); - assert_eq!(opts.initial_path, Some("/home/user".to_string())); - } - - #[test] - fn test_parse_debian() { - let opts = WSLOpts::parse("wsl://Debian").unwrap(); - assert_eq!(opts.distro, Some("Debian".to_string())); - } - - #[test] - fn test_parse_invalid_prefix() { - let result = WSLOpts::parse("ssh://user@host"); - assert!(result.is_err()); - } - - #[test] - fn test_parse_empty_string() { - let result = WSLOpts::parse(""); - assert!(result.is_err()); - } - - #[test] - fn test_effective_distro_default() { - let opts = WSLOpts::default(); - assert_eq!(opts.effective_distro(), DEFAULT_WSL_DISTRO); - } - - #[test] - fn test_effective_distro_specified() { - let opts = WSLOpts::new().with_distro("Debian"); - assert_eq!(opts.effective_distro(), "Debian"); - } - - #[test] - fn test_to_connection_string_empty() { - let opts = WSLOpts::default(); - assert_eq!(opts.to_connection_string(), "wsl://"); - } - - #[test] - fn test_to_connection_string_with_distro() { - let opts = WSLOpts::new().with_distro("Ubuntu"); - assert_eq!(opts.to_connection_string(), "wsl://Ubuntu"); - } - - #[test] - fn test_to_connection_string_with_path() { - let opts = WSLOpts::new().with_distro("Ubuntu").with_path("/home/user"); - assert_eq!(opts.to_connection_string(), "wsl://Ubuntu/home/user"); - } - - #[test] - fn test_round_trip_simple() { - let original = "wsl://Ubuntu"; - let opts = WSLOpts::parse(original).unwrap(); - assert_eq!(opts.to_connection_string(), original); - } - - #[test] - fn test_round_trip_with_path() { - let original = "wsl://Debian/home/user"; - let opts = WSLOpts::parse(original).unwrap(); - assert_eq!(opts.to_connection_string(), original); - } - - #[test] - fn test_builder_pattern() { - let opts = WSLOpts::new() - .with_distro("Ubuntu") - .with_path("/home/user") - .with_command("ls -la"); - - assert_eq!(opts.distro, Some("Ubuntu".to_string())); - assert_eq!(opts.initial_path, Some("/home/user".to_string())); - assert_eq!(opts.command, Some("ls -la".to_string())); - } - - #[test] - fn test_from_str() { - let opts: WSLOpts = "wsl://Ubuntu".parse().unwrap(); - assert_eq!(opts.distro, Some("Ubuntu".to_string())); - } - - #[test] - fn test_display() { - let opts = WSLOpts::new().with_distro("Ubuntu"); - assert_eq!(format!("{opts}"), "wsl://Ubuntu"); - } - } - - // is_wsl_conn_name 测试 - mod is_wsl_conn_name_tests { - use super::*; - - #[test] - fn test_wsl_prefix() { - assert!(is_wsl_conn_name("wsl://Ubuntu")); - assert!(is_wsl_conn_name("wsl://Debian")); - assert!(is_wsl_conn_name("wsl://")); - } - - #[test] - fn test_simple_wsl() { - assert!(is_wsl_conn_name("wsl")); - assert!(is_wsl_conn_name("WSL")); - assert!(is_wsl_conn_name("Wsl")); - } - - #[test] - fn test_not_wsl() { - assert!(!is_wsl_conn_name("ssh://user@host")); - assert!(!is_wsl_conn_name("local")); - assert!(!is_wsl_conn_name("")); - assert!(!is_wsl_conn_name("wslx")); - } - - #[test] - fn test_with_whitespace() { - assert!(is_wsl_conn_name(" wsl://Ubuntu ")); - assert!(is_wsl_conn_name(" wsl ")); - } - } - - // WSLDistroState 测试 - mod distro_state_tests { - use super::*; - - #[test] - fn test_default() { - let state = WSLDistroState::default(); - assert_eq!(state, WSLDistroState::Unknown); - } - - #[test] - fn test_display() { - assert_eq!(format!("{}", WSLDistroState::Running), "running"); - assert_eq!(format!("{}", WSLDistroState::Stopped), "stopped"); - assert_eq!(format!("{}", WSLDistroState::Installing), "installing"); - assert_eq!(format!("{}", WSLDistroState::Unknown), "unknown"); - } - } - - // WSLConn 测试 - mod wsl_conn_tests { - use super::*; - - #[test] - fn test_new() { - let opts = WSLOpts::new().with_distro("Ubuntu"); - let conn = WSLConn::new(opts); - - assert_eq!(conn.state(), ConnectionState::Init); - assert!(!conn.is_connected()); - assert!(conn.error().is_none()); - } - - #[test] - fn test_from_connection_string() { - let conn = WSLConn::from_connection_string("wsl://Ubuntu").unwrap(); - assert_eq!(conn.opts().distro, Some("Ubuntu".to_string())); - } - - #[test] - fn test_derive_conn_status() { - let opts = WSLOpts::new().with_distro("Ubuntu"); - let conn = WSLConn::new(opts); - - let status = conn.derive_conn_status(); - assert_eq!(status.status, "init"); - assert!(!status.connected); - assert_eq!(status.connection, "wsl://Ubuntu"); - assert!(!status.has_connected); - } - - #[test] - fn test_is_wsl_available_non_windows() { - // 在非 Windows 平台上应该返回 false - #[cfg(not(target_os = "windows"))] - { - assert!(!WSLConn::is_wsl_available()); - } - } - - #[test] - fn test_list_distros_non_windows() { - // 在非 Windows 平台上应该返回空列表 - #[cfg(not(target_os = "windows"))] - { - let distros = WSLConn::list_distros().unwrap(); - assert!(distros.is_empty()); - } - } - } -} diff --git a/src-tauri/crates/terminal/src/emit_helper.rs b/src-tauri/crates/terminal/src/emit_helper.rs deleted file mode 100644 index 1c121f22a..000000000 --- a/src-tauri/crates/terminal/src/emit_helper.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! 事件发射辅助函数 -//! -//! 提供类型安全的事件发射便捷方法,封装序列化逻辑。 - -use serde::Serialize; - -use crate::emitter::TerminalEventEmit; - -/// 发射序列化事件 -/// -/// 将 payload 序列化为 JSON 后通过 emitter 发射。 -pub fn emit( - emitter: &E, - event: &str, - payload: &T, -) -> Result<(), String> { - let value = serde_json::to_value(payload).map_err(|e| format!("序列化事件数据失败: {e}"))?; - emitter.emit_event(event, &value) -} diff --git a/src-tauri/crates/terminal/src/emitter.rs b/src-tauri/crates/terminal/src/emitter.rs deleted file mode 100644 index 336969d5c..000000000 --- a/src-tauri/crates/terminal/src/emitter.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! 事件发射器抽象 -//! -//! 定义事件发射器 trait 层次,用于将 Tauri 事件发射功能抽象化, -//! 使终端模块不直接依赖 Tauri。 -//! -//! ## 设计 -//! - `TerminalEventEmit`:基础 trait(dyn 兼容,不要求 Clone) -//! - `TerminalEventEmitter`:扩展 trait(要求 Clone,用于泛型参数) -//! - `DynEmitter`:`Arc` 的 newtype,实现 `TerminalEventEmitter` - -use std::path::PathBuf; -use std::sync::Arc; - -/// 基础事件发射 trait(dyn 兼容) -/// -/// 不要求 `Clone`,可以用作 `dyn TerminalEventEmit`。 -/// 主 crate 中为 `tauri::AppHandle` 实现此 trait。 -pub trait TerminalEventEmit: Send + Sync + 'static { - /// 发射事件到前端 - fn emit_event(&self, event: &str, payload: &serde_json::Value) -> Result<(), String>; - - /// 获取应用数据目录 - fn app_data_dir(&self) -> Result; -} - -/// 扩展事件发射器 trait(要求 Clone) -/// -/// 用于泛型参数场景(如 `ShellController`、`ShellProc`)。 -/// 所有实现了 `TerminalEventEmit + Clone` 的类型自动实现此 trait。 -pub trait TerminalEventEmitter: TerminalEventEmit + Clone {} - -/// 自动实现:任何 `TerminalEventEmit + Clone` 的类型都是 `TerminalEventEmitter` -impl TerminalEventEmitter for T {} - -/// 动态事件发射器包装 -/// -/// 使用 `Arc` 包装,实现 `Clone` + `TerminalEventEmit`, -/// 从而自动获得 `TerminalEventEmitter`。 -/// -/// 用于需要存储和传递发射器但不想泛型化的场景(如 `TerminalSessionManager`)。 -#[derive(Clone)] -pub struct DynEmitter(pub Arc); - -impl DynEmitter { - /// 从实现了 TerminalEventEmit 的类型创建 - pub fn new(emitter: impl TerminalEventEmit) -> Self { - Self(Arc::new(emitter)) - } -} - -impl TerminalEventEmit for DynEmitter { - fn emit_event(&self, event: &str, payload: &serde_json::Value) -> Result<(), String> { - self.0.emit_event(event, payload) - } - - fn app_data_dir(&self) -> Result { - self.0.app_data_dir() - } -} - -/// 空事件发射器(用于测试) -#[derive(Debug, Clone)] -pub struct NoOpEmitter; - -impl TerminalEventEmit for NoOpEmitter { - fn emit_event(&self, _event: &str, _payload: &serde_json::Value) -> Result<(), String> { - Ok(()) - } - - fn app_data_dir(&self) -> Result { - Ok(PathBuf::from(".lime")) - } -} diff --git a/src-tauri/crates/terminal/src/error.rs b/src-tauri/crates/terminal/src/error.rs deleted file mode 100644 index a9b41a72c..000000000 --- a/src-tauri/crates/terminal/src/error.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! 终端模块错误类型 -//! -//! 定义终端核心能力相关的错误类型。 -//! -//! ## 功能 -//! - 会话管理错误 -//! - PTY 操作错误 -//! - 块文件存储错误 -//! - 数据库错误 -//! - 序列化支持 - -use thiserror::Error; - -/// 终端错误类型 -#[derive(Debug, Error)] -pub enum TerminalError { - /// 会话不存在 - #[error("会话不存在: {0}")] - SessionNotFound(String), - - /// PTY 创建失败 - #[error("PTY 创建失败: {0}")] - PtyCreationFailed(String), - - /// 写入失败 - #[error("写入失败: {0}")] - WriteFailed(String), - - /// 调整大小失败 - #[error("调整大小失败: {0}")] - ResizeFailed(String), - - /// 会话已关闭 - #[error("会话已关闭")] - SessionClosed, - - /// Base64 解码失败 - #[error("Base64 解码失败: {0}")] - Base64DecodeFailed(String), - - /// 块文件错误 - #[error("块文件错误: {0}")] - BlockFileError(String), - - /// 数据库错误 - #[error("数据库错误: {0}")] - DatabaseError(String), - - /// 控制器未找到 - #[error("控制器未找到: {0}")] - ControllerNotFound(String), - - /// SSH 连接失败 - #[error("SSH 连接失败: {0}")] - SSHConnectionFailed(String), - - /// SSH 认证失败 - #[error("SSH 认证失败: {0}")] - SSHAuthFailed(String), - - /// WSL 连接失败 - #[error("WSL 连接失败: {0}")] - WSLConnectionFailed(String), - - /// 无效的 OSC 序列 - #[error("无效的 OSC 序列: {0}")] - InvalidOSCSequence(String), - - /// 连接超时 - #[error("连接超时")] - ConnectionTimeout, - - /// 用户取消 - #[error("用户取消")] - UserCancelled, - - /// 主机密钥验证失败 - #[error("主机密钥验证失败: {0}")] - HostKeyVerificationFailed(String), - - /// 内部错误 - #[error("内部错误: {0}")] - Internal(String), - - /// 无效的连接类型 - #[error("无效的连接类型: {0}")] - InvalidConnectionType(String), -} - -impl From for String { - fn from(err: TerminalError) -> Self { - err.to_string() - } -} - -impl serde::Serialize for TerminalError { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_string()) - } -} diff --git a/src-tauri/crates/terminal/src/events.rs b/src-tauri/crates/terminal/src/events.rs deleted file mode 100644 index 26ea15c57..000000000 --- a/src-tauri/crates/terminal/src/events.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! 终端事件定义 -//! -//! 定义终端模块使用的 Tauri 事件类型。 -//! -//! ## 事件列表 -//! - `terminal:output` - 终端输出数据 -//! - `terminal:status` - 终端状态变化 -//! - `terminal:shell-integration` - Shell 集成状态变化 -//! - `terminal:clipboard-write` - 剪贴板写入请求 -//! - `terminal:conn-change` - 连接状态变化 - -use serde::{Deserialize, Serialize}; - -use crate::connections::ConnStatus; - -/// 会话状态 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum SessionStatus { - /// 正在连接 - #[default] - Connecting, - /// 运行中 - Running, - /// 已结束 - Done, - /// 错误 - Error, -} - -/// 终端输出事件 -/// -/// Event name: `terminal:output` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TerminalOutputEvent { - /// 会话 ID - pub session_id: String, - /// 输出数据(Base64 编码) - pub data: String, -} - -/// 终端状态事件 -/// -/// Event name: `terminal:status` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TerminalStatusEvent { - /// 会话 ID - pub session_id: String, - /// 会话状态 - pub status: SessionStatus, - /// 退出码(仅当状态为 Done 时有效) - pub exit_code: Option, - /// 错误信息(仅当状态为 Error 时有效) - pub error: Option, -} - -/// 连接状态变更事件 -/// -/// Event name: `terminal:conn-change` -/// -/// _Requirements: 7.3_ -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnChangeEvent { - /// 连接名称 - pub connection: String, - /// 连接状态详情 - pub status: ConnStatus, -} - -/// 事件名称常量 -pub mod event_names { - /// 终端输出事件名 - pub const TERMINAL_OUTPUT: &str = "terminal:output"; - /// 终端状态事件名 - pub const TERMINAL_STATUS: &str = "terminal:status"; - /// Shell 集成状态事件名 - pub const SHELL_INTEGRATION_STATUS: &str = "terminal:shell-integration"; - /// 剪贴板写入事件名 - pub const CLIPBOARD_WRITE: &str = "terminal:clipboard-write"; - /// 连接状态变更事件名 - pub const CONN_CHANGE: &str = "terminal:conn-change"; -} diff --git a/src-tauri/crates/terminal/src/integration/mod.rs b/src-tauri/crates/terminal/src/integration/mod.rs deleted file mode 100644 index d51d2547a..000000000 --- a/src-tauri/crates/terminal/src/integration/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! 集成模块 -//! -//! 提供 Shell 集成、OSC 序列解析、状态重同步等功能。 -//! -//! ## 模块结构 -//! - `osc_parser` - OSC 序列解析器 -//! - `shell_integration` - Shell 集成处理器 -//! - `shell_scripts` - Shell 集成脚本管理 -//! - `resync` - 状态重同步控制器 -//! -//! ## 功能 -//! - OSC 序列解析(OSC 7/52/133/16162) -//! - Shell 集成状态管理 -//! - Shell 集成脚本安装和管理 -//! - 终端状态重同步 - -pub mod osc_parser; -pub mod resync; -pub mod shell_integration; -pub mod shell_scripts; - -// 重新导出常用类型 -pub use osc_parser::{strip_osc_sequences, OSCParser, OSCSequence, ParsedOSC, PromptMarkType}; -pub use resync::{ - resync_controller, ResyncController, ResyncOptions, ResyncResult, TERMINAL_RESET_SEQUENCE, - TERMINAL_SOFT_RESET_SEQUENCE, -}; -pub use shell_integration::{ - CommandInfo, ShellIntegration, ShellIntegrationEvent, ShellIntegrationStatus, ShellType, -}; -pub use shell_scripts::{ShellLaunchBuilder, ShellLaunchConfig, ShellScripts, TerminalEnvConfig}; diff --git a/src-tauri/crates/terminal/src/integration/osc_parser.rs b/src-tauri/crates/terminal/src/integration/osc_parser.rs deleted file mode 100644 index ddedb4659..000000000 --- a/src-tauri/crates/terminal/src/integration/osc_parser.rs +++ /dev/null @@ -1,691 +0,0 @@ -//! OSC 序列解析器 -//! -//! 解析终端输出中的 OSC(Operating System Command)序列,支持: -//! - OSC 7: 当前工作目录 -//! - OSC 52: 剪贴板操作 -//! - OSC 133: 命令提示符标记(Shell Integration) -//! - OSC 16162: Wave 特定命令 -//! -//! ## 功能 -//! - 从字节流中识别和解析 OSC 序列 -//! - 支持多种 OSC 序列类型 -//! - 无效序列容错处理 -//! -//! ## Requirements -//! - 6.1: OSC 7 当前目录解析 -//! - 6.2: OSC 52 剪贴板解析 -//! - 6.3: OSC 133 命令提示符标记解析 -//! - 6.4: OSC 16162 Wave 命令解析 -//! - 6.7: 无效序列容错处理 - -use std::ops::Range; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; - -/// OSC 序列起始标记 -const OSC_START: &[u8] = b"\x1b]"; -/// OSC 序列结束标记 - BEL -const OSC_END_BEL: u8 = 0x07; -/// OSC 序列结束标记 - ST (String Terminator) -/// 预留:某些终端使用 ST 而不是 BEL 作为结束标记 -#[allow(dead_code)] -const OSC_END_ST: &[u8] = b"\x1b\\"; - -/// OSC 序列类型 -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum OSCSequence { - /// OSC 7 - 当前工作目录 - /// 格式: OSC 7 ; file://hostname/path ST - CurrentDirectory { - /// 主机名(可选) - hostname: Option, - /// 路径 - path: String, - }, - - /// OSC 52 - 剪贴板操作 - /// 格式: OSC 52 ; selection ; base64-data ST - Clipboard { - /// 选择类型 (c=clipboard, p=primary, s=secondary, etc.) - selection: String, - /// Base64 编码的数据 - data: String, - }, - - /// OSC 133 - 命令提示符标记(Shell Integration) - /// 格式: OSC 133 ; type ST - PromptMark { - /// 标记类型 - mark_type: PromptMarkType, - }, - - /// OSC 16162 - Wave 特定命令 - /// 格式: OSC 16162 ; command ST - WaveCommand { - /// 命令内容 - command: String, - }, - - /// 未知的 OSC 序列 - Unknown { - /// OSC 代码 - code: String, - /// 参数 - params: String, - }, -} - -/// 命令提示符标记类型(OSC 133) -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PromptMarkType { - /// A - 提示符开始(Prompt Start) - PromptStart, - /// B - 命令开始(Command Start,用户输入开始) - CommandStart, - /// C - 命令执行(Command Executed) - CommandExecuted, - /// D - 命令结束(Command Finished) - CommandFinished, - /// 未知标记类型 - Unknown(char), -} - -impl PromptMarkType { - /// 从字符解析标记类型 - pub fn from_char(c: char) -> Self { - match c { - 'A' => Self::PromptStart, - 'B' => Self::CommandStart, - 'C' => Self::CommandExecuted, - 'D' => Self::CommandFinished, - other => Self::Unknown(other), - } - } - - /// 转换为字符 - pub fn to_char(&self) -> char { - match self { - Self::PromptStart => 'A', - Self::CommandStart => 'B', - Self::CommandExecuted => 'C', - Self::CommandFinished => 'D', - Self::Unknown(c) => *c, - } - } -} - -/// 解析结果 -#[derive(Debug, Clone)] -pub struct ParsedOSC { - /// 解析出的 OSC 序列 - pub sequence: OSCSequence, - /// 序列在原始数据中的位置范围 - pub range: Range, -} - -/// OSC 序列解析器 -pub struct OSCParser; - -impl OSCParser { - /// 从字节流中解析所有 OSC 序列 - /// - /// # 参数 - /// - `data`: 输入字节流 - /// - /// # 返回 - /// 解析出的 OSC 序列列表,每个元素包含序列和位置范围 - /// - /// # 示例 - /// ```ignore - /// use lime_terminal::integration::osc_parser::{OSCParser, OSCSequence}; - /// - /// let data = b"\x1b]7;file://localhost/home/user\x07"; - /// let results = OSCParser::parse(data); - /// assert_eq!(results.len(), 1); - /// ``` - /// - /// _Requirements: 6.1, 6.2, 6.3, 6.4, 6.7_ - pub fn parse(data: &[u8]) -> Vec { - let mut results = Vec::new(); - let mut pos = 0; - - while pos < data.len() { - // 查找 OSC 起始标记 - if let Some(start_offset) = Self::find_osc_start(&data[pos..]) { - let start = pos + start_offset; - let content_start = start + OSC_START.len(); - - // 查找 OSC 结束标记 - if let Some((end, terminator_len)) = Self::find_osc_end(&data[content_start..]) { - let content_end = content_start + end; - let sequence_end = content_end + terminator_len; - - // 解析 OSC 内容 - let content = &data[content_start..content_end]; - if let Some(sequence) = Self::parse_osc_content(content) { - results.push(ParsedOSC { - sequence, - range: start..sequence_end, - }); - } - - pos = sequence_end; - } else { - // 没有找到结束标记,跳过这个起始标记 - pos = content_start; - } - } else { - // 没有更多 OSC 序列 - break; - } - } - - results - } - - /// 解析单个 OSC 序列 - /// - /// # 参数 - /// - `data`: 完整的 OSC 序列(包含起始和结束标记) - /// - /// # 返回 - /// 解析成功返回 OSC 序列,失败返回 None - pub fn parse_single(data: &[u8]) -> Option { - // 检查起始标记 - if !data.starts_with(OSC_START) { - return None; - } - - let content_start = OSC_START.len(); - - // 查找结束标记 - let (end, _) = Self::find_osc_end(&data[content_start..])?; - let content = &data[content_start..content_start + end]; - - Self::parse_osc_content(content) - } - - /// 查找 OSC 起始标记 - fn find_osc_start(data: &[u8]) -> Option { - data.windows(OSC_START.len()).position(|w| w == OSC_START) - } - - /// 查找 OSC 结束标记 - /// - /// 返回 (结束位置, 终止符长度) - fn find_osc_end(data: &[u8]) -> Option<(usize, usize)> { - for (i, &byte) in data.iter().enumerate() { - // BEL 终止符 - if byte == OSC_END_BEL { - return Some((i, 1)); - } - // ST 终止符 (ESC \) - if byte == 0x1b && i + 1 < data.len() && data[i + 1] == b'\\' { - return Some((i, 2)); - } - } - None - } - - /// 解析 OSC 内容 - fn parse_osc_content(content: &[u8]) -> Option { - // 转换为字符串 - let content_str = String::from_utf8_lossy(content); - - // 分割 OSC 代码和参数 - let (code, params) = match content_str.find(';') { - Some(pos) => (&content_str[..pos], &content_str[pos + 1..]), - None => (content_str.as_ref(), ""), - }; - - // 根据 OSC 代码解析 - match code { - "7" => Self::parse_osc_7(params), - "52" => Self::parse_osc_52(params), - "133" => Self::parse_osc_133(params), - "16162" => Self::parse_osc_16162(params), - _ => Some(OSCSequence::Unknown { - code: code.to_string(), - params: params.to_string(), - }), - } - } - - /// 解析 OSC 7 - 当前工作目录 - /// - /// 格式: file://hostname/path 或 file:///path - /// - /// _Requirements: 6.1_ - fn parse_osc_7(params: &str) -> Option { - // 移除 file:// 前缀 - let path_part = params.strip_prefix("file://")?; - - // 解析主机名和路径 - let (hostname, path) = if path_part.starts_with('/') { - // file:///path 格式(无主机名) - (None, path_part.to_string()) - } else { - // file://hostname/path 格式 - match path_part.find('/') { - Some(pos) => { - let host = &path_part[..pos]; - let path = &path_part[pos..]; - ( - if host.is_empty() { - None - } else { - Some(host.to_string()) - }, - path.to_string(), - ) - } - None => { - // 只有主机名,没有路径 - (Some(path_part.to_string()), "/".to_string()) - } - } - }; - - // URL 解码路径 - let decoded_path = Self::url_decode(&path); - - Some(OSCSequence::CurrentDirectory { - hostname, - path: decoded_path, - }) - } - - /// 解析 OSC 52 - 剪贴板操作 - /// - /// 格式: selection;base64-data - /// - /// _Requirements: 6.2_ - fn parse_osc_52(params: &str) -> Option { - let (selection, data) = match params.find(';') { - Some(pos) => (¶ms[..pos], ¶ms[pos + 1..]), - None => (params, ""), - }; - - Some(OSCSequence::Clipboard { - selection: selection.to_string(), - data: data.to_string(), - }) - } - - /// 解析 OSC 133 - 命令提示符标记 - /// - /// 格式: type (A/B/C/D) - /// - /// _Requirements: 6.3_ - fn parse_osc_133(params: &str) -> Option { - let mark_char = params.chars().next()?; - let mark_type = PromptMarkType::from_char(mark_char); - - Some(OSCSequence::PromptMark { mark_type }) - } - - /// 解析 OSC 16162 - Wave 命令 - /// - /// _Requirements: 6.4_ - fn parse_osc_16162(params: &str) -> Option { - Some(OSCSequence::WaveCommand { - command: params.to_string(), - }) - } - - /// URL 解码 - fn url_decode(input: &str) -> String { - let mut result = String::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - - while let Some(c) = chars.next() { - if c == '%' { - // 尝试解析两个十六进制字符 - let hex: String = chars.by_ref().take(2).collect(); - if hex.len() == 2 { - if let Ok(byte) = u8::from_str_radix(&hex, 16) { - result.push(byte as char); - continue; - } - } - // 解析失败,保留原样 - result.push('%'); - result.push_str(&hex); - } else { - result.push(c); - } - } - - result - } - - /// 从 OSC 52 数据中解码剪贴板内容 - /// - /// # 参数 - /// - `data`: Base64 编码的数据 - /// - /// # 返回 - /// 解码后的字符串,解码失败返回 None - pub fn decode_clipboard_data(data: &str) -> Option { - if data == "?" { - // 查询请求 - return None; - } - - let decoded = BASE64.decode(data).ok()?; - String::from_utf8(decoded).ok() - } - - /// 编码剪贴板内容为 OSC 52 格式 - /// - /// # 参数 - /// - `selection`: 选择类型 - /// - `content`: 要编码的内容 - /// - /// # 返回 - /// 完整的 OSC 52 序列 - pub fn encode_clipboard(selection: &str, content: &str) -> Vec { - let encoded = BASE64.encode(content.as_bytes()); - format!("\x1b]52;{selection};{encoded}\x07").into_bytes() - } - - /// 构建 OSC 7 序列 - /// - /// # 参数 - /// - `hostname`: 主机名(可选) - /// - `path`: 路径 - /// - /// # 返回 - /// 完整的 OSC 7 序列 - pub fn build_osc_7(hostname: Option<&str>, path: &str) -> Vec { - let host = hostname.unwrap_or(""); - format!("\x1b]7;file://{host}{path}\x07").into_bytes() - } - - /// 构建 OSC 133 序列 - /// - /// # 参数 - /// - `mark_type`: 标记类型 - /// - /// # 返回 - /// 完整的 OSC 133 序列 - pub fn build_osc_133(mark_type: PromptMarkType) -> Vec { - format!("\x1b]133;{}\x07", mark_type.to_char()).into_bytes() - } -} - -/// 从数据流中过滤掉 OSC 序列,返回纯文本数据 -/// -/// # 参数 -/// - `data`: 输入数据 -/// -/// # 返回 -/// 过滤后的数据 -pub fn strip_osc_sequences(data: &[u8]) -> Vec { - let parsed = OSCParser::parse(data); - if parsed.is_empty() { - return data.to_vec(); - } - - let mut result = Vec::with_capacity(data.len()); - let mut last_end = 0; - - for osc in parsed { - // 添加 OSC 序列之前的数据 - if osc.range.start > last_end { - result.extend_from_slice(&data[last_end..osc.range.start]); - } - last_end = osc.range.end; - } - - // 添加最后一个 OSC 序列之后的数据 - if last_end < data.len() { - result.extend_from_slice(&data[last_end..]); - } - - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_osc_7_with_hostname() { - let data = b"\x1b]7;file://localhost/home/user\x07"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::CurrentDirectory { hostname, path } => { - assert_eq!(hostname.as_deref(), Some("localhost")); - assert_eq!(path, "/home/user"); - } - _ => panic!("Expected CurrentDirectory"), - } - } - - #[test] - fn test_parse_osc_7_without_hostname() { - let data = b"\x1b]7;file:///home/user\x07"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::CurrentDirectory { hostname, path } => { - assert!(hostname.is_none()); - assert_eq!(path, "/home/user"); - } - _ => panic!("Expected CurrentDirectory"), - } - } - - #[test] - fn test_parse_osc_7_url_encoded() { - let data = b"\x1b]7;file:///home/user/my%20folder\x07"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::CurrentDirectory { path, .. } => { - assert_eq!(path, "/home/user/my folder"); - } - _ => panic!("Expected CurrentDirectory"), - } - } - - #[test] - fn test_parse_osc_52() { - let data = b"\x1b]52;c;SGVsbG8gV29ybGQ=\x07"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::Clipboard { selection, data } => { - assert_eq!(selection, "c"); - assert_eq!(data, "SGVsbG8gV29ybGQ="); - - // 验证解码 - let decoded = OSCParser::decode_clipboard_data(data); - assert_eq!(decoded, Some("Hello World".to_string())); - } - _ => panic!("Expected Clipboard"), - } - } - - #[test] - fn test_parse_osc_133() { - // 测试所有标记类型 - let test_cases = [ - (b"\x1b]133;A\x07".as_slice(), PromptMarkType::PromptStart), - (b"\x1b]133;B\x07".as_slice(), PromptMarkType::CommandStart), - ( - b"\x1b]133;C\x07".as_slice(), - PromptMarkType::CommandExecuted, - ), - ( - b"\x1b]133;D\x07".as_slice(), - PromptMarkType::CommandFinished, - ), - ]; - - for (data, expected_type) in test_cases { - let results = OSCParser::parse(data); - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::PromptMark { mark_type } => { - assert_eq!(*mark_type, expected_type); - } - _ => panic!("Expected PromptMark"), - } - } - } - - #[test] - fn test_parse_osc_16162() { - let data = b"\x1b]16162;setcwd /home/user\x07"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::WaveCommand { command } => { - assert_eq!(command, "setcwd /home/user"); - } - _ => panic!("Expected WaveCommand"), - } - } - - #[test] - fn test_parse_multiple_osc() { - let data = b"Hello\x1b]7;file:///home\x07World\x1b]133;A\x07End"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 2); - assert!(matches!( - &results[0].sequence, - OSCSequence::CurrentDirectory { .. } - )); - assert!(matches!( - &results[1].sequence, - OSCSequence::PromptMark { .. } - )); - } - - #[test] - fn test_parse_osc_with_st_terminator() { - let data = b"\x1b]7;file:///home/user\x1b\\"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::CurrentDirectory { path, .. } => { - assert_eq!(path, "/home/user"); - } - _ => panic!("Expected CurrentDirectory"), - } - } - - #[test] - fn test_parse_unknown_osc() { - let data = b"\x1b]999;some params\x07"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::Unknown { code, params } => { - assert_eq!(code, "999"); - assert_eq!(params, "some params"); - } - _ => panic!("Expected Unknown"), - } - } - - #[test] - fn test_parse_no_osc() { - let data = b"Hello World"; - let results = OSCParser::parse(data); - assert!(results.is_empty()); - } - - #[test] - fn test_strip_osc_sequences() { - let data = b"Hello\x1b]7;file:///home\x07World"; - let stripped = strip_osc_sequences(data); - assert_eq!(stripped, b"HelloWorld"); - } - - #[test] - fn test_encode_clipboard() { - let encoded = OSCParser::encode_clipboard("c", "Hello"); - let results = OSCParser::parse(&encoded); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::Clipboard { selection, data } => { - assert_eq!(selection, "c"); - let decoded = OSCParser::decode_clipboard_data(data); - assert_eq!(decoded, Some("Hello".to_string())); - } - _ => panic!("Expected Clipboard"), - } - } - - #[test] - fn test_build_osc_7() { - let osc = OSCParser::build_osc_7(Some("localhost"), "/home/user"); - let results = OSCParser::parse(&osc); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::CurrentDirectory { hostname, path } => { - assert_eq!(hostname.as_deref(), Some("localhost")); - assert_eq!(path, "/home/user"); - } - _ => panic!("Expected CurrentDirectory"), - } - } - - #[test] - fn test_build_osc_133() { - let osc = OSCParser::build_osc_133(PromptMarkType::PromptStart); - let results = OSCParser::parse(&osc); - - assert_eq!(results.len(), 1); - match &results[0].sequence { - OSCSequence::PromptMark { mark_type } => { - assert_eq!(*mark_type, PromptMarkType::PromptStart); - } - _ => panic!("Expected PromptMark"), - } - } - - #[test] - fn test_prompt_mark_type_roundtrip() { - let types = [ - PromptMarkType::PromptStart, - PromptMarkType::CommandStart, - PromptMarkType::CommandExecuted, - PromptMarkType::CommandFinished, - PromptMarkType::Unknown('X'), - ]; - - for mark_type in types { - let c = mark_type.to_char(); - let parsed = PromptMarkType::from_char(c); - assert_eq!(mark_type, parsed); - } - } - - #[test] - fn test_parse_range() { - let data = b"ABC\x1b]7;file:///home\x07XYZ"; - let results = OSCParser::parse(data); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].range.start, 3); - // OSC 序列: \x1b]7;file:///home\x07 - // = ESC(1) + ](1) + "7;file:///home"(14) + BEL(1) = 17 bytes - // 所以 end = 3 + 17 = 20 - assert_eq!(results[0].range.end, 20); - } -} diff --git a/src-tauri/crates/terminal/src/integration/resync.rs b/src-tauri/crates/terminal/src/integration/resync.rs deleted file mode 100644 index 69eb049fc..000000000 --- a/src-tauri/crates/terminal/src/integration/resync.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! 状态重同步控制器 -//! -//! 负责在连接恢复时重建终端状态,包括检查控制器状态、发送终端重置序列、恢复历史数据。 -//! -//! ## 功能 -//! - 检查当前控制器状态并决定是否需要重启 -//! - 连接名称变更时停止旧控制器并创建新控制器 -//! - 发送终端重置序列(重置属性、显示光标、禁用鼠标跟踪等) -//! - 从 BlockFile 读取历史输出并推送到前端 -//! -//! ## Requirements -//! - 2.1: 检查当前控制器状态并决定是否需要重启 -//! - 2.2: 连接名称变更时停止当前控制器并使用新连接重新创建 -//! - 2.3: 发送终端重置序列 -//! - 2.4: 从 BlockFile 读取历史输出并推送到前端 -//! - 2.5: 控制器状态为 "init" 或 "done" 时启动新的控制器实例 -//! - 2.6: force 参数为 true 时强制重启控制器 - -use std::sync::Arc; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; - -use crate::block_controller::{ - BlockController, BlockControllerRuntimeStatus, BlockMeta, ControllerRegistry, RuntimeOpts, - ShellController, -}; -use crate::emit_helper; -use crate::emitter::TerminalEventEmitter; -use crate::error::TerminalError; -use crate::events::{event_names, TerminalOutputEvent}; -use crate::persistence::BlockFile; - -/// 终端重置序列 -/// -/// 用于在重同步时重置终端状态,包括: -/// - ESC c: 完全重置终端 -/// - ESC [?25h: 显示光标 -/// - ESC [?1000l: 禁用鼠标跟踪 -/// - ESC [?1002l: 禁用按钮事件鼠标跟踪 -/// - ESC [?1003l: 禁用任意事件鼠标跟踪 -/// - ESC [?1006l: 禁用 SGR 鼠标模式 -/// - ESC [0m: 重置所有属性 -pub const TERMINAL_RESET_SEQUENCE: &[u8] = - b"\x1bc\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[0m"; - -/// 软重置序列(不完全重置终端) -/// -/// 用于较轻量的重置,保留部分状态: -/// - ESC [!p: 软重置 -/// - ESC [?25h: 显示光标 -/// - ESC [0m: 重置所有属性 -pub const TERMINAL_SOFT_RESET_SEQUENCE: &[u8] = b"\x1b[!p\x1b[?25h\x1b[0m"; - -/// 重同步选项 -#[derive(Debug, Clone, Default)] -pub struct ResyncOptions { - /// 是否强制重启控制器 - pub force: bool, - /// 是否发送完全重置序列(否则使用软重置) - pub full_reset: bool, - /// 是否恢复历史数据 - pub restore_history: bool, -} - -impl ResyncOptions { - /// 创建默认选项 - pub fn new() -> Self { - Self { - force: false, - full_reset: false, - restore_history: true, - } - } - - /// 设置强制重启 - pub fn with_force(mut self, force: bool) -> Self { - self.force = force; - self - } - - /// 设置完全重置 - pub fn with_full_reset(mut self, full_reset: bool) -> Self { - self.full_reset = full_reset; - self - } - - /// 设置是否恢复历史 - pub fn with_restore_history(mut self, restore: bool) -> Self { - self.restore_history = restore; - self - } -} - -/// 重同步结果 -#[derive(Debug, Clone, Default)] -pub struct ResyncResult { - /// 是否创建了新控制器 - pub controller_created: bool, - /// 是否重启了控制器 - pub controller_restarted: bool, - /// 是否恢复了历史数据 - pub history_restored: bool, - /// 恢复的历史数据大小(字节) - pub history_size: usize, -} - -/// 状态重同步控制器 -/// -/// 提供终端状态重同步功能,用于在连接恢复或控制器状态变更时重建终端状态。 -pub struct ResyncController; - -impl ResyncController { - /// 重同步控制器 - /// - /// 检查当前控制器状态并决定是否需要重启、创建新控制器或恢复历史数据。 - /// - /// # 参数 - /// - `registry`: 控制器注册表 - /// - `tab_id`: Tab ID - /// - `block_id`: Block ID - /// - `block_meta`: 块元数据 - /// - `rt_opts`: 运行时选项 - /// - `app_handle`: Tauri 应用句柄 - /// - `block_file`: 块文件(可选) - /// - `options`: 重同步选项 - /// - /// # 返回 - /// - `Ok(ResyncResult)`: 重同步结果 - /// - `Err(TerminalError)`: 重同步失败 - /// - /// _Requirements: 2.1, 2.2, 2.5, 2.6_ - pub async fn resync_controller( - registry: &ControllerRegistry, - tab_id: &str, - block_id: &str, - block_meta: BlockMeta, - rt_opts: Option, - app_handle: impl TerminalEventEmitter, - block_file: Option>, - options: ResyncOptions, - ) -> Result { - let mut result = ResyncResult::default(); - - // 1. 获取控制器名称 - let controller_name = block_meta.get_string("controller"); - let conn_name = block_meta.connection.clone(); - - tracing::info!( - "[ResyncController] 开始重同步: block_id={}, controller={}, conn={:?}, force={}", - block_id, - controller_name, - conn_name, - options.force - ); - - // 2. 如果不需要控制器,停止现有的 - if controller_name.is_empty() { - if let Some(ctrl) = registry.get(block_id).await { - tracing::info!( - "[ResyncController] 控制器名称为空,停止现有控制器: block_id={}", - block_id - ); - let mut ctrl_guard = ctrl.write().await; - ctrl_guard.stop(true, "done".to_string()).await?; - drop(ctrl_guard); - registry.remove(block_id).await; - } - return Ok(result); - } - - // 3. 获取现有控制器 - let existing = registry.get(block_id).await; - - // 4. 检查是否需要替换控制器类型 - if let Some(ref ctrl) = existing { - let ctrl_guard = ctrl.read().await; - let status = ctrl_guard.get_runtime_status(); - let current_type = ctrl_guard.controller_type(); - - // 检查控制器类型是否变更 - let needs_replace = Self::check_needs_replace(current_type, &controller_name); - - if needs_replace { - tracing::info!( - "[ResyncController] 控制器类型变更,需要替换: block_id={}, old={}, new={}", - block_id, - current_type, - controller_name - ); - drop(ctrl_guard); - - // 停止并删除旧控制器 - let mut ctrl_guard = ctrl.write().await; - ctrl_guard.stop(true, "done".to_string()).await?; - drop(ctrl_guard); - registry.remove(block_id).await; - } else if controller_name == "shell" || controller_name == "cmd" { - // 检查连接是否变更 - if status.shell_proc_status == "running" { - if let Some(ref new_conn) = conn_name { - if status.shell_proc_conn_name.as_ref() != Some(new_conn) { - tracing::info!( - "[ResyncController] 连接变更,需要重启: block_id={}, old={:?}, new={}", - block_id, - status.shell_proc_conn_name, - new_conn - ); - drop(ctrl_guard); - - // 停止控制器但不删除,设置状态为 init - let mut ctrl_guard = ctrl.write().await; - ctrl_guard.stop(true, "init".to_string()).await?; - result.controller_restarted = true; - } - } - } - } - } - - // 5. 强制重启 - if options.force { - if let Some(ctrl) = registry.get(block_id).await { - tracing::info!("[ResyncController] 强制重启控制器: block_id={}", block_id); - let mut ctrl_guard = ctrl.write().await; - ctrl_guard.stop(true, "init".to_string()).await?; - result.controller_restarted = true; - } - } - - // 6. 创建或重用控制器 - let controller = match registry.get(block_id).await { - Some(ctrl) => ctrl, - None => { - tracing::info!( - "[ResyncController] 创建新控制器: block_id={}, type={}", - block_id, - controller_name - ); - - let ctrl = Self::create_controller( - &controller_name, - tab_id, - block_id, - app_handle.clone(), - block_file.clone(), - )?; - - registry.register(block_id.to_string(), ctrl).await; - - result.controller_created = true; - - registry - .get(block_id) - .await - .ok_or_else(|| TerminalError::Internal("控制器注册失败".to_string()))? - } - }; - - // 7. 检查是否需要启动 - let status = { - let ctrl_guard = controller.read().await; - ctrl_guard.get_runtime_status() - }; - - if status.shell_proc_status == "init" || status.shell_proc_status == "done" { - tracing::info!( - "[ResyncController] 启动控制器: block_id={}, status={}", - block_id, - status.shell_proc_status - ); - - // 发送重置序列 - Self::send_reset_sequence(&app_handle, block_id, options.full_reset)?; - - // 恢复历史数据 - if options.restore_history { - if let Some(ref bf) = block_file { - let history_size = Self::restore_history(&app_handle, block_id, bf)?; - if history_size > 0 { - result.history_restored = true; - result.history_size = history_size; - } - } - } - - // 启动控制器 - let mut ctrl_guard = controller.write().await; - ctrl_guard.start(block_meta, rt_opts, options.force).await?; - } - - tracing::info!( - "[ResyncController] 重同步完成: block_id={}, created={}, restarted={}, history_restored={}", - block_id, - result.controller_created, - result.controller_restarted, - result.history_restored - ); - - Ok(result) - } - - /// 检查是否需要替换控制器 - /// - /// # 参数 - /// - `current_type`: 当前控制器类型 - /// - `new_type`: 新控制器类型 - /// - /// # 返回 - /// 需要替换返回 true - fn check_needs_replace(current_type: &str, new_type: &str) -> bool { - // 如果类型不同,需要替换 - // shell 和 cmd 可以互相转换(都是 ShellController) - if current_type == new_type { - return false; - } - - // shell 和 cmd 之间不需要替换控制器实例,只需要重启 - if (current_type == "shell" || current_type == "cmd") - && (new_type == "shell" || new_type == "cmd") - { - return false; - } - - true - } - - /// 创建控制器 - /// - /// # 参数 - /// - `controller_name`: 控制器类型名称 - /// - `tab_id`: Tab ID - /// - `block_id`: Block ID - /// - `app_handle`: Tauri 应用句柄 - /// - `block_file`: 块文件(可选) - /// - /// # 返回 - /// 创建的控制器实例 - fn create_controller( - controller_name: &str, - tab_id: &str, - block_id: &str, - app_handle: impl TerminalEventEmitter, - block_file: Option>, - ) -> Result, TerminalError> { - match controller_name { - "shell" | "cmd" => { - let controller = if let Some(bf) = block_file { - ShellController::with_block_file( - tab_id.to_string(), - block_id.to_string(), - controller_name.to_string(), - app_handle, - bf, - ) - } else { - ShellController::new( - tab_id.to_string(), - block_id.to_string(), - controller_name.to_string(), - app_handle, - ) - }; - Ok(Box::new(controller)) - } - _ => Err(TerminalError::Internal(format!( - "未知的控制器类型: {controller_name}" - ))), - } - } - - /// 发送终端重置序列 - /// - /// 通过 Tauri 事件发送终端重置序列到前端。 - /// - /// # 参数 - /// - `app_handle`: Tauri 应用句柄 - /// - `block_id`: Block ID - /// - `full_reset`: 是否使用完全重置序列 - /// - /// _Requirements: 2.3_ - fn send_reset_sequence( - app_handle: &impl TerminalEventEmitter, - block_id: &str, - full_reset: bool, - ) -> Result<(), TerminalError> { - let reset_data = if full_reset { - TERMINAL_RESET_SEQUENCE - } else { - TERMINAL_SOFT_RESET_SEQUENCE - }; - - let data = BASE64.encode(reset_data); - - emit_helper::emit( - app_handle, - event_names::TERMINAL_OUTPUT, - &TerminalOutputEvent { - session_id: block_id.to_string(), - data, - }, - ) - .map_err(|e| TerminalError::Internal(format!("发送重置序列失败: {e}")))?; - - tracing::debug!( - "[ResyncController] 发送重置序列: block_id={}, full={}", - block_id, - full_reset - ); - - Ok(()) - } - - /// 恢复历史数据 - /// - /// 从 BlockFile 读取历史数据并通过 Tauri 事件发送到前端。 - /// - /// # 参数 - /// - `app_handle`: Tauri 应用句柄 - /// - `block_id`: Block ID - /// - `block_file`: 块文件 - /// - /// # 返回 - /// 恢复的数据大小(字节) - /// - /// _Requirements: 2.4_ - fn restore_history( - app_handle: &impl TerminalEventEmitter, - block_id: &str, - block_file: &BlockFile, - ) -> Result { - let history_data = block_file.read_all()?; - - if history_data.is_empty() { - tracing::debug!( - "[ResyncController] 无历史数据需要恢复: block_id={}", - block_id - ); - return Ok(0); - } - - let data_size = history_data.len(); - let data = BASE64.encode(&history_data); - - emit_helper::emit( - app_handle, - event_names::TERMINAL_OUTPUT, - &TerminalOutputEvent { - session_id: block_id.to_string(), - data, - }, - ) - .map_err(|e| TerminalError::Internal(format!("发送历史数据失败: {e}")))?; - - tracing::info!( - "[ResyncController] 恢复历史数据: block_id={}, size={} bytes", - block_id, - data_size - ); - - Ok(data_size) - } - - /// 停止并删除控制器 - /// - /// # 参数 - /// - `registry`: 控制器注册表 - /// - `block_id`: Block ID - /// - `graceful`: 是否优雅停止 - pub async fn stop_and_remove_controller( - registry: &ControllerRegistry, - block_id: &str, - graceful: bool, - ) -> Result<(), TerminalError> { - if let Some(ctrl) = registry.get(block_id).await { - let mut ctrl_guard = ctrl.write().await; - ctrl_guard.stop(graceful, "done".to_string()).await?; - drop(ctrl_guard); - registry.remove(block_id).await; - - tracing::info!( - "[ResyncController] 停止并删除控制器: block_id={}, graceful={}", - block_id, - graceful - ); - } - Ok(()) - } - - /// 获取控制器状态 - /// - /// # 参数 - /// - `registry`: 控制器注册表 - /// - `block_id`: Block ID - /// - /// # 返回 - /// 控制器运行时状态,如果控制器不存在返回 None - pub async fn get_controller_status( - registry: &ControllerRegistry, - block_id: &str, - ) -> Option { - if let Some(ctrl) = registry.get(block_id).await { - let ctrl_guard = ctrl.read().await; - Some(ctrl_guard.get_runtime_status()) - } else { - None - } - } - - /// 检查是否需要重同步 - /// - /// 根据当前控制器状态和块元数据判断是否需要重同步。 - /// - /// # 参数 - /// - `status`: 当前控制器状态(如果存在) - /// - `block_meta`: 块元数据 - /// - /// # 返回 - /// 需要重同步返回 true - pub fn needs_resync( - status: Option<&BlockControllerRuntimeStatus>, - block_meta: &BlockMeta, - ) -> bool { - let controller_name = block_meta.get_string("controller"); - - // 如果没有控制器状态,需要重同步 - let Some(status) = status else { - return !controller_name.is_empty(); - }; - - // 如果控制器名称为空但有控制器,需要重同步(停止控制器) - if controller_name.is_empty() { - return true; - } - - // 如果状态为 init 或 done,需要重同步 - if status.shell_proc_status == "init" || status.shell_proc_status == "done" { - return true; - } - - // 如果连接变更,需要重同步 - if let Some(ref new_conn) = block_meta.connection { - if status.shell_proc_conn_name.as_ref() != Some(new_conn) { - return true; - } - } - - false - } -} - -/// 便捷函数:执行简单的重同步 -/// -/// 使用默认选项执行重同步。 -pub async fn resync_controller( - registry: &ControllerRegistry, - tab_id: &str, - block_id: &str, - block_meta: BlockMeta, - rt_opts: Option, - app_handle: impl TerminalEventEmitter, - block_file: Option>, - force: bool, -) -> Result { - let options = ResyncOptions::new().with_force(force); - ResyncController::resync_controller( - registry, tab_id, block_id, block_meta, rt_opts, app_handle, block_file, options, - ) - .await -} diff --git a/src-tauri/crates/terminal/src/integration/shell_integration.rs b/src-tauri/crates/terminal/src/integration/shell_integration.rs deleted file mode 100644 index 7e2441da7..000000000 --- a/src-tauri/crates/terminal/src/integration/shell_integration.rs +++ /dev/null @@ -1,713 +0,0 @@ -//! Shell 集成处理器 -//! -//! 处理 Shell 集成功能,包括: -//! - 当前工作目录跟踪 -//! - 命令执行状态管理 -//! - 命令时间记录 -//! - OSC 序列处理 -//! -//! ## 功能 -//! - 处理 OSC 7 更新当前目录 -//! - 处理 OSC 52 剪贴板操作 -//! - 处理 OSC 133 命令提示符标记 -//! - 处理 OSC 16162 Wave 命令 -//! -//! ## Requirements -//! - 6.5: 支持 bash、zsh、fish、pwsh 四种 Shell 类型 -//! - 6.6: Shell 集成状态变更事件通知 -//! - 6.8: 命令开始和结束时间记录 - -use std::sync::atomic::{AtomicI64, Ordering}; -use std::sync::RwLock; -use std::time::{SystemTime, UNIX_EPOCH}; - -use serde::{Deserialize, Serialize}; - -use super::osc_parser::{OSCParser, OSCSequence, PromptMarkType}; -use crate::emit_helper; -use crate::emitter::TerminalEventEmit; -use crate::error::TerminalError; -use crate::events::event_names; - -/// Shell 类型 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ShellType { - /// Bash shell - Bash, - /// Zsh shell - Zsh, - /// Fish shell - Fish, - /// PowerShell - Pwsh, - /// 未知 Shell - #[default] - Unknown, -} - -impl ShellType { - /// 从 Shell 路径推断 Shell 类型 - pub fn from_path(path: &str) -> Self { - let path_lower = path.to_lowercase(); - if path_lower.contains("bash") { - Self::Bash - } else if path_lower.contains("zsh") { - Self::Zsh - } else if path_lower.contains("fish") { - Self::Fish - } else if path_lower.contains("pwsh") || path_lower.contains("powershell") { - Self::Pwsh - } else { - Self::Unknown - } - } - - /// 获取 Shell 名称 - pub fn name(&self) -> &'static str { - match self { - Self::Bash => "bash", - Self::Zsh => "zsh", - Self::Fish => "fish", - Self::Pwsh => "pwsh", - Self::Unknown => "unknown", - } - } -} - -/// Shell 集成状态 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "kebab-case")] -pub enum ShellIntegrationStatus { - /// 就绪状态(等待用户输入) - Ready, - /// 正在执行命令 - RunningCommand, - /// 未知状态 - #[default] - Unknown, -} - -/// 命令执行信息 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CommandInfo { - /// 命令开始时间(Unix 时间戳,毫秒) - pub start_time: i64, - /// 命令结束时间(Unix 时间戳,毫秒) - pub end_time: Option, - /// 命令持续时间(毫秒) - pub duration_ms: Option, -} - -impl CommandInfo { - /// 创建新的命令信息 - pub fn new() -> Self { - Self { - start_time: current_timestamp_ms(), - end_time: None, - duration_ms: None, - } - } - - /// 标记命令结束 - pub fn finish(&mut self) { - let end = current_timestamp_ms(); - self.end_time = Some(end); - self.duration_ms = Some(end - self.start_time); - } -} - -impl Default for CommandInfo { - fn default() -> Self { - Self::new() - } -} - -/// Shell 集成状态变更事件 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ShellIntegrationEvent { - /// Block ID - pub block_id: String, - /// 新状态 - pub status: ShellIntegrationStatus, - /// 当前目录(如果有变更) - pub current_dir: Option, - /// 命令信息(如果有) - pub command_info: Option, -} - -/// Shell 集成处理器 -/// -/// 管理单个终端会话的 Shell 集成状态。 -pub struct ShellIntegration { - /// Block ID - block_id: String, - /// Shell 类型 - shell_type: RwLock, - /// 当前工作目录 - current_dir: RwLock>, - /// 集成状态 - status: RwLock, - /// 当前命令信息 - current_command: RwLock>, - /// 上次命令开始时间 - last_command_start: AtomicI64, - /// 事件发射器(可选) - app_handle: Option>, -} - -impl ShellIntegration { - /// 创建新的 Shell 集成处理器 - pub fn new(block_id: String) -> Self { - Self { - block_id, - shell_type: RwLock::new(ShellType::Unknown), - current_dir: RwLock::new(None), - status: RwLock::new(ShellIntegrationStatus::Unknown), - current_command: RwLock::new(None), - last_command_start: AtomicI64::new(0), - app_handle: None, - } - } - - /// 创建带有事件发射器的 Shell 集成处理器 - pub fn with_app_handle(block_id: String, app_handle: impl TerminalEventEmit) -> Self { - Self { - block_id, - shell_type: RwLock::new(ShellType::Unknown), - current_dir: RwLock::new(None), - status: RwLock::new(ShellIntegrationStatus::Unknown), - current_command: RwLock::new(None), - last_command_start: AtomicI64::new(0), - app_handle: Some(std::sync::Arc::new(app_handle)), - } - } - - /// 设置 Shell 类型 - /// - /// # 参数 - /// - `shell_type`: Shell 类型 - pub fn set_shell_type(&self, shell_type: ShellType) { - let mut guard = self.shell_type.write().unwrap(); - *guard = shell_type; - } - - /// 从 Shell 路径设置 Shell 类型 - /// - /// # 参数 - /// - `path`: Shell 可执行文件路径 - pub fn set_shell_type_from_path(&self, path: &str) { - self.set_shell_type(ShellType::from_path(path)); - } - - /// 获取 Shell 类型 - pub fn get_shell_type(&self) -> ShellType { - *self.shell_type.read().unwrap() - } - - /// 获取当前工作目录 - pub fn get_current_dir(&self) -> Option { - self.current_dir.read().unwrap().clone() - } - - /// 获取集成状态 - pub fn get_status(&self) -> ShellIntegrationStatus { - *self.status.read().unwrap() - } - - /// 获取当前命令信息 - pub fn get_current_command(&self) -> Option { - self.current_command.read().unwrap().clone() - } - - /// 处理 PTY 输出数据 - /// - /// 解析数据中的 OSC 序列并更新状态。 - /// - /// # 参数 - /// - `data`: PTY 输出数据 - /// - /// # 返回 - /// 处理的 OSC 序列数量 - pub fn process_output(&self, data: &[u8]) -> usize { - let parsed = OSCParser::parse(data); - let count = parsed.len(); - - for osc in parsed { - if let Err(e) = self.process_osc(&osc.sequence) { - tracing::warn!( - "[ShellIntegration] 处理 OSC 序列失败: block_id={}, error={}", - self.block_id, - e - ); - } - } - - count - } - - /// 处理单个 OSC 序列 - /// - /// # 参数 - /// - `sequence`: OSC 序列 - /// - /// _Requirements: 6.1, 6.2, 6.3, 6.4_ - pub fn process_osc(&self, sequence: &OSCSequence) -> Result<(), TerminalError> { - match sequence { - OSCSequence::CurrentDirectory { hostname: _, path } => { - self.update_current_dir(path.clone()); - } - OSCSequence::Clipboard { selection, data } => { - self.handle_clipboard(selection, data)?; - } - OSCSequence::PromptMark { mark_type } => { - self.handle_prompt_mark(*mark_type); - } - OSCSequence::WaveCommand { command } => { - self.handle_wave_command(command)?; - } - OSCSequence::Unknown { code, params } => { - tracing::debug!( - "[ShellIntegration] 未知 OSC 序列: block_id={}, code={}, params={}", - self.block_id, - code, - params - ); - } - } - Ok(()) - } - - /// 更新当前工作目录 - /// - /// _Requirements: 6.1_ - fn update_current_dir(&self, path: String) { - let old_dir = { - let mut guard = self.current_dir.write().unwrap(); - let old = guard.clone(); - *guard = Some(path.clone()); - old - }; - - if old_dir.as_ref() != Some(&path) { - tracing::debug!( - "[ShellIntegration] 目录变更: block_id={}, old={:?}, new={}", - self.block_id, - old_dir, - path - ); - - // 发送状态变更事件 - self.send_status_event(Some(path), None); - } - } - - /// 处理剪贴板操作 - /// - /// _Requirements: 6.2_ - fn handle_clipboard(&self, selection: &str, data: &str) -> Result<(), TerminalError> { - if data == "?" { - // 查询请求,暂不支持 - tracing::debug!( - "[ShellIntegration] 剪贴板查询请求: block_id={}, selection={}", - self.block_id, - selection - ); - return Ok(()); - } - - // 解码剪贴板数据 - if let Some(content) = OSCParser::decode_clipboard_data(data) { - tracing::debug!( - "[ShellIntegration] 剪贴板写入: block_id={}, selection={}, len={}", - self.block_id, - selection, - content.len() - ); - - // 发送剪贴板事件到前端 - if let Some(ref app_handle) = self.app_handle { - let _ = emit_helper::emit( - app_handle.as_ref(), - event_names::CLIPBOARD_WRITE, - &serde_json::json!({ - "block_id": self.block_id, - "selection": selection, - "content": content, - }), - ); - } - } - - Ok(()) - } - - /// 处理命令提示符标记 - /// - /// _Requirements: 6.3, 6.6, 6.8_ - fn handle_prompt_mark(&self, mark_type: PromptMarkType) { - match mark_type { - PromptMarkType::PromptStart => { - // 提示符开始,命令已结束 - self.finish_command(); - self.set_status(ShellIntegrationStatus::Ready); - } - PromptMarkType::CommandStart => { - // 用户开始输入命令 - // 状态保持 Ready - } - PromptMarkType::CommandExecuted => { - // 命令开始执行 - self.start_command(); - self.set_status(ShellIntegrationStatus::RunningCommand); - } - PromptMarkType::CommandFinished => { - // 命令执行完成 - self.finish_command(); - self.set_status(ShellIntegrationStatus::Ready); - } - PromptMarkType::Unknown(c) => { - tracing::debug!( - "[ShellIntegration] 未知提示符标记: block_id={}, mark={}", - self.block_id, - c - ); - } - } - } - - /// 处理 Wave 命令 - /// - /// _Requirements: 6.4_ - fn handle_wave_command(&self, command: &str) -> Result<(), TerminalError> { - tracing::debug!( - "[ShellIntegration] Wave 命令: block_id={}, command={}", - self.block_id, - command - ); - - // 解析命令 - let parts: Vec<&str> = command.splitn(2, ' ').collect(); - let cmd = parts.first().unwrap_or(&""); - let args = parts.get(1).unwrap_or(&""); - - match *cmd { - "setcwd" => { - // 设置当前目录 - if !args.is_empty() { - self.update_current_dir(args.to_string()); - } - } - "setshell" => { - // 设置 Shell 类型 - if !args.is_empty() { - self.set_shell_type_from_path(args); - } - } - _ => { - tracing::debug!( - "[ShellIntegration] 未知 Wave 命令: block_id={}, cmd={}", - self.block_id, - cmd - ); - } - } - - Ok(()) - } - - /// 设置状态 - fn set_status(&self, new_status: ShellIntegrationStatus) { - let old_status = { - let mut guard = self.status.write().unwrap(); - let old = *guard; - *guard = new_status; - old - }; - - if old_status != new_status { - tracing::debug!( - "[ShellIntegration] 状态变更: block_id={}, old={:?}, new={:?}", - self.block_id, - old_status, - new_status - ); - - // 发送状态变更事件 - self.send_status_event(None, self.get_current_command()); - } - } - - /// 开始命令 - /// - /// _Requirements: 6.8_ - fn start_command(&self) { - let now = current_timestamp_ms(); - self.last_command_start.store(now, Ordering::SeqCst); - - let mut guard = self.current_command.write().unwrap(); - *guard = Some(CommandInfo::new()); - - tracing::debug!( - "[ShellIntegration] 命令开始: block_id={}, time={}", - self.block_id, - now - ); - } - - /// 结束命令 - /// - /// _Requirements: 6.8_ - fn finish_command(&self) { - let mut guard = self.current_command.write().unwrap(); - if let Some(ref mut cmd) = *guard { - cmd.finish(); - tracing::debug!( - "[ShellIntegration] 命令结束: block_id={}, duration_ms={:?}", - self.block_id, - cmd.duration_ms - ); - } - } - - /// 发送状态变更事件 - /// - /// _Requirements: 6.6_ - fn send_status_event(&self, current_dir: Option, command_info: Option) { - if let Some(ref app_handle) = self.app_handle { - let event = ShellIntegrationEvent { - block_id: self.block_id.clone(), - status: self.get_status(), - current_dir: current_dir.or_else(|| self.get_current_dir()), - command_info, - }; - - if let Err(e) = emit_helper::emit( - app_handle.as_ref(), - event_names::SHELL_INTEGRATION_STATUS, - &event, - ) { - tracing::warn!( - "[ShellIntegration] 发送状态事件失败: block_id={}, error={}", - self.block_id, - e - ); - } - } - } - - /// 重置状态 - pub fn reset(&self) { - { - let mut guard = self.current_dir.write().unwrap(); - *guard = None; - } - { - let mut guard = self.status.write().unwrap(); - *guard = ShellIntegrationStatus::Unknown; - } - { - let mut guard = self.current_command.write().unwrap(); - *guard = None; - } - self.last_command_start.store(0, Ordering::SeqCst); - - tracing::debug!("[ShellIntegration] 状态重置: block_id={}", self.block_id); - } -} - -/// 获取当前时间戳(毫秒) -fn current_timestamp_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_shell_type_from_path() { - assert_eq!(ShellType::from_path("/bin/bash"), ShellType::Bash); - assert_eq!(ShellType::from_path("/usr/bin/zsh"), ShellType::Zsh); - assert_eq!(ShellType::from_path("/usr/local/bin/fish"), ShellType::Fish); - assert_eq!(ShellType::from_path("/usr/bin/pwsh"), ShellType::Pwsh); - assert_eq!( - ShellType::from_path("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"), - ShellType::Pwsh - ); - assert_eq!(ShellType::from_path("/bin/sh"), ShellType::Unknown); - } - - #[test] - fn test_shell_integration_status_default() { - let integration = ShellIntegration::new("test-block".to_string()); - assert_eq!(integration.get_status(), ShellIntegrationStatus::Unknown); - assert!(integration.get_current_dir().is_none()); - } - - #[test] - fn test_process_osc_7() { - let integration = ShellIntegration::new("test-block".to_string()); - - let osc = OSCSequence::CurrentDirectory { - hostname: Some("localhost".to_string()), - path: "/home/user".to_string(), - }; - - integration.process_osc(&osc).unwrap(); - assert_eq!( - integration.get_current_dir(), - Some("/home/user".to_string()) - ); - } - - #[test] - fn test_process_osc_133_prompt_start() { - let integration = ShellIntegration::new("test-block".to_string()); - - // 先设置为 RunningCommand - let osc_exec = OSCSequence::PromptMark { - mark_type: PromptMarkType::CommandExecuted, - }; - integration.process_osc(&osc_exec).unwrap(); - assert_eq!( - integration.get_status(), - ShellIntegrationStatus::RunningCommand - ); - - // 然后 PromptStart 应该切换到 Ready - let osc_prompt = OSCSequence::PromptMark { - mark_type: PromptMarkType::PromptStart, - }; - integration.process_osc(&osc_prompt).unwrap(); - assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready); - } - - #[test] - fn test_process_osc_133_command_executed() { - let integration = ShellIntegration::new("test-block".to_string()); - - let osc = OSCSequence::PromptMark { - mark_type: PromptMarkType::CommandExecuted, - }; - - integration.process_osc(&osc).unwrap(); - assert_eq!( - integration.get_status(), - ShellIntegrationStatus::RunningCommand - ); - assert!(integration.get_current_command().is_some()); - } - - #[test] - fn test_process_osc_133_command_finished() { - let integration = ShellIntegration::new("test-block".to_string()); - - // 先执行命令 - let osc_exec = OSCSequence::PromptMark { - mark_type: PromptMarkType::CommandExecuted, - }; - integration.process_osc(&osc_exec).unwrap(); - - // 等待一小段时间 - std::thread::sleep(std::time::Duration::from_millis(10)); - - // 命令结束 - let osc_finish = OSCSequence::PromptMark { - mark_type: PromptMarkType::CommandFinished, - }; - integration.process_osc(&osc_finish).unwrap(); - - assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready); - - let cmd_info = integration.get_current_command().unwrap(); - assert!(cmd_info.end_time.is_some()); - assert!(cmd_info.duration_ms.is_some()); - assert!(cmd_info.duration_ms.unwrap() >= 10); - } - - #[test] - fn test_process_wave_command_setcwd() { - let integration = ShellIntegration::new("test-block".to_string()); - - let osc = OSCSequence::WaveCommand { - command: "setcwd /home/user/projects".to_string(), - }; - - integration.process_osc(&osc).unwrap(); - assert_eq!( - integration.get_current_dir(), - Some("/home/user/projects".to_string()) - ); - } - - #[test] - fn test_process_wave_command_setshell() { - let integration = ShellIntegration::new("test-block".to_string()); - - let osc = OSCSequence::WaveCommand { - command: "setshell /usr/bin/zsh".to_string(), - }; - - integration.process_osc(&osc).unwrap(); - assert_eq!(integration.get_shell_type(), ShellType::Zsh); - } - - #[test] - fn test_process_output() { - let integration = ShellIntegration::new("test-block".to_string()); - - // 包含多个 OSC 序列的数据 - let data = b"Hello\x1b]7;file:///home/user\x07World\x1b]133;A\x07End"; - let count = integration.process_output(data); - - assert_eq!(count, 2); - assert_eq!( - integration.get_current_dir(), - Some("/home/user".to_string()) - ); - assert_eq!(integration.get_status(), ShellIntegrationStatus::Ready); - } - - #[test] - fn test_reset() { - let integration = ShellIntegration::new("test-block".to_string()); - - // 设置一些状态 - let osc_dir = OSCSequence::CurrentDirectory { - hostname: None, - path: "/home/user".to_string(), - }; - integration.process_osc(&osc_dir).unwrap(); - - let osc_exec = OSCSequence::PromptMark { - mark_type: PromptMarkType::CommandExecuted, - }; - integration.process_osc(&osc_exec).unwrap(); - - // 重置 - integration.reset(); - - assert!(integration.get_current_dir().is_none()); - assert_eq!(integration.get_status(), ShellIntegrationStatus::Unknown); - assert!(integration.get_current_command().is_none()); - } - - #[test] - fn test_command_info() { - let mut cmd = CommandInfo::new(); - assert!(cmd.start_time > 0); - assert!(cmd.end_time.is_none()); - assert!(cmd.duration_ms.is_none()); - - std::thread::sleep(std::time::Duration::from_millis(5)); - cmd.finish(); - - assert!(cmd.end_time.is_some()); - assert!(cmd.duration_ms.is_some()); - assert!(cmd.duration_ms.unwrap() >= 5); - } -} diff --git a/src-tauri/crates/terminal/src/integration/shell_scripts.rs b/src-tauri/crates/terminal/src/integration/shell_scripts.rs deleted file mode 100644 index cd98cca09..000000000 --- a/src-tauri/crates/terminal/src/integration/shell_scripts.rs +++ /dev/null @@ -1,988 +0,0 @@ -//! Shell 集成脚本管理 -//! -//! 提供各种 Shell 的集成脚本安装和管理功能。 -//! -//! ## 功能 -//! - 生成 Shell 集成脚本 -//! - 安装脚本到用户目录 -//! - 构建带集成的 Shell 启动命令 -//! -//! ## 支持的 Shell -//! - Bash (--rcfile) -//! - Zsh (ZDOTDIR) -//! - Fish (-C source) -//! - PowerShell (pwsh) -//! -//! ## Requirements -//! - 17.5: 支持自定义 Shell 路径和参数 -//! - 17.6: 支持自定义初始化脚本 -//! - 17.7: 支持环境变量配置 -//! - 17.8: zsh 使用 ZDOTDIR 指向集成目录 -//! - 17.9: bash 使用 --rcfile 加载集成脚本 -//! - 17.10: fish 使用 -C 参数 source 集成脚本 - -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::error::TerminalError; -use crate::integration::shell_integration::ShellType; - -/// Shell 集成脚本目录名 -const SHELL_INTEGRATION_DIR: &str = "shell-integration"; - -/// Bash 集成脚本内容 -const BASH_INTEGRATION_SCRIPT: &str = r#"# Lime Shell Integration for Bash -# This script provides shell integration features - -# 保存原始 PS1 -if [ -z "$_LIME_ORIG_PS1" ]; then - _LIME_ORIG_PS1="$PS1" -fi - -# OSC 7 - 报告当前工作目录 -__lime_osc7() { - printf '\033]7;file://%s%s\033\\' "${HOSTNAME:-localhost}" "$PWD" -} - -# OSC 133 - 命令提示符标记 -__lime_prompt_start() { - printf '\033]133;A\033\\' -} - -__lime_command_start() { - printf '\033]133;B\033\\' -} - -__lime_command_executed() { - printf '\033]133;C\033\\' -} - -__lime_command_finished() { - printf '\033]133;D;%s\033\\' "$?" -} - -# 设置 PROMPT_COMMAND -__lime_precmd() { - local exit_code=$? - __lime_command_finished - __lime_osc7 - __lime_prompt_start - return $exit_code -} - -__lime_preexec() { - __lime_command_executed -} - -# 安装 preexec 钩子(如果可用) -if [ -n "$BASH_VERSION" ]; then - # 使用 DEBUG trap 模拟 preexec - __lime_debug_trap() { - if [ -n "$COMP_LINE" ]; then - return - fi - if [ "$BASH_COMMAND" = "$PROMPT_COMMAND" ]; then - return - fi - __lime_preexec - } - - trap '__lime_debug_trap' DEBUG -fi - -# 设置 PROMPT_COMMAND -if [ -z "$PROMPT_COMMAND" ]; then - PROMPT_COMMAND="__lime_precmd" -else - PROMPT_COMMAND="__lime_precmd;$PROMPT_COMMAND" -fi - -# 加载用户的 .bashrc(如果存在且我们是通过 --rcfile 启动的) -if [ -n "$_LIME_LOAD_BASHRC" ] && [ -f "$HOME/.bashrc" ]; then - source "$HOME/.bashrc" -fi - -# 标记集成已加载 -export LIME_SHELL_INTEGRATION=1 -"#; - -/// Zsh 集成脚本内容 (.zshrc) -const ZSH_INTEGRATION_SCRIPT: &str = r#"# Lime Shell Integration for Zsh -# This script provides shell integration features - -# OSC 7 - 报告当前工作目录 -__lime_osc7() { - printf '\033]7;file://%s%s\033\\' "${HOST:-localhost}" "$PWD" -} - -# OSC 133 - 命令提示符标记 -__lime_prompt_start() { - printf '\033]133;A\033\\' -} - -__lime_command_start() { - printf '\033]133;B\033\\' -} - -__lime_command_executed() { - printf '\033]133;C\033\\' -} - -__lime_command_finished() { - printf '\033]133;D;%s\033\\' "$?" -} - -# precmd 钩子 - 命令执行后 -__lime_precmd() { - local exit_code=$? - __lime_command_finished - __lime_osc7 - __lime_prompt_start - return $exit_code -} - -# preexec 钩子 - 命令执行前 -__lime_preexec() { - __lime_command_executed -} - -# 注册钩子 -autoload -Uz add-zsh-hook -add-zsh-hook precmd __lime_precmd -add-zsh-hook preexec __lime_preexec - -# 加载用户的原始配置 -if [ -n "$_LIME_ORIG_ZDOTDIR" ]; then - if [ -f "$_LIME_ORIG_ZDOTDIR/.zshrc" ]; then - source "$_LIME_ORIG_ZDOTDIR/.zshrc" - fi -elif [ -f "$HOME/.zshrc" ]; then - source "$HOME/.zshrc" -fi - -# 标记集成已加载 -export LIME_SHELL_INTEGRATION=1 -"#; - -/// Zsh .zshenv 内容(用于设置 ZDOTDIR) -const ZSH_ZSHENV_SCRIPT: &str = r#"# Lime Zsh Environment -# 保存原始 ZDOTDIR -if [ -z "$_LIME_ORIG_ZDOTDIR" ]; then - export _LIME_ORIG_ZDOTDIR="${ZDOTDIR:-$HOME}" -fi - -# 加载原始 .zshenv -if [ -f "$_LIME_ORIG_ZDOTDIR/.zshenv" ]; then - source "$_LIME_ORIG_ZDOTDIR/.zshenv" -fi -"#; - -/// Fish 集成脚本内容 -const FISH_INTEGRATION_SCRIPT: &str = r#"# Lime Shell Integration for Fish -# This script provides shell integration features - -# OSC 7 - 报告当前工作目录 -function __lime_osc7 --on-variable PWD - printf '\033]7;file://%s%s\033\\' (hostname) $PWD -end - -# OSC 133 - 命令提示符标记 -function __lime_prompt_start - printf '\033]133;A\033\\' -end - -function __lime_command_executed - printf '\033]133;C\033\\' -end - -function __lime_command_finished - printf '\033]133;D;%s\033\\' $status -end - -# Fish 事件钩子 -function __lime_fish_prompt --on-event fish_prompt - __lime_command_finished - __lime_osc7 - __lime_prompt_start -end - -function __lime_fish_preexec --on-event fish_preexec - __lime_command_executed -end - -# 初始化 -__lime_osc7 - -# 标记集成已加载 -set -gx LIME_SHELL_INTEGRATION 1 -"#; - -/// PowerShell 集成脚本内容 -const PWSH_INTEGRATION_SCRIPT: &str = r#"# Lime Shell Integration for PowerShell -# This script provides shell integration features - -# OSC 7 - 报告当前工作目录 -function Send-LimeOsc7 { - $hostname = [System.Net.Dns]::GetHostName() - $pwd = $PWD.Path -replace '\\', '/' - Write-Host -NoNewline "`e]7;file://$hostname$pwd`e\" -} - -# OSC 133 - 命令提示符标记 -function Send-LimePromptStart { - Write-Host -NoNewline "`e]133;A`e\" -} - -function Send-LimeCommandExecuted { - Write-Host -NoNewline "`e]133;C`e\" -} - -function Send-LimeCommandFinished { - param([int]$ExitCode = 0) - Write-Host -NoNewline "`e]133;D;$ExitCode`e\" -} - -# 保存原始 prompt 函数 -if (-not (Test-Path Function:\__LimeOriginalPrompt)) { - if (Test-Path Function:\prompt) { - Copy-Item Function:\prompt Function:\__LimeOriginalPrompt - } else { - function __LimeOriginalPrompt { "PS $($PWD.Path)> " } - } -} - -# 自定义 prompt 函数 -function prompt { - $exitCode = $LASTEXITCODE - Send-LimeCommandFinished -ExitCode $exitCode - Send-LimeOsc7 - Send-LimePromptStart - $LASTEXITCODE = $exitCode - __LimeOriginalPrompt -} - -# PSReadLine 钩子(如果可用) -if (Get-Module -ListAvailable -Name PSReadLine) { - $existingHandler = (Get-PSReadLineOption).AddToHistoryHandler - Set-PSReadLineOption -AddToHistoryHandler { - param([string]$line) - Send-LimeCommandExecuted - if ($existingHandler) { - return & $existingHandler $line - } - return $true - } -} - -# 标记集成已加载 -$env:LIME_SHELL_INTEGRATION = "1" -"#; - -/// Shell 集成脚本管理器 -pub struct ShellScripts { - /// 集成脚本目录 - integration_dir: PathBuf, -} - -impl ShellScripts { - /// 创建新的 Shell 脚本管理器 - /// - /// # 参数 - /// - `app_data_dir`: 应用数据目录 - /// - /// # 返回 - /// 新的 ShellScripts 实例 - pub fn new(app_data_dir: &Path) -> Self { - Self { - integration_dir: app_data_dir.join(SHELL_INTEGRATION_DIR), - } - } - - /// 获取集成脚本目录 - pub fn integration_dir(&self) -> &Path { - &self.integration_dir - } - - /// 确保集成脚本目录存在 - fn ensure_integration_dir(&self) -> Result<(), TerminalError> { - if !self.integration_dir.exists() { - fs::create_dir_all(&self.integration_dir) - .map_err(|e| TerminalError::Internal(format!("创建集成脚本目录失败: {e}")))?; - } - Ok(()) - } - - /// 安装所有 Shell 集成脚本 - /// - /// 将集成脚本写入应用数据目录。 - /// - /// # 返回 - /// 成功返回 Ok(()), 失败返回错误 - pub fn install_all(&self) -> Result<(), TerminalError> { - self.ensure_integration_dir()?; - - // 安装 Bash 脚本 - self.install_bash_scripts()?; - - // 安装 Zsh 脚本 - self.install_zsh_scripts()?; - - // 安装 Fish 脚本 - self.install_fish_scripts()?; - - // 安装 PowerShell 脚本 - self.install_pwsh_scripts()?; - - tracing::info!( - "[ShellScripts] 所有集成脚本已安装: dir={}", - self.integration_dir.display() - ); - - Ok(()) - } - - /// 安装 Bash 集成脚本 - /// - /// _Requirements: 17.9_ - fn install_bash_scripts(&self) -> Result<(), TerminalError> { - let bash_dir = self.integration_dir.join("bash"); - fs::create_dir_all(&bash_dir) - .map_err(|e| TerminalError::Internal(format!("创建 bash 目录失败: {e}")))?; - - let script_path = bash_dir.join("lime.bash"); - fs::write(&script_path, BASH_INTEGRATION_SCRIPT) - .map_err(|e| TerminalError::Internal(format!("写入 bash 脚本失败: {e}")))?; - - tracing::debug!("[ShellScripts] Bash 脚本已安装: {}", script_path.display()); - - Ok(()) - } - - /// 安装 Zsh 集成脚本 - /// - /// _Requirements: 17.8_ - fn install_zsh_scripts(&self) -> Result<(), TerminalError> { - let zsh_dir = self.integration_dir.join("zsh"); - fs::create_dir_all(&zsh_dir) - .map_err(|e| TerminalError::Internal(format!("创建 zsh 目录失败: {e}")))?; - - // 写入 .zshenv - let zshenv_path = zsh_dir.join(".zshenv"); - fs::write(&zshenv_path, ZSH_ZSHENV_SCRIPT) - .map_err(|e| TerminalError::Internal(format!("写入 .zshenv 失败: {e}")))?; - - // 写入 .zshrc - let zshrc_path = zsh_dir.join(".zshrc"); - fs::write(&zshrc_path, ZSH_INTEGRATION_SCRIPT) - .map_err(|e| TerminalError::Internal(format!("写入 .zshrc 失败: {e}")))?; - - tracing::debug!("[ShellScripts] Zsh 脚本已安装: {}", zsh_dir.display()); - - Ok(()) - } - - /// 安装 Fish 集成脚本 - /// - /// _Requirements: 17.10_ - fn install_fish_scripts(&self) -> Result<(), TerminalError> { - let fish_dir = self.integration_dir.join("fish"); - fs::create_dir_all(&fish_dir) - .map_err(|e| TerminalError::Internal(format!("创建 fish 目录失败: {e}")))?; - - let script_path = fish_dir.join("lime.fish"); - fs::write(&script_path, FISH_INTEGRATION_SCRIPT) - .map_err(|e| TerminalError::Internal(format!("写入 fish 脚本失败: {e}")))?; - - tracing::debug!("[ShellScripts] Fish 脚本已安装: {}", script_path.display()); - - Ok(()) - } - - /// 安装 PowerShell 集成脚本 - fn install_pwsh_scripts(&self) -> Result<(), TerminalError> { - let pwsh_dir = self.integration_dir.join("pwsh"); - fs::create_dir_all(&pwsh_dir) - .map_err(|e| TerminalError::Internal(format!("创建 pwsh 目录失败: {e}")))?; - - let script_path = pwsh_dir.join("lime.ps1"); - fs::write(&script_path, PWSH_INTEGRATION_SCRIPT) - .map_err(|e| TerminalError::Internal(format!("写入 pwsh 脚本失败: {e}")))?; - - tracing::debug!( - "[ShellScripts] PowerShell 脚本已安装: {}", - script_path.display() - ); - - Ok(()) - } - - /// 获取 Bash 集成脚本路径 - pub fn bash_script_path(&self) -> PathBuf { - self.integration_dir.join("bash").join("lime.bash") - } - - /// 获取 Zsh 集成目录路径(用于 ZDOTDIR) - pub fn zsh_integration_dir(&self) -> PathBuf { - self.integration_dir.join("zsh") - } - - /// 获取 Fish 集成脚本路径 - pub fn fish_script_path(&self) -> PathBuf { - self.integration_dir.join("fish").join("lime.fish") - } - - /// 获取 PowerShell 集成脚本路径 - pub fn pwsh_script_path(&self) -> PathBuf { - self.integration_dir.join("pwsh").join("lime.ps1") - } - - /// 检查集成脚本是否已安装 - pub fn is_installed(&self) -> bool { - self.bash_script_path().exists() - && self.zsh_integration_dir().join(".zshrc").exists() - && self.fish_script_path().exists() - && self.pwsh_script_path().exists() - } -} - -/// Shell 启动配置 -/// -/// 包含启动 Shell 所需的命令和环境变量配置。 -#[derive(Debug, Clone)] -pub struct ShellLaunchConfig { - /// Shell 可执行文件路径 - pub shell_path: String, - /// Shell 参数 - pub args: Vec, - /// 环境变量 - pub env: HashMap, -} - -impl ShellLaunchConfig { - /// 创建新的 Shell 启动配置 - pub fn new(shell_path: String) -> Self { - Self { - shell_path, - args: Vec::new(), - env: HashMap::new(), - } - } - - /// 添加参数 - pub fn arg(mut self, arg: impl Into) -> Self { - self.args.push(arg.into()); - self - } - - /// 添加环境变量 - pub fn env(mut self, key: impl Into, value: impl Into) -> Self { - self.env.insert(key.into(), value.into()); - self - } -} - -/// Shell 启动配置构建器 -/// -/// 根据 Shell 类型和集成脚本路径构建启动配置。 -pub struct ShellLaunchBuilder { - /// Shell 脚本管理器 - scripts: ShellScripts, - /// Block ID(用于环境变量) - block_id: String, -} - -impl ShellLaunchBuilder { - /// 创建新的启动配置构建器 - /// - /// # 参数 - /// - `app_data_dir`: 应用数据目录 - /// - `block_id`: Block ID - pub fn new(app_data_dir: &Path, block_id: String) -> Self { - Self { - scripts: ShellScripts::new(app_data_dir), - block_id, - } - } - - /// 确保集成脚本已安装 - pub fn ensure_scripts_installed(&self) -> Result<(), TerminalError> { - if !self.scripts.is_installed() { - self.scripts.install_all()?; - } - Ok(()) - } - - /// 构建 Shell 启动配置 - /// - /// 根据 Shell 类型构建带有集成脚本的启动配置。 - /// - /// # 参数 - /// - `shell_path`: Shell 可执行文件路径 - /// - `custom_env`: 自定义环境变量 - /// - /// # 返回 - /// Shell 启动配置 - /// - /// _Requirements: 17.5, 17.6, 17.7, 17.8, 17.9, 17.10_ - pub fn build( - &self, - shell_path: &str, - custom_env: Option<&HashMap>, - ) -> Result { - // 确保脚本已安装 - self.ensure_scripts_installed()?; - - let shell_type = ShellType::from_path(shell_path); - let mut config = ShellLaunchConfig::new(shell_path.to_string()); - - // 设置通用环境变量 - config = self.set_common_env(config); - - // 根据 Shell 类型设置特定配置 - config = match shell_type { - ShellType::Bash => self.configure_bash(config)?, - ShellType::Zsh => self.configure_zsh(config)?, - ShellType::Fish => self.configure_fish(config)?, - ShellType::Pwsh => self.configure_pwsh(config)?, - ShellType::Unknown => { - tracing::warn!( - "[ShellLaunchBuilder] 未知 Shell 类型,不加载集成脚本: {}", - shell_path - ); - config - } - }; - - // 添加自定义环境变量 - if let Some(env) = custom_env { - for (key, value) in env { - config.env.insert(key.clone(), value.clone()); - } - } - - Ok(config) - } - - /// 设置通用环境变量 - /// - /// 设置所有 Shell 类型共用的环境变量,包括: - /// - TERM: 终端类型 - /// - COLORTERM: 颜色支持 - /// - LIME_BLOCKID: 块 ID(与 WAVETERM_BLOCKID 兼容) - /// - WAVETERM_BLOCKID: Waveterm 兼容的块 ID - /// - LIME_VERSION: 应用版本 - /// - LANG: 语言设置(如果未设置) - /// - /// _Requirements: 17.2, 17.7_ - fn set_common_env(&self, config: ShellLaunchConfig) -> ShellLaunchConfig { - let mut config = config - .env("TERM", "xterm-256color") - .env("COLORTERM", "truecolor") - .env("LIME_BLOCKID", &self.block_id) - // Waveterm 兼容性 - .env("WAVETERM_BLOCKID", &self.block_id) - .env("LIME_VERSION", env!("CARGO_PKG_VERSION")); - - // 设置 LANG(如果未设置) - if std::env::var("LANG").is_err() { - config = config.env("LANG", "en_US.UTF-8"); - } - - // 设置 LC_ALL(如果未设置) - if std::env::var("LC_ALL").is_err() { - config = config.env("LC_ALL", "en_US.UTF-8"); - } - - config - } - - /// 配置 Bash 启动 - /// - /// 使用 --rcfile 参数加载集成脚本。 - /// - /// _Requirements: 17.9_ - fn configure_bash( - &self, - config: ShellLaunchConfig, - ) -> Result { - let script_path = self.scripts.bash_script_path(); - - if !script_path.exists() { - tracing::warn!( - "[ShellLaunchBuilder] Bash 集成脚本不存在: {}", - script_path.display() - ); - return Ok(config); - } - - let script_path_str = script_path.to_string_lossy().to_string(); - - Ok(config - .arg("--rcfile") - .arg(&script_path_str) - .env("_LIME_LOAD_BASHRC", "1")) - } - - /// 配置 Zsh 启动 - /// - /// 使用 ZDOTDIR 环境变量指向集成目录。 - /// - /// _Requirements: 17.8_ - fn configure_zsh(&self, config: ShellLaunchConfig) -> Result { - let zsh_dir = self.scripts.zsh_integration_dir(); - - if !zsh_dir.join(".zshrc").exists() { - tracing::warn!( - "[ShellLaunchBuilder] Zsh 集成脚本不存在: {}", - zsh_dir.display() - ); - return Ok(config); - } - - let zsh_dir_str = zsh_dir.to_string_lossy().to_string(); - - Ok(config.env("ZDOTDIR", &zsh_dir_str)) - } - - /// 配置 Fish 启动 - /// - /// 使用 -C 参数 source 集成脚本。 - /// - /// _Requirements: 17.10_ - fn configure_fish( - &self, - config: ShellLaunchConfig, - ) -> Result { - let script_path = self.scripts.fish_script_path(); - - if !script_path.exists() { - tracing::warn!( - "[ShellLaunchBuilder] Fish 集成脚本不存在: {}", - script_path.display() - ); - return Ok(config); - } - - let source_cmd = format!("source {}", script_path.to_string_lossy()); - - Ok(config.arg("-C").arg(&source_cmd)) - } - - /// 配置 PowerShell 启动 - fn configure_pwsh( - &self, - config: ShellLaunchConfig, - ) -> Result { - let script_path = self.scripts.pwsh_script_path(); - - if !script_path.exists() { - tracing::warn!( - "[ShellLaunchBuilder] PowerShell 集成脚本不存在: {}", - script_path.display() - ); - return Ok(config); - } - - let script_path_str = script_path.to_string_lossy().to_string(); - - // PowerShell 使用 -NoExit 保持会话,-Command 执行脚本 - Ok(config - .arg("-NoExit") - .arg("-Command") - .arg(format!(". '{script_path_str}'"))) - } -} - -/// 终端环境变量配置 -/// -/// 提供终端环境变量的配置和管理功能。 -/// -/// ## 标准环境变量 -/// - `TERM`: 终端类型(默认 xterm-256color) -/// - `COLORTERM`: 颜色支持(默认 truecolor) -/// - `LIME_BLOCKID`: 块 ID -/// - `WAVETERM_BLOCKID`: Waveterm 兼容的块 ID -/// - `LIME_VERSION`: 应用版本 -/// - `LANG`: 语言设置 -/// - `LC_ALL`: 区域设置 -/// -/// _Requirements: 17.2, 17.7_ -#[derive(Debug, Clone, Default)] -pub struct TerminalEnvConfig { - /// 环境变量映射 - env: HashMap, -} - -impl TerminalEnvConfig { - /// 创建新的环境变量配置 - pub fn new() -> Self { - Self { - env: HashMap::new(), - } - } - - /// 创建带有默认环境变量的配置 - /// - /// # 参数 - /// - `block_id`: 块 ID - /// - /// # 返回 - /// 包含默认环境变量的配置 - /// - /// _Requirements: 17.2_ - pub fn with_defaults(block_id: &str) -> Self { - let mut config = Self::new(); - - // 终端类型 - config.set("TERM", "xterm-256color"); - config.set("COLORTERM", "truecolor"); - - // 块标识 - config.set("LIME_BLOCKID", block_id); - config.set("WAVETERM_BLOCKID", block_id); // Waveterm 兼容 - - // 版本信息 - config.set("LIME_VERSION", env!("CARGO_PKG_VERSION")); - - // 语言设置(如果未设置) - if std::env::var("LANG").is_err() { - config.set("LANG", "en_US.UTF-8"); - } - if std::env::var("LC_ALL").is_err() { - config.set("LC_ALL", "en_US.UTF-8"); - } - - config - } - - /// 设置环境变量 - /// - /// # 参数 - /// - `key`: 环境变量名 - /// - `value`: 环境变量值 - pub fn set(&mut self, key: impl Into, value: impl Into) { - self.env.insert(key.into(), value.into()); - } - - /// 获取环境变量 - /// - /// # 参数 - /// - `key`: 环境变量名 - /// - /// # 返回 - /// 环境变量值(如果存在) - pub fn get(&self, key: &str) -> Option<&String> { - self.env.get(key) - } - - /// 移除环境变量 - /// - /// # 参数 - /// - `key`: 环境变量名 - /// - /// # 返回 - /// 被移除的值(如果存在) - pub fn remove(&mut self, key: &str) -> Option { - self.env.remove(key) - } - - /// 合并自定义环境变量 - /// - /// 自定义环境变量会覆盖已有的同名变量。 - /// - /// # 参数 - /// - `custom_env`: 自定义环境变量 - /// - /// _Requirements: 17.7_ - pub fn merge(&mut self, custom_env: &HashMap) { - for (key, value) in custom_env { - self.env.insert(key.clone(), value.clone()); - } - } - - /// 获取所有环境变量 - /// - /// # 返回 - /// 环境变量映射的引用 - pub fn all(&self) -> &HashMap { - &self.env - } - - /// 转换为 HashMap - /// - /// # 返回 - /// 环境变量的 HashMap - pub fn into_map(self) -> HashMap { - self.env - } - - /// 检查是否包含指定的环境变量 - /// - /// # 参数 - /// - `key`: 环境变量名 - /// - /// # 返回 - /// 是否包含该环境变量 - pub fn contains(&self, key: &str) -> bool { - self.env.contains_key(key) - } - - /// 获取环境变量数量 - pub fn len(&self) -> usize { - self.env.len() - } - - /// 检查是否为空 - pub fn is_empty(&self) -> bool { - self.env.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn test_shell_scripts_install() { - let temp_dir = TempDir::new().unwrap(); - let scripts = ShellScripts::new(temp_dir.path()); - - scripts.install_all().unwrap(); - - assert!(scripts.bash_script_path().exists()); - assert!(scripts.zsh_integration_dir().join(".zshrc").exists()); - assert!(scripts.zsh_integration_dir().join(".zshenv").exists()); - assert!(scripts.fish_script_path().exists()); - assert!(scripts.pwsh_script_path().exists()); - assert!(scripts.is_installed()); - } - - #[test] - fn test_shell_launch_builder_bash() { - let temp_dir = TempDir::new().unwrap(); - let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string()); - - let config = builder.build("/bin/bash", None).unwrap(); - - assert_eq!(config.shell_path, "/bin/bash"); - assert!(config.args.contains(&"--rcfile".to_string())); - assert!(config.env.contains_key("TERM")); - assert!(config.env.contains_key("LIME_BLOCKID")); - assert!(config.env.contains_key("WAVETERM_BLOCKID")); - assert_eq!(config.env.get("_LIME_LOAD_BASHRC"), Some(&"1".to_string())); - } - - #[test] - fn test_shell_launch_builder_zsh() { - let temp_dir = TempDir::new().unwrap(); - let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string()); - - let config = builder.build("/bin/zsh", None).unwrap(); - - assert_eq!(config.shell_path, "/bin/zsh"); - assert!(config.env.contains_key("ZDOTDIR")); - assert!(config.env.contains_key("TERM")); - assert!(config.env.contains_key("WAVETERM_BLOCKID")); - } - - #[test] - fn test_shell_launch_builder_fish() { - let temp_dir = TempDir::new().unwrap(); - let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string()); - - let config = builder.build("/usr/bin/fish", None).unwrap(); - - assert_eq!(config.shell_path, "/usr/bin/fish"); - assert!(config.args.contains(&"-C".to_string())); - assert!(config.env.contains_key("TERM")); - assert!(config.env.contains_key("WAVETERM_BLOCKID")); - } - - #[test] - fn test_shell_launch_builder_pwsh() { - let temp_dir = TempDir::new().unwrap(); - let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string()); - - let config = builder.build("/usr/bin/pwsh", None).unwrap(); - - assert_eq!(config.shell_path, "/usr/bin/pwsh"); - assert!(config.args.contains(&"-NoExit".to_string())); - assert!(config.args.contains(&"-Command".to_string())); - assert!(config.env.contains_key("TERM")); - assert!(config.env.contains_key("WAVETERM_BLOCKID")); - } - - #[test] - fn test_shell_launch_builder_custom_env() { - let temp_dir = TempDir::new().unwrap(); - let builder = ShellLaunchBuilder::new(temp_dir.path(), "test-block".to_string()); - - let mut custom_env = HashMap::new(); - custom_env.insert("MY_VAR".to_string(), "my_value".to_string()); - - let config = builder.build("/bin/bash", Some(&custom_env)).unwrap(); - - assert_eq!(config.env.get("MY_VAR"), Some(&"my_value".to_string())); - } - - #[test] - fn test_shell_launch_config_builder_pattern() { - let config = ShellLaunchConfig::new("/bin/bash".to_string()) - .arg("--login") - .arg("-i") - .env("FOO", "bar") - .env("BAZ", "qux"); - - assert_eq!(config.shell_path, "/bin/bash"); - assert_eq!(config.args, vec!["--login", "-i"]); - assert_eq!(config.env.get("FOO"), Some(&"bar".to_string())); - assert_eq!(config.env.get("BAZ"), Some(&"qux".to_string())); - } - - #[test] - fn test_terminal_env_config_defaults() { - let config = TerminalEnvConfig::with_defaults("test-block"); - - assert_eq!(config.get("TERM"), Some(&"xterm-256color".to_string())); - assert_eq!(config.get("COLORTERM"), Some(&"truecolor".to_string())); - assert_eq!(config.get("LIME_BLOCKID"), Some(&"test-block".to_string())); - assert_eq!( - config.get("WAVETERM_BLOCKID"), - Some(&"test-block".to_string()) - ); - assert!(config.contains("LIME_VERSION")); - } - - #[test] - fn test_terminal_env_config_merge() { - let mut config = TerminalEnvConfig::with_defaults("test-block"); - - let mut custom = HashMap::new(); - custom.insert("MY_VAR".to_string(), "my_value".to_string()); - custom.insert("TERM".to_string(), "xterm".to_string()); // 覆盖默认值 - - config.merge(&custom); - - assert_eq!(config.get("MY_VAR"), Some(&"my_value".to_string())); - assert_eq!(config.get("TERM"), Some(&"xterm".to_string())); // 被覆盖 - } - - #[test] - fn test_terminal_env_config_operations() { - let mut config = TerminalEnvConfig::new(); - - config.set("KEY1", "value1"); - config.set("KEY2", "value2"); - - assert_eq!(config.len(), 2); - assert!(!config.is_empty()); - assert!(config.contains("KEY1")); - assert!(!config.contains("KEY3")); - - let removed = config.remove("KEY1"); - assert_eq!(removed, Some("value1".to_string())); - assert_eq!(config.len(), 1); - } -} diff --git a/src-tauri/crates/terminal/src/lib.rs b/src-tauri/crates/terminal/src/lib.rs deleted file mode 100644 index f9160873d..000000000 --- a/src-tauri/crates/terminal/src/lib.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Lime 终端模块 -//! -//! 提供 PTY 管理和会话管理能力,独立于 Tauri 框架。 -//! -//! ## 模块结构 -//! - `emitter` - 事件发射器抽象 trait -//! - `emit_helper` - 事件发射辅助函数 -//! - `error` - 错误类型定义 -//! - `events` - 事件定义 -//! - `pty_session` - PTY 会话封装 -//! - `session_manager` - 会话管理器 -//! - `persistence` - 持久化存储(块文件、会话元数据) -//! - `block_controller` - 块控制器抽象层 -//! - `connections` - 连接模块(本地 PTY、SSH、WSL) -//! - `integration` - 集成模块(Shell 集成、OSC 解析、状态重同步) - -#![allow(clippy::too_many_arguments)] -#![allow(clippy::manual_strip)] -#![allow(clippy::derivable_impls)] - -// 核心抽象 -pub mod emit_helper; -pub mod emitter; - -// 基础类型 -pub mod error; -pub mod events; - -// 会话管理 -pub mod pty_session; -pub mod session_manager; - -// 持久化 -pub mod persistence; - -// 块控制器 -pub mod block_controller; - -// 连接 -pub mod connections; - -// 集成 -pub mod integration; - -#[cfg(test)] -mod tests; - -// 重新导出常用类型 -pub use emitter::{DynEmitter, NoOpEmitter, TerminalEventEmitter}; -pub use error::TerminalError; -pub use events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; -pub use pty_session::{PtySession, DEFAULT_COLS, DEFAULT_ROWS}; -pub use session_manager::{SessionMetadata, TerminalSessionManager}; - -pub use block_controller::{ - BlockController, BlockControllerRuntimeStatus, BlockInputUnion, BlockMeta, ControllerRegistry, - ControllerStatusEvent, RuntimeOpts, ShellController, TermSize, CONTROLLER_STATUS_EVENT, -}; -pub use connections::ShellProc; -pub use integration::{ - resync_controller, ResyncController, ResyncOptions, ResyncResult, TERMINAL_RESET_SEQUENCE, - TERMINAL_SOFT_RESET_SEQUENCE, -}; -pub use persistence::{BlockFile, SessionMetadataStore, SessionRecord}; diff --git a/src-tauri/crates/terminal/src/persistence/block_file.rs b/src-tauri/crates/terminal/src/persistence/block_file.rs deleted file mode 100644 index dc8229a06..000000000 --- a/src-tauri/crates/terminal/src/persistence/block_file.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! 块文件循环缓冲存储 -//! -//! 实现终端输出历史的文件存储,使用循环缓冲策略管理文件大小。 -//! -//! ## 功能 -//! - 循环缓冲写入(超过最大大小时覆盖旧数据) -//! - 文件读取和截断 -//! - 可配置最大文件大小 -//! -//! ## 设计说明 -//! 采用简单的循环缓冲策略:当文件大小超过配置的最大值时, -//! 保留最新的数据,丢弃最旧的数据。 -//! -//! _Requirements: 3.1, 3.2, 3.3, 3.4, 3.7_ - -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use parking_lot::RwLock; - -use crate::error::TerminalError; - -/// 默认终端块文件最大大小 (256KB) -pub const DEFAULT_TERM_MAX_FILE_SIZE: usize = 256 * 1024; - -/// 块文件管理器 -/// -/// 管理单个终端会话的输出历史文件,使用循环缓冲策略。 -pub struct BlockFile { - /// 块 ID(通常是会话 ID) - block_id: String, - /// 文件路径 - file_path: PathBuf, - /// 最大文件大小 - max_size: usize, - /// 当前写入位置(用于循环缓冲) - write_pos: AtomicUsize, - /// 当前文件大小 - current_size: AtomicUsize, - /// 是否已经开始循环(文件已满过一次) - is_wrapped: RwLock, - /// 文件句柄(用于写入) - file: RwLock>, -} - -impl BlockFile { - /// 创建新的块文件 - /// - /// # 参数 - /// - `block_id`: 块 ID(通常是会话 ID) - /// - `base_dir`: 基础目录路径 - /// - `max_size`: 最大文件大小(字节) - /// - /// # 返回 - /// - `Ok(BlockFile)`: 创建成功 - /// - `Err(TerminalError)`: 创建失败 - /// - /// _Requirements: 3.1, 3.3_ - pub fn new(block_id: &str, base_dir: &Path, max_size: usize) -> Result { - let file_path = base_dir.join(format!("{block_id}.block")); - - // 确保目录存在 - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).map_err(|e| { - TerminalError::BlockFileError(format!("无法创建目录 {parent:?}: {e}")) - })?; - } - - // 检查文件是否已存在,获取当前大小 - let (current_size, is_wrapped) = if file_path.exists() { - let metadata = fs::metadata(&file_path) - .map_err(|e| TerminalError::BlockFileError(format!("无法读取文件元数据: {e}")))?; - let size = metadata.len() as usize; - // 如果文件大小已经达到最大值,说明已经循环过 - (size, size >= max_size) - } else { - (0, false) - }; - - // 打开或创建文件 - let file = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&file_path) - .map_err(|e| TerminalError::BlockFileError(format!("无法打开文件: {e}")))?; - - tracing::debug!( - "[BlockFile] 创建块文件: {} (max_size: {}, current_size: {})", - block_id, - max_size, - current_size - ); - - Ok(Self { - block_id: block_id.to_string(), - file_path, - max_size, - write_pos: AtomicUsize::new(current_size), - current_size: AtomicUsize::new(current_size), - is_wrapped: RwLock::new(is_wrapped), - file: RwLock::new(Some(file)), - }) - } - - /// 使用默认最大大小创建块文件 - /// - /// # 参数 - /// - `block_id`: 块 ID - /// - `base_dir`: 基础目录路径 - pub fn with_default_size(block_id: &str, base_dir: &Path) -> Result { - Self::new(block_id, base_dir, DEFAULT_TERM_MAX_FILE_SIZE) - } - - /// 获取块文件存储的默认基础目录 - pub fn default_base_dir() -> Result { - let home = dirs::home_dir() - .ok_or_else(|| TerminalError::BlockFileError("无法获取主目录".to_string()))?; - Ok(home.join(".lime").join("terminal_blocks")) - } - - /// 获取块 ID - pub fn block_id(&self) -> &str { - &self.block_id - } - - /// 获取文件路径 - pub fn file_path(&self) -> &PathBuf { - &self.file_path - } - - /// 获取最大文件大小 - pub fn max_size(&self) -> usize { - self.max_size - } - - /// 获取当前文件大小 - /// - /// _Requirements: 3.3_ - pub fn size(&self) -> usize { - self.current_size.load(Ordering::Relaxed) - } - - /// 追加数据到块文件 - /// - /// 使用循环缓冲策略:当文件大小超过最大值时,覆盖最旧的数据。 - /// - /// # 参数 - /// - `data`: 要追加的数据 - /// - /// # 返回 - /// - `Ok(())`: 追加成功 - /// - `Err(TerminalError)`: 追加失败 - /// - /// _Requirements: 3.2, 3.4_ - pub fn append_data(&self, data: &[u8]) -> Result<(), TerminalError> { - if data.is_empty() { - return Ok(()); - } - - let mut file_guard = self.file.write(); - let file = file_guard - .as_mut() - .ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?; - - // 如果数据本身就超过最大大小,只保留最后 max_size 字节 - let data_to_write = if data.len() >= self.max_size { - &data[data.len() - self.max_size..] - } else { - data - }; - - let current_size = self.current_size.load(Ordering::Relaxed); - let new_total = current_size + data_to_write.len(); - - if new_total <= self.max_size { - // 文件未满,直接追加 - file.seek(SeekFrom::End(0)) - .map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?; - file.write_all(data_to_write) - .map_err(|e| TerminalError::BlockFileError(format!("写入失败: {e}")))?; - file.flush() - .map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {e}")))?; - self.current_size.store(new_total, Ordering::Relaxed); - self.write_pos.store(new_total, Ordering::Relaxed); - } else { - // 文件将超过最大大小,需要使用循环缓冲策略 - // 策略:读取现有数据,保留最新的部分,然后重写文件 - self.apply_circular_buffer(file, data_to_write)?; - } - - Ok(()) - } - - /// 应用循环缓冲策略 - /// - /// 当新数据会导致文件超过最大大小时调用。 - /// 保留最新的数据,丢弃最旧的数据。 - fn apply_circular_buffer(&self, file: &mut File, new_data: &[u8]) -> Result<(), TerminalError> { - // 读取现有数据 - file.seek(SeekFrom::Start(0)) - .map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?; - - let current_size = self.current_size.load(Ordering::Relaxed); - let mut existing_data = vec![0u8; current_size]; - file.read_exact(&mut existing_data) - .map_err(|e| TerminalError::BlockFileError(format!("读取失败: {e}")))?; - - // 合并数据 - let mut combined = existing_data; - combined.extend_from_slice(new_data); - - // 只保留最后 max_size 字节 - let final_data = if combined.len() > self.max_size { - &combined[combined.len() - self.max_size..] - } else { - &combined[..] - }; - - // 重写文件 - file.seek(SeekFrom::Start(0)) - .map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?; - file.write_all(final_data) - .map_err(|e| TerminalError::BlockFileError(format!("写入失败: {e}")))?; - file.set_len(final_data.len() as u64) - .map_err(|e| TerminalError::BlockFileError(format!("截断失败: {e}")))?; - file.flush() - .map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {e}")))?; - - self.current_size.store(final_data.len(), Ordering::Relaxed); - self.write_pos.store(final_data.len(), Ordering::Relaxed); - *self.is_wrapped.write() = true; - - Ok(()) - } - - /// 读取所有数据 - /// - /// # 返回 - /// - `Ok(Vec)`: 文件中的所有数据 - /// - `Err(TerminalError)`: 读取失败 - /// - /// _Requirements: 3.6_ - pub fn read_all(&self) -> Result, TerminalError> { - let mut file_guard = self.file.write(); - let file = file_guard - .as_mut() - .ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?; - - let current_size = self.current_size.load(Ordering::Relaxed); - if current_size == 0 { - return Ok(Vec::new()); - } - - file.seek(SeekFrom::Start(0)) - .map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?; - - let mut data = vec![0u8; current_size]; - file.read_exact(&mut data) - .map_err(|e| TerminalError::BlockFileError(format!("读取失败: {e}")))?; - - Ok(data) - } - - /// 截断文件(清空内容) - /// - /// # 返回 - /// - `Ok(())`: 截断成功 - /// - `Err(TerminalError)`: 截断失败 - /// - /// _Requirements: 3.7_ - pub fn truncate(&self) -> Result<(), TerminalError> { - let mut file_guard = self.file.write(); - let file = file_guard - .as_mut() - .ok_or_else(|| TerminalError::BlockFileError("文件已关闭".to_string()))?; - - file.set_len(0) - .map_err(|e| TerminalError::BlockFileError(format!("截断失败: {e}")))?; - file.seek(SeekFrom::Start(0)) - .map_err(|e| TerminalError::BlockFileError(format!("Seek 失败: {e}")))?; - file.flush() - .map_err(|e| TerminalError::BlockFileError(format!("Flush 失败: {e}")))?; - - self.current_size.store(0, Ordering::Relaxed); - self.write_pos.store(0, Ordering::Relaxed); - *self.is_wrapped.write() = false; - - tracing::debug!("[BlockFile] 截断块文件: {}", self.block_id); - Ok(()) - } - - /// 删除块文件 - /// - /// 关闭文件句柄并删除文件。 - pub fn delete(self) -> Result<(), TerminalError> { - // 先关闭文件句柄 - { - let mut file_guard = self.file.write(); - *file_guard = None; - } - - // 删除文件 - if self.file_path.exists() { - fs::remove_file(&self.file_path) - .map_err(|e| TerminalError::BlockFileError(format!("删除文件失败: {e}")))?; - } - - tracing::debug!("[BlockFile] 删除块文件: {}", self.block_id); - Ok(()) - } - - /// 检查文件是否存在 - pub fn exists(&self) -> bool { - self.file_path.exists() - } - - /// 检查是否已经循环过(文件曾经满过) - pub fn is_wrapped(&self) -> bool { - *self.is_wrapped.read() - } -} - -impl Drop for BlockFile { - fn drop(&mut self) { - // 确保文件句柄被正确关闭 - let mut file_guard = self.file.write(); - if let Some(ref mut file) = *file_guard { - let _ = file.flush(); - } - *file_guard = None; - } -} diff --git a/src-tauri/crates/terminal/src/persistence/mod.rs b/src-tauri/crates/terminal/src/persistence/mod.rs deleted file mode 100644 index 83384cd06..000000000 --- a/src-tauri/crates/terminal/src/persistence/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! 终端持久化模块 -//! -//! 提供终端会话数据的持久化存储能力。 -//! -//! ## 模块结构 -//! - `block_file` - 块文件循环缓冲存储 -//! - `session_store` - 会话元数据 SQLite 存储 -//! -//! ## 功能 -//! - 终端输出历史的文件存储(循环缓冲) -//! - 会话元数据的数据库存储 -//! - 会话恢复支持 - -pub mod block_file; -pub mod session_store; - -pub use block_file::BlockFile; -pub use session_store::{SessionMetadataStore, SessionRecord}; diff --git a/src-tauri/crates/terminal/src/persistence/session_store.rs b/src-tauri/crates/terminal/src/persistence/session_store.rs deleted file mode 100644 index 8bad84d45..000000000 --- a/src-tauri/crates/terminal/src/persistence/session_store.rs +++ /dev/null @@ -1,428 +0,0 @@ -//! 会话元数据存储 -//! -//! 使用 SQLite 存储终端会话的元数据信息。 -//! -//! ## 功能 -//! - 会话元数据的 CRUD 操作 -//! - 会话状态查询 -//! - 会话恢复支持 -//! -//! _Requirements: 3.5, 3.9_ - -use chrono::Utc; -use rusqlite::{params, OptionalExtension}; -use serde::{Deserialize, Serialize}; - -use crate::error::TerminalError; -use lime_core::database::DbConnection; - -/// 会话记录(存储在 SQLite) -/// -/// _Requirements: 3.5_ -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionRecord { - /// 会话 ID - pub id: String, - /// 块 ID(用于关联 BlockFile) - pub block_id: String, - /// 标签页 ID - pub tab_id: String, - /// 控制器类型(shell/cmd) - pub controller_type: String, - /// 连接名称(本地/SSH/WSL) - pub connection: Option, - /// 会话状态(running/done/error) - pub status: String, - /// 创建时间(Unix 时间戳,毫秒) - pub created_at: i64, - /// 更新时间(Unix 时间戳,毫秒) - pub updated_at: i64, - /// 退出码 - pub exit_code: Option, -} - -impl SessionRecord { - /// 创建新的会话记录 - pub fn new( - id: String, - block_id: String, - tab_id: String, - controller_type: String, - connection: Option, - ) -> Self { - let now = Utc::now().timestamp_millis(); - Self { - id, - block_id, - tab_id, - controller_type, - connection, - status: "running".to_string(), - created_at: now, - updated_at: now, - exit_code: None, - } - } -} - -/// 会话元数据存储服务 -/// -/// 提供会话元数据的 SQLite 存储和查询功能。 -/// -/// _Requirements: 3.5, 3.9_ -pub struct SessionMetadataStore { - db: DbConnection, -} - -impl SessionMetadataStore { - /// 创建新的会话存储服务 - pub fn new(db: DbConnection) -> Self { - Self { db } - } - - /// 初始化数据库表 - /// - /// 创建 terminal_sessions 表(如果不存在)。 - pub fn init_tables(&self) -> Result<(), TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS terminal_sessions ( - id TEXT PRIMARY KEY, - block_id TEXT NOT NULL, - tab_id TEXT NOT NULL, - controller_type TEXT NOT NULL, - connection TEXT, - status TEXT NOT NULL DEFAULT 'running', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - exit_code INTEGER - )", - [], - ) - .map_err(|e| TerminalError::DatabaseError(format!("创建表失败: {e}")))?; - - // 创建索引 - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_terminal_sessions_block_id ON terminal_sessions(block_id)", - [], - ) - .map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {e}")))?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_terminal_sessions_tab_id ON terminal_sessions(tab_id)", - [], - ) - .map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {e}")))?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_terminal_sessions_status ON terminal_sessions(status)", - [], - ) - .map_err(|e| TerminalError::DatabaseError(format!("创建索引失败: {e}")))?; - - tracing::debug!("[SessionStore] 数据库表初始化完成"); - Ok(()) - } - - /// 保存会话记录 - /// - /// 如果记录已存在则更新,否则插入新记录。 - /// - /// _Requirements: 3.5_ - pub fn save(&self, record: &SessionRecord) -> Result<(), TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - conn.execute( - "INSERT OR REPLACE INTO terminal_sessions - (id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - record.id, - record.block_id, - record.tab_id, - record.controller_type, - record.connection, - record.status, - record.created_at, - record.updated_at, - record.exit_code, - ], - ) - .map_err(|e| TerminalError::DatabaseError(format!("保存会话失败: {e}")))?; - - tracing::debug!("[SessionStore] 保存会话: {}", record.id); - Ok(()) - } - - /// 根据 ID 获取会话记录 - pub fn get_by_id(&self, id: &str) -> Result, TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let result = conn - .query_row( - "SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code - FROM terminal_sessions WHERE id = ?1", - params![id], - |row| { - Ok(SessionRecord { - id: row.get(0)?, - block_id: row.get(1)?, - tab_id: row.get(2)?, - controller_type: row.get(3)?, - connection: row.get(4)?, - status: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - exit_code: row.get(8)?, - }) - }, - ) - .optional() - .map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))?; - - Ok(result) - } - - /// 根据块 ID 获取会话记录 - pub fn get_by_block_id(&self, block_id: &str) -> Result, TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let result = conn - .query_row( - "SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code - FROM terminal_sessions WHERE block_id = ?1", - params![block_id], - |row| { - Ok(SessionRecord { - id: row.get(0)?, - block_id: row.get(1)?, - tab_id: row.get(2)?, - controller_type: row.get(3)?, - connection: row.get(4)?, - status: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - exit_code: row.get(8)?, - }) - }, - ) - .optional() - .map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))?; - - Ok(result) - } - - /// 获取所有会话记录 - pub fn get_all(&self) -> Result, TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let mut stmt = conn - .prepare( - "SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code - FROM terminal_sessions ORDER BY created_at DESC", - ) - .map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {e}")))?; - - let records = stmt - .query_map([], |row| { - Ok(SessionRecord { - id: row.get(0)?, - block_id: row.get(1)?, - tab_id: row.get(2)?, - controller_type: row.get(3)?, - connection: row.get(4)?, - status: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - exit_code: row.get(8)?, - }) - }) - .map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))? - .collect::, _>>() - .map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {e}")))?; - - Ok(records) - } - - /// 获取指定状态的会话记录 - pub fn get_by_status(&self, status: &str) -> Result, TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let mut stmt = conn - .prepare( - "SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code - FROM terminal_sessions WHERE status = ?1 ORDER BY created_at DESC", - ) - .map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {e}")))?; - - let records = stmt - .query_map(params![status], |row| { - Ok(SessionRecord { - id: row.get(0)?, - block_id: row.get(1)?, - tab_id: row.get(2)?, - controller_type: row.get(3)?, - connection: row.get(4)?, - status: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - exit_code: row.get(8)?, - }) - }) - .map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))? - .collect::, _>>() - .map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {e}")))?; - - Ok(records) - } - - /// 获取指定标签页的会话记录 - pub fn get_by_tab_id(&self, tab_id: &str) -> Result, TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let mut stmt = conn - .prepare( - "SELECT id, block_id, tab_id, controller_type, connection, status, created_at, updated_at, exit_code - FROM terminal_sessions WHERE tab_id = ?1 ORDER BY created_at DESC", - ) - .map_err(|e| TerminalError::DatabaseError(format!("准备查询失败: {e}")))?; - - let records = stmt - .query_map(params![tab_id], |row| { - Ok(SessionRecord { - id: row.get(0)?, - block_id: row.get(1)?, - tab_id: row.get(2)?, - controller_type: row.get(3)?, - connection: row.get(4)?, - status: row.get(5)?, - created_at: row.get(6)?, - updated_at: row.get(7)?, - exit_code: row.get(8)?, - }) - }) - .map_err(|e| TerminalError::DatabaseError(format!("查询会话失败: {e}")))? - .collect::, _>>() - .map_err(|e| TerminalError::DatabaseError(format!("读取会话失败: {e}")))?; - - Ok(records) - } - - /// 更新会话状态 - /// - /// _Requirements: 3.9_ - pub fn update_status( - &self, - id: &str, - status: &str, - exit_code: Option, - ) -> Result<(), TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let now = Utc::now().timestamp_millis(); - - conn.execute( - "UPDATE terminal_sessions SET status = ?1, exit_code = ?2, updated_at = ?3 WHERE id = ?4", - params![status, exit_code, now, id], - ) - .map_err(|e| TerminalError::DatabaseError(format!("更新会话状态失败: {e}")))?; - - tracing::debug!("[SessionStore] 更新会话状态: {} -> {}", id, status); - Ok(()) - } - - /// 删除会话记录 - pub fn delete(&self, id: &str) -> Result<(), TerminalError> { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - conn.execute("DELETE FROM terminal_sessions WHERE id = ?1", params![id]) - .map_err(|e| TerminalError::DatabaseError(format!("删除会话失败: {e}")))?; - - tracing::debug!("[SessionStore] 删除会话: {}", id); - Ok(()) - } - - /// 删除指定标签页的所有会话记录 - pub fn delete_by_tab_id(&self, tab_id: &str) -> Result { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let count = conn - .execute( - "DELETE FROM terminal_sessions WHERE tab_id = ?1", - params![tab_id], - ) - .map_err(|e| TerminalError::DatabaseError(format!("删除会话失败: {e}")))?; - - tracing::debug!("[SessionStore] 删除标签页 {} 的 {} 个会话", tab_id, count); - Ok(count) - } - - /// 清理已完成的旧会话 - /// - /// 删除状态为 "done" 且创建时间早于指定时间的会话。 - pub fn cleanup_old_sessions(&self, before_timestamp: i64) -> Result { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let count = conn - .execute( - "DELETE FROM terminal_sessions WHERE status = 'done' AND created_at < ?1", - params![before_timestamp], - ) - .map_err(|e| TerminalError::DatabaseError(format!("清理会话失败: {e}")))?; - - if count > 0 { - tracing::info!("[SessionStore] 清理了 {} 个旧会话", count); - } - Ok(count) - } - - /// 获取会话数量 - pub fn count(&self) -> Result { - let conn = self - .db - .lock() - .map_err(|e| TerminalError::DatabaseError(format!("无法获取数据库锁: {e}")))?; - - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM terminal_sessions", [], |row| { - row.get(0) - }) - .map_err(|e| TerminalError::DatabaseError(format!("查询会话数量失败: {e}")))?; - - Ok(count as usize) - } -} diff --git a/src-tauri/crates/terminal/src/pty_session.rs b/src-tauri/crates/terminal/src/pty_session.rs deleted file mode 100644 index fa8651c41..000000000 --- a/src-tauri/crates/terminal/src/pty_session.rs +++ /dev/null @@ -1,504 +0,0 @@ -//! PTY 会话封装 -//! -//! 封装单个 PTY 进程,处理输入输出和生命周期管理。 -//! -//! ## 功能 -//! - 创建和管理 PTY 子进程(使用默认大小预创建) -//! - 异步读取 PTY 输出并通过 Tauri Event 推送 -//! - 处理 PTY 输入写入 -//! - 监控进程退出状态 -//! - 保存输出历史(循环缓冲区) -//! -//! ## 架构说明 -//! PTY 在后端预创建,使用默认大小 (24x80)。前端连接后通过 resize 同步实际大小。 -//! 输出历史保存在循环缓冲区中,前端连接时可以获取历史数据。 - -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use parking_lot::Mutex; -use portable_pty::{native_pty_system, CommandBuilder, PtySize}; -use tokio::sync::RwLock; - -use crate::emit_helper; -use crate::emitter::TerminalEventEmit; -use crate::error::TerminalError; -use crate::events::{event_names, SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; - -/// 默认终端行数 -pub const DEFAULT_ROWS: u16 = 24; -/// 默认终端列数 -pub const DEFAULT_COLS: u16 = 80; -/// 输出历史缓冲区最大大小 (1MB) -const OUTPUT_BUFFER_MAX_SIZE: usize = 1024 * 1024; - -/// 循环缓冲区,用于存储终端输出历史 -struct CircularBuffer { - data: Vec, - max_size: usize, -} - -impl CircularBuffer { - fn new(max_size: usize) -> Self { - Self { - data: Vec::with_capacity(max_size), - max_size, - } - } - - fn append(&mut self, new_data: &[u8]) { - // 如果新数据超过最大大小,只保留最后 max_size 字节 - if new_data.len() >= self.max_size { - self.data.clear(); - self.data - .extend_from_slice(&new_data[new_data.len() - self.max_size..]); - return; - } - - // 追加新数据 - self.data.extend_from_slice(new_data); - - // 如果超过最大大小,移除开头的数据 - if self.data.len() > self.max_size { - let excess = self.data.len() - self.max_size; - self.data.drain(0..excess); - } - } - - fn get_all(&self) -> Vec { - self.data.clone() - } -} - -/// PTY 会话 -pub struct PtySession { - /// 会话 ID - id: String, - /// PTY 写入器(使用 Mutex 保证线程安全) - writer: Arc>>, - /// PTY Master(用于调整大小,使用 Mutex 保证线程安全) - master: Arc>>, - /// 会话状态 - status: Arc>, - /// 关闭标志 - shutdown_flag: Arc, - /// 输出历史缓冲区 - output_buffer: Arc>, -} - -impl PtySession { - #[cfg(target_os = "windows")] - fn is_valid_windows_shell(shell: &str) -> bool { - let cleaned = shell.trim(); - if cleaned.is_empty() { - return false; - } - - // 拒绝 Unix 风格路径(如 /bin/bash),避免在 Windows 下误判为可执行 shell - if cleaned.starts_with('/') { - return false; - } - - let path = Path::new(cleaned); - if path.is_absolute() { - if !path.exists() { - return false; - } - - let ext = path - .extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()); - - return matches!(ext.as_deref(), Some("exe" | "cmd" | "bat" | "com")); - } - - // 相对路径包含分隔符时通常不可控,直接拒绝;仅允许命令名(由 PATH 解析) - if cleaned.contains('/') || cleaned.contains('\\') { - return false; - } - - true - } - - fn resolve_default_shell() -> String { - let shell_from_env = std::env::var("SHELL").ok().and_then(|value| { - let cleaned = value - .split('\0') - .next() - .unwrap_or_default() - .trim() - .to_string(); - (!cleaned.is_empty()).then_some(cleaned) - }); - - #[cfg(target_os = "windows")] - { - if let Some(shell) = shell_from_env { - if Self::is_valid_windows_shell(&shell) { - return shell; - } - } - - if let Ok(comspec) = std::env::var("COMSPEC") { - let cleaned = comspec - .split('\0') - .next() - .unwrap_or_default() - .trim() - .to_string(); - if Self::is_valid_windows_shell(&cleaned) { - return cleaned; - } - } - - "cmd.exe".to_string() - } - - #[cfg(not(target_os = "windows"))] - { - if let Some(shell) = shell_from_env { - let path = Path::new(&shell); - if path.exists() { - return shell; - } - } - - if Path::new("/bin/bash").exists() { - "/bin/bash".to_string() - } else { - "/bin/sh".to_string() - } - } - } - - fn resolve_working_dir(cwd: Option) -> Option { - let dir = cwd?; - let cleaned = dir - .split('\0') - .next() - .unwrap_or_default() - .trim() - .to_string(); - if cleaned.is_empty() { - return None; - } - - let expanded = if let Some(stripped) = cleaned.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - home.join(stripped) - } else { - PathBuf::from(&cleaned) - } - } else if cleaned == "~" { - dirs::home_dir().unwrap_or_else(|| PathBuf::from(&cleaned)) - } else { - PathBuf::from(&cleaned) - }; - - (expanded.exists() && expanded.is_dir()).then_some(expanded) - } - - /// 创建新的 PTY 会话(使用默认大小) - /// - /// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。 - /// - /// # 参数 - /// - `id`: 会话 ID - /// - `app_handle`: Tauri 应用句柄 - /// - /// # 返回 - /// - `Ok(PtySession)`: 创建成功 - /// - `Err(TerminalError)`: 创建失败 - pub fn new(id: String, app_handle: impl TerminalEventEmit) -> Result { - Self::with_size(id, DEFAULT_ROWS, DEFAULT_COLS, app_handle) - } - - /// 创建新的 PTY 会话(指定大小) - /// - /// # 参数 - /// - `id`: 会话 ID - /// - `rows`: 终端行数 - /// - `cols`: 终端列数 - /// - `app_handle`: Tauri 应用句柄 - /// - /// # 返回 - /// - `Ok(PtySession)`: 创建成功 - /// - `Err(TerminalError)`: 创建失败 - pub fn with_size( - id: String, - rows: u16, - cols: u16, - app_handle: impl TerminalEventEmit, - ) -> Result { - Self::with_size_and_cwd(id, rows, cols, None, app_handle) - } - - /// 创建新的 PTY 会话(指定大小和工作目录) - /// - /// # 参数 - /// - `id`: 会话 ID - /// - `rows`: 终端行数 - /// - `cols`: 终端列数 - /// - `cwd`: 工作目录(可选,默认为用户主目录) - /// - `app_handle`: Tauri 应用句柄 - /// - /// # 返回 - /// - `Ok(PtySession)`: 创建成功 - /// - `Err(TerminalError)`: 创建失败 - pub fn with_size_and_cwd( - id: String, - rows: u16, - cols: u16, - cwd: Option, - app_handle: impl TerminalEventEmit, - ) -> Result { - tracing::info!( - "[终端] 创建 PTY 会话 {}, 大小: {}x{}, cwd: {:?}", - id, - cols, - rows, - cwd - ); - - let pty_system = native_pty_system(); - - // 创建 PTY - let pair = pty_system - .openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 获取用户默认 shell(Windows 优先使用 COMSPEC/cmd.exe) - let shell = Self::resolve_default_shell(); - tracing::info!("[终端] 使用 shell: {}", shell); - - // 构建命令 - let mut cmd = CommandBuilder::new(&shell); - cmd.env("TERM", "xterm-256color"); - - // 设置工作目录 - if let Some(expanded_dir) = Self::resolve_working_dir(cwd.clone()) { - tracing::info!("[终端] 设置工作目录: {:?}", expanded_dir); - cmd.cwd(expanded_dir); - } else if let Some(raw) = cwd { - tracing::warn!("[终端] 工作目录无效或不存在: {:?}, 使用主目录", raw); - if let Some(home) = dirs::home_dir() { - cmd.cwd(home); - } - } else if let Some(home) = dirs::home_dir() { - cmd.cwd(home); - } - - // 启动子进程 - let _child = pair - .slave - .spawn_command(cmd) - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 获取写入器 - let writer = pair - .master - .take_writer() - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - // 获取读取器 - let mut reader = pair - .master - .try_clone_reader() - .map_err(|e| TerminalError::PtyCreationFailed(e.to_string()))?; - - let status = Arc::new(RwLock::new(SessionStatus::Running)); - let status_clone = status.clone(); - let id_clone = id.clone(); - - // 创建关闭标志 - let shutdown_flag = Arc::new(AtomicBool::new(false)); - let shutdown_flag_clone = shutdown_flag.clone(); - - // 创建输出缓冲区 - let output_buffer = Arc::new(Mutex::new(CircularBuffer::new(OUTPUT_BUFFER_MAX_SIZE))); - let output_buffer_clone = output_buffer.clone(); - - // 获取当前 tokio runtime handle(在主线程中获取) - let runtime_handle = tokio::runtime::Handle::current(); - - // 启动输出读取任务(使用独立线程) - std::thread::spawn(move || { - let mut buffer = [0u8; 4096]; - - loop { - // 检查关闭标志 - if shutdown_flag_clone.load(Ordering::Relaxed) { - tracing::debug!("[终端] 会话 {} 收到关闭信号", id_clone); - break; - } - - // 读取输出 - match reader.read(&mut buffer) { - Ok(0) => { - // EOF,进程已退出 - tracing::info!("[终端] 会话 {} 进程已退出", id_clone); - runtime_handle.block_on(async { - *status_clone.write().await = SessionStatus::Done; - }); - - // 发送状态事件 - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: id_clone.clone(), - status: SessionStatus::Done, - exit_code: Some(0), - error: None, - }, - ); - break; - } - Ok(n) => { - let output_data = &buffer[..n]; - - // 保存到输出缓冲区 - output_buffer_clone.lock().append(output_data); - - // 发送输出事件 - let data = BASE64.encode(output_data); - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_OUTPUT, - &TerminalOutputEvent { - session_id: id_clone.clone(), - data, - }, - ); - } - Err(e) => { - // 检查是否是因为关闭导致的错误 - if shutdown_flag_clone.load(Ordering::Relaxed) { - break; - } - - tracing::error!("[终端] 会话 {} 读取错误: {}", id_clone, e); - runtime_handle.block_on(async { - *status_clone.write().await = SessionStatus::Error; - }); - - let _ = emit_helper::emit( - &app_handle, - event_names::TERMINAL_STATUS, - &TerminalStatusEvent { - session_id: id_clone.clone(), - status: SessionStatus::Error, - exit_code: None, - error: Some(e.to_string()), - }, - ); - break; - } - } - } - }); - - tracing::info!("[终端] 会话 {} 已创建 ({}x{})", id, cols, rows); - - Ok(Self { - id, - writer: Arc::new(Mutex::new(writer)), - master: Arc::new(Mutex::new(pair.master)), - status, - shutdown_flag, - output_buffer, - }) - } - - /// 获取会话 ID - pub fn id(&self) -> &str { - &self.id - } - - /// 写入数据到 PTY - pub fn write(&self, data: &[u8]) -> Result<(), TerminalError> { - let mut writer = self.writer.lock(); - writer - .write_all(data) - .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; - writer - .flush() - .map_err(|e| TerminalError::WriteFailed(e.to_string()))?; - Ok(()) - } - - /// 调整 PTY 大小 - pub fn resize(&self, rows: u16, cols: u16) -> Result<(), TerminalError> { - let master = self.master.lock(); - master - .resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| TerminalError::ResizeFailed(e.to_string()))?; - tracing::debug!("[终端] 会话 {} 调整大小为 {}x{}", self.id, cols, rows); - Ok(()) - } - - /// 获取当前状态 - pub async fn status(&self) -> SessionStatus { - *self.status.read().await - } - - /// 获取输出历史数据(Base64 编码) - pub fn get_output_history(&self) -> String { - let buffer = self.output_buffer.lock(); - let data = buffer.get_all(); - BASE64.encode(&data) - } - - /// 关闭会话 - pub async fn close(&self) -> Result<(), TerminalError> { - // 设置关闭标志 - self.shutdown_flag.store(true, Ordering::Relaxed); - - // 更新状态 - *self.status.write().await = SessionStatus::Done; - - tracing::info!("[终端] 会话 {} 已关闭", self.id); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::PtySession; - - #[test] - fn resolve_working_dir_should_strip_nul_suffix() { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let raw = format!("{}\0", temp_dir.path().to_string_lossy()); - - let resolved = PtySession::resolve_working_dir(Some(raw)); - - assert_eq!(resolved.as_deref(), Some(temp_dir.path())); - } - - #[test] - fn resolve_working_dir_should_reject_invalid_path() { - let resolved = PtySession::resolve_working_dir(Some("/path/not-exists\0".to_string())); - assert!(resolved.is_none()); - } - - #[test] - fn resolve_default_shell_should_not_be_empty() { - let shell = PtySession::resolve_default_shell(); - assert!(!shell.trim().is_empty()); - } -} diff --git a/src-tauri/crates/terminal/src/session_manager.rs b/src-tauri/crates/terminal/src/session_manager.rs deleted file mode 100644 index 386f239a8..000000000 --- a/src-tauri/crates/terminal/src/session_manager.rs +++ /dev/null @@ -1,502 +0,0 @@ -//! 终端会话管理器 -//! -//! 管理所有终端会话的完整生命周期,集成 BlockController 和 BlockFile。 -//! -//! ## 功能 -//! - 会话创建、恢复、关闭的完整流程 -//! - 集成 BlockController 进行进程管理 -//! - 集成 BlockFile 进行输出持久化 -//! - 集成 SessionMetadataStore 进行元数据存储 -//! - 支持会话状态生命周期管理 -//! -//! ## Requirements -//! - 3.1: 终端会话创建时创建对应的 Block_File -//! - 3.5: 应用启动时从数据库加载已保存的会话元数据 -//! - 3.6: 用户请求恢复会话时从 Block_File 读取历史数据并重建会话 -//! - 3.8: Block_File 读取失败时返回错误并允许创建新会话 -//! - 3.9: 会话关闭时更新会话元数据状态为已完成 - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; - -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use chrono::Utc; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; -use uuid::Uuid; - -use lime_core::database::DbConnection; - -use crate::block_controller::ControllerRegistry; -use crate::emitter::{DynEmitter, TerminalEventEmit}; -use crate::error::TerminalError; -use crate::events::SessionStatus; -use crate::persistence::{BlockFile, SessionMetadataStore, SessionRecord}; -use crate::pty_session::{PtySession, DEFAULT_COLS, DEFAULT_ROWS}; - -/// 会话元数据(用于前端展示) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionMetadata { - /// 会话 ID - pub id: String, - /// 块 ID(与会话 ID 相同) - pub block_id: String, - /// 标签页 ID - pub tab_id: String, - /// 控制器类型 - pub controller_type: String, - /// 连接名称 - pub connection: Option, - /// 会话状态 - pub status: SessionStatus, - /// 创建时间(Unix 时间戳,毫秒) - pub created_at: i64, - /// 终端行数 - pub rows: u16, - /// 终端列数 - pub cols: u16, - /// 退出码 - pub exit_code: Option, -} - -impl SessionMetadata { - /// 从 SessionRecord 创建 - pub fn from_record(record: &SessionRecord, rows: u16, cols: u16) -> Self { - let status = match record.status.as_str() { - "running" => SessionStatus::Running, - "done" => SessionStatus::Done, - "error" => SessionStatus::Error, - _ => SessionStatus::Connecting, - }; - - Self { - id: record.id.clone(), - block_id: record.block_id.clone(), - tab_id: record.tab_id.clone(), - controller_type: record.controller_type.clone(), - connection: record.connection.clone(), - status, - created_at: record.created_at, - rows, - cols, - exit_code: record.exit_code, - } - } -} - -/// 内部会话数据 -struct SessionData { - /// 会话元数据 - metadata: SessionMetadata, - /// 块文件存储 - block_file: Arc, - /// 旧版 PTY 会话(兼容模式) - legacy_pty: Option, -} - -/// 终端会话管理器 -/// -/// 管理所有终端会话的完整生命周期,集成 BlockController 和 BlockFile。 -/// -/// ## 架构说明 -/// - 使用 ControllerRegistry 管理所有 BlockController -/// - 使用 SessionMetadataStore 持久化会话元数据 -/// - 使用 BlockFile 持久化终端输出 -/// - 支持新旧两种模式: -/// - 新模式:使用 BlockController + BlockFile -/// - 兼容模式:使用旧版 PtySession -pub struct TerminalSessionManager { - /// 会话映射表 - sessions: Arc>>, - /// 控制器注册表 - controller_registry: Arc, - /// 会话元数据存储 - session_store: Option>, - /// 块文件基础目录 - block_file_base_dir: PathBuf, - /// Tauri 应用句柄(抽象为事件发射器) - app_handle: DynEmitter, -} - -impl TerminalSessionManager { - /// 创建新的会话管理器 - /// - /// # 参数 - /// - `app_handle`: 事件发射器(实现 TerminalEventEmit trait) - pub fn new(app_handle: impl TerminalEventEmit) -> Self { - let block_file_base_dir = BlockFile::default_base_dir() - .unwrap_or_else(|_| PathBuf::from(".lime/terminal_blocks")); - - tracing::info!( - "[终端] 会话管理器已初始化,块文件目录: {:?}", - block_file_base_dir - ); - - Self { - sessions: Arc::new(RwLock::new(HashMap::new())), - controller_registry: Arc::new(ControllerRegistry::new()), - session_store: None, - block_file_base_dir, - app_handle: DynEmitter::new(app_handle), - } - } - - /// 创建带数据库连接的会话管理器 - /// - /// # 参数 - /// - `app_handle`: 事件发射器(实现 TerminalEventEmit trait) - /// - `db`: 数据库连接 - /// - /// _Requirements: 3.5_ - pub fn with_database( - app_handle: impl TerminalEventEmit, - db: DbConnection, - ) -> Result { - let mut manager = Self::new(app_handle); - - // 创建会话存储服务 - let session_store = SessionMetadataStore::new(db); - session_store.init_tables()?; - - manager.session_store = Some(Arc::new(session_store)); - - tracing::info!("[终端] 会话管理器已初始化(带数据库支持)"); - Ok(manager) - } - - /// 获取控制器注册表 - pub fn controller_registry(&self) -> &Arc { - &self.controller_registry - } - - /// 获取会话存储服务 - pub fn session_store(&self) -> Option<&Arc> { - self.session_store.as_ref() - } - - /// 创建新的终端会话 - /// - /// 使用默认大小 (24x80) 创建 PTY 会话。 - /// - /// # 返回 - /// - `Ok(String)`: 会话 ID - /// - `Err(TerminalError)`: 创建失败 - /// - /// _Requirements: 3.1_ - pub async fn create_session(&self) -> Result { - self.create_session_with_size(DEFAULT_ROWS, DEFAULT_COLS) - .await - } - - /// 创建新的终端会话(指定大小) - /// - /// # 参数 - /// - `rows`: 终端行数 - /// - `cols`: 终端列数 - /// - /// # 返回 - /// - `Ok(String)`: 会话 ID - /// - `Err(TerminalError)`: 创建失败 - /// - /// _Requirements: 3.1_ - pub async fn create_session_with_size( - &self, - rows: u16, - cols: u16, - ) -> Result { - self.create_session_with_options(rows, cols, None).await - } - - /// 创建新的终端会话(指定大小和工作目录) - /// - /// # 参数 - /// - `rows`: 终端行数 - /// - `cols`: 终端列数 - /// - `cwd`: 工作目录(可选) - /// - /// # 返回 - /// - `Ok(String)`: 会话 ID - /// - `Err(TerminalError)`: 创建失败 - /// - /// _Requirements: 3.1_ - pub async fn create_session_with_options( - &self, - rows: u16, - cols: u16, - cwd: Option, - ) -> Result { - let session_id = Uuid::new_v4().to_string(); - let block_id = session_id.clone(); - let tab_id = "default".to_string(); // TODO: 支持多标签页 - - tracing::info!( - "[终端] 创建会话 {}, 大小: {}x{}, cwd: {:?}", - session_id, - cols, - rows, - cwd - ); - - // 创建块文件 - let block_file = BlockFile::with_default_size(&block_id, &self.block_file_base_dir)?; - let block_file = Arc::new(block_file); - - // 创建旧版 PTY 会话(兼容模式) - let pty_session = PtySession::with_size_and_cwd( - session_id.clone(), - rows, - cols, - cwd, - self.app_handle.clone(), - )?; - - // 创建会话元数据 - let metadata = SessionMetadata { - id: session_id.clone(), - block_id: block_id.clone(), - tab_id: tab_id.clone(), - controller_type: "shell".to_string(), - connection: None, - status: SessionStatus::Running, - created_at: Utc::now().timestamp_millis(), - rows, - cols, - exit_code: None, - }; - - // 保存到数据库 - if let Some(store) = &self.session_store { - let record = SessionRecord { - id: session_id.clone(), - block_id: block_id.clone(), - tab_id: tab_id.clone(), - controller_type: "shell".to_string(), - connection: None, - status: "running".to_string(), - created_at: metadata.created_at, - updated_at: metadata.created_at, - exit_code: None, - }; - store.save(&record)?; - } - - // 创建会话数据 - let session_data = SessionData { - metadata, - block_file, - legacy_pty: Some(pty_session), - }; - - // 添加到会话映射表 - let mut sessions = self.sessions.write().await; - sessions.insert(session_id.clone(), session_data); - - tracing::info!("[终端] 会话 {} 创建成功", session_id); - Ok(session_id) - } - - /// 向会话写入数据(Base64 编码) - /// - /// # 参数 - /// - `session_id`: 会话 ID - /// - `data`: Base64 编码的数据 - /// - /// _Requirements: 3.2_ - pub async fn write_to_session_base64( - &self, - session_id: &str, - data: &str, - ) -> Result<(), TerminalError> { - let decoded = BASE64 - .decode(data) - .map_err(|e| TerminalError::WriteFailed(format!("Base64 解码失败: {e}")))?; - self.write_to_session(session_id, &decoded).await - } - - /// 向会话写入数据 - /// - /// # 参数 - /// - `session_id`: 会话 ID - /// - `data`: 原始数据 - /// - /// _Requirements: 3.2_ - pub async fn write_to_session( - &self, - session_id: &str, - data: &[u8], - ) -> Result<(), TerminalError> { - let sessions = self.sessions.read().await; - let session = sessions - .get(session_id) - .ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?; - - // 使用旧版 PTY 会话写入 - if let Some(pty) = &session.legacy_pty { - pty.write(data)?; - } - - // 同时写入块文件(用于持久化) - session.block_file.append_data(data)?; - - Ok(()) - } - - /// 调整会话终端大小 - /// - /// # 参数 - /// - `session_id`: 会话 ID - /// - `rows`: 新的行数 - /// - `cols`: 新的列数 - pub async fn resize_session( - &self, - session_id: &str, - rows: u16, - cols: u16, - ) -> Result<(), TerminalError> { - let mut sessions = self.sessions.write().await; - let session = sessions - .get_mut(session_id) - .ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?; - - // 使用旧版 PTY 会话调整大小 - if let Some(pty) = &session.legacy_pty { - pty.resize(rows, cols)?; - } - - // 更新元数据 - session.metadata.rows = rows; - session.metadata.cols = cols; - - tracing::debug!("[终端] 会话 {} 调整大小为 {}x{}", session_id, cols, rows); - - Ok(()) - } - - /// 关闭会话 - /// - /// # 参数 - /// - `session_id`: 会话 ID - /// - /// _Requirements: 3.9_ - pub async fn close_session(&self, session_id: &str) -> Result<(), TerminalError> { - let mut sessions = self.sessions.write().await; - - if let Some(mut session) = sessions.remove(session_id) { - // 关闭旧版 PTY 会话 - if let Some(pty) = session.legacy_pty.take() { - pty.close().await?; - } - - // 更新数据库状态 - if let Some(store) = &self.session_store { - store.update_status(session_id, "done", None)?; - } - - tracing::info!("[终端] 会话 {} 已关闭", session_id); - } - - Ok(()) - } - - /// 获取所有会话列表 - pub async fn list_sessions(&self) -> Vec { - let sessions = self.sessions.read().await; - sessions.values().map(|s| s.metadata.clone()).collect() - } - - /// 获取单个会话信息 - /// - /// # 参数 - /// - `session_id`: 会话 ID - pub async fn get_session(&self, session_id: &str) -> Option { - let sessions = self.sessions.read().await; - sessions.get(session_id).map(|s| s.metadata.clone()) - } - - /// 恢复会话(从持久化存储) - /// - /// # 参数 - /// - `session_id`: 会话 ID - /// - /// _Requirements: 3.6, 3.8_ - pub async fn restore_session( - &self, - session_id: &str, - ) -> Result { - // 从数据库加载会话记录 - let store = self - .session_store - .as_ref() - .ok_or_else(|| TerminalError::DatabaseError("会话存储未初始化".to_string()))?; - - let record = store - .get_by_id(session_id)? - .ok_or_else(|| TerminalError::SessionNotFound(session_id.to_string()))?; - - // 检查块文件是否存在 - let block_file_path = self.block_file_base_dir.join(format!("{session_id}.block")); - if !block_file_path.exists() { - return Err(TerminalError::BlockFileError(format!( - "块文件不存在: {block_file_path:?}" - ))); - } - - // 创建块文件引用 - let block_file = BlockFile::with_default_size(&record.block_id, &self.block_file_base_dir)?; - let block_file = Arc::new(block_file); - - // 读取历史数据 - let _history = block_file.read_all()?; - - // 创建新的 PTY 会话 - let rows = DEFAULT_ROWS; - let cols = DEFAULT_COLS; - let pty_session = - PtySession::with_size(session_id.to_string(), rows, cols, self.app_handle.clone())?; - - // 创建会话元数据 - let metadata = SessionMetadata::from_record(&record, rows, cols); - - // 创建会话数据 - let session_data = SessionData { - metadata: metadata.clone(), - block_file, - legacy_pty: Some(pty_session), - }; - - // 添加到会话映射表 - let mut sessions = self.sessions.write().await; - sessions.insert(session_id.to_string(), session_data); - - // 更新数据库状态 - store.update_status(session_id, "running", None)?; - - tracing::info!("[终端] 会话 {} 已恢复", session_id); - Ok(metadata) - } - - /// 加载所有已保存的会话(应用启动时调用) - /// - /// _Requirements: 3.5_ - pub async fn load_saved_sessions(&self) -> Result, TerminalError> { - let store = match &self.session_store { - Some(s) => s, - None => return Ok(vec![]), - }; - - let records = store.get_all()?; - let mut result = Vec::new(); - - for record in records { - // 只加载运行中的会话 - if record.status == "running" { - let metadata = SessionMetadata::from_record(&record, DEFAULT_ROWS, DEFAULT_COLS); - result.push(metadata); - } - } - - tracing::info!("[终端] 加载了 {} 个已保存的会话", result.len()); - Ok(result) - } -} diff --git a/src-tauri/crates/terminal/src/tests.rs b/src-tauri/crates/terminal/src/tests.rs deleted file mode 100644 index fd7727f89..000000000 --- a/src-tauri/crates/terminal/src/tests.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! 终端模块单元测试 -//! -//! 测试终端核心能力的各个组件。 -//! -//! ## 测试覆盖 -//! - 错误类型序列化 -//! - 会话状态转换 -//! - 事件结构序列化 - -#[cfg(test)] -mod unit_tests { - use super::super::error::TerminalError; - use super::super::events::{SessionStatus, TerminalOutputEvent, TerminalStatusEvent}; - - // ======================================================================== - // 错误类型测试 - // ======================================================================== - - #[test] - fn test_terminal_error_session_not_found() { - let err = TerminalError::SessionNotFound("test-session-123".to_string()); - assert_eq!(err.to_string(), "会话不存在: test-session-123"); - } - - #[test] - fn test_terminal_error_pty_creation_failed() { - let err = TerminalError::PtyCreationFailed("spawn failed".to_string()); - assert_eq!(err.to_string(), "PTY 创建失败: spawn failed"); - } - - #[test] - fn test_terminal_error_write_failed() { - let err = TerminalError::WriteFailed("broken pipe".to_string()); - assert_eq!(err.to_string(), "写入失败: broken pipe"); - } - - #[test] - fn test_terminal_error_resize_failed() { - let err = TerminalError::ResizeFailed("invalid size".to_string()); - assert_eq!(err.to_string(), "调整大小失败: invalid size"); - } - - #[test] - fn test_terminal_error_session_closed() { - let err = TerminalError::SessionClosed; - assert_eq!(err.to_string(), "会话已关闭"); - } - - #[test] - fn test_terminal_error_base64_decode_failed() { - let err = TerminalError::Base64DecodeFailed("invalid base64".to_string()); - assert_eq!(err.to_string(), "Base64 解码失败: invalid base64"); - } - - #[test] - fn test_terminal_error_internal() { - let err = TerminalError::Internal("unexpected error".to_string()); - assert_eq!(err.to_string(), "内部错误: unexpected error"); - } - - #[test] - fn test_terminal_error_to_string_conversion() { - let err = TerminalError::SessionNotFound("abc".to_string()); - let s: String = err.into(); - assert_eq!(s, "会话不存在: abc"); - } - - #[test] - fn test_terminal_error_serialize() { - let err = TerminalError::SessionNotFound("test".to_string()); - let json = serde_json::to_string(&err).unwrap(); - assert_eq!(json, "\"会话不存在: test\""); - } - - // ======================================================================== - // 会话状态测试 - // ======================================================================== - - #[test] - fn test_session_status_default() { - let status = SessionStatus::default(); - assert_eq!(status, SessionStatus::Connecting); - } - - #[test] - fn test_session_status_serialize() { - assert_eq!( - serde_json::to_string(&SessionStatus::Connecting).unwrap(), - "\"connecting\"" - ); - assert_eq!( - serde_json::to_string(&SessionStatus::Running).unwrap(), - "\"running\"" - ); - assert_eq!( - serde_json::to_string(&SessionStatus::Done).unwrap(), - "\"done\"" - ); - assert_eq!( - serde_json::to_string(&SessionStatus::Error).unwrap(), - "\"error\"" - ); - } - - #[test] - fn test_session_status_deserialize() { - assert_eq!( - serde_json::from_str::("\"connecting\"").unwrap(), - SessionStatus::Connecting - ); - assert_eq!( - serde_json::from_str::("\"running\"").unwrap(), - SessionStatus::Running - ); - assert_eq!( - serde_json::from_str::("\"done\"").unwrap(), - SessionStatus::Done - ); - assert_eq!( - serde_json::from_str::("\"error\"").unwrap(), - SessionStatus::Error - ); - } - - #[test] - fn test_session_status_equality() { - assert_eq!(SessionStatus::Running, SessionStatus::Running); - assert_ne!(SessionStatus::Running, SessionStatus::Done); - } - - #[test] - fn test_session_status_clone() { - let status = SessionStatus::Running; - let cloned = status; - assert_eq!(status, cloned); - } - - // ======================================================================== - // 事件结构测试 - // ======================================================================== - - #[test] - fn test_terminal_output_event_serialize() { - let event = TerminalOutputEvent { - session_id: "session-123".to_string(), - data: "SGVsbG8gV29ybGQ=".to_string(), // "Hello World" in Base64 - }; - - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"session_id\":\"session-123\"")); - assert!(json.contains("\"data\":\"SGVsbG8gV29ybGQ=\"")); - } - - #[test] - fn test_terminal_output_event_deserialize() { - let json = r#"{"session_id":"abc","data":"dGVzdA=="}"#; - let event: TerminalOutputEvent = serde_json::from_str(json).unwrap(); - assert_eq!(event.session_id, "abc"); - assert_eq!(event.data, "dGVzdA=="); - } - - #[test] - fn test_terminal_status_event_serialize_done() { - let event = TerminalStatusEvent { - session_id: "session-456".to_string(), - status: SessionStatus::Done, - exit_code: Some(0), - error: None, - }; - - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"status\":\"done\"")); - assert!(json.contains("\"exit_code\":0")); - } - - #[test] - fn test_terminal_status_event_serialize_error() { - let event = TerminalStatusEvent { - session_id: "session-789".to_string(), - status: SessionStatus::Error, - exit_code: None, - error: Some("Connection refused".to_string()), - }; - - let json = serde_json::to_string(&event).unwrap(); - assert!(json.contains("\"status\":\"error\"")); - assert!(json.contains("\"error\":\"Connection refused\"")); - } - - #[test] - fn test_terminal_status_event_deserialize() { - let json = r#"{"session_id":"test","status":"running","exit_code":null,"error":null}"#; - let event: TerminalStatusEvent = serde_json::from_str(json).unwrap(); - assert_eq!(event.session_id, "test"); - assert_eq!(event.status, SessionStatus::Running); - assert!(event.exit_code.is_none()); - assert!(event.error.is_none()); - } - - // ======================================================================== - // 事件名称常量测试 - // ======================================================================== - - #[test] - fn test_event_names() { - use super::super::events::event_names; - assert_eq!(event_names::TERMINAL_OUTPUT, "terminal:output"); - assert_eq!(event_names::TERMINAL_STATUS, "terminal:status"); - } -} - -// ======================================================================== -// 属性测试 - ShellController -// ======================================================================== - -/// **Feature: terminal-enhancement, Property 1: 控制器类型一致性** -/// **Validates: Requirements 1.2, 1.3** -/// -/// *对于任意* 控制器创建请求,如果请求指定 controller_type 为 "shell", -/// 则创建的控制器实例的 controller_type 字段应为 "shell"; -/// 如果请求指定为 "cmd",则应为 "cmd"。 -#[cfg(test)] -mod property_tests { - use super::super::block_controller::{BlockControllerRuntimeStatus, BlockMeta}; - use proptest::prelude::*; - - /// 生成有效的控制器类型 - fn arb_controller_type() -> impl Strategy { - prop_oneof![Just("shell".to_string()), Just("cmd".to_string()),] - } - - /// 生成有效的 block_id - fn arb_block_id() -> impl Strategy { - "[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}".prop_map(|s| s.to_string()) - } - - proptest! { - /// **Feature: terminal-enhancement, Property 1: 控制器类型一致性** - /// **Validates: Requirements 1.2, 1.3** - /// - /// 测试 BlockControllerRuntimeStatus 的创建和状态一致性 - #[test] - fn prop_controller_type_consistency( - block_id in arb_block_id(), - _controller_type in arb_controller_type(), - ) { - // 创建运行时状态 - let status = BlockControllerRuntimeStatus::new(block_id.clone()); - - // 验证初始状态 - prop_assert_eq!(&status.block_id, &block_id); - prop_assert_eq!(status.version, 0); - prop_assert_eq!(&status.shell_proc_status, "init"); - prop_assert!(status.shell_proc_conn_name.is_none()); - prop_assert_eq!(status.shell_proc_exit_code, 0); - - // 验证状态检查方法 - prop_assert!(status.is_init()); - prop_assert!(!status.is_running()); - prop_assert!(!status.is_done()); - } - - /// **Feature: terminal-enhancement, Property 1: 控制器类型一致性** - /// **Validates: Requirements 1.2, 1.3** - /// - /// 测试 BlockMeta 的控制器类型字段一致性 - #[test] - fn prop_block_meta_controller_type_consistency( - controller_type in arb_controller_type(), - ) { - // 创建 BlockMeta - let meta = BlockMeta { - controller: Some(controller_type.clone()), - ..Default::default() - }; - - // 验证 get_string 返回正确的控制器类型 - prop_assert_eq!(meta.get_string("controller"), controller_type); - } - - /// **Feature: terminal-enhancement, Property 4: 控制器类型变更正确性** - /// **Validates: Requirements 1.7** - /// - /// 测试状态转换的有效性 - #[test] - fn prop_controller_status_transitions( - block_id in arb_block_id(), - ) { - // 创建初始状态 - let mut status = BlockControllerRuntimeStatus::new(block_id.clone()); - prop_assert!(status.is_init()); - - // 模拟状态转换到 running - status.shell_proc_status = "running".to_string(); - prop_assert!(status.is_running()); - prop_assert!(!status.is_init()); - prop_assert!(!status.is_done()); - - // 模拟状态转换到 done - status.shell_proc_status = "done".to_string(); - prop_assert!(status.is_done()); - prop_assert!(!status.is_init()); - prop_assert!(!status.is_running()); - } - - /// **Feature: terminal-enhancement, Property 4: 控制器类型变更正确性** - /// **Validates: Requirements 1.7** - /// - /// 测试版本号递增 - #[test] - fn prop_controller_version_increment( - block_id in arb_block_id(), - increments in 1..100usize, - ) { - let mut status = BlockControllerRuntimeStatus::new(block_id); - prop_assert_eq!(status.version, 0); - - // 模拟多次状态更新 - for i in 1..=increments { - status.version = i as i32; - prop_assert_eq!(status.version, i as i32); - } - } - } - - /// 测试 BlockMeta 默认值 - #[test] - fn test_block_meta_defaults() { - let meta = BlockMeta::default(); - assert!(meta.controller.is_none()); - assert!(meta.connection.is_none()); - assert!(meta.cmd.is_none()); - assert!(meta.cmd_args.is_none()); - assert!(meta.cmd_cwd.is_none()); - assert!(meta.cmd_env.is_none()); - assert!(meta.cmd_run_on_start.is_none()); - assert!(meta.cmd_run_once.is_none()); - assert!(meta.cmd_clear_on_start.is_none()); - assert!(meta.cmd_close_on_exit.is_none()); - } - - /// 测试 BlockMeta get_string 默认值 - #[test] - fn test_block_meta_get_string_defaults() { - let meta = BlockMeta::default(); - assert_eq!(meta.get_string("controller"), ""); - assert_eq!(meta.get_string("connection"), ""); - assert_eq!(meta.get_string("cmd"), ""); - assert_eq!(meta.get_string("cmd_cwd"), ""); - assert_eq!(meta.get_string("term_mode"), "term"); - assert_eq!(meta.get_string("term_theme"), ""); - assert_eq!(meta.get_string("unknown_field"), ""); - } -} diff --git a/src-tauri/src/README.md b/src-tauri/src/README.md index 86d99ff15..811edfa82 100644 --- a/src-tauri/src/README.md +++ b/src-tauri/src/README.md @@ -32,7 +32,7 @@ Tauri 后端核心代码,处理系统级功能和 API 服务。 - 更新检查支持提醒频控(同版本 24h 一次、关闭退避)与埋点计数 - `streaming/` - 流式响应处理 - `telemetry/` - 遥测和统计 -- `terminal/` - 终端核心模块(PTY 管理、会话管理) +- `terminal/` - 已移除的终端历史模块(仅保留治理语义,不再参与编译) - `tray/` - 系统托盘 - `websocket/` - WebSocket 支持 - `workspace/` - Workspace 工作目录管理 diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index b3d783688..af0d5fe26 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -17,7 +17,6 @@ use crate::commands::plugin_install_cmd::PluginInstallerState; use crate::commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState}; use crate::commands::session_files_cmd::SessionFilesState; use crate::commands::skill_cmd::SkillServiceState; -use crate::commands::terminal_cmd::TerminalManagerState; use crate::commands::webview_cmd::{ ChromeProfileManagerWrapper, WebviewManagerState, WebviewManagerWrapper, }; @@ -64,7 +63,6 @@ pub struct AppStates { pub connect_state: ConnectStateWrapper, pub model_registry: ModelRegistryState, pub global_config_manager: GlobalConfigManagerState, - pub terminal_manager: TerminalManagerState, pub webview_manager: WebviewManagerWrapper, pub chrome_profile_manager: ChromeProfileManagerWrapper, pub update_check_service: UpdateCheckServiceState, @@ -198,9 +196,6 @@ pub fn init_states(config: &Config) -> Result { // 初始化 Model Registry 状态(延迟初始化,在 setup hook 中完成) let model_registry_state: ModelRegistryState = Arc::new(RwLock::new(None)); - // 初始化终端管理器状态(延迟初始化,在 setup hook 中完成) - let terminal_manager_state = TerminalManagerState(Arc::new(RwLock::new(None))); - // 初始化 Webview 管理器状态 let webview_manager_state = WebviewManagerWrapper(Arc::new(RwLock::new(WebviewManagerState::new()))); @@ -289,7 +284,6 @@ pub fn init_states(config: &Config) -> Result { connect_state, model_registry: model_registry_state, global_config_manager: global_config_manager_state, - terminal_manager: terminal_manager_state, webview_manager: webview_manager_state, chrome_profile_manager: chrome_profile_manager_state, update_check_service: update_check_service_state, diff --git a/src-tauri/src/app/runner.rs b/src-tauri/src/app/runner.rs index 404fe1d14..6d27afe5f 100644 --- a/src-tauri/src/app/runner.rs +++ b/src-tauri/src/app/runner.rs @@ -157,7 +157,6 @@ pub fn run() { connect_state, model_registry: model_registry_state, global_config_manager: global_config_manager_state, - terminal_manager: terminal_manager_state, webview_manager: webview_manager_state, chrome_profile_manager: chrome_profile_manager_state, update_check_service: update_check_service_state, @@ -267,7 +266,6 @@ pub fn run() { .manage(connect_state) .manage(model_registry_state) .manage(global_config_manager_state) - .manage(terminal_manager_state) .manage(webview_manager_state) .manage(chrome_profile_manager_state) .manage(update_check_service_state) @@ -654,17 +652,6 @@ pub fn run() { }); } - // 初始化终端会话管理器 - { - let app_handle = app.handle().clone(); - let terminal_manager = lime_terminal::TerminalSessionManager::new(crate::terminal::TauriEmitter(app_handle.clone())); - if let Some(state) = app_handle.try_state::() { - let mut guard = state.inner().0.blocking_write(); - *guard = Some(terminal_manager); - tracing::info!("[启动] 终端会话管理器初始化成功"); - } - } - // 注册 Deep Link 事件处理器(桌面端) // _Requirements: 1.4_ #[cfg(desktop)] @@ -1282,6 +1269,7 @@ pub fn run() { commands::sceneapp_cmd::sceneapp_list_catalog, commands::sceneapp_cmd::sceneapp_get_descriptor, commands::sceneapp_cmd::sceneapp_plan_launch, + commands::sceneapp_cmd::sceneapp_save_context_baseline, commands::sceneapp_cmd::sceneapp_create_automation_job, commands::sceneapp_cmd::sceneapp_list_runs, commands::sceneapp_cmd::sceneapp_get_run_summary, @@ -1572,24 +1560,6 @@ pub fn run() { commands::model_cmd::get_all_available_models, commands::model_cmd::refresh_all_credential_models, commands::model_cmd::get_default_models_for_provider, - // Terminal commands - commands::terminal_cmd::terminal_create_session, - commands::terminal_cmd::terminal_write, - commands::terminal_cmd::terminal_resize, - commands::terminal_cmd::terminal_close, - commands::terminal_cmd::terminal_list_sessions, - commands::terminal_cmd::terminal_get_session, - // Connection commands - commands::connection_cmd::connection_list, - commands::connection_cmd::connection_add, - commands::connection_cmd::connection_update, - commands::connection_cmd::connection_delete, - commands::connection_cmd::connection_get, - commands::connection_cmd::connection_get_config_path, - commands::connection_cmd::connection_get_raw_config, - commands::connection_cmd::connection_save_raw_config, - commands::connection_cmd::connection_test, - commands::connection_cmd::connection_import_ssh_host, // WebSocket commands commands::websocket_cmd::get_websocket_status, commands::websocket_cmd::get_websocket_connections, diff --git a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs index 8de115640..35a0768c5 100644 --- a/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs +++ b/src-tauri/src/commands/aster_agent_cmd/command_api/runtime_api.rs @@ -1,4 +1,6 @@ use super::*; +use crate::sceneapp::application::SceneAppService; +use crate::services::execution_tracker_service::ExecutionTracker; use crate::services::runtime_analysis_handoff_service::{ export_runtime_analysis_handoff, RuntimeAnalysisHandoffExportResult, }; @@ -519,7 +521,7 @@ pub async fn agent_runtime_save_review_decision( let context = load_runtime_export_context(&runtime, &session_id, "保存 review decision 前").await?; - save_runtime_review_decision( + let saved = save_runtime_review_decision( &context.detail, &context.thread_read, &context.workspace_root, @@ -535,7 +537,22 @@ pub async fn agent_runtime_save_review_decision( regression_requirements: request.regression_requirements, notes: request.notes, }, - ) + )?; + + let tracker = ExecutionTracker::new(runtime.db().clone()); + if let Err(error) = SceneAppService::sync_review_decision_feedback_for_session( + &tracker, + &session_id, + &saved.decision, + ) { + tracing::warn!( + "[AsterAgent] 保存 review decision 后同步 SceneApp feedback 失败: session_id={}, error={}", + session_id, + error + ); + } + + Ok(saved) } /// 统一运行时:导出当前会话的 replay case。 diff --git a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs index c9d8f6027..2511194d7 100644 --- a/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs +++ b/src-tauri/src/commands/aster_agent_cmd/runtime_turn.rs @@ -2939,7 +2939,14 @@ fn resolve_runtime_access_mode_from_request( } fn backfill_runtime_access_policies(request: &mut AsterChatRequest) { - let Some(access_mode) = resolve_runtime_access_mode_from_request(request) else { + let access_mode = resolve_runtime_access_mode_from_request(request).or_else(|| { + if request.approval_policy.is_none() && request.sandbox_policy.is_none() { + Some(lime_agent::SessionExecutionRuntimeAccessMode::default_for_session()) + } else { + None + } + }); + let Some(access_mode) = access_mode else { return; }; @@ -5398,6 +5405,41 @@ mod tests { ); } + #[test] + fn backfill_runtime_access_policies_should_default_to_full_access_when_request_missing() { + let mut request = AsterChatRequest { + message: "继续执行".to_string(), + session_id: "session-access-default".to_string(), + event_name: "agent_stream".to_string(), + images: None, + provider_config: None, + provider_preference: None, + model_preference: None, + thinking_enabled: None, + approval_policy: None, + sandbox_policy: None, + project_id: None, + workspace_id: "workspace-access".to_string(), + web_search: None, + search_mode: None, + execution_strategy: None, + auto_continue: None, + system_prompt: None, + metadata: None, + turn_id: None, + queue_if_busy: None, + queued_turn_id: None, + }; + + backfill_runtime_access_policies(&mut request); + + assert_eq!(request.approval_policy.as_deref(), Some("never")); + assert_eq!( + request.sandbox_policy.as_deref(), + Some("danger-full-access") + ); + } + #[tokio::test] async fn update_compaction_session_metrics_should_move_summary_tokens_to_current_window() { ensure_runtime_turn_test_session_manager().await; diff --git a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs index 8c17ce42a..f36a28473 100644 --- a/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/session_runtime.rs @@ -205,7 +205,7 @@ pub(crate) async fn create_runtime_session_internal( .map(ToString::to_string) .or_else(|| Some(workspace_root.clone())); - AsterAgentWrapper::create_session_sync( + let session_id = AsterAgentWrapper::create_session_sync( db, name, resolved_working_dir, @@ -216,7 +216,15 @@ pub(crate) async fn create_runtime_session_internal( .as_db_value() .to_string(), ), + )?; + + AsterAgentWrapper::persist_session_recent_access_mode( + &session_id, + lime_agent::SessionExecutionRuntimeAccessMode::default_for_session(), ) + .await?; + + Ok(session_id) } pub(crate) fn update_runtime_session_execution_strategy_internal( diff --git a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs index 7c56c947b..797738996 100644 --- a/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs +++ b/src-tauri/src/commands/aster_agent_cmd/subagent_runtime.rs @@ -405,6 +405,7 @@ struct PreparedRuntimeSubagentSession { session: aster::session::Session, customization: Option, system_prompt: Option, + access_mode: lime_agent::SessionExecutionRuntimeAccessMode, } fn build_subagent_runtime_event_name(session_id: &str) -> String { @@ -421,6 +422,15 @@ fn parse_subagent_runtime_event_session_id(event_name: &str) -> Option<&str> { .and_then(|rest| rest.strip_prefix(':')) } +fn resolve_subagent_request_access_mode( + session_id: &str, + session: &aster::session::Session, +) -> lime_agent::SessionExecutionRuntimeAccessMode { + lime_agent::build_session_execution_runtime(session_id, Some(session), None, None, None) + .and_then(|runtime| runtime.recent_access_mode) + .unwrap_or_else(lime_agent::SessionExecutionRuntimeAccessMode::default_for_session) +} + fn should_emit_subagent_status_for_runtime_event(event: &RuntimeAgentEvent) -> bool { matches!( event, @@ -666,6 +676,7 @@ async fn create_runtime_subagent_session( .as_ref() .and_then(|state| state.profile_name.as_deref()); let role_hint = resolve_subagent_role_hint(request, customization.as_ref()); + let access_mode = resolve_subagent_request_access_mode(&parent_session_id, &parent_session); let working_dir = resolve_spawn_working_dir(parent_session.working_dir.as_path(), request.cwd.clone())?; @@ -717,6 +728,7 @@ async fn create_runtime_subagent_session( "写入 subagent session metadata", ) .await?; + AsterAgentWrapper::persist_session_recent_access_mode(&session.id, access_mode).await?; if let (Some(team_name), Some(teammate_name)) = (team_name, teammate_name.clone()) { register_spawned_teammate( &parent_session_id, @@ -740,6 +752,7 @@ async fn create_runtime_subagent_session( session, customization, system_prompt, + access_mode, }) } @@ -771,6 +784,7 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( session: child_session, customization, system_prompt, + access_mode, } = create_runtime_subagent_session(runtime, &request).await?; let child_session_id = child_session.id.clone(); let workspace_id = @@ -786,8 +800,8 @@ pub(crate) async fn agent_runtime_spawn_subagent_internal( provider_preference: None, model_preference: None, thinking_enabled: None, - approval_policy: None, - sandbox_policy: None, + approval_policy: Some(access_mode.approval_policy().to_string()), + sandbox_policy: Some(access_mode.sandbox_policy().to_string()), project_id: None, workspace_id, web_search: None, @@ -853,6 +867,7 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( } let (session, _) = read_subagent_control_state(&session_id).await?; + let access_mode = resolve_subagent_request_access_mode(&session_id, &session); let customization = SubagentCustomizationState::from_session(&session); let system_prompt = build_subagent_customization_system_prompt(customization.as_ref())?; if request.interrupt { @@ -873,8 +888,8 @@ pub(crate) async fn agent_runtime_send_subagent_input_internal( provider_preference: None, model_preference: None, thinking_enabled: None, - approval_policy: None, - sandbox_policy: None, + approval_policy: Some(access_mode.approval_policy().to_string()), + sandbox_policy: Some(access_mode.sandbox_policy().to_string()), project_id: None, workspace_id, web_search: None, @@ -1161,4 +1176,28 @@ mod tests { "isolation is not supported in the current runtime" ); } + + #[test] + fn test_resolve_subagent_request_access_mode_defaults_to_full_access() { + let session = aster::session::Session::default(); + + assert_eq!( + resolve_subagent_request_access_mode("child-1", &session), + lime_agent::SessionExecutionRuntimeAccessMode::FullAccess + ); + } + + #[test] + fn test_resolve_subagent_request_access_mode_prefers_session_recent_access_mode() { + let mut session = aster::session::Session::default(); + session.id = "child-2".to_string(); + lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly + .to_extension_data(&mut session.extension_data) + .expect("persist access mode"); + + assert_eq!( + resolve_subagent_request_access_mode("child-2", &session), + lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly + ); + } } diff --git a/src-tauri/src/commands/connection_cmd.rs b/src-tauri/src/commands/connection_cmd.rs deleted file mode 100644 index 97266f43b..000000000 --- a/src-tauri/src/commands/connection_cmd.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! 连接管理 Tauri 命令 -//! -//! 提供连接配置的 CRUD 操作接口。 -//! -//! ## 命令列表 -//! - `connection_list` - 获取所有连接(本地 + SSH 配置) -//! - `connection_add` - 添加新连接 -//! - `connection_update` - 更新连接 -//! - `connection_delete` - 删除连接 -//! - `connection_get_config_path` - 获取配置文件路径 -//! - `connection_get_raw_config` - 获取原始配置内容 -//! - `connection_save_raw_config` - 保存原始配置内容 - -use lime_terminal::connections::{ - ConnectionConfig, ConnectionConfigManager, ConnectionConfigType, ConnectionListEntry, -}; -use serde::{Deserialize, Serialize}; - -/// 添加连接的请求参数 -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AddConnectionRequest { - /// 连接名称 - pub name: String, - /// 连接类型 - #[serde(rename = "type")] - pub conn_type: ConnectionConfigType, - /// 用户名(SSH) - pub user: Option, - /// 主机名(SSH) - pub host: Option, - /// 端口(SSH) - pub port: Option, - /// 身份文件 - pub identity_file: Option, - /// 跳板机 - pub proxy_jump: Option, - /// WSL 发行版 - pub wsl_distro: Option, -} - -/// 更新连接的请求参数 -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateConnectionRequest { - /// 连接名称 - pub name: String, - /// 连接配置 - pub config: ConnectionConfig, -} - -/// 通用操作响应 -#[derive(Debug, Serialize)] -pub struct ConnectionResponse { - /// 是否成功 - pub success: bool, - /// 错误信息 - pub error: Option, -} - -impl ConnectionResponse { - pub fn ok() -> Self { - Self { - success: true, - error: None, - } - } - - pub fn err(msg: impl Into) -> Self { - Self { - success: false, - error: Some(msg.into()), - } - } -} - -/// 获取所有可用连接 -/// -/// 返回本地连接 + 用户配置 + SSH 配置中的 Host 列表 -#[tauri::command] -pub fn connection_list() -> Result, String> { - let manager = ConnectionConfigManager::new(); - manager.list_all_connections() -} - -/// 添加新连接 -#[tauri::command] -pub fn connection_add(request: AddConnectionRequest) -> ConnectionResponse { - let manager = ConnectionConfigManager::new(); - - // 加载现有配置 - let mut config = match manager.load() { - Ok(c) => c, - Err(e) => return ConnectionResponse::err(e), - }; - - // 检查名称是否已存在 - if config.connections.contains_key(&request.name) { - return ConnectionResponse::err(format!("连接 '{}' 已存在", request.name)); - } - - // 创建新连接配置 - let conn_config = ConnectionConfig { - conn_type: request.conn_type, - user: request.user, - host: request.host, - port: request.port, - identity_file: request.identity_file, - identity_files: None, - proxy_jump: request.proxy_jump, - display_order: None, - hidden: None, - wsl_distro: request.wsl_distro, - }; - - // 添加并保存 - config.add(request.name.clone(), conn_config); - - match manager.save(&config) { - Ok(_) => { - tracing::info!("[Connection] 添加连接成功: {}", request.name); - ConnectionResponse::ok() - } - Err(e) => ConnectionResponse::err(e), - } -} - -/// 更新连接 -#[tauri::command] -pub fn connection_update(request: UpdateConnectionRequest) -> ConnectionResponse { - let manager = ConnectionConfigManager::new(); - - // 加载现有配置 - let mut config = match manager.load() { - Ok(c) => c, - Err(e) => return ConnectionResponse::err(e), - }; - - // 检查连接是否存在 - if !config.connections.contains_key(&request.name) { - return ConnectionResponse::err(format!("连接 '{}' 不存在", request.name)); - } - - // 更新配置 - config - .connections - .insert(request.name.clone(), request.config); - - match manager.save(&config) { - Ok(_) => { - tracing::info!("[Connection] 更新连接成功: {}", request.name); - ConnectionResponse::ok() - } - Err(e) => ConnectionResponse::err(e), - } -} - -/// 删除连接 -#[tauri::command] -pub fn connection_delete(name: String) -> ConnectionResponse { - let manager = ConnectionConfigManager::new(); - - // 加载现有配置 - let mut config = match manager.load() { - Ok(c) => c, - Err(e) => return ConnectionResponse::err(e), - }; - - // 检查连接是否存在 - if !config.connections.contains_key(&name) { - return ConnectionResponse::err(format!("连接 '{name}' 不存在")); - } - - // 删除并保存 - config.remove(&name); - - match manager.save(&config) { - Ok(_) => { - tracing::info!("[Connection] 删除连接成功: {}", name); - ConnectionResponse::ok() - } - Err(e) => ConnectionResponse::err(e), - } -} - -/// 获取连接配置 -#[tauri::command] -pub fn connection_get(name: String) -> Result, String> { - let manager = ConnectionConfigManager::new(); - let config = manager.load()?; - Ok(config.get(&name).cloned()) -} - -/// 获取配置文件路径 -#[tauri::command] -pub fn connection_get_config_path() -> String { - ConnectionConfigManager::default_config_path() - .to_string_lossy() - .to_string() -} - -/// 获取原始配置文件内容(用于 JSON 编辑器) -#[tauri::command] -pub fn connection_get_raw_config() -> Result { - let manager = ConnectionConfigManager::new(); - manager.load_raw() -} - -/// 保存原始配置文件内容(用于 JSON 编辑器) -#[tauri::command] -pub fn connection_save_raw_config(content: String) -> ConnectionResponse { - let manager = ConnectionConfigManager::new(); - - match manager.save_raw(&content) { - Ok(_) => { - tracing::info!("[Connection] 原始配置保存成功"); - ConnectionResponse::ok() - } - Err(e) => ConnectionResponse::err(e), - } -} - -/// 测试连接(异步) -#[tauri::command] -pub async fn connection_test(name: String) -> ConnectionResponse { - let manager = ConnectionConfigManager::new(); - - // 加载配置 - let config = match manager.load() { - Ok(c) => c, - Err(e) => return ConnectionResponse::err(e), - }; - - // 获取连接 - let conn = match config.get(&name) { - Some(c) => c, - None => return ConnectionResponse::err(format!("连接 '{name}' 不存在")), - }; - - // 根据连接类型测试 - match conn.conn_type { - ConnectionConfigType::Local => { - // 本地连接总是可用 - ConnectionResponse::ok() - } - ConnectionConfigType::Ssh => { - // SSH 连接测试 - let host = match &conn.host { - Some(h) => h, - None => return ConnectionResponse::err("SSH 连接缺少主机名"), - }; - - let port = conn.port.unwrap_or(22); - - // 简单的 TCP 连接测试 - match tokio::net::TcpStream::connect(format!("{host}:{port}")).await { - Ok(_) => { - tracing::info!("[Connection] SSH 连接测试成功: {}:{}", host, port); - ConnectionResponse::ok() - } - Err(e) => { - tracing::warn!("[Connection] SSH 连接测试失败: {} - {}", host, e); - ConnectionResponse::err(format!("连接失败: {e}")) - } - } - } - ConnectionConfigType::Wsl => { - // WSL 连接测试 - #[cfg(target_os = "windows")] - { - // 检查 wsl.exe 是否可用 - match std::process::Command::new("wsl") - .arg("--list") - .arg("--quiet") - .output() - { - Ok(output) => { - if output.status.success() { - ConnectionResponse::ok() - } else { - ConnectionResponse::err("WSL 命令执行失败") - } - } - Err(e) => ConnectionResponse::err(format!("WSL 不可用: {}", e)), - } - } - #[cfg(not(target_os = "windows"))] - { - ConnectionResponse::err("WSL 仅在 Windows 上可用") - } - } - } -} - -/// 导入 SSH 配置中的 Host 到用户配置 -#[tauri::command] -pub fn connection_import_ssh_host(host_name: String) -> ConnectionResponse { - let manager = ConnectionConfigManager::new(); - - // 加载 SSH hosts - let ssh_hosts = manager.load_ssh_hosts(); - - // 查找指定的 host - let ssh_host = match ssh_hosts.into_iter().find(|h| h.pattern == host_name) { - Some(h) => h, - None => return ConnectionResponse::err(format!("SSH Host '{host_name}' 不存在")), - }; - - // 加载用户配置 - let mut config = match manager.load() { - Ok(c) => c, - Err(e) => return ConnectionResponse::err(e), - }; - - // 检查是否已存在 - if config.connections.contains_key(&host_name) { - return ConnectionResponse::err(format!("连接 '{host_name}' 已存在")); - } - - // 创建连接配置 - let conn_config = ConnectionConfig { - conn_type: ConnectionConfigType::Ssh, - user: ssh_host.user, - host: ssh_host.hostname.or(Some(ssh_host.pattern.clone())), - port: ssh_host.port, - identity_file: ssh_host.identity_file, - identity_files: None, - proxy_jump: None, - display_order: None, - hidden: None, - wsl_distro: None, - }; - - // 添加并保存 - config.add(host_name.clone(), conn_config); - - match manager.save(&config) { - Ok(_) => { - tracing::info!("[Connection] 导入 SSH Host 成功: {}", host_name); - ConnectionResponse::ok() - } - Err(e) => ConnectionResponse::err(e), - } -} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b3c504b86..3199082c6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -13,7 +13,6 @@ pub mod channels_cmd; pub mod companion_cmd; pub mod config_cmd; pub mod connect_cmd; -pub mod connection_cmd; pub mod content_cmd; pub mod context_memory; pub mod document_import_cmd; @@ -58,7 +57,6 @@ pub mod skill_error; pub mod skill_exec_cmd; pub mod telegram_remote_cmd; pub mod telemetry_cmd; -pub mod terminal_cmd; pub mod theme_context_cmd; pub mod tray_cmd; pub mod unified_memory_cmd; diff --git a/src-tauri/src/commands/sceneapp_cmd.rs b/src-tauri/src/commands/sceneapp_cmd.rs index cbaa4741e..61409bbf7 100644 --- a/src-tauri/src/commands/sceneapp_cmd.rs +++ b/src-tauri/src/commands/sceneapp_cmd.rs @@ -31,6 +31,14 @@ pub async fn sceneapp_plan_launch( SceneAppService::plan_launch(db.inner(), intent) } +#[tauri::command] +pub async fn sceneapp_save_context_baseline( + db: State<'_, DbConnection>, + intent: SceneAppLaunchIntent, +) -> Result { + SceneAppService::save_context_baseline(db.inner(), intent) +} + #[tauri::command] pub async fn sceneapp_create_automation_job( automation_state: State<'_, AutomationServiceState>, diff --git a/src-tauri/src/commands/terminal_cmd.rs b/src-tauri/src/commands/terminal_cmd.rs deleted file mode 100644 index 18840334c..000000000 --- a/src-tauri/src/commands/terminal_cmd.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! 终端 Tauri 命令 -//! -//! 提供终端核心能力的 Tauri 命令接口。 -//! -//! ## 架构说明 -//! PTY 在后端预创建,使用默认大小 (24x80)。前端连接后通过 resize 同步实际大小。 -//! -//! ## 命令列表 -//! - `terminal_create_session` - 创建终端会话(使用默认大小) -//! - `terminal_write` - 向终端发送输入 -//! - `terminal_resize` - 调整终端大小 -//! - `terminal_close` - 关闭终端会话 -//! - `terminal_list_sessions` - 获取所有会话列表 - -use std::sync::Arc; - -use serde::Serialize; -use tauri::State; -use tokio::sync::RwLock; - -use lime_terminal::{SessionMetadata, TerminalSessionManager}; - -/// 终端会话管理器状态包装 -pub struct TerminalManagerState(pub Arc>>); - -/// 创建终端会话响应 -#[derive(Debug, Serialize)] -pub struct CreateSessionResponse { - /// 会话 ID - pub session_id: String, -} - -/// 创建终端会话(使用默认大小) -/// -/// PTY 使用默认大小 (24x80) 预创建,前端连接后通过 resize 同步实际大小。 -/// -/// # 参数 -/// - `cwd`: 工作目录(可选) -/// -/// # 返回 -/// - `Ok(CreateSessionResponse)`: 包含会话 ID -/// - `Err(String)`: 错误信息 -#[tauri::command] -pub async fn terminal_create_session( - state: State<'_, TerminalManagerState>, - cwd: Option, -) -> Result { - let guard = state.inner().0.read().await; - let manager = guard - .as_ref() - .ok_or_else(|| "终端管理器未初始化".to_string())?; - - let session_id = manager - .create_session_with_options(24, 80, cwd) - .await - .map_err(|e| e.to_string())?; - - Ok(CreateSessionResponse { session_id }) -} - -/// 向终端发送输入 -/// -/// # 参数 -/// - `session_id`: 会话 ID -/// - `data`: Base64 编码的输入数据 -#[tauri::command] -pub async fn terminal_write( - state: State<'_, TerminalManagerState>, - session_id: String, - data: String, -) -> Result<(), String> { - let guard = state.inner().0.read().await; - let manager = guard - .as_ref() - .ok_or_else(|| "终端管理器未初始化".to_string())?; - - manager - .write_to_session_base64(&session_id, &data) - .await - .map_err(|e| e.to_string()) -} - -/// 调整终端大小 -/// -/// # 参数 -/// - `session_id`: 会话 ID -/// - `rows`: 新的行数 -/// - `cols`: 新的列数 -#[tauri::command] -pub async fn terminal_resize( - state: State<'_, TerminalManagerState>, - session_id: String, - rows: u16, - cols: u16, -) -> Result<(), String> { - let guard = state.inner().0.read().await; - let manager = guard - .as_ref() - .ok_or_else(|| "终端管理器未初始化".to_string())?; - - manager - .resize_session(&session_id, rows, cols) - .await - .map_err(|e| e.to_string()) -} - -/// 关闭终端会话 -/// -/// # 参数 -/// - `session_id`: 会话 ID -#[tauri::command] -pub async fn terminal_close( - state: State<'_, TerminalManagerState>, - session_id: String, -) -> Result<(), String> { - let guard = state.inner().0.read().await; - let manager = guard - .as_ref() - .ok_or_else(|| "终端管理器未初始化".to_string())?; - - manager - .close_session(&session_id) - .await - .map_err(|e| e.to_string()) -} - -/// 获取所有终端会话 -#[tauri::command] -pub async fn terminal_list_sessions( - state: State<'_, TerminalManagerState>, -) -> Result, String> { - let guard = state.inner().0.read().await; - let manager = guard - .as_ref() - .ok_or_else(|| "终端管理器未初始化".to_string())?; - - Ok(manager.list_sessions().await) -} - -/// 获取单个终端会话信息 -/// -/// # 参数 -/// - `session_id`: 会话 ID -#[tauri::command] -pub async fn terminal_get_session( - state: State<'_, TerminalManagerState>, - session_id: String, -) -> Result, String> { - let guard = state.inner().0.read().await; - let manager = guard - .as_ref() - .ok_or_else(|| "终端管理器未初始化".to_string())?; - - Ok(manager.get_session(&session_id).await) -} diff --git a/src-tauri/src/commands/unified_memory_cmd.rs b/src-tauri/src/commands/unified_memory_cmd.rs index 5268bf236..90150ecc0 100644 --- a/src-tauri/src/commands/unified_memory_cmd.rs +++ b/src-tauri/src/commands/unified_memory_cmd.rs @@ -770,7 +770,7 @@ fn load_existing_memories_by_session( Ok(map) } -fn get_memory_by_id( +pub(crate) fn get_memory_by_id( conn: &rusqlite::Connection, id: &str, ) -> Result, String> { @@ -789,6 +789,31 @@ fn get_memory_by_id( } } +pub(crate) fn load_unified_memories_by_ids( + conn: &rusqlite::Connection, + ids: &[String], +) -> Result, String> { + let normalized_ids = ids + .iter() + .map(|id| id.trim()) + .filter(|id| !id.is_empty()) + .fold(Vec::::new(), |mut acc, id| { + if !acc.iter().any(|existing| existing == id) { + acc.push(id.to_string()); + } + acc + }); + + let mut memories = Vec::new(); + for id in normalized_ids { + if let Some(memory) = get_memory_by_id(conn, id.as_str())? { + memories.push(memory); + } + } + + Ok(memories) +} + fn insert_unified_memory( conn: &rusqlite::Connection, memory: &UnifiedMemory, diff --git a/src-tauri/src/dev_bridge/dispatcher.rs b/src-tauri/src/dev_bridge/dispatcher.rs index 6f32f3387..a4c99bf71 100644 --- a/src-tauri/src/dev_bridge/dispatcher.rs +++ b/src-tauri/src/dev_bridge/dispatcher.rs @@ -718,6 +718,170 @@ mod tests { assert!(value["version"].is_string()); } + #[tokio::test] + async fn sceneapp_get_descriptor_is_bridged() { + let state = make_test_state(); + + let value = handle_command( + &state, + "sceneapp_get_descriptor", + Some(serde_json::json!({ + "id": "story-video-suite" + })), + ) + .await + .unwrap(); + + assert_eq!(value["id"], "story-video-suite"); + assert_eq!(value["deliveryContract"], "project_pack"); + } + + #[tokio::test] + async fn sceneapp_plan_launch_is_bridged() { + let state = make_test_state(); + + let value = handle_command( + &state, + "sceneapp_plan_launch", + Some(serde_json::json!({ + "intent": { + "sceneappId": "story-video-suite", + "entrySource": "sceneapp_detail_preview", + "workspaceId": "workspace-default", + "projectId": "project-devbridge", + "userInput": "继续整理成 30 秒新品短视频方案", + "referenceMemoryIds": [], + "slots": {} + } + })), + ) + .await + .unwrap(); + + assert_eq!(value["descriptor"]["id"], "story-video-suite"); + assert!(value["contextOverlay"].is_object()); + assert!(value["projectPackPlan"].is_object()); + } + + #[tokio::test] + async fn sceneapp_save_context_baseline_is_bridged() { + let state = make_test_state(); + let temp_dir = TempDir::new().unwrap(); + let root_path = temp_dir.path().join("sceneapp-context-workspace"); + + let workspace_value = handle_command( + &state, + "workspace_create", + Some(serde_json::json!({ + "request": { + "name": "SceneApp Context Workspace", + "rootPath": root_path.to_string_lossy().to_string(), + "workspaceType": "general" + } + })), + ) + .await + .unwrap(); + let workspace_id = workspace_value["id"].as_str().unwrap().to_string(); + + let value = handle_command( + &state, + "sceneapp_save_context_baseline", + Some(serde_json::json!({ + "intent": { + "sceneappId": "story-video-suite", + "entrySource": "sceneapp_detail_save_context_baseline", + "workspaceId": workspace_id.clone(), + "projectId": workspace_id, + "userInput": "继续整理成 30 秒新品短视频方案", + "referenceMemoryIds": [], + "slots": {} + } + })), + ) + .await + .unwrap(); + + assert_eq!(value["descriptor"]["id"], "story-video-suite"); + assert!(value["contextOverlay"]["snapshot"]["referenceItems"].is_array()); + assert!(value["contextOverlay"]["compilerPlan"]["notes"] + .as_array() + .is_some_and(|notes| notes.iter().any(|note| { + note.as_str() + .is_some_and(|value| value.contains("已写入项目级 Context Snapshot")) + }))); + } + + #[tokio::test] + async fn sceneapp_list_runs_is_bridged() { + let state = make_test_state(); + + let error = handle_command( + &state, + "sceneapp_list_runs", + Some(serde_json::json!({ + "sceneappId": "story-video-suite" + })), + ) + .await + .expect_err("missing app handle should fail after bridge routing"); + + assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle")); + } + + #[tokio::test] + async fn sceneapp_get_run_summary_is_bridged() { + let state = make_test_state(); + + let value = handle_command( + &state, + "sceneapp_get_run_summary", + Some(serde_json::json!({ + "runId": "sceneapp-run-story-video-seed" + })), + ) + .await + .unwrap(); + + assert_eq!(value["runId"], "sceneapp-run-story-video-seed"); + assert_eq!(value["sceneappId"], "story-video-suite"); + } + + #[tokio::test] + async fn sceneapp_prepare_run_governance_artifact_is_bridged() { + let state = make_test_state(); + + let value = handle_command( + &state, + "sceneapp_prepare_run_governance_artifact", + Some(serde_json::json!({ + "runId": "sceneapp-run-story-video-seed", + "kind": "review_decision_json" + })), + ) + .await + .unwrap(); + + assert!(value.is_null()); + } + + #[tokio::test] + async fn sceneapp_get_scorecard_is_bridged() { + let state = make_test_state(); + + let error = handle_command( + &state, + "sceneapp_get_scorecard", + Some(serde_json::json!({ + "sceneappId": "story-video-suite" + })), + ) + .await + .expect_err("missing app handle should fail after bridge routing"); + + assert!(error.to_string().contains("Dev Bridge 未持有 AppHandle")); + } + #[tokio::test] async fn gateway_channel_status_is_bridged() { let state = make_test_state(); diff --git a/src-tauri/src/dev_bridge/dispatcher/sceneapp.rs b/src-tauri/src/dev_bridge/dispatcher/sceneapp.rs index 4a23803f7..4b658d4c1 100644 --- a/src-tauri/src/dev_bridge/dispatcher/sceneapp.rs +++ b/src-tauri/src/dev_bridge/dispatcher/sceneapp.rs @@ -1,19 +1,121 @@ +use super::{args_or_default, get_db, get_string_arg, parse_nested_arg, require_app_handle}; use crate::dev_bridge::DevBridgeState; +use crate::sceneapp::application::SceneAppService; +use crate::services::execution_tracker_service::ExecutionTracker; use serde_json::Value as JsonValue; +use tauri::Manager; type DynError = Box; pub(super) async fn try_handle( - _state: &DevBridgeState, + state: &DevBridgeState, cmd: &str, - _args: Option<&JsonValue>, + args: Option<&JsonValue>, ) -> Result, DynError> { - if cmd != "sceneapp_list_catalog" { + if !matches!( + cmd, + "sceneapp_list_catalog" + | "sceneapp_get_descriptor" + | "sceneapp_plan_launch" + | "sceneapp_save_context_baseline" + | "sceneapp_create_automation_job" + | "sceneapp_list_runs" + | "sceneapp_get_run_summary" + | "sceneapp_prepare_run_governance_artifact" + | "sceneapp_get_scorecard" + ) { return Ok(None); } - let result = - serde_json::to_value(crate::commands::sceneapp_cmd::sceneapp_list_catalog().await?)?; + let args = args_or_default(args); + let result = match cmd { + "sceneapp_list_catalog" => { + serde_json::to_value(crate::commands::sceneapp_cmd::sceneapp_list_catalog().await?)? + } + "sceneapp_get_descriptor" => { + let id = get_string_arg(&args, "id", "id")?; + serde_json::to_value(crate::commands::sceneapp_cmd::sceneapp_get_descriptor(id).await?)? + } + "sceneapp_plan_launch" => { + let db = get_db(state)?; + let intent = + parse_nested_arg::(&args, "intent")?; + serde_json::to_value(SceneAppService::plan_launch(db, intent)?)? + } + "sceneapp_save_context_baseline" => { + let db = get_db(state)?; + let intent = + parse_nested_arg::(&args, "intent")?; + serde_json::to_value(SceneAppService::save_context_baseline(db, intent)?)? + } + "sceneapp_create_automation_job" => { + let app_handle = require_app_handle(state)?; + let automation_state = + app_handle.state::(); + let intent = parse_nested_arg::( + &args, "intent", + )?; + let service = automation_state.0.read().await; + serde_json::to_value(SceneAppService::create_automation_job(&service, intent).await?)? + } + "sceneapp_list_runs" => { + let db = get_db(state)?.clone(); + let app_handle = require_app_handle(state)?; + let automation_state = + app_handle.state::(); + let sceneapp_id = args + .get("sceneappId") + .or_else(|| args.get("sceneapp_id")) + .and_then(|value| value.as_str()) + .map(ToString::to_string); + let service = automation_state.0.read().await; + let tracker = ExecutionTracker::new(db); + serde_json::to_value(SceneAppService::collect_runs( + &tracker, + &service, + sceneapp_id.as_deref(), + )?)? + } + "sceneapp_get_run_summary" => { + let db = get_db(state)?.clone(); + let run_id = get_string_arg(&args, "runId", "run_id")?; + let tracker = ExecutionTracker::new(db); + if let Some(summary) = + SceneAppService::get_run_summary_from_tracker(&tracker, run_id.as_str())? + { + serde_json::to_value(Some(summary))? + } else { + serde_json::to_value(SceneAppService::get_run_summary(run_id.as_str()))? + } + } + "sceneapp_prepare_run_governance_artifact" => { + let db = get_db(state)?.clone(); + let run_id = get_string_arg(&args, "runId", "run_id")?; + let kind = parse_nested_arg::( + &args, "kind", + )?; + let tracker = ExecutionTracker::new(db); + serde_json::to_value(SceneAppService::prepare_run_governance_artifact( + &tracker, + run_id.as_str(), + &kind, + )?)? + } + "sceneapp_get_scorecard" => { + let db = get_db(state)?; + let app_handle = require_app_handle(state)?; + let automation_state = + app_handle.state::(); + let sceneapp_id = get_string_arg(&args, "sceneappId", "sceneapp_id")?; + let service = automation_state.0.read().await; + serde_json::to_value(SceneAppService::get_scorecard( + db, + &service, + sceneapp_id.as_str(), + )?)? + } + _ => unreachable!("已通过前置 matches! 过滤 sceneapp 命令"), + }; Ok(Some(result)) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2b1bd57a9..e3672059d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,7 +46,6 @@ pub mod sceneapp; pub mod screenshot; pub mod services; pub mod skills; -pub mod terminal; pub mod tray; pub mod voice; diff --git a/src-tauri/src/sceneapp/adapters.rs b/src-tauri/src/sceneapp/adapters.rs index 4df6b1524..3d2cff1a7 100644 --- a/src-tauri/src/sceneapp/adapters.rs +++ b/src-tauri/src/sceneapp/adapters.rs @@ -119,6 +119,7 @@ fn build_sceneapp_launch_harness_value( "entry_source": launch_intent.entry_source.clone(), "workspace_id": launch_intent.workspace_id.clone(), "project_id": launch_intent.project_id.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), "linked_service_skill_id": descriptor.linked_service_skill_id.clone(), "linked_scene_key": descriptor.linked_scene_key.clone(), }) @@ -200,6 +201,12 @@ fn build_sceneapp_metadata_root( json!(launch_intent.slots.clone()), ); } + if !launch_intent.reference_memory_ids.is_empty() { + root.insert( + "sceneapp_reference_memory_ids".to_string(), + json!(launch_intent.reference_memory_ids.clone()), + ); + } root } @@ -303,6 +310,7 @@ fn build_sceneapp_runtime_request_metadata( "workspace_id": launch_intent.workspace_id.clone(), "project_id": launch_intent.project_id.clone(), "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), "slots": launch_intent.slots.clone(), } }), @@ -362,6 +370,7 @@ fn build_sceneapp_runtime_request_metadata( "project_id": launch_intent.project_id.clone(), "workspace_id": launch_intent.workspace_id.clone(), "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), "slots": launch_intent.slots.clone(), }), ); @@ -1478,6 +1487,7 @@ pub fn build_sceneapp_runtime_adapter_plan( "project_id": launch_intent.project_id.clone(), "entry_source": launch_intent.entry_source.clone().unwrap_or_else(|| "sceneapp_plan".to_string()), "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), "slots": launch_intent.slots.clone(), }) } @@ -1527,6 +1537,7 @@ pub fn build_sceneapp_runtime_adapter_plan( "workspace_id": launch_intent.workspace_id.clone(), "project_id": launch_intent.project_id.clone(), "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), "slots": launch_intent.slots.clone(), "runtime_context": launch_intent.runtime_context.clone(), }, @@ -1545,6 +1556,7 @@ pub fn build_sceneapp_runtime_adapter_plan( "workspace_id": launch_intent.workspace_id.clone(), "project_id": launch_intent.project_id.clone(), "user_input": launch_intent.user_input.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), "slots": launch_intent.slots.clone(), }) } @@ -1559,6 +1571,7 @@ pub fn build_sceneapp_runtime_adapter_plan( "message": launch_intent.user_input.clone().unwrap_or_default(), "workspace_id": launch_intent.workspace_id.clone(), "project_id": launch_intent.project_id.clone(), + "reference_memory_ids": launch_intent.reference_memory_ids.clone(), "slots": launch_intent.slots.clone(), }) } @@ -1677,6 +1690,8 @@ pub fn build_sceneapp_automation_draft( .unwrap_or("agent_turn") )), web_search: false, + approval_policy: Some("never".to_string()), + sandbox_policy: Some("danger-full-access".to_string()), request_metadata: Some(build_sceneapp_request_metadata(descriptor, intent)), content_id: None, }, diff --git a/src-tauri/src/sceneapp/application.rs b/src-tauri/src/sceneapp/application.rs index b8e005978..c3ead45ca 100644 --- a/src-tauri/src/sceneapp/application.rs +++ b/src-tauri/src/sceneapp/application.rs @@ -5,16 +5,22 @@ use super::adapters::{ extract_sceneapp_id_from_run_metadata, prepare_sceneapp_run_governance_artifact, }; use super::catalog::{get_sceneapp_descriptor, seeded_sceneapp_catalog}; +use super::context::compiler::build_reference_library_items; +use super::context::dto::{ReferenceItem, TasteProfile}; use super::context::store::{ build_persisted_sceneapp_context, load_persisted_sceneapp_context, - save_persisted_sceneapp_context, + save_persisted_sceneapp_context, PersistedSceneAppContext, }; use super::dto::*; use super::governance::build_sceneapp_scorecard_from_runs; use super::runtime::build_launch_plan; +use crate::commands::unified_memory_cmd::load_unified_memories_by_ids; use crate::database::DbConnection; use crate::services::automation_service::AutomationService; use crate::services::execution_tracker_service::ExecutionTracker; +use crate::services::runtime_review_decision_service::RuntimeReviewDecisionContent; +use chrono::Utc; +use std::collections::BTreeSet; pub struct SceneAppService; @@ -30,6 +36,699 @@ fn sort_and_dedupe_runs(runs: &mut Vec) { runs.dedup_by(|left, right| left.run_id == right.run_id); } +fn normalize_reference_memory_ids(ids: &[String]) -> Vec { + ids.iter() + .map(|id| id.trim()) + .filter(|id| !id.is_empty()) + .fold(Vec::::new(), |mut acc, id| { + if !acc.iter().any(|existing| existing == id) { + acc.push(id.to_string()); + } + acc + }) +} + +fn load_selected_reference_items( + db: &DbConnection, + ids: &[String], +) -> Result, String> { + let normalized_ids = normalize_reference_memory_ids(ids); + if normalized_ids.is_empty() { + return Ok(Vec::new()); + } + + let conn = db + .lock() + .map_err(|error| format!("数据库锁定失败: {error}"))?; + let memories = load_unified_memories_by_ids(&conn, normalized_ids.as_slice())?; + Ok(build_reference_library_items(memories.as_slice())) +} + +fn build_sceneapp_plan_result( + db: &DbConnection, + intent: &SceneAppLaunchIntent, +) -> Result { + let sceneapp_id = intent.sceneapp_id.clone(); + let workspace_id = intent.workspace_id.clone(); + let project_id = intent.project_id.clone(); + let requested_reference_memory_count = + normalize_reference_memory_ids(intent.reference_memory_ids.as_slice()).len(); + let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()) + .ok_or_else(|| format!("未找到 SceneApp: {sceneapp_id}"))?; + + let persisted_context = match load_persisted_sceneapp_context( + db, + sceneapp_id.as_str(), + workspace_id.as_deref(), + project_id.as_deref(), + ) { + Ok(context) => context, + Err(error) => { + let mut result = build_launch_plan(descriptor, intent.clone(), None, &[]); + result.plan.warnings.push(format!( + "读取项目级 Context Snapshot 失败,本次先按最新输入继续 planning:{error}" + )); + return Ok(result); + } + }; + + let explicit_reference_items = + match load_selected_reference_items(db, intent.reference_memory_ids.as_slice()) { + Ok(items) => items, + Err(error) => { + let mut result = + build_launch_plan(descriptor, intent.clone(), persisted_context.as_ref(), &[]); + result.plan.warnings.push(format!( + "读取灵感库参考失败,本次先按当前输入继续 planning:{error}" + )); + return Ok(result); + } + }; + + let mut result = build_launch_plan( + descriptor, + intent.clone(), + persisted_context.as_ref(), + explicit_reference_items.as_slice(), + ); + let missing_reference_count = + requested_reference_memory_count.saturating_sub(explicit_reference_items.len()); + if missing_reference_count > 0 { + result.plan.warnings.push(format!( + "已选中的 {} 条灵感条目未找到,planning 仅继续使用当前可解析的参考。", + missing_reference_count + )); + } + + Ok(result) +} + +fn normalize_optional_id(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn run_feedback_timestamp(run: &SceneAppRunSummary) -> &str { + run.finished_at + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(run.started_at.as_str()) +} + +fn should_sync_feedback_from_run(run: &SceneAppRunSummary) -> bool { + matches!( + run.status, + SceneAppRunStatus::Success + | SceneAppRunStatus::Error + | SceneAppRunStatus::Canceled + | SceneAppRunStatus::Timeout + ) || run.artifact_count > 0 + || run.failure_signal.is_some() + || !run.evidence_known_gaps.is_empty() + || !run.verification_failure_outcomes.is_empty() +} + +fn failure_signal_label(signal: &str) -> &str { + match signal { + "pack_incomplete" => "整包不完整", + "review_blocked" => "复核阻塞", + "publish_stalled" => "发布卡点", + "automation_timeout" => "自动化超时", + "dependency_failure" => "外部依赖与会话稳定性", + "adoption_failure" => "人工中断与补参", + "runtime_failure" => "运行链稳定性", + "artifact_validation_issue" => "结果结构校验问题", + "telemetry_missing" => "请求遥测缺失", + "runtime_evidence_gap" => "会话证据缺口", + "verification_failure" => "结果校验失败", + _ => signal, + } +} + +fn recommended_action_label(action: &SceneAppRecommendedAction) -> &'static str { + match action { + SceneAppRecommendedAction::Launch => "继续补样本", + SceneAppRecommendedAction::Keep => "继续保留", + SceneAppRecommendedAction::Optimize => "优先优化", + SceneAppRecommendedAction::Retire => "考虑收口", + } +} + +fn build_reference_feedback_label( + run: &SceneAppRunSummary, + scorecard: Option<&SceneAppScorecard>, +) -> String { + if let Some(signal) = run + .failure_signal + .as_deref() + .or_else(|| scorecard.and_then(|item| item.top_failure_signal.as_deref())) + { + return failure_signal_label(signal).to_string(); + } + + if let Some(scorecard) = scorecard { + return recommended_action_label(&scorecard.recommended_action).to_string(); + } + + match run.status { + SceneAppRunStatus::Success => "可继续复用".to_string(), + SceneAppRunStatus::Canceled => "人工中断".to_string(), + SceneAppRunStatus::Timeout => "运行超时".to_string(), + SceneAppRunStatus::Error => "运行受阻".to_string(), + SceneAppRunStatus::Queued => "等待执行".to_string(), + SceneAppRunStatus::Running => "运行中".to_string(), + } +} + +fn push_feedback_signal(signals: &mut BTreeSet, value: Option<&str>) { + let Some(value) = normalize_optional_id(value) else { + return; + }; + signals.insert(value); +} + +fn collect_feedback_signals( + run: &SceneAppRunSummary, + scorecard: Option<&SceneAppScorecard>, +) -> Vec { + let mut signals = BTreeSet::new(); + push_feedback_signal(&mut signals, run.failure_signal.as_deref()); + push_feedback_signal( + &mut signals, + scorecard.and_then(|item| item.top_failure_signal.as_deref()), + ); + + if let Some(scorecard) = scorecard { + for signal in scorecard.observed_failure_signals.iter() { + push_feedback_signal(&mut signals, Some(signal.as_str())); + } + } + + if run.artifact_validator_issue_count.unwrap_or(0) > 0 { + signals.insert("artifact_validation_issue".to_string()); + } + if run.request_telemetry_available == Some(false) { + signals.insert("telemetry_missing".to_string()); + } + if !run.evidence_known_gaps.is_empty() { + signals.insert("runtime_evidence_gap".to_string()); + } + if !run.verification_failure_outcomes.is_empty() { + signals.insert("verification_failure".to_string()); + } + if run.failure_signal.is_none() && !run.delivery_missing_parts.is_empty() { + signals.insert("pack_incomplete".to_string()); + } + + signals.into_iter().collect() +} + +fn build_feedback_summary( + run: &SceneAppRunSummary, + scorecard: Option<&SceneAppScorecard>, +) -> String { + let delivery_sentence = + if run.delivery_part_coverage_known && !run.delivery_required_parts.is_empty() { + let delivered = run.delivery_completed_parts.len(); + let total = run.delivery_required_parts.len(); + if run.delivery_missing_parts.is_empty() { + format!("最近一次运行已交齐 {delivered}/{total} 个必含部件。") + } else { + format!("最近一次运行已交付 {delivered}/{total} 个必含部件。") + } + } else if run.artifact_count > 0 { + format!("最近一次运行已回流 {} 份结果。", run.artifact_count) + } else { + "最近一次运行还没有回流可复盘结果。".to_string() + }; + + let failure_sentence = if let Some(signal) = run + .failure_signal + .as_deref() + .or_else(|| scorecard.and_then(|item| item.top_failure_signal.as_deref())) + { + format!("当前主要卡点是{}。", failure_signal_label(signal)) + } else if !run.evidence_known_gaps.is_empty() { + "当前仍有会话证据缺口需要补齐。".to_string() + } else if !run.verification_failure_outcomes.is_empty() { + "当前仍有结果校验问题需要回头处理。".to_string() + } else { + "当前还没有出现明显阻塞。".to_string() + }; + + let action_sentence = scorecard + .map(|item| { + format!( + "经营上建议{}。", + recommended_action_label(&item.recommended_action) + ) + }) + .unwrap_or_else(|| match run.status { + SceneAppRunStatus::Success => "经营上可以继续复用这轮结果。".to_string(), + SceneAppRunStatus::Canceled => "经营上更适合先确认人工中断原因。".to_string(), + SceneAppRunStatus::Timeout | SceneAppRunStatus::Error => { + "经营上更适合先修运行链再继续放大。".to_string() + } + SceneAppRunStatus::Queued | SceneAppRunStatus::Running => { + "经营上先等待这轮运行稳定收口。".to_string() + } + }); + + format!("{delivery_sentence}{failure_sentence}{action_sentence}") +} + +fn normalize_optional_text(value: &str) -> Option { + let normalized = value.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } +} + +fn ensure_feedback_sentence(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + return String::new(); + } + + if matches!( + trimmed.chars().last(), + Some('。' | '!' | '?' | '.' | '!' | '?') + ) { + trimmed.to_string() + } else { + format!("{trimmed}。") + } +} + +fn review_decision_status_label(status: &str) -> &'static str { + match status { + "accepted" => "人工接受", + "deferred" => "人工延后", + "rejected" => "人工否决", + "needs_more_evidence" => "人工补证据", + "pending_review" => "待人工审核", + _ => "人工复核", + } +} + +fn review_decision_status_signal(status: &str) -> Option<&'static str> { + match status { + "accepted" => Some("review_decision_accepted"), + "deferred" => Some("review_decision_deferred"), + "rejected" => Some("review_decision_rejected"), + "needs_more_evidence" => Some("review_decision_needs_more_evidence"), + "pending_review" => Some("review_decision_pending_review"), + _ => None, + } +} + +fn review_decision_risk_label(risk_level: &str) -> Option<&'static str> { + match risk_level { + "low" => Some("低风险"), + "medium" => Some("中风险"), + "high" => Some("高风险"), + _ => None, + } +} + +fn review_decision_risk_signal(risk_level: &str) -> Option<&'static str> { + match risk_level { + "low" => Some("review_risk_low"), + "medium" => Some("review_risk_medium"), + "high" => Some("review_risk_high"), + _ => None, + } +} + +fn review_decision_feedback_timestamp( + run: &SceneAppRunSummary, + decision: &RuntimeReviewDecisionContent, +) -> String { + decision + .reviewed_at + .as_deref() + .and_then(|value| normalize_optional_text(value)) + .unwrap_or_else(|| run_feedback_timestamp(run).to_string()) +} + +fn build_review_decision_feedback_label(decision: &RuntimeReviewDecisionContent) -> String { + let status_label = review_decision_status_label(decision.decision_status.as_str()); + match review_decision_risk_label(decision.risk_level.as_str()) { + Some(risk_label) => format!("{status_label} · {risk_label}"), + None => status_label.to_string(), + } +} + +fn collect_review_decision_feedback_signals( + run: &SceneAppRunSummary, + decision: &RuntimeReviewDecisionContent, +) -> Vec { + let mut signals = collect_feedback_signals(run, None) + .into_iter() + .collect::>(); + if let Some(signal) = review_decision_status_signal(decision.decision_status.as_str()) { + signals.insert(signal.to_string()); + } + if let Some(signal) = review_decision_risk_signal(decision.risk_level.as_str()) { + signals.insert(signal.to_string()); + } + signals.into_iter().collect() +} + +fn build_review_decision_feedback_summary( + run: &SceneAppRunSummary, + decision: &RuntimeReviewDecisionContent, +) -> String { + let mut sentences = vec![format!( + "人工复核结论:{}。", + build_review_decision_feedback_label(decision) + )]; + + if let Some(summary) = normalize_optional_text(decision.decision_summary.as_str()) { + sentences.push(ensure_feedback_sentence(summary.as_str())); + } + if let Some(strategy) = normalize_optional_text(decision.chosen_fix_strategy.as_str()) { + sentences.push(format!("处理策略:{}。", strategy)); + } + if !decision.followup_actions.is_empty() { + sentences.push(format!( + "后续动作:{}。", + decision.followup_actions.join(";") + )); + } + if !decision.regression_requirements.is_empty() { + sentences.push(format!( + "回归要求:{}。", + decision.regression_requirements.join(";") + )); + } + if let Some(reviewer) = normalize_optional_text(decision.human_reviewer.as_str()) { + sentences.push(format!("审核人:{}。", reviewer)); + } + if let Some(notes) = normalize_optional_text(decision.notes.as_str()) { + sentences.push(format!("备注:{}。", notes)); + } + if let Some(signal) = run.failure_signal.as_deref() { + sentences.push(format!( + "这次运行原始卡点:{}。", + failure_signal_label(signal) + )); + } + + sentences.join("") +} + +fn resolve_feedback_scope_from_run(run: &SceneAppRunSummary) -> (Option, Option) { + let project_id = run + .delivery_artifact_refs + .iter() + .find_map(|artifact| normalize_optional_id(artifact.project_id.as_deref())) + .or_else(|| { + run.governance_artifact_refs + .iter() + .find_map(|artifact| normalize_optional_id(artifact.project_id.as_deref())) + }) + .or_else(|| { + run.cloud_scene_runtime_ref + .as_ref() + .and_then(|runtime| normalize_optional_id(runtime.project_id.as_deref())) + }) + .or_else(|| { + run.native_skill_runtime_ref + .as_ref() + .and_then(|runtime| normalize_optional_id(runtime.project_id.as_deref())) + }); + + let workspace_id = run + .delivery_artifact_refs + .iter() + .find_map(|artifact| normalize_optional_id(artifact.workspace_id.as_deref())) + .or_else(|| { + run.governance_artifact_refs + .iter() + .find_map(|artifact| normalize_optional_id(artifact.workspace_id.as_deref())) + }) + .or_else(|| { + run.cloud_scene_runtime_ref + .as_ref() + .and_then(|runtime| normalize_optional_id(runtime.workspace_id.as_deref())) + }) + .or_else(|| { + run.native_skill_runtime_ref + .as_ref() + .and_then(|runtime| normalize_optional_id(runtime.workspace_id.as_deref())) + }); + + (project_id, workspace_id) +} + +fn build_default_taste_profile(context: &PersistedSceneAppContext) -> TasteProfile { + TasteProfile { + profile_id: format!("taste-{}", context.sceneapp_id), + summary: "当前 TasteProfile 已根据项目级反馈自动恢复。".to_string(), + keywords: Vec::new(), + avoid_keywords: Vec::new(), + derived_from_reference_ids: context + .reference_items + .iter() + .filter(|item| item.selected) + .map(|item| item.id.clone()) + .collect(), + confidence: None, + feedback_summary: None, + feedback_signals: Vec::new(), + last_feedback_at: None, + } +} + +fn apply_run_feedback_to_context( + context: &mut PersistedSceneAppContext, + run: &SceneAppRunSummary, + scorecard: Option<&SceneAppScorecard>, +) -> bool { + if !should_sync_feedback_from_run(run) { + return false; + } + + let feedback_at = run_feedback_timestamp(run).to_string(); + let same_run = context.last_feedback_run_id.as_deref() == Some(run.run_id.as_str()); + let existing_feedback_at = context + .taste_profile + .as_ref() + .and_then(|profile| profile.last_feedback_at.as_deref()) + .filter(|value| !value.trim().is_empty()); + if !same_run && existing_feedback_at.is_some_and(|value| value > feedback_at.as_str()) { + return false; + } + + let before = context.clone(); + let derived_reference_ids = context + .taste_profile + .as_ref() + .map(|profile| { + profile + .derived_from_reference_ids + .iter() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + let feedback_label = build_reference_feedback_label(run, scorecard); + + for item in context.reference_items.iter_mut() { + let should_update = if !derived_reference_ids.is_empty() { + derived_reference_ids.contains(item.id.as_str()) + } else { + item.selected + }; + if !should_update { + continue; + } + + if !same_run { + item.usage_count = Some(item.usage_count.unwrap_or(0).saturating_add(1)); + } + item.last_used_at = Some(feedback_at.clone()); + item.last_feedback_label = Some(feedback_label.clone()); + } + + let mut taste_profile = context + .taste_profile + .clone() + .unwrap_or_else(|| build_default_taste_profile(context)); + if taste_profile.derived_from_reference_ids.is_empty() { + taste_profile.derived_from_reference_ids = context + .reference_items + .iter() + .filter(|item| item.selected) + .map(|item| item.id.clone()) + .collect(); + } + if !same_run || scorecard.is_some() || taste_profile.feedback_summary.is_none() { + taste_profile.feedback_summary = Some(build_feedback_summary(run, scorecard)); + taste_profile.feedback_signals = collect_feedback_signals(run, scorecard); + } + taste_profile.last_feedback_at = Some(feedback_at); + context.taste_profile = Some(taste_profile); + context.last_feedback_run_id = Some(run.run_id.clone()); + + *context != before +} + +fn apply_review_decision_to_context( + context: &mut PersistedSceneAppContext, + run: &SceneAppRunSummary, + decision: &RuntimeReviewDecisionContent, +) -> bool { + if !should_sync_feedback_from_run(run) { + return false; + } + + let reviewed_at = review_decision_feedback_timestamp(run, decision); + let same_run = context.last_feedback_run_id.as_deref() == Some(run.run_id.as_str()); + let existing_feedback_at = context + .taste_profile + .as_ref() + .and_then(|profile| profile.last_feedback_at.as_deref()) + .filter(|value| !value.trim().is_empty()); + if !same_run && existing_feedback_at.is_some_and(|value| value > reviewed_at.as_str()) { + return false; + } + + let before = context.clone(); + let _ = apply_run_feedback_to_context(context, run, None); + let derived_reference_ids = context + .taste_profile + .as_ref() + .map(|profile| { + profile + .derived_from_reference_ids + .iter() + .cloned() + .collect::>() + }) + .unwrap_or_default(); + let feedback_label = build_review_decision_feedback_label(decision); + + for item in context.reference_items.iter_mut() { + let should_update = if !derived_reference_ids.is_empty() { + derived_reference_ids.contains(item.id.as_str()) + } else { + item.selected + }; + if !should_update { + continue; + } + + item.last_used_at = Some(reviewed_at.clone()); + item.last_feedback_label = Some(feedback_label.clone()); + } + + let mut taste_profile = context + .taste_profile + .clone() + .unwrap_or_else(|| build_default_taste_profile(context)); + if taste_profile.derived_from_reference_ids.is_empty() { + taste_profile.derived_from_reference_ids = context + .reference_items + .iter() + .filter(|item| item.selected) + .map(|item| item.id.clone()) + .collect(); + } + taste_profile.feedback_summary = Some(build_review_decision_feedback_summary(run, decision)); + taste_profile.feedback_signals = collect_review_decision_feedback_signals(run, decision); + taste_profile.last_feedback_at = Some(reviewed_at); + context.taste_profile = Some(taste_profile); + context.last_feedback_run_id = Some(run.run_id.clone()); + + *context != before +} + +fn sync_persisted_context_feedback( + db: &DbConnection, + run: &SceneAppRunSummary, + scorecard: Option<&SceneAppScorecard>, +) -> Result, String> { + let (project_id, workspace_id) = resolve_feedback_scope_from_run(run); + if project_id.is_none() && workspace_id.is_none() { + return Ok(None); + } + + let mut context = load_persisted_sceneapp_context( + db, + run.sceneapp_id.as_str(), + workspace_id.as_deref(), + project_id.as_deref(), + )? + .unwrap_or(PersistedSceneAppContext { + sceneapp_id: run.sceneapp_id.clone(), + workspace_id: workspace_id.clone(), + project_id: project_id.clone(), + reference_items: Vec::new(), + taste_profile: None, + last_feedback_run_id: None, + }); + + if context.workspace_id.is_none() { + context.workspace_id = workspace_id; + } + if context.project_id.is_none() { + context.project_id = project_id; + } + + if !apply_run_feedback_to_context(&mut context, run, scorecard) { + return Ok(Some(context)); + } + + save_persisted_sceneapp_context(db, &context)?; + Ok(Some(context)) +} + +fn sync_persisted_context_review_decision( + db: &DbConnection, + run: &SceneAppRunSummary, + decision: &RuntimeReviewDecisionContent, +) -> Result, String> { + let (project_id, workspace_id) = resolve_feedback_scope_from_run(run); + if project_id.is_none() && workspace_id.is_none() { + return Ok(None); + } + + let mut context = load_persisted_sceneapp_context( + db, + run.sceneapp_id.as_str(), + workspace_id.as_deref(), + project_id.as_deref(), + )? + .unwrap_or(PersistedSceneAppContext { + sceneapp_id: run.sceneapp_id.clone(), + workspace_id: workspace_id.clone(), + project_id: project_id.clone(), + reference_items: Vec::new(), + taste_profile: None, + last_feedback_run_id: None, + }); + + if context.workspace_id.is_none() { + context.workspace_id = workspace_id; + } + if context.project_id.is_none() { + context.project_id = project_id; + } + + if !apply_review_decision_to_context(&mut context, run, decision) { + return Ok(Some(context)); + } + + save_persisted_sceneapp_context(db, &context)?; + Ok(Some(context)) +} + impl SceneAppService { pub fn list_catalog() -> SceneAppCatalog { seeded_sceneapp_catalog() @@ -43,47 +742,45 @@ impl SceneAppService { db: &DbConnection, intent: SceneAppLaunchIntent, ) -> Result { + build_sceneapp_plan_result(db, &intent) + } + + pub fn save_context_baseline( + db: &DbConnection, + intent: SceneAppLaunchIntent, + ) -> Result { + if normalize_optional_id(intent.project_id.as_deref()).is_none() + && normalize_optional_id(intent.workspace_id.as_deref()).is_none() + { + return Err("当前还没有绑定项目工作区,无法写入场景基线。".to_string()); + } + let sceneapp_id = intent.sceneapp_id.clone(); - let workspace_id = intent.workspace_id.clone(); - let project_id = intent.project_id.clone(); - let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()) - .ok_or_else(|| format!("未找到 SceneApp: {sceneapp_id}"))?; + let mut result = build_sceneapp_plan_result(db, &intent)?; + let Some(context_overlay) = result.context_overlay.as_mut() else { + return Err("当前还没有可写入的场景上下文基线。".to_string()); + }; - let persisted_context = match load_persisted_sceneapp_context( - db, - sceneapp_id.as_str(), - workspace_id.as_deref(), - project_id.as_deref(), - ) { - Ok(context) => context, - Err(error) => { - let mut result = build_launch_plan(descriptor, intent, None); - result.plan.warnings.push(format!( - "读取项目级 Context Snapshot 失败,本次先按最新输入继续 planning:{error}" - )); - return Ok(result); + let used_at = Utc::now().to_rfc3339(); + for item in context_overlay.snapshot.reference_items.iter_mut() { + if !item.selected { + continue; } - }; + item.usage_count = Some(item.usage_count.unwrap_or(0).saturating_add(1)); + item.last_used_at = Some(used_at.clone()); + } + context_overlay.compiler_plan.notes.push( + "当前场景基线已写入项目级 Context Snapshot,后续 planning 会优先复用。".to_string(), + ); - let mut result = build_launch_plan(descriptor, intent, persisted_context.as_ref()); - let Some(context_overlay) = result.context_overlay.as_ref() else { - return Ok(result); - }; let persisted_context = build_persisted_sceneapp_context(sceneapp_id.as_str(), &context_overlay.snapshot); match save_persisted_sceneapp_context(db, &persisted_context) { - Ok(Some(_)) => {} - Ok(None) => result - .plan - .warnings - .push("当前未解析到项目目录,暂未写入项目级 Context Snapshot。".to_string()), - Err(error) => result.plan.warnings.push(format!( - "写入项目级 Context Snapshot 失败,本次 planning 结果仍已返回:{error}" - )), + Ok(Some(_)) => Ok(result), + Ok(None) => Err("当前未解析到项目目录,无法写入场景基线。".to_string()), + Err(error) => Err(format!("写入项目级 Context Snapshot 失败:{error}")), } - - Ok(result) } fn seeded_runs() -> Vec { @@ -319,12 +1016,14 @@ impl SceneAppService { return Ok(None); }; let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()); - Ok(Some(build_sceneapp_run_summary_from_agent_run_with_db( + let summary = build_sceneapp_run_summary_from_agent_run_with_db( tracker.db(), &run, descriptor.as_ref(), sceneapp_id, - ))) + ); + let _ = sync_persisted_context_feedback(tracker.db(), &summary, None); + Ok(Some(summary)) } pub fn prepare_run_governance_artifact( @@ -340,13 +1039,14 @@ impl SceneAppService { }; prepare_sceneapp_run_governance_artifact(tracker.db(), &run, kind)?; let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()); - - Ok(Some(build_sceneapp_run_summary_from_agent_run_with_db( + let summary = build_sceneapp_run_summary_from_agent_run_with_db( tracker.db(), &run, descriptor.as_ref(), sceneapp_id, - ))) + ); + let _ = sync_persisted_context_feedback(tracker.db(), &summary, None); + Ok(Some(summary)) } pub fn get_scorecard( @@ -358,6 +1058,402 @@ impl SceneAppService { .ok_or_else(|| format!("未找到 SceneApp scorecard: {sceneapp_id}"))?; let tracker = ExecutionTracker::new(db.clone()); let runs = Self::collect_runs(&tracker, automation_service, Some(sceneapp_id))?; - Ok(build_sceneapp_scorecard_from_runs(&descriptor, &runs)) + let scorecard = build_sceneapp_scorecard_from_runs(&descriptor, &runs); + if let Some(run) = runs.iter().find(|run| should_sync_feedback_from_run(run)) { + let _ = sync_persisted_context_feedback(db, run, Some(&scorecard)); + } + Ok(scorecard) + } + + pub fn sync_review_decision_feedback_for_session( + tracker: &ExecutionTracker, + session_id: &str, + decision: &RuntimeReviewDecisionContent, + ) -> Result, String> { + let normalized_session_id = session_id.trim(); + if normalized_session_id.is_empty() { + return Ok(None); + } + + let Some(run) = tracker + .list_runs_by_session(normalized_session_id, SCENEAPP_TRACKER_RUN_LIMIT)? + .into_iter() + .find_map(|run| { + let sceneapp_id = extract_sceneapp_id_from_run_metadata(&run)?; + let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()); + Some(build_sceneapp_run_summary_from_agent_run_with_db( + tracker.db(), + &run, + descriptor.as_ref(), + sceneapp_id, + )) + }) + else { + return Ok(None); + }; + + sync_persisted_context_review_decision(tracker.db(), &run, decision) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::schema::create_tables; + use crate::workspace::{WorkspaceManager, WorkspaceType}; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + use tempfile::tempdir; + + fn setup_test_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + Arc::new(Mutex::new(conn)) + } + + fn create_workspace(db: &DbConnection) -> String { + let temp_dir = tempdir().expect("创建临时目录失败"); + let workspace_root = temp_dir.path().join("sceneapp-feedback-workspace"); + std::fs::create_dir_all(&workspace_root).expect("创建 workspace 根目录失败"); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "SceneApp Feedback".to_string(), + workspace_root, + WorkspaceType::General, + ) + .expect("创建 workspace 失败"); + std::mem::forget(temp_dir); + workspace.id + } + + fn create_persisted_context(sceneapp_id: &str, workspace_id: &str) -> PersistedSceneAppContext { + PersistedSceneAppContext { + sceneapp_id: sceneapp_id.to_string(), + workspace_id: Some(workspace_id.to_string()), + project_id: Some(workspace_id.to_string()), + reference_items: vec![ReferenceItem { + id: "memory:ref-1".to_string(), + label: "竞品视频".to_string(), + source_kind: + crate::sceneapp::context::dto::ContextLayerSourceKind::ReferenceLibrary, + content_type: "video".to_string(), + uri: None, + summary: Some("保留强节奏和结论前置。".to_string()), + selected: true, + usage_count: None, + last_used_at: None, + last_feedback_label: None, + }], + taste_profile: Some(TasteProfile { + profile_id: format!("taste-{sceneapp_id}"), + summary: "偏好快节奏、强结论。".to_string(), + keywords: vec!["快节奏".to_string()], + avoid_keywords: vec!["铺垫过长".to_string()], + derived_from_reference_ids: vec!["memory:ref-1".to_string()], + confidence: Some(0.68), + feedback_summary: None, + feedback_signals: Vec::new(), + last_feedback_at: None, + }), + last_feedback_run_id: None, + } + } + + fn create_run_summary(sceneapp_id: &str, workspace_id: &str) -> SceneAppRunSummary { + SceneAppRunSummary { + run_id: "sceneapp-run-feedback-1".to_string(), + sceneapp_id: sceneapp_id.to_string(), + status: SceneAppRunStatus::Success, + source: "automation".to_string(), + source_ref: Some("job-story-video".to_string()), + session_id: Some("session-story-video".to_string()), + browser_runtime_ref: None, + cloud_scene_runtime_ref: Some(SceneAppCloudSceneRuntimeRef { + scene_key: Some("story-video".to_string()), + skill_id: Some("skill-story-video".to_string()), + project_id: Some(workspace_id.to_string()), + content_id: None, + workspace_id: Some(workspace_id.to_string()), + entry_source: Some("sceneapp_detail_preview".to_string()), + user_input: Some("生成短视频".to_string()), + slots: Default::default(), + }), + native_skill_runtime_ref: None, + started_at: "2026-04-17T08:00:00.000Z".to_string(), + finished_at: Some("2026-04-17T08:02:00.000Z".to_string()), + artifact_count: 2, + delivery_artifact_refs: vec![SceneAppDeliveryArtifactRef { + relative_path: "exports/story-video/brief.md".to_string(), + absolute_path: None, + part_key: Some("brief".to_string()), + project_id: Some(workspace_id.to_string()), + workspace_id: Some(workspace_id.to_string()), + source: "runtime_evidence".to_string(), + }], + governance_artifact_refs: Vec::new(), + delivery_required_parts: vec![ + "brief".to_string(), + "video_draft".to_string(), + "review_note".to_string(), + ], + delivery_completed_parts: vec!["brief".to_string(), "video_draft".to_string()], + delivery_missing_parts: vec!["review_note".to_string()], + delivery_completion_rate: Some(66.7), + delivery_part_coverage_known: true, + failure_signal: Some("review_blocked".to_string()), + runtime_evidence_used: true, + evidence_known_gaps: Vec::new(), + verification_failure_outcomes: Vec::new(), + request_telemetry_available: Some(true), + request_telemetry_matched_count: Some(2), + artifact_validator_applicable: Some(true), + artifact_validator_issue_count: Some(1), + artifact_validator_recovered_count: Some(0), + } + } + + fn create_scorecard(sceneapp_id: &str) -> SceneAppScorecard { + SceneAppScorecard { + sceneapp_id: sceneapp_id.to_string(), + updated_at: "2026-04-17T08:05:00.000Z".to_string(), + summary: "当前更适合优先优化。".to_string(), + metrics: Vec::new(), + recommended_action: SceneAppRecommendedAction::Optimize, + observed_failure_signals: vec!["review_blocked".to_string()], + top_failure_signal: Some("review_blocked".to_string()), + } + } + + #[test] + fn should_only_preview_context_during_plan_launch() { + let db = setup_test_db(); + let workspace_id = create_workspace(&db); + + let result = SceneAppService::plan_launch( + &db, + SceneAppLaunchIntent { + sceneapp_id: "story-video-suite".to_string(), + entry_source: Some("sceneapp_detail_preview".to_string()), + workspace_id: Some(workspace_id.clone()), + project_id: Some(workspace_id.clone()), + user_input: Some("根据发布会亮点生成 30 秒短视频草稿".to_string()), + reference_memory_ids: Vec::new(), + slots: Default::default(), + runtime_context: Some(SceneAppRuntimeContext { + cloud_session_ready: true, + ..Default::default() + }), + }, + ) + .expect("planning 失败"); + + assert!(result.context_overlay.is_some()); + let loaded = load_persisted_sceneapp_context( + &db, + "story-video-suite", + Some(workspace_id.as_str()), + Some(workspace_id.as_str()), + ) + .expect("读取 context 失败"); + assert!(loaded.is_none()); + } + + #[test] + fn should_save_context_baseline_explicitly() { + let db = setup_test_db(); + let workspace_id = create_workspace(&db); + + let result = SceneAppService::save_context_baseline( + &db, + SceneAppLaunchIntent { + sceneapp_id: "story-video-suite".to_string(), + entry_source: Some("sceneapp_detail_save_context_baseline".to_string()), + workspace_id: Some(workspace_id.clone()), + project_id: Some(workspace_id.clone()), + user_input: Some("根据发布会亮点生成 30 秒短视频草稿".to_string()), + reference_memory_ids: Vec::new(), + slots: Default::default(), + runtime_context: Some(SceneAppRuntimeContext { + cloud_session_ready: true, + ..Default::default() + }), + }, + ) + .expect("写入场景基线失败"); + + let note_list = &result + .context_overlay + .as_ref() + .expect("应返回 context overlay") + .compiler_plan + .notes; + assert!(note_list + .iter() + .any(|note| note.contains("已写入项目级 Context Snapshot"))); + + let loaded = load_persisted_sceneapp_context( + &db, + "story-video-suite", + Some(workspace_id.as_str()), + Some(workspace_id.as_str()), + ) + .expect("读取 context 失败") + .expect("应读取到已写入的 context"); + + assert_eq!(loaded.reference_items.len(), 1); + assert_eq!(loaded.reference_items[0].usage_count, Some(1)); + assert!(loaded.reference_items[0].last_used_at.is_some()); + } + + #[test] + fn should_sync_run_feedback_into_persisted_context() { + let db = setup_test_db(); + let workspace_id = create_workspace(&db); + let persisted = create_persisted_context("story-video-suite", workspace_id.as_str()); + save_persisted_sceneapp_context(&db, &persisted) + .expect("写入初始 context 失败") + .expect("应返回 context 路径"); + + let run = create_run_summary("story-video-suite", workspace_id.as_str()); + let scorecard = create_scorecard("story-video-suite"); + + sync_persisted_context_feedback(&db, &run, Some(&scorecard)) + .expect("同步反馈失败") + .expect("应返回回写后的 context"); + + let loaded = load_persisted_sceneapp_context( + &db, + "story-video-suite", + Some(workspace_id.as_str()), + Some(workspace_id.as_str()), + ) + .expect("读取回写后的 context 失败") + .expect("应能读取到已回写 context"); + + assert_eq!( + loaded.last_feedback_run_id.as_deref(), + Some(run.run_id.as_str()) + ); + assert_eq!(loaded.reference_items[0].usage_count, Some(1)); + assert_eq!( + loaded.reference_items[0].last_feedback_label.as_deref(), + Some("复核阻塞") + ); + assert_eq!( + loaded.reference_items[0].last_used_at.as_deref(), + run.finished_at.as_deref() + ); + assert!(loaded.taste_profile.as_ref().is_some_and(|profile| profile + .feedback_summary + .as_deref() + .is_some_and(|summary| summary.contains("经营上建议优先优化")) + && profile + .feedback_signals + .iter() + .any(|signal| signal == "review_blocked") + && profile + .feedback_signals + .iter() + .any(|signal| signal == "artifact_validation_issue"))); + } + + #[test] + fn should_not_double_count_same_run_feedback_when_scorecard_arrives_later() { + let db = setup_test_db(); + let workspace_id = create_workspace(&db); + let persisted = create_persisted_context("story-video-suite", workspace_id.as_str()); + save_persisted_sceneapp_context(&db, &persisted) + .expect("写入初始 context 失败") + .expect("应返回 context 路径"); + + let run = create_run_summary("story-video-suite", workspace_id.as_str()); + sync_persisted_context_feedback(&db, &run, None) + .expect("首次同步反馈失败") + .expect("应返回首次回写后的 context"); + sync_persisted_context_feedback(&db, &run, Some(&create_scorecard("story-video-suite"))) + .expect("二次同步反馈失败") + .expect("应返回二次回写后的 context"); + + let loaded = load_persisted_sceneapp_context( + &db, + "story-video-suite", + Some(workspace_id.as_str()), + Some(workspace_id.as_str()), + ) + .expect("读取回写后的 context 失败") + .expect("应能读取到已回写 context"); + + assert_eq!(loaded.reference_items[0].usage_count, Some(1)); + assert!(loaded.taste_profile.as_ref().is_some_and(|profile| profile + .feedback_summary + .as_deref() + .is_some_and(|summary| summary.contains("经营上建议优先优化")))); + } + + #[test] + fn should_apply_manual_review_decision_into_existing_context() { + let db = setup_test_db(); + let workspace_id = create_workspace(&db); + let persisted = create_persisted_context("story-video-suite", workspace_id.as_str()); + save_persisted_sceneapp_context(&db, &persisted) + .expect("写入初始 context 失败") + .expect("应返回 context 路径"); + + let run = create_run_summary("story-video-suite", workspace_id.as_str()); + sync_persisted_context_feedback(&db, &run, Some(&create_scorecard("story-video-suite"))) + .expect("先同步自动反馈失败") + .expect("应返回自动反馈后的 context"); + + let decision = RuntimeReviewDecisionContent { + decision_status: "accepted".to_string(), + decision_summary: "这轮结果已经可以进入发布前微调".to_string(), + chosen_fix_strategy: "只补封面和字幕排版".to_string(), + risk_level: "high".to_string(), + risk_tags: Vec::new(), + human_reviewer: "Robin".to_string(), + reviewed_at: Some("2026-04-17T08:06:00.000Z".to_string()), + followup_actions: vec!["补一版封面".to_string(), "复查字幕".to_string()], + regression_requirements: vec!["确认视频导出尺寸".to_string()], + notes: "先不要扩大发布范围".to_string(), + }; + + sync_persisted_context_review_decision(&db, &run, &decision) + .expect("同步人工复核失败") + .expect("应返回人工复核后的 context"); + + let loaded = load_persisted_sceneapp_context( + &db, + "story-video-suite", + Some(workspace_id.as_str()), + Some(workspace_id.as_str()), + ) + .expect("读取回写后的 context 失败") + .expect("应能读取到人工复核后的 context"); + + assert_eq!(loaded.reference_items[0].usage_count, Some(1)); + assert_eq!( + loaded.reference_items[0].last_feedback_label.as_deref(), + Some("人工接受 · 高风险") + ); + assert_eq!( + loaded.reference_items[0].last_used_at.as_deref(), + Some("2026-04-17T08:06:00.000Z") + ); + assert!(loaded.taste_profile.as_ref().is_some_and(|profile| profile + .feedback_summary + .as_deref() + .is_some_and(|summary| summary.contains("人工复核结论:人工接受 · 高风险。")) + && profile + .feedback_summary + .as_deref() + .is_some_and(|summary| summary.contains("处理策略:只补封面和字幕排版。")) + && profile + .feedback_signals + .iter() + .any(|signal| signal == "review_decision_accepted") + && profile + .feedback_signals + .iter() + .any(|signal| signal == "review_risk_high"))); } } diff --git a/src-tauri/src/sceneapp/context/compiler.rs b/src-tauri/src/sceneapp/context/compiler.rs index eddbb74dc..0e07b1b0d 100644 --- a/src-tauri/src/sceneapp/context/compiler.rs +++ b/src-tauri/src/sceneapp/context/compiler.rs @@ -4,6 +4,7 @@ use super::dto::{ }; use super::store::PersistedSceneAppContext; use crate::sceneapp::dto::{SceneAppDescriptor, SceneAppLaunchIntent, SceneAppPattern}; +use lime_memory::UnifiedMemory; use std::collections::{hash_map::DefaultHasher, BTreeSet}; use std::hash::{Hash, Hasher}; @@ -50,6 +51,46 @@ fn stable_reference_item_id(prefix: &str, key: &str, value: &str) -> String { format!("{prefix}-{key}-{:x}", hasher.finish()) } +fn stable_memory_reference_item_id(memory_id: &str) -> String { + format!("memory:{memory_id}") +} + +fn build_reference_content_type(memory: &UnifiedMemory) -> String { + if extract_url_candidate(memory.content.as_str()).is_some() { + return "url".to_string(); + } + + match memory.category { + lime_memory::MemoryCategory::Identity => "style_memory".to_string(), + lime_memory::MemoryCategory::Preference => "preference_memory".to_string(), + lime_memory::MemoryCategory::Context => "reference_memory".to_string(), + lime_memory::MemoryCategory::Experience => "outcome_memory".to_string(), + lime_memory::MemoryCategory::Activity => "collection_memory".to_string(), + } +} + +pub fn build_reference_library_items(memories: &[UnifiedMemory]) -> Vec { + memories + .iter() + .map(|memory| ReferenceItem { + id: stable_memory_reference_item_id(memory.id.as_str()), + label: memory.title.trim().to_string(), + source_kind: ContextLayerSourceKind::ReferenceLibrary, + content_type: build_reference_content_type(memory), + uri: extract_url_candidate(memory.content.as_str()), + summary: Some(if memory.summary.trim().is_empty() { + truncate_summary(memory.content.as_str(), 80) + } else { + truncate_summary(memory.summary.as_str(), 80) + }), + selected: true, + usage_count: None, + last_used_at: None, + last_feedback_label: None, + }) + .collect() +} + fn build_reference_items(intent: &SceneAppLaunchIntent) -> Vec { let mut items = Vec::new(); @@ -67,6 +108,9 @@ fn build_reference_items(intent: &SceneAppLaunchIntent) -> Vec { uri: extract_url_candidate(user_input), summary: Some(truncate_summary(user_input, 80)), selected: true, + usage_count: None, + last_used_at: None, + last_feedback_label: None, }); } } @@ -88,20 +132,69 @@ fn build_reference_items(intent: &SceneAppLaunchIntent) -> Vec { uri: extract_url_candidate(normalized), summary: Some(truncate_summary(normalized, 80)), selected: true, + usage_count: None, + last_used_at: None, + last_feedback_label: None, }) })); items } +fn merge_reference_item_feedback( + mut item: ReferenceItem, + persisted_item: &ReferenceItem, +) -> ReferenceItem { + if item.summary.is_none() { + item.summary = persisted_item.summary.clone(); + } + if item.uri.is_none() { + item.uri = persisted_item.uri.clone(); + } + if item.usage_count.is_none() { + item.usage_count = persisted_item.usage_count; + } + if item.last_used_at.is_none() { + item.last_used_at = persisted_item.last_used_at.clone(); + } + if item.last_feedback_label.is_none() { + item.last_feedback_label = persisted_item.last_feedback_label.clone(); + } + item.selected = item.selected || persisted_item.selected; + item +} + fn merge_reference_items( + explicit_reference_items: Vec, input_items: Vec, persisted_context: Option<&PersistedSceneAppContext>, ) -> Vec { let mut seen = BTreeSet::new(); let mut merged = Vec::new(); - for item in input_items { + for mut item in explicit_reference_items { + if let Some(persisted_item) = persisted_context.and_then(|context| { + context + .reference_items + .iter() + .find(|existing| existing.id == item.id) + }) { + item = merge_reference_item_feedback(item, persisted_item); + } + if seen.insert(item.id.clone()) { + merged.push(item); + } + } + + for mut item in input_items { + if let Some(persisted_item) = persisted_context.and_then(|context| { + context + .reference_items + .iter() + .find(|existing| existing.id == item.id) + }) { + item = merge_reference_item_feedback(item, persisted_item); + } if seen.insert(item.id.clone()) { merged.push(item); } @@ -206,6 +299,14 @@ fn build_taste_profile( push_unique(&mut keywords, Some(keyword.clone())); } } + for item in reference_items.iter().filter(|item| { + matches!( + item.source_kind, + ContextLayerSourceKind::ReferenceLibrary | ContextLayerSourceKind::Project + ) + }) { + push_unique(&mut keywords, Some(item.label.clone())); + } for alias in descriptor.aliases.iter().take(2) { push_unique(&mut keywords, Some(alias.clone())); } @@ -268,6 +369,13 @@ fn build_taste_profile( 0.56 }, ), + feedback_summary: persisted_taste_profile + .and_then(|profile| profile.feedback_summary.clone()), + feedback_signals: persisted_taste_profile + .map(|profile| profile.feedback_signals.clone()) + .unwrap_or_default(), + last_feedback_at: persisted_taste_profile + .and_then(|profile| profile.last_feedback_at.clone()), }) } @@ -275,9 +383,14 @@ pub fn build_sceneapp_context_overlay( descriptor: &SceneAppDescriptor, intent: &SceneAppLaunchIntent, persisted_context: Option<&PersistedSceneAppContext>, + explicit_reference_items: &[ReferenceItem], ) -> SceneAppContextOverlay { let input_reference_items = build_reference_items(intent); - let reference_items = merge_reference_items(input_reference_items.clone(), persisted_context); + let reference_items = merge_reference_items( + explicit_reference_items.to_vec(), + input_reference_items.clone(), + persisted_context, + ); let memory_refs = build_memory_refs(intent); let tool_refs = build_tool_refs(descriptor, intent); let skill_refs = build_skill_refs(descriptor); @@ -308,8 +421,24 @@ pub fn build_sceneapp_context_overlay( restored_reference_count )); } - if input_reference_items.is_empty() && reference_items.is_empty() { + if explicit_reference_items.is_empty() + && input_reference_items.is_empty() + && reference_items.is_empty() + { notes.push("当前尚未选中显式参考素材,将主要依赖用户输入与场景画像。".to_string()); + } else if !explicit_reference_items.is_empty() && !input_reference_items.is_empty() { + notes.push(format!( + "本次显式带入 {} 条灵感对象,并新增 {} 条输入参考,当前 planning 共带上 {} 条参考。", + explicit_reference_items.len(), + input_reference_items.len(), + reference_items.len() + )); + } else if !explicit_reference_items.is_empty() { + notes.push(format!( + "本次显式带入 {} 条灵感对象,当前 planning 共带上 {} 条参考。", + explicit_reference_items.len(), + reference_items.len() + )); } else if !input_reference_items.is_empty() { notes.push(format!( "本次新增 {} 条参考输入,当前 planning 共带上 {} 条参考。", @@ -328,7 +457,8 @@ pub fn build_sceneapp_context_overlay( { notes.push("当前已复用项目级 TasteProfile,并按最新输入继续更新。".to_string()); } else if taste_profile.is_some() { - notes.push("当前 TasteProfile 为启发式摘要,后续可继续接入持久化反馈回写。".to_string()); + notes + .push("当前 TasteProfile 为启发式摘要,可继续通过场景基线与运行反馈沉淀。".to_string()); } SceneAppContextOverlay { @@ -353,11 +483,12 @@ pub fn build_sceneapp_context_overlay( #[cfg(test)] mod tests { - use super::build_sceneapp_context_overlay; + use super::{build_reference_library_items, build_sceneapp_context_overlay}; use crate::sceneapp::catalog::get_sceneapp_descriptor; use crate::sceneapp::context::dto::{ContextLayerSourceKind, ReferenceItem, TasteProfile}; use crate::sceneapp::context::store::PersistedSceneAppContext; use crate::sceneapp::dto::{SceneAppLaunchIntent, SceneAppRuntimeContext}; + use lime_memory::UnifiedMemory; use std::collections::BTreeMap; #[test] @@ -379,6 +510,7 @@ mod tests { workspace_id: Some("workspace-default".to_string()), project_id: Some("project-export".to_string()), user_input: Some("请导出这篇文章并保持原始语气".to_string()), + reference_memory_ids: Vec::new(), slots, runtime_context: Some(SceneAppRuntimeContext { browser_session_attached: true, @@ -386,6 +518,7 @@ mod tests { }), }, None, + &[], ); assert!(overlay @@ -422,6 +555,9 @@ mod tests { uri: None, summary: Some("保留结论前置和对比镜头。".to_string()), selected: true, + usage_count: Some(2), + last_used_at: Some("2026-04-16T12:00:00.000Z".to_string()), + last_feedback_label: Some("复核阻塞".to_string()), }], taste_profile: Some(TasteProfile { profile_id: "taste-story-video-suite".to_string(), @@ -430,7 +566,11 @@ mod tests { avoid_keywords: vec!["冗长铺垫".to_string()], derived_from_reference_ids: vec!["saved-reference-1".to_string()], confidence: Some(0.66), + feedback_summary: Some("最近一次运行卡在复核环节。".to_string()), + feedback_signals: vec!["review_blocked".to_string()], + last_feedback_at: Some("2026-04-16T12:00:00.000Z".to_string()), }), + last_feedback_run_id: Some("sceneapp-run-42".to_string()), }; let overlay = build_sceneapp_context_overlay( @@ -441,10 +581,12 @@ mod tests { workspace_id: Some("workspace-default".to_string()), project_id: Some("project-video".to_string()), user_input: Some("做一个 30 秒新品短视频".to_string()), + reference_memory_ids: Vec::new(), slots, runtime_context: None, }, Some(&persisted_context), + &[], ); assert_eq!(overlay.compiler_plan.reference_count, 3); @@ -463,11 +605,83 @@ mod tests { .reference_items .iter() .any(|item| item.label == "竞品拆解" - && matches!(item.source_kind, ContextLayerSourceKind::ReferenceLibrary))); + && matches!(item.source_kind, ContextLayerSourceKind::ReferenceLibrary) + && item.usage_count == Some(2) + && item.last_feedback_label.as_deref() == Some("复核阻塞"))); assert!(overlay .snapshot .taste_profile .as_ref() - .is_some_and(|profile| profile.summary.contains("项目沉淀基础"))); + .is_some_and(|profile| profile.summary.contains("项目沉淀基础") + && profile.feedback_summary.as_deref() == Some("最近一次运行卡在复核环节。") + && profile + .feedback_signals + .iter() + .any(|signal| signal == "review_blocked"))); + } + + #[test] + fn should_promote_selected_memory_entries_into_reference_items() { + let descriptor = get_sceneapp_descriptor("story-video-suite") + .expect("story-video-suite descriptor should exist"); + let explicit_reference_items = build_reference_library_items(&[UnifiedMemory { + id: "memory-1".to_string(), + session_id: "session-1".to_string(), + memory_type: lime_memory::MemoryType::Conversation, + category: lime_memory::MemoryCategory::Identity, + title: "夏日短视频语气".to_string(), + content: "保留轻盈、结论前置和更强的节奏推进。".to_string(), + summary: "轻盈、结论前置、快节奏。".to_string(), + tags: vec!["小红书".to_string()], + metadata: lime_memory::MemoryMetadata { + confidence: 0.86, + importance: 7, + access_count: 0, + last_accessed_at: None, + source: lime_memory::MemorySource::Manual, + embedding: None, + }, + created_at: 1_712_345_678_900, + updated_at: 1_712_345_678_900, + archived: false, + }]); + + let overlay = build_sceneapp_context_overlay( + &descriptor, + &SceneAppLaunchIntent { + sceneapp_id: "story-video-suite".to_string(), + entry_source: Some("memory_page".to_string()), + workspace_id: Some("workspace-default".to_string()), + project_id: Some("project-video".to_string()), + user_input: Some("继续整理成 30 秒短视频方案".to_string()), + reference_memory_ids: vec!["memory-1".to_string()], + slots: BTreeMap::new(), + runtime_context: Some(SceneAppRuntimeContext { + cloud_session_ready: true, + ..SceneAppRuntimeContext::default() + }), + }, + None, + explicit_reference_items.as_slice(), + ); + + assert!(overlay + .snapshot + .reference_items + .iter() + .any(|item| item.id == "memory:memory-1" && item.label == "夏日短视频语气")); + assert!(overlay + .snapshot + .taste_profile + .as_ref() + .is_some_and(|profile| profile + .keywords + .iter() + .any(|keyword| keyword == "夏日短视频语气"))); + assert!(overlay + .compiler_plan + .notes + .iter() + .any(|note| note.contains("显式带入 1 条灵感对象"))); } } diff --git a/src-tauri/src/sceneapp/context/dto.rs b/src-tauri/src/sceneapp/context/dto.rs index 03c53cc1c..bb20c39e1 100644 --- a/src-tauri/src/sceneapp/context/dto.rs +++ b/src-tauri/src/sceneapp/context/dto.rs @@ -22,6 +22,12 @@ pub struct ReferenceItem { pub uri: Option, pub summary: Option, pub selected: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_feedback_label: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -33,6 +39,12 @@ pub struct TasteProfile { pub avoid_keywords: Vec, pub derived_from_reference_ids: Vec, pub confidence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub feedback_summary: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub feedback_signals: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_feedback_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src-tauri/src/sceneapp/context/store.rs b/src-tauri/src/sceneapp/context/store.rs index a448562ae..f3df96ccb 100644 --- a/src-tauri/src/sceneapp/context/store.rs +++ b/src-tauri/src/sceneapp/context/store.rs @@ -17,6 +17,8 @@ pub struct PersistedSceneAppContext { pub reference_items: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub taste_profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_feedback_run_id: Option, } fn normalize_optional_id(value: Option<&str>) -> Option { @@ -97,6 +99,7 @@ pub fn build_persisted_sceneapp_context( project_id: snapshot.project_id.clone(), reference_items: snapshot.reference_items.clone(), taste_profile: snapshot.taste_profile.clone(), + last_feedback_run_id: None, } } @@ -211,6 +214,9 @@ mod tests { uri: None, summary: Some("科技感,快节奏".to_string()), selected: true, + usage_count: Some(1), + last_used_at: Some("2026-04-17T00:00:00.000Z".to_string()), + last_feedback_label: Some("可继续复用".to_string()), }], taste_profile: Some(crate::sceneapp::context::dto::TasteProfile { profile_id: "taste-story-video-suite".to_string(), @@ -219,6 +225,9 @@ mod tests { avoid_keywords: vec!["冗长铺垫".to_string()], derived_from_reference_ids: vec!["slot-style-a1b2".to_string()], confidence: Some(0.72), + feedback_summary: Some("最近一次运行已沉淀为正向风格反馈。".to_string()), + feedback_signals: vec!["publish_ready".to_string()], + last_feedback_at: Some("2026-04-17T00:00:00.000Z".to_string()), }), }; let persisted = build_persisted_sceneapp_context("story-video-suite", &snapshot); diff --git a/src-tauri/src/sceneapp/dto.rs b/src-tauri/src/sceneapp/dto.rs index 6bc0eecd8..4c88aa59f 100644 --- a/src-tauri/src/sceneapp/dto.rs +++ b/src-tauri/src/sceneapp/dto.rs @@ -183,6 +183,8 @@ pub struct SceneAppLaunchIntent { pub project_id: Option, pub user_input: Option, #[serde(default)] + pub reference_memory_ids: Vec, + #[serde(default)] pub slots: BTreeMap, pub runtime_context: Option, } diff --git a/src-tauri/src/sceneapp/runtime.rs b/src-tauri/src/sceneapp/runtime.rs index 54b9493d3..bfb8c26d5 100644 --- a/src-tauri/src/sceneapp/runtime.rs +++ b/src-tauri/src/sceneapp/runtime.rs @@ -1,6 +1,6 @@ use super::adapters::build_sceneapp_runtime_adapter_plan; use super::context::compiler::build_sceneapp_context_overlay; -use super::context::dto::SceneAppContextOverlay; +use super::context::dto::{ReferenceItem, SceneAppContextOverlay}; use super::context::store::PersistedSceneAppContext; use super::dto::*; use std::collections::BTreeSet; @@ -236,8 +236,14 @@ pub fn build_launch_plan( descriptor: SceneAppDescriptor, intent: SceneAppLaunchIntent, persisted_context: Option<&PersistedSceneAppContext>, + explicit_reference_items: &[ReferenceItem], ) -> SceneAppPlanResult { - let context_overlay = build_sceneapp_context_overlay(&descriptor, &intent, persisted_context); + let context_overlay = build_sceneapp_context_overlay( + &descriptor, + &intent, + persisted_context, + explicit_reference_items, + ); let project_pack_plan = build_project_pack_plan(&descriptor, &intent, &context_overlay); let sceneapp_id = descriptor.id.clone(); let binding_family = descriptor @@ -306,6 +312,7 @@ mod tests { workspace_id: Some("workspace-default".to_string()), project_id: Some("project-video".to_string()), user_input: Some("根据发布会内容生成 30 秒短视频草稿".to_string()), + reference_memory_ids: Vec::new(), slots: BTreeMap::new(), runtime_context: Some(SceneAppRuntimeContext { cloud_session_ready: true, @@ -313,6 +320,7 @@ mod tests { }), }, None, + &[], ); assert_eq!( @@ -369,6 +377,7 @@ mod tests { workspace_id: Some("workspace-default".to_string()), project_id: Some("project-research".to_string()), user_input: Some("请导出这篇文章".to_string()), + reference_memory_ids: Vec::new(), slots, runtime_context: Some(SceneAppRuntimeContext { browser_session_attached: true, @@ -376,6 +385,7 @@ mod tests { }), }, None, + &[], ); assert_eq!( @@ -419,6 +429,7 @@ mod tests { workspace_id: Some("workspace-default".to_string()), project_id: Some("project-growth".to_string()), user_input: Some("关注 AI Agent 产品趋势".to_string()), + reference_memory_ids: Vec::new(), slots: BTreeMap::new(), runtime_context: Some(SceneAppRuntimeContext { automation_enabled: true, @@ -426,6 +437,7 @@ mod tests { }), }, None, + &[], ); assert_eq!( @@ -466,6 +478,9 @@ mod tests { uri: None, summary: Some("保留强开头与多镜头切换。".to_string()), selected: true, + usage_count: Some(1), + last_used_at: Some("2026-04-16T00:00:00.000Z".to_string()), + last_feedback_label: Some("可继续复用".to_string()), }], taste_profile: Some(TasteProfile { profile_id: "taste-story-video-suite".to_string(), @@ -474,7 +489,11 @@ mod tests { avoid_keywords: vec!["铺垫过长".to_string()], derived_from_reference_ids: vec!["saved-reference-1".to_string()], confidence: Some(0.74), + feedback_summary: Some("最近一次运行可继续复用。".to_string()), + feedback_signals: vec!["publish_ready".to_string()], + last_feedback_at: Some("2026-04-16T00:00:00.000Z".to_string()), }), + last_feedback_run_id: Some("sceneapp-run-1".to_string()), }; let mut slots = BTreeMap::new(); slots.insert("style".to_string(), "科技感".to_string()); @@ -487,6 +506,7 @@ mod tests { workspace_id: Some("workspace-default".to_string()), project_id: Some("project-video".to_string()), user_input: Some("根据这次发布会做 30 秒短视频".to_string()), + reference_memory_ids: Vec::new(), slots, runtime_context: Some(SceneAppRuntimeContext { cloud_session_ready: true, @@ -494,6 +514,7 @@ mod tests { }), }, Some(&persisted_context), + &[], ); let overlay = plan.context_overlay.expect("context overlay should exist"); diff --git a/src-tauri/src/screenshot/window.rs b/src-tauri/src/screenshot/window.rs index 2adad44a6..704f1bfe2 100644 --- a/src-tauri/src/screenshot/window.rs +++ b/src-tauri/src/screenshot/window.rs @@ -381,10 +381,10 @@ fn open_floating_window_with_url(app: &AppHandle, url: &str) -> Result<(), Windo if is_voice_mode { use tauri::Emitter; // 延迟发送事件,等待页面加载 - let window_clone = window.clone(); + let app_clone = app.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(200)); - let _ = window_clone.emit("voice-start-recording", ()); + let _ = app_clone.emit("voice-start-recording", ()); info!("[语音输入] 已发送开始录音事件"); }); } @@ -428,6 +428,16 @@ fn open_floating_window_with_url(app: &AppHandle, url: &str) -> Result<(), Windo info!("悬浮窗口创建成功: {}", FLOATING_WINDOW_LABEL); + if is_voice_mode { + use tauri::Emitter; + let app_clone = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(200)); + let _ = app_clone.emit("voice-start-recording", ()); + info!("[语音输入] 已广播开始录音事件"); + }); + } + Ok(()) } @@ -440,12 +450,9 @@ pub fn open_floating_window_with_voice(app: &AppHandle) -> Result<(), WindowErro pub fn send_voice_stop_event(app: &AppHandle) -> Result<(), WindowError> { use tauri::Emitter; - if let Some(window) = app.get_webview_window(FLOATING_WINDOW_LABEL) { - window - .emit("voice-stop-recording", ()) - .map_err(|e| WindowError::OperationFailed(format!("发送停止录音事件失败: {e}")))?; - info!("[语音输入] 已发送停止录音事件到截图输入框"); - } + app.emit("voice-stop-recording", ()) + .map_err(|e| WindowError::OperationFailed(format!("发送停止录音事件失败: {e}")))?; + info!("[语音输入] 已广播停止录音事件"); Ok(()) } diff --git a/src-tauri/src/services/automation_service/executor.rs b/src-tauri/src/services/automation_service/executor.rs index bd7626949..e6c5cded9 100644 --- a/src-tauri/src/services/automation_service/executor.rs +++ b/src-tauri/src/services/automation_service/executor.rs @@ -54,6 +54,8 @@ pub async fn execute_job( prompt, system_prompt, web_search, + approval_policy, + sandbox_policy, request_metadata, content_id, } => { @@ -64,6 +66,8 @@ pub async fn execute_job( prompt, system_prompt, web_search, + approval_policy, + sandbox_policy, request_metadata, content_id, ) @@ -84,6 +88,8 @@ async fn execute_agent_turn( prompt: String, system_prompt: Option, web_search: bool, + approval_policy: Option, + sandbox_policy: Option, request_metadata: Option, content_id: Option, ) -> Result { @@ -108,6 +114,12 @@ async fn execute_agent_turn( job.workspace_id.clone(), Some("auto".to_string()), )?; + let access_mode = resolve_agent_turn_access_mode_from_payload( + approval_policy.as_deref(), + sandbox_policy.as_deref(), + request_metadata.as_ref(), + )?; + AsterAgentWrapper::persist_session_recent_access_mode(&session_id, access_mode).await?; let agent_state = app .try_state::() @@ -137,8 +149,8 @@ async fn execute_agent_turn( provider_preference: None, model_preference: None, thinking_enabled: None, - approval_policy: None, - sandbox_policy: None, + approval_policy: Some(access_mode.approval_policy().to_string()), + sandbox_policy: Some(access_mode.sandbox_policy().to_string()), project_id: None, workspace_id: job.workspace_id.clone(), web_search: Some(web_search), @@ -198,6 +210,50 @@ fn build_prompt(job: &AutomationJobRecord, prompt: &str, web_search: bool) -> St sections.join("\n\n") } +fn extract_access_mode_text(request_metadata: Option<&Value>) -> Option<&str> { + let root = request_metadata?.as_object()?; + let harness = root.get("harness").and_then(Value::as_object); + + harness + .and_then(|value| value.get("access_mode").and_then(Value::as_str)) + .or_else(|| harness.and_then(|value| value.get("accessMode").and_then(Value::as_str))) + .or_else(|| root.get("access_mode").and_then(Value::as_str)) + .or_else(|| root.get("accessMode").and_then(Value::as_str)) +} + +pub(super) fn resolve_agent_turn_access_mode_from_payload( + approval_policy: Option<&str>, + sandbox_policy: Option<&str>, + request_metadata: Option<&Value>, +) -> Result { + if approval_policy.is_some() || sandbox_policy.is_some() { + return match (approval_policy.map(str::trim), sandbox_policy.map(str::trim)) { + (Some("on-request"), Some("read-only")) | (None, Some("read-only")) => { + Ok(lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly) + } + (Some("on-request"), Some("workspace-write")) + | (None, Some("workspace-write")) => { + Ok(lime_agent::SessionExecutionRuntimeAccessMode::Current) + } + (Some("never"), Some("danger-full-access")) + | (None, Some("danger-full-access")) => { + Ok(lime_agent::SessionExecutionRuntimeAccessMode::FullAccess) + } + _ => Err( + "自动化任务 approval_policy/sandbox_policy 仅支持 read-only/current/full-access 对应的正式策略组合" + .to_string(), + ), + }; + } + + Ok( + lime_agent::SessionExecutionRuntimeAccessMode::from_access_mode_text( + extract_access_mode_text(request_metadata), + ) + .unwrap_or_else(lime_agent::SessionExecutionRuntimeAccessMode::default_for_session), + ) +} + fn normalize_agent_turn_request_metadata( request_metadata: Option, content_id: Option, @@ -282,4 +338,63 @@ mod tests { Some("content-2") ); } + + #[test] + fn resolve_agent_turn_access_mode_prefers_formal_policies_over_legacy_metadata() { + assert_eq!( + resolve_agent_turn_access_mode_from_payload( + Some("never"), + Some("danger-full-access"), + Some(&json!({ + "harness": { + "access_mode": "read-only" + } + })), + ) + .expect("resolved access mode"), + lime_agent::SessionExecutionRuntimeAccessMode::FullAccess + ); + } + + #[test] + fn resolve_agent_turn_access_mode_prefers_explicit_harness_value_when_formal_policies_missing() + { + assert_eq!( + resolve_agent_turn_access_mode_from_payload( + None, + None, + Some(&json!({ + "harness": { + "access_mode": "read-only" + } + })), + ) + .expect("resolved access mode"), + lime_agent::SessionExecutionRuntimeAccessMode::ReadOnly + ); + } + + #[test] + fn resolve_agent_turn_access_mode_defaults_to_full_access() { + assert_eq!( + resolve_agent_turn_access_mode_from_payload(None, None, None) + .expect("resolved access mode"), + lime_agent::SessionExecutionRuntimeAccessMode::FullAccess + ); + } + + #[test] + fn resolve_agent_turn_access_mode_rejects_invalid_formal_policy_pair() { + assert_eq!( + resolve_agent_turn_access_mode_from_payload( + Some("never"), + Some("workspace-write"), + None, + ), + Err( + "自动化任务 approval_policy/sandbox_policy 仅支持 read-only/current/full-access 对应的正式策略组合" + .to_string() + ) + ); + } } diff --git a/src-tauri/src/services/automation_service/mod.rs b/src-tauri/src/services/automation_service/mod.rs index 8632caada..fc98a1003 100644 --- a/src-tauri/src/services/automation_service/mod.rs +++ b/src-tauri/src/services/automation_service/mod.rs @@ -76,6 +76,10 @@ pub enum AutomationPayload { #[serde(default)] web_search: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sandbox_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] request_metadata: Option, #[serde(default, skip_serializing_if = "Option::is_none")] content_id: Option, @@ -794,6 +798,8 @@ fn validate_payload(payload: &AutomationPayload) -> Result<(), String> { match payload { AutomationPayload::AgentTurn { prompt, + approval_policy, + sandbox_policy, request_metadata, content_id, .. @@ -811,6 +817,11 @@ fn validate_payload(payload: &AutomationPayload) -> Result<(), String> { return Err("自动化任务 request_metadata 必须为对象".to_string()); } } + executor::resolve_agent_turn_access_mode_from_payload( + approval_policy.as_deref(), + sandbox_policy.as_deref(), + request_metadata.as_ref(), + )?; } AutomationPayload::BrowserSession { .. } => { return Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string()); @@ -1600,6 +1611,8 @@ mod tests { prompt: "汇总今日异常".to_string(), system_prompt: None, web_search: false, + approval_policy: None, + sandbox_policy: None, request_metadata: Some(json!({ "harness": { "theme": "general", @@ -1620,6 +1633,8 @@ mod tests { prompt: "汇总今日异常".to_string(), system_prompt: None, web_search: false, + approval_policy: None, + sandbox_policy: None, request_metadata: Some(json!(["invalid"])), content_id: Some("content-1".to_string()), }; @@ -1647,4 +1662,25 @@ mod tests { Err(BROWSER_AUTOMATION_RETIRED_MESSAGE.to_string()) ); } + + #[test] + fn validate_payload_should_reject_invalid_formal_policy_pair() { + let payload = AutomationPayload::AgentTurn { + prompt: "汇总今日异常".to_string(), + system_prompt: None, + web_search: false, + approval_policy: Some("never".to_string()), + sandbox_policy: Some("workspace-write".to_string()), + request_metadata: None, + content_id: None, + }; + + assert_eq!( + validate_payload(&payload), + Err( + "自动化任务 approval_policy/sandbox_policy 仅支持 read-only/current/full-access 对应的正式策略组合" + .to_string() + ) + ); + } } diff --git a/src-tauri/src/terminal/README.md b/src-tauri/src/terminal/README.md deleted file mode 100644 index cb5ff03e4..000000000 --- a/src-tauri/src/terminal/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# terminal(重导出层) - - - -## 架构说明 - -本目录是终端模块的**重导出层**,实际实现已迁移至独立 crate `lime-terminal`(位于 `crates/terminal/`)。 - -本层职责: -- 提供 `TauriEmitter` newtype,桥接 Tauri `AppHandle` 与终端 crate 的 `TerminalEventEmit` trait -- 重导出 `lime-terminal` 的所有公共模块和类型,保持 `crate::terminal::xxx` 路径兼容 - -## 文件索引 - -- `mod.rs` - TauriEmitter 定义 + lime-terminal 重导出 - -## 实际实现 - -详见 `crates/terminal/` 目录及其 README。 - -## 更新提醒 - -任何文件变更后,请更新此文档和相关的上级文档。 diff --git a/src-tauri/src/terminal/mod.rs b/src-tauri/src/terminal/mod.rs deleted file mode 100644 index ea9923e29..000000000 --- a/src-tauri/src/terminal/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! 终端模块 -//! -//! 实际实现位于 `lime-terminal` crate。 -//! 本模块提供 `TauriEmitter` newtype 桥接 Tauri 与终端 crate。 - -use std::path::PathBuf; - -use tauri::Emitter; - -use lime_terminal::emitter::TerminalEventEmit; - -/// Tauri AppHandle 的 newtype 包装 -/// -/// 实现 `TerminalEventEmit` trait,桥接 Tauri 框架与终端 crate。 -#[derive(Clone)] -pub struct TauriEmitter(pub tauri::AppHandle); - -impl TerminalEventEmit for TauriEmitter { - fn emit_event(&self, event: &str, payload: &serde_json::Value) -> Result<(), String> { - self.0 - .emit(event, payload.clone()) - .map_err(|e| format!("Tauri emit 失败: {e}")) - } - - fn app_data_dir(&self) -> Result { - lime_core::app_paths::preferred_data_dir().map_err(|e| format!("获取应用数据目录失败: {e}")) - } -} diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index 30fcc7569..f244a6379 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.12.3", + "version": "1.13.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3cf9fc461..69fce05b5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "1.12.3", + "version": "1.13.0", "identifier": "com.limecloud.lime", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index cb843c2d7..e67c41d19 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -96,7 +96,14 @@ const pageLoadingFallback = ( function AppContent() { const hasTauriDesktopRuntime = hasTauriInvokeCapability(); const [showSplash, setShowSplash] = useState(true); - const { currentPage, pageParams, handleNavigate } = useAppNavigation(); + const { + currentPage, + pageParams, + requestedPage, + requestedPageParams, + navigationRequestId, + handleNavigate, + } = useAppNavigation(); const [agentHasMessages, setAgentHasMessages] = useState(false); const { needsOnboarding, completeOnboarding } = useOnboardingState(); @@ -251,6 +258,8 @@ function AppContent() { )} @@ -259,6 +268,9 @@ function AppContent() { diff --git a/src/components/AppPageContent.test.tsx b/src/components/AppPageContent.test.tsx index fece20252..8d08ed1ef 100644 --- a/src/components/AppPageContent.test.tsx +++ b/src/components/AppPageContent.test.tsx @@ -1,4 +1,4 @@ -import { Suspense } from "react"; +import { Suspense, useEffect } from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -29,6 +29,13 @@ const latestSceneAppsPageProps = vi.hoisted( value: null as Record | null, }) as { value: Record | null }, ); +const sceneAppsLifecycle = vi.hoisted( + () => + ({ + mounts: 0, + unmounts: 0, + }) as { mounts: number; unmounts: number }, +); vi.mock("./agent/chat", () => ({ AgentChatPage: (props: Record) => { @@ -62,6 +69,15 @@ vi.mock("./memory", () => ({ vi.mock("./sceneapps", () => ({ SceneAppsPage: (props: Record) => { latestSceneAppsPageProps.value = props; + + useEffect(() => { + sceneAppsLifecycle.mounts += 1; + + return () => { + sceneAppsLifecycle.unmounts += 1; + }; + }, []); + return
; }, })); @@ -73,26 +89,63 @@ interface MountedContent { const mountedContents: MountedContent[] = []; -function renderContent(currentPage: Page, pageParams: PageParams = {}) { +interface RenderContentOptions { + currentPage: Page; + pageParams?: PageParams; + requestedPage?: Page; + requestedPageParams?: PageParams; + navigationRequestId?: number; +} + +function renderContentWithNavigationState(options: RenderContentOptions) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - act(() => { - root.render( - }> - void} - onAgentHasMessagesChange={vi.fn()} - /> - , - ); - }); + const renderWithProps = (nextOptions: RenderContentOptions) => { + act(() => { + root.render( + }> + void} + onAgentHasMessagesChange={vi.fn()} + /> + , + ); + }); + }; + + renderWithProps(options); mountedContents.push({ container, root }); - return container; + return { + container, + rerender(nextOptions: RenderContentOptions) { + renderWithProps(nextOptions); + }, + }; +} + +function renderContent(currentPage: Page, pageParams: PageParams = {}) { + const rendered = renderContentWithNavigationState({ + currentPage, + pageParams, + }); + + return { + container: rendered.container, + rerender(nextCurrentPage: Page, nextPageParams: PageParams = {}) { + rendered.rerender({ + currentPage: nextCurrentPage, + pageParams: nextPageParams, + }); + }, + }; } async function flushEffects(times = 6) { @@ -110,6 +163,8 @@ describe("AppPageContent", () => { latestSkillsWorkspaceProps.value = null; latestMemoryPageProps.value = null; latestSceneAppsPageProps.value = null; + sceneAppsLifecycle.mounts = 0; + sceneAppsLifecycle.unmounts = 0; }); afterEach(() => { @@ -250,8 +305,87 @@ describe("AppPageContent", () => { }); }); + it("agent 页面应把创作场景执行摘要与自动发送 metadata 透传给 AgentChatPage", async () => { + const pageParams: AgentPageParams = { + agentEntry: "claw", + projectId: "project-sceneapp", + theme: "general", + initialSceneAppExecutionSummary: { + sceneappId: "story-video-suite", + title: "短视频编排", + summary: "把线框图、脚本与配乐压成同一条结果链。", + businessLabel: "内容闭环", + typeLabel: "多模态组合", + executionChainLabel: "创作场景 -> 生成 -> Project Pack", + deliveryContractLabel: "Project Pack", + planningStatusLabel: "已就绪", + planningSummary: "当前可直接进入生成。", + activeLayers: [{ key: "skill", label: "Skill" }], + referenceCount: 1, + referenceItems: [ + { + key: "ref-1", + label: "品牌 KV", + sourceLabel: "灵感库", + contentTypeLabel: "图片", + selected: true, + }, + ], + tasteSummary: "偏好克制的科技蓝。", + feedbackSummary: "最近反馈要求减少文案堆叠。", + projectPackPlan: { + packKindLabel: "短视频项目包", + completionStrategyLabel: "按必含部件判断整包完成度", + viewerLabel: "结果包查看器", + primaryPart: "任务简报", + requiredParts: [{ key: "brief", label: "任务简报" }], + notes: ["完整度将按 1 个必含部件判断。"], + }, + scorecardProfileRef: "story-video-scorecard", + scorecardMetricKeys: [ + { key: "delivery_readiness", label: "交付就绪度" }, + ], + scorecardFailureSignals: [ + { key: "publish_stalled", label: "发布卡点" }, + ], + notes: ["已装配 1 条参考素材。"], + }, + initialAutoSendRequestMetadata: { + harness: { + service_scene_launch: { + kind: "cloud_scene", + service_scene_run: { + scene_key: "story-video-suite", + }, + }, + }, + }, + }; + + renderContent("agent", pageParams); + await flushEffects(); + + expect(latestAgentChatProps.value).toMatchObject({ + projectId: "project-sceneapp", + initialSceneAppExecutionSummary: expect.objectContaining({ + sceneappId: "story-video-suite", + title: "短视频编排", + }), + initialAutoSendRequestMetadata: { + harness: { + service_scene_launch: { + kind: "cloud_scene", + service_scene_run: { + scene_key: "story-video-suite", + }, + }, + }, + }, + }); + }); + it("channels 页面应渲染 IM 配置页", async () => { - const container = renderContent("channels"); + const { container } = renderContent("channels"); await flushEffects(); expect( @@ -260,7 +394,7 @@ describe("AppPageContent", () => { }); it("settings 页面应渲染设置页入口", async () => { - const container = renderContent("settings"); + const { container } = renderContent("settings"); await flushEffects(); expect( @@ -294,7 +428,7 @@ describe("AppPageContent", () => { }); it("memory 页面应把记忆页挂进可滚动容器", async () => { - const container = renderContent("memory", { + const { container } = renderContent("memory", { section: "home", }); await flushEffects(); @@ -313,7 +447,7 @@ describe("AppPageContent", () => { }); it("sceneapps 页面应把目录页参数透传给 SceneAppsPage", async () => { - const container = renderContent("sceneapps", { + const { container } = renderContent("sceneapps", { sceneappId: "story-video-suite", runId: "story-video-suite-run-1", projectId: "project-sceneapp", @@ -326,6 +460,7 @@ describe("AppPageContent", () => { container.querySelector('[data-testid="sceneapps-page"]'), ).not.toBeNull(); expect(latestSceneAppsPageProps.value).toMatchObject({ + isActive: true, pageParams: { sceneappId: "story-video-suite", runId: "story-video-suite-run-1", @@ -335,4 +470,98 @@ describe("AppPageContent", () => { }, }); }); + + it("sceneapps 页面在持续流程往返后不应重新挂载", async () => { + const { rerender } = renderContent("sceneapps", { + sceneappId: "story-video-suite", + runId: "story-video-suite-run-1", + projectId: "project-sceneapp", + search: "短视频", + }); + await flushEffects(); + + const mountsAfterInitialRender = sceneAppsLifecycle.mounts; + + expect(mountsAfterInitialRender).toBeGreaterThan(0); + expect(sceneAppsLifecycle.unmounts).toBe(0); + expect(latestSceneAppsPageProps.value).toMatchObject({ + pageParams: { + sceneappId: "story-video-suite", + runId: "story-video-suite-run-1", + projectId: "project-sceneapp", + search: "短视频", + }, + }); + + rerender("automation", { + selectedJobId: "automation-job-1", + workspaceTab: "tasks", + }); + await flushEffects(); + + expect(sceneAppsLifecycle.mounts).toBe(mountsAfterInitialRender); + expect(sceneAppsLifecycle.unmounts).toBe(0); + expect(latestSceneAppsPageProps.value).toMatchObject({ + pageParams: { + sceneappId: "story-video-suite", + runId: "story-video-suite-run-1", + projectId: "project-sceneapp", + search: "短视频", + }, + }); + + rerender("sceneapps", { + sceneappId: "story-video-suite", + runId: "story-video-suite-run-2", + projectId: "project-sceneapp", + search: "复盘", + }); + await flushEffects(); + + expect(sceneAppsLifecycle.mounts).toBe(mountsAfterInitialRender); + expect(sceneAppsLifecycle.unmounts).toBe(0); + expect(latestSceneAppsPageProps.value).toMatchObject({ + isActive: true, + pageParams: { + sceneappId: "story-video-suite", + runId: "story-video-suite-run-2", + projectId: "project-sceneapp", + search: "复盘", + }, + }); + }); + + it("requested 页面已切回 sceneapps 时应优先渲染最新请求目标", async () => { + const { container } = renderContentWithNavigationState({ + currentPage: "automation", + pageParams: { + selectedJobId: "automation-job-1", + workspaceTab: "tasks", + }, + requestedPage: "sceneapps", + requestedPageParams: { + sceneappId: "story-video-suite", + runId: "story-video-suite-run-2", + projectId: "project-sceneapp", + search: "复盘", + }, + navigationRequestId: 9, + }); + await flushEffects(); + + expect( + container.querySelector('[data-testid="sceneapps-page"]'), + ).not.toBeNull(); + expect(latestSceneAppsPageProps.value).toMatchObject({ + isActive: true, + isNavigationTargetOwner: true, + navigationRequestId: 9, + pageParams: { + sceneappId: "story-video-suite", + runId: "story-video-suite-run-2", + projectId: "project-sceneapp", + search: "复盘", + }, + }); + }); }); diff --git a/src/components/AppPageContent.tsx b/src/components/AppPageContent.tsx index 703d7ff40..07c55993f 100644 --- a/src/components/AppPageContent.tsx +++ b/src/components/AppPageContent.tsx @@ -4,8 +4,12 @@ * 负责根据当前页面类型渲染对应主内容,避免主入口继续膨胀。 */ -import { lazy } from "react"; +import { lazy, useEffect, useState, type ReactNode } from "react"; import styled from "styled-components"; +import { + normalizeSceneAppsPageParams, + serializeSceneAppsPageParams, +} from "@/lib/sceneapp"; import type { AgentPageParams, AutomationPageParams, @@ -18,6 +22,9 @@ import type { SettingsPageParams, SkillsPageParams, } from "@/types/page"; +import { AutomationPage } from "./automation"; +import { ImConfigPage } from "./channels/ImConfigPage"; +import { SceneAppsPage } from "./sceneapps"; import { SettingsPageV2 } from "./settings-v2"; const PageWrapper = styled.div<{ $isActive: boolean }>` @@ -27,6 +34,12 @@ const PageWrapper = styled.div<{ $isActive: boolean }>` display: ${(props) => (props.$isActive ? "block" : "none")}; `; +const KeepAliveColumnPage = styled.div<{ $isActive: boolean }>` + flex: 1; + min-height: 0; + display: ${(props) => (props.$isActive ? "flex" : "none")}; +`; + const columnPageStyle = { flex: 1, minHeight: 0, @@ -34,60 +47,49 @@ const columnPageStyle = { flexDirection: "column", } as const; -const ResourcesPage = lazy(() => +const loadResourcesPage = () => import("./resources").then((module) => ({ default: module.ResourcesPage, - })), -); -const MemoryPage = lazy(() => + })); +const loadMemoryPage = () => import("./memory").then((module) => ({ default: module.MemoryPage, - })), -); -const PluginsPage = lazy(() => + })); +const loadPluginsPage = () => import("./plugins/PluginsPage").then((module) => ({ default: module.PluginsPage, - })), -); -const AutomationPage = lazy(() => - import("./automation").then((module) => ({ - default: module.AutomationPage, - })), -); -const ImConfigPage = lazy(() => - import("./channels/ImConfigPage").then((module) => ({ - default: module.ImConfigPage, - })), -); -const OpenClawPage = lazy(() => + })); +const loadOpenClawPage = () => import("./openclaw").then((module) => ({ default: module.OpenClawPage, - })), -); -const SkillsWorkspacePage = lazy(() => + })); +const loadSkillsWorkspacePage = () => import("./skills").then((module) => ({ default: module.SkillsWorkspacePage, - })), -); -const SceneAppsPage = lazy(() => - import("./sceneapps").then((module) => ({ - default: module.SceneAppsPage, - })), -); -const BrowserRuntimeWorkspace = lazy(() => + })); +const loadBrowserRuntimeWorkspace = () => import("@/features/browser-runtime").then((module) => ({ default: module.BrowserRuntimeWorkspace, - })), -); -const AgentChatPage = lazy(() => + })); +const loadAgentChatPage = () => import("./agent/chat").then((module) => ({ default: module.AgentChatPage, - })), -); + })); + +const ResourcesPage = lazy(loadResourcesPage); +const MemoryPage = lazy(loadMemoryPage); +const PluginsPage = lazy(loadPluginsPage); +const OpenClawPage = lazy(loadOpenClawPage); +const SkillsWorkspacePage = lazy(loadSkillsWorkspacePage); +const BrowserRuntimeWorkspace = lazy(loadBrowserRuntimeWorkspace); +const AgentChatPage = lazy(loadAgentChatPage); interface AppPageContentProps { currentPage: Page; pageParams: PageParams; + requestedPage?: Page; + requestedPageParams?: PageParams; + navigationRequestId?: number; onNavigate: (page: Page, params?: PageParams) => void; onAgentHasMessagesChange: (hasMessages: boolean) => void; } @@ -95,22 +97,86 @@ interface AppPageContentProps { export function AppPageContent({ currentPage, pageParams, + requestedPage, + requestedPageParams, + navigationRequestId = 0, onNavigate, onAgentHasMessagesChange, }: AppPageContentProps) { - if (currentPage === "automation") { - return ( + const activePage = requestedPage ?? currentPage; + const activePageParams = requestedPageParams ?? pageParams; + const activeSceneAppsPageParams = + activePage === "sceneapps" + ? normalizeSceneAppsPageParams(activePageParams as SceneAppsPageParams) + : null; + const activeSceneAppsPageParamsKey = serializeSceneAppsPageParams( + activeSceneAppsPageParams ?? {}, + ); + const [hasVisitedSceneApps, setHasVisitedSceneApps] = useState( + activePage === "sceneapps", + ); + const [cachedSceneAppsPageParams, setCachedSceneAppsPageParams] = + useState(() => activeSceneAppsPageParams ?? {}); + + useEffect(() => { + if (activePage !== "sceneapps" || !activeSceneAppsPageParams) { + return; + } + + setHasVisitedSceneApps(true); + setCachedSceneAppsPageParams((current) => + serializeSceneAppsPageParams(current) === activeSceneAppsPageParamsKey + ? current + : activeSceneAppsPageParams, + ); + }, [ + activeSceneAppsPageParams, + activeSceneAppsPageParamsKey, + activePage, + ]); + + const shouldRenderSceneApps = + hasVisitedSceneApps || activePage === "sceneapps"; + const sceneAppsContent = shouldRenderSceneApps ? ( + + + + ) : null; + const wrapWithSceneApps = (content: ReactNode) => + sceneAppsContent ? ( + <> + {sceneAppsContent} + {content} + + ) : ( + content + ); + + if (activePage === "sceneapps") { + return wrapWithSceneApps(null); + } + + if (activePage === "automation") { + const content = (
); + + return wrapWithSceneApps(content); } - if (currentPage === "channels") { - return ( + if (activePage === "channels") { + const content = (
@@ -119,12 +185,14 @@ export function AppPageContent({
); + + return wrapWithSceneApps(content); } - if (currentPage === "agent") { - const agentPageParams = pageParams as AgentPageParams; + if (activePage === "agent") { + const agentPageParams = activePageParams as AgentPageParams; - return ( + const content = (
); + + return wrapWithSceneApps(content); } - if (currentPage === "resources") { - return ( + if (activePage === "resources") { + const content = (
); + + return wrapWithSceneApps(content); } - if (currentPage === "browser-runtime") { - const browserRuntimeParams = pageParams as BrowserRuntimePageParams; + if (activePage === "browser-runtime") { + const browserRuntimeParams = activePageParams as BrowserRuntimePageParams; - return ( + const content = ( ); + + return wrapWithSceneApps(content); } - if (currentPage === "plugins") { - return ( + if (activePage === "plugins") { + const content = ( ); + + return wrapWithSceneApps(content); } - if (currentPage === "memory") { - return ( + if (activePage === "memory") { + const content = (
); + + return wrapWithSceneApps(content); } - if (currentPage === "openclaw") { - return ( + if (activePage === "openclaw") { + const content = (
); + + return wrapWithSceneApps(content); } - if (currentPage === "skills") { - return ( + if (activePage === "skills") { + const content = (
); + + return wrapWithSceneApps(content); } - if (currentPage === "sceneapps") { - return ( -
- -
- ); - } - - if (currentPage === "settings") { - return ( + if (activePage === "settings") { + const content = (
); + + return wrapWithSceneApps(content); } return null; diff --git a/src/components/AppSidebar.test.tsx b/src/components/AppSidebar.test.tsx index f77b93238..87e3af4f8 100644 --- a/src/components/AppSidebar.test.tsx +++ b/src/components/AppSidebar.test.tsx @@ -1,3 +1,4 @@ +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"; @@ -25,6 +26,7 @@ interface MountedSidebar { const mountedSidebars: MountedSidebar[] = []; const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; +const APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY = "lime.app-sidebar.enabled-items"; function mountSidebar(options?: { currentPage?: Page; @@ -49,6 +51,52 @@ function mountSidebar(options?: { return container; } +function mountControlledSidebar(options?: { + currentPage?: Page; + currentPageParams?: PageParams; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const navigationCalls: Array<{ page: Page; params?: PageParams }> = []; + + function ControlledSidebar() { + const [currentPage, setCurrentPage] = React.useState( + options?.currentPage ?? "agent", + ); + const [currentPageParams, setCurrentPageParams] = React.useState( + options?.currentPageParams ?? {}, + ); + + const handleNavigate = React.useCallback( + (page: Page, params?: PageParams) => { + navigationCalls.push({ page, params }); + setCurrentPage(page); + setCurrentPageParams(params ?? {}); + }, + [], + ); + + return ( + + ); + } + + act(() => { + root.render(); + }); + + mountedSidebars.push({ container, root }); + return { + container, + navigationCalls, + }; +} + async function flushEffects() { await act(async () => { await Promise.resolve(); @@ -78,7 +126,7 @@ describe("AppSidebar", () => { vi.unstubAllGlobals(); }); - it("进入 Claw 任务中心时应自动折叠导航栏", async () => { + it("进入 Claw 生成页时应自动折叠导航栏", async () => { localStorage.setItem(APP_SIDEBAR_COLLAPSED_STORAGE_KEY, "false"); const container = mountSidebar({ @@ -152,7 +200,49 @@ describe("AppSidebar", () => { ).not.toBeNull(); }); - it("点击场景应用入口时应优先恢复最近一次 SceneApp 上下文", async () => { + it("点击当前已激活的创作场景入口时不应重复导航", async () => { + const onNavigate = vi.fn(); + const container = mountSidebar({ + currentPage: "sceneapps", + currentPageParams: { + sceneappId: "story-video-suite", + projectId: "project-9", + }, + onNavigate, + }); + await flushEffects(); + + const button = container.querySelector( + 'button[aria-label="创作场景"]', + ) as HTMLButtonElement | null; + + expect(button).not.toBeNull(); + expect(button?.getAttribute("aria-current")).toBe("page"); + + act(() => { + button?.click(); + }); + + expect(onNavigate).not.toHaveBeenCalled(); + }); + + it("侧边栏重新挂载前应先恢复缓存的可选入口,避免持续流程短暂消失", () => { + localStorage.setItem( + APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, + JSON.stringify(["automation"]), + ); + mockGetConfig.mockImplementation(() => new Promise(() => undefined)); + + const container = mountSidebar({ + currentPageParams: { + agentEntry: "new-task", + } as AgentPageParams, + }); + + expect(container.textContent).toContain("持续流程"); + }); + + it("点击创作场景入口时应优先恢复最近一次 SceneApp 上下文", async () => { const onNavigate = vi.fn(); recordSceneAppRecentVisit( { @@ -174,7 +264,7 @@ describe("AppSidebar", () => { await flushEffects(); const button = container.querySelector( - 'button[aria-label="场景应用"]', + 'button[aria-label="创作场景"]', ) as HTMLButtonElement | null; expect(button).not.toBeNull(); @@ -190,6 +280,78 @@ describe("AppSidebar", () => { }); }); + it("快速从持续流程切回创作场景时不应吞掉第二次导航", async () => { + localStorage.setItem( + APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, + JSON.stringify(["automation"]), + ); + mockGetConfig.mockResolvedValue({ + navigation: { + enabled_items: ["automation"], + }, + }); + recordSceneAppRecentVisit( + { + sceneappId: "story-video-suite", + projectId: "project-9", + runId: "run-2", + }, + { + visitedAt: 600, + }, + ); + + const { container, navigationCalls } = mountControlledSidebar({ + currentPage: "sceneapps", + currentPageParams: { + sceneappId: "story-video-suite", + projectId: "project-9", + runId: "run-2", + }, + }); + await flushEffects(); + + const automationButton = container.querySelector( + 'button[aria-label="持续流程"]', + ) as HTMLButtonElement | null; + const sceneAppsButton = container.querySelector( + 'button[aria-label="创作场景"]', + ) as HTMLButtonElement | null; + + expect(automationButton).not.toBeNull(); + expect(sceneAppsButton).not.toBeNull(); + + act(() => { + automationButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + sceneAppsButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + }); + await flushEffects(); + + expect(navigationCalls).toEqual([ + { + page: "automation", + params: undefined, + }, + { + page: "sceneapps", + params: { + sceneappId: "story-video-suite", + projectId: "project-9", + runId: "run-2", + }, + }, + ]); + expect( + container.querySelector( + 'button[aria-label="创作场景"][aria-current="page"]', + ), + ).not.toBeNull(); + }); + it("默认应隐藏系统区的可选入口,只保留设置", async () => { const container = mountSidebar({ currentPageParams: { diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index f044fcc02..3dda876f1 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,10 +1,10 @@ /** * 全局应用侧边栏 * - * 参考任务中心信息架构:任务、工作台、能力、资料库、系统入口分层展示 + * 参考生成主执行面信息架构:任务、工作台、能力、资料库、系统入口分层展示 */ -import { useState, useEffect, useMemo, type ReactElement } from "react"; +import { useState, useEffect, useMemo, useRef, type ReactElement } from "react"; import styled from "styled-components"; import { ChevronDown, @@ -45,6 +45,8 @@ import { scheduleMinimumDelayIdleTask } from "@/lib/utils/scheduleMinimumDelayId interface AppSidebarProps { currentPage: Page; currentPageParams?: PageParams; + requestedPage?: Page; + requestedPageParams?: PageParams; onNavigate: (page: Page, params?: PageParams) => void; } @@ -52,6 +54,7 @@ type SidebarNavItem = SidebarNavItemDefinition; type SidebarNavSection = SidebarNavSectionDefinition; const APP_SIDEBAR_COLLAPSED_STORAGE_KEY = "lime.app-sidebar.collapsed"; +const APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY = "lime.app-sidebar.enabled-items"; const SIDEBAR_PLUGIN_IDLE_TIMEOUT_MS = 1200; const SIDEBAR_PLUGIN_BROWSER_IDLE_TIMEOUT_MS = 6000; @@ -66,6 +69,89 @@ function scheduleSidebarPluginLoad(task: () => void): () => void { }); } +interface SidebarNavigationTarget { + page: Page; + rawParams?: PageParams; + paramsKey: string; +} + +function normalizeNavigationParams(params?: PageParams): PageParams { + return params ? { ...params } : {}; +} + +function serializeNavigationParams(params?: PageParams): string { + return JSON.stringify(normalizeNavigationParams(params)); +} + +function resolveSidebarNavigationTarget( + item: SidebarNavItem, +): SidebarNavigationTarget | null { + if (!item.page) { + return null; + } + + const rawParams = item.resolveParams ? item.resolveParams(item.params) : item.params; + + return { + page: item.page, + rawParams, + paramsKey: serializeNavigationParams(rawParams), + }; +} + +function isSameSidebarNavigationTarget( + target: SidebarNavigationTarget | null, + page: Page, + params?: PageParams, +): boolean { + if (!target) { + return false; + } + + return ( + target.page === page && target.paramsKey === serializeNavigationParams(params) + ); +} + +function readCachedEnabledNavItems(): string[] { + if (typeof window === "undefined") { + return [...DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS]; + } + + try { + const raw = window.localStorage.getItem( + APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, + ); + if (!raw) { + return [...DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS]; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return [...DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS]; + } + + return resolveEnabledSidebarNavItems(parsed); + } catch { + return [...DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS]; + } +} + +function persistEnabledNavItems(items: string[]): void { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + APP_SIDEBAR_ENABLED_ITEMS_STORAGE_KEY, + JSON.stringify(items), + ); + } catch { + // ignore + } +} + const Container = styled.aside<{ $collapsed?: boolean; $themeMode: "light" | "dark"; @@ -438,12 +524,24 @@ function getIconByName(iconName: string): LucideIcon { export function AppSidebar({ currentPage, currentPageParams, + requestedPage, + requestedPageParams, onNavigate, }: AppSidebarProps) { - const agentEntry = (currentPageParams as AgentPageParams | undefined) + const activePage = requestedPage ?? currentPage; + const activePageParams = requestedPageParams ?? currentPageParams; + const activeNavigationTarget = { + page: activePage, + rawParams: activePageParams, + paramsKey: serializeNavigationParams(activePageParams), + } satisfies SidebarNavigationTarget; + const requestedNavigationTargetRef = useRef({ + ...activeNavigationTarget, + }); + const agentEntry = (activePageParams as AgentPageParams | undefined) ?.agentEntry; - const isClawTaskCenter = currentPage === "agent" && agentEntry === "claw"; - const isNewTaskHome = currentPage === "agent" && agentEntry === "new-task"; + const isClawTaskCenter = activePage === "agent" && agentEntry === "claw"; + const isNewTaskHome = activePage === "agent" && agentEntry === "new-task"; const [collapsed, setCollapsed] = useState(() => { if (typeof window === "undefined") { return false; @@ -462,9 +560,8 @@ export function AppSidebar({ return "light"; }); - const [enabledNavItems, setEnabledNavItems] = useState( - DEFAULT_ENABLED_SIDEBAR_NAV_ITEM_IDS, - ); + const [enabledNavItems, setEnabledNavItems] = + useState(readCachedEnabledNavItems); const [sidebarPlugins, setSidebarPlugins] = useState([]); const [refreshTrigger, setRefreshTrigger] = useState(0); const [expandedGroupIds, setExpandedGroupIds] = useState(() => @@ -478,8 +575,11 @@ export function AppSidebar({ const loadNavConfig = async () => { try { const config = await getConfig(); - const saved = config.navigation?.enabled_items; - setEnabledNavItems(resolveEnabledSidebarNavItems(saved)); + const resolvedItems = resolveEnabledSidebarNavItems( + config.navigation?.enabled_items, + ); + setEnabledNavItems(resolvedItems); + persistEnabledNavItems(resolvedItems); } catch (error) { console.error("加载配置失败:", error); } @@ -580,6 +680,10 @@ export function AppSidebar({ localStorage.setItem("theme", theme); }, [theme]); + useEffect(() => { + requestedNavigationTargetRef.current = activeNavigationTarget; + }, [activeNavigationTarget]); + useEffect(() => { if (typeof window === "undefined") { return; @@ -626,12 +730,21 @@ export function AppSidebar({ } if (item.isActive) { - return item.isActive(currentPage, currentPageParams); + return item.isActive(activePage, activePageParams); } - return currentPage === item.page; + return activePage === item.page; }; + const activeLeafNavItemId = + [ + ...filteredMainSections.flatMap((section) => section.items), + ...assistantItems, + ...filteredFooterSections.flatMap((section) => section.items), + ].find( + (item) => (!item.children || item.children.length === 0) && isActive(item), + )?.id ?? null; + useEffect(() => { const isGroupActive = (item: SidebarNavItem): boolean => { if (item.children && item.children.length > 0) { @@ -643,10 +756,10 @@ export function AppSidebar({ } if (item.isActive) { - return item.isActive(currentPage, currentPageParams); + return item.isActive(activePage, activePageParams); } - return currentPage === item.page; + return activePage === item.page; }; const activeGroupIds = MAIN_SIDEBAR_NAV_ITEMS.filter( @@ -663,18 +776,38 @@ export function AppSidebar({ activeGroupIds.forEach((id) => next.add(id)); return Array.from(next); }); - }, [currentPage, currentPageParams]); + }, [activeLeafNavItemId, activePage, activePageParams]); const handleNavigate = (item: SidebarNavItem) => { - if (!item.page) { + const target = resolveSidebarNavigationTarget(item); + + if (!target) { return; } - const params: PageParams | undefined = item.resolveParams - ? item.resolveParams(item.params) - : item.params; + if ( + isActive(item) && + isSameSidebarNavigationTarget( + requestedNavigationTargetRef.current, + activeNavigationTarget.page, + activeNavigationTarget.rawParams, + ) + ) { + return; + } - onNavigate(item.page, params); + if ( + isSameSidebarNavigationTarget( + target, + requestedNavigationTargetRef.current.page, + requestedNavigationTargetRef.current.rawParams, + ) + ) { + return; + } + + requestedNavigationTargetRef.current = target; + onNavigate(target.page, target.rawParams); }; const toggleGroup = (groupId: string) => { diff --git a/src/components/HelpTip.tsx b/src/components/HelpTip.tsx index 89bfb1765..65d34171d 100644 --- a/src/components/HelpTip.tsx +++ b/src/components/HelpTip.tsx @@ -18,10 +18,10 @@ export function HelpTip({ const variantStyles = { blue: { - border: "border-blue-200 dark:border-blue-900", - bg: "bg-blue-50 dark:bg-blue-950/30", - title: "text-blue-800 dark:text-blue-300", - icon: "text-blue-600 dark:text-blue-400", + border: "border-emerald-200 dark:border-emerald-900", + bg: "bg-emerald-50 dark:bg-emerald-950/30", + title: "text-emerald-800 dark:text-emerald-300", + icon: "text-emerald-600 dark:text-emerald-400", }, amber: { border: "border-amber-200 dark:border-amber-900", diff --git a/src/components/agent/chat/AgentChatWorkspace.tsx b/src/components/agent/chat/AgentChatWorkspace.tsx index da7c2d3f5..2006d3b46 100644 --- a/src/components/agent/chat/AgentChatWorkspace.tsx +++ b/src/components/agent/chat/AgentChatWorkspace.tsx @@ -67,7 +67,17 @@ import { createImageGenerationTaskArtifact, getMediaTaskArtifact, } from "@/lib/api/mediaTasks"; -import { updateAgentRuntimeSession } from "@/lib/api/agentRuntime"; +import { + exportAgentRuntimeReviewDecisionTemplate, + saveAgentRuntimeReviewDecision, + updateAgentRuntimeSession, + type AgentRuntimeReviewDecisionTemplate, + type AgentRuntimeSaveReviewDecisionRequest, +} from "@/lib/api/agentRuntime"; +import { + prepareSceneAppRunGovernanceArtifact, + prepareSceneAppRunGovernanceArtifacts, +} from "@/lib/api/sceneapp"; import { getProjectMemory, type ProjectMemory, @@ -139,6 +149,7 @@ import { mergeMessageArtifactsIntoStore, readFirstString, } from "./workspace/browserAssistArtifact"; +import { SceneAppExecutionSummaryCard } from "./workspace/SceneAppExecutionSummaryCard"; import { ServiceSkillExecutionCard } from "./workspace/ServiceSkillExecutionCard"; import { useWorkspaceBrowserAssistRuntime } from "./workspace/useWorkspaceBrowserAssistRuntime"; import { useWorkspaceA2UISubmitActions } from "./workspace/useWorkspaceA2UISubmitActions"; @@ -156,6 +167,7 @@ import { useWorkspaceCanvasLayoutRuntime } from "./workspace/useWorkspaceCanvasL import { useWorkspaceCanvasTaskFileSync } from "./workspace/useWorkspaceCanvasTaskFileSync"; import { useWorkspaceGeneralResourceSync } from "./workspace/useWorkspaceGeneralResourceSync"; import { useWorkspaceArtifactWorkbenchActions } from "./workspace/useWorkspaceArtifactWorkbenchActions"; +import { useSceneAppExecutionSummaryRuntime } from "./workspace/useSceneAppExecutionSummaryRuntime"; import { useWorkspaceImageWorkbenchActionRuntime, type SubmitImageWorkbenchAgentCommandParams, @@ -192,6 +204,7 @@ import { useWorkspaceInitialSessionNavigation } from "./workspace/useWorkspaceIn import { WorkspaceGeneralWorkbenchSidebar } from "./workspace/WorkspaceGeneralWorkbenchSidebar"; import { GeneralWorkbenchHarnessDialogSection } from "./workspace/WorkspaceHarnessDialogs"; import { WorkspaceShellScene } from "./workspace/WorkspaceShellScene"; +import { RuntimeReviewDecisionDialog } from "./components/RuntimeReviewDecisionDialog"; import { TEAM_PRIMARY_CHAT_PANEL_MIN_WIDTH, TEAM_PRIMARY_CHAT_PANEL_WIDTH, @@ -237,8 +250,15 @@ import { buildSkillsPageParamsFromMessage } from "./utils/skillScaffoldDraft"; import { AutomationJobDialog } from "@/components/settings-v2/system/automation/AutomationJobDialog"; import { shouldAutoSelectGeneralArtifact } from "./workspace/generalArtifactAutoSelection"; import { + buildSceneAppExecutionSummaryRunDetailViewModel, + type SceneAppExecutionPromptAction, + buildSceneAppQuickReviewDecisionRequest, + formatSceneAppErrorMessage, hasSceneAppRecentVisit, + resolveSceneAppRunEntryNavigationTarget, + resolveSceneAppRuntimeArtifactOpenTarget, resolveSceneAppsPageEntryParams, + SCENEAPP_QUICK_REVIEW_ACTIONS, subscribeSceneAppRecentVisits, } from "@/lib/sceneapp"; @@ -363,6 +383,7 @@ export function AgentChatWorkspace({ projectId: externalProjectId, contentId, initialSessionId, + initialSceneAppExecutionSummary, initialRequestMetadata, initialAutoSendRequestMetadata, autoRunInitialPromptOnMount = false, @@ -1018,7 +1039,7 @@ export function AgentChatWorkspace({ }, [_onNavigate]); const handleOpenSceneAppsDirectory = useCallback(() => { if (!_onNavigate) { - toast.error("当前入口暂不支持跳转到 SceneApp 目录"); + toast.error("当前入口暂不支持跳转到创作场景目录"); return; } @@ -1037,7 +1058,7 @@ export function AgentChatWorkspace({ }, [_onNavigate, input, projectId]); const handleResumeRecentSceneApp = useCallback(() => { if (!_onNavigate) { - toast.error("当前入口暂不支持跳转到 SceneApp 目录"); + toast.error("当前入口暂不支持跳转到创作场景目录"); return; } @@ -3143,6 +3164,34 @@ export function AgentChatWorkspace({ input, webSearchPreferenceRef, ]); + const handleRunSceneAppExecutionPromptAction = useCallback( + async (action: SceneAppExecutionPromptAction) => { + const prompt = action.prompt.trim(); + if (!prompt) { + toast.error("当前动作缺少可发送内容。"); + return; + } + + if (isSending || queuedTurns.length > 0) { + toast.info("当前会话还有执行中的内容,请稍后再继续推进。"); + return; + } + + await handleSendRef.current( + [], + webSearchPreferenceRef.current, + effectiveChatToolPreferences.thinking, + prompt, + ); + }, + [ + effectiveChatToolPreferences.thinking, + handleSendRef, + isSending, + queuedTurns.length, + webSearchPreferenceRef, + ], + ); const handleRestartGeneralWorkbenchEntryPrompt = useCallback(() => { if (!generalWorkbenchEntryPrompt) { return; @@ -3795,6 +3844,476 @@ export function AgentChatWorkspace({ siteSkillExecutionState, ], ); + const sceneAppExecutionSummaryState = useSceneAppExecutionSummaryRuntime({ + initialSummary: initialSceneAppExecutionSummary, + sessionId, + isSending, + }); + const [sceneAppReviewDecisionDialogOpen, setSceneAppReviewDecisionDialogOpen] = + useState(false); + const [sceneAppReviewDecisionTemplate, setSceneAppReviewDecisionTemplate] = + useState(null); + const [sceneAppReviewDecisionLoading, setSceneAppReviewDecisionLoading] = + useState(false); + const [sceneAppReviewDecisionSaving, setSceneAppReviewDecisionSaving] = + useState(false); + const sceneAppReviewTargetRunSummary = + sceneAppExecutionSummaryState?.reviewTargetRunSummary ?? null; + const sceneAppReviewTargetSessionId = + sceneAppReviewTargetRunSummary?.sessionId?.trim() || ""; + const canOpenSceneAppExecutionHumanReview = + sceneAppReviewTargetSessionId.length > 0 && + Boolean( + sceneAppReviewTargetRunSummary && + ["success", "error", "canceled", "timeout"].includes( + sceneAppReviewTargetRunSummary.status, + ), + ); + const sceneAppExecutionFailureSignal = + sceneAppExecutionSummaryState?.latestPackResultDetailView + ?.failureSignalLabel ?? + sceneAppExecutionSummaryState?.summary?.runtimeBackflow + ?.topFailureSignalLabel; + const resolveSceneAppExecutionReviewDecisionTemplate = useCallback( + async () => { + if (!sceneAppReviewTargetSessionId) { + return null; + } + + if ( + sceneAppReviewDecisionTemplate?.session_id === + sceneAppReviewTargetSessionId + ) { + return sceneAppReviewDecisionTemplate; + } + + setSceneAppReviewDecisionLoading(true); + try { + const template = await exportAgentRuntimeReviewDecisionTemplate( + sceneAppReviewTargetSessionId, + ); + setSceneAppReviewDecisionTemplate(template); + return template; + } catch (error) { + toast.error(formatSceneAppErrorMessage(error)); + return null; + } finally { + setSceneAppReviewDecisionLoading(false); + } + }, + [sceneAppReviewDecisionTemplate, sceneAppReviewTargetSessionId], + ); + const persistSceneAppExecutionHumanReview = useCallback( + async ( + request: AgentRuntimeSaveReviewDecisionRequest, + options?: { + closeDialog?: boolean; + successMessage?: string; + }, + ) => { + setSceneAppReviewDecisionSaving(true); + try { + const template = await saveAgentRuntimeReviewDecision(request); + setSceneAppReviewDecisionTemplate(template); + if (options?.closeDialog !== false) { + setSceneAppReviewDecisionDialogOpen(false); + } + toast.success(options?.successMessage ?? "已保存人工复核结果"); + } catch (error) { + toast.error(formatSceneAppErrorMessage(error)); + } finally { + setSceneAppReviewDecisionSaving(false); + } + }, + [], + ); + const handleOpenSceneAppExecutionHumanReview = useCallback(() => { + if (!sceneAppReviewTargetSessionId) { + toast.error("当前运行还没有关联会话,暂时无法填写人工复核。"); + return; + } + + void (async () => { + const template = await resolveSceneAppExecutionReviewDecisionTemplate(); + if (template) { + setSceneAppReviewDecisionDialogOpen(true); + } + })(); + }, [ + resolveSceneAppExecutionReviewDecisionTemplate, + sceneAppReviewTargetSessionId, + ]); + const handleSaveSceneAppExecutionHumanReview = useCallback( + async (request: AgentRuntimeSaveReviewDecisionRequest) => { + await persistSceneAppExecutionHumanReview(request); + }, + [persistSceneAppExecutionHumanReview], + ); + const handleApplySceneAppExecutionQuickReview = useCallback( + (actionKey: (typeof SCENEAPP_QUICK_REVIEW_ACTIONS)[number]["key"]) => { + if (!canOpenSceneAppExecutionHumanReview || !sceneAppReviewTargetSessionId) { + toast.error("当前运行还没有关联会话,暂时无法记录轻量反馈。"); + return; + } + + const action = SCENEAPP_QUICK_REVIEW_ACTIONS.find( + (item) => item.key === actionKey, + ); + if (!action) { + return; + } + + void (async () => { + const template = + await resolveSceneAppExecutionReviewDecisionTemplate(); + if (!template) { + return; + } + + await persistSceneAppExecutionHumanReview( + buildSceneAppQuickReviewDecisionRequest({ + template, + action, + sceneTitle: sceneAppExecutionSummaryState?.summary?.title, + failureSignal: sceneAppExecutionFailureSignal, + sourceLabel: "生成主执行面", + }), + { + closeDialog: false, + successMessage: `已记录「${action.label}」判断`, + }, + ); + })(); + }, + [ + canOpenSceneAppExecutionHumanReview, + persistSceneAppExecutionHumanReview, + resolveSceneAppExecutionReviewDecisionTemplate, + sceneAppExecutionFailureSignal, + sceneAppExecutionSummaryState?.summary?.title, + sceneAppReviewTargetSessionId, + ], + ); + const handleOpenSceneAppExecutionDetail = useCallback(() => { + const sceneappId = sceneAppExecutionSummaryState?.summary?.sceneappId?.trim(); + if (!_onNavigate || !sceneappId) { + return; + } + + _onNavigate( + "sceneapps", + resolveSceneAppsPageEntryParams( + { + view: "detail", + sceneappId, + projectId: projectId || undefined, + }, + { + mode: "browse", + }, + ), + ); + }, [_onNavigate, projectId, sceneAppExecutionSummaryState?.summary?.sceneappId]); + const handleOpenSceneAppExecutionGovernance = useCallback(() => { + const sceneappId = sceneAppExecutionSummaryState?.summary?.sceneappId?.trim(); + if (!_onNavigate || !sceneappId) { + return; + } + + _onNavigate( + "sceneapps", + resolveSceneAppsPageEntryParams( + { + view: "governance", + sceneappId, + runId: + sceneAppExecutionSummaryState?.latestPackResultDetailView?.runId || + sceneAppExecutionSummaryState?.summary?.runtimeBackflow?.runId || + undefined, + projectId: projectId || undefined, + }, + { + mode: "browse", + }, + ), + ); + }, [ + _onNavigate, + projectId, + sceneAppExecutionSummaryState?.latestPackResultDetailView?.runId, + sceneAppExecutionSummaryState?.summary?.runtimeBackflow?.runId, + sceneAppExecutionSummaryState?.summary?.sceneappId, + ]); + const handleOpenSceneAppExecutionDeliveryArtifact = useCallback( + ( + artifactEntry?: NonNullable< + NonNullable< + typeof sceneAppExecutionSummaryState + >["latestPackResultDetailView"] + >["deliveryArtifactEntries"][number], + ) => { + if (!_onNavigate) { + return; + } + + const target = resolveSceneAppRuntimeArtifactOpenTarget({ + entry: artifactEntry, + fallbackProjectId: projectId, + bannerPrefix: "已从生成主执行面打开结果文件", + }); + if (!target) { + toast.error("当前这次运行还没有可打开的结果文件路径。"); + return; + } + + _onNavigate("agent", { + agentEntry: "claw", + projectId: target.projectId, + initialProjectFileOpenTarget: { + relativePath: target.openTargetPath, + requestKey: Date.now(), + }, + entryBannerMessage: target.bannerMessage, + }); + }, + [_onNavigate, projectId], + ); + const handleOpenSceneAppExecutionGovernanceArtifact = useCallback( + ( + artifactEntry?: NonNullable< + NonNullable< + typeof sceneAppExecutionSummaryState + >["latestPackResultDetailView"] + >["governanceArtifactEntries"][number], + ) => { + if (!_onNavigate || !artifactEntry) { + return; + } + + const runId = + sceneAppExecutionSummaryState?.latestPackResultDetailView?.runId?.trim() || + sceneAppReviewTargetRunSummary?.runId?.trim() || + ""; + + void (async () => { + let resolvedEntry = artifactEntry; + if (runId && sceneAppExecutionSummaryState?.summary) { + try { + const refreshed = await prepareSceneAppRunGovernanceArtifact( + runId, + artifactEntry.artifactRef.kind, + ); + if (!refreshed) { + toast.error("当前运行已不存在,无法继续准备治理文件。"); + return; + } + + const refreshedDetailView = + buildSceneAppExecutionSummaryRunDetailViewModel({ + summary: sceneAppExecutionSummaryState.summary, + run: refreshed, + }); + resolvedEntry = + refreshedDetailView.governanceArtifactEntries.find( + (entry) => entry.artifactRef.kind === artifactEntry.artifactRef.kind, + ) ?? artifactEntry; + } catch (error) { + toast.error(formatSceneAppErrorMessage(error)); + return; + } + } + + const target = resolveSceneAppRuntimeArtifactOpenTarget({ + entry: resolvedEntry, + fallbackProjectId: projectId, + bannerPrefix: "已从生成主执行面打开治理文件", + }); + if (!target) { + toast.error("当前这次运行还没有可打开的证据或复核文件。"); + return; + } + + _onNavigate("agent", { + agentEntry: "claw", + projectId: target.projectId, + initialProjectFileOpenTarget: { + relativePath: target.openTargetPath, + requestKey: Date.now(), + }, + entryBannerMessage: target.bannerMessage, + }); + })(); + }, + [ + _onNavigate, + projectId, + sceneAppExecutionSummaryState?.latestPackResultDetailView?.runId, + sceneAppExecutionSummaryState?.summary, + sceneAppReviewTargetRunSummary?.runId, + ], + ); + const handleRunSceneAppExecutionGovernanceAction = useCallback( + ( + action?: NonNullable< + NonNullable< + typeof sceneAppExecutionSummaryState + >["latestPackResultDetailView"] + >["governanceActionEntries"][number], + ) => { + if ( + !_onNavigate || + !action || + !sceneAppExecutionSummaryState?.summary + ) { + return; + } + + const runId = + sceneAppExecutionSummaryState.latestPackResultDetailView?.runId?.trim() || + sceneAppReviewTargetRunSummary?.runId?.trim() || + ""; + if (!runId) { + toast.error("当前还没有可用于治理动作的运行样本。"); + return; + } + + const sceneAppExecutionSummary = sceneAppExecutionSummaryState.summary; + + void (async () => { + try { + const refreshed = await prepareSceneAppRunGovernanceArtifacts( + runId, + action.artifactKinds, + ); + if (!refreshed) { + toast.error("当前运行已不存在,无法继续准备治理动作。"); + return; + } + + const refreshedDetailView = + buildSceneAppExecutionSummaryRunDetailViewModel({ + summary: sceneAppExecutionSummary, + run: refreshed, + }); + const targetEntry = + refreshedDetailView.governanceArtifactEntries.find( + (entry) => entry.artifactRef.kind === action.primaryArtifactKind, + ); + const target = resolveSceneAppRuntimeArtifactOpenTarget({ + entry: targetEntry, + fallbackProjectId: projectId, + bannerPrefix: "已从生成主执行面打开治理动作", + }); + if (!target) { + toast.error( + `治理动作已准备完成,但当前没有可打开的${action.primaryArtifactLabel}路径。`, + ); + return; + } + + _onNavigate("agent", { + agentEntry: "claw", + projectId: target.projectId, + initialProjectFileOpenTarget: { + relativePath: target.openTargetPath, + requestKey: Date.now(), + }, + entryBannerMessage: target.bannerMessage, + }); + } catch (error) { + toast.error(formatSceneAppErrorMessage(error)); + } + })(); + }, + [ + _onNavigate, + projectId, + sceneAppExecutionSummaryState, + sceneAppReviewTargetRunSummary?.runId, + ], + ); + const handleOpenSceneAppExecutionEntryAction = useCallback( + ( + action?: NonNullable< + NonNullable< + typeof sceneAppExecutionSummaryState + >["latestPackResultDetailView"] + >["entryAction"], + ) => { + if (!_onNavigate || !action || !sceneAppExecutionSummaryState?.summary) { + return; + } + + const target = resolveSceneAppRunEntryNavigationTarget({ + action, + sceneappId: sceneAppExecutionSummaryState.summary.sceneappId, + sceneTitle: sceneAppExecutionSummaryState.summary.title, + sourceLabel: "生成主执行面", + projectId, + linkedServiceSkillId: + sceneAppExecutionSummaryState.summary.descriptorSnapshot + ?.linkedServiceSkillId, + linkedSceneKey: + sceneAppExecutionSummaryState.summary.descriptorSnapshot?.linkedSceneKey, + }); + if (!target) { + toast.error("当前运行缺少可恢复的入口上下文。"); + return; + } + + _onNavigate(target.page, target.params); + }, + [_onNavigate, projectId, sceneAppExecutionSummaryState?.summary], + ); + const sceneAppExecutionSummaryCard = useMemo( + () => ( + 0} + onPromptAction={handleRunSceneAppExecutionPromptAction} + /> + ), + [ + canOpenSceneAppExecutionHumanReview, + handleApplySceneAppExecutionQuickReview, + handleOpenSceneAppExecutionDetail, + handleOpenSceneAppExecutionHumanReview, + handleOpenSceneAppExecutionGovernance, + handleOpenSceneAppExecutionDeliveryArtifact, + handleOpenSceneAppExecutionEntryAction, + handleOpenSceneAppExecutionGovernanceArtifact, + handleRunSceneAppExecutionPromptAction, + handleRunSceneAppExecutionGovernanceAction, + isSending, + queuedTurns.length, + sceneAppReviewDecisionLoading, + sceneAppReviewDecisionSaving, + sceneAppExecutionSummaryState, + ], + ); const handleJumpToTimelineItem = useCallback((itemId: string) => { const normalizedItemId = itemId.trim(); if (!normalizedItemId) { @@ -4550,6 +5069,7 @@ export function AgentChatWorkspace({ handleToggleHarnessPanel: contextHarnessRuntime.handleToggleHarnessPanel, entryBannerVisible, entryBannerMessage, + sceneAppExecutionSummaryCard, serviceSkillExecutionCard, contextWorkspaceEnabled: contextWorkspace.generalWorkbenchEnabled, input, @@ -4734,6 +5254,13 @@ export function AgentChatWorkspace({ } onSubmit={workspaceSceneAppEntryActions.handleAutomationDialogSubmit} /> + ); } diff --git a/src/components/agent/chat/README.md b/src/components/agent/chat/README.md index 42797af0f..aceb2f1a2 100644 --- a/src/components/agent/chat/README.md +++ b/src/components/agent/chat/README.md @@ -5,7 +5,7 @@ ## 模块说明 -AI Agent 聊天页面,承接新建任务与任务中心两种入口。当前工作台主题已收口为统一的 `general` 主链,并集成布局过渡、画布工作台与能力面板。 +AI Agent 聊天页面,承接新建任务与生成两种入口。当前工作台主题已收口为统一的 `general` 主链,并集成布局过渡、画布工作台与能力面板。 ## 文件索引 diff --git a/src/components/agent/chat/agentChatWorkspaceContract.ts b/src/components/agent/chat/agentChatWorkspaceContract.ts index faacc923f..40a627672 100644 --- a/src/components/agent/chat/agentChatWorkspaceContract.ts +++ b/src/components/agent/chat/agentChatWorkspaceContract.ts @@ -7,6 +7,7 @@ import type { AgentProjectFileOpenTarget, AgentSiteSkillLaunchParams, } from "@/types/page"; +import type { SceneAppExecutionSummaryViewModel } from "@/lib/sceneapp/product"; export interface WorkflowProgressSnapshot { steps: Array<{ @@ -22,6 +23,7 @@ export interface AgentChatWorkspaceProps { projectId?: string; contentId?: string; initialSessionId?: string; + initialSceneAppExecutionSummary?: SceneAppExecutionSummaryViewModel; initialRequestMetadata?: Record; initialAutoSendRequestMetadata?: Record; autoRunInitialPromptOnMount?: boolean; diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx index e00cc4994..49dc48a70 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.test.tsx @@ -1034,7 +1034,7 @@ describe("AgentThreadReliabilityPanel", () => { ); }); - it("应支持从线程可靠性面板跳到记忆工作台查看当前预演", async () => { + it("应支持从线程可靠性面板跳到灵感库查看当前预演", async () => { const onOpenMemoryWorkbench = vi.fn(); const container = renderPanel({ turns: [ @@ -1062,7 +1062,7 @@ describe("AgentThreadReliabilityPanel", () => { }); const button = Array.from(container.querySelectorAll("button")).find( - (element) => element.textContent?.includes("在记忆工作台查看"), + (element) => element.textContent?.includes("在灵感库查看"), ); expect(button).toBeTruthy(); diff --git a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx index 13477ee56..381f087e3 100644 --- a/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx +++ b/src/components/agent/chat/components/AgentThreadReliabilityPanel.tsx @@ -445,7 +445,7 @@ function buildMemoryPrefetchDiagnosticLines( function resolveMemoryPrefetchHistorySourceLabel( source: RuntimeMemoryPrefetchHistoryEntry["source"], ): string { - return source === "thread_reliability" ? "线程面板" : "记忆工作台"; + return source === "thread_reliability" ? "线程面板" : "灵感库"; } function resolveMemoryPrefetchPreviewChangeLabel( @@ -1623,7 +1623,7 @@ export const AgentThreadReliabilityPanel: React.FC< onClick={onOpenMemoryWorkbench} className="h-8 rounded-full border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50" > - 在记忆工作台查看 + 在灵感库查看 ) : undefined } diff --git a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx index 73e7d4398..2e7db56e7 100644 --- a/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx +++ b/src/components/agent/chat/components/CanvasWorkbenchLayout.tsx @@ -1684,7 +1684,7 @@ export const CanvasWorkbenchLayout = memo(function CanvasWorkbenchLayout({ {documentTitle}
{currentVersionLabel ? ( - + {currentVersionLabel} ) : null} diff --git a/src/components/agent/chat/components/ChatNavbar.test.tsx b/src/components/agent/chat/components/ChatNavbar.test.tsx index e64a1be73..2bfe3dc36 100644 --- a/src/components/agent/chat/components/ChatNavbar.test.tsx +++ b/src/components/agent/chat/components/ChatNavbar.test.tsx @@ -145,28 +145,26 @@ describe("ChatNavbar", () => { ).toBeNull(); }); - it("任务中心顶栏应展示轻量上下文提示", () => { + it("生成顶栏应展示轻量上下文提示", () => { const container = renderChatNavbar({ - entryContextLabel: "任务中心", - entryContextHint: "回到进行中的任务、旧历史和最近工作现场。", + entryContextLabel: "生成", + entryContextHint: "在这里继续推进当前创作、回看最近结果和旧历史。", }); - expect(container.textContent).toContain("任务中心"); - expect(container.textContent).toContain( - "回到进行中的任务、旧历史和最近工作现场。", - ); + expect(container.textContent).toContain("生成"); + expect(container.textContent).toContain("在这里继续推进当前创作、回看最近结果和旧历史。"); }); - it("紧凑顶栏应只保留任务中心标签,不额外展开说明", () => { + it("紧凑顶栏应只保留生成标签,不额外展开说明", () => { const container = renderChatNavbar({ chrome: "workspace-compact", - entryContextLabel: "任务中心", - entryContextHint: "回到进行中的任务、旧历史和最近工作现场。", + entryContextLabel: "生成", + entryContextHint: "在这里继续推进当前创作、回看最近结果和旧历史。", }); - expect(container.textContent).toContain("任务中心"); + expect(container.textContent).toContain("生成"); expect(container.textContent).not.toContain( - "回到进行中的任务、旧历史和最近工作现场。", + "在这里继续推进当前创作、回看最近结果和旧历史。", ); }); diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index d264aa06f..c024ebfe6 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -329,7 +329,7 @@ export const ChatNavbar: React.FC = ({ {harnessToggleLabel} {harnessPendingCount > 0 ? ( - + {harnessPendingCount > 99 ? "99+" : harnessPendingCount} ) : null} diff --git a/src/components/agent/chat/components/ChatSidebar.test.tsx b/src/components/agent/chat/components/ChatSidebar.test.tsx index 9a6994503..07ec043d0 100644 --- a/src/components/agent/chat/components/ChatSidebar.test.tsx +++ b/src/components/agent/chat/components/ChatSidebar.test.tsx @@ -144,24 +144,24 @@ describe("ChatSidebar", () => { expect(container.textContent).toContain("任务一"); }); - it("任务中心侧栏空态应展示回访型文案", () => { + it("生成侧栏空态应展示执行面文案", () => { const container = renderSidebar({ contextVariant: "task-center", topics: [], currentTopicId: 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( - "从“新建任务”开始也很自然,创建后会在这里继续回访。", + "从“新建任务”开始也很自然,开始后会回到这里继续生成。", ); }); - it("任务中心侧栏应在顶部展示继续上次任务与最近结果入口", async () => { + it("生成侧栏应在顶部展示继续上次生成与最近结果入口", async () => { mockGetProject.mockImplementation(async (projectId: string) => { if (projectId === "project-waiting") { return { @@ -219,9 +219,9 @@ describe("ChatSidebar", () => { '[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("品牌项目"); @@ -251,7 +251,7 @@ describe("ChatSidebar", () => { expect(onSwitchTopic).toHaveBeenCalledWith("topic-recent"); }); - it("任务中心侧栏应使用回访型任务分组标题", () => { + it("生成侧栏应使用回访型任务分组标题", () => { const now = Date.now(); const container = renderSidebar({ contextVariant: "task-center", diff --git a/src/components/agent/chat/components/ChatSidebar.tsx b/src/components/agent/chat/components/ChatSidebar.tsx index 831ed2dd7..3e6934237 100644 --- a/src/components/agent/chat/components/ChatSidebar.tsx +++ b/src/components/agent/chat/components/ChatSidebar.tsx @@ -46,6 +46,12 @@ const TEAM_SECTION_INITIAL_SIBLING_COUNT = 2; const TEAM_SECTION_LABEL = "子任务"; const PINNED_TASK_IDS_STORAGE_KEY = "lime_task_sidebar_pinned_ids"; +const CHAT_SIDEBAR_PRIMARY_ACTION_BUTTON_CLASSNAME = + "inline-flex h-11 w-full items-center justify-center gap-2 rounded-[18px] border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-4 text-sm font-semibold text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-100"; + +const CHAT_SIDEBAR_ACTIVE_FILTER_CLASSNAME = + "border-emerald-200 bg-[linear-gradient(135deg,rgba(240,253,250,0.98)_0%,rgba(236,253,245,0.96)_52%,rgba(224,242,254,0.95)_100%)] text-slate-800 shadow-sm shadow-emerald-950/10 dark:border-white dark:bg-white dark:text-slate-900"; + const STATUS_META: Record< TaskStatus, { @@ -240,7 +246,7 @@ function resolveTaskCenterContinuationBadge( if (item.status === "running") { return "正在推进"; } - return "工作现场"; + return "生成现场"; } function resolveTaskCenterContinuationActionLabel( @@ -797,19 +803,23 @@ export const ChatSidebar: React.FC = ({ const hasAnyTasks = topics.length > 0; const hasFilteredResults = filteredTaskItems.length > 0; const taskHeadingLabel = - contextVariant === "task-center" ? "工作现场" : "任务"; + contextVariant === "task-center" ? "生成现场" : "任务"; const taskHeadingHint = contextVariant === "task-center" - ? "回到进行中的任务、旧历史和最近工作现场。" + ? "在这里继续推进当前创作、回看最近结果和旧历史。" : null; const emptyStateTitle = - contextVariant === "task-center" ? "还没有进行中的任务" : "还没有任务"; + contextVariant === "task-center" + ? "还没有进行中的生成任务" + : "还没有任务"; const emptyStateDescription = contextVariant === "task-center" - ? "从“新建任务”开始也很自然,创建后会在这里继续回访。" + ? "从“新建任务”开始也很自然,开始后会回到这里继续生成。" : "从“新建任务”开始输入需求,创建后会出现在这里。"; const olderSectionMoreLabel = - contextVariant === "task-center" ? "查看更多旧历史" : "查看更多历史任务"; + contextVariant === "task-center" + ? "查看更多旧生成历史" + : "查看更多历史任务"; useEffect(() => { if (editingTopicId && editInputRef.current) { @@ -1071,7 +1081,7 @@ export const ChatSidebar: React.FC = ({ diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarAccessModeSelect.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarAccessModeSelect.tsx index 90f4b72d0..497ac62ce 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarAccessModeSelect.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarAccessModeSelect.tsx @@ -1,7 +1,10 @@ import React from "react"; import { ShieldCheck } from "lucide-react"; import { MetaSelect, MetaSelectIcon, MetaSelectWrap } from "../styles"; -import type { AgentAccessMode } from "../../../hooks/agentChatStorage"; +import { + DEFAULT_AGENT_ACCESS_MODE, + type AgentAccessMode, +} from "../../../hooks/agentChatStorage"; interface InputbarAccessModeSelectProps { isFullscreen?: boolean; @@ -20,7 +23,11 @@ const ACCESS_MODE_OPTIONS: Array<{ export const InputbarAccessModeSelect: React.FC< InputbarAccessModeSelectProps -> = ({ isFullscreen = false, accessMode = "current", setAccessMode }) => { +> = ({ + isFullscreen = false, + accessMode = DEFAULT_AGENT_ACCESS_MODE, + setAccessMode, +}) => { if (isFullscreen || !setAccessMode) { return null; } diff --git a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx index 567b6d14b..8d31acc5e 100644 --- a/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx +++ b/src/components/agent/chat/components/Inputbar/components/InputbarComposerSection.tsx @@ -352,7 +352,7 @@ export const InputbarComposerSection: React.FC< ? workflowGate?.status === "waiting" ? "说说你的选择,剩下的交给我" : contextVariant === "task-center" - ? "继续补充当前任务,或回到左侧继续旧历史" + ? "继续补充当前生成任务,或回到左侧继续旧历史" : "试着输入任何指令,剩下的交给我" : undefined } diff --git a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx index 4241cc615..c4f78b420 100644 --- a/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx +++ b/src/components/agent/chat/components/Inputbar/components/TeamSelectorPanel.tsx @@ -48,6 +48,9 @@ interface TeamSelectorPanelProps { onClose?: () => void; } +const TEAM_SELECTOR_PRIMARY_BUTTON_CLASSNAME = + "border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] text-white shadow-sm shadow-emerald-950/15 hover:opacity-95"; + interface TeamDraft { id?: string; label: string; @@ -568,7 +571,7 @@ export const TeamSelectorPanel: React.FC = ({ {!isCurrent ? (
@@ -1394,13 +1399,13 @@ const MessageListInner: React.FC = ({

- 任务中心 + 生成

- 回到进行中的任务、最近结果和已经跑过的工作现场。 + 这里是 Lime 的生成主执行面,可以继续进行中的创作、回看最近结果和旧历史。

- 还没有进行中的任务时,从新建任务开始也很自然;跑过的结果和做法后面都会继续留在这里。 + 还没有生成任务时,从新建任务开始也很自然;跑过的结果、做法和中间素材后面都会继续留在这里。

@@ -1408,13 +1413,13 @@ const MessageListInner: React.FC = ({
- 左侧会继续显示继续中的任务 + 左侧会继续显示正在推进的生成任务 最近结果会继续在这里回访 - 常用做法和恢复中的会话会自动回到这里 + 常用做法和恢复中的会话会自动回到这里继续生成
diff --git a/src/components/agent/chat/components/TeamSuggestionBar.tsx b/src/components/agent/chat/components/TeamSuggestionBar.tsx index c96c5b1c3..23968f30b 100644 --- a/src/components/agent/chat/components/TeamSuggestionBar.tsx +++ b/src/components/agent/chat/components/TeamSuggestionBar.tsx @@ -17,6 +17,9 @@ interface TeamSuggestionBarProps { compact?: boolean; } +const TEAM_SUGGESTION_PRIMARY_BUTTON_CLASSNAME = + "h-8 rounded-full border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-3 text-white shadow-sm shadow-emerald-950/15 hover:opacity-95"; + export function TeamSuggestionBar({ score, reasons, @@ -137,7 +140,7 @@ export function TeamSuggestionBar({ + ) : null} + {onOpenSceneAppGovernance ? ( + + ) : null} + {humanReviewAvailable ? ( + + ) : null} + + {latestPackResultUsesFallback ? ( +

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

+ ) : null} + {humanReviewAvailable && quickReviewActions.length ? ( +
+
+ 轻量判断 +
+
+ {quickReviewActions.map((action) => ( + + ))} +
+
+ ) : null} + + {latestPackResultDetailView && + (followupDestinations.length || + promptActions.length || + latestPackResultDetailView.governanceActionEntries.length || + latestPackResultDetailView.governanceArtifactEntries.length || + latestPackResultDetailView.entryAction) ? ( +
+
+ 生成后动作编排 +
+

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

+ + {followupDestinations.length ? ( +
+ {followupDestinations.map((item) => ( +
+
+ {item.label} +
+
+ {item.description} +
+
+ ))} +
+ ) : null} + + {latestPackResultDetailView.governanceActionEntries.length || + promptActions.length || + latestPackResultDetailView.entryAction ? ( +
+
+ 推荐动作 +
+
+ {latestPackResultDetailView.governanceActionEntries.map( + (entry) => ( + + ), + )} + + {latestPackResultDetailView.entryAction ? ( + + ) : null} +
+
+ ) : null} + + {promptActions.length ? ( +
+
+ 同聊推进 +
+
+ {promptActions.map((action) => { + const disabled = + promptActionPending || Boolean(action.disabledReason); + + return ( + + ); + })} +
+
+ ) : null} + + {latestPackResultDetailView.governanceArtifactEntries.length ? ( +
+
+ 基础治理材料 +
+
+ {latestPackResultDetailView.governanceArtifactEntries.map( + (entry) => ( + + ), + )} +
+
+ ) : null} +
+ ) : null} + + ) : null} + + {summary.notes.length ? ( +
+
规划备注
+
+ {summary.notes.map((note) => ( + + {note} + + ))} +
+
+ ) : null} + + + ); +} + +export default SceneAppExecutionSummaryCard; diff --git a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx index 14adb7edb..031ae36a3 100644 --- a/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx +++ b/src/components/agent/chat/workspace/WorkspaceConversationScene.tsx @@ -54,6 +54,7 @@ interface WorkspaceChatContentParams { entryBannerVisible: boolean; entryBannerMessage?: string; onDismissEntryBanner: () => void; + sceneAppExecutionSummaryCard?: ReactNode; serviceSkillExecutionCard?: ReactNode; stepProgressProps?: StepProgressProps | null; showChatLayout: boolean; @@ -103,6 +104,7 @@ function renderWorkspaceChatContent({ entryBannerVisible, entryBannerMessage, onDismissEntryBanner, + sceneAppExecutionSummaryCard, serviceSkillExecutionCard, stepProgressProps, showChatLayout, @@ -121,9 +123,21 @@ function renderWorkspaceChatContent({ showInlineInputbar, inputbarNode, }: WorkspaceChatContentParams): ReactNode { + const leadingMessageContent = + sceneAppExecutionSummaryCard || + stepProgressProps || + serviceSkillExecutionCard ? ( + <> + {sceneAppExecutionSummaryCard} + {stepProgressProps ? : null} + {serviceSkillExecutionCard} + + ) : null; + const messageListNode = ( ); @@ -145,9 +159,6 @@ function renderWorkspaceChatContent({ ) : null} - {stepProgressProps ? : null} - {serviceSkillExecutionCard} - {showChatLayout ? ( <> @@ -212,6 +223,7 @@ interface WorkspaceConversationSceneProps extends WorkspaceMainAreaProps { entryBannerVisible: boolean; entryBannerMessage?: string; onDismissEntryBanner: () => void; + sceneAppExecutionSummaryCard?: WorkspaceChatContentParams["sceneAppExecutionSummaryCard"]; serviceSkillExecutionCard?: WorkspaceChatContentParams["serviceSkillExecutionCard"]; stepProgressProps?: WorkspaceChatContentParams["stepProgressProps"]; showChatLayout: boolean; @@ -362,6 +374,7 @@ export function WorkspaceConversationScene({ entryBannerVisible, entryBannerMessage, onDismissEntryBanner, + sceneAppExecutionSummaryCard, serviceSkillExecutionCard, stepProgressProps, showChatLayout, @@ -534,6 +547,7 @@ export function WorkspaceConversationScene({ entryBannerVisible, entryBannerMessage, onDismissEntryBanner, + sceneAppExecutionSummaryCard, serviceSkillExecutionCard, stepProgressProps, showChatLayout, diff --git a/src/components/agent/chat/workspace/artifactWorkbenchDocument.tsx b/src/components/agent/chat/workspace/artifactWorkbenchDocument.tsx index b3830bb6d..290568a09 100644 --- a/src/components/agent/chat/workspace/artifactWorkbenchDocument.tsx +++ b/src/components/agent/chat/workspace/artifactWorkbenchDocument.tsx @@ -85,6 +85,15 @@ interface ArtifactRecoveryPresentation { badgeLabel: string; } +const ARTIFACT_PRIMARY_ACTION_BUTTON_CLASSNAME = + "inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-4 py-2 text-sm font-medium text-white shadow-sm shadow-emerald-950/15 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"; + +const ARTIFACT_ACTIVE_SURFACE_CLASSNAME = + "border-emerald-200 bg-[linear-gradient(135deg,rgba(240,253,250,0.98)_0%,rgba(236,253,245,0.96)_52%,rgba(224,242,254,0.95)_100%)] text-slate-800 shadow-sm shadow-emerald-950/10"; + +const ARTIFACT_ACTIVE_BADGE_CLASSNAME = + "border border-emerald-200 bg-white/90 text-emerald-700 shadow-sm shadow-emerald-950/10"; + export interface ArtifactWorkbenchDocumentController { artifact: Artifact; document: ArtifactDocumentV1 | null; @@ -944,7 +953,7 @@ const OverviewPanel: React.FC<{ ); diff --git a/src/components/layout/CrashRecoveryPanel.tsx b/src/components/layout/CrashRecoveryPanel.tsx index 7f987acf8..4976fc44d 100644 --- a/src/components/layout/CrashRecoveryPanel.tsx +++ b/src/components/layout/CrashRecoveryPanel.tsx @@ -365,7 +365,7 @@ export function CrashRecoveryPanel({ onClick={() => void handleSelectNewDirectory()} disabled={busy} className={cn( - "inline-flex items-center gap-1 rounded-md border border-blue-400 bg-blue-50 px-3 py-1.5 text-xs text-blue-700 transition-colors dark:border-blue-700 dark:bg-blue-950/30 dark:text-blue-300", + "inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs text-emerald-700 transition-colors dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300", busy && "cursor-not-allowed opacity-50", )} > @@ -436,7 +436,7 @@ export function CrashRecoveryPanel({ onClick={handleForceResourceReload} disabled={busy} className={cn( - "inline-flex items-center gap-1 rounded-md border border-blue-400 bg-blue-50 px-3 py-1.5 text-xs text-blue-700 transition-colors dark:border-blue-700 dark:bg-blue-950/30 dark:text-blue-300", + "inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-3 py-1.5 text-xs text-emerald-700 transition-colors dark:border-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300", busy && "cursor-not-allowed opacity-50", )} > diff --git a/src/components/mcp/McpPage.tsx b/src/components/mcp/McpPage.tsx index 245f34075..9ca4f6476 100644 --- a/src/components/mcp/McpPage.tsx +++ b/src/components/mcp/McpPage.tsx @@ -59,6 +59,9 @@ const defaultServerConfig = JSON.stringify( 2, ); +const mcpPrimaryActionButtonClassName = + "rounded-lg border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-3 py-1.5 text-sm text-white shadow-sm shadow-emerald-950/15 hover:opacity-95"; + interface McpPageProps { hideHeader?: boolean; } @@ -297,7 +300,7 @@ export function McpPage({ hideHeader = false }: McpPageProps) { {/* 同步到外部按钮 */} diff --git a/src/components/mcp/McpPanel.tsx b/src/components/mcp/McpPanel.tsx index 4e968b36c..77015db16 100644 --- a/src/components/mcp/McpPanel.tsx +++ b/src/components/mcp/McpPanel.tsx @@ -105,12 +105,12 @@ export function McpPanel({ hideHeader = false }: McpPanelProps) { {tab.label} {/* 数量标记 */} {tab.id === "tools" && tools.length > 0 && ( - + {tools.length} )} {tab.id === "prompts" && prompts.length > 0 && ( - + {prompts.length} )} diff --git a/src/components/mcp/McpPromptsBrowser.tsx b/src/components/mcp/McpPromptsBrowser.tsx index 979a23f6b..f58217c69 100644 --- a/src/components/mcp/McpPromptsBrowser.tsx +++ b/src/components/mcp/McpPromptsBrowser.tsx @@ -186,8 +186,8 @@ export function McpPromptsBrowser({
- - + + {prompt.name}
@@ -273,7 +273,7 @@ export function McpPromptsBrowser({ diff --git a/src/components/mcp/McpServerList.tsx b/src/components/mcp/McpServerList.tsx index 149ef0672..f6f5c69aa 100644 --- a/src/components/mcp/McpServerList.tsx +++ b/src/components/mcp/McpServerList.tsx @@ -169,7 +169,7 @@ export function McpServerList({ diff --git a/src/components/mcp/McpToolsBrowser.tsx b/src/components/mcp/McpToolsBrowser.tsx index 6d28c57ba..5aca44475 100644 --- a/src/components/mcp/McpToolsBrowser.tsx +++ b/src/components/mcp/McpToolsBrowser.tsx @@ -292,9 +292,9 @@ export function McpToolsBrowser({ )}
- + {displayName} @@ -320,7 +320,7 @@ export function McpToolsBrowser({ onClick={() => onCallTool(tool.name, {}) } - className="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700" + className="rounded border border-emerald-200 bg-[linear-gradient(135deg,#0ea5e9_0%,#14b8a6_52%,#10b981_100%)] px-2 py-1 text-xs text-white shadow-sm shadow-emerald-950/15 hover:opacity-95" > 调用工具 diff --git a/src/components/memory/MemoryPage.test.tsx b/src/components/memory/MemoryPage.test.tsx index c34291d37..1279814fa 100644 --- a/src/components/memory/MemoryPage.test.tsx +++ b/src/components/memory/MemoryPage.test.tsx @@ -281,37 +281,40 @@ describe("MemoryPage", () => { cleanupMountedRoots(mountedRoots); }); - it("应展示新的六区记忆结构", async () => { + it("应展示灵感库与底层诊断分层结构", async () => { renderPage(); await flushPageEffects(); const bodyText = document.body.textContent ?? ""; - expect(bodyText).toContain("记忆工作台"); - expect(bodyText).toContain("记忆来源"); - expect(bodyText).toContain("会话记忆"); - expect(bodyText).toContain("记忆类型"); - expect(bodyText).toContain("团队记忆"); - expect(bodyText).toContain("会话压缩"); - expect(bodyText).toContain("记忆目录与作用域"); + expect(bodyText).toContain("灵感库"); + expect(bodyText).toContain("灵感总览"); + expect(bodyText).toContain("底层来源"); + expect(bodyText).toContain("会话工作记忆"); + expect(bodyText).toContain("参考与风格"); + expect(bodyText).toContain("团队影子"); + expect(bodyText).toContain("压缩摘要"); + expect(bodyText).toContain("底层记忆目录与作用域"); expect(bodyText).toContain("记忆目录(memdir)"); - expect(bodyText).toContain("不要写进记忆的内容"); + expect(bodyText).toContain("底层记忆守则"); expect(bodyText).not.toContain("Claude Code"); }); - it("home 分区应展示默认心智层摘要卡", async () => { + it("home 分区应展示灵感总览与风格层摘要", async () => { renderPage(); await flushPageEffects(); const bodyText = document.body.textContent ?? ""; - expect(bodyText).toContain("默认心智层"); - expect(bodyText).toContain("先看这轮真实命中"); - expect(bodyText).toContain("跨会话可复用的沉淀"); - expect(bodyText).toContain("项目规则与协作上下文"); - expect(bodyText).toContain("长会话如何续接"); - expect(bodyText).toContain("最近会话条目 2"); - expect(bodyText).toContain("1 条长期记忆"); - expect(bodyText).toContain("规则来源 1 条"); - expect(bodyText).toContain("最近压缩保留 8 轮"); + expect(bodyText).toContain("灵感库总览"); + expect(bodyText).toContain("可继续复用的灵感对象"); + expect(bodyText).toContain("这次可带上的参考素材"); + expect(bodyText).toContain("系统已提炼的创作倾向"); + expect(bodyText).toContain("底层记忆只做事实源"); + expect(bodyText).toContain("1 条灵感对象"); + expect(bodyText).toContain("0 条参考素材"); + expect(bodyText).toContain("4 个风格关键词"); + expect(bodyText).toContain("1 条来源已接入"); + expect(bodyText).toContain("风格层摘要"); + expect(bodyText).toContain("夏日短视频语气"); }); it("rules 分区应展示来源分类、provider 与 memdir 类型标签", async () => { @@ -355,7 +358,7 @@ describe("MemoryPage", () => { await flushPageEffects(); const bodyText = document.body.textContent ?? ""; - expect(bodyText).toContain("记忆来源与 memdir"); + expect(bodyText).toContain("底层记忆来源与 memdir"); expect(bodyText).toContain("来源分类:记忆目录(memdir)"); expect(bodyText).toContain("provider:memdir"); expect(bodyText).toContain("反馈记忆"); @@ -388,8 +391,8 @@ describe("MemoryPage", () => { renderPage({ section: "identity", onNavigate }); await flushPageEffects(); - expect(document.body.textContent ?? "").toContain("Lime 记忆类型"); - expect(document.body.textContent ?? "").toContain("当前存量条目"); + expect(document.body.textContent ?? "").toContain("灵感对象分层"); + expect(document.body.textContent ?? "").toContain("灵感条目明细"); expect(document.body.textContent ?? "").toContain("夏日短视频语气"); const button = Array.from(document.body.querySelectorAll("button")).find( @@ -436,6 +439,24 @@ describe("MemoryPage", () => { expect.stringContaining("标签:小红书、口播、夏日氛围"), }), ); + + const sceneButton = Array.from(document.body.querySelectorAll("button")).find( + (element) => element.textContent?.includes("用于创作场景"), + ); + expect(sceneButton).toBeTruthy(); + + await act(async () => { + sceneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + expect(onNavigate).toHaveBeenCalledWith("sceneapps", { + view: "catalog", + projectId: "project-42", + referenceMemoryIds: ["memory-1"], + search: "夏日短视频语气", + prefillIntent: expect.stringContaining("围绕这条风格灵感继续创作:夏日短视频语气"), + }); }); it("应支持带着当前会话上下文打开运行时记忆命中预演", async () => { @@ -502,7 +523,7 @@ describe("MemoryPage", () => { expect(bodyText).toContain("研究输出格式偏好"); expect(bodyText).toContain("研究协作队"); expect(bodyText).toContain("【运行时记忆召回】"); - expect(bodyText).toContain("来自记忆工作台"); + expect(bodyText).toContain("来自灵感库"); expect(bodyText).toContain("切换到这次对照"); expect(mockPrefetchContextMemoryForTurn).toHaveBeenCalledWith({ session_id: "session-runtime-1", diff --git a/src/components/memory/MemoryPage.tsx b/src/components/memory/MemoryPage.tsx index 3f122a4f2..672a43018 100644 --- a/src/components/memory/MemoryPage.tsx +++ b/src/components/memory/MemoryPage.tsx @@ -70,6 +70,12 @@ import { type RuntimeMemoryPrefetchHistoryScope, } from "@/lib/runtimeMemoryPrefetchHistory"; import { buildLayerMetrics } from "./memoryLayerMetrics"; +import { + buildInspirationProjectionEntries, + buildInspirationTasteSummary, + buildScenePrefillFromInspiration, + INSPIRATION_PROJECTION_META, +} from "./inspirationProjection"; import type { MemoryPageParams, MemoryPageSection, @@ -162,39 +168,39 @@ const SECTION_META: Array<{ }> = [ { key: "home", - label: "记忆总览", + label: "灵感总览", icon: BrainCircuit, - description: "按 Lime 的记忆目录、作用域与类型查看当前记忆。", + description: "先看灵感对象、风格层和本轮可继续创作的线索。", }, { key: "rules", - label: "记忆来源", + label: "底层来源", icon: ScrollText, - description: "托管策略、用户/项目/本地记忆、规则目录、memdir 与 /memories。", + description: "查看支撑灵感库的底层记忆来源、规则目录和 memdir。", }, { key: "working", - label: "会话记忆", + label: "会话工作记忆", icon: FolderKanban, - description: "当前 session 的计划、摘录和工作文件。", + description: "查看当前 session 的计划、摘录和工作文件。", }, { key: "durable", - label: "记忆类型", + label: "参考与风格", icon: Database, - description: "Lime 四类记忆与当前存量映射。", + description: "按灵感对象查看参考、风格、偏好和成果沉淀。", }, { key: "team", - label: "团队记忆", + label: "团队影子", icon: Users, - description: "仓库作用域的协作影子与分工快照。", + description: "查看 repo 作用域的协作影子和分工快照。", }, { key: "compaction", - label: "会话压缩", + label: "压缩摘要", icon: GitBranch, - description: "长会话压缩后保留下来的可续接摘要。", + description: "查看长会话压缩后保留下来的可续接摘要。", }, ]; @@ -214,38 +220,6 @@ const CATEGORY_LABELS: Record = { activity: "收藏", }; -const MEMORY_TYPE_META: Array<{ - key: MemoryKnowledgeType; - label: string; - description: string; - useCase: string; -}> = [ - { - key: "user", - label: "用户记忆", - description: "记录用户角色、职责、目标与长期偏好,用来调整协作方式。", - useCase: "例如用户的经验背景、沟通偏好、对解释深浅的期待。", - }, - { - key: "feedback", - label: "反馈记忆", - description: "记录用户反复强调的做事方式,包括纠偏和验证通过的做法。", - useCase: "例如“不要做额外重构”“回复保持简洁”“测试要打真实链路”。", - }, - { - key: "project", - label: "项目记忆", - description: "记录项目内不易从代码直接推导的目标、约束、时间点和背景。", - useCase: "例如当前主线、冻结窗口、重构动机、团队分工和交付背景。", - }, - { - key: "reference", - label: "参考记忆", - description: "记录外部资料和系统入口,帮助下次知道去哪里查最新事实。", - useCase: "例如文档地址、监控面板、工单系统、知识库目录。", - }, -]; - const MEMORY_TYPE_LABELS: Record = { user: "用户记忆", feedback: "反馈记忆", @@ -514,49 +488,6 @@ function formatRelativeTime(timestamp: number | undefined): string { .padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; } -function truncateSummaryText(value?: string | null, maxLength = 88): string { - const normalized = (value || "").trim().replace(/\s+/g, " "); - if (!normalized) { - return ""; - } - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, maxLength - 1).trimEnd()}…`; -} - -function countRuntimePrefetchHitLayers( - result: TurnMemoryPrefetchResult | null, -): number { - if (!result) { - return 0; - } - - return [ - result.rules_source_paths.length > 0, - Boolean(result.working_memory_excerpt), - result.durable_memories.length > 0, - result.team_memory_entries.length > 0, - Boolean(result.latest_compaction), - ].filter(Boolean).length; -} - -function buildRuntimePrefetchHitSummary( - result: TurnMemoryPrefetchResult | null, -): string { - if (!result) { - return "带着当前会话进入时,这里会直接显示这轮真实命中的规则、工作摘录、长期记忆、团队影子和压缩摘要。"; - } - - return [ - `规则 ${result.rules_source_paths.length}`, - result.working_memory_excerpt ? "工作已命中" : "工作未命中", - `长期 ${result.durable_memories.length}`, - `团队 ${result.team_memory_entries.length}`, - result.latest_compaction ? "压缩已命中" : "压缩未命中", - ].join(" · "); -} - function formatRuntimeLayerStatusLabel( label: string, count?: number | null, @@ -605,7 +536,7 @@ function resolveRuntimeHistoryPreviewChangeLabel( function resolveRuntimeHistorySourceLabel( source: RuntimeMemoryPrefetchHistoryEntry["source"], ): string { - return source === "thread_reliability" ? "来自线程面板" : "来自记忆工作台"; + return source === "thread_reliability" ? "来自线程面板" : "来自灵感库"; } function formatRuntimeLayerLatestValue( @@ -1005,6 +936,57 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ); }, [durableFilter, unifiedMemories]); + const inspirationEntries = useMemo( + () => buildInspirationProjectionEntries(unifiedMemories), + [unifiedMemories], + ); + + const inspirationEntryMap = useMemo( + () => new Map(inspirationEntries.map((entry) => [entry.id, entry])), + [inspirationEntries], + ); + + const inspirationProjectionCounts = useMemo(() => { + return inspirationEntries.reduce( + (result, entry) => { + result[entry.projectionKind] += 1; + return result; + }, + { + style: 0, + reference: 0, + preference: 0, + outcome: 0, + collection: 0, + } as Record, + ); + }, [inspirationEntries]); + + const featuredInspirationEntries = useMemo( + () => inspirationEntries.slice(0, 6), + [inspirationEntries], + ); + + const tasteSummary = useMemo( + () => buildInspirationTasteSummary(inspirationEntries), + [inspirationEntries], + ); + + const inspirationKindCards = useMemo( + () => + ( + Object.keys( + INSPIRATION_PROJECTION_META, + ) as Array + ).map((key) => ({ + key, + label: INSPIRATION_PROJECTION_META[key].label, + description: INSPIRATION_PROJECTION_META[key].description, + count: inspirationProjectionCounts[key], + })), + [inspirationProjectionCounts], + ); + const layerMetrics = useMemo( () => buildLayerMetrics({ @@ -1023,14 +1005,6 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ], ); - const durableCountsByCategory = useMemo(() => { - const counts = new Map(); - unifiedStats?.categories.forEach((item) => { - counts.set(item.category, item.count); - }); - return counts; - }, [unifiedStats?.categories]); - const sourceBuckets = useMemo( () => SOURCE_BUCKET_META.map((bucket) => { @@ -1156,29 +1130,6 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { unifiedStats?.total_entries, ]); - const memoryTypeCards = useMemo( - () => - MEMORY_TYPE_META.map((item) => { - const mappedCategories = ( - Object.keys(LEGACY_CATEGORY_TO_MEMORY_TYPE) as MemoryCategory[] - ).filter((category) => resolveMemoryType(category) === item.key); - const count = mappedCategories.reduce( - (total, category) => - total + (durableCountsByCategory.get(category) || 0), - 0, - ); - - return { - ...item, - count, - legacyLabels: mappedCategories - .map((category) => CATEGORY_LABELS[category]) - .join("、"), - }; - }), - [durableCountsByCategory], - ); - const runtimeTeamSnapshot = useMemo(() => { if (!runtimeWorkingDir) { return null; @@ -1411,6 +1362,21 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { ); } + function handleOpenInScene(entry: UnifiedMemory) { + const projectedEntry = inspirationEntryMap.get(entry.id); + const prefillIntent = projectedEntry + ? buildScenePrefillFromInspiration(projectedEntry) + : `围绕这条灵感继续创作:${entry.title}`; + + onNavigate("sceneapps", { + view: "catalog", + projectId: projectId || undefined, + referenceMemoryIds: [entry.id], + search: entry.title, + prefillIntent, + }); + } + function handleOpenRuntimeHistoryEntry( entry: RuntimeMemoryPrefetchHistoryEntry, ) { @@ -1538,118 +1504,98 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) { className={cn(BUTTON_CLASS_NAME, EMERALD_BUTTON_CLASS_NAME)} onClick={() => navigateToMemorySection("rules")} > - 看来源链 + 看底层来源
) : null; const homeOverviewCards = useMemo( () => [ { - key: "current-turn", - eyebrow: "当前回合", - title: "先看这轮真实命中", - value: - runtimePrefetchState.status === "loading" - ? "正在对照当前回合" - : runtimePrefetchState.result - ? `已命中 ${countRuntimePrefetchHitLayers( - runtimePrefetchState.result, - )} 层` - : hasRuntimeContext - ? "当前回合还未命中" - : workingView?.total_entries - ? `最近会话条目 ${workingView.total_entries}` - : "等待带入当前回合", - detail: buildRuntimePrefetchHitSummary(runtimePrefetchState.result), - actionLabel: "看会话记忆", - onClick: () => navigateToMemorySection("working"), - }, - { - key: "durable", - eyebrow: "长期记忆", - title: "跨会话可复用的沉淀", - value: `${unifiedStats?.memory_count || 0} 条长期记忆`, + key: "library", + eyebrow: "灵感库", + title: "可继续复用的灵感对象", + value: `${inspirationEntries.length} 条灵感对象`, detail: - (unifiedStats?.categories?.length || 0) > 0 - ? `已覆盖 ${unifiedStats?.categories.length || 0} 个记忆类型,适合回看稳定偏好、项目背景和长期参考。` - : "当前还没有长期记忆沉淀,后续的稳定结论会先在这里累计。", - actionLabel: "看记忆类型", + inspirationEntries[0] + ? `最近更新:${inspirationEntries[0].title} · ${inspirationEntries[0].summary}` + : "把认可的风格、参考、成果和收藏沉淀成可复用对象。", + actionLabel: "看灵感条目", onClick: () => navigateToMemorySection("durable"), }, { - key: "team", - eyebrow: "团队 / 项目", - title: "项目规则与协作上下文", - value: - teamSnapshots.length > 0 - ? `团队快照 ${teamSnapshots.length} 个` - : `规则来源 ${rulesSources?.loaded_sources || 0} 条`, - detail: `规则已加载 ${rulesSources?.loaded_sources || 0}/${ - rulesSources?.total_sources || 0 - } 条;${ - teamSnapshots.length > 0 - ? "最近的团队影子可补充 repo 范围内的分工和上下文。" - : "当前还没有本地团队影子。" - }`, - actionLabel: "看团队记忆", - onClick: () => navigateToMemorySection("team"), + key: "reference", + eyebrow: "参考库", + title: "这次可带上的参考素材", + value: `${inspirationProjectionCounts.reference} 条参考素材`, + detail: + inspirationProjectionCounts.reference > 0 + ? `当前已整理出 ${tasteSummary.referenceKeywords.length} 个可直接带入 planning 的参考关键词。` + : "当前还没有明确参考素材,可以从聊天结果或资料里继续沉淀。", + actionLabel: "看参考素材", + onClick: () => navigateToMemorySection("durable"), }, { - key: "compaction", - eyebrow: "会话压缩", - title: "长会话如何续接", - value: extractionStatus?.latest_compaction - ? `最近压缩保留 ${extractionStatus.latest_compaction.turn_count} 轮` - : "还没有会话压缩", + key: "taste", + eyebrow: "风格层", + title: "系统已提炼的创作倾向", + value: tasteSummary.styleKeywords.length + ? `${tasteSummary.styleKeywords.length} 个风格关键词` + : `${inspirationProjectionCounts.style + inspirationProjectionCounts.preference} 条风格线索`, + detail: tasteSummary.summary, + actionLabel: "看风格层", + onClick: () => navigateToMemorySection("durable"), + }, + { + key: "memory", + eyebrow: "底层记忆", + title: "底层记忆只做事实源", + value: `${rulesSources?.loaded_sources || 0} 条来源已接入`, detail: - truncateSummaryText( - extractionStatus?.latest_compaction?.summary_preview || - extractionStatus?.status_summary, - ) || "长会话压缩后会把可续接摘要收在这里,便于下次继续。", - actionLabel: "看会话压缩", - onClick: () => navigateToMemorySection("compaction"), + `会话 ${workingView?.total_entries || 0} 条 · 团队 ${teamSnapshots.length} 个 · 压缩 ${extractionStatus?.recent_compactions.length || 0} 条`, + actionLabel: "看底层来源", + onClick: () => navigateToMemorySection("rules"), }, ], [ - extractionStatus?.latest_compaction, - extractionStatus?.status_summary, - hasRuntimeContext, + extractionStatus?.recent_compactions.length, + inspirationEntries, + inspirationProjectionCounts.preference, + inspirationProjectionCounts.reference, + inspirationProjectionCounts.style, navigateToMemorySection, rulesSources?.loaded_sources, - rulesSources?.total_sources, - runtimePrefetchState.result, - runtimePrefetchState.status, + tasteSummary.referenceKeywords.length, + tasteSummary.styleKeywords.length, + tasteSummary.summary, teamSnapshots.length, - unifiedStats?.categories, - unifiedStats?.memory_count, workingView?.total_entries, ], ); @@ -1660,13 +1606,13 @@ export function MemoryPage({ onNavigate, pageParams }: MemoryPageProps) {