Compare commits

...

2 Commits

Author SHA1 Message Date
f129b3ba43 Merge branch 'develop' of https://git.muticolturano.com/Adiuva/adiuva into develop 2026-04-08 22:04:43 +02:00
7f0c6f45b0 feat(local-agent-v2): step 5 — migrate promptTemplate → agentConfig in FE
- store.ts: LocalAgentLocalConfig.promptTemplate (string) → agentConfig (Record | null)
- agent-scheduler.ts + router runNow: send agentConfig object to trigger, drop customAgentPrompt
- api-types.ts: WsJourneyReplySchema + LocalAgentConfigSchema + JourneyMessageSchema use agentConfig
- WsJourneyStartSchema: existingTemplate → existingConfig (aligns with backend existing_config field)
- backend-client.ts: JourneyListener + sendJourneyStart + journey_reply handler use agentConfig
- router/index.ts: local agent create/update accept agentConfig; journey router returns agentConfig
- types.ts + AgentsSection + JourneyDialog: promptTemplate → agentConfig throughout
- JourneyDialog: parses JSON agentConfig string → object; shows AgentConfigSummary preview
- PromptBuilderChat: adds onConfigUpdate callback for local agent path (cloud keeps onPromptUpdate)
- InlineAgentCreationStepper: local path uses agentConfig state; cloud path keeps promptTemplate

Cloud agents are intentionally NOT migrated — they retain promptTemplate string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 22:03:26 +02:00
10 changed files with 119 additions and 65 deletions

View File

@@ -86,7 +86,7 @@ async function tickAgentScheduler(): Promise<void> {
agentId: agent.id,
whatToExtract: agent.dataTypes,
batchInterval: agent.scheduleCron,
customAgentPrompt: agent.promptTemplate,
agentConfig: agent.agentConfig ?? undefined,
activeAgents,
},
);

View File

@@ -177,7 +177,7 @@ interface StreamListener {
/** Pending journey reply listener — resolves when a `journey_reply` arrives. */
interface JourneyListener {
resolve: (reply: { sessionId: string; message: string; done: boolean; promptTemplate?: string | null }) => void;
resolve: (reply: { sessionId: string; message: string; done: boolean; agentConfig?: string | null }) => void;
reject: (err: Error) => void;
}
@@ -384,8 +384,8 @@ export class BackendClient {
agentType: 'local_directory' | 'gmail' | 'teams' | 'outlook',
dataTypes: string[],
directory?: string,
existingTemplate?: string | null,
): Promise<{ sessionId: string; message: string; done: boolean; promptTemplate?: string | null }> {
existingConfig?: string | null,
): Promise<{ sessionId: string; message: string; done: boolean; agentConfig?: string | null }> {
return new Promise((resolve, reject) => {
this.journeyListeners.set(sessionId, { resolve, reject });
@@ -402,7 +402,7 @@ export class BackendClient {
agentType,
directory: directory ?? null,
dataTypes,
existingTemplate: existingTemplate ?? null,
existingConfig: existingConfig ?? null,
});
logWsSend(payload);
ws.send(JSON.stringify(payload));
@@ -417,7 +417,7 @@ export class BackendClient {
sendJourneyMessage(
sessionId: string,
message: string,
): Promise<{ sessionId: string; message: string; done: boolean; promptTemplate?: string | null }> {
): Promise<{ sessionId: string; message: string; done: boolean; agentConfig?: string | null }> {
return new Promise((resolve, reject) => {
this.journeyListeners.set(sessionId, { resolve, reject });
@@ -730,7 +730,7 @@ export class BackendClient {
sessionId: frame.data.sessionId,
message: frame.data.message,
done: frame.data.done,
promptTemplate: frame.data.promptTemplate,
agentConfig: frame.data.agentConfig,
});
}
break;

View File

@@ -707,7 +707,7 @@ const agentLocalRouter = router({
name: z.string(),
directory: z.string(),
dataTypes: z.array(z.string()),
promptTemplate: z.string(),
agentConfig: z.record(z.string(), z.unknown()).nullable().optional(),
scheduleCron: z.string(),
}))
.mutation(({ input }) => {
@@ -716,7 +716,7 @@ const agentLocalRouter = router({
name: input.name,
directory: input.directory,
dataTypes: input.dataTypes,
promptTemplate: input.promptTemplate,
agentConfig: input.agentConfig ?? null,
scheduleCron: input.scheduleCron,
enabled: true,
lastRunAt: null,
@@ -731,7 +731,7 @@ const agentLocalRouter = router({
name: z.string().optional(),
directory: z.string().optional(),
dataTypes: z.array(z.string()).optional(),
promptTemplate: z.string().optional(),
agentConfig: z.record(z.string(), z.unknown()).nullable().optional(),
scheduleCron: z.string().optional(),
enabled: z.boolean().optional(),
}))
@@ -745,7 +745,7 @@ const agentLocalRouter = router({
...(input.name !== undefined && { name: input.name }),
...(input.directory !== undefined && { directory: input.directory }),
...(input.dataTypes !== undefined && { dataTypes: input.dataTypes }),
...(input.promptTemplate !== undefined && { promptTemplate: input.promptTemplate }),
...('agentConfig' in input && { agentConfig: input.agentConfig ?? null }),
...(input.scheduleCron !== undefined && { scheduleCron: input.scheduleCron }),
...(input.enabled !== undefined && { enabled: input.enabled }),
};
@@ -837,7 +837,7 @@ const agentJourneyRouter = router({
agentType: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
dataTypes: z.array(z.string()),
directory: z.string().optional(),
existingTemplate: z.string().optional(),
existingConfig: z.string().optional(),
}))
.mutation(async ({ input }) => {
try {
@@ -847,9 +847,9 @@ const agentJourneyRouter = router({
input.agentType,
input.dataTypes,
input.directory,
input.existingTemplate,
input.existingConfig,
);
return { data: { sessionId: result.sessionId, message: result.message, done: result.done, promptTemplate: result.promptTemplate ?? undefined }, error: null };
return { data: { sessionId: result.sessionId, message: result.message, done: result.done, agentConfig: result.agentConfig ?? undefined }, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to start journey';
return { data: null, error: msg };
@@ -861,7 +861,7 @@ const agentJourneyRouter = router({
.mutation(async ({ input }) => {
try {
const result = await getBackendClient().sendJourneyMessage(input.sessionId, input.message);
return { data: { sessionId: result.sessionId, message: result.message, done: result.done, promptTemplate: result.promptTemplate ?? undefined }, error: null };
return { data: { sessionId: result.sessionId, message: result.message, done: result.done, agentConfig: result.agentConfig ?? undefined }, error: null };
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to send journey message';
return { data: null, error: msg };
@@ -981,7 +981,7 @@ const agentRouter = router({
agentId: agent.id,
whatToExtract: agent.dataTypes,
batchInterval: agent.scheduleCron,
customAgentPrompt: agent.promptTemplate,
agentConfig: agent.agentConfig ?? undefined,
activeAgents,
},
);

View File

@@ -9,7 +9,8 @@ export interface LocalAgentLocalConfig {
name: string;
directory: string;
dataTypes: string[];
promptTemplate: string;
/** Structured extraction config produced by the Journey setup flow. */
agentConfig: Record<string, unknown> | null;
scheduleCron: string;
enabled: boolean;
lastRunAt: number | null;

View File

@@ -20,7 +20,7 @@ export function AgentsSection() {
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [journeyAgent, setJourneyAgent] = useState<{ id: string; type: 'local' | 'cloud'; name: string; currentPrompt: string; dataTypes: string[]; directory?: string } | null>(null);
const [journeyAgent, setJourneyAgent] = useState<{ id: string; type: 'local' | 'cloud'; name: string; currentConfig: Record<string, unknown> | null; dataTypes: string[]; directory?: string } | null>(null);
const catalogQuery = trpc.agent.catalog.useQuery(undefined, {
enabled: showTemplatePicker,
@@ -116,7 +116,7 @@ export function AgentsSection() {
id: agent.id,
type: agent.agentType,
name: agent.name,
currentPrompt: agent.promptTemplate,
currentConfig: agent.agentType === 'local' ? (agent as LocalAgentConfig).agentConfig ?? null : null,
dataTypes: agent.dataTypes,
directory: agent.agentType === 'local' ? (agent as LocalAgentConfig).directory : undefined,
})}
@@ -145,27 +145,19 @@ export function AgentsSection() {
<JourneyDialog
agentType={journeyAgent.type}
agentName={journeyAgent.name}
currentPrompt={journeyAgent.currentPrompt}
currentConfig={journeyAgent.currentConfig}
dataTypes={journeyAgent.dataTypes}
directory={journeyAgent.directory}
onClose={() => setJourneyAgent(null)}
onSaved={(promptTemplate) => {
onSaved={(agentConfig) => {
const local = localAgents.find(a => a.id === journeyAgent.id);
const cloud = cloudAgents.find(a => a.id === journeyAgent.id);
if (local) {
updateLocalMutation.mutate({ id: journeyAgent.id, promptTemplate }, {
updateLocalMutation.mutate({ id: journeyAgent.id, agentConfig }, {
onSuccess: () => {
void utils.agent.local.list.invalidate();
setJourneyAgent(null);
},
});
} else if (cloud) {
updateCloudMutation.mutate({ id: journeyAgent.id, promptTemplate }, {
onSuccess: () => {
void utils.agent.cloud.list.invalidate();
setJourneyAgent(null);
},
});
} else {
setJourneyAgent(null);
}

View File

@@ -38,6 +38,7 @@ export function InlineAgentCreationStepper({
const [dataTypes, setDataTypes] = useState<string[]>([]);
const [schedule, setSchedule] = useState('0 * * * *');
const [promptTemplate, setPromptTemplate] = useState('');
const [agentConfig, setAgentConfig] = useState<Record<string, unknown> | null>(null);
const [error, setError] = useState('');
const isSubmitting = createLocalMutation.isPending || createCloudMutation.isPending;
@@ -49,6 +50,7 @@ export function InlineAgentCreationStepper({
setDataTypes((item.supportedDataTypes ?? []).slice(0, 2));
setSchedule('0 * * * *');
setPromptTemplate('');
setAgentConfig(null);
setError('');
setStep(2);
}
@@ -103,7 +105,7 @@ export function InlineAgentCreationStepper({
directory,
dataTypes,
scheduleCron: schedule,
promptTemplate,
agentConfig: agentConfig ?? null,
},
{
onSuccess: () => onCreated(),
@@ -269,6 +271,7 @@ export function InlineAgentCreationStepper({
dataTypes={dataTypes}
directory={selectedTemplate.type === 'local_directory' ? directory : undefined}
onPromptUpdate={(p) => setPromptTemplate(p)}
onConfigUpdate={(c) => setAgentConfig(c)}
/>
</div>}
</div>
@@ -298,7 +301,9 @@ export function InlineAgentCreationStepper({
{selectedTemplate.type === 'local_directory' && directory && (
<p><span className="text-muted-foreground">Directory:</span> {directory}</p>
)}
{promptTemplate && <p><span className="text-muted-foreground">Custom prompt:</span> Added</p>}
{(selectedTemplate.type === 'local_directory' ? agentConfig : promptTemplate) && (
<p><span className="text-muted-foreground">Extraction config:</span> Added</p>
)}
</div>
</CardContent>
</Card>

View File

@@ -20,10 +20,43 @@ interface JourneyMessage {
content: string;
}
/** Parse a JSON string safely — returns null on failure. */
function parseAgentConfig(raw: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
/** Render a short human-readable summary of an AgentConfig object. */
function AgentConfigSummary({ config }: { config: Record<string, unknown> }) {
const contentTypes = config.content_types as { id?: string; label?: string }[] | undefined;
const dataTypes = config.data_types as string[] | undefined;
const globalRules = config.global_rules as string[] | undefined;
return (
<div className="text-xs leading-relaxed space-y-1">
{dataTypes && dataTypes.length > 0 && (
<p><span className="font-medium">Extracts:</span> {dataTypes.join(', ')}</p>
)}
{contentTypes && contentTypes.length > 0 && (
<p><span className="font-medium">Content types:</span>{' '}
{contentTypes.map(ct => ct.label ?? ct.id).join(', ')}
</p>
)}
{globalRules && globalRules.length > 0 && (
<p><span className="font-medium">Rules:</span> {globalRules.length} global rule{globalRules.length !== 1 ? 's' : ''}</p>
)}
</div>
);
}
export function JourneyDialog({
agentType,
agentName,
currentPrompt,
currentConfig,
dataTypes,
directory,
onClose,
@@ -31,11 +64,11 @@ export function JourneyDialog({
}: {
agentType: 'local' | 'cloud';
agentName: string;
currentPrompt: string;
currentConfig: Record<string, unknown> | null;
dataTypes: string[];
directory?: string;
onClose: () => void;
onSaved: (promptTemplate: string) => void;
onSaved: (agentConfig: Record<string, unknown>) => void;
}) {
const startMutation = trpc.agent.journey.start.useMutation();
const messageMutation = trpc.agent.journey.message.useMutation();
@@ -43,7 +76,7 @@ export function JourneyDialog({
const [sessionId, setSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<JourneyMessage[]>([]);
const [input, setInput] = useState('');
const [finalPrompt, setFinalPrompt] = useState<string | null>(currentPrompt || null);
const [finalConfig, setFinalConfig] = useState<Record<string, unknown> | null>(currentConfig);
const [isLoading, setIsLoading] = useState(false);
const [isDone, setIsDone] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -55,15 +88,23 @@ export function JourneyDialog({
useEffect(() => {
setIsLoading(true);
startMutation.mutate(
{ agentType: journeyAgentType, dataTypes, directory, existingTemplate: currentPrompt || undefined },
{
agentType: journeyAgentType,
dataTypes,
directory,
existingConfig: currentConfig ? JSON.stringify(currentConfig) : undefined,
},
{
onSuccess: (res) => {
if (res.data) {
setSessionId(res.data.sessionId);
setMessages([{ role: 'assistant', content: res.data.message }]);
if (res.data.done && res.data.promptTemplate) {
setFinalPrompt(res.data.promptTemplate);
setIsDone(true);
if (res.data.done && res.data.agentConfig) {
const parsed = parseAgentConfig(res.data.agentConfig);
if (parsed) {
setFinalConfig(parsed);
setIsDone(true);
}
}
}
setIsLoading(false);
@@ -71,7 +112,7 @@ export function JourneyDialog({
onError: () => {
setMessages([{
role: 'assistant',
content: "Hi! I'll help you set up your AI prompt. What kinds of information should the agent extract from your files? For example: task titles, due dates, project names, notes.",
content: "Hi! I'll help you set up your agent configuration. What kinds of files will this agent process, and what should it extract from them?",
}]);
setIsLoading(false);
},
@@ -97,9 +138,12 @@ export function JourneyDialog({
onSuccess: (res) => {
if (res.data) {
setMessages(prev => [...prev, { role: 'assistant', content: res.data.message }]);
if (res.data.done && res.data.promptTemplate) {
setFinalPrompt(res.data.promptTemplate);
setIsDone(true);
if (res.data.done && res.data.agentConfig) {
const parsed = parseAgentConfig(res.data.agentConfig);
if (parsed) {
setFinalConfig(parsed);
setIsDone(true);
}
}
} else {
setMessages(prev => [...prev, { role: 'assistant', content: 'Something went wrong. Please try again.' }]);
@@ -120,10 +164,10 @@ export function JourneyDialog({
<DialogHeader className="px-6 pt-5 pb-4 border-b shrink-0">
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
Customize AI prompt {agentName}
Configure agent {agentName}
</DialogTitle>
<DialogDescription>
Describe what you want your agent to look for we'll write a tailored prompt for it.
Describe what this agent should look for we'll produce a tailored extraction config.
</DialogDescription>
</DialogHeader>
@@ -155,11 +199,11 @@ export function JourneyDialog({
</div>
</ScrollArea>
{/* Final prompt preview */}
{finalPrompt && (
{/* Config preview */}
{finalConfig && (
<div className="mx-6 mb-3 rounded-xl bg-muted/50 border px-4 py-3">
<p className="text-xs font-medium text-muted-foreground mb-1">Your custom instructions</p>
<p className="text-xs leading-relaxed line-clamp-4">{finalPrompt}</p>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Extraction config ready</p>
<AgentConfigSummary config={finalConfig} />
</div>
)}
@@ -168,7 +212,7 @@ export function JourneyDialog({
{isDone ? (
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-xl bg-primary/5 border border-primary/20">
<CheckCircle2 className="size-4 text-primary shrink-0" />
<p className="text-sm text-muted-foreground">Prompt ready — click &ldquo;Save &amp; apply&rdquo; below.</p>
<p className="text-sm text-muted-foreground">Config ready — click &ldquo;Save &amp; apply&rdquo; below.</p>
</div>
) : (
<div className="flex gap-2">
@@ -190,8 +234,8 @@ export function JourneyDialog({
<DialogFooter className="px-6 pb-5 gap-2 shrink-0">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={() => onSaved(finalPrompt ?? '')}
disabled={!finalPrompt}
onClick={() => finalConfig && onSaved(finalConfig)}
disabled={!finalConfig}
>
Save &amp; apply
</Button>

View File

@@ -10,11 +10,13 @@ export function PromptBuilderChat({
dataTypes,
directory,
onPromptUpdate,
onConfigUpdate,
}: {
agentType: 'local_directory' | 'gmail' | 'teams' | 'outlook';
dataTypes: string[];
directory?: string;
onPromptUpdate?: (prompt: string) => void;
onConfigUpdate?: (config: Record<string, unknown>) => void;
}) {
const startMutation = trpc.agent.journey.start.useMutation();
const messageMutation = trpc.agent.journey.message.useMutation();
@@ -38,10 +40,14 @@ export function PromptBuilderChat({
setSessionId(res.data.sessionId);
const msgs: { role: 'user' | 'assistant'; content: string }[] = [];
if (res.data.message.trim()) msgs.push({ role: 'assistant', content: res.data.message });
if (res.data.done && res.data.promptTemplate) {
onPromptUpdate?.(res.data.promptTemplate);
if (res.data.done && res.data.agentConfig) {
try {
const parsed = JSON.parse(res.data.agentConfig) as Record<string, unknown>;
onConfigUpdate?.(parsed);
} catch { /* ignore parse errors */ }
onPromptUpdate?.(res.data.agentConfig);
setIsDone(true);
msgs.push({ role: 'assistant', content: 'Your extraction prompt has been saved.' });
msgs.push({ role: 'assistant', content: 'Your extraction config has been saved.' });
}
setMessages(msgs);
}
@@ -74,13 +80,17 @@ export function PromptBuilderChat({
{
onSuccess: (res) => {
if (res.data) {
if (res.data.done && res.data.promptTemplate) {
onPromptUpdate?.(res.data.promptTemplate);
if (res.data.done && res.data.agentConfig) {
try {
const parsed = JSON.parse(res.data.agentConfig) as Record<string, unknown>;
onConfigUpdate?.(parsed);
} catch { /* ignore parse errors */ }
onPromptUpdate?.(res.data.agentConfig);
setIsDone(true);
}
const toAdd: { role: 'user' | 'assistant'; content: string }[] = [];
if (res.data.message.trim()) toAdd.push({ role: 'assistant', content: res.data.message });
if (res.data.done) toAdd.push({ role: 'assistant', content: 'Your extraction prompt has been saved.' });
if (res.data.done) toAdd.push({ role: 'assistant', content: 'Your extraction config has been saved.' });
if (toAdd.length) setMessages(prev => [...prev, ...toAdd]);
} else {
setMessages(prev => [...prev, { role: 'assistant', content: 'Something went wrong. Please try again.' }]);

View File

@@ -31,7 +31,8 @@ export interface LocalAgentConfig {
name: string;
directory: string;
dataTypes: string[];
promptTemplate: string;
/** Structured extraction config produced by the Journey setup flow. */
agentConfig: Record<string, unknown> | null;
scheduleCron: string;
enabled: boolean;
lastRunAt: number | null;

View File

@@ -108,7 +108,7 @@ export const WsJourneyStartSchema = z.object({
agentType: z.enum(['local_directory', 'gmail', 'teams', 'outlook']),
directory: z.string().optional(),
dataTypes: z.array(z.string()),
existingTemplate: z.string().nullable().optional(),
existingConfig: z.string().nullable().optional(),
});
export type WsJourneyStart = z.infer<typeof WsJourneyStartSchema>;
@@ -205,7 +205,8 @@ export const WsJourneyReplySchema = z.object({
sessionId: z.string(),
message: z.string(),
done: z.boolean(),
promptTemplate: z.string().nullable().optional(),
/** Serialised AgentConfig JSON string produced by the journey when done=true. */
agentConfig: z.string().nullable().optional(),
});
export type WsJourneyReply = z.infer<typeof WsJourneyReplySchema>;
@@ -299,7 +300,7 @@ export const LocalAgentConfigSchema = z.object({
name: z.string(),
directoryPaths: z.array(z.string()),
dataTypes: z.array(z.string()),
promptTemplate: z.string(),
agentConfig: z.record(z.string(), z.unknown()).nullable(),
scheduleCron: z.string(),
enabled: z.boolean(),
lastRunAt: z.number().int().nullable().optional(),
@@ -345,7 +346,7 @@ export const JourneyMessageSchema = z.object({
sessionId: z.string(),
message: z.string(),
done: z.boolean(),
/** Present on the final message when `done === true`. */
promptTemplate: z.string().optional(),
/** Serialised AgentConfig JSON string — present on the final message when `done === true`. */
agentConfig: z.string().optional(),
});
export type JourneyMessage = z.infer<typeof JourneyMessageSchema>;