b7f3735b29
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
465 lines
17 KiB
TypeScript
465 lines
17 KiB
TypeScript
/**
|
|
* @file AiToolsSettings.tsx
|
|
* @description Settings → Agents → AI tools (`/settings/agents`): every AI coding CLI in the server's catalog
|
|
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
|
|
* Default badge or Set default, and Update whenever the catalog names an
|
|
* updater — no version lookup, the updater itself knows whether it has work.
|
|
* The ones available to install fold behind a disclosure, with Install where
|
|
* the catalog knows how. Install/Update/Update all 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; a finished job folds to one line).
|
|
* A right-click on an installed row opens its Update / Set default /
|
|
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
|
|
* A click on an installed row (outside its buttons) opens that tool's settings
|
|
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
|
|
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
|
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
|
* working.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { Switch } from "../Switch";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
|
import { useAiTools } from "../../hooks/useAiTools";
|
|
import { ConfirmModal } from "../ConfirmModal";
|
|
import { AiToolIcon } from "../AiToolIcon";
|
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
|
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
|
import { usePermissions } from "../AuthGate";
|
|
|
|
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
|
|
|
const GHOST_BTN =
|
|
"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 hover:text-fg-primary disabled:opacity-50";
|
|
|
|
function Homepage({ tool }: { tool: AiTool }) {
|
|
const { t } = useTranslation("settings");
|
|
if (!tool.homepageUrl) return null;
|
|
return (
|
|
<a
|
|
href={tool.homepageUrl}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
title={t("aiTools.homepage")}
|
|
aria-label={t("aiTools.homepageOf", { tool: tool.label })}
|
|
className="text-fg-muted hover:text-accent"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function ToolSwitch({
|
|
tool,
|
|
disabled,
|
|
onToggle,
|
|
}: {
|
|
tool: AiTool;
|
|
disabled: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const { t } = useTranslation("settings");
|
|
return (
|
|
<Switch
|
|
checked={tool.enabled}
|
|
onChange={onToggle}
|
|
label={t("aiTools.enabledOf", { tool: tool.label })}
|
|
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
|
disabled={disabled}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function JobLog({ job }: { job: AiToolJob }) {
|
|
const { t } = useTranslation("settings");
|
|
const ref = useRef<HTMLPreElement | null>(null);
|
|
// A finished job folds to its status line; running or failed stays open.
|
|
const [open, setOpen] = useState(job.status !== "ok");
|
|
useEffect(() => {
|
|
if (job.status === "ok") setOpen(false);
|
|
}, [job.status]);
|
|
useEffect(() => {
|
|
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
|
}, [job.log, open]);
|
|
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">
|
|
<div className="flex items-center gap-2 text-[11px]">
|
|
<span className={job.status === "failed" ? "text-status-danger" : "text-fg-muted"}>
|
|
{state}
|
|
</span>
|
|
{job.status !== "running" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
|
>
|
|
{open ? t("aiTools.hideLog") : t("aiTools.showLog")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{open && (
|
|
<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-0 p-2 font-mono text-[11px] text-fg-secondary"
|
|
>
|
|
{job.log}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AiToolsSettings({
|
|
settingsHref,
|
|
}: {
|
|
/** A tool's own settings page, when it has one. */
|
|
settingsHref?: (id: string) => string | undefined;
|
|
} = {}) {
|
|
const { t } = useTranslation("settings");
|
|
const navigate = useNavigate();
|
|
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
|
|
// Installing, updating and switching tools changes the host: an admin's.
|
|
const { isAdmin } = usePermissions();
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [job, setJob] = useState<AiToolJob | null>(null);
|
|
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
|
const [showAvailable, setShowAvailable] = useState(false);
|
|
const ctx = useContextMenu<AiTool>();
|
|
// A menu item: close the menu, then act on the tool it was opened on.
|
|
const pick = (fn: (tool: AiTool) => unknown) => () => {
|
|
const tool = ctx.menu?.item;
|
|
ctx.close();
|
|
if (tool) fn(tool);
|
|
};
|
|
const setDefault = (tool: AiTool) => void run(tool.id, () => update(tool.id, { default: true }));
|
|
const toggle = (tool: AiTool) =>
|
|
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }));
|
|
|
|
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
|
|
// unreachable server (or a mocked api without it) means "no job".
|
|
useEffect(() => {
|
|
Promise.resolve()
|
|
.then(() => api.aiTools.job())
|
|
.then(setJob)
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
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();
|
|
} catch {
|
|
/* next tick */
|
|
}
|
|
}, 1000);
|
|
return () => window.clearInterval(h);
|
|
}, [running, reload]);
|
|
|
|
const run = async (key: string, fn: () => Promise<void>) => {
|
|
setBusy(key);
|
|
setError(null);
|
|
try {
|
|
await fn();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
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 "";
|
|
};
|
|
const installed = tools.filter((x) => x.installed);
|
|
const available = tools.filter((x) => !x.installed);
|
|
// Mirrors the server's update-all: every installed tool with an updater.
|
|
const updateAllTools = installed.filter((x) => x.update);
|
|
|
|
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 confirmLines =
|
|
confirm?.kind === "update-all"
|
|
? updateAllTools.map((x) => commandFor(x, "update"))
|
|
: confirm
|
|
? [commandFor(confirm.tool, confirm.kind)]
|
|
: [];
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between gap-3">
|
|
<p className="text-xs font-medium text-fg-secondary">
|
|
{t("aiTools.installed", { count: installed.length })}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
{isAdmin && updateAllTools.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirm({ kind: "update-all" })}
|
|
disabled={jobBusy}
|
|
className={GHOST_BTN}
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
|
</button>
|
|
)}
|
|
{isAdmin && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void run("refresh", refresh)}
|
|
disabled={busy !== null}
|
|
className={GHOST_BTN}
|
|
>
|
|
<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 overflow-hidden rounded-lg border border-border bg-surface-2">
|
|
{installed.map((tool) => {
|
|
const isDefault = tool.id === defaultId;
|
|
const href = settingsHref?.(tool.id);
|
|
return (
|
|
<li
|
|
key={tool.id}
|
|
data-testid={`ai-tool-${tool.id}`}
|
|
onContextMenu={(e) => ctx.open(e, tool)}
|
|
onClick={(e) => {
|
|
// The row's own buttons, switch, links and log keep their click.
|
|
if (href && !(e.target as Element).closest("button, a, input, pre"))
|
|
navigate(href);
|
|
}}
|
|
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
|
|
>
|
|
<AiToolIcon tool={tool} />
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
|
{href ? (
|
|
<Link
|
|
to={href}
|
|
title={t("aiTools.openSettings", { tool: tool.label })}
|
|
className="truncate hover:text-accent hover:underline"
|
|
>
|
|
{tool.label}
|
|
</Link>
|
|
) : (
|
|
<span className="truncate">{tool.label}</span>
|
|
)}
|
|
<Homepage tool={tool} />
|
|
{isDefault && (
|
|
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
|
{t("aiTools.default")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="truncate font-mono text-[11px] text-fg-muted"
|
|
title={tool.path ?? ""}
|
|
>
|
|
{tool.command}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Fixed slots, so the buttons line up from row to row. */}
|
|
{isAdmin && (
|
|
<>
|
|
<div className="flex min-w-20 shrink-0 justify-end">
|
|
{tool.update && (
|
|
<button
|
|
type="button"
|
|
disabled={jobBusy}
|
|
onClick={() => setConfirm({ kind: "update", tool })}
|
|
className={GHOST_BTN}
|
|
>
|
|
{t("aiTools.update")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex min-w-28 shrink-0 justify-end">
|
|
{!isDefault && (
|
|
<button
|
|
type="button"
|
|
disabled={!tool.enabled || busy !== null}
|
|
onClick={() => setDefault(tool)}
|
|
className={GHOST_BTN}
|
|
>
|
|
{t("aiTools.setDefault")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ToolSwitch
|
|
tool={tool}
|
|
disabled={busy !== null}
|
|
onToggle={() => toggle(tool)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
{logFor(tool.id)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{ctx.menu && (
|
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
|
|
{isAdmin && ctx.menu.item.update && (
|
|
<MenuItem
|
|
disabled={jobBusy}
|
|
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
|
|
>
|
|
{t("aiTools.update")}
|
|
</MenuItem>
|
|
)}
|
|
{isAdmin && ctx.menu.item.id !== defaultId && (
|
|
<MenuItem
|
|
disabled={!ctx.menu.item.enabled || busy !== null}
|
|
onSelect={pick(setDefault)}
|
|
>
|
|
{t("aiTools.setDefault")}
|
|
</MenuItem>
|
|
)}
|
|
{isAdmin && (
|
|
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
|
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
|
</MenuItem>
|
|
)}
|
|
{ctx.menu.item.path && (
|
|
<>
|
|
{isAdmin && <MenuSeparator />}
|
|
<MenuItem
|
|
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
|
>
|
|
{t("rowMenu.copyPath")}
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</ContextMenu>
|
|
)}
|
|
</div>
|
|
|
|
{available.length > 0 && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
aria-expanded={showAvailable}
|
|
onClick={() => setShowAvailable((s) => !s)}
|
|
className="mb-2 inline-flex items-center gap-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
|
>
|
|
<ChevronDown
|
|
className={`w-3.5 h-3.5 transition-transform ${showAvailable ? "" : "-rotate-90"}`}
|
|
/>
|
|
{t("aiTools.available", { count: available.length })}
|
|
</button>
|
|
{showAvailable && (
|
|
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
{available.map((tool) => (
|
|
<li
|
|
key={tool.id}
|
|
data-testid={`ai-tool-${tool.id}`}
|
|
className={`flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-2.5 py-2 ${
|
|
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
|
|
}`}
|
|
>
|
|
<AiToolIcon tool={tool} />
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-xs text-fg-primary">{tool.label}</span>
|
|
<span className="block truncate font-mono text-[10px] text-fg-muted">
|
|
{tool.command}
|
|
</span>
|
|
</span>
|
|
<Homepage tool={tool} />
|
|
{isAdmin && 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>
|
|
)}
|
|
{logFor(tool.id)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</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="mt-3 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>
|
|
</div>
|
|
);
|
|
}
|