feat(local-llm): Phase A foundation (A1-A8) workspace + indexeddb

- add idb dependency and create typed db layer (conversations/messages/agents/etc)
- extend app/lib/types.ts with v4 workspace interfaces
- move existing dashboard to /mission-control route group
- create / workspace route group with sidebar shell and conversation pages
- implement conversation list grouping + search in sidebar
- implement conversation view with streaming via /api/ollama/chat
- add context bar and token/context utilities
- add /api/ollama/title endpoint for auto-title generation
- add v3->v4 migration utility (llm-inference-log + llm-chat-* to indexeddb)
- wire migration in workspace layout and cmd+/ sidebar toggle

Implements roadmap Phase A tasks A1-A8.
This commit is contained in:
saravanakumardb1 2026-02-20 00:11:27 -08:00
parent d7dc66eb92
commit e17bb311c9
20 changed files with 9174 additions and 914 deletions

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"idb": "^8.0.3",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"react": "19.2.3",

View File

@ -0,0 +1,213 @@
'use client';
import React, { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface MarkdownResponseProps {
content: string;
isThinkModel?: boolean;
streaming?: boolean;
}
// F27: Split <think>...</think> blocks from the main response
function splitThinkBlocks(text: string): { thinking: string | null; answer: string } {
const thinkMatch = text.match(/^<think>([\s\S]*?)<\/think>\s*/);
if (thinkMatch) {
return {
thinking: thinkMatch[1].trim(),
answer: text.slice(thinkMatch[0].length).trim(),
};
}
// Partial think block (still streaming)
if (text.startsWith('<think>') && !text.includes('</think>')) {
return {
thinking: text.slice(7).trim(),
answer: '',
};
}
return { thinking: null, answer: text };
}
export function MarkdownResponse({ content, isThinkModel, streaming }: MarkdownResponseProps) {
const { thinking, answer } = useMemo(() => {
if (!isThinkModel) return { thinking: null, answer: content };
return splitThinkBlocks(content);
}, [content, isThinkModel]);
return (
<div className="markdown-response">
{/* F27: Collapsible think block */}
{thinking && (
<details className="mb-3" open={!answer && streaming}>
<summary
className="cursor-pointer text-[11px] font-medium px-2 py-1 rounded"
style={{ color: 'var(--warning)', background: 'rgba(245, 158, 11, 0.08)' }}
>
Show reasoning ({thinking.split(/\s+/).length} words)
</summary>
<pre
className="mt-1 p-3 rounded text-xs whitespace-pre-wrap overflow-auto max-h-40"
style={{
background: 'var(--bg-canvas)',
color: 'var(--text-tertiary)',
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
}}
>
{thinking}
</pre>
</details>
)}
{/* F25/F26: Markdown with syntax highlighted code blocks */}
<div className="prose-dark">
<ReactMarkdown
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const codeString = String(children).replace(/\n$/, '');
if (match) {
return (
<div className="relative my-2">
<div
className="flex items-center justify-between px-3 py-1 rounded-t text-[10px] font-mono"
style={{ background: 'rgba(0,0,0,0.3)', color: 'var(--text-tertiary)' }}
>
<span>{match[1]}</span>
</div>
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
borderRadius: '0 0 0.375rem 0.375rem',
fontSize: '12px',
maxHeight: '300px',
}}
>
{codeString}
</SyntaxHighlighter>
</div>
);
}
return (
<code
className="px-1.5 py-0.5 rounded text-[12px] font-mono"
style={{ background: 'var(--surface-muted)', color: 'var(--accent-secondary)' }}
{...props}
>
{children}
</code>
);
},
p({ children }) {
return (
<p
className="mb-2 last:mb-0"
style={{ color: 'var(--text-secondary)', lineHeight: '1.6' }}
>
{children}
</p>
);
},
h1({ children }) {
return (
<h1
className="text-lg font-bold mt-3 mb-1"
style={{ color: 'var(--text-primary)' }}
>
{children}
</h1>
);
},
h2({ children }) {
return (
<h2
className="text-base font-bold mt-3 mb-1"
style={{ color: 'var(--text-primary)' }}
>
{children}
</h2>
);
},
h3({ children }) {
return (
<h3
className="text-sm font-bold mt-2 mb-1"
style={{ color: 'var(--text-primary)' }}
>
{children}
</h3>
);
},
ul({ children }) {
return (
<ul
className="list-disc list-inside mb-2 space-y-0.5"
style={{ color: 'var(--text-secondary)' }}
>
{children}
</ul>
);
},
ol({ children }) {
return (
<ol
className="list-decimal list-inside mb-2 space-y-0.5"
style={{ color: 'var(--text-secondary)' }}
>
{children}
</ol>
);
},
li({ children }) {
return <li className="text-sm">{children}</li>;
},
blockquote({ children }) {
return (
<blockquote
className="border-l-2 pl-3 my-2"
style={{ borderColor: 'var(--accent-primary)', color: 'var(--text-tertiary)' }}
>
{children}
</blockquote>
);
},
strong({ children }) {
return (
<strong className="font-bold" style={{ color: 'var(--text-primary)' }}>
{children}
</strong>
);
},
em({ children }) {
return <em style={{ color: 'var(--text-secondary)' }}>{children}</em>;
},
a({ href, children }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: 'var(--accent-primary)' }}
>
{children}
</a>
);
},
}}
>
{answer || (streaming ? '' : '(empty response)')}
</ReactMarkdown>
</div>
{streaming && (
<span
className="inline-block w-2 h-4 ml-0.5 animate-pulse"
style={{ background: 'var(--accent-primary)' }}
/>
)}
</div>
);
}

View File

@ -0,0 +1,105 @@
'use client';
import { formatBytes } from '../../../lib/format';
interface RunningModelSegment {
name: string;
size_vram: number;
}
interface RamBudgetBarProps {
totalRam: number;
appMemory: number;
runningModels: RunningModelSegment[];
freeRam: number;
}
const MODEL_COLORS = [
'var(--success)',
'var(--accent-secondary)',
'var(--accent-primary)',
'var(--purple)',
'var(--warning)',
];
export function RamBudgetBar({ totalRam, appMemory, runningModels, freeRam }: RamBudgetBarProps) {
if (totalRam <= 0) return null;
const modelTotal = runningModels.reduce((sum, m) => sum + m.size_vram, 0);
const osApps = Math.max(0, totalRam - modelTotal - freeRam);
const pct = (bytes: number) => Math.max(0.5, (bytes / totalRam) * 100);
return (
<div className="mb-4">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-medium" style={{ color: 'var(--text-secondary)' }}>
Memory Budget
</span>
<span className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>
{formatBytes(totalRam)} unified
</span>
</div>
<div
className="flex w-full h-5 rounded-md overflow-hidden"
style={{ background: 'var(--surface-muted)' }}
>
{/* OS + Apps */}
<div
className="h-full flex items-center justify-center text-[9px] font-medium shrink-0 overflow-hidden"
style={{
width: `${pct(osApps)}%`,
background: 'var(--text-tertiary)',
color: 'var(--bg-canvas)',
opacity: 0.5,
}}
title={`OS + Apps: ${formatBytes(osApps)}`}
>
{pct(osApps) > 8 ? 'OS' : ''}
</div>
{/* Loaded models */}
{runningModels.map((m, i) => (
<div
key={m.name}
className="h-full flex items-center justify-center text-[9px] font-mono font-medium shrink-0 overflow-hidden"
style={{
width: `${pct(m.size_vram)}%`,
background: MODEL_COLORS[i % MODEL_COLORS.length],
color: 'var(--bg-canvas)',
opacity: 0.85,
}}
title={`${m.name}: ${formatBytes(m.size_vram)}`}
>
{pct(m.size_vram) > 10 ? m.name.split(':')[0] : ''}
</div>
))}
{/* Free */}
<div
className="h-full flex-1 flex items-center justify-center text-[9px] font-medium overflow-hidden"
style={{
color: 'var(--text-tertiary)',
opacity: 0.6,
}}
title={`Free: ${formatBytes(freeRam)}`}
>
{pct(freeRam) > 8 ? `${formatBytes(freeRam)} free` : ''}
</div>
</div>
{runningModels.length > 0 && (
<div className="flex items-center gap-3 mt-1 flex-wrap">
{runningModels.map((m, i) => (
<span
key={m.name}
className="flex items-center gap-1 text-[10px]"
style={{ color: 'var(--text-tertiary)' }}
>
<span
className="w-2 h-2 rounded-sm inline-block"
style={{ background: MODEL_COLORS[i % MODEL_COLORS.length], opacity: 0.85 }}
/>
{m.name.split(':')[0]}: {formatBytes(m.size_vram)}
</span>
))}
</div>
)}
</div>
);
}

View File

@ -44,17 +44,17 @@ import type {
Toast,
PullProgress,
StreamMetrics,
} from './lib/types';
} from '../../lib/types';
import {
formatBytes,
formatUptime,
estimateRam,
checkMemoryFit,
getModelBadges,
} from './lib/format';
import { StatusDot } from './components/StatusDot';
import { ProgressBar } from './components/ProgressBar';
import { Sparkline } from './components/Sparkline';
} from '../../lib/format';
import { StatusDot } from '../../components/StatusDot';
import { ProgressBar } from '../../components/ProgressBar';
import { Sparkline } from '../../components/Sparkline';
import { RamBudgetBar } from './components/RamBudgetBar';
import { MarkdownResponse } from './components/MarkdownResponse';

View File

@ -0,0 +1,51 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { getConversation, getMessages } from '../../../lib/db';
import type { Conversation, Message } from '../../../lib/types';
import { ConversationView } from '../../components/ConversationView';
export default function ConversationPage({ params }: { params: { id: string } }) {
const [conversation, setConversation] = useState<Conversation | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
const row = await getConversation(params.id);
if (!row) {
setLoading(false);
return;
}
const msgRows = await getMessages(params.id, { limit: 200 });
setConversation(row);
setMessages(msgRows);
setLoading(false);
};
void load();
}, [params.id]);
if (loading) {
return <div className="p-6 text-sm text-[var(--text-secondary)]">Loading conversation...</div>;
}
if (!conversation) {
return (
<div className="p-6">
<h1 className="text-lg font-semibold text-[var(--text-primary)]">Conversation not found</h1>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
The conversation may have been deleted.
</p>
<Link
href="/"
className="mt-4 inline-block text-sm text-[var(--accent-primary)] hover:underline"
>
Back to workspace
</Link>
</div>
);
}
return <ConversationView conversation={conversation} initialMessages={messages} />;
}

View File

@ -0,0 +1,36 @@
'use client';
interface ContextBarProps {
usedTokens: number;
maxTokens: number;
}
function fmt(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return `${n}`;
}
export function ContextBar({ usedTokens, maxTokens }: ContextBarProps) {
const safeMax = Math.max(1, maxTokens);
const ratio = Math.min(1, usedTokens / safeMax);
const pct = Math.round(ratio * 100);
let color = 'var(--success)';
if (pct >= 95) color = 'var(--danger)';
else if (pct >= 80) color = 'var(--warning)';
else if (pct >= 60) color = 'var(--accent-secondary)';
return (
<div className="mt-2">
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="text-[var(--text-tertiary)]">Context</span>
<span className="text-[var(--text-secondary)]">
{fmt(usedTokens)} / {fmt(maxTokens)} tokens
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded bg-white/10">
<div className="h-full" style={{ width: `${pct}%`, background: color }} />
</div>
</div>
);
}

View File

@ -0,0 +1,85 @@
'use client';
import type { Conversation } from '../../lib/types';
interface ConversationListProps {
conversations: Conversation[];
activeId?: string;
onSelect: (id: string) => void;
}
function startOfDay(ts: number): number {
const d = new Date(ts);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function formatGroup(ts: number, now: number): string {
const today = startOfDay(now);
const day = startOfDay(ts);
const diffDays = Math.floor((today - day) / 86400000);
if (diffDays <= 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays <= 7) return 'Last 7 Days';
if (diffDays <= 30) return 'Last 30 Days';
return 'Older';
}
function formatTime(ts: number): string {
const date = new Date(ts);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function ConversationList({ conversations, activeId, onSelect }: ConversationListProps) {
const now = Date.now();
const groups = new Map<string, Conversation[]>();
const sorted = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt);
for (const conv of sorted) {
const key = formatGroup(conv.updatedAt, now);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(conv);
}
if (conversations.length === 0) {
return <p className="px-2 py-1 text-xs text-[var(--text-tertiary)]">No conversations yet</p>;
}
return (
<div className="space-y-3">
{[...groups.entries()].map(([group, items]) => (
<section key={group}>
<h3 className="mb-1 px-2 text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">
{group}
</h3>
<div className="space-y-1">
{items.map(conv => {
const active = conv.id === activeId;
return (
<button
key={conv.id}
type="button"
onClick={() => onSelect(conv.id)}
className="w-full rounded px-2 py-1 text-left hover:bg-white/5"
style={{
borderLeft: active
? '2px solid var(--accent-primary)'
: '2px solid transparent',
background: active ? 'rgba(90, 140, 255, 0.1)' : undefined,
}}
title={conv.title}
>
<p className="truncate text-xs text-[var(--text-primary)]">{conv.title}</p>
<p className="mt-0.5 text-[10px] text-[var(--text-tertiary)]">
{conv.model || 'No model'} · {conv.messageCount} msgs ·{' '}
{formatTime(conv.updatedAt)}
</p>
</button>
);
})}
</div>
</section>
))}
</div>
);
}

View File

@ -0,0 +1,275 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import type { Conversation, Message, OllamaData } from '../../lib/types';
import { addMessage, updateConversation } from '../../lib/db';
import { InputBar } from './InputBar';
import { MessageThread } from './MessageThread';
import { ContextBar } from './ContextBar';
import { estimateTokens, getModelContextWindow } from '../../lib/format';
interface ConversationViewProps {
conversation: Conversation;
initialMessages: Message[];
onConversationUpdated?: (next: Conversation) => void;
}
export function ConversationView({
conversation,
initialMessages,
onConversationUpdated,
}: ConversationViewProps) {
const [title, setTitle] = useState(conversation.title);
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [streaming, setStreaming] = useState(false);
const [selectedModel, setSelectedModel] = useState(conversation.model);
const [models, setModels] = useState<string[]>([]);
const [showModels, setShowModels] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const usedTokens = useMemo(() => {
return messages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
}, [messages]);
const maxTokens = getModelContextWindow(selectedModel);
const ensureModels = async () => {
if (models.length > 0) return;
const res = await fetch('/api/ollama');
if (!res.ok) return;
const data = (await res.json()) as OllamaData;
setModels(data.models.map(m => m.name));
};
const persistConversationMeta = async (nextMessages: Message[]) => {
const now = Date.now();
const tokenTotal = nextMessages.reduce((sum, m) => sum + (m.metrics?.totalTokens ?? 0), 0);
const next = await updateConversation(conversation.id, {
model: selectedModel,
updatedAt: now,
messageCount: nextMessages.length,
metadata: {
...conversation.metadata,
totalTokens: tokenTotal,
totalPrompts: nextMessages.filter(m => m.role === 'user').length,
models: Array.from(
new Set(nextMessages.map(m => m.model).filter((m): m is string => Boolean(m)))
),
},
});
if (next && onConversationUpdated) onConversationUpdated(next);
};
const maybeAutoTitle = async (firstUserMessage: string) => {
if (title !== 'New Conversation' || !firstUserMessage.trim()) return;
try {
const res = await fetch('/api/ollama/title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: firstUserMessage, model: selectedModel }),
});
const data = (await res.json()) as { title?: string };
const nextTitle = (data.title || 'New Conversation').trim();
if (!nextTitle) return;
setTitle(nextTitle);
await updateConversation(conversation.id, { title: nextTitle, updatedAt: Date.now() });
} catch {
// Keep default title on failure
}
};
const onSend = async (text: string) => {
const now = Date.now();
const userMessage: Message = {
id: crypto.randomUUID(),
conversationId: conversation.id,
role: 'user',
content: text,
timestamp: now,
model: selectedModel,
};
await addMessage(userMessage);
setMessages(prev => [...prev, userMessage]);
const controller = new AbortController();
abortRef.current = controller;
setStreaming(true);
const chatPayload = [...messages, userMessage].map(m => ({
role: m.role,
content: m.content,
}));
let assistantContent = '';
const assistantId = crypto.randomUUID();
try {
const res = await fetch('/api/ollama/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: selectedModel, messages: chatPayload }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
throw new Error('Failed to stream response');
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let totalTokens = 0;
let durationMs = 0;
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;
const chunk = JSON.parse(line) as {
message?: { content?: string };
done?: boolean;
eval_count?: number;
eval_duration?: number;
};
if (chunk.message?.content) {
assistantContent += chunk.message.content;
const tempAssistant: Message = {
id: assistantId,
conversationId: conversation.id,
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
model: selectedModel,
};
setMessages(prev => {
const withoutTemp = prev.filter(m => m.id !== assistantId);
return [...withoutTemp, tempAssistant];
});
}
if (chunk.done && chunk.eval_count && chunk.eval_duration) {
totalTokens = chunk.eval_count;
durationMs = chunk.eval_duration / 1e6;
}
}
}
const tokensPerSec = durationMs > 0 ? (totalTokens / durationMs) * 1000 : 0;
const finalAssistant: Message = {
id: assistantId,
conversationId: conversation.id,
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
model: selectedModel,
metrics: {
tokensPerSec,
totalTokens,
promptTokens: estimateTokens(text),
durationMs,
},
};
await addMessage(finalAssistant);
const nextMessages = [...messages, userMessage, finalAssistant];
await persistConversationMeta(nextMessages);
const userCount = nextMessages.filter(m => m.role === 'user').length;
if (userCount === 1) {
await maybeAutoTitle(text);
}
} catch (err) {
if (!controller.signal.aborted) {
const failed: Message = {
id: assistantId,
conversationId: conversation.id,
role: 'assistant',
content: `Error: ${String(err)}`,
timestamp: Date.now(),
model: selectedModel,
};
setMessages(prev => {
const withoutTemp = prev.filter(m => m.id !== assistantId);
return [...withoutTemp, failed];
});
await addMessage(failed);
}
} finally {
abortRef.current = null;
setStreaming(false);
setShowModels(false);
}
};
const onCancel = () => {
abortRef.current?.abort();
abortRef.current = null;
setStreaming(false);
};
return (
<div className="flex h-screen flex-col">
<header className="border-b border-white/10 bg-[var(--bg-elevated)] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h1>
<p className="text-xs text-[var(--text-tertiary)]">{conversation.id}</p>
</div>
<div className="relative">
<button
type="button"
onClick={async () => {
await ensureModels();
setShowModels(prev => !prev);
}}
className="inline-flex items-center gap-1 rounded border border-white/10 bg-[var(--surface-card)] px-2 py-1 text-xs text-[var(--text-secondary)]"
>
{selectedModel || 'Select model'}
<ChevronDown size={12} />
</button>
{showModels && models.length > 0 ? (
<div className="absolute right-0 z-20 mt-1 max-h-64 w-64 overflow-auto rounded border border-white/10 bg-[var(--surface-card)] p-1">
{models.map(model => (
<button
key={model}
type="button"
onClick={() => {
setSelectedModel(model);
setShowModels(false);
}}
className="block w-full rounded px-2 py-1 text-left text-xs text-[var(--text-secondary)] hover:bg-white/5 hover:text-[var(--text-primary)]"
>
{model}
</button>
))}
</div>
) : null}
</div>
</div>
<ContextBar usedTokens={usedTokens} maxTokens={maxTokens} />
</header>
<MessageThread messages={messages} />
<InputBar
onSend={onSend}
streaming={streaming}
onCancel={onCancel}
disabled={!selectedModel}
/>
</div>
);
}

View File

@ -0,0 +1,74 @@
'use client';
import { useEffect, useState } from 'react';
import { Send, Square } from 'lucide-react';
interface InputBarProps {
onSend: (text: string) => Promise<void>;
disabled?: boolean;
streaming?: boolean;
onCancel?: () => void;
initialText?: string;
}
export function InputBar({
onSend,
disabled,
streaming,
onCancel,
initialText = '',
}: InputBarProps) {
const [text, setText] = useState(initialText);
useEffect(() => {
setText(initialText);
}, [initialText]);
const handleSend = async () => {
const trimmed = text.trim();
if (!trimmed || disabled || streaming) return;
await onSend(trimmed);
setText('');
};
return (
<div className="border-t border-white/10 bg-[var(--bg-elevated)] p-3">
<div className="flex items-end gap-2">
<textarea
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void handleSend();
}
}}
disabled={disabled || streaming}
rows={2}
placeholder="Message... (Cmd+Enter to send)"
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)]"
/>
{streaming ? (
<button
type="button"
onClick={onCancel}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--danger)] text-white hover:opacity-90"
title="Stop"
>
<Square size={16} />
</button>
) : (
<button
type="button"
onClick={() => void handleSend()}
disabled={disabled || !text.trim()}
className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--accent-primary)] text-white disabled:cursor-not-allowed disabled:opacity-50"
title="Send"
>
<Send size={16} />
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
'use client';
import { MarkdownResponse } from '../../components/MarkdownResponse';
import type { Message } from '../../lib/types';
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const user = message.role === 'user';
return (
<div className={`flex ${user ? 'justify-end' : 'justify-start'}`}>
<div
className="max-w-[85%] rounded-lg border border-white/10 px-3 py-2"
style={{
background: user ? 'rgba(90, 140, 255, 0.2)' : 'var(--surface-card)',
color: 'var(--text-primary)',
}}
>
{!user && message.model ? (
<div className="mb-1 text-[10px] text-[var(--text-tertiary)]">{message.model}</div>
) : null}
{user ? (
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
) : (
<MarkdownResponse content={message.content} isThinkModel={false} />
)}
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
'use client';
import { useEffect, useRef } from 'react';
import type { Message } from '../../lib/types';
import { MessageBubble } from './MessageBubble';
interface MessageThreadProps {
messages: Message[];
}
export function MessageThread({ messages }: MessageThreadProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
ref.current?.scrollTo({ top: ref.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
return (
<div ref={ref} className="flex-1 space-y-3 overflow-auto p-4">
{messages.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)]">No messages yet. Ask anything.</p>
) : (
messages.map(msg => <MessageBubble key={msg.id} message={msg} />)
)}
</div>
);
}

View File

@ -0,0 +1,138 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import {
FolderKanban,
LayoutDashboard,
Plus,
Search,
Settings,
Sparkles,
UserRound,
} from 'lucide-react';
import type { Conversation } from '../../lib/types';
import type { ReactNode } from 'react';
import { ConversationList } from './ConversationList';
interface SidebarProps {
collapsed: boolean;
conversations: Conversation[];
onNewConversation: () => void;
activeConversationId?: string;
onSelectConversation: (id: string) => void;
}
export function Sidebar({
collapsed,
conversations,
onNewConversation,
activeConversationId,
onSelectConversation,
}: SidebarProps) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return conversations;
return conversations.filter(c => c.title.toLowerCase().includes(q));
}, [conversations, query]);
return (
<aside
className="h-screen border-r border-white/10 bg-[var(--bg-elevated)] text-[var(--text-primary)] transition-all"
style={{ width: collapsed ? 72 : 240 }}
>
<div className="flex h-full flex-col gap-4 p-3">
<button
type="button"
onClick={onNewConversation}
className="inline-flex items-center justify-center gap-2 rounded-md bg-[var(--accent-primary)] px-3 py-2 text-sm font-medium text-white hover:opacity-90"
title="New Conversation"
>
<Plus size={16} />
{!collapsed && <span>New Conversation</span>}
</button>
<nav className="space-y-1 text-sm">
<SidebarNavItem
collapsed={collapsed}
icon={<Sparkles size={16} />}
label="Quick Actions"
/>
<SidebarNavItem collapsed={collapsed} icon={<UserRound size={16} />} label="My Agents" />
<SidebarNavItem
collapsed={collapsed}
icon={<FolderKanban size={16} />}
label="Projects"
/>
</nav>
<div className="my-1 border-t border-white/10" />
<div className="min-h-0 flex-1 overflow-auto">
{!collapsed && (
<div className="mb-2">
<label className="mb-1 block text-[10px] uppercase tracking-wide text-[var(--text-tertiary)]">
Conversations
</label>
<div className="flex items-center gap-2 rounded-md border border-white/10 bg-[var(--surface-muted)] px-2 py-1">
<Search size={12} className="text-[var(--text-tertiary)]" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search"
className="w-full bg-transparent text-xs text-[var(--text-primary)] outline-none placeholder:text-[var(--text-tertiary)]"
/>
</div>
</div>
)}
{!collapsed && (
<ConversationList
conversations={filtered}
activeId={activeConversationId}
onSelect={onSelectConversation}
/>
)}
</div>
<div className="mt-auto space-y-1 border-t border-white/10 pt-2 text-sm">
<Link
href="/mission-control"
className="flex items-center gap-2 rounded px-2 py-1 text-[var(--text-secondary)] hover:bg-white/5 hover:text-[var(--text-primary)]"
>
<LayoutDashboard size={16} />
{!collapsed && <span>Mission Control</span>}
</Link>
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 text-[var(--text-secondary)] hover:bg-white/5 hover:text-[var(--text-primary)]"
>
<Settings size={16} />
{!collapsed && <span>Settings</span>}
</button>
</div>
</div>
</aside>
);
}
function SidebarNavItem({
collapsed,
icon,
label,
}: {
collapsed: boolean;
icon: ReactNode;
label: string;
}) {
return (
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 text-[var(--text-secondary)] hover:bg-white/5 hover:text-[var(--text-primary)]"
title={label}
>
{icon}
{!collapsed && <span>{label}</span>}
</button>
);
}

View File

@ -0,0 +1,82 @@
'use client';
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { Sidebar } from './components/Sidebar';
import { addConversation, listConversations } from '../lib/db';
import type { Conversation } from '../lib/types';
import { migrateV3ToV4 } from '../lib/migrate';
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const [conversations, setConversations] = useState<Conversation[]>([]);
useEffect(() => {
const saved = localStorage.getItem('llm-sidebar-state');
if (saved === 'collapsed') setCollapsed(true);
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
e.preventDefault();
setCollapsed(prev => {
const next = !prev;
localStorage.setItem('llm-sidebar-state', next ? 'collapsed' : 'expanded');
return next;
});
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
const load = async () => {
await migrateV3ToV4();
const rows = await listConversations({ archived: false, limit: 100 });
setConversations(rows);
};
void load();
}, [pathname]);
const onNewConversation = async () => {
const now = Date.now();
const conv: Conversation = {
id: crypto.randomUUID(),
title: 'New Conversation',
model: '',
messageCount: 0,
createdAt: now,
updatedAt: now,
pinned: false,
archived: false,
metadata: {
totalTokens: 0,
totalPrompts: 0,
avgTokPerSec: 0,
models: [],
},
};
await addConversation(conv);
setConversations(prev => [conv, ...prev]);
router.push(`/c/${conv.id}`);
};
const activeConversationId = pathname.startsWith('/c/') ? pathname.slice(3) : undefined;
return (
<div className="flex min-h-screen bg-[var(--bg-canvas)]">
<Sidebar
collapsed={collapsed}
conversations={conversations}
onNewConversation={onNewConversation}
activeConversationId={activeConversationId}
onSelectConversation={id => router.push(`/c/${id}`)}
/>
<main className="min-w-0 flex-1">{children}</main>
</div>
);
}

View File

@ -0,0 +1,33 @@
'use client';
import { MessageSquarePlus, Sparkles } from 'lucide-react';
export default function WorkspaceHomePage() {
return (
<div className="flex min-h-screen items-center justify-center px-6">
<div className="w-full max-w-2xl rounded-xl border border-white/10 bg-[var(--surface-card)] p-8 text-center">
<div className="mx-auto mb-4 inline-flex h-12 w-12 items-center justify-center rounded-full bg-[var(--surface-muted)]">
<Sparkles size={22} className="text-[var(--accent-secondary)]" />
</div>
<h1 className="mb-2 text-2xl font-semibold text-[var(--text-primary)]">
Start a conversation
</h1>
<p className="mb-6 text-sm text-[var(--text-secondary)]">
Use New Conversation from the sidebar, or choose a Quick Action to begin.
</p>
<div className="mx-auto grid max-w-xl gap-3 sm:grid-cols-2">
{['Code Review', 'Debug Error', 'Deep Think', 'Draft Email'].map(item => (
<button
key={item}
type="button"
className="flex items-center gap-2 rounded-md border border-white/10 bg-[var(--surface-muted)] px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
<MessageSquarePlus size={16} />
<span>{item}</span>
</button>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,64 @@
import { NextRequest } from 'next/server';
import { OLLAMA_URL } from '../../../lib/ollama-config';
const FALLBACK_TITLE = 'New Conversation';
function cleanTitle(raw: string): string {
const title = raw
.replace(/["'`]/g, '')
.replace(/[\n\r]/g, ' ')
.trim();
if (!title) return FALLBACK_TITLE;
return title.split(/\s+/).slice(0, 6).join(' ');
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const message = String(body?.message || '').trim();
const model = String(body?.model || '').trim();
if (!message || !model) {
return new Response(JSON.stringify({ title: FALLBACK_TITLE }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const prompt =
'Generate a 3-5 word title for a conversation that starts with this message. Reply with ONLY the title and no punctuation.\n\n' +
message;
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, stream: false }),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
return new Response(JSON.stringify({ title: FALLBACK_TITLE }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const data = (await response.json()) as { response?: string };
const title = cleanTitle(data.response || '');
return new Response(JSON.stringify({ title }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch {
return new Response(JSON.stringify({ title: FALLBACK_TITLE }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@ -0,0 +1,236 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
import type {
Agent,
Conversation,
Message,
Orchestration,
Project,
QuickAction,
ScheduledTask,
} from './types';
interface TaskRunRecord {
id?: number;
taskId: string;
timestamp: number;
durationMs: number;
success: boolean;
result: string;
}
interface ModelRatingRecord {
id?: number;
model: string;
taskType: string;
rating: 'up' | 'down';
timestamp: number;
}
interface WorkspaceDB extends DBSchema {
conversations: {
key: string;
value: Conversation;
indexes: {
'by-updated-at': number;
'by-project-id': string;
'by-archived': number;
};
};
messages: {
key: string;
value: Message;
indexes: {
'by-conversation-ts': [string, number];
'by-conversation-parent': [string, string];
};
};
agents: {
key: string;
value: Agent;
};
quickActions: {
key: string;
value: QuickAction;
indexes: {
'by-category': string;
'by-usage-count': number;
};
};
projects: {
key: string;
value: Project;
};
scheduledTasks: {
key: string;
value: ScheduledTask;
};
taskRuns: {
key: number;
value: TaskRunRecord;
indexes: {
'by-task-ts': [string, number];
};
};
modelRatings: {
key: number;
value: ModelRatingRecord;
indexes: {
'by-model-task': [string, string];
};
};
orchestrations: {
key: string;
value: Orchestration;
};
}
const DB_NAME = 'llm-workspace';
const DB_VERSION = 1;
let dbPromise: Promise<IDBPDatabase<WorkspaceDB>> | null = null;
export function getDb(): Promise<IDBPDatabase<WorkspaceDB>> {
if (dbPromise) return dbPromise;
dbPromise = openDB<WorkspaceDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
const conversations = db.createObjectStore('conversations', { keyPath: 'id' });
conversations.createIndex('by-updated-at', 'updatedAt');
conversations.createIndex('by-project-id', 'projectId');
conversations.createIndex('by-archived', 'archived');
const messages = db.createObjectStore('messages', { keyPath: 'id' });
messages.createIndex('by-conversation-ts', ['conversationId', 'timestamp']);
messages.createIndex('by-conversation-parent', ['conversationId', 'parentId']);
db.createObjectStore('agents', { keyPath: 'id' });
const quickActions = db.createObjectStore('quickActions', { keyPath: 'id' });
quickActions.createIndex('by-category', 'category');
quickActions.createIndex('by-usage-count', 'usageCount');
db.createObjectStore('projects', { keyPath: 'id' });
db.createObjectStore('scheduledTasks', { keyPath: 'id' });
const taskRuns = db.createObjectStore('taskRuns', { keyPath: 'id', autoIncrement: true });
taskRuns.createIndex('by-task-ts', ['taskId', 'timestamp']);
const modelRatings = db.createObjectStore('modelRatings', {
keyPath: 'id',
autoIncrement: true,
});
modelRatings.createIndex('by-model-task', ['model', 'taskType']);
db.createObjectStore('orchestrations', { keyPath: 'id' });
},
});
return dbPromise;
}
export async function addConversation(conversation: Conversation): Promise<void> {
const db = await getDb();
await db.put('conversations', conversation);
}
export async function getConversation(id: string): Promise<Conversation | undefined> {
const db = await getDb();
return db.get('conversations', id);
}
export async function updateConversation(
id: string,
partial: Partial<Conversation>
): Promise<Conversation | null> {
const db = await getDb();
const existing = await db.get('conversations', id);
if (!existing) return null;
const updated: Conversation = { ...existing, ...partial, id };
await db.put('conversations', updated);
return updated;
}
export async function deleteConversation(id: string): Promise<void> {
const db = await getDb();
await db.delete('conversations', id);
}
export async function listConversations(opts?: {
archived?: boolean;
projectId?: string;
limit?: number;
offset?: number;
}): Promise<Conversation[]> {
const db = await getDb();
let rows = await db.getAll('conversations');
if (typeof opts?.archived === 'boolean') {
rows = rows.filter(row => row.archived === opts.archived);
}
if (typeof opts?.projectId === 'string' && opts.projectId.length > 0) {
rows = rows.filter(row => row.projectId === opts.projectId);
}
rows.sort((a, b) => b.updatedAt - a.updatedAt);
const offset = opts?.offset ?? 0;
const limit = opts?.limit ?? rows.length;
return rows.slice(offset, offset + limit);
}
export async function addMessage(message: Message): Promise<void> {
const db = await getDb();
await db.put('messages', message);
}
export async function getMessages(
conversationId: string,
opts?: { limit?: number; before?: number }
): Promise<Message[]> {
const db = await getDb();
let rows = await db.getAllFromIndex(
'messages',
'by-conversation-ts',
IDBKeyRange.bound([conversationId, 0], [conversationId, Number.MAX_SAFE_INTEGER])
);
if (typeof opts?.before === 'number') {
rows = rows.filter(row => row.timestamp < opts.before!);
}
rows.sort((a, b) => b.timestamp - a.timestamp);
const limited = typeof opts?.limit === 'number' ? rows.slice(0, opts.limit) : rows;
return limited.sort((a, b) => a.timestamp - b.timestamp);
}
export async function updateMessage(
id: string,
partial: Partial<Message>
): Promise<Message | null> {
const db = await getDb();
const existing = await db.get('messages', id);
if (!existing) return null;
const updated: Message = { ...existing, ...partial, id };
await db.put('messages', updated);
return updated;
}
export async function deleteMessagesByConversation(conversationId: string): Promise<void> {
const db = await getDb();
const tx = db.transaction('messages', 'readwrite');
const index = tx.store.index('by-conversation-ts');
let cursor = await index.openCursor(
IDBKeyRange.bound([conversationId, 0], [conversationId, Number.MAX_SAFE_INTEGER])
);
while (cursor) {
await cursor.delete();
cursor = await cursor.continue();
}
await tx.done;
}
export async function countMessages(conversationId: string): Promise<number> {
const db = await getDb();
const range = IDBKeyRange.bound([conversationId, 0], [conversationId, Number.MAX_SAFE_INTEGER]);
return db.countFromIndex('messages', 'by-conversation-ts', range);
}

View File

@ -65,6 +65,24 @@ export function getModelBadges(name: string, family?: string): ModelBadge[] {
return badges;
}
// v4: Approximate tokenizer for context window usage indicator
export function estimateTokens(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
return Math.ceil(trimmed.split(/\s+/).length * 1.3);
}
// v4: Best-effort model context window lookup from model name
export function getModelContextWindow(modelName: string): number {
const n = modelName.toLowerCase();
if (n.includes('128k')) return 128_000;
if (n.includes('64k')) return 64_000;
if (n.includes('32k')) return 32_000;
if (n.includes('16k')) return 16_000;
if (n.includes('8k')) return 8_000;
return 4_096;
}
export function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);

View File

@ -0,0 +1,158 @@
import { addConversation, addMessage, listConversations } from './db';
import type { Conversation, Message } from './types';
interface InferenceLogItem {
prompt?: string;
response?: string;
model?: string;
timestamp?: number;
}
interface ChatItem {
role?: 'user' | 'assistant';
content?: string;
}
export async function migrateV3ToV4(): Promise<{
migrated: boolean;
stats: { conversations: number; messages: number };
}> {
if (typeof window === 'undefined') {
return { migrated: false, stats: { conversations: 0, messages: 0 } };
}
const already = localStorage.getItem('llm-migrated-v4');
if (already === 'true') {
return { migrated: false, stats: { conversations: 0, messages: 0 } };
}
// Guard against duplicate migrations if db already has records
const existing = await listConversations({ limit: 1 });
if (existing.length > 0) {
localStorage.setItem('llm-migrated-v4', 'true');
return { migrated: false, stats: { conversations: 0, messages: 0 } };
}
let conversations = 0;
let messages = 0;
const inferenceRaw = localStorage.getItem('llm-inference-log');
if (inferenceRaw) {
try {
const entries = JSON.parse(inferenceRaw) as InferenceLogItem[];
for (const entry of entries) {
const prompt = String(entry.prompt || '').trim();
const response = String(entry.response || '').trim();
if (!prompt && !response) continue;
const now = Number(entry.timestamp || Date.now());
const convId = crypto.randomUUID();
const conv: Conversation = {
id: convId,
title: prompt ? prompt.split(/\s+/).slice(0, 5).join(' ') : 'Migrated Conversation',
model: entry.model || '',
messageCount: 0,
createdAt: now,
updatedAt: now,
pinned: false,
archived: false,
metadata: {
totalTokens: 0,
totalPrompts: prompt ? 1 : 0,
avgTokPerSec: 0,
models: entry.model ? [entry.model] : [],
},
};
await addConversation(conv);
conversations += 1;
const rows: Message[] = [];
if (prompt) {
rows.push({
id: crypto.randomUUID(),
conversationId: convId,
role: 'user',
content: prompt,
timestamp: now,
model: entry.model || '',
});
}
if (response) {
rows.push({
id: crypto.randomUUID(),
conversationId: convId,
role: 'assistant',
content: response,
timestamp: now + 1,
model: entry.model || '',
});
}
for (const row of rows) {
await addMessage(row);
messages += 1;
}
}
} catch {
// Ignore malformed migration payload
}
}
for (const key of Object.keys(localStorage)) {
if (!key.startsWith('llm-chat-')) continue;
const model = key.replace('llm-chat-', '');
const raw = localStorage.getItem(key);
if (!raw) continue;
try {
const chat = JSON.parse(raw) as ChatItem[];
if (!Array.isArray(chat) || chat.length === 0) continue;
const now = Date.now();
const convId = crypto.randomUUID();
const conv: Conversation = {
id: convId,
title: `Chat (${model})`,
model,
messageCount: chat.length,
createdAt: now,
updatedAt: now,
pinned: false,
archived: false,
metadata: {
totalTokens: 0,
totalPrompts: chat.filter(item => item.role === 'user').length,
avgTokPerSec: 0,
models: model ? [model] : [],
},
};
await addConversation(conv);
conversations += 1;
let ts = now;
for (const item of chat) {
if (!item.content) continue;
const msg: Message = {
id: crypto.randomUUID(),
conversationId: convId,
role: item.role === 'assistant' ? 'assistant' : 'user',
content: item.content,
timestamp: ts,
model,
};
ts += 1;
await addMessage(msg);
messages += 1;
}
} catch {
// Ignore malformed chat payload
}
}
localStorage.setItem('llm-migrated-v4', 'true');
return {
migrated: conversations > 0 || messages > 0,
stats: { conversations, messages },
};
}

View File

@ -74,3 +74,147 @@ export interface StreamMetrics {
totalTokens: number;
durationMs: number;
}
// --- v4 Workspace Types ---
export interface Conversation {
id: string;
title: string;
model: string;
agentId?: string;
projectId?: string;
systemPrompt?: string;
messageCount: number;
createdAt: number;
updatedAt: number;
pinned: boolean;
archived: boolean;
metadata: {
totalTokens: number;
totalPrompts: number;
avgTokPerSec: number;
models: string[];
};
}
export interface Message {
id: string;
conversationId: string;
role: 'user' | 'assistant' | 'system';
content: string;
model?: string;
timestamp: number;
attachments?: Attachment[];
metrics?: {
tokensPerSec: number;
totalTokens: number;
promptTokens: number;
durationMs: number;
};
rating?: 'up' | 'down';
parentId?: string;
branchIndex?: number;
}
export interface Attachment {
type: 'image' | 'file' | 'audio' | 'url';
name: string;
data: string;
mimeType?: string;
size?: number;
language?: string;
}
export interface Agent {
id: string;
name: string;
icon: string;
description: string;
model: string;
systemPrompt: string;
temperature?: number;
tools: AgentTool[];
welcomeMessage?: string;
examplePrompts: string[];
constraints?: string[];
responseFormat?: 'markdown' | 'json' | 'code' | 'plain';
builtin: boolean;
conversationCount: number;
createdAt: number;
}
export type AgentTool = 'file_read' | 'vision' | 'voice_input';
export interface QuickAction {
id: string;
name: string;
icon: string;
category: 'code' | 'writing' | 'analysis' | 'creative' | 'devops' | 'custom';
description: string;
modelHint: string;
systemPrompt: string;
userTemplate: string;
builtin: boolean;
hotkey?: string;
usageCount: number;
lastUsed?: number;
}
export interface ModelDefaults {
fast: string;
coding: string;
reasoning: string;
chat: string;
vision: string;
}
export interface Project {
id: string;
name: string;
icon: string;
description?: string;
defaultModel?: string;
defaultAgent?: string;
systemContext?: string;
conversationIds: string[];
pinned: boolean;
createdAt: number;
}
export interface ScheduledTask {
id: string;
name: string;
schedule: string;
scheduleHuman: string;
model: string;
prompt: string;
inputSource?:
| { type: 'static' }
| { type: 'file'; path: string }
| { type: 'command'; command: string }
| { type: 'clipboard' };
outputAction?:
| { type: 'conversation' }
| { type: 'clipboard' }
| { type: 'file'; path: string }
| { type: 'notification' };
enabled: boolean;
lastRun?: number;
runHistory: Array<{ timestamp: number; durationMs: number; success: boolean; result: string }>;
createdAt: number;
}
export interface Orchestration {
id: string;
name: string;
mode: 'chain' | 'race' | 'vote';
steps: OrchestrationStep[];
synthesizer?: string;
description?: string;
}
export interface OrchestrationStep {
model: string;
systemPrompt?: string;
transformInput?: string;
}

8305
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff