diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationView.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationView.tsx index 398071a1..5a1e78a1 100644 --- a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationView.tsx +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/ConversationView.tsx @@ -3,7 +3,7 @@ import { useMemo, useRef, useState } from 'react'; import { ChevronDown } from 'lucide-react'; import type { Agent, Conversation, Message, OllamaData } from '../../lib/types'; -import { addMessage, updateConversation } from '../../lib/db'; +import { addMessage, updateConversation, updateMessage } from '../../lib/db'; import { InputBar } from './InputBar'; import { MessageThread } from './MessageThread'; import { ContextBar } from './ContextBar'; @@ -35,6 +35,11 @@ export function ConversationView({ const [showModels, setShowModels] = useState(false); const [modelDefaults, setModelDefaults] = useState(null); const abortRef = useRef(null); + const [streamingId, setStreamingId] = useState(); + const [streamTokens, setStreamTokens] = useState(0); + const [streamTokPerSec, setStreamTokPerSec] = useState(0); + const streamStartRef = useRef(0); + const streamTokenCountRef = useRef(0); const usedTokens = useMemo(() => { return messages.reduce((sum, m) => sum + estimateTokens(m.content), 0); @@ -135,6 +140,10 @@ export function ConversationView({ const controller = new AbortController(); abortRef.current = controller; setStreaming(true); + setStreamTokens(0); + setStreamTokPerSec(0); + streamStartRef.current = Date.now(); + streamTokenCountRef.current = 0; const chatPayload = [...messages, userMessage].map(m => ({ role: m.role, @@ -143,6 +152,7 @@ export function ConversationView({ let assistantContent = ''; const assistantId = crypto.randomUUID(); + setStreamingId(assistantId); try { const res = await fetch('/api/ollama/chat', { @@ -186,6 +196,10 @@ export function ConversationView({ if (chunk.message?.content) { assistantContent += chunk.message.content; + streamTokenCountRef.current += 1; + const elapsed = (Date.now() - streamStartRef.current) / 1000; + setStreamTokens(streamTokenCountRef.current); + setStreamTokPerSec(elapsed > 0 ? streamTokenCountRef.current / elapsed : 0); const tempAssistant: Message = { id: assistantId, conversationId: conversation.id, @@ -251,6 +265,7 @@ export function ConversationView({ } finally { abortRef.current = null; setStreaming(false); + setStreamingId(undefined); setShowModels(false); } }; @@ -259,6 +274,21 @@ export function ConversationView({ abortRef.current?.abort(); abortRef.current = null; setStreaming(false); + setStreamingId(undefined); + }; + + const onRegenerate = async (messageId: string, model?: string) => { + const idx = messages.findIndex(m => m.id === messageId); + if (idx < 1) return; + const userMsg = messages[idx - 1]; + if (!userMsg || userMsg.role !== 'user') return; + if (model) setSelectedModel(model); + await onSend(userMsg.content); + }; + + const onRate = async (messageId: string, rating: 'up' | 'down') => { + await updateMessage(messageId, { rating }); + setMessages(prev => prev.map(m => (m.id === messageId ? { ...m, rating } : m))); }; return ( @@ -321,7 +351,15 @@ export function ConversationView({ - + void onRegenerate(mid, mdl)} + onRate={(mid, r) => void onRate(mid, r)} + models={models} + /> void; + onRate?: (messageId: string, rating: 'up' | 'down') => void; + models?: string[]; } -export function MessageBubble({ message }: MessageBubbleProps) { +export function MessageBubble({ + message, + isStreaming, + streamTokens, + streamTokPerSec, + onRegenerate, + onRate, + models = [], +}: MessageBubbleProps) { const user = message.role === 'user'; + const [copied, setCopied] = useState(false); + const [showRegenModels, setShowRegenModels] = useState(false); + const [rating, setRating] = useState<'up' | 'down' | undefined>(message.rating); + + const handleCopy = async () => { + await navigator.clipboard.writeText(message.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleRate = (r: 'up' | 'down') => { + setRating(r); + onRate?.(message.id, r); + }; return ( -
+
{message.content}

) : ( - + + )} + + {/* E4: Live streaming metrics */} + {isStreaming && typeof streamTokens === 'number' && streamTokens > 0 && ( +
+ {streamTokens} tokens · {(streamTokPerSec ?? 0).toFixed(1)} tok/s +
+ )} + + {/* Completed assistant metrics */} + {!user && !isStreaming && message.metrics ? ( +
+ {message.metrics.totalTokens} tokens · {message.metrics.tokensPerSec.toFixed(1)} tok/s ·{' '} + {(message.metrics.durationMs / 1000).toFixed(1)}s +
+ ) : null} + + {/* E1: Action bar on hover for assistant messages */} + {!user && !isStreaming && ( +
+ + + {/* E3: Regenerate / try with other model */} +
+ + {showRegenModels && ( +
+ + {models.map(m => ( + + ))} +
+ )} +
+ + {/* E5: Rating buttons */} + + +
)}
diff --git a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/MessageThread.tsx b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/MessageThread.tsx index 54a6bd11..5f1ce5ea 100644 --- a/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/MessageThread.tsx +++ b/__LOCAL_LLMs/dashboard/src/app/(workspace)/components/MessageThread.tsx @@ -6,9 +6,23 @@ import { MessageBubble } from './MessageBubble'; interface MessageThreadProps { messages: Message[]; + streamingId?: string; + streamTokens?: number; + streamTokPerSec?: number; + onRegenerate?: (messageId: string, model?: string) => void; + onRate?: (messageId: string, rating: 'up' | 'down') => void; + models?: string[]; } -export function MessageThread({ messages }: MessageThreadProps) { +export function MessageThread({ + messages, + streamingId, + streamTokens, + streamTokPerSec, + onRegenerate, + onRate, + models, +}: MessageThreadProps) { const ref = useRef(null); useEffect(() => { @@ -20,7 +34,18 @@ export function MessageThread({ messages }: MessageThreadProps) { {messages.length === 0 ? (

No messages yet. Ask anything.

) : ( - messages.map(msg => ) + messages.map(msg => ( + + )) )}
); diff --git a/__LOCAL_LLMs/dashboard/src/app/components/MarkdownResponse.tsx b/__LOCAL_LLMs/dashboard/src/app/components/MarkdownResponse.tsx index 8ac8d2d1..7cfe561b 100644 --- a/__LOCAL_LLMs/dashboard/src/app/components/MarkdownResponse.tsx +++ b/__LOCAL_LLMs/dashboard/src/app/components/MarkdownResponse.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } 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'; @@ -30,6 +30,48 @@ function splitThinkBlocks(text: string): { thinking: string | null; answer: stri return { thinking: null, answer: text }; } +function CodeBlock({ language, code }: { language: string; code: string }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ {language} + +
+ + {code} + +
+ ); +} + export function MarkdownResponse({ content, isThinkModel, streaming }: MarkdownResponseProps) { const { thinking, answer } = useMemo(() => { if (!isThinkModel) return { thinking: null, answer: content }; @@ -67,29 +109,7 @@ export function MarkdownResponse({ content, isThinkModel, streaming }: MarkdownR const match = /language-(\w+)/.exec(className || ''); const codeString = String(children).replace(/\n$/, ''); if (match) { - return ( -
-
- {match[1]} -
- - {codeString} - -
- ); + return ; } return (