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:
parent
d7dc66eb92
commit
e17bb311c9
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
51
__LOCAL_LLMs/dashboard/src/app/(workspace)/c/[id]/page.tsx
Normal file
51
__LOCAL_LLMs/dashboard/src/app/(workspace)/c/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
82
__LOCAL_LLMs/dashboard/src/app/(workspace)/layout.tsx
Normal file
82
__LOCAL_LLMs/dashboard/src/app/(workspace)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
__LOCAL_LLMs/dashboard/src/app/(workspace)/page.tsx
Normal file
33
__LOCAL_LLMs/dashboard/src/app/(workspace)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
__LOCAL_LLMs/dashboard/src/app/api/ollama/title/route.ts
Normal file
64
__LOCAL_LLMs/dashboard/src/app/api/ollama/title/route.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
236
__LOCAL_LLMs/dashboard/src/app/lib/db.ts
Normal file
236
__LOCAL_LLMs/dashboard/src/app/lib/db.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
158
__LOCAL_LLMs/dashboard/src/app/lib/migrate.ts
Normal file
158
__LOCAL_LLMs/dashboard/src/app/lib/migrate.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
@ -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
8305
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user