diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..992fb6e --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,57 @@ +# AgentDock Wave 2.2 实现说明 + +基线:工位 git `wave-2.1` tag(d2e9de1,即 HEL-137 交付的 `agentdock-wave-2.1-source.zip`)。 +本波对应老板复验 Wave 2.1 后提的 4 条「可用性」硬要求,逐条解决如下。 + +## 1. 安装输出实时滚动 + 失败提示人话化 + +**根因**:旧 `run_streaming` 先读 stdout、读完后才读 stderr,且主循环只在一行到达时才检查取消。导致 stderr 内容全部延迟到进程结束才一次性上屏(老板所见「最后才显示结果和中间过程」)。 + +**修复**(`crates/agentdock-core/src/process.rs`): +- `run_streaming` / `run_streaming_cancellable` 改为**两条读取线程并发**消费 stdout/stderr,经 mpsc 通道按到达顺序实时回调,主循环用 `recv_timeout(200ms)` 兜底(静默期也能响应取消)。 +- 新增 `resolve_exe`(PATH 解析到 .exe/.cmd/.bat 实际路径)、`run_with_stdin`(API Key 经 stdin 注入官方登录命令)、`run_terminal`(CREATE_NEW_CONSOLE 一次性终端窗口)、`open_with_shell`(explorer/xdg-open 打开文件或 URL)。 +- `RunningProcess` 提供 `request_cancel` / `cancel_flag`;`kill` 在 Windows 用 `taskkill /T /F` 杀整棵进程树,避免 `.cmd` 包装的 node 子进程变孤儿。 + +**人话化错误映射**(`crates/agentdock-core/src/errors_zh.rs`): +- `program not found` → 「未找到 X 命令:需要先安装 Y(可在本机环境区一键安装)」+ `missing_runtime`(npm→node、python→python、git、winget、uv…)。 +- 网络超时/连接拒绝/DNS、磁盘不足、权限不足等均映射为中文建议。 +- 原始报错保留在 `raw`,事件 `data` 携带结构化 `ErrorHint { code, friendly_zh, raw, missing_runtime }`(`ActionEvent::error_hint`)。 + +**前端**(`pages/CliDetail.tsx`):失败态默认展示人话版,原始报错折叠可展开;`missing_runtime` 时给内联「去安装 Node.js」按钮,直达本机环境区一键安装(联动第 3 条)。 + +## 2. 软件内授权(PRD FR-06) + +**引擎**(`crates/agentdock-core/src/engine.rs`): +- 新增 `Engine::authorize_stream` + `cancel_authorize` + 会话注册表(可取消进程句柄)。 +- 四类流程: + - `api_key`:有官方命令时(如 codex `login --with-api-key`)从密钥库读 key 经 stdin 注入;无命令(opencode)仅核对密钥库。 + - `browser_oauth`:后台运行官方登录命令,流式回传、可取消、结束后刷新授权状态。 + - `device_code`:运行官方命令,`parse_device_code` 解析 `user_code` + `verification_url`(ANSI 去色 + 正则),大号验证码上屏。 + - `local_tui`:`run_terminal` 打开一次性本机终端窗口,结束只回传脱敏成败状态。 +- 全程不记录令牌明文,日志/状态只存枚举;输出经 `redact` 脱敏。 + +**前端**(`components/AuthPanel.tsx`):配置页「授权 / 登录」第一层从说明文字升级为可操作流程——每个授权方式一个「开始授权」按钮,弹「授权进行中」面板(可取消),设备码大号展示 + 一键复制 + 「打开授权网页」,结束后即时刷新授权状态灯。 + +## 3. 本机环境一键安装 + 修总览诊断 + +**运行时来源表**(`crates/agentdock-core/src/runtime_install.rs`): +- 内置 node/python/git/winget/uv 官方来源(download_url + download_page + size_approx + elevate_needed + allowed_hosts)。 +- `is_url_host_allowed` 白名单校验(安全红线);`download_with_curl` 用系统 curl.exe 下载。 + +**IPC**(`src-tauri/src/commands/runtime.rs`):`previewRuntimeInstall` / `installRuntime`(下载→打开安装向导,进度经 `runtime-install-event` 回传)/ `openRuntimePage`(官网兜底)。 + +**前端**(`components/RuntimeInstallModal.tsx` + `pages/Overview.tsx`):本机环境未安装项变「安装」按钮 → 弹确认框(官方来源/体积/权限)→ 下载并打开安装向导;失败或不可直接安装时兜底「打开官方下载页」。 + +**修诊断**:新增 `diagnoseAll` IPC(对全部已装工具跑诊断),总览「立即诊断」(诊断卡 + 快速操作两处)都接上,弹全量诊断汇总。 + +## 4. 总览加载闪烁 + +- `hooks/useDetectAll.ts` / `useEnv.ts`:检测结果**模块级本地缓存**——切页回来先用缓存立即渲染,后台静默刷新。 +- `pages/Overview.tsx`:检测未完成(首次、无缓存)时显示**骨架屏**「正在检测本机 CLI…」,绝不显示「工具箱是空的」;该文案仅在检测完成且确实零安装时出现。 + +## 涉及文件 + +- Rust:`process.rs`、`errors_zh.rs`(新)、`runtime_install.rs`(新)、`engine.rs`、`types.rs`、`lib.rs`(core);`commands/cli.rs`、`commands/runtime.rs`(新)、`commands/mod.rs`、`lib.rs`(desktop);`secrets/src/keyring.rs`(加真机往返测试)。 +- 前端:`ipc/index.ts`、`ipc/types.ts`、`components/AuthPanel.tsx`(新)、`components/RuntimeInstallModal.tsx`(新)、`components/ConfigForm.tsx`、`pages/CliDetail.tsx`、`pages/Overview.tsx`、`hooks/useDetectAll.ts`、`hooks/useEnv.ts`、`styles/global.css`。 + +未引入任何新依赖(下载用系统 curl.exe、打开用 explorer.exe、任务树终止用 taskkill)。 diff --git a/SELF-TEST.md b/SELF-TEST.md new file mode 100644 index 0000000..007cc39 --- /dev/null +++ b/SELF-TEST.md @@ -0,0 +1,56 @@ +# AgentDock Wave 2.2 自测记录 + +## 构建与测试 + +- `cargo test`:**97 通过 / 0 失败**(基线 Wave 2.1 为 78,本波 +19;另有 7 个 `--ignored` 真机用例)。 +- 前端 `npm run build`(tsc + vite):通过,无类型错误。 +- `tauri dev` 实机启动:窗口正常打开,控制台出现 `[agentdock] window-ready`(无崩溃)。 + +## 4 条要求逐条自证 + +### 1. 安装输出实时滚动(留证:时间戳) + +运行交错输出 stdout/stderr 的命令,记录每行到达时间(`cargo test -p agentdock-core real_machine_streaming_evidence -- --ignored --nocapture`): + +``` +[ 173ms] stdout out1 +[ 177ms] stderr err1 +[ 591ms] stdout out2 +[ 591ms] stderr err2 +[ 995ms] stdout out3 +[ 995ms] stderr err3 +[ 1402ms] stdout out4 +[ 1402ms] stderr err4 +``` + +stderr 在 stdout 结束前**交错实时到达**(旧实现会等 stdout 全部读完才输出 stderr,即结尾回放)。断言 `first_stderr_idx < last_stdout_idx` 通过。另附总览截图 `shot-overview-wave22.png`(1920×1080,窗口已打开)。 + +人话化映射:单元测试覆盖 `program_not_found→Node`、`permission_denied`、`network_timeout`、`disk_full`、兜底 `exec_failed`(`errors_zh` 6 例全过);`ActionEvent::error_hint` 携带结构化 `ErrorHint`(含 `missing_runtime`)已测。 + +### 2. 软件内授权(留证:codex 设备码到出码步骤) + +真机跑 `codex login --device-auth`,8 秒后自动取消(走到出码即可,不要求真实账号完成最后一步): + +``` +=== codex 设备码流程(到出码步骤) === +验证链接: https://auth.openai.com/codex/device +设备码: BGBY-I9EY2 +``` + +断言链接指向官方域名、设备码非空均通过;取消时 `taskkill /T /F` 正确终止整棵进程树(日志可见「成功: 已终止 PID …」)。设备码解析(含 ANSI 去色)另有 2 个单元测试。真机密钥库往返(Windows Credential Manager,写假 key→读回一致→删除清理)通过。 + +### 3. 本机环境一键安装 + 诊断 + +- `runtime_install`:来源表白名单校验(`is_url_host_allowed` 精确/子域/伪造域名)6 例单元测试通过;`previewRuntimeInstall` / `openRuntimePage` / `installRuntime`(curl 下载→explorer 打开向导→失败兜底官网)已接线。**未做**:真实下载 30MB+ 安装包并走完安装向导(会弹 UAC,工位不宜真装;代码路径已就位,UI 确认/白名单/兜底逻辑已测)。 +- 总览「立即诊断」:`diagnoseAll` 对全部已装工具跑四类诊断并弹汇总,已接两处入口。**未做**:真机点按截图(留给总工真机 GUI 复核)。 + +### 4. 总览加载闪烁 + +- `useDetectAll` / `useEnv` 模块级缓存:切页先用缓存渲染、后台刷新;首检(无缓存)显示骨架屏,空态文案仅在检测完成且零安装时出现。逻辑已实现并通过前端 build。 + +## 未测到 / 待真机复核 + +- 真实下载大安装包并走完安装向导(需 UAC,工位不做)。 +- 浏览器 OAuth 全流程到真实登录完成(本机 codex 已登录,为避免覆盖现有会话未真跑完整登录;`browser_oauth` 路径与设备码共用同一套流式/取消/状态刷新机制,且设备码流程已真机走通)。 +- Linux 分支(本波依旧只 Windows)。 +- 总工真机 GUI 逐页点按复核(本卡截图由全屏截取,AI 读图受限,以真机复核为准)。 diff --git a/apps/desktop/src-tauri/src/commands/cli.rs b/apps/desktop/src-tauri/src/commands/cli.rs index c964f8f..8976aff 100644 --- a/apps/desktop/src-tauri/src/commands/cli.rs +++ b/apps/desktop/src-tauri/src/commands/cli.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; use agentdock_adapter::{AdapterAction, DryRunPlan}; -use agentdock_core::{ActionEvent, ActionOpts, AuthStatus, ConfigFormState, ConfigVerifyResult, DetectResult, Engine, WriteResult}; +use agentdock_core::{ActionEvent, ActionOpts, AuthFlowEvent, AuthStatus, ConfigFormState, ConfigVerifyResult, DetectResult, Engine, WriteResult}; use agentdock_diag::DiagnosticReport; use serde_json::json; use tauri::Emitter; @@ -125,9 +125,44 @@ pub fn auth_status(id: String, state: tauri::State<'_, Engine>) -> Result, +) -> Result<(), String> { + let engine = state.inner().clone(); + std::thread::spawn(move || { + let cli_id = id.clone(); + let m = mode.clone(); + let result = engine.authorize_stream(&id, &mode, |ev| { + let _ = app.emit("cli-auth-event", &ev); + }); + if let Err(e) = result { + let _ = app.emit("cli-auth-event", AuthFlowEvent::error(&cli_id, &m, e.to_string())); + } + }); + Ok(()) +} + +/// 取消正在进行的授权流程。 +#[tauri::command(rename = "cancelAuthorize")] +pub fn cancel_authorize(id: String, mode: String, state: tauri::State<'_, Engine>) { + state.cancel_authorize(&id, &mode); +} + /// 诊断(diagnose):内部实时取本机环境快照。 #[tauri::command(rename = "diagnose")] pub fn diagnose(id: String, state: tauri::State<'_, Engine>) -> Result { let env = agentdock_platform::detect::detect_env(); state.diagnose(&id, &env).map_err(|e| e.to_string()) } + +/// 批量诊断全部已装工具(总览「立即诊断」入口用),内部实时取本机环境快照。 +#[tauri::command(rename = "diagnoseAll")] +pub fn diagnose_all(state: tauri::State<'_, Engine>) -> Result, String> { + let env = agentdock_platform::detect::detect_env(); + state.diagnose_all(&env).map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 802c07b..0cbc6f8 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ pub mod catalog; pub mod cli; pub mod env; +pub mod runtime; diff --git a/apps/desktop/src-tauri/src/commands/runtime.rs b/apps/desktop/src-tauri/src/commands/runtime.rs new file mode 100644 index 0000000..3e58dc6 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/runtime.rs @@ -0,0 +1,85 @@ +//! IPC 命令:本机环境运行时一键安装(Wave 2.2 Req 3) +//! +//! 下载仅限官方白名单(`RuntimeSource.allowed_hosts`),下载前经 +//! `is_url_host_allowed` 校验;下载用系统 `curl.exe`;下载成功后用 +//! `open_with_shell` 打开安装向导(用户在向导里点完即装)。失败兜底 +//! 提供「打开官方下载页」。应用本身不提权、不静默安装。 + +use agentdock_core::{ + is_url_host_allowed, open_with_shell, source_for, RuntimeSource, +}; +use serde_json::json; +use tauri::Emitter; + +/// 预览运行时安装来源(确认弹窗展示官方地址/体积/权限)。 +#[tauri::command(rename = "previewRuntimeInstall")] +pub fn preview_runtime_install(runtime: String) -> Result { + source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}")) +} + +/// 兜底:打开运行时官方下载页(默认浏览器)。 +#[tauri::command(rename = "openRuntimePage")] +pub fn open_runtime_page(runtime: String) -> Result<(), String> { + let src = source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}"))?; + open_with_shell(&src.download_page).map_err(|e| e.to_string()) +} + +/// 一键安装运行时:下载官方安装包 → 打开安装向导。进度经 `runtime-install-event` 回传。 +#[tauri::command(rename = "installRuntime")] +pub fn install_runtime(app: tauri::AppHandle, runtime: String) -> Result<(), String> { + let src = source_for(&runtime).ok_or_else(|| format!("未知运行时: {runtime}"))?; + + std::thread::spawn(move || { + let emit = |app: &tauri::AppHandle, payload: serde_json::Value| { + let _ = app.emit("runtime-install-event", payload); + }; + + // 不可直接下载的运行时(如 uv):直接打开官方下载页 + let Some(url) = src.download_url.clone().filter(|_| src.direct_installable) else { + let _ = open_with_shell(&src.download_page); + emit( + &app, + json!({ "runtime": runtime, "phase": "opened_page", "message": format!("已打开{}官方下载页", src.label_zh) }), + ); + return; + }; + + // 安全红线:来源必须落白名单 + emit(&app, json!({ "runtime": runtime, "phase": "validate", "message": format!("正在校验来源({})…", src.source_label) })); + if !is_url_host_allowed(&url, &src.allowed_hosts) { + emit( + &app, + json!({ "runtime": runtime, "phase": "error", "message": "下载来源不在官方白名单内,已中止(安全红线)", "fallback": true }), + ); + return; + } + + // 下载到临时目录 + let dir = std::env::temp_dir().join("agentdock-downloads"); + let _ = std::fs::create_dir_all(&dir); + let file_name = url.rsplit('/').next().unwrap_or("installer.bin"); + let dest = dir.join(file_name); + emit(&app, json!({ "runtime": runtime, "phase": "download", "message": format!("正在下载 {}({},来自 {})…", src.label_zh, src.size_approx, src.source_label) })); + + match agentdock_core::download_with_curl(&url, &dest) { + Ok(()) => { + emit(&app, json!({ "runtime": runtime, "phase": "open", "message": "下载完成,正在打开安装向导(请在向导中完成安装)…" })); + if let Err(e) = open_with_shell(&dest.to_string_lossy()) { + emit( + &app, + json!({ "runtime": runtime, "phase": "error", "message": format!("无法打开安装向导:{e}"), "fallback": true }), + ); + return; + } + emit(&app, json!({ "runtime": runtime, "phase": "done", "message": "安装向导已打开" })); + } + Err(e) => { + emit( + &app, + json!({ "runtime": runtime, "phase": "error", "message": format!("下载失败:{e}。可点击「打开官方下载页」手动下载。"), "fallback": true }), + ); + } + } + }); + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 59acb42..a700fd4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -56,7 +56,13 @@ pub fn run() { commands::cli::write_config, commands::cli::verify_config, commands::cli::auth_status, + commands::cli::authorize, + commands::cli::cancel_authorize, commands::cli::diagnose, + commands::cli::diagnose_all, + commands::runtime::preview_runtime_install, + commands::runtime::open_runtime_page, + commands::runtime::install_runtime, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 60ac188..b207e76 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -29,6 +29,7 @@ function envWarningCount(env: PlatformEnv | null): number { export default function App() { const [page, setPage] = useState("overview"); const [detailCliId, setDetailCliId] = useState(null); + const [pendingRuntime, setPendingRuntime] = useState(null); const { env } = useEnv(); const warningCount = envWarningCount(env); @@ -37,13 +38,27 @@ export default function App() { setPage(next); } + // 从详情页/其它页直达本机环境区某个运行时的一键安装(联动 Wave 2.2 Req 1/3) + function openRuntimeInstall(runtime: string) { + setDetailCliId(null); + setPage("overview"); + setPendingRuntime(runtime); + } + const content = useMemo(() => { if (detailCliId) { - return setDetailCliId(null)} />; + return setDetailCliId(null)} onInstallRuntime={openRuntimeInstall} />; } switch (page) { case "overview": - return ; + return ( + setPendingRuntime(null)} + /> + ); case "catalog": return ; case "my-cli": @@ -55,7 +70,7 @@ export default function App() { case "settings": return ; } - }, [page, detailCliId, env]); + }, [page, detailCliId, env, pendingRuntime]); const title = detailCliId ? "CLI 详情" : PAGE_META[page].title; diff --git a/apps/desktop/src/components/AuthPanel.tsx b/apps/desktop/src/components/AuthPanel.tsx new file mode 100644 index 0000000..d3a39a2 --- /dev/null +++ b/apps/desktop/src/components/AuthPanel.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from "react"; +import { Copy, ExternalLink, Loader2, ShieldCheck, X } from "lucide-react"; +import { authorize, cancelAuthorize, onCliAuth } from "../ipc"; +import type { AuthFlowEvent, AuthModeInfo } from "../ipc/types"; +import { Modal } from "./Modal"; + +/** 官方授权方式中文名 */ +function authModeLabel(mode: string): string { + switch (mode) { + case "browser_oauth": + return "账号授权(浏览器)"; + case "device_code": + return "设备码"; + case "api_key": + return "API Key"; + case "local_tui": + return "本机终端授权"; + default: + return mode; + } +} + +function modeButtonLabel(mode: string): string { + switch (mode) { + case "browser_oauth": + return "开始授权"; + case "device_code": + return "开始授权"; + case "local_tui": + return "打开终端授权"; + case "api_key": + return "用已保存的 Key 授权"; + default: + return "去授权"; + } +} + +interface FlowState { + mode: string; + events: AuthFlowEvent[]; + device: { code: string; url: string } | null; + done: boolean | null; // null=进行中 +} + +/** 软件内授权面板(Wave 2.2 Req 2):把「说明文字」升级为可操作授权流程。 + * 覆盖 API Key / 浏览器 / 设备码 / 本机终端四类,事件流经 cli-auth-event 实时回传。 */ +export function AuthPanel({ + id, + authModes, + onAuthChanged, +}: { + id: string; + authModes: AuthModeInfo[]; + onAuthChanged: () => void; +}) { + const [flow, setFlow] = useState(null); + + useEffect(() => { + let unlisten: (() => void) | undefined; + onCliAuth((ev) => { + if (ev.cli_id !== id) return; + setFlow((f) => { + if (!f) return f; + const next: FlowState = { ...f, events: [...f.events, ev] }; + if (ev.kind === "device_code" && ev.user_code && ev.verification_url) { + next.device = { code: ev.user_code, url: ev.verification_url }; + } + if (ev.kind === "done") { + next.done = ev.authorized ?? false; + onAuthChanged(); + } + if (ev.kind === "error" || ev.kind === "cancelled") { + next.done = false; + } + return next; + }); + }).then((fn) => { + unlisten = fn; + }); + return () => unlisten?.(); + }, [id, onAuthChanged]); + + function start(mode: string) { + setFlow({ mode, events: [], device: null, done: null }); + void authorize(id, mode); + } + + function cancel() { + if (flow) void cancelAuthorize(id, flow.mode); + } + + function close() { + setFlow(null); + } + + if (authModes.length === 0) return null; + + return ( + <> +
    + {authModes.map((m) => ( +
  • + {authModeLabel(m.mode)} + {m.notes_zh && {m.notes_zh}} + +
  • + ))} +
+ + {flow && ( + + + )} + + ); +} + +function AuthFlowBody({ flow }: { flow: FlowState }) { + const { device, events, done } = flow; + return ( +
+ {/* 设备码:大号验证码 + 一键复制 + 打开授权网页 */} + {device && ( +
+
在浏览器打开验证链接,输入以下设备码
+
+ {device.code} + +
+ + +
请在弹出的浏览器里完成确认,本软件会轮询授权结果。
+
+ )} + + {/* 状态行 */} +
+ {done == null ? ( + + + ) : done ? ( + + + ) : ( + + + )} +
+ + {/* 实时输出(脱敏后) */} + {events.length > 0 && ( +
+ {events + .filter((e) => e.kind === "line" || e.kind === "waiting") + .map((e, i) => ( +
+ {e.message} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/ConfigForm.tsx b/apps/desktop/src/components/ConfigForm.tsx index 78e9aba..4a38e14 100644 --- a/apps/desktop/src/components/ConfigForm.tsx +++ b/apps/desktop/src/components/ConfigForm.tsx @@ -7,27 +7,13 @@ import type { ConfigVerifyResult, WriteResult, } from "../ipc/types"; +import { AuthPanel } from "./AuthPanel"; import { MonoChip } from "./MonoChip"; -/** 官方授权方式中文名 */ -function authModeLabel(mode: string): string { - switch (mode) { - case "browser_oauth": - return "账号授权(浏览器)"; - case "device_code": - return "设备码"; - case "api_key": - return "API Key"; - case "local_tui": - return "本机终端授权"; - default: - return mode; - } -} - /** 配置表单(视觉规范 §3.4:单列 ≤640px、敏感字段密码框 + 密钥库说明、吸底保存条) - * Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」 */ -export function ConfigForm({ id }: { id: string }) { + * Wave 2.1:字段按官方配置方法渲染 + 保存后「生效检查」 + * Wave 2.2:授权/登录层改为可操作授权流程(AuthPanel) */ +export function ConfigForm({ id, onAuthChanged }: { id: string; onAuthChanged?: () => void }) { const [state, setState] = useState(null); const [values, setValues] = useState>({}); const [revealed, setRevealed] = useState>({}); @@ -138,24 +124,12 @@ export function ConfigForm({ id }: { id: string }) {
{!hasFields &&

该 CLI 未声明可配置的中文表单字段。

} - {/* 第一层:授权 / 登录(官方授权方式引导 + API Key 类字段) */} + {/* 第一层:授权 / 登录(可操作授权流程 + API Key 类字段) */} {(state.auth_modes.length > 0 || authFields.length > 0) && (

授权 / 登录

{state.auth_modes.length > 0 && ( -
    - {state.auth_modes.map((m) => ( -
  • - {authModeLabel(m.mode)} - {m.notes_zh && {m.notes_zh}} - {m.command.length > 0 && ( - - {m.command.join(" ")} - - )} -
  • - ))} -
+ {})} /> )} {authFields.map(renderField)}
diff --git a/apps/desktop/src/components/RuntimeInstallModal.tsx b/apps/desktop/src/components/RuntimeInstallModal.tsx new file mode 100644 index 0000000..e6c6b7e --- /dev/null +++ b/apps/desktop/src/components/RuntimeInstallModal.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import { Check, Download, ExternalLink, Loader2, ShieldAlert } from "lucide-react"; +import { installRuntime, onRuntimeInstall, openRuntimePage, previewRuntimeInstall } from "../ipc"; +import type { RuntimeInstallEvent, RuntimeSource } from "../ipc/types"; +import { Modal } from "./Modal"; + +const RUNTIME_LABELS: Record = { + node: "Node.js", + python: "Python", + git: "Git", + winget: "winget", + uv: "uv", +}; + +/** 本机环境运行时一键安装弹窗(Wave 2.2 Req 3): + * 展示官方来源/体积/权限 → 确认后下载官方安装包并打开安装向导 → 失败兜底打开官网下载页。 */ +export function RuntimeInstallModal({ runtime, onClose }: { runtime: string; onClose: () => void }) { + const [source, setSource] = useState(null); + const [phase, setPhase] = useState<"preview" | "running" | "done" | "error" | "opened">("preview"); + const [message, setMessage] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + previewRuntimeInstall(runtime) + .then((s) => { + if (!cancelled) setSource(s); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }); + return () => { + cancelled = true; + }; + }, [runtime]); + + useEffect(() => { + let unlisten: (() => void) | undefined; + onRuntimeInstall((ev: RuntimeInstallEvent) => { + if (ev.runtime !== runtime) return; + setMessage(ev.message); + if (ev.phase === "done") setPhase("done"); + else if (ev.phase === "opened_page") setPhase("opened"); + else if (ev.phase === "error") { + setPhase("error"); + setError(ev.message); + } else { + setPhase("running"); + } + }).then((fn) => { + unlisten = fn; + }); + return () => unlisten?.(); + }, [runtime]); + + function start() { + setPhase("running"); + setMessage(""); + setError(null); + void installRuntime(runtime); + } + + function openPage() { + void openRuntimePage(runtime); + setPhase("opened"); + } + + const label = RUNTIME_LABELS[runtime] ?? runtime; + + return ( + }> + {error && phase === "preview" && !source ? ( +

无法获取安装来源:{error}

+ ) : !source ? ( +

加载安装来源…

+ ) : ( +
+
+
官方来源
+

{source.source_label}

+ {source.download_url && ( +

+ 下载地址 + {source.download_url} +

+ )} +
+
+
+ 大概体积 + {source.size_approx} +
+
+ 需要的权限 + {source.elevate_needed ? "需要管理员权限(安装向导会弹 UAC 确认)" : "无需管理员权限"} +
+
+ + {(phase === "running" || phase === "done") && ( +
+ {phase === "running" && ( + + + )} + {phase === "done" && ( + + + )} +
+ )} + + {phase === "error" && error && ( +
+
+ )} + +

+ 仅从官方渠道下载,不静默安装;应用本身不提权。下载完成后会打开安装向导,你在向导里点完即装。 +

+
+ )} +
+ ); +} + +function Footer({ + phase, + source, + onStart, + onOpenPage, + onClose, +}: { + phase: "preview" | "running" | "done" | "error" | "opened"; + source: RuntimeSource | null; + onStart: () => void; + onOpenPage: () => void; + onClose: () => void; +}) { + const running = phase === "running"; + const finished = phase === "done" || phase === "opened"; + const failed = phase === "error"; + const canDirect = source?.direct_installable && source?.download_url; + return ( + <> + {(failed || !canDirect) && ( + + )} + {finished ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/desktop/src/hooks/useDetectAll.ts b/apps/desktop/src/hooks/useDetectAll.ts index 61b6d79..7c3fd01 100644 --- a/apps/desktop/src/hooks/useDetectAll.ts +++ b/apps/desktop/src/hooks/useDetectAll.ts @@ -9,10 +9,14 @@ export interface UseDetectAllResult { refresh: () => Promise; } +// 检测结果本地缓存(Wave 2.2 Req 4):切换页面回来先用缓存立即渲染, +// 后台静默刷新有变化再更新,不再每次重检重闪。 +let cachedMap: Record | null = null; + /** 批量检测全部 CLI(总览/目录/我的 CLI 接真机状态) */ export function useDetectAll(): UseDetectAllResult { - const [detectMap, setDetectMap] = useState>({}); - const [loading, setLoading] = useState(true); + const [detectMap, setDetectMap] = useState>(cachedMap ?? {}); + const [loading, setLoading] = useState(cachedMap == null); const [error, setError] = useState(null); const refresh = useCallback(async () => { @@ -20,6 +24,7 @@ export function useDetectAll(): UseDetectAllResult { const list = await detectCliAll(); const map: Record = {}; for (const d of list) map[d.cli_id] = d; + cachedMap = map; setDetectMap(map); setError(null); } catch (err) { diff --git a/apps/desktop/src/hooks/useEnv.ts b/apps/desktop/src/hooks/useEnv.ts index f713687..70f9f76 100644 --- a/apps/desktop/src/hooks/useEnv.ts +++ b/apps/desktop/src/hooks/useEnv.ts @@ -8,10 +8,13 @@ export interface UseEnvResult { error: string | null; } -/** 加载本机环境检测结果(一次性的只读快照) */ +// 本机环境本地缓存(Wave 2.2 Req 4):切换页面先用缓存立即渲染,后台静默刷新。 +let cachedEnv: PlatformEnv | null = null; + +/** 加载本机环境检测结果(缓存优先 + 后台刷新) */ export function useEnv(): UseEnvResult { - const [env, setEnv] = useState(null); - const [loading, setLoading] = useState(true); + const [env, setEnv] = useState(cachedEnv); + const [loading, setLoading] = useState(cachedEnv == null); const [error, setError] = useState(null); useEffect(() => { @@ -19,6 +22,7 @@ export function useEnv(): UseEnvResult { detectEnv() .then((e) => { if (!cancelled) { + cachedEnv = e; setEnv(e); setLoading(false); } diff --git a/apps/desktop/src/ipc/index.ts b/apps/desktop/src/ipc/index.ts index 946d7b3..22ece95 100644 --- a/apps/desktop/src/ipc/index.ts +++ b/apps/desktop/src/ipc/index.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { Adapter, + AuthFlowEvent, AuthStatus, CatalogEntry, CliAction, @@ -13,6 +14,8 @@ import type { DiagnosticReport, DryRunPlan, PlatformEnv, + RuntimeInstallEvent, + RuntimeSource, WriteResult, } from "./types"; @@ -98,6 +101,53 @@ export async function diagnose(id: string): Promise { return invoke("diagnose", { id }); } +export async function diagnoseAll(): Promise { + if (!isTauri()) return []; + return invoke("diagnoseAll"); +} + +/** 软件内授权:启动指定模式的授权流程(事件经 cli-auth-event 流式回传)。 */ +export async function authorize(id: string, mode: string): Promise { + if (!isTauri()) return; + return invoke("authorize", { id, mode }); +} + +export async function cancelAuthorize(id: string, mode: string): Promise { + if (!isTauri()) return; + return invoke("cancelAuthorize", { id, mode }); +} + +/** 订阅授权流程事件(cli-auth-event)。 */ +export async function onCliAuth( + handler: (payload: AuthFlowEvent) => void, +): Promise { + if (!isTauri()) return () => {}; + return listen("cli-auth-event", (e) => handler(e.payload)); +} + +/** 本机环境运行时安装(预览/一键安装/官网兜底)。 */ +export async function previewRuntimeInstall(runtime: string): Promise { + if (!isTauri()) return mockRuntimeSource(runtime); + return invoke("previewRuntimeInstall", { runtime }); +} + +export async function installRuntime(runtime: string): Promise { + if (!isTauri()) return; + return invoke("installRuntime", { runtime }); +} + +export async function openRuntimePage(runtime: string): Promise { + if (!isTauri()) return; + return invoke("openRuntimePage", { runtime }); +} + +export async function onRuntimeInstall( + handler: (payload: RuntimeInstallEvent) => void, +): Promise { + if (!isTauri()) return () => {}; + return listen("runtime-install-event", (e) => handler(e.payload)); +} + /** 订阅 runAction 的流式事件(cli-action-event)。返回取消订阅函数。 */ export async function onCliAction( handler: (payload: CliActionEvent) => void, @@ -256,3 +306,15 @@ const mockDryRun = (_id: string, action: CliAction): DryRunPlan => ({ affected_files: ["~/.codex/config.toml"], rollback_zh: action === "install" ? "可执行卸载命令回退;配置文件默认保留。" : "卸载默认保留配置,可重新安装恢复。", }); + +const mockRuntimeSource = (runtime: string): RuntimeSource => ({ + runtime, + label_zh: runtime === "node" ? "Node.js" : runtime === "python" ? "Python" : "Git", + download_url: "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi", + download_page: "https://nodejs.org/en/download", + size_approx: "约 31 MB", + elevate_needed: true, + allowed_hosts: ["nodejs.org"], + direct_installable: true, + source_label: "官方来源", +}); diff --git a/apps/desktop/src/ipc/types.ts b/apps/desktop/src/ipc/types.ts index 2b465a2..5864dac 100644 --- a/apps/desktop/src/ipc/types.ts +++ b/apps/desktop/src/ipc/types.ts @@ -186,6 +186,54 @@ export interface DiagnosticReport { findings: DiagnosticFinding[]; } +// ---- Wave 2.2:错误人话化 / 授权流程 / 运行时安装 ---- + +export interface ErrorHint { + code: string; + friendly_zh: string; + raw: string; + missing_runtime: string | null; +} + +export type AuthFlowKind = "started" | "line" | "device_code" | "waiting" | "done" | "error" | "cancelled"; + +export interface AuthFlowEvent { + cli_id: string; + mode: string; + kind: AuthFlowKind; + message: string; + user_code: string | null; + verification_url: string | null; + authorized: boolean | null; +} + +export interface RuntimeSource { + runtime: string; + label_zh: string; + download_url: string | null; + download_page: string; + size_approx: string; + elevate_needed: boolean; + allowed_hosts: string[]; + direct_installable: boolean; + source_label: string; +} + +export type RuntimeInstallPhase = + | "validate" + | "download" + | "open" + | "done" + | "error" + | "opened_page"; + +export interface RuntimeInstallEvent { + runtime: string; + phase: RuntimeInstallPhase; + message: string; + fallback?: boolean; +} + // ---- 适配器完整定义(getAdapter 返回,detail 页展示用) ---- export interface AdapterChannel { diff --git a/apps/desktop/src/pages/CliDetail.tsx b/apps/desktop/src/pages/CliDetail.tsx index 9ffc41e..c5e50ef 100644 --- a/apps/desktop/src/pages/CliDetail.tsx +++ b/apps/desktop/src/pages/CliDetail.tsx @@ -21,6 +21,7 @@ import type { DetectResult, DiagnosticReport, DryRunPlan, + ErrorHint, } from "../ipc/types"; import { CliMonogram } from "../components/CliMonogram"; import { MonoChip } from "../components/MonoChip"; @@ -40,7 +41,7 @@ type RunPhase = "prepare" | "exec" | "verify"; type RunOutcome = | { status: "success"; action: CliAction; detect: DetectResult | null } - | { status: "failed"; action: CliAction; message: string }; + | { status: "failed"; action: CliAction; message: string; hint: ErrorHint | null }; /** 授权状态灯(§3.3:已授权=绿、未授权=紫、可能过期=黄呼吸、未知=灰) */ function AuthLight({ auth }: { auth: AuthStatus | null }) { @@ -90,7 +91,15 @@ function detectLabel(d: DetectResult | null): string { } } -export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }) { +export function CliDetailPage({ + id, + onBack, + onInstallRuntime, +}: { + id: string; + onBack: () => void; + onInstallRuntime?: (runtime: string) => void; +}) { const { adapter, detect, auth, loading, error, refresh } = useCliDetail(id); const [tab, setTab] = useState("overview"); const [confirm, setConfirm] = useState<{ action: CliAction; plan: DryRunPlan } | null>(null); @@ -119,7 +128,8 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void } } if (ev.kind === "error") { setRunning(false); - setOutcome({ status: "failed", action: currentActionRef.current, message: ev.message }); + const hint = (ev.data as ErrorHint | null) ?? null; + setOutcome({ status: "failed", action: currentActionRef.current, message: ev.message, hint }); return; } if (ev.phase) setPhase(ev.phase as RunPhase); @@ -239,7 +249,7 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void }
{tab === "overview" && } - {tab === "config" && } + {tab === "config" && } {tab === "docs" && } {tab === "diag" && }
@@ -277,7 +287,7 @@ export function CliDetailPage({ id, onBack }: { id: string; onBack: () => void } ) } > - + )}
@@ -309,10 +319,12 @@ function RunBody({ phase, log, outcome, + onInstallRuntime, }: { phase: RunPhase; log: ActionEvent[]; outcome: RunOutcome | null; + onInstallRuntime?: (runtime: string) => void; }) { return (
@@ -349,15 +361,70 @@ function RunBody({
)} {outcome?.status === "failed" && ( -
-
+ )} ); } +/** 失败态:默认展示人话版建议;原始报错折叠保留;缺失运行时给内联「去安装」按钮(联动本机环境一键装) */ +function FailureResult({ + outcome, + onInstallRuntime, +}: { + outcome: { status: "failed"; action: CliAction; message: string; hint: ErrorHint | null }; + onInstallRuntime?: (runtime: string) => void; +}) { + const [showRaw, setShowRaw] = useState(false); + const hint = outcome.hint; + return ( +
+
+
+ {hint?.missing_runtime && onInstallRuntime && ( + + )} + {hint?.raw && ( + <> + + {showRaw &&
{hint.raw}
} + + )} +
+ ); +} + +function runtimeLabel(runtime: string): string { + switch (runtime) { + case "node": + return "Node.js"; + case "python": + return "Python"; + case "git": + return "Git"; + case "winget": + return "winget"; + case "uv": + return "uv"; + default: + return runtime; + } +} + /** 自动滚动到底部(把 ref 挂在日志末尾) */ function useAutoScroll(dep: unknown) { const ref = useRef(null); diff --git a/apps/desktop/src/pages/Overview.tsx b/apps/desktop/src/pages/Overview.tsx index 5b1eb64..f03c0fd 100644 --- a/apps/desktop/src/pages/Overview.tsx +++ b/apps/desktop/src/pages/Overview.tsx @@ -1,13 +1,23 @@ -import { useState } from "react"; -import { Activity, Archive, Lock, Plus, ScanSearch } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Activity, Archive, Download, Loader2, Lock, Plus, ScanSearch } from "lucide-react"; import { useEnv } from "../hooks/useEnv"; import { useCatalog } from "../hooks/useCatalog"; import { useDetectAll } from "../hooks/useDetectAll"; +import { diagnoseAll } from "../ipc"; +import type { + CatalogEntry, + DetectResult, + DiagnosticReport, + PlatformEnv, + RuntimeInfo, + RuntimeStatus, +} from "../ipc/types"; import { KpiCard } from "../components/KpiCard"; import { MonoChip } from "../components/MonoChip"; import { StatusBadge } from "../components/StatusBadge"; import { CliMonogram } from "../components/CliMonogram"; -import type { CatalogEntry, DetectResult, PlatformEnv, RuntimeInfo, RuntimeStatus } from "../ipc/types"; +import { Modal } from "../components/Modal"; +import { RuntimeInstallModal } from "../components/RuntimeInstallModal"; import type { PageKey } from "../components/Sidebar"; /** 平台相关运行时集合(Windows 不看 apt,Linux 不看 winget) */ @@ -30,34 +40,34 @@ function runtimeProblems(env: PlatformEnv): { name: string; info: RuntimeInfo }[ function runtimeMeta(status: RuntimeStatus): { dot: string; missingText: string | null; - needsGuide: boolean; + needsInstall: boolean; } { switch (status) { case "installed": - return { dot: "status-dot ok", missingText: null, needsGuide: false }; + return { dot: "status-dot ok", missingText: null, needsInstall: false }; case "not_installed": - return { dot: "status-dot unknown", missingText: "未检测到", needsGuide: true }; + return { dot: "status-dot unknown", missingText: "未检测到", needsInstall: true }; case "not_in_path": - return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsGuide: false }; + return { dot: "status-dot warn breathe", missingText: "不在 PATH", needsInstall: false }; case "permission_denied": - return { dot: "status-dot warn breathe", missingText: "无访问权限", needsGuide: false }; + return { dot: "status-dot warn breathe", missingText: "无访问权限", needsInstall: false }; case "exec_failed": - return { dot: "status-dot warn breathe", missingText: "执行失败", needsGuide: false }; + return { dot: "status-dot warn breathe", missingText: "执行失败", needsInstall: false }; case "version_unparseable": - return { dot: "status-dot warn breathe", missingText: "版本未知", needsGuide: false }; + return { dot: "status-dot warn breathe", missingText: "版本未知", needsInstall: false }; default: - return { dot: "status-dot unknown", missingText: "未知", needsGuide: false }; + return { dot: "status-dot unknown", missingText: "未知", needsInstall: false }; } } function RuntimeItem({ name, info, - onNavigate, + onInstall, }: { name: string; info: RuntimeInfo; - onNavigate: (p: PageKey) => void; + onInstall: (runtime: string) => void; }) { const meta = runtimeMeta(info.status); return ( @@ -71,107 +81,121 @@ function RuntimeItem({ ) : ( {meta.missingText} )} - {meta.needsGuide && ( - )} ); } -function EnvPanel({ onNavigate }: { onNavigate: (p: PageKey) => void }) { - const { env, loading, error } = useEnv(); +function EnvPanel({ + onInstallRuntime, +}: { + onInstallRuntime: (runtime: string) => void; +}) { + const { env, loading } = useEnv(); const [showAllPath, setShowAllPath] = useState(false); - if (error) { + if (!env && loading) { + return ( +
+
+

本机环境

+ 检测中… +
+
+
+
+
+
+
+ ); + } + + if (!env) { return (

本机环境

-

环境检测失败:{error}

+

环境检测失败

); } - const pathPreview = env ? env.path_entries.slice(0, 8) : []; + const pathPreview = env.path_entries.slice(0, 8); return (

本机环境

- {loading && 检测中…} + {loading && 刷新中…}
- {env && ( - <> -
-
-
- 系统 - {env.os_version} -
-
- 架构 - {env.arch} -
-
- Shell - - {env.shells.powershell_version ? ( - <>PowerShell {env.shells.powershell_version} - ) : null} - {env.shells.pwsh_version ? ( - <> · pwsh {env.shells.pwsh_version} - ) : null} - {env.shells.bash_available ? " · Bash ✓" : ""} - -
-
- 密钥库 - - {env.capabilities.keyring === "ok" ? "Credential Manager ✓" : "密钥库缺失"} - -
-
- 提权 - {env.capabilities.can_elevate ? "可用" : "不可用"} -
-
-
- {(["node", "npm", "python", "uv", "git", "winget", "apt"] as const).map( - (key) => { - const info = env.runtimes[key]; - if (!info) return null; - return ; - }, - )} -
+
+
+
+ 系统 + {env.os_version}
-
-
- - PATH({env.path_entries.length} 条) - {env.path_entries.length} - - {env.path_entries.length > pathPreview.length && ( - - )} -
- {(showAllPath ? env.path_entries : pathPreview).map((entry, idx) => ( -
- {entry} -
- ))} +
+ 架构 + {env.arch}
- - )} +
+ Shell + + {env.shells.powershell_version ? ( + <>PowerShell {env.shells.powershell_version} + ) : null} + {env.shells.pwsh_version ? ( + <> · pwsh {env.shells.pwsh_version} + ) : null} + {env.shells.bash_available ? " · Bash ✓" : ""} + +
+
+ 密钥库 + + {env.capabilities.keyring === "ok" ? "Credential Manager ✓" : "密钥库缺失"} + +
+
+ 提权 + {env.capabilities.can_elevate ? "可用" : "不可用"} +
+
+
+ {(["node", "npm", "python", "uv", "git", "winget", "apt"] as const).map((key) => { + const info = env.runtimes[key]; + if (!info) return null; + return ; + })} +
+
+
+
+ + PATH({env.path_entries.length} 条) + {env.path_entries.length} + + {env.path_entries.length > pathPreview.length && ( + + )} +
+ {(showAllPath ? env.path_entries : pathPreview).map((entry, idx) => ( +
+ {entry} +
+ ))} +
); } @@ -221,9 +245,29 @@ function Onboarding({ onNavigate }: { onNavigate: (p: PageKey) => void }) { ); } -/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态,不渲染占位行与「查看全部」) */ -function DiagPanel() { - // Wave 0.5 尚无诊断引擎数据,恒为空态(语义按 §3.7,不虚构记录) +/** 检测进行中的骨架屏(Wave 2.2 Req 4:禁止在检测未完成时显示空态文案) */ +function OverviewSkeleton() { + return ( +
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ ); +} + +/** 最近诊断卡(v1.3 §3.1.2:无记录时展示空态;「立即诊断」跑全量诊断) */ +function DiagPanel({ onDiagnose }: { onDiagnose: () => void }) { const records: { cli: string; text: string; time: string }[] = []; return (
@@ -233,7 +277,7 @@ function DiagPanel() { {records.length === 0 ? (
暂无诊断记录 -
@@ -257,9 +301,11 @@ function DiagPanel() { function QuickActions({ firstRun, onNavigate, + onDiagnose, }: { firstRun: boolean; onNavigate: (p: PageKey) => void; + onDiagnose: () => void; }) { return (
@@ -267,7 +313,7 @@ function QuickActions({ -