Add web terminal, git credentials, OpenSCAD, review loops, automations
- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay) - Per-user git credentials and commit identity (credential store in the user's home) - OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card - Craft phase inferred from tool activity when the agent does not report it - Configurable review loop per mode (rounds, reviewer model, criteria) - Delete config and Markdown agents from the agent editor - Automations: cron-scheduled agents from Markdown job files, schedule tool, page - change_directory tool (only on the user's request) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0d0e707f2
commit
df1924b0a1
41 files changed
+2809
-34
No files matched your search
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { post } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
import { GitAccount } from "./GitAccount";
|
||||
|
||||
export function Account() {
|
||||
const t = useT();
|
||||
@@ -21,6 +22,7 @@ export function Account() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{t("account.title")}: {me?.username}
|
||||
@@ -43,5 +45,7 @@ export function Account() {
|
||||
</button>
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
|
||||
</div>
|
||||
<GitAccount />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "../Icon";
|
||||
import { useState } from "react";
|
||||
import { patch } from "../../api";
|
||||
import { del, patch } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import type { AgentInfo } from "../../types";
|
||||
@@ -22,6 +23,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
tools: (raw?.tools ?? []).join(", "),
|
||||
prompt: raw?.prompt ?? "",
|
||||
mode: raw?.mode ?? a.mode,
|
||||
refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0),
|
||||
refine_model: raw?.refine?.model ?? "",
|
||||
refine_criteria: raw?.refine?.criteria ?? "",
|
||||
});
|
||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "general", "explore"].includes(a.name));
|
||||
|
||||
@@ -31,6 +35,13 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
{ path: [...base, "model"], value: form.model || null },
|
||||
{ path: [...base, "max_parallel"], value: form.max_parallel ? Number(form.max_parallel) : null },
|
||||
];
|
||||
if (a.mode === "primary") {
|
||||
const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0));
|
||||
patches.push({
|
||||
path: [...base, "refine"],
|
||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : null,
|
||||
});
|
||||
}
|
||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||
if (!builtin) {
|
||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||
@@ -45,6 +56,16 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = async () => {
|
||||
if (!(await confirmDialog({ message: t("agents.deleteFile", { path: a.path ?? a.name }), danger: true }))) return;
|
||||
try {
|
||||
await del(`/agents/${encodeURIComponent(a.name)}`);
|
||||
onSaved(true, t("settings.saved"));
|
||||
} catch (e: any) {
|
||||
onSaved(false, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
|
||||
try {
|
||||
@@ -63,6 +84,11 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
<span className="chip">{t(`agents.source.${a.source}` as any)}</span>
|
||||
{a.read_only && <span className="chip">{t("agents.readOnly")}</span>}
|
||||
<span className="spacer" />
|
||||
{!builtin && (a.source === "config" || a.source === "file") && (
|
||||
<button className="small ghost danger" onClick={a.source === "file" ? removeFile : reset} title={t("common.delete")} aria-label={t("common.delete")}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
)}
|
||||
<button className="small" onClick={() => setOpen(!open)}>
|
||||
{open ? t("common.collapse") : t("common.edit")}
|
||||
</button>
|
||||
@@ -72,6 +98,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
<span style={{ fontSize: 12, color: "var(--muted)" }}>{t("agents.model")}:</span>
|
||||
<span className="chip">{a.model || t("agents.sessionModel")}</span>
|
||||
{a.max_parallel ? <span className="chip">{t("agents.maxParallelShort", { n: a.max_parallel })}</span> : null}
|
||||
{a.refine?.rounds ? <span className="chip">{t("agents.refineShort", { n: a.refine.rounds })}</span> : null}
|
||||
</div>
|
||||
{open && (
|
||||
<>
|
||||
@@ -85,6 +112,17 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
<input type="number" min={1} value={form.max_parallel} placeholder="∞" onChange={(e) => setForm({ ...form, max_parallel: e.target.value })} />
|
||||
</>
|
||||
)}
|
||||
{a.mode === "primary" && (
|
||||
<>
|
||||
<label>{t("agents.refineRounds")}</label>
|
||||
<input type="number" min={0} max={10} value={form.refine_rounds} placeholder={t("agents.refineOff")} onChange={(e) => setForm({ ...form, refine_rounds: e.target.value })} />
|
||||
<label>{t("agents.refineModel")}</label>
|
||||
<ModelSelect value={form.refine_model} onChange={(v) => setForm({ ...form, refine_model: v })} emptyLabel={t("agents.sessionModel")} />
|
||||
<label>{t("agents.refineCriteria")}</label>
|
||||
<textarea rows={3} value={form.refine_criteria} onChange={(e) => setForm({ ...form, refine_criteria: e.target.value })} />
|
||||
<div className="hint">{t("agents.refineCriteriaHint")}</div>
|
||||
</>
|
||||
)}
|
||||
<label>{t("agents.description")}</label>
|
||||
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
|
||||
@@ -107,9 +145,9 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
{t("common.save")}
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
{a.source === "config" && (
|
||||
{a.source === "config" && builtin && (
|
||||
<button className="danger" onClick={reset}>
|
||||
{builtin ? t("agents.reset") : t("common.delete")}
|
||||
{t("agents.reset")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { get, put } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { toast } from "../../dialogs";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
interface Cred {
|
||||
host: string;
|
||||
username: string;
|
||||
token: string;
|
||||
protocol?: string;
|
||||
}
|
||||
interface GitSettings {
|
||||
name: string;
|
||||
email: string;
|
||||
credentials: Cred[];
|
||||
}
|
||||
|
||||
/** Git identity and credentials for agents, terminal and git client (per user). */
|
||||
export function GitAccount() {
|
||||
const t = useT();
|
||||
const [s, setS] = useState<GitSettings>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
get<GitSettings>("/account/git")
|
||||
.then((v) => setS({ name: v.name ?? "", email: v.email ?? "", credentials: v.credentials ?? [] }))
|
||||
.catch((e) => toast(e.message));
|
||||
}, []);
|
||||
if (!s) return null;
|
||||
|
||||
const setCred = (i: number, patch: Partial<Cred>) => setS({ ...s, credentials: s.credentials.map((c, j) => (j === i ? { ...c, ...patch } : c)) });
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const v = await put<GitSettings>("/account/git", s);
|
||||
setS({ name: v.name ?? "", email: v.email ?? "", credentials: v.credentials ?? [] });
|
||||
toast(t("gitcred.saved"), "success");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<Icon name="fork" /> {t("gitcred.title")}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
{t("gitcred.hint")}
|
||||
</div>
|
||||
<div className="form">
|
||||
<label>{t("gitcred.name")}</label>
|
||||
<input value={s.name} onChange={(e) => setS({ ...s, name: e.target.value })} placeholder="Jane Doe" autoComplete="name" />
|
||||
<label>{t("gitcred.email")}</label>
|
||||
<input value={s.email} onChange={(e) => setS({ ...s, email: e.target.value })} placeholder="jane@example.com" autoComplete="email" />
|
||||
</div>
|
||||
<h4 style={{ margin: "16px 0 8px" }}>{t("gitcred.credentials")}</h4>
|
||||
<div className="gitcred-list">
|
||||
{s.credentials.length === 0 && <div className="empty" style={{ padding: 8 }}>{t("gitcred.none")}</div>}
|
||||
{s.credentials.map((c, i) => (
|
||||
<div key={i} className="gitcred-row">
|
||||
<input value={c.host} placeholder="git.example.com" onChange={(e) => setCred(i, { host: e.target.value })} aria-label={t("gitcred.host")} />
|
||||
<input value={c.username} placeholder={t("gitcred.user")} onChange={(e) => setCred(i, { username: e.target.value })} aria-label={t("gitcred.user")} autoComplete="off" />
|
||||
<input
|
||||
type="password"
|
||||
value={c.token}
|
||||
placeholder={t("gitcred.token")}
|
||||
onFocus={(e) => c.token.startsWith("•") && e.target.select()}
|
||||
onChange={(e) => setCred(i, { token: e.target.value })}
|
||||
aria-label={t("gitcred.token")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button className="ghost small" onClick={() => setS({ ...s, credentials: s.credentials.filter((_, j) => j !== i) })} aria-label={t("common.remove")}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 10, gap: 8 }}>
|
||||
<button className="small" onClick={() => setS({ ...s, credentials: [...s.credentials, { host: "", username: "", token: "" }] })}>
|
||||
<Icon name="plus" /> {t("gitcred.add")}
|
||||
</button>
|
||||
<span className="grow" />
|
||||
<button className="primary" onClick={save} disabled={saving}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user