Wave 3.1:老板十项返工(kimi 换代 kimi-code/授权检测/可更新判定/模型列表+测试连通/系统环境诊断/外链/命令复制/官方图标主色)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
leefer
2026-08-25 22:52:33 +08:00
co-authored by multica-agent
parent 0cec468ae6
commit b19efef0f3
30 changed files with 1594 additions and 124 deletions
+547 -3
View File
@@ -15,11 +15,12 @@ use agentdock_secrets::{SecretStore, redact, service_name};
use serde_json::json;
use crate::error::EngineError;
use crate::errors_zh::map_exec_error;
use crate::errors_zh::{map_exec_error, runtime_label_zh};
use crate::process::{
resolve_exe, run_capture, run_streaming, run_streaming_cancellable, run_terminal, run_with_stdin,
which_all, RunningProcess,
};
use crate::runtime_install::{fetch_http_text, is_url_host_allowed};
use crate::types::*;
/// 授权会话 key`<cli_id>:<mode>`。
@@ -345,6 +346,30 @@ impl Engine {
.collect())
}
/// 系统环境诊断(Wave 3.1 Req 7):检查缺失/过低的系统依赖,标注影响哪些 CLI。
pub fn diagnose_system(&self, env: &agentdock_platform::model::PlatformEnv) -> Result<SystemEnvReport, EngineError> {
let adapters = self.adapters()?;
Ok(diagnose_system(env, &adapters))
}
/// 「可更新」判定(Wave 3.1 Req 4):官方源最新版本 vs 本地已装版本 semver 对比。
pub fn check_update(&self, id: &str) -> Result<UpdateCheckResult, EngineError> {
let adapter = self.adapter(id)?;
Ok(check_update(&adapter))
}
/// 模型列表(Wave 3.1 Req 5):优先 CLI 自带列举命令,否则适配器维护清单。
pub fn list_models(&self, id: &str) -> Result<ModelListResult, EngineError> {
let adapter = self.adapter(id)?;
Ok(list_models(&adapter))
}
/// 测试连通(Wave 3.1 Req 5):密钥从密钥库读取,直连官方接口测一次。
pub fn test_connection(&self, id: &str) -> Result<ConnectionTestResult, EngineError> {
let adapter = self.adapter(id)?;
Ok(test_connection(&adapter, self.secrets.as_ref()))
}
// ---- 执行 ----
pub fn run<F>(&self, id: &str, action: AdapterAction, opts: &ActionOpts, mut emit: F) -> Result<(), EngineError>
@@ -840,7 +865,23 @@ pub fn auth_status(adapter: &Adapter, secrets: &dyn SecretStore) -> AuthStatus {
}
}
// 2. keyring
// 2. credential_filesWave 3.1 Req 3):无 status_command 的 CLI 用登录态/OAuth 凭据文件判定
if let Some(auth) = adapter.authorization.as_ref() {
for cred in &auth.credential_files {
let p = cfg::resolve_path(cred);
if p.exists() {
return AuthStatus {
cli_id: adapter.id.clone(),
status: "authorized".into(),
via: "credential_file".into(),
detail_zh: format!("检测到登录凭据({}", cred),
checked_at,
};
}
}
}
// 3. keyring
if let Some(cfg) = adapter.configuration.as_ref() {
for field in &cfg.fields {
if field.sensitive && secrets.has(&service, &field.id) {
@@ -855,7 +896,7 @@ pub fn auth_status(adapter: &Adapter, secrets: &dyn SecretStore) -> AuthStatus {
}
}
// 3. unknown
// 4. unknown
AuthStatus {
cli_id: adapter.id.clone(),
status: "unknown".into(),
@@ -1175,6 +1216,435 @@ where
Ok(())
}
// =====================================================================
// Wave 3.1:可更新判定 / 模型列表 / 测试连通 / 系统环境诊断
// =====================================================================
/// 版本号 → 数值元组(忽略 v 前缀与非数字段,供版本比较)。
fn version_parts(s: &str) -> Vec<u64> {
s.split(|c: char| !c.is_ascii_digit())
.filter(|seg| !seg.is_empty())
.filter_map(|seg| seg.parse::<u64>().ok())
.collect()
}
/// 比较两个版本号,返回 Ordering(缺失段按 0 处理)。
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
let pa = version_parts(a);
let pb = version_parts(b);
let n = pa.len().max(pb.len());
for i in 0..n {
let x = pa.get(i).copied().unwrap_or(0);
let y = pb.get(i).copied().unwrap_or(0);
match x.cmp(&y) {
std::cmp::Ordering::Equal => continue,
other => return other,
}
}
std::cmp::Ordering::Equal
}
/// 官方源最新版本查询结果。
enum LatestVersion {
Found(String),
Unsupported(String),
Failed(String),
}
/// 从官方源查询最新版本(npm registry / PyPI / GitHub Releases),均经 allowed_hosts 白名单。
fn fetch_latest_version(adapter: &Adapter) -> LatestVersion {
let allowed = adapter
.official
.as_ref()
.map(|o| o.allowed_hosts.clone())
.unwrap_or_default();
let src = match adapter.update.as_ref().and_then(|u| u.source.as_ref()) {
Some(s) => s,
None => return LatestVersion::Unsupported("适配器未声明 update.source(可更新依据来源)".into()),
};
let url = match src.kind.as_str() {
"npm" => format!("https://registry.npmjs.org/{}", src.package.as_deref().unwrap_or("")),
"pypi" => format!("https://pypi.org/pypi/{}/json", src.package.as_deref().unwrap_or("")),
"github" => format!("https://api.github.com/repos/{}/releases/latest", src.repo.as_deref().unwrap_or("")),
other => return LatestVersion::Unsupported(format!("不支持的更新来源 {other}")),
};
if !is_url_host_allowed(&url, &allowed) {
return LatestVersion::Failed(format!("来源主机不在白名单内:{url}"));
}
match fetch_http_text(&url, &[]) {
Ok(text) => {
let v: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => return LatestVersion::Failed("官方源返回非 JSON".into()),
};
let latest = match src.kind.as_str() {
"npm" => v.pointer("/dist-tags/latest").and_then(|x| x.as_str()),
"pypi" => v.pointer("/info/version").and_then(|x| x.as_str()),
"github" => v.pointer("/tag_name").and_then(|x| x.as_str()),
_ => None,
};
match latest {
Some(l) => LatestVersion::Found(l.trim_start_matches('v').to_string()),
None => LatestVersion::Failed("官方源未返回版本号".into()),
}
}
Err(e) => LatestVersion::Failed(format!("查询官方源失败:{e}")),
}
}
/// 「可更新」判定(Wave 3.1 Req 4):官方源最新版本 vs 本地已装版本 semver 对比。
pub fn check_update(adapter: &Adapter) -> UpdateCheckResult {
let cli_id = adapter.id.clone();
let detect = detect_one(adapter);
let current = if detect.status == "installed" { detect.version.clone() } else { None };
let (source_zh, source_url) = match adapter.update.as_ref().and_then(|u| u.source.as_ref()) {
Some(s) => match s.kind.as_str() {
"npm" => (
"npm registry".to_string(),
format!("https://registry.npmjs.org/{}", s.package.as_deref().unwrap_or("")),
),
"pypi" => (
"PyPI".to_string(),
format!("https://pypi.org/pypi/{}", s.package.as_deref().unwrap_or("")),
),
"github" => (
"GitHub Releases".to_string(),
format!("https://github.com/{}/releases", s.repo.as_deref().unwrap_or("")),
),
_ => ("官方源".to_string(), String::new()),
},
None => ("官方源".to_string(), String::new()),
};
match fetch_latest_version(adapter) {
LatestVersion::Found(latest) => {
let update_available = match &current {
Some(c) => compare_versions(c, &latest) == std::cmp::Ordering::Less,
None => false,
};
UpdateCheckResult {
cli_id,
current,
latest: Some(latest),
update_available,
source_zh,
source_url,
error: None,
}
}
LatestVersion::Unsupported(m) => UpdateCheckResult {
cli_id, current, latest: None, update_available: false, source_zh, source_url, error: Some(m),
},
LatestVersion::Failed(m) => UpdateCheckResult {
cli_id, current, latest: None, update_available: false, source_zh, source_url, error: Some(m),
},
}
}
/// 模型列表(Wave 3.1 Req 5):优先 CLI 自带列举命令,否则适配器维护清单。
pub fn list_models(adapter: &Adapter) -> ModelListResult {
let cli_id = adapter.id.clone();
let models = match adapter.models.as_ref() {
None => {
return ModelListResult {
cli_id,
models: vec![],
source: "empty".into(),
detail_zh: "该 CLI 未声明模型清单".into(),
}
}
Some(m) => m,
};
// 优先:CLI 自带列举命令(已安装时实时调取)
if !models.command.is_empty() {
let prog = &models.command[0];
let args: Vec<String> = models.command[1..].to_vec();
if agentdock_exec::validate_argv(prog, &args).is_ok() {
if let Some(exe) = which_all(prog).first() {
let exe = exe.to_string_lossy().to_string();
if let Ok((true, text)) = run_capture(std::path::Path::new(&exe), &args) {
let parsed = parse_model_lines(&text);
if !parsed.is_empty() {
return ModelListResult {
cli_id,
models: parsed,
source: "cli_command".into(),
detail_zh: format!("已通过 {} 实时列举", models.command.join(" ")),
};
}
}
}
}
}
// 回退:适配器按官方文档维护的模型清单
let list: Vec<ModelInfo> = models
.list
.iter()
.map(|e| ModelInfo { id: e.id.clone(), label_zh: e.label_zh.clone() })
.collect();
let source = if list.is_empty() { "empty" } else { "adapter" };
ModelListResult {
cli_id,
models: list,
source: source.to_string(),
detail_zh: "按官方文档维护的模型清单".into(),
}
}
/// 从模型列举命令输出解析模型 ID(逐行清理,剔除明显表头)。
fn parse_model_lines(text: &str) -> Vec<ModelInfo> {
text.lines()
.map(|l| {
l.trim()
.trim_start_matches('-')
.trim_start_matches('*')
.trim()
.to_string()
})
.filter(|l| !l.is_empty() && l.len() <= 200)
.filter(|l| !l.contains("模型") && !l.to_lowercase().starts_with("available") && !l.to_lowercase().starts_with("model"))
.take(200)
.map(|l| ModelInfo { id: l, label_zh: None })
.collect()
}
/// curl 探测(不 --fail,返回 HTTP 状态码 + 响应体,用于测试连通时区分 401/403/网络失败)。
fn curl_probe(url: &str, headers: &[String]) -> (Option<u16>, String) {
let mut cmd = std::process::Command::new("curl.exe");
cmd.args(["-L", "--silent", "--show-error", "--max-time", "20", "-w", "\n__HTTP_STATUS__%{http_code}"]);
for h in headers {
cmd.arg("-H").arg(h);
}
cmd.arg(url);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
match cmd.output() {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout).to_string();
let status = text
.rsplit("__HTTP_STATUS__")
.next()
.and_then(|s| s.trim().parse::<u16>().ok());
let body = text.split("__HTTP_STATUS__").next().unwrap_or("").trim().to_string();
(status, body)
}
Err(_) => (None, String::new()),
}
}
/// 测试连通(Wave 3.1 Req 5):密钥从密钥库读取直连官方接口测一次,全程不落盘不进日志。
pub fn test_connection(adapter: &Adapter, secrets: &dyn SecretStore) -> ConnectionTestResult {
let cli_id = adapter.id.clone();
let test = match adapter.authorization.as_ref().and_then(|a| a.test.as_ref()) {
Some(t) => t,
None => {
return ConnectionTestResult {
cli_id,
ok: false,
message_zh: "该 CLI 未声明测试连通端点".into(),
http_status: None,
detail: None,
}
}
};
let allowed = adapter
.official
.as_ref()
.map(|o| o.allowed_hosts.clone())
.unwrap_or_default();
if !is_url_host_allowed(&test.url, &allowed) {
return ConnectionTestResult {
cli_id,
ok: false,
message_zh: "测试端点不在白名单内".into(),
http_status: None,
detail: None,
};
}
let service = service_name(&adapter.id);
let key = match secrets.get(&service, "api_key") {
Ok(k) if !k.is_empty() => k,
_ => {
return ConnectionTestResult {
cli_id,
ok: false,
message_zh: "未找到已保存的 API Key,请先在「授权 / 登录」里保存".into(),
http_status: None,
detail: None,
}
}
};
let header_name = test
.key_header
.clone()
.unwrap_or_else(|| "Authorization".to_string());
let header_value = if header_name.eq_ignore_ascii_case("authorization") || test.bearer {
format!("Bearer {key}")
} else {
key.clone()
};
let header = format!("{header_name}: {header_value}");
let mut headers = vec![header];
headers.extend(test.extra_headers.clone());
let (status, body) = curl_probe(&test.url, &headers);
match status {
Some(s) if (200..300).contains(&s) => ConnectionTestResult {
cli_id,
ok: true,
message_zh: "连通正常,官方接口响应成功".into(),
http_status: Some(s),
detail: Some(redact(&body).chars().take(200).collect()),
},
Some(401) | Some(403) => ConnectionTestResult {
cli_id,
ok: false,
message_zh: "密钥无效或权限不足(官方接口返回 401/403)".into(),
http_status: status,
detail: Some(redact(&body).chars().take(200).collect()),
},
Some(s) => ConnectionTestResult {
cli_id,
ok: false,
message_zh: format!("官方接口返回 HTTP {s}").into(),
http_status: Some(s),
detail: Some(redact(&body).chars().take(200).collect()),
},
None => ConnectionTestResult {
cli_id,
ok: false,
message_zh: "网络请求失败,无法连通官方接口".into(),
http_status: None,
detail: None,
},
}
}
/// 程序名 → 运行时 id(安装命令用到 npm 视为依赖 Node.js,等等)。
fn prog_to_runtime(prog: &str) -> Option<&'static str> {
match prog.to_ascii_lowercase().as_str() {
"npm" | "npx" | "node" => Some("node"),
"uv" | "uvx" => Some("uv"),
"python" | "python3" | "pip" => Some("python"),
"git" => Some("git"),
"winget" => Some("winget"),
"apt" | "apt-get" | "sudo" => Some("apt"),
_ => None,
}
}
/// 判断适配器是否依赖某运行时(runtime_deps 声明,或其安装/更新/卸载命令用到对应程序)。
fn adapter_depends_on(a: &Adapter, runtime_id: &str) -> bool {
if a.runtime_deps.iter().any(|d| d.id == runtime_id) {
return true;
}
let mut progs: Vec<String> = Vec::new();
if let Some(i) = a.install.as_ref() {
for c in &i.channels {
if let Some(p) = c.command.first() {
progs.push(p.to_lowercase());
}
}
}
if let Some(u) = a.update.as_ref() {
if let Some(p) = u.command.first() {
progs.push(p.to_lowercase());
}
}
if let Some(u) = a.uninstall.as_ref() {
if let Some(p) = u.command.first() {
progs.push(p.to_lowercase());
}
}
progs.iter().any(|p| prog_to_runtime(p) == Some(runtime_id))
}
/// 系统环境诊断(Wave 3.1 Req 7):检查缺失/过低的系统依赖,每项标注影响哪些 CLI。
pub fn diagnose_system(env: &agentdock_platform::model::PlatformEnv, adapters: &[Adapter]) -> SystemEnvReport {
let rt = &env.runtimes;
let ids: &[&str] = if env.os == "windows" {
&["node", "python", "uv", "git", "winget"]
} else {
&["node", "python", "uv", "git", "apt"]
};
let mut findings = Vec::new();
for id in ids {
let info: Option<&agentdock_platform::model::RuntimeInfo> = match *id {
"node" => rt.node.as_ref(),
"npm" => rt.npm.as_ref(),
"python" => rt.python.as_ref(),
"uv" => rt.uv.as_ref(),
"git" => rt.git.as_ref(),
"winget" => rt.winget.as_ref(),
"apt" => rt.apt.as_ref(),
_ => None,
};
let affected: Vec<String> = adapters
.iter()
.filter(|a| adapter_depends_on(a, id))
.map(|a| a.id.clone())
.collect();
if affected.is_empty() {
continue;
}
let (status, version, message_zh) = match info {
Some(i) if i.status == "installed" => {
let mut below_min = false;
let mut range_used = String::new();
for a in adapters {
for d in &a.runtime_deps {
if d.id == *id {
if let Some(range) = &d.semver_range {
if let Some(v) = i.version.as_deref() {
if !diag::version_satisfies(v, range) {
below_min = true;
range_used = range.clone();
}
}
}
}
}
}
if below_min {
(
"below_min".to_string(),
i.version.clone(),
format!(
"版本 {} 不满足受影响 CLI 要求的 {}",
i.version.as_deref().unwrap_or("?"),
range_used
),
)
} else {
("installed".to_string(), i.version.clone(), "已安装且版本满足要求".to_string())
}
}
Some(i) => (i.status.clone(), i.version.clone(), format!("状态:{}", i.status)),
None => ("not_installed".to_string(), None, "未检测到该运行时".to_string()),
};
findings.push(SystemEnvFinding {
runtime_id: id.to_string(),
label_zh: runtime_label_zh(id).to_string(),
status,
version,
affected_cli: affected,
message_zh,
});
}
SystemEnvReport { findings }
}
// =====================================================================
// 测试
// =====================================================================
@@ -1833,4 +2303,78 @@ configuration:
assert!(url.contains("openai.com") || url.contains("auth.openai"), "验证链接应指向官方域名: {url}");
assert!(!code.is_empty(), "设备码不应为空");
}
#[test]
fn compare_versions_ordering() {
use std::cmp::Ordering;
assert_eq!(compare_versions("1.2.3", "1.2.4"), Ordering::Less);
assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
assert_eq!(compare_versions("v1.2.3", "1.2.3"), Ordering::Equal);
assert_eq!(compare_versions("2026.08.11", "2026.08.10"), Ordering::Greater);
assert_eq!(compare_versions("1.2.3", "1.2.3.0"), Ordering::Equal);
}
#[test]
fn prog_to_runtime_maps_commands() {
assert_eq!(prog_to_runtime("npm"), Some("node"));
assert_eq!(prog_to_runtime("uv"), Some("uv"));
assert_eq!(prog_to_runtime("winget"), Some("winget"));
assert_eq!(prog_to_runtime("apt-get"), Some("apt"));
assert_eq!(prog_to_runtime("opencode"), None);
}
#[test]
fn adapter_depends_on_via_install_command() {
let adapter = codex_adapter(); // codex 走 npm install → 依赖 node
assert!(adapter_depends_on(&adapter, "node"));
assert!(!adapter_depends_on(&adapter, "uv"));
}
#[test]
fn diagnose_system_reports_missing_runtime_with_affected_cli() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let mut env = agentdock_platform::model::PlatformEnv {
os: "windows".into(),
..Default::default()
};
env.runtimes.node = Some(agentdock_platform::model::RuntimeInfo {
status: "installed".into(),
version: Some("24.18.0".into()),
path: None,
});
env.runtimes.uv = None; // uv 未安装
let report = diagnose_system(&env, &adapters);
let uv = report.findings.iter().find(|f| f.runtime_id == "uv").expect("应包含 uv 依赖项");
assert_eq!(uv.status, "not_installed");
assert!(uv.affected_cli.iter().any(|c| c == "kimi" || c == "aider"), "uv 应标注影响 kimi/aider");
assert!(!uv.message_zh.is_empty());
let node = report.findings.iter().find(|f| f.runtime_id == "node").expect("应包含 node 依赖项");
assert_eq!(node.status, "installed");
assert!(node.affected_cli.iter().any(|c| c == "gemini"), "node 应标注影响 gemini");
}
#[test]
fn list_models_falls_back_to_adapter_list() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let kimi = adapters.iter().find(|a| a.id == "kimi").unwrap();
let r = list_models(kimi);
assert!(!r.models.is_empty(), "kimi 应维护模型清单");
assert!(r.models.iter().any(|m| m.id == "kimi-code/k3"));
}
#[test]
fn check_update_returns_error_when_no_source() {
// warp 未声明 update.source,应如实返回「未声明来源」而非误报可更新
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters");
let adapters = load_adapters(&dir).expect("适配器目录应可加载");
let warp = adapters.iter().find(|a| a.id == "warp").unwrap();
let r = check_update(warp);
assert!(!r.update_available);
assert!(r.latest.is_none());
assert!(r.error.is_some());
}
}
+2
View File
@@ -27,10 +27,12 @@ pub fn runtime_for_prog(prog: &str) -> Option<&'static str> {
pub fn runtime_label_zh(id: &str) -> &'static str {
match id {
"node" => "Node.js",
"npm" => "npm",
"python" => "Python",
"git" => "Git",
"winget" => "winget",
"uv" => "uv",
"apt" => "apt",
"choco" => "choco",
"scoop" => "scoop",
_ => "运行时",
+5 -3
View File
@@ -16,12 +16,14 @@ pub use engine::{
parse_version, read_config, run_action, strip_ansi, verify_config, write_config,
};
pub use error::EngineError;
pub use errors_zh::{map_exec_error, runtime_for_prog};
pub use errors_zh::{map_exec_error, runtime_for_prog, runtime_label_zh};
pub use process::{open_with_shell, resolve_exe, run_capture, run_streaming, run_terminal, run_with_stdin, which_all, RunningProcess};
pub use runtime_install::{
download_with_curl, is_url_host_allowed, source_for, supported_runtimes, RuntimeSource,
download_with_curl, fetch_http_text, is_url_host_allowed, source_for, supported_runtimes,
RuntimeSource,
};
pub use types::{
ActionEvent, AuthFlowEvent, AuthStatus, AuthModeInfo, ConfigFieldState, ConfigFileState, ConfigFormState,
DetectResult, EnvFieldState, ErrorHint, WriteResult, ConfigVerifyResult, now_secs,
ConnectionTestResult, DetectResult, EnvFieldState, ErrorHint, ModelInfo, ModelListResult,
SystemEnvFinding, SystemEnvReport, UpdateCheckResult, WriteResult, ConfigVerifyResult, now_secs,
};
@@ -134,6 +134,31 @@ pub fn download_with_curl(url: &str, dest: &std::path::Path) -> std::io::Result<
}
}
/// 用系统 `curl.exe` 拉取 HTTPS 文本到 stdout(用于官方源版本查询 / 测试连通)。
/// 仅应配合 `is_url_host_allowed` 白名单校验后使用。`headers` 为额外请求头(如鉴权)。
pub fn fetch_http_text(url: &str, headers: &[String]) -> std::io::Result<String> {
let mut cmd = std::process::Command::new("curl.exe");
cmd.args(["-L", "--fail", "--silent", "--show-error", "--max-time", "20"]);
for h in headers {
cmd.arg("-H").arg(h);
}
cmd.arg(url);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let out = cmd.output()?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::Other,
"curl 请求失败(网络错误或来源不可用)",
))
}
}
/// 极简 URL 主机提取(仅用于白名单比对,不解析完整 URL)。
fn extract_host(url: &str) -> Option<String> {
let s = url.trim();
+73
View File
@@ -291,3 +291,76 @@ pub fn now_secs() -> u64 {
.map(|d| d.as_secs())
.unwrap_or(0)
}
// =====================================================================
// Wave 3.1:可更新判定 / 模型列表 / 测试连通 / 系统环境诊断
// =====================================================================
/// 「可更新」判定结果(官方源最新版本 vs 本地已装版本 semver 对比)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheckResult {
pub cli_id: String,
/// 本地已装版本(未安装为 None)
pub current: Option<String>,
/// 官方源最新版本(查询失败为 None)
pub latest: Option<String>,
/// 是否可更新(本地版本 < 官方最新版本)
pub update_available: bool,
/// 依据来源(中文,如「npm registry」)
pub source_zh: String,
/// 依据来源链接
pub source_url: String,
/// 查询失败原因(成功为 None)
pub error: Option<String>,
}
/// 模型信息(id + 中文名)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub label_zh: Option<String>,
}
/// 模型列表结果(来源:CLI 自带列举命令或适配器维护清单)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelListResult {
pub cli_id: String,
pub models: Vec<ModelInfo>,
/// cli_command | adapter | empty
pub source: String,
pub detail_zh: String,
}
/// 「测试连通」结果(已授权工具直连官方接口测一次)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionTestResult {
pub cli_id: String,
pub ok: bool,
pub message_zh: String,
pub http_status: Option<u16>,
/// 脱敏后的补充信息(不含密钥明文)
pub detail: Option<String>,
}
/// 系统环境诊断:单个环境依赖项的状态及其影响的 CLI。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemEnvFinding {
/// 依赖 idnode / npm / python / uv / git / winget / apt
pub runtime_id: String,
/// 中文名(Node.js / Python / Git / uv / winget / apt
pub label_zh: String,
/// installed | not_installed | not_in_path | below_min
pub status: String,
/// 当前版本(可为 None
pub version: Option<String>,
/// 该依赖缺失/版本过低时受影响的 CLI id 列表
pub affected_cli: Vec<String>,
/// 中文结论
pub message_zh: String,
}
/// 系统环境诊断报告。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemEnvReport {
pub findings: Vec<SystemEnvFinding>,
}