fix(settings): surface "installed but not runnable" state in tool cards

After an install/update command succeeds, re-probe the tool version: if it
still can't be detected the tool is installed-but-unrunnable (e.g. Node too
old), so report a warning toast and show "installed · can't run" with the
cause, instead of a misleading "success" plus a stale version number.

Read the backend `installed_but_broken` flag rather than matching error
text; the card's current-version line, action button (no useless "install"
for a broken tool), and a "check environment" hint all key off it. Drop the
now-redundant version-clearing setState (the refresh merge already nulls it),
flatten the action ternary, and use extractErrorMessage in the catch path.

Adds toolNotRunnable / toolActionInstalledNotRunnable / installedNotRunnable
/ toolCheckEnv i18n keys (zh/en/ja).
This commit is contained in:
Jason
2026-05-22 16:19:43 +08:00
Unverified
parent 768c5f9f39
commit ea604a1827
5 changed files with 74 additions and 16 deletions
+61 -16
View File
@@ -33,6 +33,7 @@ import { motion } from "framer-motion";
import appIcon from "@/assets/icons/app-icon.png";
import { APP_ICON_MAP } from "@/config/appConfig";
import type { AppId } from "@/lib/api/types";
import { extractErrorMessage } from "@/utils/errorUtils";
interface AboutSectionProps {
isPortable: boolean;
@@ -43,6 +44,9 @@ interface ToolVersion {
version: string | null;
latest_version: string | null;
error: string | null;
// 后端已定位到可执行文件但 --version 报错(装了却跑不起来)。直接读此字段,
// 不要靠匹配 error 文案反推——避免前端与后端字符串硬耦合。
installed_but_broken: boolean;
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
wsl_distro: string | null;
}
@@ -175,8 +179,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
async (
toolNames: ToolName[],
wslOverrides?: Record<string, WslShellPreference>,
) => {
if (toolNames.length === 0) return;
): Promise<ToolVersion[]> => {
if (toolNames.length === 0) return [];
// 单工具刷新使用统一后端入口(get_tool_versions)并带工具过滤。
setLoadingTools((prev) => {
@@ -201,8 +205,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
}
return merged;
});
// 返回刷新结果,调用方可据此判断版本是否真的探到(避免读 state 撞 stale closure)。
return updated;
} catch (error) {
console.error("[AboutSection] Failed to refresh tools", error);
return [];
} finally {
setLoadingTools((prev) => {
const next = { ...prev };
@@ -379,7 +387,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
// 逐工具串行执行:每个工具独立成败、独立刷新版本,一个失败不会连坐
// 后续工具(后端把整批拼成单脚本 + set -e,会在首个失败处中止整批)。
const failures: { toolName: ToolName; detail: string }[] = [];
// soft=true 表示"命令成功执行但版本探不到"(装上却跑不起来),
// 与命令本身报错(soft=false)区别对待:前者不算硬失败,toast 降级为 warning。
const failures: { toolName: ToolName; detail: string; soft: boolean }[] =
[];
let succeeded = 0;
for (const toolName of toolNames) {
@@ -391,15 +402,27 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
wslShellByTool,
);
// 静默执行真正结束后刷新该工具版本,卡片立即反映结果。
await refreshToolVersions([toolName], wslShellByTool);
succeeded += 1;
const refreshed = await refreshToolVersions(
[toolName],
wslShellByTool,
);
const tool = refreshed.find((t) => t.name === toolName);
if (tool?.version) {
succeeded += 1;
} else {
// 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来"
// (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把
// version 置空并写入后端 error,这里只需归类为软失败并展示原因。
const detail = tool?.error?.trim() || t("settings.toolNotRunnable");
failures.push({ toolName, detail, soft: true });
}
} catch (error) {
console.error(
`[AboutSection] Failed to run tool action for ${toolName}`,
error,
);
const detail = error instanceof Error ? error.message : String(error);
failures.push({ toolName, detail });
const detail = extractErrorMessage(error) || String(error);
failures.push({ toolName, detail, soft: false });
} finally {
setToolActions((prev) => {
const next = { ...prev };
@@ -442,7 +465,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
.join("\n")
: failures[0]?.detail;
if (succeeded === 0) {
const hardFailures = failures.filter((f) => !f.soft);
if (succeeded === 0 && hardFailures.length === 0) {
// 命令均成功执行、但工具都探不到版本(装上却跑不起来)→ 降级为 warning 并解释原因。
toast.warning(t("settings.toolActionInstalledNotRunnable"), {
description: failureDescription || undefined,
closeButton: true,
});
} else if (succeeded === 0) {
toast.error(t("settings.toolActionFailed"), {
description: failureDescription || undefined,
closeButton: true,
@@ -655,13 +686,18 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
tool.latest_version &&
tool.version !== tool.latest_version,
);
const action = isToolVersionLoading
? null
: !tool?.version
? "install"
: isOutdated
? "update"
: null;
// 已安装却跑不起来(如 Node 版本不达标):用它区分卡片文案与按钮,避免把
// "装了跑不起来"误判成"未安装"而给出无用的安装按钮(重装同一版本解决不了)。
const installedButBroken = Boolean(tool?.installed_but_broken);
// loading 和 broken 都没有可执行动作;其余按是否已装/是否过期选择。
const action: ToolLifecycleAction | null =
isToolVersionLoading || installedButBroken
? null
: !tool?.version
? "install"
: isOutdated
? "update"
: null;
const runningAction = toolActions[toolName];
const title = tool?.version || tool?.error || t("common.unknown");
@@ -718,7 +754,11 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
>
{isToolVersionLoading
? t("common.loading")
: tool?.version || t("common.notInstalled")}
: tool?.version
? tool.version
: installedButBroken
? t("settings.installedNotRunnable")
: t("common.notInstalled")}
</span>
</div>
<div className="flex items-center justify-between gap-3">
@@ -788,6 +828,11 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : installedButBroken ? (
// 已安装但跑不起来:重装无济于事,不给按钮,给一句指向环境的提示。
<span className="text-xs text-yellow-600 dark:text-yellow-400">
{t("settings.toolCheckEnv")}
</span>
) : action ? (
<Button
size="sm"
+4
View File
@@ -672,6 +672,10 @@
"toolActionDone": "{{action}} completed for {{count}} tool(s)",
"toolActionPartial": "{{action}} succeeded for {{succeeded}}, failed for {{failed}}",
"toolActionFailed": "Install/update command failed",
"toolNotRunnable": "Installed but not runnable (no version detected)",
"toolActionInstalledNotRunnable": "Installed, but it can't run in the current environment — please check",
"installedNotRunnable": "Installed · can't run",
"toolCheckEnv": "Check environment",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+4
View File
@@ -672,6 +672,10 @@
"toolActionDone": "{{count}} 個のツールの{{action}}が完了しました",
"toolActionPartial": "{{succeeded}} 個成功、{{failed}} 個失敗({{action}}",
"toolActionFailed": "インストール/更新コマンドの実行に失敗しました",
"toolNotRunnable": "インストール済みですが実行できません(バージョン未検出)",
"toolActionInstalledNotRunnable": "インストールされましたが、現在の環境では実行できません。確認してください",
"installedNotRunnable": "インストール済み・実行不可",
"toolCheckEnv": "実行環境を確認",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+4
View File
@@ -672,6 +672,10 @@
"toolActionDone": "{{count}} 个工具{{action}}完成",
"toolActionPartial": "{{succeeded}} 个{{action}}成功,{{failed}} 个失败",
"toolActionFailed": "安装/升级命令执行失败",
"toolNotRunnable": "已安装但无法运行(未探测到版本)",
"toolActionInstalledNotRunnable": "已安装,但当前环境无法运行,请检查",
"installedNotRunnable": "已安装·无法运行",
"toolCheckEnv": "请检查运行环境",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
+1
View File
@@ -185,6 +185,7 @@ export const settingsApi = {
version: string | null;
latest_version: string | null;
error: string | null;
installed_but_broken: boolean;
env_type: "windows" | "wsl" | "macos" | "linux" | "unknown";
wsl_distro: string | null;
}>