feat(FE): Prompt Chain Builder — multi-step AI pipelines with variable injection and output chaining

This commit is contained in:
admin-valentin 2026-08-26 19:12:34 +00:00
parent 979e41845b
commit 3d5ca76b54

View file

@ -0,0 +1,281 @@
'use client';
import { useState, useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface ChainStep {
id: string; name: string; prompt: string;
useOutputOf?: string; // step id whose output to inject as {prev_output}
model?: string;
}
interface Chain {
id?: string; name: string; description: string;
steps: ChainStep[]; tags: string[];
}
interface StepResult { stepId: string; name: string; output: string; elapsed: number; }
const TEMPLATES: Chain[] = [
{
name: 'Research → Summary → Action Plan',
description: 'Cercetare profundă → rezumat executiv → plan acționabil',
tags: ['research', 'strategy'],
steps: [
{ id: 's1', name: 'Research', prompt: 'Cercetează în detaliu: {topic}\nFurnizează fapte, date, surse și context.' },
{ id: 's2', name: 'Executive Summary', prompt: 'Rezumă în maxim 200 cuvinte pentru un CEO:\n\n{prev_output}', useOutputOf: 's1' },
{ id: 's3', name: 'Action Plan', prompt: 'Pe baza rezumatului următor, creează un plan de acțiune în 5 pași cu deadline-uri:\n\n{prev_output}', useOutputOf: 's2' },
],
},
{
name: 'Pitch Deck Generator',
description: 'Din descriere companie → toate slide-urile pitch deck',
tags: ['fundraising', 'pitch'],
steps: [
{ id: 'p1', name: 'Problem & Solution', prompt: 'Pentru această companie: {company_description}\nGenerează Slide 1 (Problem) și Slide 2 (Solution) pentru pitch deck. Format: titlu + 3 bullet points per slide.' },
{ id: 'p2', name: 'Market & Business Model', prompt: 'Pe baza:\n{prev_output}\n\nGenerează Slide 3 (Market Size: TAM/SAM/SOM) și Slide 4 (Business Model). Include cifre estimate.', useOutputOf: 'p1' },
{ id: 'p3', name: 'Team & The Ask', prompt: 'Pe baza pitch deck-ului de până acum:\n{prev_output}\n\nGenerează Slide 5 (Team - placeholder) și Slide 6 (The Ask - ce și cât se ridică). Format consistent.', useOutputOf: 'p2' },
],
},
{
name: 'Trade Analysis Chain',
description: 'Analiza unui produs: piață → concurență → oportunitate import/export',
tags: ['trade', 'analysis'],
steps: [
{ id: 't1', name: 'Product Market Analysis', prompt: 'Analizează piața globală pentru: {product}\nInclude: dimensiune piață, top țări producătoare/consumatoare, tendințe 2024-2026.' },
{ id: 't2', name: 'Competition & Trade Routes', prompt: 'Pe baza analizei:\n{prev_output}\n\nIdentifică: principalele rute comerciale, competitori cheie, bariere tarifare/non-tarifare, oportunități arbitraj.', useOutputOf: 't1' },
{ id: 't3', name: 'Opportunity Report', prompt: 'Pe baza analizei complete:\n{prev_output}\n\nGenerează un raport de oportunitate: recomandare go/no-go, piețe țintă, strategie intrare, risc estimat.', useOutputOf: 't2' },
],
},
];
function uid() { return Math.random().toString(36).slice(2, 9); }
export default function PromptChainPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [chain, setChain] = useState<Chain>({ name: 'Lanț nou', description: '', steps: [], tags: [] });
const [variables, setVariables] = useState<Record<string, string>>({});
const [results, setResults] = useState<StepResult[]>([]);
const [runningStep, setRunningStep] = useState<string | null>(null);
const [showTemplates, setShowTemplates] = useState(true);
// Save chain as observation
const saveMut = useMutation({
mutationFn: () => apiFetch('/v1/observations', {
tenantId, method: 'POST',
body: {
metric: 'prompt-chain',
value: chain.name,
subjectType: 'chain',
unit: `${chain.steps.length} steps`,
confidence: 1,
source: JSON.stringify({ chain }),
},
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chains', tenantId] }),
});
// Saved chains
const { data: savedObs = [] } = useQuery({
queryKey: ['chains', tenantId],
queryFn: () => apiFetch<any[]>('/v1/observations', { tenantId }),
select: (d: any[]) => d.filter(o => o.metric === 'prompt-chain'),
enabled: Boolean(tenantId),
});
// Run single step
const runStep = useCallback(async (step: ChainStep, allResults: StepResult[], vars: Record<string, string>) => {
setRunningStep(step.id);
const t0 = Date.now();
let prompt = step.prompt;
// Inject variables
for (const [k, v] of Object.entries(vars)) {
prompt = prompt.replace(new RegExp(`\\{${k}\\}`, 'g'), v);
}
// Inject prev_output
if (step.useOutputOf) {
const prev = allResults.find(r => r.stepId === step.useOutputOf);
if (prev) prompt = prompt.replace(/\{prev_output\}/g, prev.output);
}
try {
const r = await apiFetch<{ reply: string }>('/v1/ai/ask', {
tenantId, method: 'POST',
body: {
messages: [{ role: 'user', content: prompt }],
context: JSON.stringify({ chain: chain.name, step: step.name }),
},
});
return { stepId: step.id, name: step.name, output: r.reply, elapsed: Date.now() - t0 };
} catch (err: any) {
return { stepId: step.id, name: step.name, output: `Error: ${err.message}`, elapsed: Date.now() - t0 };
} finally {
setRunningStep(null);
}
}, [tenantId, chain.name]);
async function runAll() {
setResults([]);
const accumulated: StepResult[] = [];
for (const step of chain.steps) {
const result = await runStep(step, accumulated, variables);
accumulated.push(result);
setResults([...accumulated]);
}
}
function loadTemplate(t: Chain) {
setChain({ ...t, id: undefined });
// Detect variables in steps
const vars: Record<string, string> = {};
for (const step of t.steps) {
const matches = step.prompt.matchAll(/\{([^}]+)\}/g);
for (const m of matches) {
if (!['prev_output'].includes(m[1])) vars[m[1]] = '';
}
}
setVariables(vars);
setShowTemplates(false);
setResults([]);
}
function addStep() {
setChain(c => ({ ...c, steps: [...c.steps, { id: uid(), name: `Step ${c.steps.length + 1}`, prompt: '' }] }));
}
function updateStep(id: string, patch: Partial<ChainStep>) {
setChain(c => ({ ...c, steps: c.steps.map(s => s.id === id ? { ...s, ...patch } : s) }));
}
function removeStep(id: string) {
setChain(c => ({ ...c, steps: c.steps.filter(s => s.id !== id) }));
}
const detectedVars = [...new Set(chain.steps.flatMap(s => [...s.prompt.matchAll(/\{([^}]+)\}/g)].map(m => m[1])))].filter(v => v !== 'prev_output');
return (
<div className="max-w-5xl space-y-6 p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Prompt Chain Builder</h1>
<p className="text-sm text-ink-faint mt-1">Construiește și rulează lanțuri de prompts output-ul unui step alimentează următorul</p>
</div>
<button onClick={() => setShowTemplates(t => !t)}
className="rounded-lg border px-3 py-1.5 text-sm text-ink hover:bg-muted/50">
{showTemplates ? 'Ascunde' : 'Template-uri'}
</button>
</div>
{/* Templates */}
{showTemplates && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{TEMPLATES.map(t => (
<button key={t.name} onClick={() => loadTemplate(t)}
className="card p-4 text-left space-y-2 hover:border-primary/40 transition-colors">
<p className="text-sm font-semibold text-ink">{t.name}</p>
<p className="text-xs text-ink-faint">{t.description}</p>
<p className="text-[10px] text-ink-faint">{t.steps.length} pași</p>
</button>
))}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Builder */}
<div className="space-y-4">
<div>
<input value={chain.name} onChange={e => setChain(c => ({...c, name: e.target.value}))}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm font-semibold text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
{/* Variables */}
{detectedVars.length > 0 && (
<div className="card p-3 space-y-2 border-amber-200 bg-amber-50">
<p className="text-xs font-semibold text-amber-800">Variabile detectate</p>
{detectedVars.map(v => (
<div key={v} className="flex items-center gap-2">
<code className="text-[10px] font-mono text-amber-700 w-24 shrink-0">{`{${v}}`}</code>
<input value={variables[v] ?? ''} onChange={e => setVariables(vs => ({...vs, [v]: e.target.value}))}
placeholder={`Valoare pentru ${v}`}
className="flex-1 rounded border bg-white px-2 py-1 text-xs text-ink focus:outline-none focus:ring-1 focus:ring-amber-400" />
</div>
))}
</div>
)}
{/* Steps */}
<div className="space-y-3">
{chain.steps.map((step, i) => (
<div key={step.id} className="card p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold shrink-0">{i+1}</span>
<input value={step.name} onChange={e => updateStep(step.id, {name: e.target.value})}
className="flex-1 rounded border bg-background px-2 py-1 text-xs font-medium text-ink focus:outline-none focus:ring-1 focus:ring-ring" />
<button onClick={() => removeStep(step.id)} className="text-ink-faint hover:text-signal-danger text-xs"></button>
</div>
<textarea value={step.prompt} onChange={e => updateStep(step.id, {prompt: e.target.value})}
rows={3} placeholder="Prompt… usa {variabila} sau {prev_output}"
className="w-full rounded border bg-background px-2 py-1 text-xs text-ink focus:outline-none focus:ring-1 focus:ring-ring resize-none font-mono" />
{i > 0 && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-ink-faint">Injectează output de la:</span>
<select value={step.useOutputOf ?? ''} onChange={e => updateStep(step.id, {useOutputOf: e.target.value || undefined})}
className="rounded border bg-background px-2 py-0.5 text-[10px] text-ink focus:outline-none">
<option value=""> niciun </option>
{chain.steps.slice(0, i).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
</div>
))}
<button onClick={addStep} className="w-full rounded-xl border border-dashed py-2.5 text-sm text-ink-faint hover:border-primary/40 hover:text-primary">
+ Adaugă step
</button>
</div>
<div className="flex gap-2">
<button onClick={runAll} disabled={chain.steps.length === 0 || !!runningStep}
className="flex-1 rounded-xl bg-primary py-2.5 text-sm font-semibold text-white disabled:opacity-50">
{runningStep ? `✦ Rulează: ${chain.steps.find(s => s.id === runningStep)?.name}` : '▶ Rulează tot lanțul'}
</button>
<button onClick={() => saveMut.mutate()} disabled={!chain.name || chain.steps.length === 0}
className="rounded-xl border px-4 py-2.5 text-sm text-ink hover:bg-muted/50 disabled:opacity-50">
💾
</button>
</div>
</div>
{/* Results */}
<div className="space-y-3">
{results.length === 0 ? (
<div className="card p-8 text-center">
<p className="text-sm text-ink-faint">Rezultatele vor apărea aici după rulare.</p>
<p className="text-xs text-ink-faint mt-2">Fiecare step poate folosi output-ul stepului anterior via {'{prev_output}'}.</p>
</div>
) : results.map(r => (
<div key={r.stepId} className={`card p-4 space-y-2 ${runningStep === r.stepId ? 'border-primary/40 bg-primary/5 animate-pulse' : ''}`}>
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-ink">{r.name}</p>
<span className="text-[10px] text-ink-faint">{r.elapsed}ms</span>
</div>
<p className="text-xs text-ink whitespace-pre-line leading-relaxed max-h-48 overflow-y-auto">{r.output}</p>
<button onClick={() => navigator.clipboard.writeText(r.output)}
className="text-[10px] text-ink-faint hover:text-ink">📋 Copiază</button>
</div>
))}
{runningStep && results.length < chain.steps.length && (
<div className="card p-4 border-primary/20 bg-primary/5 animate-pulse">
<p className="text-xs text-primary"> {chain.steps.find(s => s.id === runningStep)?.name} în execuție</p>
</div>
)}
</div>
</div>
</div>
);
}