3a8be55b55
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
172 lines
6.4 KiB
TypeScript
172 lines
6.4 KiB
TypeScript
/**
|
|
* @file AgentInstructionsSettings — Settings → AI tools → My agent
|
|
* instructions (issue #67): the account's own rules in markdown, put before
|
|
* every workspace's own instructions in each new Claude or Codex session of a
|
|
* workspace it owns (`GET/PUT /api/me/instructions`). Saved per account. A
|
|
* failed load says so with a Retry instead of leaving a dead, empty editor —
|
|
* the editor stays read-only until the saved text is known, so a save can
|
|
* never overwrite instructions it did not load. **Choose file** reads a local
|
|
* Markdown/text file in the browser into the draft (nothing is uploaded until
|
|
* Save, and Discard brings the saved text back). "Applies to" lists only the
|
|
* installed, enabled tools whose panes the server says receive them.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useRef, useState, type ChangeEvent } from "react";
|
|
import { FileUp } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api, type AgentInstructionsView } from "../../lib/api";
|
|
import { useAiTools } from "../../hooks/useAiTools";
|
|
import { AiToolIcon } from "../AiToolIcon";
|
|
import { toast } from "../../lib/toast";
|
|
import { SettingsSection } from "./SettingsRow";
|
|
|
|
export function AgentInstructionsSettings() {
|
|
const { t } = useTranslation("settings");
|
|
const [saved, setSaved] = useState<string | null>(null);
|
|
const [draft, setDraft] = useState("");
|
|
const [max, setMax] = useState<number | undefined>();
|
|
const [toolIds, setToolIds] = useState<string[]>([]);
|
|
const { tools } = useAiTools();
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const fileInput = useRef<HTMLInputElement | null>(null);
|
|
|
|
const apply = (r: AgentInstructionsView) => {
|
|
setSaved(r.instructions);
|
|
setDraft(r.instructions);
|
|
setMax(r.max);
|
|
setToolIds(r.tools ?? []);
|
|
};
|
|
|
|
const load = () => {
|
|
setLoadError(null);
|
|
api.me
|
|
.instructions()
|
|
.then(apply)
|
|
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)));
|
|
};
|
|
|
|
useEffect(load, []);
|
|
|
|
async function save() {
|
|
setSaving(true);
|
|
try {
|
|
apply(await api.me.saveInstructions(draft));
|
|
toast.success(t("agentInstructions.saved"));
|
|
} catch (err) {
|
|
toast.error(
|
|
t("agentInstructions.saveFailed", { message: err instanceof Error ? err.message : "" })
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function pickFile(e: ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
e.target.value = ""; // the same file can be picked again after a Discard
|
|
if (!file) return;
|
|
const text = await file.text();
|
|
if (max && text.length > max) {
|
|
toast.error(
|
|
t("agentInstructions.fileTooBig", {
|
|
name: file.name,
|
|
size: text.length.toLocaleString(),
|
|
max: max.toLocaleString(),
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
setDraft(text);
|
|
}
|
|
|
|
const appliesTo = tools.filter((x) => x.installed && x.enabled && toolIds.includes(x.id));
|
|
const ready = saved !== null;
|
|
const dirty = ready && draft !== saved;
|
|
|
|
return (
|
|
<SettingsSection
|
|
id="agent-instructions"
|
|
title={t("agentInstructions.title")}
|
|
description={t("agentInstructions.description")}
|
|
>
|
|
<div className="pt-1">
|
|
{appliesTo.length > 0 && (
|
|
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-fg-muted">
|
|
<span>{t("agentInstructions.appliesTo")}</span>
|
|
{appliesTo.map((x) => (
|
|
<span
|
|
key={x.id}
|
|
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface-2 py-0.5 pl-1 pr-2.5 text-fg-secondary"
|
|
>
|
|
<AiToolIcon tool={x} className="h-4 w-4" />
|
|
{x.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
aria-label={t("agentInstructions.title")}
|
|
className="block w-full min-h-[180px] resize-y rounded-lg border border-border bg-surface-1 px-3 py-2.5 font-mono text-xs leading-relaxed text-fg-primary placeholder:text-fg-muted/70 focus:border-accent focus:outline-none disabled:opacity-60"
|
|
rows={8}
|
|
spellCheck={false}
|
|
placeholder={t("agentInstructions.placeholder")}
|
|
value={draft}
|
|
maxLength={max}
|
|
disabled={!ready}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
/>
|
|
<div className="mt-3 flex items-center justify-between gap-3">
|
|
{loadError ? (
|
|
<p className="flex min-w-0 items-center gap-2 text-xs text-status-danger">
|
|
<span className="truncate">
|
|
{t("agentInstructions.loadFailed", { message: loadError })}
|
|
</span>
|
|
<button type="button" className="btn-ghost shrink-0 text-xs" onClick={load}>
|
|
{t("common:retry")}
|
|
</button>
|
|
</p>
|
|
) : (
|
|
<span className="text-[11px] tabular-nums text-fg-muted">
|
|
{ready && max ? `${draft.length.toLocaleString()} / ${max.toLocaleString()}` : ""}
|
|
</span>
|
|
)}
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
accept=".md,.markdown,.txt,text/markdown,text/plain"
|
|
className="hidden"
|
|
data-testid="agent-instructions-file"
|
|
onChange={(e) => void pickFile(e)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn-ghost inline-flex items-center gap-1.5 text-xs disabled:opacity-50"
|
|
disabled={!ready || saving}
|
|
onClick={() => fileInput.current?.click()}
|
|
>
|
|
<FileUp className="h-3.5 w-3.5" />
|
|
{t("agentInstructions.chooseFile")}
|
|
</button>
|
|
{dirty && !saving && (
|
|
<button type="button" className="btn-ghost text-xs" onClick={() => setDraft(saved)}>
|
|
{t("agentInstructions.discard")}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn-primary text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
|
disabled={saving || !dirty}
|
|
onClick={() => void save()}
|
|
>
|
|
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingsSection>
|
|
);
|
|
}
|