feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)

This commit is contained in:
2026-09-29 08:25:37 +07:00
parent fcfebe44f1
commit 7ec5a7cd85
40 changed files with 3145 additions and 92 deletions
@@ -2,23 +2,40 @@
* @file AiToolsSettings.tsx
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
* (GET /api/ai-tools), split into the ones found on PATH — each with
* Enabled/Disabled and Set default — and the ones available to install, which
* only link to their homepage. Refresh re-reads the login shell's PATH and
* re-detects. A disabled tool is hidden from Add lane and the server refuses a
* new lane for it; existing lanes keep working.
* Enabled/Disabled, Set default and, when the catalog names one, Update with a
* new-version badge — and the ones available to install, with Install where
* the catalog knows how. Install/Update/Update tools confirm the exact command
* first, run as ONE server job at a time and show its log under the row
* (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
* A disabled tool is hidden from Add lane; existing lanes keep working.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Bot, ExternalLink, RefreshCw } from "lucide-react";
import { Bot, Download, ExternalLink, RefreshCw } from "lucide-react";
import { useAiTools } from "../../hooks/useAiTools";
import type { AiTool } from "../../lib/api";
import { ConfirmModal } from "../ConfirmModal";
import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api";
function Avatar({ label }: { label: string }) {
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
function Avatar({ tool }: { tool: AiTool }) {
const [failed, setFailed] = useState(false);
if (!failed) {
return (
<img
src={api.aiTools.iconUrl(tool.id)}
alt=""
onError={() => setFailed(true)}
className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1"
/>
);
}
return (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
{label.charAt(0).toUpperCase()}
{tool.label.charAt(0).toUpperCase()}
</span>
);
}
@@ -40,11 +57,44 @@ function Homepage({ tool }: { tool: AiTool }) {
);
}
function JobLog({ job }: { job: AiToolJob }) {
const { t } = useTranslation("settings");
const ref = useRef<HTMLPreElement | null>(null);
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [job.log]);
const state =
job.status === "running"
? t("aiTools.jobRunning")
: job.status === "ok"
? t("aiTools.jobOk")
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
return (
<div className="mt-2 w-full">
<p
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
>
{state}
</p>
<pre
ref={ref}
data-testid="ai-tool-log"
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-secondary"
>
{job.log}
</pre>
</div>
);
}
export function AiToolsSettings() {
const { t } = useTranslation("settings");
const { tools, loaded, defaultId, refresh, update } = useAiTools();
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [job, setJob] = useState<AiToolJob | null>(null);
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
const [confirm, setConfirm] = useState<Confirm | null>(null);
const sectionRef = useRef<HTMLElement | null>(null);
// The Add-lane modal links here as /settings#ai-tools; the section renders
@@ -53,6 +103,41 @@ export function AiToolsSettings() {
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
}, []);
// Deferred like useAiTools' load: an unreachable server (or a mocked api
// without these calls) degrades to "no job, no badges", never a throw.
const loadVersions = () =>
Promise.resolve()
.then(() => api.aiTools.versions())
.then(setVersions)
.catch(() => undefined);
// A page opened mid-job picks it up; versions are cached a day server-side.
useEffect(() => {
Promise.resolve()
.then(() => api.aiTools.job())
.then(setJob)
.catch(() => undefined);
void loadVersions();
}, []);
const running = job?.status === "running";
useEffect(() => {
if (!running) return;
const h = window.setInterval(async () => {
try {
const j = await api.aiTools.job();
setJob(j);
if (j?.status !== "running") {
void reload();
void loadVersions();
}
} catch {
/* next tick */
}
}, 1000);
return () => window.clearInterval(h);
}, [running, reload]);
const run = async (key: string, fn: () => Promise<void>) => {
setBusy(key);
setError(null);
@@ -65,9 +150,52 @@ export function AiToolsSettings() {
}
};
const npmCmd = (pkg: string) => `npm install -g ${pkg}@latest`;
const commandFor = (tool: AiTool, action: "install" | "update"): string => {
const inst = tool.install;
if (action === "update" && tool.update?.kind === "self") {
return [tool.command ?? tool.id, ...tool.update.args].join(" ");
}
if (inst?.kind === "npm") return npmCmd(inst.package);
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
return "";
};
// Mirrors the server's update-all: badged npm tools, then every self updater.
const updateAllTools = tools.filter(
(x) =>
x.installed &&
(x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable))
);
const canUpdate = (tool: AiTool) =>
!!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true);
const startConfirmed = async () => {
const c = confirm;
setConfirm(null);
if (!c) return;
await run("job", async () => {
const r =
c.kind === "install"
? await api.aiTools.install(c.tool.id)
: c.kind === "update"
? await api.aiTools.updateTool(c.tool.id)
: await api.aiTools.updateAll();
setJob(r.job);
});
};
const jobBusy = running || busy !== null;
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
const installed = tools.filter((x) => x.installed);
const available = tools.filter((x) => !x.installed);
const confirmLines =
confirm?.kind === "update-all"
? updateAllTools.map((x) => commandFor(x, "update"))
: confirm
? [commandFor(confirm.tool, confirm.kind)]
: [];
return (
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
@@ -82,42 +210,79 @@ export function AiToolsSettings() {
<p className="text-xs font-medium text-fg-secondary">
{t("aiTools.installed", { count: installed.length })}
</p>
<button
type="button"
onClick={() => void run("refresh", refresh)}
disabled={busy !== null}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
disabled={jobBusy || updateAllTools.length === 0}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<Download className="w-3.5 h-3.5" />
{t("aiTools.updateTools")}
</button>
<button
type="button"
onClick={() =>
void run("refresh", async () => {
await refresh();
await loadVersions();
})
}
disabled={busy !== null}
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
{t("aiTools.refresh")}
</button>
</div>
</div>
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
{logFor(null)}
{loaded && installed.length === 0 && (
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
)}
<ul className="divide-y divide-border rounded-md border border-border">
{installed.map((tool) => {
const isDefault = tool.id === defaultId;
const v = versions[tool.id];
return (
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className={`flex items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
>
<Avatar label={tool.label} />
<Avatar tool={tool} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
{tool.label}
<Homepage tool={tool} />
{v?.updateAvailable && v.latest && (
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
{t("aiTools.newVersion", { version: v.latest })}
</span>
)}
</div>
<div
className="truncate font-mono text-[11px] text-fg-muted"
title={tool.path ?? ""}
>
{tool.command}
{v?.installed ? ` · v${v.installed}` : ""}
{tool.update?.kind === "npm" && v && !v.npmManaged && (
<span className="ml-1.5 font-sans">· {t("aiTools.outsideNpm")}</span>
)}
</div>
</div>
{canUpdate(tool) && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "update", tool })}
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.update")}
</button>
)}
{isDefault ? (
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
) : (
@@ -147,6 +312,7 @@ export function AiToolsSettings() {
>
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
</button>
{logFor(tool.id)}
</li>
);
})}
@@ -163,22 +329,61 @@ export function AiToolsSettings() {
<li
key={tool.id}
data-testid={`ai-tool-${tool.id}`}
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5"
className={`flex flex-wrap items-center gap-2 rounded-md border border-border px-2.5 py-1.5 ${
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
}`}
>
<Avatar label={tool.label} />
<Avatar tool={tool} />
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
{tool.label}
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
{tool.command}
</span>
</span>
{tool.install && (
<button
type="button"
disabled={jobBusy}
onClick={() => setConfirm({ kind: "install", tool })}
className="rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
>
{t("aiTools.install")}
</button>
)}
<Homepage tool={tool} />
{logFor(tool.id)}
</li>
))}
</ul>
</div>
)}
</div>
<ConfirmModal
open={confirm !== null}
destructive={false}
title={
confirm?.kind === "update-all"
? t("aiTools.confirmUpdateAll")
: confirm
? t(confirm.kind === "install" ? "aiTools.confirmInstall" : "aiTools.confirmUpdate", {
tool: confirm.tool.label,
})
: ""
}
message={t("aiTools.confirmBody")}
confirmLabel={t("aiTools.run")}
cancelLabel={t("aiTools.cancel")}
onConfirm={() => void startConfirmed()}
onCancel={() => setConfirm(null)}
>
<pre
data-testid="ai-tool-confirm-command"
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
>
{confirmLines.join("\n")}
</pre>
</ConfirmModal>
</section>
);
}