mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
Add agent instructions bundle editing
Expose first-class instructions bundle APIs, preserve agent prompt bundles in portability flows, and replace the Agent Detail prompts tab with file-backed bundle editing while retiring bootstrap prompt UI. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
827b09d7a5
commit
e980c2ef64
16 changed files with 1482 additions and 138 deletions
|
|
@ -118,6 +118,10 @@ function redactEnvValue(key: string, value: unknown): string {
|
|||
}
|
||||
}
|
||||
|
||||
function isMarkdown(pathValue: string) {
|
||||
return pathValue.toLowerCase().endsWith(".md");
|
||||
}
|
||||
|
||||
function formatEnvForDisplay(envValue: unknown): string {
|
||||
const env = asRecord(envValue);
|
||||
if (!env) return "<unable-to-parse>";
|
||||
|
|
@ -1300,6 +1304,7 @@ function AgentConfigurePage({
|
|||
updatePermissions={updatePermissions}
|
||||
companyId={companyId}
|
||||
hidePromptTemplate
|
||||
hideInstructionsFile
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">API Keys</h3>
|
||||
|
|
@ -1371,6 +1376,7 @@ function ConfigurationTab({
|
|||
onSavingChange,
|
||||
updatePermissions,
|
||||
hidePromptTemplate,
|
||||
hideInstructionsFile,
|
||||
}: {
|
||||
agent: Agent;
|
||||
companyId?: string;
|
||||
|
|
@ -1380,6 +1386,7 @@ function ConfigurationTab({
|
|||
onSavingChange: (saving: boolean) => void;
|
||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||
hidePromptTemplate?: boolean;
|
||||
hideInstructionsFile?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||
|
|
@ -1434,6 +1441,7 @@ function ConfigurationTab({
|
|||
onCancelActionChange={onCancelActionChange}
|
||||
hideInlineSave
|
||||
hidePromptTemplate={hidePromptTemplate}
|
||||
hideInstructionsFile={hideInstructionsFile}
|
||||
sectionLayout="cards"
|
||||
/>
|
||||
|
||||
|
|
@ -1479,13 +1487,16 @@ function PromptsTab({
|
|||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const [selectedFile, setSelectedFile] = useState<string>("AGENTS.md");
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [bundleDraft, setBundleDraft] = useState<{
|
||||
mode: "managed" | "external";
|
||||
rootPath: string;
|
||||
entryFile: string;
|
||||
} | null>(null);
|
||||
const [newFilePath, setNewFilePath] = useState("");
|
||||
const [awaitingRefresh, setAwaitingRefresh] = useState(false);
|
||||
const lastAgentRef = useRef(agent);
|
||||
|
||||
const currentValue = String(agent.adapterConfig?.promptTemplate ?? "");
|
||||
const displayValue = draft ?? currentValue;
|
||||
const isDirty = draft !== null && draft !== currentValue;
|
||||
const lastFileVersionRef = useRef<string | null>(null);
|
||||
|
||||
const isLocal =
|
||||
agent.adapterType === "claude_local" ||
|
||||
|
|
@ -1495,10 +1506,60 @@ function PromptsTab({
|
|||
agent.adapterType === "hermes_local" ||
|
||||
agent.adapterType === "cursor";
|
||||
|
||||
const updateAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||
const { data: bundle, isLoading: bundleLoading } = useQuery({
|
||||
queryKey: queryKeys.agents.instructionsBundle(agent.id),
|
||||
queryFn: () => agentsApi.instructionsBundle(agent.id, companyId),
|
||||
enabled: Boolean(companyId && isLocal),
|
||||
});
|
||||
|
||||
const currentMode = bundleDraft?.mode ?? bundle?.mode ?? "managed";
|
||||
const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md";
|
||||
const currentRootPath = bundleDraft?.rootPath ?? bundle?.rootPath ?? "";
|
||||
const fileOptions = bundle?.files.map((file) => file.path) ?? [];
|
||||
const selectedOrEntryFile = selectedFile || currentEntryFile;
|
||||
const selectedFileExists = fileOptions.includes(selectedOrEntryFile);
|
||||
|
||||
const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
|
||||
queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
|
||||
queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId),
|
||||
enabled: Boolean(companyId && isLocal && selectedFileExists),
|
||||
});
|
||||
|
||||
const updateBundle = useMutation({
|
||||
mutationFn: (data: {
|
||||
mode?: "managed" | "external";
|
||||
rootPath?: string | null;
|
||||
entryFile?: string;
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
}) => agentsApi.updateInstructionsBundle(agent.id, data, companyId),
|
||||
onMutate: () => setAwaitingRefresh(true),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||
},
|
||||
onError: () => setAwaitingRefresh(false),
|
||||
});
|
||||
|
||||
const saveFile = useMutation({
|
||||
mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) =>
|
||||
agentsApi.saveInstructionsFile(agent.id, data, companyId),
|
||||
onMutate: () => setAwaitingRefresh(true),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, variables.path) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||
},
|
||||
onError: () => setAwaitingRefresh(false),
|
||||
});
|
||||
|
||||
const deleteFile = useMutation({
|
||||
mutationFn: (relativePath: string) => agentsApi.deleteInstructionsFile(agent.id, relativePath, companyId),
|
||||
onMutate: () => setAwaitingRefresh(true),
|
||||
onSuccess: (_, relativePath) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
|
||||
queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||
},
|
||||
|
|
@ -1513,60 +1574,339 @@ function PromptsTab({
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (awaitingRefresh && agent !== lastAgentRef.current) {
|
||||
setAwaitingRefresh(false);
|
||||
setDraft(null);
|
||||
if (!bundle) return;
|
||||
const availablePaths = bundle.files.map((file) => file.path);
|
||||
if (availablePaths.length === 0) {
|
||||
if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile);
|
||||
return;
|
||||
}
|
||||
lastAgentRef.current = agent;
|
||||
}, [agent, awaitingRefresh]);
|
||||
if (!availablePaths.includes(selectedFile)) {
|
||||
setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!);
|
||||
}
|
||||
}, [bundle, selectedFile]);
|
||||
|
||||
const isSaving = updateAgent.isPending || awaitingRefresh;
|
||||
useEffect(() => {
|
||||
const versionKey = selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${selectedOrEntryFile}`;
|
||||
if (awaitingRefresh) {
|
||||
setAwaitingRefresh(false);
|
||||
setBundleDraft(null);
|
||||
setDraft(null);
|
||||
lastFileVersionRef.current = versionKey;
|
||||
return;
|
||||
}
|
||||
if (lastFileVersionRef.current !== versionKey) {
|
||||
setDraft(null);
|
||||
lastFileVersionRef.current = versionKey;
|
||||
}
|
||||
}, [awaitingRefresh, selectedFileDetail, selectedOrEntryFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bundle) return;
|
||||
setBundleDraft((current) => {
|
||||
if (current) return current;
|
||||
return {
|
||||
mode: bundle.mode ?? "managed",
|
||||
rootPath: bundle.rootPath ?? "",
|
||||
entryFile: bundle.entryFile,
|
||||
};
|
||||
});
|
||||
}, [bundle]);
|
||||
|
||||
const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : "";
|
||||
const displayValue = draft ?? currentContent;
|
||||
const bundleDirty = Boolean(
|
||||
bundleDraft &&
|
||||
(
|
||||
bundleDraft.mode !== (bundle?.mode ?? "managed") ||
|
||||
bundleDraft.rootPath !== (bundle?.rootPath ?? "") ||
|
||||
bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md")
|
||||
),
|
||||
);
|
||||
const fileDirty = draft !== null && draft !== currentContent;
|
||||
const isDirty = bundleDirty || fileDirty;
|
||||
const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh;
|
||||
|
||||
useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
|
||||
useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
onSaveActionChange(isDirty ? () => {
|
||||
updateAgent.mutate({ adapterConfig: { promptTemplate: draft } });
|
||||
const save = async () => {
|
||||
const shouldClearLegacy =
|
||||
Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive);
|
||||
if (bundleDirty && bundleDraft) {
|
||||
await updateBundle.mutateAsync({
|
||||
mode: bundleDraft.mode,
|
||||
rootPath: bundleDraft.mode === "external" ? bundleDraft.rootPath : null,
|
||||
entryFile: bundleDraft.entryFile,
|
||||
});
|
||||
}
|
||||
if (fileDirty) {
|
||||
await saveFile.mutateAsync({
|
||||
path: selectedOrEntryFile,
|
||||
content: displayValue,
|
||||
clearLegacyPromptTemplate: shouldClearLegacy,
|
||||
});
|
||||
}
|
||||
};
|
||||
void save().catch(() => undefined);
|
||||
} : null);
|
||||
}, [onSaveActionChange, isDirty, draft, updateAgent]);
|
||||
}, [
|
||||
bundle,
|
||||
bundleDirty,
|
||||
bundleDraft,
|
||||
displayValue,
|
||||
fileDirty,
|
||||
isDirty,
|
||||
onSaveActionChange,
|
||||
saveFile,
|
||||
selectedOrEntryFile,
|
||||
updateBundle,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onCancelActionChange(isDirty ? () => setDraft(null) : null);
|
||||
}, [onCancelActionChange, isDirty]);
|
||||
onCancelActionChange(isDirty ? () => {
|
||||
setDraft(null);
|
||||
if (bundle) {
|
||||
setBundleDraft({
|
||||
mode: bundle.mode ?? "managed",
|
||||
rootPath: bundle.rootPath ?? "",
|
||||
entryFile: bundle.entryFile,
|
||||
});
|
||||
}
|
||||
} : null);
|
||||
}, [bundle, isDirty, onCancelActionChange]);
|
||||
|
||||
if (!isLocal) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prompt templates are only available for local adapters.
|
||||
Instructions bundles are only available for local adapters.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (bundleLoading && !bundle) {
|
||||
return <div className="max-w-5xl text-sm text-muted-foreground">Loading instructions bundle…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Prompt Template</h3>
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{help.promptTemplate}
|
||||
</p>
|
||||
<MarkdownEditor
|
||||
value={displayValue}
|
||||
onChange={(v) => setDraft(v ?? "")}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${agent.id}/prompt-template`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
|
||||
<div className="max-w-6xl space-y-4">
|
||||
<div className="border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Instructions Bundle</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
`AGENTS.md` is the entry file. Sibling files like `HEARTBEAT.md`, `SOUL.md`, `TOOLS.md`, and arbitrary custom files live in the same bundle.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bundle?.files.length ?? 0} files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(bundle?.legacyPromptTemplateActive || bundle?.legacyBootstrapPromptTemplateActive) && (
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Legacy inline prompt fields are still active for this agent. The next bundle save will migrate behavior into file-backed instructions and clear those legacy fields.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(bundle?.warnings ?? []).map((warning) => (
|
||||
<div key={warning} className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
|
||||
{warning}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Mode</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={currentMode === "managed" ? "default" : "outline"}
|
||||
onClick={() => setBundleDraft((current) => ({
|
||||
mode: "managed",
|
||||
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
|
||||
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
|
||||
}))}
|
||||
>
|
||||
Managed
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={currentMode === "external" ? "default" : "outline"}
|
||||
onClick={() => setBundleDraft((current) => ({
|
||||
mode: "external",
|
||||
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
|
||||
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
|
||||
}))}
|
||||
>
|
||||
External
|
||||
</Button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Entry file</span>
|
||||
<Input
|
||||
value={currentEntryFile}
|
||||
onChange={(event) => {
|
||||
const nextEntryFile = event.target.value || "AGENTS.md";
|
||||
if (selectedOrEntryFile === currentEntryFile) {
|
||||
setSelectedFile(nextEntryFile);
|
||||
}
|
||||
setBundleDraft((current) => ({
|
||||
mode: current?.mode ?? bundle?.mode ?? "managed",
|
||||
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
|
||||
entryFile: nextEntryFile,
|
||||
}));
|
||||
}}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Root path</span>
|
||||
<Input
|
||||
value={currentRootPath}
|
||||
onChange={(event) => setBundleDraft((current) => ({
|
||||
mode: current?.mode ?? bundle?.mode ?? "managed",
|
||||
rootPath: event.target.value,
|
||||
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
|
||||
}))}
|
||||
disabled={currentMode === "managed"}
|
||||
className="font-mono text-sm"
|
||||
placeholder={currentMode === "managed" ? "Managed by Paperclip" : "/absolute/path/to/agent/prompts"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||
<div className="border border-border rounded-lg p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium">Files</h4>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const candidate = newFilePath.trim();
|
||||
if (!candidate) return;
|
||||
setSelectedFile(candidate);
|
||||
setDraft("");
|
||||
setNewFilePath("");
|
||||
}}
|
||||
disabled={!newFilePath.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newFilePath}
|
||||
onChange={(event) => setNewFilePath(event.target.value)}
|
||||
placeholder="docs/TOOLS.md"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["HEARTBEAT.md", "SOUL.md", "TOOLS.md"].map((filePath) => (
|
||||
<Button
|
||||
key={filePath}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedFile(filePath);
|
||||
if (!fileOptions.includes(filePath)) setDraft("");
|
||||
}}
|
||||
>
|
||||
{filePath}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{[...new Set([currentEntryFile, ...fileOptions])].map((filePath) => {
|
||||
const file = bundle?.files.find((entry) => entry.path === filePath);
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm",
|
||||
filePath === selectedOrEntryFile ? "border-foreground/30 bg-accent/30" : "border-border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedFile(filePath);
|
||||
if (!fileOptions.includes(filePath)) setDraft("");
|
||||
}}
|
||||
>
|
||||
<span className="truncate font-mono">{filePath}</span>
|
||||
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
|
||||
{file?.isEntryFile ? "entry" : file ? `${file.size}b` : "new"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium font-mono">{selectedOrEntryFile}</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedFileExists
|
||||
? `${selectedFileDetail?.language ?? "text"} file`
|
||||
: "New file in this bundle"}
|
||||
</p>
|
||||
</div>
|
||||
{selectedFileExists && selectedOrEntryFile !== currentEntryFile && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete ${selectedOrEntryFile}?`)) {
|
||||
deleteFile.mutate(selectedOrEntryFile, {
|
||||
onSuccess: () => {
|
||||
setSelectedFile(currentEntryFile);
|
||||
setDraft(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={deleteFile.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedFileExists && fileLoading && !selectedFileDetail ? (
|
||||
<p className="text-sm text-muted-foreground">Loading file…</p>
|
||||
) : isMarkdown(selectedOrEntryFile) ? (
|
||||
<MarkdownEditor
|
||||
value={displayValue}
|
||||
onChange={(value) => setDraft(value ?? "")}
|
||||
placeholder="# Agent instructions"
|
||||
contentClassName="min-h-[420px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={displayValue}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
className="min-h-[420px] w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm outline-none"
|
||||
placeholder="File contents"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue