feat: model lists and prices that follow each AI tool, a Settings tab per tool, live lane status (#40)
Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.
Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.
Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.
Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.
DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @file AiToolConfig.tsx
|
||||
* @description Settings → <tool>: the one tab every installed, enabled
|
||||
* catalog tool without a hand-written tab gets (Claude Code and Codex keep
|
||||
* theirs). Shows where the CLI lives, its homepage and an Update link into the
|
||||
* AI Tools modal; how many sessions it ran, their cost, and its lanes; a
|
||||
* default model (`PUT /api/ai-tools/:id {defaultModel}`) for a tool with a
|
||||
* model flag; its vendor's pricing table; and the config files its catalog
|
||||
* file lists, each opened over a sha and saved by the server only if it is
|
||||
* still that sha, valid, and backed up. Antigravity adds its hooks card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ComponentType } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Cpu, ExternalLink, FileCog, RefreshCw } from "lucide-react";
|
||||
import { api, type AiToolConfigFile } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { ModelPicker } from "../components/run/RunSetup";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
|
||||
|
||||
/** Cards one tool adds below the generic ones. */
|
||||
const EXTRAS: Record<string, ComponentType> = { antigravity: AntigravityHooksSettings };
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** The default-model card: the picker, saved on an explicit click. */
|
||||
function DefaultModel({ toolId, saved }: { toolId: string; saved: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const [value, setValue] = useState(saved);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => setValue(saved), [saved]);
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await aiTools.update(toolId, { defaultModel: value || null });
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-fg-muted">{t("toolTab.defaultModelHint")}</p>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className="min-w-[16rem] flex-1">
|
||||
<ModelPicker value={value} onChange={setValue} tool={toolId} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={value === saved}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("toolTab.saveDefaultModel")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The config-files card: pick a declared file, edit it, save over its sha. */
|
||||
function ConfigFiles({ toolId }: { toolId: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [files, setFiles] = useState<AiToolConfigFile[] | null>(null);
|
||||
const [open, setOpen] = useState<{ index: number; path: string; sha: string } | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.aiTools
|
||||
.config(toolId)
|
||||
.then((r) => setFiles(r.files))
|
||||
.catch((err) => setNote({ ok: false, text: message(err) }));
|
||||
}, [toolId]);
|
||||
|
||||
const openFile = async (f: AiToolConfigFile) => {
|
||||
setNote(null);
|
||||
try {
|
||||
const r = await api.aiTools.readConfig(toolId, f.index);
|
||||
setOpen({ index: f.index, path: r.path, sha: r.sha });
|
||||
setText(r.text);
|
||||
} catch (err) {
|
||||
setNote({ ok: false, text: message(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!open) return;
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const r = await api.aiTools.saveConfig(toolId, open.index, text, open.sha);
|
||||
setOpen({ ...open, sha: r.sha });
|
||||
setNote({ ok: true, text: t("toolTab.saved", { backup: r.backup }) });
|
||||
} catch (err) {
|
||||
setNote({ ok: false, text: message(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-fg-muted">{t("toolTab.configHint")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(files ?? []).map((f) => (
|
||||
<button
|
||||
key={f.index}
|
||||
type="button"
|
||||
onClick={() => openFile(f)}
|
||||
disabled={!f.exists}
|
||||
title={f.exists ? undefined : t("toolTab.missing")}
|
||||
className={`rounded-md border px-2.5 py-1 font-mono text-[11px] disabled:opacity-50 ${
|
||||
open?.index === f.index
|
||||
? "border-accent/50 text-fg-primary"
|
||||
: "border-border text-fg-secondary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
{f.path}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
aria-label={open.path}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
spellCheck={false}
|
||||
rows={16}
|
||||
className="w-full rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] text-fg-primary focus:border-accent/50 focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy && <RefreshCw className="h-3.5 w-3.5 animate-spin" />}
|
||||
{t("toolTab.save")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(null)} className="btn-ghost text-xs">
|
||||
{t("toolTab.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{note && (
|
||||
<p className={`text-xs ${note.ok ? "text-fg-muted" : "text-status-danger"}`}>{note.text}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
Icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-fg-secondary">
|
||||
<Icon className="h-4 w-4 text-fg-muted" />
|
||||
{title}
|
||||
</h3>
|
||||
<div className="card p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tab for one catalog tool. */
|
||||
export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const tool = aiTools.byId(toolId);
|
||||
const [usage, setUsage] = useState<{ sessions: number; cost: number } | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.aiTools
|
||||
.usage(toolId)
|
||||
.then(setUsage)
|
||||
.catch(() => setUsage(null));
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes.filter((l) => l.tool === toolId)))
|
||||
.catch(() => setLanes([]));
|
||||
}, [toolId]);
|
||||
|
||||
if (!tool) return null;
|
||||
const Extra = EXTRAS[toolId];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="card flex flex-wrap items-start gap-4 p-5">
|
||||
<AiToolIcon tool={tool} className="h-10 w-10" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-base font-semibold text-fg-primary">{tool.label}</h2>
|
||||
{tool.homepageUrl && (
|
||||
<a
|
||||
href={tool.homepageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 text-xs text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("aiTools.homepage")}
|
||||
</a>
|
||||
)}
|
||||
{tool.update && (
|
||||
<Link to={`/settings?tool=${toolId}#ai-tools`} className="btn-ghost text-xs">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("toolTab.update")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<p className="break-all font-mono text-[11px] text-fg-muted">{tool.path}</p>
|
||||
{usage && (
|
||||
<p className="text-xs text-fg-secondary">
|
||||
{t("toolTab.usage", { count: usage.sessions, cost: usage.cost.toFixed(2) })}
|
||||
</p>
|
||||
)}
|
||||
{lanes.length > 0 && (
|
||||
<p className="flex flex-wrap items-center gap-2 text-xs text-fg-secondary">
|
||||
{t("toolTab.lanes")}
|
||||
{lanes.map((l) => (
|
||||
<Link
|
||||
key={l.id}
|
||||
to={`/workspace?lane=${l.id}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{l.title || l.cwd}
|
||||
</Link>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tool.modelFlag && (
|
||||
<Card title={t("toolTab.defaultModel")} Icon={Cpu}>
|
||||
<DefaultModel toolId={toolId} saved={tool.defaultModel ?? ""} />
|
||||
</Card>
|
||||
)}
|
||||
{tool.modelVendor && <PricingSettings vendor={tool.modelVendor} id={`${toolId}-pricing`} />}
|
||||
{tool.configFiles > 0 && (
|
||||
<Card title={t("toolTab.config")} Icon={FileCog}>
|
||||
<ConfigFiles toolId={toolId} />
|
||||
</Card>
|
||||
)}
|
||||
{Extra && <Extra />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user