From 5b6c197a7457d581b14b5acd40eecb6a9092f355 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sun, 4 Oct 2026 01:35:06 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(onboard/parity):=20UI=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=8E=A5=E5=85=A5=E9=9D=A2=E6=9D=BF=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=85=88=E8=AE=A1=E5=88=92=E9=A2=84=E8=A7=88=E3=80=81=E4=BA=BA?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=90=8E=E6=8E=A5=E5=85=A5=EF=BC=8C=E5=9B=9B?= =?UTF-8?q?=E4=B8=AA=E5=85=A5=E5=8F=A3=E6=8E=A5=E7=BA=BF=EF=BC=88P8g?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onboardView.ts:计划 / 报告 → 视图模型的纯函数(阻断禁用确认、可跳过只看后端 skippable、 not_installed / not_applicable 灰色说明、bypassPermissions 与 Codex 信任单列、确认带 expected_digest 与生成计划时的选项),onboardView.test.ts 13 条 vitest - OnboardPanel.tsx / OnboardSections.tsx:全局挂载的接入面板(plan → 预览 → onboard_project → 分步结果、 L1 矩阵、待你提交的 N 个文件、待你操作),refused 可回到新计划重新确认 - 入口:新建项目选了目录、模板新建成功后弹出预览;批量添加只给逐项入口;蓝图治理「施加 Buff」 改为同一面板(先 plan 再 apply) - plan.rs:BlockedItem 序列化附派生字段 skippable(= BlockReason::skippable,不进 digest) Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 4 +- docs/ai-context/project-context.yaml | 4 +- src-tauri/src/onboard/plan.rs | 38 +- src/App.tsx | 2 + src/components/dashboard/BatchAddModal.tsx | 71 +- src/components/dashboard/BlueprintModal.tsx | 134 ++-- src/components/dashboard/OnboardPanel.tsx | 189 ++++++ src/components/dashboard/OnboardSections.tsx | 277 ++++++++ src/components/dashboard/ProjectModal.tsx | 5 + src/components/dashboard/onboardView.test.ts | 290 ++++++++ src/components/dashboard/onboardView.ts | 671 +++++++++++++++++++ src/components/manage/NewProjectModal.tsx | 15 +- src/lib/commands.ts | 2 + src/store/ui.ts | 19 +- 14 files changed, 1625 insertions(+), 96 deletions(-) create mode 100644 src/components/dashboard/OnboardPanel.tsx create mode 100644 src/components/dashboard/OnboardSections.tsx create mode 100644 src/components/dashboard/onboardView.test.ts create mode 100644 src/components/dashboard/onboardView.ts diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index ed0b130..aec4ba6 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -331,8 +331,8 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - review: "2026-10-03 独立只读审查 bd4ebaf,结论 fix_then_merge,10 条发现处置:M1 [中] MCP 入参原样落库(正斜杠、尾部分隔符、长路径前缀、8.3、`..`),scan_unregistered_projects 按字面比较会自相矛盾 → 已修:本体对 Windows 目录 canonicalize 后去长路径前缀落库,register_project_at 回执 path 为落库值、名称缺省按规范形式取目录名(相对路径不 canonicalize,交本体拒绝);scan 的已登记判定与父目录去重改用 registration_path_key。M2 [中] UI 不选目录的概念占位被取消,超出 P8f 范围且与 mcp-server 模块记录的用户用法冲突 → 已恢复原行为:本体新增 RootRequirement,UI 入口有效路径为空时只写 profile+workspace、不校验目录、不查重复,MCP 入口仍拒绝;是否改为显式占位概念交统筹会话问用户(需求澄清),本 PR 不预设答案。L1 [低] 已有登记两列都比,残留列误拦 → 已修:按 platform 只取有效根目录(registration_root),查询补 platform 列。L2 [低] 8.3、`..`、末尾点等漏判 → 已修:新目录在写事务外另算 canonical 键,两键比对已有行的字面键(已有行不读磁盘,写锁内无 I/O)。L3 [低] mount_repo 检查在插 profile 之后且无事务 → 已修:抽 mount_repo_records(连接注入),检查前移、整体 IMMEDIATE 事务;前端先 gitClone 再 mount 的白克隆属 UI 范围,未改。L4 [低] 路径比较键多份实现 → 部分修:registration_path_key 改为复用 mcp_inject::normalize_dsh_root + strip_verbatim_prefix(两函数改 pub(crate));normalize_trust_path、WSL 发行版查询与 UNC 换算的第三份拷贝统一抽 crate::path_key 转后续小修(会动 P4a/P8d 热点 mcp_inject.rs)。I1 [信息] update_project 仍可造同目录登记 → 转后续小修:UI 编辑整表提交,须「有效根目录实际变化才检查 + 排除自身」,否则存量重复登记(beast-mode)连改名都会被拦,单独做更稳。I2 [信息] project_root_with_conn 对 UNC 形式的 wsl_path 错误拼接(已有缺陷)→ 不在本 PR 改(mcp_inject.rs 热点),转后续小修;卡片与代码注释里「同口径」改为只指取列与 Linux 路径换算。I3 [信息] 重登目录遗留旧 context.json 无提示 → 已修:register_project_at 只读检查,project_id 不同时回执与摘要带 warning(不沿用旧 ID)。I4 [信息] 分发分支无测试 → 已修:分发抽成 register_project_call(conn, args),测试按 schema 声明的参数名构造入参;IMMEDIATE 并发仍无测试(见没验什么③)。" - mcp_verify: "PR #37 合并(55dc637);P8e 合并后经全局 MCP 实测链路 register_project → plan_onboarding → onboard_project 得 status=done(证据同 P8e mcp_verify);同目录换写法再登记被拒并给出已有 project_id。" -### 📋 P8g — UI 一键接入 -- status: todo +### 🔵 P8g — UI 一键接入 +- status: in_progress - complexity: M - files: src/components/dashboard/OnboardPanel.tsx, src/components/dashboard/onboardView.ts, src/components/dashboard/onboardView.test.ts, src/components/dashboard/ProjectModal.tsx, src/components/dashboard/BatchAddModal.tsx, src/components/manage/NewProjectModal.tsx, src/components/dashboard/BlueprintModal.tsx, src/lib/commands.ts - depends: P8e diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index fc7ad4d..77f889e 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -30,13 +30,13 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。接入经客户端适配器注册表(onboard::clients::registry:Claude、Codex、DSH)逐端收敛并返回结构化结果 ClientOutcome(converged/applied/refreshed/removed/manual_kept/other_project/not_installed/not_applicable{control_plane|wsl}/deferred/missing/unavailable/needs_human/failed);inject_mcp 回执逐端列出,只有 Codex 托管块写入失败算整体失败;项目 .codex/config.toml 已手写同名 server 记 manual_kept,不再整体报错;verify_onboarding 附加 clients 字段(三端只读状态,不计入 checks、不影响 pass)。src-tauri/src/onboard/ 是一键接入本体模块,源码不得依赖 Tauri、不得直接取全局连接池(适应度测试 onboard_module_is_projection_free)。一键接入编排本体 onboard::apply(P8d;生产入口 mcp_inject::plan_onboarding 与 mcp_inject::onboard_project,P8e 的 MCP 工具与 Tauri command 同名投影都只一行委托它们):计划含项目根目录 root(将要写入的目录)、逐文件动作与 diff、本机七步 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、blocked、skipped、bypass_permissions(本次会向 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions 时为 true)、codex_trust、next_actions 与 digest(绑定 root、文件、本机步骤、阻断项与跳过项);选项 bypass_permissions=false 即按项目取消,settings.json 缺 permissions 时改写显式 defaultMode=default 记住选择(之后的重注入同样只在缺省时写);选项 skip_targets 是人对阻断项逐项选「跳过」的 target:只有 manual_entry(手写同名条目)与 shared_key(共享项目键)可跳过,移入 skipped、对应一端 apply 时记 skipped(manual_kept|other_project)、不调用该端适配器、文件原样保留,身份冲突、混合文件与 needs_human 列进来也照旧阻断;跳过不等于该端可用,done 仍按三端可用子集判定。onboard_project(expected_digest, confirmed) 依次:同一项目已在执行 → busy;预检(未登记、空路径、相对路径、根目录不存在)→ failed;未确认 → refused;接入写锁内重新解析登记(等锁期间登记被删除 → failed;根目录或端口变了 → refused 并附当前计划),再重算计划,digest 不符或有阻断项 → refused 并附当前计划;以上均零写入。之后在写锁内按依赖执行 identity、project_files(与计划同一写入序列)、product_group_buff(只写 DB)、token → shim → 三端适配器(token 或 shim 未完成则 blocked(dep=…);Codex 配置即项目 .codex/config.toml,由 project_files 写入,codex 步只复核,本次写过记 applied),释放写锁后执行 git_hooks(lefthook install,非 git 仓库或仓库子目录 not_applicable,缺可执行文件 needs_human,本地可执行文件存在却起不来 failed 并附启动错误);每步状态 converged/applied/skipped/not_applicable/blocked/failed/needs_human;某步失败只阻断依赖它的步骤,整体 partial,不自动回滚。随后跑 L1:客户端步骤未完成的一端整列 blocked(cell.cause=dependency、cell.dep=步骤名)、不起进程。报告 status=done/partial/failed/refused/busy,done 只按三端可用子集判定(全部步骤 converged/applied/skipped/not_applicable,且 completion 中不带 pending_user 的项全部 ready:verify_onboarding 中除 origin、CI、飞轮、工具链、delivery profile 外的检查全部 ready,三端只读判定已收敛,L1 适用格全部就绪,git 仓库可报告改动且 HEAD 未变)。completion 每项为 {item, source(l0|clients|l1|repo), ready, detail, pending_user};统筹决定(P8d 审查 ⑩,P8e 实现):L1 的 Codex 列里仅因 cause=codex_project_untrusted 未就绪的格只等用户在 Codex 里信任目录(炼境不写 Codex 信任表),不计入「L1 适用格」,completion 单列一项「Codex 信任」(source=l1、ready=false、pending_user=true,不计入 done),next_actions 保留一条 codex_trust(计划已列则不重复,计划预判已信任而 L1 仍未信任时补一条);Codex 列其余原因(含 blocked)照常计入;L1Report.all_applicable_ready 保持 L1 自身的原始判定。非 git 目录 repo_changes 为 unavailable,到不了 done;项目根是仓库子目录或 .git 为文件(worktree、子模块)时 verify 的「lefthook hook 挂载」只认项目根 .git/hooks、不会就绪,到不了 done,next_actions 补一条 repo_layout 说明原因(外层仓库根或实际 hooks 目录)与怎样才能 done;written_files 为本次实际写入的项目文件;repo_changes 为 git status --porcelain --untracked-files=all -- . 与 git diff --stat -- .(限定在项目根内;非 git 为 unavailable),并分出 onboard_files(本次写入的,加上接入写入序列管理且仍未跟踪的,路径相对项目根)与 other_changes(用户原有改动等);建议提交信息与 next_actions 的 commit 一项(git add -- )只针对接入文件,炼境不 add、不 commit;next_actions 含 commit、Gitea 建仓(注明工具默认建公开仓库)、CI、飞轮、工具链、lefthook、Codex 信任、仓库布局(repo_layout)、重启 DSH GUI、Claude 首次 trust 与重开已打开的会话(不据 reload_required 推断已重载)。一键接入执行了写入时另补一条 category=mcp_inject 的事件(token、shim 与三端步骤都完成记 info,否则 warn),get_diagnostics 的注入状态与设置页注入告警据此更新。删除登记(UI delete_project 与 unregister_project)在同一把接入写锁内先清理 MCP 配置、再删登记的数据库部分;端口变更的批量重注入在写锁内取目标快照。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。接入经客户端适配器注册表(onboard::clients::registry:Claude、Codex、DSH)逐端收敛并返回结构化结果 ClientOutcome(converged/applied/refreshed/removed/manual_kept/other_project/not_installed/not_applicable{control_plane|wsl}/deferred/missing/unavailable/needs_human/failed);inject_mcp 回执逐端列出,只有 Codex 托管块写入失败算整体失败;项目 .codex/config.toml 已手写同名 server 记 manual_kept,不再整体报错;verify_onboarding 附加 clients 字段(三端只读状态,不计入 checks、不影响 pass)。src-tauri/src/onboard/ 是一键接入本体模块,源码不得依赖 Tauri、不得直接取全局连接池(适应度测试 onboard_module_is_projection_free)。一键接入编排本体 onboard::apply(P8d;生产入口 mcp_inject::plan_onboarding 与 mcp_inject::onboard_project,P8e 的 MCP 工具与 Tauri command 同名投影都只一行委托它们):计划含项目根目录 root(将要写入的目录)、逐文件动作与 diff、本机七步 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、blocked、skipped、bypass_permissions(本次会向 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions 时为 true)、codex_trust、next_actions 与 digest(绑定 root、文件、本机步骤、阻断项与跳过项);选项 bypass_permissions=false 即按项目取消,settings.json 缺 permissions 时改写显式 defaultMode=default 记住选择(之后的重注入同样只在缺省时写);选项 skip_targets 是人对阻断项逐项选「跳过」的 target:只有 manual_entry(手写同名条目)与 shared_key(共享项目键)可跳过,移入 skipped、对应一端 apply 时记 skipped(manual_kept|other_project)、不调用该端适配器、文件原样保留,身份冲突、混合文件与 needs_human 列进来也照旧阻断;跳过不等于该端可用,done 仍按三端可用子集判定。blocked / skipped 每项序列化时带派生字段 skippable(= BlockReason::skippable,P8g;反序列化忽略、不进 digest),界面与 agent 据此决定是否给「跳过」,不自行复制判定。onboard_project(expected_digest, confirmed) 依次:同一项目已在执行 → busy;预检(未登记、空路径、相对路径、根目录不存在)→ failed;未确认 → refused;接入写锁内重新解析登记(等锁期间登记被删除 → failed;根目录或端口变了 → refused 并附当前计划),再重算计划,digest 不符或有阻断项 → refused 并附当前计划;以上均零写入。之后在写锁内按依赖执行 identity、project_files(与计划同一写入序列)、product_group_buff(只写 DB)、token → shim → 三端适配器(token 或 shim 未完成则 blocked(dep=…);Codex 配置即项目 .codex/config.toml,由 project_files 写入,codex 步只复核,本次写过记 applied),释放写锁后执行 git_hooks(lefthook install,非 git 仓库或仓库子目录 not_applicable,缺可执行文件 needs_human,本地可执行文件存在却起不来 failed 并附启动错误);每步状态 converged/applied/skipped/not_applicable/blocked/failed/needs_human;某步失败只阻断依赖它的步骤,整体 partial,不自动回滚。随后跑 L1:客户端步骤未完成的一端整列 blocked(cell.cause=dependency、cell.dep=步骤名)、不起进程。报告 status=done/partial/failed/refused/busy,done 只按三端可用子集判定(全部步骤 converged/applied/skipped/not_applicable,且 completion 中不带 pending_user 的项全部 ready:verify_onboarding 中除 origin、CI、飞轮、工具链、delivery profile 外的检查全部 ready,三端只读判定已收敛,L1 适用格全部就绪,git 仓库可报告改动且 HEAD 未变)。completion 每项为 {item, source(l0|clients|l1|repo), ready, detail, pending_user};统筹决定(P8d 审查 ⑩,P8e 实现):L1 的 Codex 列里仅因 cause=codex_project_untrusted 未就绪的格只等用户在 Codex 里信任目录(炼境不写 Codex 信任表),不计入「L1 适用格」,completion 单列一项「Codex 信任」(source=l1、ready=false、pending_user=true,不计入 done),next_actions 保留一条 codex_trust(计划已列则不重复,计划预判已信任而 L1 仍未信任时补一条);Codex 列其余原因(含 blocked)照常计入;L1Report.all_applicable_ready 保持 L1 自身的原始判定。非 git 目录 repo_changes 为 unavailable,到不了 done;项目根是仓库子目录或 .git 为文件(worktree、子模块)时 verify 的「lefthook hook 挂载」只认项目根 .git/hooks、不会就绪,到不了 done,next_actions 补一条 repo_layout 说明原因(外层仓库根或实际 hooks 目录)与怎样才能 done;written_files 为本次实际写入的项目文件;repo_changes 为 git status --porcelain --untracked-files=all -- . 与 git diff --stat -- .(限定在项目根内;非 git 为 unavailable),并分出 onboard_files(本次写入的,加上接入写入序列管理且仍未跟踪的,路径相对项目根)与 other_changes(用户原有改动等);建议提交信息与 next_actions 的 commit 一项(git add -- )只针对接入文件,炼境不 add、不 commit;next_actions 含 commit、Gitea 建仓(注明工具默认建公开仓库)、CI、飞轮、工具链、lefthook、Codex 信任、仓库布局(repo_layout)、重启 DSH GUI、Claude 首次 trust 与重开已打开的会话(不据 reload_required 推断已重载)。一键接入执行了写入时另补一条 category=mcp_inject 的事件(token、shim 与三端步骤都完成记 info,否则 warn),get_diagnostics 的注入状态与设置页注入告警据此更新。删除登记(UI delete_project 与 unregister_project)在同一把接入写锁内先清理 MCP 配置、再删登记的数据库部分;端口变更的批量重注入在写锁内取目标快照。" - label: Tauri command 接口(前后端契约) paths: - "src-tauri/src/commands/**/*.rs" - "src/lib/commands.ts" - doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report);L1 格子可带 dep(只在一键接入编排里出现:cause=dependency,dep 为未完成的接入步骤名)。apply_onboarding_pack 为 #[tauri::command(async)](P8d:buff 建组会注入 MCP、可能起 claude CLI,lefthook install 为子进程且带 60 秒超时;在 Tauri 异步运行时的任务里执行,不占界面线程,但会占住一个运行时 worker,同 buff.rs、groups.rs 的写法),入参与返回不变。一键接入的 Tauri 投影(P8e)plan_onboarding(projectId, options?) → OnboardingPlan 与 onboard_project(projectId, expectedDigest, confirmed, options?) → OnboardReport 为 async fn + tokio::task::spawn_blocking(与 probe_client_parity 一致;不用 #[tauri::command(async)] 包同步函数,以免占住 MCP 服务端所在运行时的 worker 30 秒以上),与 MCP 同名工具一样只委托 mcp_inject 生产入口;options 可省略(OnboardOptions 默认:bypass_permissions=true、skip_targets 为空),计划与应用须传同一组;plan 的错误(未登记、路径无效等)以 Err 返回,onboard_project 的 refused / busy / failed / partial 一律以报告 status 返回,Err 只表示线程异常。commands.ts 的 OnboardOptions、OnboardingPlan、OnboardReport(含 completion[].pending_user、repo_changes.onboard_files / other_changes、written_files、plan.root / skipped)与 Rust 序列化结构一一对应。" + doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report);L1 格子可带 dep(只在一键接入编排里出现:cause=dependency,dep 为未完成的接入步骤名)。apply_onboarding_pack 为 #[tauri::command(async)](P8d:buff 建组会注入 MCP、可能起 claude CLI,lefthook install 为子进程且带 60 秒超时;在 Tauri 异步运行时的任务里执行,不占界面线程,但会占住一个运行时 worker,同 buff.rs、groups.rs 的写法),入参与返回不变。一键接入的 Tauri 投影(P8e)plan_onboarding(projectId, options?) → OnboardingPlan 与 onboard_project(projectId, expectedDigest, confirmed, options?) → OnboardReport 为 async fn + tokio::task::spawn_blocking(与 probe_client_parity 一致;不用 #[tauri::command(async)] 包同步函数,以免占住 MCP 服务端所在运行时的 worker 30 秒以上),与 MCP 同名工具一样只委托 mcp_inject 生产入口;options 可省略(OnboardOptions 默认:bypass_permissions=true、skip_targets 为空),计划与应用须传同一组;plan 的错误(未登记、路径无效等)以 Err 返回,onboard_project 的 refused / busy / failed / partial 一律以报告 status 返回,Err 只表示线程异常。commands.ts 的 OnboardOptions、OnboardingPlan、OnboardReport(含 completion[].pending_user、repo_changes.onboard_files / other_changes、written_files、plan.root / skipped、blocked[].skippable)与 Rust 序列化结构一一对应。界面投影(P8g):全局挂载的一键接入面板 OnboardPanel(useUIStore.openOnboard 打开)先 plan_onboarding 预览、人确认后带同一 digest 与选项调 onboard_project,判定只由 onboardView.ts 映射后端字段;新建项目选了目录与模板新建成功后自动弹出预览(create_project 本身仍无副作用,概念占位不接入),批量添加只给逐项入口,蓝图治理的「一键接入」也走该面板(不再直接调 apply_onboarding_pack,该 command 与封装保留)。" architecture_docs: - "AGENTS.md" diff --git a/src-tauri/src/onboard/plan.rs b/src-tauri/src/onboard/plan.rs index 701da4f..4f0329a 100644 --- a/src-tauri/src/onboard/plan.rs +++ b/src-tauri/src/onboard/plan.rs @@ -121,13 +121,27 @@ impl BlockReason { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct BlockedItem { pub target: String, pub reason: BlockReason, pub detail: String, } +/// 序列化时附派生字段 skippable(= BlockReason::skippable):UI 与 agent 据此决定是否给「跳过」, +/// 可跳过判定只在本体一处(P8g,AGENTS.md「边界单点实现」)。反序列化忽略该字段;digest 不含它。 +impl Serialize for BlockedItem { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut item = serializer.serialize_struct("BlockedItem", 4)?; + item.serialize_field("target", &self.target)?; + item.serialize_field("reason", &self.reason)?; + item.serialize_field("detail", &self.detail)?; + item.serialize_field("skippable", &self.reason.skippable())?; + item.end() + } +} + /// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置。 /// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | unavailable /// | manual_kept | other_project | needs_human(后三者阻断确认,见 machine_step_blocker)。 @@ -1040,6 +1054,28 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + /// 阻断项序列化带派生字段 skippable(P8g 界面据此给「跳过」,不在前端复制判定);反序列化忽略它。 + #[test] + fn blocked_item_serializes_skippable_from_reason() { + for (reason, skippable) in [ + (BlockReason::ManualEntry, true), + (BlockReason::SharedKey, true), + (BlockReason::MixedFile, false), + (BlockReason::IdentityConflict, false), + (BlockReason::NeedsHuman, false), + ] { + let item = BlockedItem { + target: "t".into(), + reason, + detail: "d".into(), + }; + let value = serde_json::to_value(&item).unwrap(); + assert_eq!(value["skippable"], skippable, "{reason:?}"); + assert_eq!(value["target"], "t"); + assert_eq!(serde_json::from_value::(value).unwrap(), item); + } + } + /// 身份冲突、混合文件与需人工处理的项不可跳过(P8d 审查 ②):即使列进 skip_targets 也照旧阻断。 #[test] fn identity_conflict_and_needs_human_cannot_be_skipped() { diff --git a/src/App.tsx b/src/App.tsx index 2dc2583..606742a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { OverviewPage } from "./components/overview/OverviewPage"; import { Dashboard } from "./components/dashboard/Dashboard"; import { ProjectModal } from "./components/dashboard/ProjectModal"; +import { OnboardPanel } from "./components/dashboard/OnboardPanel"; import { ResourcesPage } from "./components/resources/ResourcesPage"; import { ToolsPage } from "./components/tools/ToolsPage"; import { SettingsPage } from "./components/settings/SettingsPage"; @@ -75,6 +76,7 @@ export default function App() { + ); diff --git a/src/components/dashboard/BatchAddModal.tsx b/src/components/dashboard/BatchAddModal.tsx index 266641e..4166b04 100644 --- a/src/components/dashboard/BatchAddModal.tsx +++ b/src/components/dashboard/BatchAddModal.tsx @@ -1,10 +1,47 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { open } from "@tauri-apps/plugin-dialog"; -import { batchCreateProjects, getSpaces, scanSubfolders, type SubFolder } from "../../lib/commands"; +import { batchCreateProjects, getSpaces, scanSubfolders, type OnboardStatus, type SubFolder } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; import { Dialog } from "../ui/Dialog"; +/** 本次添加成功的项目:批量只给入口、不自动接入(P8g),每个项目各自预览确认 */ +interface AddedProject { + id: string; + name: string; + /** 本次面板里的接入结果;cancelled = 打开后未执行接入 */ + onboard?: OnboardStatus | "cancelled"; +} + +const ONBOARD_STATUS_LABEL: Record = { + done: "✓ 接入完成", + partial: "⚠ 部分完成", + failed: "✗ 预检失败", + refused: "⚠ 已拒绝,需重新确认", + busy: "… 正在接入中", + cancelled: "未接入", +}; + +function AddedProjects({ added, onOnboard }: { added: AddedProject[]; onOnboard: (project: AddedProject) => void }) { + return ( +
+

已添加 {added.length} 个项目。一键接入需逐个预览并确认,不会自动写入子项目:

+ {added.map((project) => ( +
+ {project.name} + {project.onboard ? ONBOARD_STATUS_LABEL[project.onboard] : "未接入"} + +
+ ))} +
+ ); +} + // Windows 路径转 WSL 路径(仅 /mnt/x/... 形式,不处理 UNC) function winToWsl(p: string): string { const norm = p.replace(/\\/g, "/"); @@ -22,7 +59,19 @@ interface FolderItem extends SubFolder { export function BatchAddModal({ onClose }: { onClose: () => void }) { const showToast = useUIStore((s) => s.showToast); + const openOnboard = useUIStore((s) => s.openOnboard); const qc = useQueryClient(); + const [added, setAdded] = useState(null); + + const onboardOne = (project: AddedProject) => + openOnboard({ + projectId: project.id, + projectName: project.name, + onClosed: (report) => + setAdded((prev) => + prev?.map((p) => (p.id === project.id ? { ...p, onboard: report?.status ?? p.onboard ?? "cancelled" } : p)) ?? prev, + ), + }); const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces }); const currentSpace = spaces.find((s) => s.is_current); @@ -103,7 +152,12 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) { } else { showToast(`成功添加 ${selected.length} 个项目 ✓`); } - onClose(); + // 没有目录的登记(概念占位)不给接入入口 + const created = payload + .filter((p) => !skipped.includes(p.id) && (p.win_path || p.wsl_path)) + .map((p) => ({ id: p.id, name: p.name })); + if (created.length > 0) setAdded(created); + else onClose(); } catch (e) { showToast(`❌ ${e}`); } finally { @@ -113,6 +167,19 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) { const checkedCount = items.filter((i) => i.checked).length; + if (added) { + return ( + + +
+ +
+
+ ); + } + return (
diff --git a/src/components/dashboard/BlueprintModal.tsx b/src/components/dashboard/BlueprintModal.tsx index bbcd936..eab7e80 100644 --- a/src/components/dashboard/BlueprintModal.tsx +++ b/src/components/dashboard/BlueprintModal.tsx @@ -20,7 +20,6 @@ import { getBlueprintStatus, syncBlueprintRules, generateBlueprintPrompt, - applyOnboardingPack, getCommitStats, removeBlueprintBuff, getBuffStatus, @@ -31,10 +30,10 @@ import { type BlueprintStatus, type SyncResult, type BuffStatus, - type TemplateResult, - type HookActivation, type CommitStats, + type OnboardReport, } from "../../lib/commands"; +import { useUIStore } from "../../store/ui"; interface Props { projectName: string; @@ -54,19 +53,6 @@ const STATUS_CONFIG: Record = { - written: { icon: "✓", color: "text-green-600", label: "已写入" }, - updated: { icon: "↑", color: "text-blue-600", label: null }, - appended: { icon: "+", color: "text-blue-600", label: null }, - skipped: { icon: "○", color: "text-gray-400", label: "已存在" }, - failed: { icon: "✗", color: "text-red-600", label: null }, -}; - const TASK_PREFIX: Record = { done: "✅", in_progress: "🔵", @@ -1058,8 +1044,9 @@ function GovernancePanel({ const [buffError, setBuffError] = useState(null); const [buffInitPrompt, setBuffInitPrompt] = useState(null); const [buffInitCopied, setBuffInitCopied] = useState(false); - const [packTemplates, setPackTemplates] = useState(null); - const [packHook, setPackHook] = useState(null); + /** 最近一次一键接入的结果摘要(详情在接入面板里看过) */ + const [onboardSummary, setOnboardSummary] = useState(null); + const openOnboard = useUIStore((s) => s.openOnboard); const [promptMode, setPromptMode] = useState<"init" | "sync" | null>(null); const [prompt, setPrompt] = useState(""); @@ -1067,28 +1054,29 @@ function GovernancePanel({ const [promptError, setPromptError] = useState(null); const [copied, setCopied] = useState(false); - const handleApplyBuff = async () => { + /** 接入面板关闭后:未执行接入不动;执行过则刷新 buff,尚无蓝图时给 init prompt */ + const afterOnboard = async (report: OnboardReport | null) => { + if (!report) return; + setOnboardSummary(`${report.status}:${report.summary}`); + refetchBuff(); + if (report.status !== "done" && report.status !== "partial") return; + // 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化 + if (bpStatus?.status === "none") { + try { + setBuffInitPrompt(await generateBlueprintPrompt(projectPath, "init")); + } catch (e) { + setBuffError(String(e)); + } + } + }; + + // 「应用接入包」改为一键接入:先 plan 预览、人确认后再 apply(P8g),与新建项目走同一面板 + const handleApplyBuff = () => { if (!projectId) return; - setBuffLoading(true); setBuffError(null); setBuffInitPrompt(null); - setPackTemplates(null); - setPackHook(null); - try { - const report = await applyOnboardingPack(projectPath, projectId); - setPackTemplates(report.templates); - setPackHook(report.hook); - refetchBuff(); - // 只有尚无蓝图时才展示 init prompt;已有蓝图则 Buff 直接激活无需初始化 - if (bpStatus?.status === "none") { - const p = await generateBlueprintPrompt(projectPath, "init").catch(() => null); - if (p) setBuffInitPrompt(p); - } - } catch (e) { - setBuffError(String(e)); - } finally { - setBuffLoading(false); - } + setOnboardSummary(null); + openOnboard({ projectId, projectName, onClosed: (report) => void afterOnboard(report) }); }; const handleRemoveBuff = async () => { @@ -1098,8 +1086,7 @@ function GovernancePanel({ await removeBlueprintBuff(projectPath); refetchBuff(); setBuffInitPrompt(null); - setPackTemplates(null); - setPackHook(null); + setOnboardSummary(null); } catch (e) { setBuffError(String(e)); } finally { @@ -1296,7 +1283,7 @@ function GovernancePanel({ )}

- 应用接入包:种入 AGENTS.md / lefthook / git-workflow 标准模板(不覆盖已有),并挂 buff 自动监听 git 提交、采集飞轮数据。 + 一键接入:先预览将写入的接入包文件(AGENTS.md / lefthook / git-workflow 等)与本机三端配置,确认后再写入,并挂 buff 自动监听 git 提交、采集飞轮数据。

{buffStatus?.last_sync_at && (

上次同步:{buffStatus.last_sync_at}

@@ -1304,54 +1291,27 @@ function GovernancePanel({ {buffError && (

{buffError}

)} - {packTemplates && ( -
-

模板分发

- {packTemplates.map((t) => { - const style = - TEMPLATE_ACTION_STYLE[t.action] ?? TEMPLATE_ACTION_STYLE.failed; - return ( -
-

- {style.icon} - {t.name} - {style.label ?? t.detail} -

- {t.warning && ( -

⚠ {t.warning}

- )} -
- ); - })} - {packHook && ( -

- - {packHook.status === "activated" ? "✓" : packHook.status === "missing_binary" ? "⚠" : "✗"} - - lefthook hook - - {packHook.detail} - -

- )} -
+ {onboardSummary && ( +

最近一次一键接入:{onboardSummary}

)} {buffStatus?.status === "active" ? ( - +
+ + +
) : ( )} {/* 已有蓝图时施加 Buff:直接激活,无需初始化 */} diff --git a/src/components/dashboard/OnboardPanel.tsx b/src/components/dashboard/OnboardPanel.tsx new file mode 100644 index 0000000..581d9ad --- /dev/null +++ b/src/components/dashboard/OnboardPanel.tsx @@ -0,0 +1,189 @@ +// 一键接入面板(agent-client-parity P8g):plan_onboarding 预览 → 人确认 → onboard_project → 分步结果。 +// 全局挂载在 App,由 useUIStore.openOnboard 打开。判定全部来自后端,映射见 onboardView.ts。 +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { onboardProject, planOnboarding, type OnboardReport, type OnboardingPlan } from "../../lib/commands"; +import { useUIStore } from "../../store/ui"; +import { + DEFAULT_SELECTION, + buildPlanView, + buildReportView, + confirmRequest, + selectionOptions, + setBypass, + toggleSkip, + type OnboardSelection, +} from "./onboardView"; +import { PlanPreview, ReportResult } from "./OnboardSections"; + +type Phase = + | { kind: "planning" } + | { kind: "preview"; plan: OnboardingPlan; planned: OnboardSelection } + | { kind: "applying"; plan: OnboardingPlan; planned: OnboardSelection } + | { kind: "report"; report: OnboardReport } + | { kind: "error"; message: string }; + +export function OnboardPanel() { + const target = useUIStore((s) => s.onboardTarget); + if (!target) return null; + // key:换项目即重置面板状态 + return ; +} + +function OnboardPanelBody() { + const target = useUIStore((s) => s.onboardTarget)!; + const closeOnboard = useUIStore((s) => s.closeOnboard); + const showToast = useUIStore((s) => s.showToast); + const qc = useQueryClient(); + const [phase, setPhase] = useState({ kind: "planning" }); + const [selection, setSelection] = useState(DEFAULT_SELECTION); + const lastReport = useRef(null); + const requestSeq = useRef(0); + + const plan = useCallback( + async (chosen: OnboardSelection) => { + const seq = ++requestSeq.current; + setPhase({ kind: "planning" }); + try { + const next = await planOnboarding(target.projectId, selectionOptions(chosen)); + if (seq === requestSeq.current) setPhase({ kind: "preview", plan: next, planned: chosen }); + } catch (e) { + if (seq === requestSeq.current) setPhase({ kind: "error", message: String(e) }); + } + }, + [target.projectId], + ); + + useEffect(() => { + void plan(DEFAULT_SELECTION); + }, [plan]); + + const applyConfirmed = async () => { + if (phase.kind !== "preview") return; + const request = confirmRequest(phase.plan, phase.planned, selection); + if (!request) return; + const seq = ++requestSeq.current; + setPhase({ kind: "applying", plan: phase.plan, planned: phase.planned }); + try { + const report = await onboardProject(request.projectId, request.expectedDigest, request.confirmed, request.options); + if (seq !== requestSeq.current) return; + lastReport.current = report; + setPhase({ kind: "report", report }); + showToast(buildReportView(report).toast, 4000); + void qc.invalidateQueries({ queryKey: ["projects"] }); + } catch (e) { + if (seq === requestSeq.current) setPhase({ kind: "error", message: String(e) }); + } + }; + + /** refused 附当前计划:回到预览,按新计划重新确认(选项沿用后端拒绝时的那组) */ + const reviewReplan = (next: OnboardingPlan) => { + if (phase.kind !== "report") return; + setPhase({ kind: "preview", plan: next, planned: selection }); + }; + + const close = () => { + if (phase.kind === "applying") return; // 写入进行中不允许关闭,避免误以为已取消 + requestSeq.current += 1; + const report = lastReport.current; + closeOnboard(); + target.onClosed?.(report); + }; + + const title = `一键接入${target.projectName ? ` · ${target.projectName}` : ""}`; + return ( +
+
+
+

{title}

+ +
+
+ {phase.kind === "planning" &&

正在生成接入计划(只读,不写任何文件)…

} + {phase.kind === "error" && ( +

❌ {phase.message}

+ )} + {(phase.kind === "preview" || phase.kind === "applying") && ( + setSelection((s) => toggleSkip(s, item))} + onBypass={(on) => setSelection((s) => setBypass(s, on))} + /> + )} + {phase.kind === "report" && ( + + )} +
+ void plan(selection)} + onConfirm={() => void applyConfirmed()} + /> +
+
+ ); +} + +const btnCls = "px-4 py-2 rounded-lg text-sm border transition-colors disabled:opacity-50"; + +function PanelFooter({ + phase, + selection, + onCancel, + onReplan, + onConfirm, +}: { + phase: Phase; + selection: OnboardSelection; + onCancel: () => void; + onReplan: () => void; + onConfirm: () => void; +}) { + const view = phase.kind === "preview" ? buildPlanView(phase.plan, phase.planned, selection) : null; + return ( +
+ {view?.confirm.reason &&

{view.confirm.reason}

} + {phase.kind === "applying" && ( +

正在接入:首次可能 30 秒以上,完成前请勿关闭…

+ )} + {phase.kind === "report" ? ( + + ) : ( + + )} + {(phase.kind === "error" || (view && (view.needsReplan || view.blockingCount > 0))) && ( + + )} + {(phase.kind === "preview" || phase.kind === "applying") && ( + + )} +
+ ); +} diff --git a/src/components/dashboard/OnboardSections.tsx b/src/components/dashboard/OnboardSections.tsx new file mode 100644 index 0000000..674446d --- /dev/null +++ b/src/components/dashboard/OnboardSections.tsx @@ -0,0 +1,277 @@ +// 一键接入面板的呈现块(P8g):计划预览与接入结果。只渲染 onboardView 的视图模型,不做判定。 +import type { ReactNode } from "react"; +import type { OnboardBlockedItem, OnboardingPlan } from "../../lib/commands"; +import { PARITY_CHECKS, CHECK_LABEL, type OnboardTone, type PlanView, type ReportView } from "./onboardView"; + +const TONE_CLS: Record = { + ok: "text-green-700 bg-green-50 border-green-200", + info: "text-blue-700 bg-blue-50 border-blue-200", + muted: "text-gray-500 bg-gray-50 border-gray-200", + pending: "text-violet-700 bg-violet-50 border-violet-200", + warn: "text-amber-700 bg-amber-50 border-amber-200", + danger: "text-red-700 bg-red-50 border-red-200", +}; + +function ToneBadge({ tone, children }: { tone: OnboardTone; children: ReactNode }) { + return ( + {children} + ); +} + +function Section({ title, extra, children }: { title: string; extra?: ReactNode; children: ReactNode }) { + return ( +
+
+

{title}

+ {extra} +
+ {children} +
+ ); +} + +function Row({ label, tone, badge, detail, mono }: { label: ReactNode; tone: OnboardTone; badge: string; detail?: string; mono?: boolean }) { + return ( +
+ {label} + {detail && {detail}} + {badge} +
+ ); +} + +// ── 计划预览 ────────────────────────────────────────────────────────────────── + +export function PlanPreview({ + view, + disabled, + onToggleSkip, + onBypass, +}: { + view: PlanView; + disabled: boolean; + onToggleSkip: (item: OnboardBlockedItem) => void; + onBypass: (on: boolean) => void; +}) { + const { fileCounts } = view; + return ( + <> +

+ 将写入 {view.root} + digest {view.digest.slice(0, 8)} +

+ + {view.blocked.length > 0 && ( +
0 && {view.blockingCount} 项阻断确认}> + {view.blocked.map((row) => ( +
+
+ {row.target} + {row.reasonLabel} + {row.stateLabel} + {row.toggleLabel && ( + + )} +
+

{row.detail}

+

{row.hint}

+
+ ))} +
+ )} + +
+
+ +

{view.bypass.detail}

+
+
+

{view.codexTrust.label}

+ {view.codexTrust.detail &&

{view.codexTrust.detail}

} +
+
+ +
新建 {fileCounts.create} · 更新 {fileCounts.update} · 不变 {fileCounts.noop}} + > + {view.files.map((file) => ( +
+ + {file.warning &&

⚠ {file.warning}

} + {file.diff && file.action !== "noop" && ( +
+ 查看差异 +
{file.diff}
+
+ )} +
+ ))} +
+ +
+ {view.machineSteps.map((step) => ( + + ))} +
+ + {view.nextActions.length > 0 && ( +
+ {view.nextActions.map((action) => ( + + ))} +
+ )} + + ); +} + +// ── 接入结果 ────────────────────────────────────────────────────────────────── + +function L1Matrix({ l1 }: { l1: NonNullable }) { + return ( +
+ + + + + {l1.columns.map((column) => ( + + ))} + + + + {PARITY_CHECKS.map((check) => ( + + + {l1.columns.map((column) => { + const cell = column.cells[check]; + return ( + + ); + })} + + ))} + +
检查项 + {column.label} + {column.version && {column.version}} +
+ {check} {CHECK_LABEL[check]} + + {cell.label} +
+ {l1.note &&

{l1.note}

} +
+ ); +} + +export function ReportResult({ + view, + onReviewReplan, +}: { + view: ReportView; + onReviewReplan: (plan: OnboardingPlan) => void; +}) { + const { commit } = view; + return ( + <> +
+
+ {view.statusLabel} + 用时 {view.durationLabel} +
+

{view.summary}

+ {view.reason &&

原因:{view.reason}

} + {view.replan && ( + + )} +
+ + {view.steps.length > 0 && ( +
+ {view.steps.map((step) => ( + + ))} +
+ )} + + {view.l1 && ( +
适用格全部就绪}> + +
+ )} + + {commit.state === "available" && ( +
+ {commit.files.map((file) => ( +

+ {file.code} + {file.path} +

+ ))} + {commit.otherWarning &&

⚠ {commit.otherWarning}

} + {commit.suggestedMessage && ( +

+ 建议提交信息:{commit.suggestedMessage} +

+ )} +
+ )} + {commit.state === "unavailable" &&

无法列出改动:{commit.reason}

} + {view.head &&

{view.head.label}

} + + {(view.pendingUser.length > 0 || view.nextActions.length > 0) && ( +
+ {view.pendingUser.map((row) => ( + + ))} + {view.nextActions.map((action) => ( + + ))} +
+ )} + + {view.completion.length > 0 && ( +
+ + 完成判定明细({view.completion.filter((row) => row.ready).length}/{view.completion.length} 就绪) + +
+ {view.completion.map((row) => ( + + ))} +
+
+ )} +

{view.l3}

+ + ); +} diff --git a/src/components/dashboard/ProjectModal.tsx b/src/components/dashboard/ProjectModal.tsx index bc593e0..566722e 100644 --- a/src/components/dashboard/ProjectModal.tsx +++ b/src/components/dashboard/ProjectModal.tsx @@ -19,6 +19,7 @@ export function ProjectModal() { const editTarget = useUIStore((s) => s.editTarget); const closeEdit = useUIStore((s) => s.closeEdit); const showToast = useUIStore((s) => s.showToast); + const openOnboard = useUIStore((s) => s.openOnboard); const qc = useQueryClient(); const { data: spaces = [] } = useQuery({ queryKey: ["spaces"], queryFn: getSpaces }); @@ -93,6 +94,10 @@ const pickFolder = async (field: "wsl_path" | "win_path") => { qc.invalidateQueries({ queryKey: ["projects"] }); qc.invalidateQueries({ queryKey: ["tagMaps"] }); closeEdit(); + // create_project 本身无副作用;选了目录的新项目弹出接入预览(P8g),由人确认后才写入。不选目录的概念占位不接入 + if (isNew && (form.win_path?.trim() || form.wsl_path?.trim())) { + openOnboard({ projectId: form.id!, projectName: form.name?.trim() }); + } } catch (e) { showToast(`❌ ${e}`); } finally { diff --git a/src/components/dashboard/onboardView.test.ts b/src/components/dashboard/onboardView.test.ts new file mode 100644 index 0000000..a469d11 --- /dev/null +++ b/src/components/dashboard/onboardView.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vitest"; +import type { + ClientParityL1Report, + OnboardBlockedItem, + OnboardReport, + OnboardingPlan, + ParityCell, + ParityCheck, +} from "../../lib/commands"; +import { + DEFAULT_SELECTION, + buildPlanView, + buildReportView, + confirmRequest, + l1MatrixView, + selectionOptions, + setBypass, + toggleSkip, + type OnboardSelection, +} from "./onboardView"; + +const manualCodex: OnboardBlockedItem = { + target: ".codex/config.toml", + reason: "manual_entry", + detail: "已有手写的 lian-jing 条目", + skippable: true, +}; + +const mixedAgents: OnboardBlockedItem = { + target: "AGENTS.md", + reason: "mixed_file", + detail: "托管块外有未提交改动", + skippable: false, +}; + +function plan(overrides: Partial = {}): OnboardingPlan { + return { + project_id: "p1", + root: "E:\\code-project\\demo", + files: [ + { path: "AGENTS.md", action: "create", content_sha256: "a" }, + { path: ".claude/settings.json", action: "update", content_sha256: "b", before_sha256: "c" }, + { path: ".lian-jing/context.json", action: "noop", content_sha256: "d" }, + ], + machine_changes: [ + { step: "token", status: "apply", detail: "将生成项目 token" }, + { step: "claude", status: "noop", detail: "已配置" }, + { step: "codex", status: "not_installed", detail: "未找到 codex" }, + { step: "dsh", status: "not_applicable", detail: "控制面项目" }, + ], + blocked: [], + skipped: [], + bypass_permissions: true, + codex_trust: "untrusted", + next_actions: [{ action: "codex_trust", detail: "在 Codex 里信任 E:\\code-project\\demo" }], + digest: "digest-1", + ...overrides, + }; +} + +const sel = (overrides: Partial = {}): OnboardSelection => ({ ...DEFAULT_SELECTION, ...overrides }); + +describe("buildPlanView 阻断与确认", () => { + it("blocked 非空时确认禁用,并逐项列出文件与原因", () => { + const view = buildPlanView(plan({ blocked: [mixedAgents, manualCodex] }), sel(), sel()); + expect(view.confirm.enabled).toBe(false); + expect(view.confirm.reason).toContain("2 项阻断"); + expect(view.blockingCount).toBe(2); + expect(view.blocked.map((row) => [row.target, row.reasonLabel, row.detail, row.state])).toEqual([ + ["AGENTS.md", "混合文件:托管块外有未提交改动", "托管块外有未提交改动", "blocking"], + [".codex/config.toml", "手写同名条目", "已有手写的 lian-jing 条目", "blocking"], + ]); + expect(view.blocked.every((row) => row.tone === "danger")).toBe(true); + expect(confirmRequest(plan({ blocked: [mixedAgents] }), sel(), sel())).toBeNull(); + }); + + it("只有后端标 skippable 的项给「跳过」,不可跳过的项切换无效", () => { + const view = buildPlanView(plan({ blocked: [mixedAgents, manualCodex] }), sel(), sel()); + expect(view.blocked[0].toggleLabel).toBeUndefined(); + expect(view.blocked[0].hint).toContain("禁止字符串手术"); + expect(view.blocked[1].toggleLabel).toBe("跳过"); + expect(toggleSkip(sel(), mixedAgents)).toEqual(sel()); + expect(toggleSkip(sel(), manualCodex).skipTargets).toEqual([".codex/config.toml"]); + expect(toggleSkip(sel({ skipTargets: [".codex/config.toml"] }), manualCodex).skipTargets).toEqual([]); + }); + + it("选了跳过但未重新计划时确认禁用;按跳过重新计划后可确认", () => { + const chosen = toggleSkip(sel(), manualCodex); + const pending = buildPlanView(plan({ blocked: [manualCodex] }), sel(), chosen); + expect(pending.needsReplan).toBe(true); + expect(pending.blocked[0].state).toBe("skip_pending"); + expect(pending.confirm).toEqual({ enabled: false, reason: "选项已改:须按当前选择重新计划后才能确认" }); + + const replanned = buildPlanView(plan({ blocked: [], skipped: [manualCodex] }), chosen, chosen); + expect(replanned.confirm.enabled).toBe(true); + expect(replanned.blocked[0]).toMatchObject({ state: "skipped", tone: "muted", toggleLabel: "取消跳过" }); + expect(replanned.blockingCount).toBe(0); + }); + + it("确认请求携带计划 digest 与生成计划时的选项", () => { + const planned = sel({ skipTargets: [".codex/config.toml", ".codex/config.toml"] }); + const request = confirmRequest(plan({ skipped: [manualCodex], digest: "digest-xyz" }), planned, planned); + expect(request).toEqual({ + projectId: "p1", + expectedDigest: "digest-xyz", + confirmed: true, + options: { bypass_permissions: true, skip_targets: [".codex/config.toml"] }, + }); + }); + + it("selectionOptions 去重排序,计划与应用两次逐字一致", () => { + expect(selectionOptions(sel({ skipTargets: ["b", "a", "b"] }))).toEqual({ + bypass_permissions: true, + skip_targets: ["a", "b"], + }); + }); +}); + +describe("buildPlanView 灰色说明与单列项", () => { + it("not_installed / not_applicable 显示为灰色说明,不是红色失败", () => { + const view = buildPlanView(plan(), sel(), sel()); + const byStep = Object.fromEntries(view.machineSteps.map((row) => [row.step, row])); + expect(byStep.codex).toMatchObject({ statusLabel: "未安装", tone: "muted" }); + expect(byStep.dsh).toMatchObject({ statusLabel: "不适用", tone: "muted" }); + expect(byStep.token).toMatchObject({ label: "项目 MCP token", statusLabel: "将写入", tone: "info" }); + expect(view.machineSteps.some((row) => row.tone === "danger")).toBe(false); + expect(view.fileCounts).toEqual({ create: 1, update: 1, noop: 1 }); + }); + + it("bypassPermissions 单独列出:将写入可取消,取消后须重新计划", () => { + const view = buildPlanView(plan(), sel(), sel()); + expect(view.bypass).toMatchObject({ willWrite: true, toggleable: true, tone: "warn" }); + expect(view.bypass.label).toContain("bypassPermissions"); + + const off = setBypass(sel(), false); + expect(buildPlanView(plan(), sel(), off).confirm.enabled).toBe(false); + const cancelled = buildPlanView(plan({ bypass_permissions: false }), off, off); + expect(cancelled.bypass).toMatchObject({ willWrite: false, selected: false, toggleable: true }); + expect(cancelled.bypass.label).toBe("已按项目取消 bypassPermissions"); + expect(confirmRequest(plan({ bypass_permissions: false }), off, off)?.options.bypass_permissions).toBe(false); + + const existing = buildPlanView(plan({ bypass_permissions: false }), sel(), sel()); + expect(existing.bypass).toMatchObject({ toggleable: false, tone: "muted" }); + }); + + it("Codex 信任单独列出:未信任为「待你操作」而非阻断", () => { + const view = buildPlanView(plan(), sel(), sel()); + expect(view.codexTrust).toMatchObject({ state: "untrusted", tone: "pending" }); + expect(view.codexTrust.detail).toContain("信任"); + expect(view.confirm.enabled).toBe(true); + expect(buildPlanView(plan({ codex_trust: "not_installed", next_actions: [] }), sel(), sel()).codexTrust.tone).toBe("muted"); + }); +}); + +function cell(state: ParityCell["state"], extra: Partial = {}): ParityCell { + return { state, evidence: "protocol", detail: `${state} detail`, ...extra }; +} + +function cells(fill: (check: ParityCheck) => ParityCell): Record { + return Object.fromEntries((["A", "B", "C", "D", "E", "F"] as ParityCheck[]).map((c) => [c, fill(c)])) as Record< + ParityCheck, + ParityCell + >; +} + +const l1: ClientParityL1Report = { + project_id: "p1", + tier: "l1", + probe_spec_version: 1, + profile: "project", + expected_route: "http://127.0.0.1:27190/mcp/projects/p1", + expected_tools: 12, + nonce: "n", + clients: [ + { client: "claude", version: "2.1.90", route: null, cells: cells(() => cell("ready")), duration_ms: 1 }, + { + client: "codex", + version: "0.50", + route: null, + cells: cells((c) => (c < "C" ? cell("ready") : cell("fail", { cause: "codex_project_untrusted" }))), + duration_ms: 1, + }, + { client: "dsh", version: null, route: null, cells: cells(() => cell("not_installed")), duration_ms: 1 }, + ], + all_applicable_ready: false, + duration_ms: 3, + note: "", +}; + +function report(overrides: Partial = {}): OnboardReport { + return { + project_id: "p1", + status: "done", + summary: "9 步完成", + bypass_permissions: true, + steps: [ + { step: "project_files", status: "applied", detail: "写入 14 个文件" }, + { step: "dsh", status: "not_applicable", reason: "not_installed", detail: "未安装 DSH" }, + { step: "codex", status: "blocked", dep: "token", detail: "token 未就绪" }, + ], + completion: [ + { item: "Claude MCP 已配置", source: "clients", ready: true, detail: "", pending_user: false }, + { item: "Codex 信任", source: "l1", ready: false, detail: "待信任", pending_user: true }, + ], + l1, + l3: "L3 尚未提供", + written_files: ["AGENTS.md"], + repo_changes: { + state: "available", + status_porcelain: [], + diff_stat: "2 files changed", + onboard_files: [ + { path: "AGENTS.md", code: "??" }, + { path: ".claude/settings.json", code: " M" }, + ], + other_changes: 1, + }, + head: { before: "abc", after: "abc", unchanged: true }, + suggested_commit_message: "chore(lian-jing): 一键接入", + next_actions: [{ action: "commit", detail: "git add -- AGENTS.md" }], + duration_ms: 7900, + ...overrides, + }; +} + +describe("buildReportView", () => { + it("分步结果:not_applicable 灰色、blocked 注明依赖", () => { + const view = buildReportView(report()); + expect(view.steps.map((row) => [row.step, row.statusLabel, row.tone, row.note])).toEqual([ + ["project_files", "已写入", "ok", undefined], + ["dsh", "不适用", "muted", "未安装"], + ["codex", "未执行", "warn", "依赖「项目 MCP token」未完成"], + ]); + expect(view.toast).toBe("一键接入接入完成 ✓"); + expect(view.durationLabel).toBe("7.9 秒"); + }); + + it("待你提交的 N 个文件与 Codex 信任单列,不计入完成", () => { + const view = buildReportView(report()); + expect(view.commit).toMatchObject({ state: "available", title: "待你提交的 2 个文件", otherChanges: 1 }); + expect(view.commit.state === "available" && view.commit.otherWarning).toContain("不要 git add -A"); + expect(view.completion.map((row) => row.item)).toEqual(["Claude MCP 已配置"]); + expect(view.pendingUser).toEqual([ + { item: "Codex 信任", source: "l1", ready: false, label: "待你操作", tone: "pending", detail: "待信任" }, + ]); + expect(view.head).toMatchObject({ unchanged: true, tone: "ok" }); + }); + + it("refused 附当前计划供重新确认,提示以 ❌ 开头且不报告改动", () => { + const replan = plan({ digest: "digest-2" }); + const view = buildReportView( + report({ status: "refused", reason: "digest 不符", plan: replan, repo_changes: undefined, l1: undefined }), + ); + expect(view.replan).toBe(replan); + expect(view.tone).toBe("warn"); + expect(view.toast).toBe("❌ 一键接入已拒绝(零写入):digest 不符"); + expect(view.commit).toEqual({ state: "none" }); + expect(view.l1).toBeUndefined(); + }); +}); + +describe("l1MatrixView", () => { + it("未安装为灰色,Codex 未信任为「待你信任」而非红色失败", () => { + const view = l1MatrixView(l1); + const [claude, codex, dsh] = view.columns; + expect(claude.cells.F).toMatchObject({ label: "就绪", tone: "ok" }); + expect(codex.cells.A.tone).toBe("ok"); + expect(codex.cells.C).toMatchObject({ label: "待你信任", tone: "pending", cause: "codex_project_untrusted" }); + expect(dsh.cells.A).toMatchObject({ label: "未安装", tone: "muted" }); + expect(Object.values(dsh.cells).every((c) => c.tone === "muted")).toBe(true); + expect(dsh.label).toBe("DSH"); + }); + + it("依赖步骤未完成的格注明步骤;真实失败为红色", () => { + const report = { + ...l1, + clients: [ + { + ...l1.clients[0], + cells: cells((c) => + c === "A" ? cell("blocked", { cause: "dependency", dep: "claude" }) : cell("fail", { cause: "auth_failed" }), + ), + }, + ], + }; + const [claude] = l1MatrixView(report).columns; + expect(claude.cells.A).toMatchObject({ label: "未检查:Claude Code 步骤未完成", tone: "warn" }); + expect(claude.cells.F).toMatchObject({ label: "失败", tone: "danger" }); + }); +}); diff --git a/src/components/dashboard/onboardView.ts b/src/components/dashboard/onboardView.ts new file mode 100644 index 0000000..3af0c27 --- /dev/null +++ b/src/components/dashboard/onboardView.ts @@ -0,0 +1,671 @@ +// 一键接入(agent-client-parity P8g)视图模型:把 OnboardingPlan / OnboardReport 转成界面要展示的结构。 +// 只做呈现映射,不复制本体判定(AGENTS.md「边界单点实现」):阻断与否看 plan.blocked、能否跳过看 +// blocked[].skippable、完成与否看 report.status 与 completion[].pending_user,全部由后端给出。 +// 界面自己只管一件事:当前选择(bypass / 跳过项)与生成计划时所用的选择是否一致——不一致必须重新计划, +// 因为 onboardProject 须带同一组选项与 digest(否则后端 refused)。 + +import type { + ClientParityL1Report, + CodexTrust, + OnboardBlockReason, + OnboardBlockedItem, + OnboardCompletionItem, + OnboardFileAction, + OnboardNextAction, + OnboardOptions, + OnboardPendingFile, + OnboardReport, + OnboardStatus, + OnboardStepReport, + OnboardingPlan, + ParityCause, + ParityCell, + ParityCellState, + ParityCheck, + ParityEvidence, +} from "../../lib/commands"; + +/** 呈现色调。muted 是灰色说明(未安装 / 不适用 / 已跳过 / 不变),不是失败;pending 是「待你操作」 */ +export type OnboardTone = "ok" | "info" | "muted" | "pending" | "warn" | "danger"; + +// ── 选项:计划与应用必须一致 ────────────────────────────────────────────────── + +export interface OnboardSelection { + /** 勾选 = 保持默认:项目 .claude/settings.json 还没有 permissions 时写入 bypassPermissions */ + bypassPermissions: boolean; + /** 人逐项选「跳过」的阻断项 target(只对 skippable 的项生效) */ + skipTargets: string[]; +} + +export const DEFAULT_SELECTION: OnboardSelection = { bypassPermissions: true, skipTargets: [] }; + +function normalizeTargets(targets: readonly string[]): string[] { + return [...new Set(targets)].sort(); +} + +/** 选择 → 传给 planOnboarding / onboardProject 的选项(跳过项去重排序,两次调用逐字一致) */ +export function selectionOptions(selection: OnboardSelection): OnboardOptions { + return { + bypass_permissions: selection.bypassPermissions, + skip_targets: normalizeTargets(selection.skipTargets), + }; +} + +export function sameSelection(a: OnboardSelection, b: OnboardSelection): boolean { + if (a.bypassPermissions !== b.bypassPermissions) return false; + const x = normalizeTargets(a.skipTargets); + const y = normalizeTargets(b.skipTargets); + return x.length === y.length && x.every((target, i) => target === y[i]); +} + +/** 切换某阻断项的「跳过」。后端没标 skippable 的项不响应(只能中止或处理后重新计划) */ +export function toggleSkip(selection: OnboardSelection, item: OnboardBlockedItem): OnboardSelection { + if (!item.skippable) return selection; + const has = selection.skipTargets.includes(item.target); + return { + ...selection, + skipTargets: has + ? selection.skipTargets.filter((target) => target !== item.target) + : [...selection.skipTargets, item.target], + }; +} + +export function setBypass(selection: OnboardSelection, bypassPermissions: boolean): OnboardSelection { + return { ...selection, bypassPermissions }; +} + +// ── 文案映射(只翻译后端给的枚举,不推断) ───────────────────────────────────── + +interface Badge { + label: string; + tone: OnboardTone; +} + +const FILE_ACTION: Record = { + create: { label: "新建", tone: "info" }, + update: { label: "更新", tone: "info" }, + noop: { label: "不变", tone: "muted" }, +}; + +const BLOCK_REASON_LABEL: Record = { + mixed_file: "混合文件:托管块外有未提交改动", + identity_conflict: "身份冲突:目录已属于别的项目", + manual_entry: "手写同名条目", + shared_key: "共享项目键:生效条目属于别的项目", + needs_human: "需人工处理", +}; + +/** 不可跳过的项怎么办(可跳过的项统一给「跳过 / 中止」) */ +const BLOCK_REASON_HINT: Record = { + mixed_file: "先在子项目提交或丢弃块外改动,再重新计划(禁止字符串手术);或中止", + identity_conflict: "核对 .lian-jing/context.json 的归属后重新计划;或中止", + manual_entry: "处理后重新计划;或中止", + shared_key: "处理后重新计划;或中止", + needs_human: "按说明处理后重新计划;或中止", +}; + +export const STEP_LABEL: Record = { + identity: "项目身份", + project_files: "项目文件", + git_hooks: "Git hooks(lefthook install)", + product_group_buff: "产品组与 buff", + token: "项目 MCP token", + shim: "稳定 helper 入口", + claude: "Claude Code", + codex: "Codex", + dsh: "DSH", +}; + +/** 计划里本机七步的预计动作 */ +const MACHINE_STATUS: Record = { + noop: { label: "已就绪", tone: "ok" }, + apply: { label: "将写入", tone: "info" }, + not_installed: { label: "未安装", tone: "muted" }, + not_applicable: { label: "不适用", tone: "muted" }, + unavailable: { label: "无法判定", tone: "warn" }, + manual_kept: { label: "手写同名条目", tone: "warn" }, + other_project: { label: "属于别的项目", tone: "warn" }, + needs_human: { label: "需人工处理", tone: "danger" }, +}; + +const CODEX_TRUST: Record = { + trusted: { label: "Codex 已信任该目录", tone: "ok" }, + untrusted: { label: "待你操作:在 Codex 里信任该目录", tone: "pending" }, + not_installed: { label: "未安装 Codex", tone: "muted" }, + unknown: { label: "待你确认:无法判断 Codex 是否信任该目录", tone: "pending" }, +}; + +export const NEXT_ACTION_LABEL: Record = { + codex_trust: "在 Codex 里信任该目录", + commit: "提交接入文件", + gitea_repo: "建 Gitea 仓库", + ci: "配置 CI", + flywheel: "飞轮数据", + toolchain: "准备工具链", + delivery_profile: "交付配置", + lefthook: "安装 lefthook", + restart_dsh_gui: "重启 DSH 桌面 GUI", + claude_trust: "Claude 首次打开目录时确认信任", + reopen_sessions: "重开已打开的会话", + repo_layout: "仓库布局说明", +}; + +// ── 计划预览 ────────────────────────────────────────────────────────────────── + +export interface FileRow { + path: string; + action: OnboardFileAction; + actionLabel: string; + tone: OnboardTone; + diff?: string; + warning?: string; +} + +export interface MachineStepRow { + step: string; + label: string; + status: string; + statusLabel: string; + tone: OnboardTone; + detail: string; +} + +/** blocking 阻断中;skip_pending 已选跳过、待重新计划;skipped 计划已按跳过生成;unskip_pending 已取消跳过、待重新计划 */ +export type BlockedRowState = "blocking" | "skip_pending" | "skipped" | "unskip_pending"; + +export interface BlockedRow { + item: OnboardBlockedItem; + target: string; + reasonLabel: string; + detail: string; + /** 来自后端 skippable:为 true 才给「跳过」按钮 */ + skippable: boolean; + state: BlockedRowState; + stateLabel: string; + tone: OnboardTone; + hint: string; + /** 可跳过的项的按钮文案;不可跳过为空(只能中止或处理后重新计划) */ + toggleLabel?: string; +} + +export interface BypassView { + /** 本次计划会新写入 permissions.defaultMode=bypassPermissions(后端 plan.bypass_permissions) */ + willWrite: boolean; + /** 当前选择:勾选 = 保持默认写入 */ + selected: boolean; + /** 生成本计划时的选择 */ + planned: boolean; + label: string; + detail: string; + tone: OnboardTone; + /** 会写入或已按项目取消时给开关;项目已有 permissions 时开关无意义,不给 */ + toggleable: boolean; +} + +export interface CodexTrustView { + state: CodexTrust; + label: string; + tone: OnboardTone; + detail: string; +} + +export interface NextActionRow { + action: string; + label: string; + detail: string; + tone: OnboardTone; +} + +export interface PlanView { + projectId: string; + root: string; + digest: string; + files: FileRow[]; + fileCounts: Record; + machineSteps: MachineStepRow[]; + /** plan.blocked 与 plan.skipped 合并展示 */ + blocked: BlockedRow[]; + /** 仍阻断确认的项数(= plan.blocked.length) */ + blockingCount: number; + bypass: BypassView; + codexTrust: CodexTrustView; + nextActions: NextActionRow[]; + /** 当前选择与生成计划时不同:必须重新计划才能确认 */ + needsReplan: boolean; + confirm: { enabled: boolean; reason?: string }; +} + +const BLOCKED_STATE: Record = { + blocking: { label: "阻断", tone: "danger" }, + skip_pending: { label: "将跳过(需重新计划)", tone: "warn" }, + skipped: { label: "已跳过:保留现状,该端不写", tone: "muted" }, + unskip_pending: { label: "将恢复阻断(需重新计划)", tone: "warn" }, +}; + +function blockedRow(item: OnboardBlockedItem, inPlanSkipped: boolean, selected: boolean): BlockedRow { + const skippable = item.skippable === true; + const state: BlockedRowState = inPlanSkipped + ? selected ? "skipped" : "unskip_pending" + : skippable && selected ? "skip_pending" : "blocking"; + const badge = BLOCKED_STATE[state]; + return { + item, + target: item.target, + reasonLabel: BLOCK_REASON_LABEL[item.reason] ?? item.reason, + detail: item.detail, + skippable, + state, + stateLabel: badge.label, + tone: badge.tone, + hint: skippable + ? "可逐项「跳过」:保留现状、该端不写(跳过不等于该端可用);或中止" + : BLOCK_REASON_HINT[item.reason] ?? "处理后重新计划;或中止", + toggleLabel: skippable ? (selected ? "取消跳过" : "跳过") : undefined, + }; +} + +function bypassView(plan: OnboardingPlan, planned: OnboardSelection, selection: OnboardSelection): BypassView { + const base = { willWrite: plan.bypass_permissions, selected: selection.bypassPermissions, planned: planned.bypassPermissions }; + if (plan.bypass_permissions) { + return { + ...base, + label: "将写入 permissions.defaultMode=bypassPermissions", + detail: "项目 .claude/settings.json 还没有 permissions:按炼境默认写入,Claude Code 在本项目跳过权限确认。可按项目取消(取消后须重新计划)。", + tone: "warn", + toggleable: true, + }; + } + if (!planned.bypassPermissions) { + return { + ...base, + label: "已按项目取消 bypassPermissions", + detail: "settings.json 缺 permissions 时写显式 defaultMode=default 记住选择,之后的重注入不会补回。", + tone: "muted", + toggleable: true, + }; + } + return { + ...base, + label: "不新写入 bypassPermissions", + detail: "项目 .claude/settings.json 已有 permissions 设置,本次不改。", + tone: "muted", + toggleable: false, + }; +} + +function nextActionRow(action: OnboardNextAction): NextActionRow { + return { + action: action.action, + label: NEXT_ACTION_LABEL[action.action] ?? action.action, + detail: action.detail, + tone: action.action === "codex_trust" ? "pending" : "info", + }; +} + +/** + * 计划 → 预览视图。planned 是生成本计划时的选择,selection 是界面当前的选择。 + * 确认按钮:plan.blocked 非空一律禁用(后端也会 refused);选择改过未重新计划也禁用。 + */ +export function buildPlanView( + plan: OnboardingPlan, + planned: OnboardSelection, + selection: OnboardSelection, +): PlanView { + const selected = new Set(selection.skipTargets); + const fileCounts: Record = { create: 0, update: 0, noop: 0 }; + const files = plan.files.map((file): FileRow => { + fileCounts[file.action] = (fileCounts[file.action] ?? 0) + 1; + const badge = FILE_ACTION[file.action] ?? { label: file.action, tone: "warn" }; + return { + path: file.path, + action: file.action, + actionLabel: badge.label, + tone: file.warning ? "warn" : badge.tone, + diff: file.diff, + warning: file.warning, + }; + }); + const machineSteps = plan.machine_changes.map((step): MachineStepRow => { + const badge = MACHINE_STATUS[step.status] ?? { label: step.status, tone: "warn" }; + return { + step: step.step, + label: STEP_LABEL[step.step] ?? step.step, + status: step.status, + statusLabel: badge.label, + tone: badge.tone, + detail: step.detail, + }; + }); + const blocked = [ + ...plan.blocked.map((item) => blockedRow(item, false, selected.has(item.target))), + ...(plan.skipped ?? []).map((item) => blockedRow(item, true, selected.has(item.target))), + ]; + const trust = CODEX_TRUST[plan.codex_trust] ?? { label: plan.codex_trust, tone: "warn" }; + const trustAction = plan.next_actions.find((action) => action.action === "codex_trust"); + const needsReplan = !sameSelection(planned, selection); + const blockingCount = plan.blocked.length; + const reason = needsReplan + ? "选项已改:须按当前选择重新计划后才能确认" + : blockingCount > 0 + ? `有 ${blockingCount} 项阻断,确认已禁用:可跳过的项选「跳过」后重新计划,其余处理后重新计划或中止` + : undefined; + return { + projectId: plan.project_id, + root: plan.root, + digest: plan.digest, + files, + fileCounts, + machineSteps, + blocked, + blockingCount, + bypass: bypassView(plan, planned, selection), + codexTrust: { + state: plan.codex_trust, + label: trust.label, + tone: trust.tone, + detail: trustAction?.detail ?? (plan.codex_trust === "trusted" ? "Codex 会加载项目 .codex/config.toml" : ""), + }, + nextActions: plan.next_actions.map(nextActionRow), + needsReplan, + confirm: { enabled: reason === undefined, reason }, + }; +} + +export interface OnboardConfirmRequest { + projectId: string; + /** 用户确认过的计划 digest */ + expectedDigest: string; + confirmed: true; + /** 生成该计划时的选项(不是界面当前选择) */ + options: OnboardOptions; +} + +/** 确认接入的调用参数;确认按钮应禁用时返回 null(不发请求) */ +export function confirmRequest( + plan: OnboardingPlan, + planned: OnboardSelection, + selection: OnboardSelection, +): OnboardConfirmRequest | null { + if (!buildPlanView(plan, planned, selection).confirm.enabled) return null; + return { + projectId: plan.project_id, + expectedDigest: plan.digest, + confirmed: true, + options: selectionOptions(planned), + }; +} + +// ── 接入报告 ────────────────────────────────────────────────────────────────── + +const STATUS: Record = { + done: { label: "接入完成", tone: "ok" }, + partial: { label: "部分完成", tone: "warn" }, + failed: { label: "未执行:预检失败(零写入)", tone: "danger" }, + refused: { label: "已拒绝(零写入)", tone: "warn" }, + busy: { label: "该项目正在接入中(零写入)", tone: "info" }, +}; + +const STEP_STATUS: Record = { + converged: { label: "已一致", tone: "ok" }, + applied: { label: "已写入", tone: "ok" }, + skipped: { label: "已跳过", tone: "muted" }, + not_applicable: { label: "不适用", tone: "muted" }, + blocked: { label: "未执行", tone: "warn" }, + failed: { label: "失败", tone: "danger" }, + needs_human: { label: "需人工处理", tone: "warn" }, +}; + +/** 步骤 reason 的常见取值(其余原样展示) */ +const STEP_REASON: Record = { + not_installed: "未安装", + control_plane: "控制面项目", + wsl: "WSL 项目", + manual_kept: "保留手写配置", + other_project: "属于别的项目", + deferred: "延后", +}; + +export interface StepRow { + step: string; + label: string; + status: OnboardStepReport["status"]; + statusLabel: string; + tone: OnboardTone; + /** reason 或 dep 的说明 */ + note?: string; + detail: string; +} + +export interface CompletionRow { + item: string; + source: OnboardCompletionItem["source"]; + ready: boolean; + label: string; + tone: OnboardTone; + detail: string; +} + +export const PARITY_CHECKS: ParityCheck[] = ["A", "B", "C", "D", "E", "F"]; + +export const CHECK_LABEL: Record = { + A: "项目规则", + B: "lian-jing skill", + C: "MCP 工具集", + D: "get_diagnostics", + E: "列出开发变量", + F: "敏感读取认证链", +}; + +const CELL_STATE: Record = { + ready: { label: "就绪", tone: "ok" }, + fail: { label: "失败", tone: "danger" }, + unknown: { label: "未知", tone: "warn" }, + not_installed: { label: "未安装", tone: "muted" }, + not_applicable: { label: "不适用", tone: "muted" }, + blocked: { label: "未检查", tone: "warn" }, + timeout: { label: "超时", tone: "danger" }, +}; + +export const EVIDENCE_LABEL: Record = { + static: "静态", + client_introspection: "客户端自省", + protocol: "协议重放", + session_event: "会话事件", + server_trace: "服务端轨迹", +}; + +/** 只等用户在客户端里操作的归因(与后端 completion 的 pending_user 同口径,仅用于着色) */ +const PENDING_USER_CAUSES: ParityCause[] = ["codex_project_untrusted"]; + +export interface L1CellView { + state: ParityCellState; + label: string; + tone: OnboardTone; + evidence: ParityEvidence; + evidenceLabel: string; + cause?: ParityCause; + detail: string; +} + +export interface L1ColumnView { + client: string; + label: string; + version: string | null; + route: string | null; + cells: Record; +} + +export interface L1MatrixView { + profile: ClientParityL1Report["profile"]; + allApplicableReady: boolean; + note: string; + columns: L1ColumnView[]; +} + +function cellView(cell: ParityCell): L1CellView { + const badge = CELL_STATE[cell.state] ?? { label: cell.state, tone: "warn" }; + const pendingUser = cell.state !== "ready" && cell.cause !== undefined && PENDING_USER_CAUSES.includes(cell.cause); + const label = pendingUser + ? "待你信任" + : cell.state === "blocked" && cell.dep + ? `未检查:${STEP_LABEL[cell.dep] ?? cell.dep} 步骤未完成` + : badge.label; + return { + state: cell.state, + label, + tone: pendingUser ? "pending" : badge.tone, + evidence: cell.evidence, + evidenceLabel: EVIDENCE_LABEL[cell.evidence] ?? cell.evidence, + cause: cell.cause, + detail: cell.detail, + }; +} + +export function l1MatrixView(report: ClientParityL1Report): L1MatrixView { + return { + profile: report.profile, + allApplicableReady: report.all_applicable_ready, + note: report.note, + columns: report.clients.map((column) => { + const cells = {} as Record; + for (const check of PARITY_CHECKS) { + const cell = column.cells[check]; + cells[check] = cell + ? cellView(cell) + : { state: "unknown", label: "缺失", tone: "warn", evidence: "static", evidenceLabel: EVIDENCE_LABEL.static, detail: "报告未给出该格" }; + } + return { + client: column.client, + label: STEP_LABEL[column.client] ?? column.client, + version: column.version, + route: column.route, + cells, + }; + }), + }; +} + +export type CommitView = + | { + state: "available"; + /** 「待你提交的 N 个文件」 */ + title: string; + files: OnboardPendingFile[]; + otherChanges: number; + /** 有非接入改动时提醒勿混进接入提交 */ + otherWarning?: string; + suggestedMessage?: string; + diffStat: string; + } + | { state: "unavailable"; reason: string } + /** 未执行写入(refused / busy / failed)时不报告改动 */ + | { state: "none" }; + +export interface ReportView { + status: OnboardStatus; + statusLabel: string; + tone: OnboardTone; + summary: string; + reason?: string; + steps: StepRow[]; + /** 计入「完成」的项(不含 pending_user) */ + completion: CompletionRow[]; + /** 「待你操作」:只等你在客户端里操作、不计入完成判定的项(目前只有 Codex 信任) */ + pendingUser: CompletionRow[]; + l1?: L1MatrixView; + l3: string; + bypassWritten: boolean; + writtenFiles: string[]; + commit: CommitView; + nextActions: NextActionRow[]; + head?: { unchanged: boolean; label: string; tone: OnboardTone }; + /** refused 且附当前计划:须回到预览、按新计划重新确认 */ + replan?: OnboardingPlan; + durationLabel: string; + /** 结果提示:失败类状态以「❌」开头 */ + toast: string; +} + +function stepRow(step: OnboardStepReport): StepRow { + const badge = STEP_STATUS[step.status] ?? { label: step.status, tone: "warn" }; + let note: string | undefined; + if (step.status === "blocked") { + note = `依赖「${STEP_LABEL[step.dep] ?? step.dep}」未完成`; + } else if ("reason" in step && step.reason) { + note = STEP_REASON[step.reason] ?? step.reason; + } + return { + step: step.step, + label: STEP_LABEL[step.step] ?? step.step, + status: step.status, + statusLabel: badge.label, + tone: badge.tone, + note, + detail: step.detail, + }; +} + +function completionRow(item: OnboardCompletionItem): CompletionRow { + const pending = !item.ready && item.pending_user; + return { + item: item.item, + source: item.source, + ready: item.ready, + label: item.ready ? "就绪" : pending ? "待你操作" : "未就绪", + tone: item.ready ? "ok" : pending ? "pending" : "danger", + detail: item.detail, + }; +} + +function commitView(report: OnboardReport): CommitView { + const changes = report.repo_changes; + if (!changes) return { state: "none" }; + if (changes.state === "unavailable") return { state: "unavailable", reason: changes.reason }; + return { + state: "available", + title: `待你提交的 ${changes.onboard_files.length} 个文件`, + files: changes.onboard_files, + otherChanges: changes.other_changes, + otherWarning: + changes.other_changes > 0 + ? `另有 ${changes.other_changes} 处不属于接入的改动:只 add 上面的文件,不要 git add -A` + : undefined, + suggestedMessage: report.suggested_commit_message, + diffStat: changes.diff_stat, + }; +} + +const FAILURE_STATUSES: OnboardStatus[] = ["failed", "refused", "busy"]; + +/** 报告 → 结果视图(分步结果、L1 矩阵、待你操作、待提交文件、next_actions) */ +export function buildReportView(report: OnboardReport): ReportView { + const badge = STATUS[report.status] ?? { label: report.status, tone: "warn" }; + const completion = report.completion.map((item) => ({ item, row: completionRow(item) })); + const failure = FAILURE_STATUSES.includes(report.status); + return { + status: report.status, + statusLabel: badge.label, + tone: badge.tone, + summary: report.summary, + reason: report.reason, + steps: report.steps.map(stepRow), + completion: completion.filter(({ item }) => !item.pending_user).map(({ row }) => row), + pendingUser: completion.filter(({ item }) => item.pending_user).map(({ row }) => row), + l1: report.l1 ? l1MatrixView(report.l1) : undefined, + l3: report.l3, + bypassWritten: report.bypass_permissions, + writtenFiles: report.written_files ?? [], + commit: commitView(report), + nextActions: report.next_actions.map(nextActionRow), + head: report.head + ? report.head.unchanged + ? { unchanged: true, label: "HEAD 未变(炼境不 add、不 commit)", tone: "ok" } + : { unchanged: false, label: "HEAD 已变化:写入期间有别的提交,请核对", tone: "warn" } + : undefined, + replan: report.status === "refused" ? report.plan : undefined, + durationLabel: `${(report.duration_ms / 1000).toFixed(1)} 秒`, + toast: failure + ? `❌ 一键接入${badge.label}:${report.reason ?? report.summary}` + : `一键接入${badge.label} ✓`, + }; +} diff --git a/src/components/manage/NewProjectModal.tsx b/src/components/manage/NewProjectModal.tsx index a693669..022e8bb 100644 --- a/src/components/manage/NewProjectModal.tsx +++ b/src/components/manage/NewProjectModal.tsx @@ -28,7 +28,10 @@ const EXEC_STEPS = [ export function NewProjectModal({ spaceId, onClose }: Props) { const showToast = useUIStore((s) => s.showToast); + const openOnboard = useUIStore((s) => s.openOnboard); const qc = useQueryClient(); + /** 模板创建成功的项目 ID:自动弹接入预览(P8g),取消后可从完成页再次打开 */ + const [createdId, setCreatedId] = useState(null); // ── Step 1 ──────────────────────────────────────────────────────────────── const [platform, setPlatform] = useState<"win" | "wsl">("win"); @@ -128,7 +131,7 @@ export function NewProjectModal({ spaceId, onClose }: Props) { const variantCmd = selectedVariants.length > 0 ? selectedVariants[selectedVariantIdx]?.initCommands : undefined; - await createProjectFromTemplate( + const result = await createProjectFromTemplate( selectedTemplateId, parentPath, projectName.trim(), @@ -138,6 +141,8 @@ export function NewProjectModal({ spaceId, onClose }: Props) { ); qc.invalidateQueries({ queryKey: ["projects"] }); setStep("done"); + setCreatedId(result.projectId); + openOnboard({ projectId: result.projectId, projectName: projectName.trim() }); } catch (e) { setLogs((prev) => [...prev, `❌ 创建失败: ${e}`]); setHasError(true); @@ -449,6 +454,14 @@ export function NewProjectModal({ spaceId, onClose }: Props) { )}
+ {step === "done" && createdId && ( + + )} diff --git a/src/components/dashboard/ProjectModal.tsx b/src/components/dashboard/ProjectModal.tsx index 566722e..0e627c2 100644 --- a/src/components/dashboard/ProjectModal.tsx +++ b/src/components/dashboard/ProjectModal.tsx @@ -95,7 +95,9 @@ const pickFolder = async (field: "wsl_path" | "win_path") => { qc.invalidateQueries({ queryKey: ["tagMaps"] }); closeEdit(); // create_project 本身无副作用;选了目录的新项目弹出接入预览(P8g),由人确认后才写入。不选目录的概念占位不接入 - if (isNew && (form.win_path?.trim() || form.wsl_path?.trim())) { + // 与后端登记同口径:按 platform 只看有效根目录那一列(另一列残留不算) + const effectiveRoot = form.platform === "wsl" ? form.wsl_path : form.win_path; + if (isNew && effectiveRoot?.trim()) { openOnboard({ projectId: form.id!, projectName: form.name?.trim() }); } } catch (e) { diff --git a/src/components/dashboard/onboardView.ts b/src/components/dashboard/onboardView.ts index 3af0c27..fd7d32a 100644 --- a/src/components/dashboard/onboardView.ts +++ b/src/components/dashboard/onboardView.ts @@ -279,7 +279,8 @@ function bypassView(plan: OnboardingPlan, planned: OnboardSelection, selection: return { ...base, label: "已按项目取消 bypassPermissions", - detail: "settings.json 缺 permissions 时写显式 defaultMode=default 记住选择,之后的重注入不会补回。", + detail: + "不写入 bypassPermissions。settings.json 缺 permissions 时会写显式 defaultMode=default 记住选择(之后的重注入不会补回);已有 permissions 则原样不动。", tone: "muted", toggleable: true, }; diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index fbc73d5..5abe75c 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -7,7 +7,7 @@ export function Toast() { const isError = toast.startsWith("❌"); return ( -
{toast} -- 2.45.2