From 6d98d12f049e9f95148f2f1fa15b8f5baa958ebf Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 20 Feb 2026 00:47:34 -0800 Subject: [PATCH] =?UTF-8?q?feat(local-llm):=20Phase=20G=20=E2=80=94=20proj?= =?UTF-8?q?ects=20+=20multi-model=20orchestration=20(G1-G7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/OrchestrationEditor.tsx | 206 ++++++++++++++ .../components/OrchestrationRunner.tsx | 255 ++++++++++++++++++ .../(workspace)/components/ProjectSidebar.tsx | 131 +++++++++ .../components/ProjectSwitcher.tsx | 93 +++++++ 4 files changed, 685 insertions(+) create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/OrchestrationEditor.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/OrchestrationRunner.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/ProjectSidebar.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/ProjectSwitcher.tsx diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/OrchestrationEditor.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/OrchestrationEditor.tsx new file mode 100644 index 00000000..25e0319f --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/OrchestrationEditor.tsx @@ -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 || 'chain'); + const [description, setDescription] = useState(orchestration?.description || ''); + const [synthesizer, setSynthesizer] = useState(orchestration?.synthesizer || ''); + const [steps, setSteps] = useState( + 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) => { + 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 ( +
+
+
+

+ {orchestration ? 'Edit Orchestration' : 'New Orchestration'} +

+ +
+ +
+
+ + 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" + /> +
+ +
+ + 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?" + /> +
+ +
+ +
+ {(['chain', 'race', 'vote'] as const).map(m => ( + + ))} +
+

+ {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'} +

+
+ +
+
+ + +
+
+ {steps.map((step, idx) => ( +
+ #{idx + 1} +
+ + {mode === 'chain' && ( + 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" + /> + )} +
+ {steps.length > 1 && ( + + )} +
+ ))} +
+
+ + {mode === 'vote' && ( +
+ + +
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/OrchestrationRunner.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/OrchestrationRunner.tsx new file mode 100644 index 00000000..af22d360 --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/OrchestrationRunner.tsx @@ -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([]); + 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 => 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 => 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 ( +
+
+
+

{orchestration.name}

+

+ {orchestration.mode} ยท {orchestration.steps.length} models +

+
+ +
+ +
+ {synthesis && ( +
+

+ Synthesized Consensus +

+ +
+ )} + + {results.length > 0 && ( +
+ {results.map((r, idx) => ( +
+
+ + {orchestration.mode === 'chain' ? `Step ${idx + 1}` : ''} {r.model} + + + {(r.durationMs / 1000).toFixed(1)}s + +
+ +
+ ))} +
+ )} + + {results.length === 0 && !running && ( +

+ Enter a prompt and run the orchestration. +

+ )} + + {running && ( +
+ + Running {orchestration.mode}... +
+ )} +
+ +
+
+