feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user