Wave 2.1: 返工黑屏/安装进度/官方配置三层/文档深度/真机列表

老板实测 5 条修复:DiagTab Hooks 黑屏、安装三阶段进度、配置按官方字段三层分组、FR-09 中文文档、detectCliAll 接入列表页。
This commit is contained in:
总工
2026-08-25 11:57:10 +08:00
parent 01996a80fe
commit a2fd5e369b
23 changed files with 1821 additions and 198 deletions
+29
View File
@@ -252,6 +252,21 @@ pub struct ConfigField {
pub platforms: Vec<String>,
#[serde(rename = "docs_url", default)]
pub docs_url: Option<String>,
/// enum 类型的可选值(value + 中文名)
#[serde(default)]
pub options: Vec<FieldOption>,
/// 表单分组:auth(授权/登录)| common(常用配置)| advanced(高级配置,默认折叠)
/// 未声明时按 common 处理
#[serde(default)]
pub group: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct FieldOption {
pub value: String,
#[serde(rename = "label_zh")]
pub label_zh: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -266,8 +281,14 @@ pub struct Diagnostic {
pub struct Documentation {
#[serde(rename = "quickstart_zh", default)]
pub quickstart_zh: Option<String>,
#[serde(rename = "install_zh", default)]
pub install_zh: Option<String>,
#[serde(rename = "auth_zh", default)]
pub auth_zh: Option<String>,
#[serde(default)]
pub commands: Vec<DocCommand>,
#[serde(default)]
pub params: Vec<DocParam>,
#[serde(rename = "updated_at", default)]
pub updated_at: Option<String>,
#[serde(rename = "risks_zh", default)]
@@ -282,6 +303,14 @@ pub struct DocCommand {
pub desc_zh: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct DocParam {
pub param: String,
#[serde(rename = "desc_zh", default)]
pub desc_zh: Option<String>,
}
fn default_true() -> bool {
true
}
+283 -16
View File
@@ -56,6 +56,12 @@ impl Engine {
Ok(detect_one(&adapter))
}
/// 批量检测全部适配器(总览/目录/我的 CLI 接入真机状态用)。
pub fn detect_all(&self) -> Result<Vec<DetectResult>, EngineError> {
let adapters = self.adapters()?;
Ok(adapters.iter().map(detect_one).collect())
}
// ---- dry-run ----
pub fn preview(&self, id: &str, action: AdapterAction) -> Result<DryRunPlan, EngineError> {
@@ -75,6 +81,12 @@ impl Engine {
write_config(&adapter, patch, self.secrets.as_ref())
}
/// 配置写入后的「生效检查」:重读配置文件 + 解析 +(若已安装)CLI 版本探测。
pub fn verify_config(&self, id: &str) -> Result<ConfigVerifyResult, EngineError> {
let adapter = self.adapter(id)?;
Ok(verify_config(&adapter))
}
// ---- 授权 ----
pub fn auth_status(&self, id: &str) -> Result<AuthStatus, EngineError> {
@@ -262,14 +274,24 @@ pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result<Confi
// 字段当前值
for field in &cfg.fields {
let env_key = cfg
.environment
.iter()
.find(|e| e.maps_to_field.as_deref() == Some(field.id.as_str()))
.map(|e| e.key.clone());
let (value, has_value) = match field.storage.as_str() {
"keyring" | "env" => (None, secrets.has(&service, &field.id)),
"file" => {
"keyring" => (None, secrets.has(&service, &field.id)),
"file" | "env" => {
let val = read_file_field(adapter, &field.id)?;
(val, false)
}
_ => (None, false),
};
let options: Vec<ConfigFieldOption> = field
.options
.iter()
.map(|o| ConfigFieldOption { value: o.value.clone(), label_zh: o.label_zh.clone() })
.collect();
fields.push(ConfigFieldState {
id: field.id.clone(),
label_zh: field.label_zh.clone(),
@@ -280,6 +302,10 @@ pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result<Confi
storage: field.storage.clone(),
value,
has_value,
docs_url: field.docs_url.clone(),
options,
env_key,
group: field.group.clone().unwrap_or_else(|| "common".into()),
});
}
@@ -293,7 +319,19 @@ pub fn read_config(adapter: &Adapter, secrets: &dyn SecretStore) -> Result<Confi
}
}
Ok(ConfigFormState { cli_id, files, fields, environment })
// 官方授权方式(授权/登录层引导)
let mut auth_modes = Vec::new();
if let Some(auth) = adapter.authorization.as_ref() {
for m in &auth.modes {
auth_modes.push(AuthModeInfo {
mode: m.mode.clone(),
notes_zh: m.notes_zh.clone(),
command: m.command.clone(),
});
}
}
Ok(ConfigFormState { cli_id, files, fields, environment, auth_modes })
}
/// 从配置文件读取字段值(首个声明了该字段可写的文件)。
@@ -353,7 +391,7 @@ pub fn write_config(
continue;
};
match field.storage.as_str() {
"keyring" | "env" => {
"keyring" => {
match secrets.set(&service, &field.id, value) {
Ok(()) => {
written_fields.push(field.id.clone());
@@ -364,7 +402,7 @@ pub fn write_config(
}
}
}
"file" => {
"file" | "env" => {
if let Some(idx) = cfg.files.iter().position(|f| matches!(f.scope.as_deref(), Some("user") | None)) {
file_patches.entry(idx).or_default().push((field.id.clone(), value.clone()));
} else {
@@ -406,6 +444,96 @@ pub fn write_config(
Ok(WriteResult { backup_path, written_file, written_fields, errors })
}
// =====================================================================
// 配置「生效检查」(writeConfig 后验证,架构 §C「配置后验证」)
// =====================================================================
/// 写配置后验证:配置文件能否被解析、CLI 本身是否认可(--version 探测)。
/// 不真调付费 API——验证到「CLI 认可配置」为止,并如实标注验证层级。
pub fn verify_config(adapter: &Adapter) -> ConfigVerifyResult {
let cli_id = adapter.id.clone();
// 1. 重读配置文件并解析(有配置文件声明时)
if let Some(cfg) = adapter.configuration.as_ref() {
for f in &cfg.files {
let path = cfg::resolve_path(&f.path);
if !path.exists() {
continue;
}
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
return ConfigVerifyResult {
cli_id,
ok: false,
level: "config_parse_failed".into(),
message_zh: format!("配置文件读取失败:{e}"),
detail: Some(path.to_string_lossy().to_string()),
};
}
};
if let Err(e) = cfg::parse(cfg::ConfigFormat::from_str(&f.format), &text) {
return ConfigVerifyResult {
cli_id,
ok: false,
level: "config_parse_failed".into(),
message_zh: format!("配置文件解析失败:{}", f.path),
detail: Some(e.to_string()),
};
}
}
}
// 2. 若 CLI 已安装,运行版本探测,确认 CLI 本身可正常启动(不真调 API)
let detect = detect_one(adapter);
if detect.status == "installed" {
let exe = detect.executable.as_deref().unwrap_or_default();
let version_args: Vec<String> = adapter
.detect
.as_ref()
.map(|d| d.version_args.clone())
.unwrap_or_default();
let args: Vec<String> = if version_args.is_empty() {
vec!["--version".to_string()]
} else {
version_args
};
match run_capture(std::path::Path::new(exe), &args) {
Ok((true, text)) => {
let v = detect.version.clone();
return ConfigVerifyResult {
cli_id,
ok: true,
level: "cli_accepted".into(),
message_zh: format!(
"配置文件已写入且可解析;{} 版本探测正常{}。未实际调用付费 API,验证到「CLI 认可配置」为止。",
adapter.name_zh,
v.as_deref().map(|v| format!("{v}")).unwrap_or_default()
),
detail: Some(text.trim().to_string()),
};
}
Ok((false, _)) | Err(_) => {
return ConfigVerifyResult {
cli_id,
ok: true,
level: "config_parsed".into(),
message_zh: "配置文件已写入且可解析;CLI 版本探测未成功,仅验证到「配置可解析」一层。".into(),
detail: None,
};
}
}
}
ConfigVerifyResult {
cli_id,
ok: true,
level: "not_installed".into(),
message_zh: "配置文件已写入且可解析;该 CLI 尚未安装,未做 CLI 实际校验(以官方文档为准)。".into(),
detail: None,
}
}
// =====================================================================
// 授权
// =====================================================================
@@ -660,7 +788,26 @@ where
return Err(EngineError::NotSupported(format!("{} 未声明可执行的 {label} 命令", adapter.id)));
}
emit(ActionEvent::step(format!("开始{label} {}{}", adapter.name_zh, adapter.id)));
// 渠道信息(安装时展示;更新/卸载无渠道概念)
let channel_hint = if action == AdapterAction::Install {
adapter
.install
.as_ref()
.and_then(|i| pick_channel(i, opts.channel.as_deref()))
.map(|c| c.id.clone())
} else {
None
};
emit(ActionEvent::step_phase(
"prepare",
format!(
"准备{label} {}{}{}",
adapter.name_zh,
adapter.id,
channel_hint.as_deref().map(|c| format!("· 渠道 {c}")).unwrap_or_default()
),
));
for cmd in &commands {
let prog = &cmd[0];
@@ -671,30 +818,43 @@ where
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| prog.clone());
emit(ActionEvent::step(format!(
"执行:{} {}",
prog,
args.join(" ")
)));
emit(ActionEvent::step_phase(
"exec",
format!("执行命令:{} {}", prog, args.join(" ")),
));
let ok = run_streaming(&exe, &args, |is_err, line| {
let result = run_streaming(&exe, &args, |is_err, line| {
let r = redact(line);
if is_err {
emit(ActionEvent::stderr(r));
} else {
emit(ActionEvent::stdout(r));
}
})?;
});
let ok = match result {
Ok(ok) => ok,
Err(e) => {
let hint = diag_hint_for_install_failure(adapter, &e.to_string());
emit(ActionEvent::error(format!(
"{label}失败:无法启动命令({e})。{hint}"
)));
return Err(EngineError::Exec(format!("{label} 命令启动失败: {e}")));
}
};
if !ok {
emit(ActionEvent::error(format!("{label} 命令退出码非 0")));
let hint = diag_hint_for_install_failure(adapter, "");
emit(ActionEvent::error(format!(
"{label}失败:命令退出码非 0(原始错误见上方 stderr 输出)。{hint}"
)));
return Err(EngineError::Exec(format!("{label} 命令退出码非 0")));
}
}
// 安装后自动复检
// 安装后自动复检(当场复检并展示版本/路径,不只看事后 detect)
if action == AdapterAction::Install {
emit(ActionEvent::step("安装完成,自动复检"));
emit(ActionEvent::step_phase("verify", "安装命令执行完成,正在复检安装结果"));
let detect = detect_one(adapter);
emit(ActionEvent::done(Some(serde_json::to_value(&detect).unwrap_or(json!({})))));
} else {
@@ -704,6 +864,25 @@ where
Ok(())
}
/// 安装失败时的中文诊断建议(按适配器声明的依赖/渠道给针对性提示,不吞原始错误)。
fn diag_hint_for_install_failure(adapter: &Adapter, spawn_err: &str) -> String {
let mut parts: Vec<String> = Vec::new();
if !spawn_err.is_empty() {
parts.push("请确认所需命令已安装并加入 PATH(可到「诊断」页排查)".into());
}
let deps: Vec<&str> = adapter
.runtime_deps
.iter()
.filter(|d| d.required_for.iter().any(|r| r == "install" || r == "run"))
.map(|d| d.id.as_str())
.collect();
if !deps.is_empty() {
parts.push(format!("依赖检查:{}", deps.join("")));
}
parts.push("常见原因:网络不通 / 依赖版本不符 / 需要管理员权限,可在「诊断」页查看详细结论。".into());
format!("{}", parts.join(""))
}
// =====================================================================
// 测试
// =====================================================================
@@ -845,6 +1024,40 @@ configuration:
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_official_codex_fields_to_file_and_keyring() {
// 用真实 adapters/tools/codex.yaml 验证官方字段(含 enum)写入 + 密钥铁律
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("真实适配器目录应可加载");
let mut adapter = adapters.into_iter().find(|a| a.id == "codex").unwrap();
let tmp = std::env::temp_dir().join(format!("agentdock-core-official-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let file = tmp.join("config.toml");
if let Some(c) = adapter.configuration.as_mut() {
c.files[0].path = file.to_string_lossy().to_string();
}
let secrets = MockSecretStore::new();
let mut patch = std::collections::BTreeMap::new();
patch.insert("model".to_string(), "gpt-5.6-terra".to_string());
patch.insert("approval_policy".to_string(), "on-failure".to_string());
patch.insert("openai_base_url".to_string(), "http://proxy.example.com".to_string());
patch.insert("api_key".to_string(), "sk-test-xxx".to_string());
let res = write_config(&adapter, &patch, &secrets).unwrap();
assert_eq!(res.written_fields.len(), 4);
let text = std::fs::read_to_string(&file).unwrap();
assert!(text.contains("gpt-5.6-terra"), "默认模型应写入配置: {text}");
assert!(text.contains("on-failure"), "审批策略(enum)应写入配置: {text}");
assert!(text.contains("http://proxy.example.com"), "Base URL 应写入配置: {text}");
assert!(!text.contains("sk-test-xxx"), "API Key 不得写入配置文件");
assert!(secrets.has(&service_name("codex"), "api_key"));
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn write_config_creates_backup_on_second_write() {
let adapter = codex_adapter();
@@ -966,6 +1179,60 @@ configuration:
.contains(&r.status.as_str()));
}
#[test]
fn verify_config_reports_parse_failure() {
let adapter = codex_adapter();
let dir = std::env::temp_dir().join(format!("agentdock-core-verify-bad-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("config.toml");
std::fs::write(&file, "model = [unclosed").unwrap();
let mut adapter = adapter;
if let Some(c) = adapter.configuration.as_mut() {
c.files[0].path = file.to_string_lossy().to_string();
}
let r = verify_config(&adapter);
assert_eq!(r.level, "config_parse_failed");
assert!(!r.ok);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn verify_config_valid_file_passes_without_cli() {
// CLI 未安装时应返回 ok=true(配置可解析),level 为 not_installed 或 cli_accepted
let adapter = codex_adapter();
let dir = std::env::temp_dir().join(format!("agentdock-core-verify-ok-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("config.toml");
std::fs::write(&file, "model = \"gpt-5\"\n").unwrap();
let mut adapter = adapter;
if let Some(c) = adapter.configuration.as_mut() {
c.files[0].path = file.to_string_lossy().to_string();
}
let r = verify_config(&adapter);
assert!(r.ok);
assert!(["not_installed", "cli_accepted", "config_parsed"].contains(&r.level.as_str()));
let _ = std::fs::remove_dir_all(&dir);
}
/// 真机配置「生效检查」:对已安装的 codex 跑 verify_config,打印验证层级。
/// 运行:cargo test -p agentdock-core real_machine_verify_codex -- --ignored --nocapture
#[test]
#[ignore]
fn real_machine_verify_codex() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let codex = adapters.iter().find(|a| a.id == "codex").expect("缺少 codex");
let r = verify_config(codex);
println!("codex verify: level={} ok={}", r.level, r.ok);
println!(" message={}", r.message_zh);
if let Some(d) = &r.detail {
println!(" detail={}", d);
}
assert!(r.ok, "codex 已安装且配置可解析时应 ok=true");
}
/// 真机检测记录:对首批五件套逐项 detect 并打印结果(自测记录用)。
/// 运行:cargo test -p agentdock-core real_machine_detect_five -- --ignored --nocapture
#[test]
+3 -3
View File
@@ -9,9 +9,9 @@ pub mod error;
pub mod process;
pub mod types;
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, write_config};
pub use engine::{ActionOpts, Engine, auth_status, authorize, detect_one, diagnose, parse_version, read_config, run_action, verify_config, write_config};
pub use error::EngineError;
pub use types::{
ActionEvent, AuthStatus, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
EnvFieldState, WriteResult, now_secs,
ActionEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState, DetectResult,
EnvFieldState, WriteResult, ConfigVerifyResult, now_secs,
};
+50 -5
View File
@@ -48,6 +48,20 @@ pub struct ConfigFieldState {
pub value: Option<String>,
/// 密钥库类字段:是否已有值
pub has_value: bool,
/// 官方文档链接(配置项依据)
pub docs_url: Option<String>,
/// enum 类型的可选值(value + 中文名)
pub options: Vec<ConfigFieldOption>,
/// 该字段(keyring 类)对应的环境变量名(如 OPENAI_API_KEY),供界面说明
pub env_key: Option<String>,
/// 表单分组:auth | common | advanced(未声明按 common
pub group: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFieldOption {
pub value: String,
pub label_zh: String,
}
/// 配置文件解析状态。
@@ -77,6 +91,18 @@ pub struct ConfigFormState {
pub files: Vec<ConfigFileState>,
pub fields: Vec<ConfigFieldState>,
pub environment: Vec<EnvFieldState>,
/// 官方授权方式(授权/登录层引导用)
pub auth_modes: Vec<AuthModeInfo>,
}
/// 官方授权方式(授权/登录层展示)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthModeInfo {
/// browser_oauth | device_code | api_key | local_tui
pub mode: String,
pub notes_zh: Option<String>,
/// 交互式登录命令(浏览器/设备码等;api_key 类常为空)
pub command: Vec<String>,
}
/// 写配置结果(writeConfig 返回)。
@@ -92,6 +118,20 @@ pub struct WriteResult {
pub errors: Vec<String>,
}
/// 配置写入后的「生效检查」结果(verifyConfig 返回)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigVerifyResult {
pub cli_id: String,
/// 整体是否通过(配置文件可解析即视为「已写入成功」)
pub ok: bool,
/// 验证层级:cli_accepted | config_parsed | config_parse_failed | not_installed
pub level: String,
/// 中文结论(如实标注验证到哪一层)
pub message_zh: String,
/// 脱敏后的补充信息(CLI 版本输出 / 解析错误)
pub detail: Option<String>,
}
/// 动作流事件(runAction 流式输出)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionEvent {
@@ -99,24 +139,29 @@ pub struct ActionEvent {
pub kind: String,
/// 已脱敏的消息文本
pub message: String,
/// 当前阶段:prepare | exec | verify(供界面渲染步骤进度)
pub phase: Option<String>,
pub data: Option<serde_json::Value>,
}
impl ActionEvent {
pub fn step(msg: impl Into<String>) -> Self {
ActionEvent { kind: "step".into(), message: msg.into(), data: None }
ActionEvent { kind: "step".into(), message: msg.into(), phase: None, data: None }
}
pub fn step_phase(phase: &str, msg: impl Into<String>) -> Self {
ActionEvent { kind: "step".into(), message: msg.into(), phase: Some(phase.into()), data: None }
}
pub fn stdout(line: impl Into<String>) -> Self {
ActionEvent { kind: "stdout".into(), message: line.into(), data: None }
ActionEvent { kind: "stdout".into(), message: line.into(), phase: Some("exec".into()), data: None }
}
pub fn stderr(line: impl Into<String>) -> Self {
ActionEvent { kind: "stderr".into(), message: line.into(), data: None }
ActionEvent { kind: "stderr".into(), message: line.into(), phase: Some("exec".into()), data: None }
}
pub fn done(data: Option<serde_json::Value>) -> Self {
ActionEvent { kind: "done".into(), message: String::new(), data }
ActionEvent { kind: "done".into(), message: String::new(), phase: Some("verify".into()), data }
}
pub fn error(msg: impl Into<String>) -> Self {
ActionEvent { kind: "error".into(), message: msg.into(), data: None }
ActionEvent { kind: "error".into(), message: msg.into(), phase: None, data: None }
}
}