mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +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
|
|
@ -1,5 +1,7 @@
|
|||
import type {
|
||||
Agent,
|
||||
AgentInstructionsBundle,
|
||||
AgentInstructionsFileDetail,
|
||||
AgentSkillSnapshot,
|
||||
AdapterEnvironmentTestResult,
|
||||
AgentKeyCreated,
|
||||
|
|
@ -103,6 +105,31 @@ export const agentsApi = {
|
|||
api.patch<Agent>(agentPath(id, companyId), data),
|
||||
updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) =>
|
||||
api.patch<Agent>(agentPath(id, companyId, "/permissions"), data),
|
||||
instructionsBundle: (id: string, companyId?: string) =>
|
||||
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
|
||||
updateInstructionsBundle: (
|
||||
id: string,
|
||||
data: {
|
||||
mode?: "managed" | "external";
|
||||
rootPath?: string | null;
|
||||
entryFile?: string;
|
||||
clearLegacyPromptTemplate?: boolean;
|
||||
},
|
||||
companyId?: string,
|
||||
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
|
||||
instructionsFile: (id: string, relativePath: string, companyId?: string) =>
|
||||
api.get<AgentInstructionsFileDetail>(
|
||||
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
|
||||
),
|
||||
saveInstructionsFile: (
|
||||
id: string,
|
||||
data: { path: string; content: string; clearLegacyPromptTemplate?: boolean },
|
||||
companyId?: string,
|
||||
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
|
||||
deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) =>
|
||||
api.delete<AgentInstructionsBundle>(
|
||||
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
|
||||
),
|
||||
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
|
||||
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
|
||||
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),
|
||||
|
|
|
|||
|
|
@ -735,36 +735,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
|
||||
<MarkdownEditor
|
||||
value={
|
||||
isCreate
|
||||
? val!.bootstrapPrompt
|
||||
: eff(
|
||||
"adapterConfig",
|
||||
"bootstrapPromptTemplate",
|
||||
String(config.bootstrapPromptTemplate ?? ""),
|
||||
)
|
||||
}
|
||||
onChange={(v) =>
|
||||
isCreate
|
||||
? set!({ bootstrapPrompt: v })
|
||||
: mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
|
||||
}
|
||||
placeholder="Optional initial setup prompt for the first run"
|
||||
contentClassName="min-h-[44px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = isCreate
|
||||
? "agents/drafts/bootstrap-prompt"
|
||||
: `agents/${props.agent.id}/bootstrap-prompt`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
|
||||
Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it.
|
||||
</div>
|
||||
{adapterType === "claude_local" && (
|
||||
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export const queryKeys = {
|
|||
runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
|
||||
taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
|
||||
skills: (id: string) => ["agents", "skills", id] as const,
|
||||
instructionsBundle: (id: string) => ["agents", "instructions-bundle", id] as const,
|
||||
instructionsFile: (id: string, relativePath: string) =>
|
||||
["agents", "instructions-bundle", id, "file", relativePath] as const,
|
||||
keys: (agentId: string) => ["agents", "keys", agentId] as const,
|
||||
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
|
||||
adapterModels: (companyId: string, adapterType: string) =>
|
||||
|
|
|
|||
|
|
@ -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