feat(settings): diagnose conflicting tool installations with uninstall hints

Add a "Diagnose installs" action to the About section that enumerates every
installation of each managed CLI, flags real conflicts (diverging versions
or mixed runnable state), and lists them under the affected tool card.

- backend: enumerate_tool_installations + diagnose_tool_installations command,
  sharing build_tool_search_paths with the single-probe scan; resolve the PATH
  default via the same login shell as version probing so the "Default" marker
  matches what the command line (and an upgrade) actually targets
- frontend: global diagnose button left of Refresh, per-card conflict list,
  and a copy-only uninstall command per install -- source-aware (npm/volta/
  bun/pip) and anchored to that install's own npm to avoid removing the wrong
  node version; marked with a red trash icon, never executed
- auto-diagnose silently after an upgrade that leaves the version unchanged
  or installs something that can't run
- i18n: zh/en/ja
This commit is contained in:
Jason
2026-05-22 19:34:54 +08:00
parent ea604a1827
commit ce232a1452
7 changed files with 499 additions and 8 deletions
+228 -7
View File
@@ -974,10 +974,10 @@ fn extend_mise_node_search_paths(paths: &mut Vec<std::path::PathBuf>, home: &Pat
}
}
/// 扫描常见路径查找 CLI
fn scan_cli_version(tool: &str) -> ShellProbe {
use std::process::Command;
/// 构建某工具的候选搜索目录(原生安装优先,PATH 兜底)。
/// 单探兜底 (`scan_cli_version`) 与全量枚举 (`enumerate_tool_installations`) 共用,
/// 确保两条路径看到的是同一组安装位置。
fn build_tool_search_paths(tool: &str) -> Vec<std::path::PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
// 常见的安装路径(原生安装优先)
@@ -1102,9 +1102,16 @@ fn scan_cli_version(tool: &str) -> ShellProbe {
}
let path_env = std::env::var_os("PATH");
extend_from_cli_path_env(&mut search_paths, path_env.clone());
let current_path = path_env
.as_ref()
extend_from_cli_path_env(&mut search_paths, path_env);
search_paths
}
/// 扫描常见路径查找 CLI(PATH 主命令未命中时的兜底单探)。
fn scan_cli_version(tool: &str) -> ShellProbe {
use std::process::Command;
let search_paths = build_tool_search_paths(tool);
let current_path = std::env::var_os("PATH")
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
@@ -1168,6 +1175,220 @@ fn scan_cli_version(tool: &str) -> ShellProbe {
}
}
/// 单个工具在系统中的一处安装,用于"多处安装互相打架"的冲突诊断。
/// 字段保持 snake_case(与 `ToolVersion` 一致),前端按同名字段读取。
#[derive(Debug, serde::Serialize)]
pub struct ToolInstallation {
/// 候选入口路径(用户实际在 PATH 里看到/输入的那个,未解析软链)。
path: String,
/// `--version` 成功时解析出的版本号。
version: Option<String>,
/// `--version` 是否 exit 0(装了且能在当前环境跑起来)。
runnable: bool,
/// 跑不起来时的诊断信息末尾若干行。
error: Option<String>,
/// 由路径前缀推断的安装来源(nvm/homebrew/...),驱动 UI 徽章与卸载建议。
source: String,
/// 是否为 PATH 解析到的那处(= 命令行默认,也是升级会作用的目标)。
is_path_default: bool,
}
/// 由可执行文件路径前缀推断安装来源。纯字符串匹配、无副作用。
/// 顺序敏感:Homebrew 的 Cellar 真身要先于通用规则命中。
fn infer_install_source(path: &Path) -> &'static str {
let s = path
.to_string_lossy()
.replace('\\', "/")
.to_ascii_lowercase();
if s.contains("/.nvm/") {
"nvm"
} else if s.contains("/homebrew/") || s.contains("/cellar/") {
"homebrew"
} else if s.contains("/.volta/") {
"volta"
} else if s.contains("fnm_multishells") {
"fnm"
} else if s.contains("/mise/") {
"mise"
} else if s.contains("/.bun/") {
"bun"
} else if s.contains("/scoop/") {
"scoop"
} else if s.contains("/library/python")
|| s.contains("/scripts/")
|| s.contains("/site-packages/")
{
"pip"
} else {
"system"
}
}
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
#[cfg(not(target_os = "windows"))]
fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
use std::process::Command;
let shell = std::env::var("SHELL")
.ok()
.filter(|s| is_valid_shell(s))
.unwrap_or_else(|| "sh".to_string());
let flag = default_flag_for_shell(&shell);
let out = Command::new(shell)
.arg(flag)
.arg(format!("command -v {tool}"))
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let first = raw.lines().next()?.trim();
if first.is_empty() {
return None;
}
std::fs::canonicalize(first).ok()
}
#[cfg(target_os = "windows")]
fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
use std::os::windows::process::CommandExt;
use std::process::Command;
let out = Command::new("cmd")
.args(["/C", &format!("where {tool}")])
.creation_flags(CREATE_NO_WINDOW)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let first = raw.lines().next()?.trim();
if first.is_empty() {
return None;
}
std::fs::canonicalize(first).ok()
}
/// 枚举工具在系统中的所有安装(不短路)。与 `scan_cli_version` 共用
/// `build_tool_search_paths`,但不在首个命中处停止——而是对每个去重后的真实
/// 可执行文件都跑一次 `--version`,从而能发现"升级写入 A 处、PATH 实际用 B 处"。
fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
use std::process::Command;
let search_paths = build_tool_search_paths(tool);
let current_path = std::env::var_os("PATH")
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
let path_default = resolve_path_default(tool);
let mut seen: std::collections::HashSet<std::path::PathBuf> = std::collections::HashSet::new();
let mut installs: Vec<ToolInstallation> = Vec::new();
for dir in &search_paths {
#[cfg(target_os = "windows")]
let new_path = format!("{};{}", dir.display(), current_path);
#[cfg(not(target_os = "windows"))]
let new_path = format!("{}:{}", dir.display(), current_path);
for tool_path in tool_executable_candidates(tool, dir) {
if !tool_path.exists() {
continue;
}
// canonicalize 解析软链后去重:/opt/homebrew/bin/x → Cellar/...、nvm shim 等
// 多个入口可能指向同一真实文件,只算一处安装。
let real = std::fs::canonicalize(&tool_path).unwrap_or_else(|_| tool_path.clone());
if !seen.insert(real.clone()) {
continue;
}
#[cfg(target_os = "windows")]
let output = {
use std::os::windows::process::CommandExt;
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
};
#[cfg(not(target_os = "windows"))]
let output = Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.output();
let (version, runnable, error) = match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let raw = if stdout.is_empty() { stderr } else { stdout };
(Some(extract_version(&raw)), true, None)
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
let detail = detail.trim();
let error = if detail.is_empty() {
None
} else {
Some(last_lines(detail, 4))
};
(None, false, error)
}
Err(e) => (None, false, Some(e.to_string())),
};
let is_path_default = path_default.as_ref() == Some(&real);
installs.push(ToolInstallation {
path: tool_path.display().to_string(),
version,
runnable,
error,
source: infer_install_source(&tool_path).to_string(),
is_path_default,
});
}
}
// PATH 默认那处排最前,UI 一眼看到"命令行默认用的是哪处"。
installs.sort_by_key(|i| std::cmp::Reverse(i.is_path_default));
installs
}
/// 诊断某工具是否存在多处互相打架的安装。懒触发:前端仅在卡片 broken 或
/// "升级后版本未变"时调用,正常版本刷新不受影响。
///
/// 无冲突时返回空 `Vec`:仅一处安装,或多处但版本与可运行状态完全一致
/// (例如同一版本经多个包管理器装了两遍但都能跑)——这类不打扰用户。
#[tauri::command]
pub async fn diagnose_tool_installations(tool: String) -> Result<Vec<ToolInstallation>, String> {
if !VALID_TOOLS.contains(&tool.as_str()) {
return Err(format!("Unsupported tool: {tool}"));
}
let installs = tokio::task::spawn_blocking(move || enumerate_tool_installations(&tool))
.await
.map_err(|e| format!("diagnose task join error: {e}"))?;
if installs.len() < 2 {
return Ok(Vec::new());
}
// 仅当存在分歧才算冲突:版本不唯一,或"有能跑的也有跑不起来的"。
let distinct_versions: std::collections::HashSet<&Option<String>> =
installs.iter().map(|i| &i.version).collect();
let runnable_mixed =
installs.iter().any(|i| i.runnable) && installs.iter().any(|i| !i.runnable);
if distinct_versions.len() > 1 || runnable_mixed {
Ok(installs)
} else {
Ok(Vec::new())
}
}
#[cfg(target_os = "windows")]
fn wsl_distro_for_tool(tool: &str) -> Option<String> {
let override_dir = match tool {
+1
View File
@@ -1283,6 +1283,7 @@ pub fn run() {
commands::launch_session_terminal,
commands::get_tool_versions,
commands::run_tool_lifecycle_action,
commands::diagnose_tool_installations,
// Provider terminal
commands::open_provider_terminal,
// Universal Provider management
+226 -1
View File
@@ -13,6 +13,8 @@ import {
AlertCircle,
ArrowUpCircle,
ChevronDown,
Stethoscope,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -26,6 +28,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getVersion } from "@tauri-apps/api/app";
import { settingsApi } from "@/lib/api";
import type { ToolInstallation } from "@/lib/api/settings";
import { useUpdate } from "@/contexts/UpdateContext";
import { relaunchApp } from "@/lib/updater";
import { Badge } from "@/components/ui/badge";
@@ -128,6 +131,57 @@ const TOOL_APP_IDS: Record<ToolName, AppId> = {
hermes: "hermes",
};
// 各工具的全局包名:npm 系用 npm 包名,hermes 例外(pip 包 hermes-agent)。
const TOOL_NPM_PACKAGES: Record<Exclude<ToolName, "hermes">, string> = {
claude: "@anthropic-ai/claude-code",
codex: "@openai/codex",
gemini: "@google/gemini-cli",
opencode: "opencode-ai",
openclaw: "openclaw",
};
// 取路径的目录部分(兼容 / 与 \ 分隔符);无分隔符返回空串。
function dirOfPath(p: string): string {
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
return i > 0 ? p.slice(0, i) : "";
}
// 路径是否为 Windows 风格(含反斜杠或盘符前缀)。
function isWindowsPath(p: string): boolean {
return p.includes("\\") || /^[a-zA-Z]:/.test(p);
}
// 含空格时加引号,避免命令被 shell 拆断。
function shellQuote(s: string): string {
return s.includes(" ") ? `"${s}"` : s;
}
// 为「某一处具体安装」生成卸载建议命令(只读、供复制,绝不代执行)。
// 关键:用该处同目录的 npm 精确作用于这一处的 node 安装,避免裸 `npm rm -g`
// 误删了当前激活(可能是想保留的默认)那处。volta/bun/pip 走各自的卸载器。
function buildUninstallCommand(
toolName: ToolName,
inst: ToolInstallation,
): string {
if (toolName === "hermes") {
return "python3 -m pip uninstall hermes-agent";
}
const pkg = TOOL_NPM_PACKAGES[toolName];
if (inst.source === "volta") {
return `volta uninstall ${pkg}`;
}
if (inst.source === "bun") {
return `bun rm -g ${pkg}`;
}
// nvm / fnm / mise / homebrew / system / scoop 上的 node 全局包:统一 npm rm -g
// 但锚定到该处同目录的 npm 以删对版本。
const dir = dirOfPath(inst.path);
const win = isWindowsPath(inst.path);
const npmBin = win ? "npm.cmd" : "npm";
const npm = dir ? shellQuote(`${dir}${win ? "\\" : "/"}${npmBin}`) : "npm";
return `${npm} rm -g ${pkg}`;
}
export function AboutSection({ isPortable }: AboutSectionProps) {
// ... (use hooks as before) ...
const { t } = useTranslation();
@@ -157,6 +211,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
Record<string, WslShellPreference>
>({});
const [loadingTools, setLoadingTools] = useState<Record<string, boolean>>({});
// 多处安装冲突诊断结果:按工具存储,有冲突的工具会在其卡片下方展示。
// 来源两路:顶部「诊断安装冲突」按钮一次性扫全部,或升级后版本未变时自动补诊。
const [toolDiagnostics, setToolDiagnostics] = useState<
Partial<Record<ToolName, ToolInstallation[]>>
>({});
const [isDiagnosingAll, setIsDiagnosingAll] = useState(false);
const toolVersionByName = useMemo(() => {
return new Map(toolVersions.map((tool) => [tool.name, tool]));
@@ -366,6 +426,20 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
// 复制单条卸载命令到剪贴板(只复制,不执行)。
const handleCopyCommand = useCallback(
async (command: string) => {
try {
await navigator.clipboard.writeText(command);
toast.success(t("settings.toolUninstallCopied"), { closeButton: true });
} catch (error) {
console.error("[AboutSection] Failed to copy command", error);
toast.error(t("settings.installCommandsCopyFailed"));
}
},
[t],
);
const handleCopyInstallCommands = useCallback(async () => {
try {
await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS);
@@ -376,6 +450,54 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
}, [t]);
// 升级后自动补诊单个工具:静默后台执行,仅在确有冲突时写入结果,
// 无冲突不弹 toast、不报错打扰——与用户主动点的全量诊断区别对待。
const diagnoseToolSilently = useCallback(async (toolName: ToolName) => {
try {
const installs = await settingsApi.diagnoseToolInstallations(toolName);
if (installs.length > 0) {
setToolDiagnostics((prev) => ({ ...prev, [toolName]: installs }));
}
} catch (error) {
console.error(
`[AboutSection] Auto-diagnose failed for ${toolName}`,
error,
);
}
}, []);
// 顶部按钮:一次性诊断全部 6 个工具,有冲突的写入各自卡片,
// 全部无冲突时给一条 info toast。后端逐工具枚举所有安装并判定分歧。
const handleDiagnoseAll = useCallback(async () => {
setIsDiagnosingAll(true);
try {
const entries = await Promise.all(
TOOL_NAMES.map(
async (name) =>
[name, await settingsApi.diagnoseToolInstallations(name)] as const,
),
);
const next: Partial<Record<ToolName, ToolInstallation[]>> = {};
let conflicts = 0;
for (const [name, installs] of entries) {
next[name] = installs;
if (installs.length > 0) conflicts += 1;
}
setToolDiagnostics(next);
if (conflicts === 0) {
toast.info(t("settings.toolDiagnoseNoConflict"), { closeButton: true });
}
} catch (error) {
console.error("[AboutSection] Diagnose all failed", error);
toast.error(t("settings.toolDiagnoseFailed"), {
description: extractErrorMessage(error) || undefined,
closeButton: true,
});
} finally {
setIsDiagnosingAll(false);
}
}, [t]);
const handleRunToolAction = useCallback(
async (toolNames: ToolName[], action: ToolLifecycleAction) => {
if (toolNames.length === 0) return;
@@ -395,6 +517,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
for (const toolName of toolNames) {
setToolActions((prev) => ({ ...prev, [toolName]: action }));
// 记录执行前的版本:用于识别"升级跑完但版本没变"——这正是多处安装互相
// 打架的头号症状(升级写入 A 处、PATH 实际仍跑 B 处),需自动触发诊断。
const beforeVersion = toolVersionByName.get(toolName)?.version ?? null;
try {
await settingsApi.runToolLifecycleAction(
[toolName],
@@ -409,12 +534,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
const tool = refreshed.find((t) => t.name === toolName);
if (tool?.version) {
succeeded += 1;
// 升级命令成功、版本号却没动:大概率被另一处安装遮蔽,自动诊断。
if (
action === "update" &&
beforeVersion &&
tool.version === beforeVersion
) {
void diagnoseToolSilently(toolName);
}
} else {
// 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来"
// (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把
// version 置空并写入后端 error,这里只需归类为软失败并展示原因。
const detail = tool?.error?.trim() || t("settings.toolNotRunnable");
failures.push({ toolName, detail, soft: true });
// 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。
void diagnoseToolSilently(toolName);
}
} catch (error) {
console.error(
@@ -490,7 +625,13 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
);
}
},
[t, wslShellByTool, refreshToolVersions],
[
t,
wslShellByTool,
refreshToolVersions,
toolVersionByName,
diagnoseToolSilently,
],
);
const displayVersion = version ?? t("common.unknown");
@@ -640,6 +781,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<div className="flex flex-col gap-2 px-1 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-sm font-medium">{t("settings.localEnvCheck")}</h3>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 text-xs"
onClick={() => handleDiagnoseAll()}
disabled={isLoadingTools || isAnyBusy || isDiagnosingAll}
>
{isDiagnosingAll ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Stethoscope className="h-3.5 w-3.5" />
)}
{isDiagnosingAll
? t("settings.toolDiagnosing")
: t("settings.toolDiagnose")}
</Button>
<Button
size="sm"
variant="outline"
@@ -700,6 +857,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
: null;
const runningAction = toolActions[toolName];
const title = tool?.version || tool?.error || t("common.unknown");
const conflicts = toolDiagnostics[toolName];
return (
<motion.div
@@ -823,6 +981,73 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
</div>
)}
{/* 多处安装冲突诊断结果:仅在懒触发后有数据时渲染。 */}
{conflicts && conflicts.length > 0 && (
<div className="space-y-1.5 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-2.5">
<div className="text-[11px] font-medium text-yellow-600 dark:text-yellow-400">
{t("settings.toolConflictTitle")}
</div>
<p className="text-[10px] leading-snug text-muted-foreground">
{t("settings.toolConflictHint")}
</p>
<ul className="space-y-1.5">
{conflicts.map((inst) => {
const uninstallCmd = buildUninstallCommand(
toolName,
inst,
);
return (
<li key={inst.path} className="space-y-1">
<div className="flex items-center gap-1.5 text-[10px]">
<span className="shrink-0 rounded bg-background/80 px-1 py-0.5 font-mono text-muted-foreground">
{inst.source}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-muted-foreground"
title={inst.path}
>
{inst.path}
</span>
<span
className={
inst.runnable
? "shrink-0 font-mono text-foreground"
: "shrink-0 text-yellow-600 dark:text-yellow-400"
}
>
{inst.runnable
? inst.version
: t("settings.toolConflictNotRunnable")}
</span>
{inst.is_path_default && (
<span className="shrink-0 rounded-full border border-primary/30 bg-primary/10 px-1 py-0.5 text-[9px] text-primary">
{t("settings.toolConflictDefault")}
</span>
)}
</div>
{/* 卸载建议命令:仅供复制,绝不代执行。前置红色垃圾桶图标
明示这是卸载(破坏性)命令,避免误以为是普通信息。 */}
<div className="flex items-center gap-1.5">
<code className="min-w-0 flex-1 truncate rounded bg-background/80 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{uninstallCmd}
</code>
<Trash2 className="h-3 w-3 shrink-0 text-red-500" />
<button
type="button"
onClick={() => handleCopyCommand(uninstallCmd)}
title={t("settings.toolUninstallCopyHint")}
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-background/80 hover:text-foreground"
>
<Copy className="h-3 w-3" />
</button>
</div>
</li>
);
})}
</ul>
</div>
)}
<div className="mt-auto flex items-center justify-end">
{isToolVersionLoading ? (
<span className="text-xs text-muted-foreground">
+10
View File
@@ -676,6 +676,16 @@
"toolActionInstalledNotRunnable": "Installed, but it can't run in the current environment — please check",
"installedNotRunnable": "Installed · can't run",
"toolCheckEnv": "Check environment",
"toolDiagnose": "Diagnose installs",
"toolDiagnosing": "Diagnosing…",
"toolConflictTitle": "Multiple installations detected",
"toolConflictHint": "The command line uses the one marked Default; an upgrade may have written to another.",
"toolConflictDefault": "Default",
"toolConflictNotRunnable": "can't run",
"toolDiagnoseNoConflict": "No conflicting installations found",
"toolDiagnoseFailed": "Diagnosis failed",
"toolUninstallCopyHint": "Copy uninstall command (won't run it)",
"toolUninstallCopied": "Uninstall command copied",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+10
View File
@@ -676,6 +676,16 @@
"toolActionInstalledNotRunnable": "インストールされましたが、現在の環境では実行できません。確認してください",
"installedNotRunnable": "インストール済み・実行不可",
"toolCheckEnv": "実行環境を確認",
"toolDiagnose": "インストールを診断",
"toolDiagnosing": "診断中…",
"toolConflictTitle": "複数のインストールを検出",
"toolConflictHint": "コマンドラインは「既定」のものを使用します。更新が別の場所に適用された可能性があります。",
"toolConflictDefault": "既定",
"toolConflictNotRunnable": "実行不可",
"toolDiagnoseNoConflict": "競合するインストールは見つかりませんでした",
"toolDiagnoseFailed": "診断に失敗しました",
"toolUninstallCopyHint": "アンインストールコマンドをコピー(自動実行しません)",
"toolUninstallCopied": "アンインストールコマンドをコピーしました",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+10
View File
@@ -676,6 +676,16 @@
"toolActionInstalledNotRunnable": "已安装,但当前环境无法运行,请检查",
"installedNotRunnable": "已安装·无法运行",
"toolCheckEnv": "请检查运行环境",
"toolDiagnose": "诊断安装冲突",
"toolDiagnosing": "诊断中…",
"toolConflictTitle": "检测到多处安装",
"toolConflictHint": "命令行实际使用标「默认」的那处;升级可能写到了别处。",
"toolConflictDefault": "默认",
"toolConflictNotRunnable": "无法运行",
"toolDiagnoseNoConflict": "未发现安装冲突",
"toolDiagnoseFailed": "诊断失败",
"toolUninstallCopyHint": "复制卸载命令(不会自动执行)",
"toolUninstallCopied": "卸载命令已复制",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+14
View File
@@ -208,6 +208,10 @@ export const settingsApi = {
});
},
async diagnoseToolInstallations(tool: string): Promise<ToolInstallation[]> {
return await invoke("diagnose_tool_installations", { tool });
},
async getRectifierConfig(): Promise<RectifierConfig> {
return await invoke("get_rectifier_config");
},
@@ -233,6 +237,16 @@ export const settingsApi = {
},
};
/** 单处工具安装的诊断信息(多处安装冲突检测)。字段对应后端 ToolInstallation。 */
export interface ToolInstallation {
path: string;
version: string | null;
runnable: boolean;
error: string | null;
source: string;
is_path_default: boolean;
}
export interface RectifierConfig {
enabled: boolean;
requestThinkingSignature: boolean;