feat(local-llm): Phase G — projects + multi-model orchestration (G1-G7)

G1: Project CRUD in IndexedDB (already added in Phase F commit)
G2: Project sidebar section with create, pin, delete, and active selection
G3: Project system context injection (via project default model/agent/context)
G4: Cmd+P project switcher modal with keyboard navigation
G5: Chain orchestration — sequential multi-model pipeline with {prev} placeholder
G6: Race orchestration — parallel model competition with timing
G7: Vote orchestration — consensus synthesis from multiple model responses
This commit is contained in:
saravanakumardb1 2026-02-20 00:47:34 -08:00
parent 52f3d16b65
commit 6d98d12f04
4 changed files with 685 additions and 0 deletions

View File

@ -0,0 +1,206 @@
'use client';
import { useState } from 'react';
import { Plus, Trash2, X } from 'lucide-react';
import type { Orchestration, OrchestrationStep } from '../../lib/types';
interface OrchestrationEditorProps {
orchestration?: Orchestration;
models: string[];
onSave: (orch: Orchestration) => void;
onClose: () => void;
}
export function OrchestrationEditor({
orchestration,
models,
onSave,
onClose,
}: OrchestrationEditorProps) {
const [name, setName] = useState(orchestration?.name || '');
const [mode, setMode] = useState<Orchestration['mode']>(orchestration?.mode || 'chain');
const [description, setDescription] = useState(orchestration?.description || '');
const [synthesizer, setSynthesizer] = useState(orchestration?.synthesizer || '');
const [steps, setSteps] = useState<OrchestrationStep[]>(
orchestration?.steps || [{ model: models[0] || '' }]
);
const addStep = () => {
setSteps(prev => [...prev, { model: models[0] || '' }]);
};
const removeStep = (idx: number) => {
setSteps(prev => prev.filter((_, i) => i !== idx));
};
const updateStep = (idx: number, partial: Partial<OrchestrationStep>) => {
setSteps(prev => prev.map((s, i) => (i === idx ? { ...s, ...partial } : s)));
};
const handleSave = () => {
if (!name.trim() || steps.length === 0) return;
onSave({
id: orchestration?.id || crypto.randomUUID(),
name: name.trim(),
mode,
steps,
synthesizer: mode === 'vote' ? synthesizer : undefined,
description: description.trim() || undefined,
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-lg rounded-lg border border-white/10 bg-[var(--bg-elevated)] p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
{orchestration ? 'Edit Orchestration' : 'New Orchestration'}
</h2>
<button
type="button"
onClick={onClose}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
<X size={18} />
</button>
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
placeholder="My orchestration"
/>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Description</label>
<input
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
placeholder="What does this orchestration do?"
/>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Mode</label>
<div className="flex gap-2">
{(['chain', 'race', 'vote'] as const).map(m => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`rounded border px-3 py-1 text-xs capitalize ${mode === m ? 'border-[var(--accent-primary)] bg-[var(--accent-primary)]/20 text-[var(--accent-primary)]' : 'border-white/10 text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
>
{m}
</button>
))}
</div>
<p className="mt-1 text-[10px] text-[var(--text-tertiary)]">
{mode === 'chain' && 'Sequential: each step output feeds into the next via {prev}'}
{mode === 'race' && 'Parallel: same prompt to all models, pick the best'}
{mode === 'vote' &&
'Consensus: all models respond, synthesizer produces final answer'}
</p>
</div>
<div>
<div className="mb-1 flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">Steps ({steps.length})</label>
<button
type="button"
onClick={addStep}
className="inline-flex items-center gap-1 text-[10px] text-[var(--accent-primary)] hover:underline"
>
<Plus size={10} /> Add step
</button>
</div>
<div className="space-y-2">
{steps.map((step, idx) => (
<div
key={idx}
className="flex items-start gap-2 rounded border border-white/10 bg-[var(--surface-card)] p-2"
>
<span className="mt-1 text-[10px] text-[var(--text-tertiary)]">#{idx + 1}</span>
<div className="min-w-0 flex-1 space-y-1">
<select
value={step.model}
onChange={e => updateStep(idx, { model: e.target.value })}
className="w-full rounded border border-white/10 bg-[var(--bg-canvas)] px-2 py-1 text-xs text-[var(--text-primary)] outline-none"
>
<option value="">Select model</option>
{models.map(m => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
{mode === 'chain' && (
<input
value={step.systemPrompt || ''}
onChange={e => updateStep(idx, { systemPrompt: e.target.value })}
placeholder="System prompt (optional, use {prev} for previous output)"
className="w-full rounded border border-white/10 bg-[var(--bg-canvas)] px-2 py-1 text-[11px] text-[var(--text-secondary)] outline-none"
/>
)}
</div>
{steps.length > 1 && (
<button
type="button"
onClick={() => removeStep(idx)}
className="mt-1 text-[var(--text-tertiary)] hover:text-[var(--danger)]"
>
<Trash2 size={12} />
</button>
)}
</div>
))}
</div>
</div>
{mode === 'vote' && (
<div>
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
Synthesizer Model
</label>
<select
value={synthesizer}
onChange={e => setSynthesizer(e.target.value)}
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
>
<option value="">Select synthesizer</option>
{models.map(m => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</div>
)}
</div>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!name.trim() || steps.length === 0}
className="rounded bg-[var(--accent-primary)] px-4 py-1.5 text-sm text-white disabled:opacity-50"
>
Save
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,255 @@
'use client';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import type { Orchestration } from '../../lib/types';
import { MarkdownResponse } from '../../components/MarkdownResponse';
interface OrchestrationRunnerProps {
orchestration: Orchestration;
onClose: () => void;
}
interface StepResult {
model: string;
content: string;
durationMs: number;
done: boolean;
}
async function streamChat(
model: string,
messages: { role: string; content: string }[],
signal?: AbortSignal
): Promise<{ content: string; durationMs: number }> {
const start = Date.now();
const res = await fetch('/api/ollama/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages }),
signal,
});
if (!res.ok || !res.body) throw new Error('Stream failed');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let content = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const chunk = JSON.parse(line) as { message?: { content?: string } };
if (chunk.message?.content) content += chunk.message.content;
} catch {
/* skip */
}
}
}
return { content, durationMs: Date.now() - start };
}
export function OrchestrationRunner({ orchestration, onClose }: OrchestrationRunnerProps) {
const [prompt, setPrompt] = useState('');
const [running, setRunning] = useState(false);
const [results, setResults] = useState<StepResult[]>([]);
const [synthesis, setSynthesis] = useState('');
const runChain = async (userPrompt: string) => {
setRunning(true);
setResults([]);
setSynthesis('');
let prevOutput = userPrompt;
const allResults: StepResult[] = [];
for (const step of orchestration.steps) {
const systemContent = step.systemPrompt
? step.systemPrompt.replace(/\{prev\}/g, prevOutput)
: undefined;
const messages: { role: string; content: string }[] = [];
if (systemContent) messages.push({ role: 'system', content: systemContent });
messages.push({ role: 'user', content: prevOutput });
const { content, durationMs } = await streamChat(step.model, messages);
const stepResult: StepResult = { model: step.model, content, durationMs, done: true };
allResults.push(stepResult);
setResults([...allResults]);
prevOutput = content;
}
setRunning(false);
};
const runRace = async (userPrompt: string) => {
setRunning(true);
setResults([]);
setSynthesis('');
const promises = orchestration.steps.map(async step => {
const { content, durationMs } = await streamChat(step.model, [
{ role: 'user', content: userPrompt },
]);
return { model: step.model, content, durationMs, done: true } as StepResult;
});
const settled = await Promise.allSettled(promises);
const allResults = settled
.filter((r): r is PromiseFulfilledResult<StepResult> => r.status === 'fulfilled')
.map(r => r.value);
setResults(allResults);
setRunning(false);
};
const runVote = async (userPrompt: string) => {
setRunning(true);
setResults([]);
setSynthesis('');
const promises = orchestration.steps.map(async step => {
const { content, durationMs } = await streamChat(step.model, [
{ role: 'user', content: userPrompt },
]);
return { model: step.model, content, durationMs, done: true } as StepResult;
});
const settled = await Promise.allSettled(promises);
const allResults = settled
.filter((r): r is PromiseFulfilledResult<StepResult> => r.status === 'fulfilled')
.map(r => r.value);
setResults(allResults);
if (orchestration.synthesizer && allResults.length > 0) {
const synthPrompt = `You are a synthesizer. Multiple models responded to the user's question. Analyze their responses, find consensus, and highlight disagreements. Produce a final authoritative answer.
User question: ${userPrompt}
${allResults.map((r, i) => `--- Model ${i + 1} (${r.model}) ---\n${r.content}`).join('\n\n')}
Provide a synthesized consensus response:`;
const { content } = await streamChat(orchestration.synthesizer, [
{ role: 'user', content: synthPrompt },
]);
setSynthesis(content);
}
setRunning(false);
};
const handleRun = async () => {
if (!prompt.trim() || running) return;
if (orchestration.mode === 'chain') await runChain(prompt);
else if (orchestration.mode === 'race') await runRace(prompt);
else if (orchestration.mode === 'vote') await runVote(prompt);
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--bg-canvas)]">
<header className="flex items-center justify-between border-b border-white/10 bg-[var(--bg-elevated)] px-4 py-3">
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">{orchestration.name}</h2>
<p className="text-xs text-[var(--text-tertiary)]">
{orchestration.mode} · {orchestration.steps.length} models
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded border border-white/10 px-3 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Close
</button>
</header>
<div className="flex-1 overflow-auto p-4">
{synthesis && (
<div className="mb-4 rounded-lg border border-[var(--accent-secondary)]/30 bg-[var(--surface-card)] p-4">
<p className="mb-2 text-xs font-medium text-[var(--accent-secondary)]">
Synthesized Consensus
</p>
<MarkdownResponse content={synthesis} isThinkModel={false} />
</div>
)}
{results.length > 0 && (
<div
className={
orchestration.mode === 'race' || orchestration.mode === 'vote'
? 'grid gap-3 md:grid-cols-2'
: 'space-y-3'
}
>
{results.map((r, idx) => (
<div
key={idx}
className="rounded-lg border border-white/10 bg-[var(--surface-card)] p-3"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-[var(--text-primary)]">
{orchestration.mode === 'chain' ? `Step ${idx + 1}` : ''} {r.model}
</span>
<span className="text-[10px] text-[var(--text-tertiary)]">
{(r.durationMs / 1000).toFixed(1)}s
</span>
</div>
<MarkdownResponse content={r.content} isThinkModel={false} />
</div>
))}
</div>
)}
{results.length === 0 && !running && (
<p className="text-center text-sm text-[var(--text-tertiary)]">
Enter a prompt and run the orchestration.
</p>
)}
{running && (
<div className="flex items-center justify-center gap-2 py-8 text-[var(--accent-primary)]">
<Loader2 size={18} className="animate-spin" />
<span className="text-sm">Running {orchestration.mode}...</span>
</div>
)}
</div>
<div className="border-t border-white/10 bg-[var(--bg-elevated)] p-3">
<div className="flex gap-2">
<textarea
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void handleRun();
}
}}
rows={2}
disabled={running}
placeholder="Enter your prompt... (Cmd+Enter to run)"
className="min-h-[56px] flex-1 resize-y rounded-md border border-white/10 bg-[var(--surface-card)] px-3 py-2 text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
/>
<button
type="button"
onClick={() => void handleRun()}
disabled={running || !prompt.trim()}
className="self-end rounded bg-[var(--accent-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
>
Run
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { FolderPlus, Pin, Trash2 } from 'lucide-react';
import type { Project } from '../../lib/types';
import { addProject, deleteProject, updateProject } from '../../lib/db';
interface ProjectSidebarProps {
projects: Project[];
activeProjectId?: string;
onSelect: (projectId: string | undefined) => void;
onProjectsChanged: () => void;
}
export function ProjectSidebar({
projects,
activeProjectId,
onSelect,
onProjectsChanged,
}: ProjectSidebarProps) {
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const handleCreate = async () => {
if (!newName.trim()) return;
const project: Project = {
id: crypto.randomUUID(),
name: newName.trim(),
icon: '📁',
conversationIds: [],
pinned: false,
createdAt: Date.now(),
};
await addProject(project);
setNewName('');
setCreating(false);
onProjectsChanged();
onSelect(project.id);
};
const handlePin = async (id: string, pinned: boolean) => {
await updateProject(id, { pinned: !pinned });
onProjectsChanged();
};
const handleDelete = async (id: string) => {
await deleteProject(id);
if (activeProjectId === id) onSelect(undefined);
onProjectsChanged();
};
return (
<div className="border-t border-white/10 px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
Projects
</span>
<button
type="button"
onClick={() => setCreating(true)}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
title="New project"
>
<FolderPlus size={13} />
</button>
</div>
{creating && (
<div className="mb-1 flex gap-1">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void handleCreate();
if (e.key === 'Escape') setCreating(false);
}}
autoFocus
className="min-w-0 flex-1 rounded border border-white/10 bg-[var(--surface-card)] px-2 py-0.5 text-xs text-[var(--text-primary)] outline-none"
placeholder="Project name"
/>
</div>
)}
<button
type="button"
onClick={() => onSelect(undefined)}
className={`mb-0.5 block w-full rounded px-2 py-1 text-left text-xs ${!activeProjectId ? 'bg-white/5 text-[var(--accent-primary)]' : 'text-[var(--text-secondary)] hover:bg-white/5'}`}
>
All Conversations
</button>
{projects.map(p => (
<div
key={p.id}
className={`group mb-0.5 flex items-center gap-1 rounded px-2 py-1 text-xs ${activeProjectId === p.id ? 'bg-white/5 text-[var(--accent-primary)]' : 'text-[var(--text-secondary)] hover:bg-white/5'}`}
>
<button
type="button"
onClick={() => onSelect(p.id)}
className="flex min-w-0 flex-1 items-center gap-1 text-left"
>
<span>{p.icon}</span>
<span className="truncate">{p.name}</span>
</button>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100">
<button
type="button"
onClick={() => void handlePin(p.id, p.pinned)}
className={
p.pinned
? 'text-[var(--accent-secondary)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
}
title={p.pinned ? 'Unpin' : 'Pin'}
>
<Pin size={10} />
</button>
<button
type="button"
onClick={() => void handleDelete(p.id)}
className="text-[var(--text-tertiary)] hover:text-[var(--danger)]"
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,93 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { Project } from '../../lib/types';
interface ProjectSwitcherProps {
open: boolean;
projects: Project[];
onSelect: (projectId: string | undefined) => void;
onClose: () => void;
}
export function ProjectSwitcher({ open, projects, onSelect, onClose }: ProjectSwitcherProps) {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (open) {
setQuery('');
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [open]);
if (!open) return null;
const allOption = { id: '', name: 'All Conversations', icon: '📂' };
const items = [allOption, ...projects].filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
const handleSelect = (id: string) => {
onSelect(id || undefined);
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && items[selectedIndex]) {
handleSelect(items[selectedIndex].id);
} else if (e.key === 'Escape') {
onClose();
}
};
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] bg-black/50"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-lg border border-white/10 bg-[var(--bg-elevated)] shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="border-b border-white/10 p-3">
<input
ref={inputRef}
value={query}
onChange={e => {
setQuery(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder="Switch project..."
className="w-full bg-transparent text-sm text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
/>
</div>
<div className="max-h-64 overflow-auto p-1">
{items.map((item, idx) => (
<button
key={item.id || '__all'}
type="button"
onClick={() => handleSelect(item.id)}
className={`flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm ${idx === selectedIndex ? 'bg-white/10 text-[var(--text-primary)]' : 'text-[var(--text-secondary)] hover:bg-white/5'}`}
>
<span>{item.icon}</span>
<span>{item.name}</span>
</button>
))}
{items.length === 0 && (
<p className="px-3 py-2 text-xs text-[var(--text-tertiary)]">No projects found</p>
)}
</div>
</div>
</div>
);
}