feat(FE): Prompt Chain Builder — multi-step AI pipelines with variable injection and output chaining
This commit is contained in:
parent
979e41845b
commit
3d5ca76b54
1 changed files with 281 additions and 0 deletions
281
src/app/dashboard/prompt-chains/page.tsx
Normal file
281
src/app/dashboard/prompt-chains/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue