From e17bb311c906a494c7c06fd9082d84ec77b788d8 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 20 Feb 2026 00:11:27 -0800 Subject: [PATCH] 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. --- __LOCAL_LLMs/dashboard/package.json | 1 + .../components/MarkdownResponse.tsx | 213 + .../components/RamBudgetBar.tsx | 105 + .../mission-control}/page.tsx | 10 +- .../src/app/(workspace)/c/[id]/page.tsx | 51 + .../app/(workspace)/components/ContextBar.tsx | 36 + .../components/ConversationList.tsx | 85 + .../components/ConversationView.tsx | 275 + .../app/(workspace)/components/InputBar.tsx | 74 + .../(workspace)/components/MessageBubble.tsx | 33 + .../(workspace)/components/MessageThread.tsx | 27 + .../app/(workspace)/components/Sidebar.tsx | 138 + .../dashboard/src/app/(workspace)/layout.tsx | 82 + .../dashboard/src/app/(workspace)/page.tsx | 33 + .../src/app/api/ollama/title/route.ts | 64 + __LOCAL_LLMs/dashboard/src/app/lib/db.ts | 236 + __LOCAL_LLMs/dashboard/src/app/lib/format.ts | 18 + __LOCAL_LLMs/dashboard/src/app/lib/migrate.ts | 158 + __LOCAL_LLMs/dashboard/src/app/lib/types.ts | 144 + pnpm-lock.yaml | 8305 +++++++++++++++-- 20 files changed, 9174 insertions(+), 914 deletions(-) create mode 100644 __LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/components/MarkdownResponse.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/components/RamBudgetBar.tsx rename __LOCAL_LLMs/dashboard/src/app/{ => (mission-control)/mission-control}/page.tsx (99%) create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/c/[id]/page.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/ContextBar.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationList.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationView.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/InputBar.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/MessageBubble.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/MessageThread.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/components/Sidebar.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/layout.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/(workspace)/page.tsx create mode 100644 __LOCAL_LLMs/dashboard/src/app/api/ollama/title/route.ts create mode 100644 __LOCAL_LLMs/dashboard/src/app/lib/db.ts create mode 100644 __LOCAL_LLMs/dashboard/src/app/lib/migrate.ts diff --git a/__LOCAL_LLMs/dashboard/package.json b/__LOCAL_LLMs/dashboard/package.json index 65622863..0ae97f2a 100644 --- a/__LOCAL_LLMs/dashboard/package.json +++ b/__LOCAL_LLMs/dashboard/package.json @@ -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", diff --git a/__LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/components/MarkdownResponse.tsx b/__LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/components/MarkdownResponse.tsx new file mode 100644 index 00000000..8ac8d2d1 --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/components/MarkdownResponse.tsx @@ -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 ... blocks from the main response +function splitThinkBlocks(text: string): { thinking: string | null; answer: string } { + const thinkMatch = text.match(/^([\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('') && !text.includes('')) { + 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 ( +
+ {/* F27: Collapsible think block */} + {thinking && ( +
+ + Show reasoning ({thinking.split(/\s+/).length} words) + +
+            {thinking}
+          
+
+ )} + {/* F25/F26: Markdown with syntax highlighted code blocks */} +
+ +
+ {match[1]} +
+ + {codeString} + +
+ ); + } + return ( + + {children} + + ); + }, + p({ children }) { + return ( +

+ {children} +

+ ); + }, + h1({ children }) { + return ( +

+ {children} +

+ ); + }, + h2({ children }) { + return ( +

+ {children} +

+ ); + }, + h3({ children }) { + return ( +

+ {children} +

+ ); + }, + ul({ children }) { + return ( +
    + {children} +
+ ); + }, + ol({ children }) { + return ( +
    + {children} +
+ ); + }, + li({ children }) { + return
  • {children}
  • ; + }, + blockquote({ children }) { + return ( +
    + {children} +
    + ); + }, + strong({ children }) { + return ( + + {children} + + ); + }, + em({ children }) { + return {children}; + }, + a({ href, children }) { + return ( + + {children} + + ); + }, + }} + > + {answer || (streaming ? '' : '(empty response)')} + +
    + {streaming && ( + + )} + + ); +} diff --git a/__LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/components/RamBudgetBar.tsx b/__LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/components/RamBudgetBar.tsx new file mode 100644 index 00000000..df48050c --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/components/RamBudgetBar.tsx @@ -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 ( +
    +
    + + Memory Budget + + + {formatBytes(totalRam)} unified + +
    +
    + {/* OS + Apps */} +
    + {pct(osApps) > 8 ? 'OS' : ''} +
    + {/* Loaded models */} + {runningModels.map((m, i) => ( +
    + {pct(m.size_vram) > 10 ? m.name.split(':')[0] : ''} +
    + ))} + {/* Free */} +
    + {pct(freeRam) > 8 ? `${formatBytes(freeRam)} free` : ''} +
    +
    + {runningModels.length > 0 && ( +
    + {runningModels.map((m, i) => ( + + + {m.name.split(':')[0]}: {formatBytes(m.size_vram)} + + ))} +
    + )} +
    + ); +} diff --git a/__LOCAL_LLMs/dashboard/src/app/page.tsx b/__LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/page.tsx similarity index 99% rename from __LOCAL_LLMs/dashboard/src/app/page.tsx rename to __LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/page.tsx index 5242db58..b9ae8913 100644 --- a/__LOCAL_LLMs/dashboard/src/app/page.tsx +++ b/__LOCAL_LLMs/dashboard/src/app/(mission-control)/mission-control/page.tsx @@ -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'; diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/c/[id]/page.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/c/[id]/page.tsx new file mode 100644 index 00000000..b6ae1314 --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/c/[id]/page.tsx @@ -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(null); + const [messages, setMessages] = useState([]); + 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
    Loading conversation...
    ; + } + + if (!conversation) { + return ( +
    +

    Conversation not found

    +

    + The conversation may have been deleted. +

    + + Back to workspace + +
    + ); + } + + return ; +} diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ContextBar.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ContextBar.tsx new file mode 100644 index 00000000..48fc48d4 --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ContextBar.tsx @@ -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 ( +
    +
    + Context + + {fmt(usedTokens)} / {fmt(maxTokens)} tokens + +
    +
    +
    +
    +
    + ); +} diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationList.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationList.tsx new file mode 100644 index 00000000..2289e9ce --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationList.tsx @@ -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(); + + 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

    No conversations yet

    ; + } + + return ( +
    + {[...groups.entries()].map(([group, items]) => ( +
    +

    + {group} +

    +
    + {items.map(conv => { + const active = conv.id === activeId; + return ( + + ); + })} +
    +
    + ))} +
    + ); +} diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationView.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationView.tsx new file mode 100644 index 00000000..6e74ed9d --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationView.tsx @@ -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(initialMessages); + const [streaming, setStreaming] = useState(false); + const [selectedModel, setSelectedModel] = useState(conversation.model); + const [models, setModels] = useState([]); + const [showModels, setShowModels] = useState(false); + const abortRef = useRef(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 ( +
    +
    +
    +
    +

    {title}

    +

    {conversation.id}

    +
    + +
    + + {showModels && models.length > 0 ? ( +
    + {models.map(model => ( + + ))} +
    + ) : null} +
    +
    + + +
    + + + +
    + ); +} diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/InputBar.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/InputBar.tsx new file mode 100644 index 00000000..7e121af7 --- /dev/null +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/InputBar.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Send, Square } from 'lucide-react'; + +interface InputBarProps { + onSend: (text: string) => Promise; + 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 ( +
    +
    +