feat(local-llm): Phase E — response enhancements (E1-E5)

E1: Per-message action bar (copy, regenerate dropdown, rating) on hover
E2: Per-code-block copy button in MarkdownResponse with 'Copied!' feedback
E3: 'Try with other model' — regenerate dropdown shows loaded models
E4: Live streaming metrics (token count + tok/s during stream)
E5: Rating (thumbs up/down) persisted per message in IndexedDB
This commit is contained in:
saravanakumardb1 2026-02-20 00:40:49 -08:00
parent d625be283c
commit e15a5a2f2f
4 changed files with 236 additions and 32 deletions

View File

@ -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<ModelDefaults | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [streamingId, setStreamingId] = useState<string | undefined>();
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({
<ContextBar usedTokens={usedTokens} maxTokens={maxTokens} />
</header>
<MessageThread messages={messages} />
<MessageThread
messages={messages}
streamingId={streamingId}
streamTokens={streamTokens}
streamTokPerSec={streamTokPerSec}
onRegenerate={(mid, mdl) => void onRegenerate(mid, mdl)}
onRate={(mid, r) => void onRate(mid, r)}
models={models}
/>
<InputBar
onSend={onSend}
streaming={streaming}

View File

@ -1,19 +1,49 @@
'use client';
import { useState } from 'react';
import { Check, ChevronDown, Copy, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react';
import { MarkdownResponse } from '../../components/MarkdownResponse';
import type { Message } from '../../lib/types';
interface MessageBubbleProps {
message: Message;
isStreaming?: boolean;
streamTokens?: number;
streamTokPerSec?: number;
onRegenerate?: (messageId: string, model?: string) => 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 (
<div className={`flex ${user ? 'justify-end' : 'justify-start'}`}>
<div className={`group flex ${user ? 'justify-end' : 'justify-start'}`}>
<div
className="max-w-[85%] rounded-lg border border-white/10 px-3 py-2"
className="relative 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)',
@ -25,7 +55,98 @@ export function MessageBubble({ message }: MessageBubbleProps) {
{user ? (
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
) : (
<MarkdownResponse content={message.content} isThinkModel={false} />
<MarkdownResponse
content={message.content}
isThinkModel={false}
streaming={isStreaming}
/>
)}
{/* E4: Live streaming metrics */}
{isStreaming && typeof streamTokens === 'number' && streamTokens > 0 && (
<div className="mt-1 text-[10px] text-[var(--accent-secondary)]">
{streamTokens} tokens · {(streamTokPerSec ?? 0).toFixed(1)} tok/s
</div>
)}
{/* Completed assistant metrics */}
{!user && !isStreaming && message.metrics ? (
<div className="mt-1 text-[10px] text-[var(--text-tertiary)]">
{message.metrics.totalTokens} tokens · {message.metrics.tokensPerSec.toFixed(1)} tok/s ·{' '}
{(message.metrics.durationMs / 1000).toFixed(1)}s
</div>
) : null}
{/* E1: Action bar on hover for assistant messages */}
{!user && !isStreaming && (
<div className="mt-1 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={() => void handleCopy()}
className="inline-flex h-6 w-6 items-center justify-center rounded text-[var(--text-tertiary)] hover:bg-white/10 hover:text-[var(--text-primary)]"
title="Copy"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
{/* E3: Regenerate / try with other model */}
<div className="relative">
<button
type="button"
onClick={() => setShowRegenModels(p => !p)}
className="inline-flex h-6 items-center gap-0.5 rounded px-1 text-[var(--text-tertiary)] hover:bg-white/10 hover:text-[var(--text-primary)]"
title="Regenerate"
>
<RefreshCw size={12} />
<ChevronDown size={10} />
</button>
{showRegenModels && (
<div className="absolute bottom-7 left-0 z-30 max-h-48 w-52 overflow-auto rounded border border-white/10 bg-[var(--surface-card)] p-1 shadow-lg">
<button
type="button"
onClick={() => {
setShowRegenModels(false);
onRegenerate?.(message.id);
}}
className="block w-full rounded px-2 py-1 text-left text-[11px] text-[var(--accent-secondary)] hover:bg-white/5"
>
Regenerate (same model)
</button>
{models.map(m => (
<button
key={m}
type="button"
onClick={() => {
setShowRegenModels(false);
onRegenerate?.(message.id, m);
}}
className="block w-full rounded px-2 py-1 text-left text-[11px] text-[var(--text-secondary)] hover:bg-white/5"
>
Try with {m}
</button>
))}
</div>
)}
</div>
{/* E5: Rating buttons */}
<button
type="button"
onClick={() => handleRate('up')}
className={`inline-flex h-6 w-6 items-center justify-center rounded hover:bg-white/10 ${rating === 'up' ? 'text-[var(--success)]' : 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'}`}
title="Good response"
>
<ThumbsUp size={12} />
</button>
<button
type="button"
onClick={() => handleRate('down')}
className={`inline-flex h-6 w-6 items-center justify-center rounded hover:bg-white/10 ${rating === 'down' ? 'text-[var(--danger)]' : 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'}`}
title="Bad response"
>
<ThumbsDown size={12} />
</button>
</div>
)}
</div>
</div>

View File

@ -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<HTMLDivElement | null>(null);
useEffect(() => {
@ -20,7 +34,18 @@ export function MessageThread({ messages }: MessageThreadProps) {
{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} />)
messages.map(msg => (
<MessageBubble
key={msg.id}
message={msg}
isStreaming={msg.id === streamingId}
streamTokens={msg.id === streamingId ? streamTokens : undefined}
streamTokPerSec={msg.id === streamingId ? streamTokPerSec : undefined}
onRegenerate={onRegenerate}
onRate={onRate}
models={models}
/>
))
)}
</div>
);

View File

@ -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 (
<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>{language}</span>
<button
type="button"
onClick={() => void handleCopy()}
className="text-[10px] hover:text-[var(--text-primary)] transition-colors"
style={{ color: 'var(--text-tertiary)' }}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<SyntaxHighlighter
style={oneDark}
language={language}
PreTag="div"
customStyle={{
margin: 0,
borderRadius: '0 0 0.375rem 0.375rem',
fontSize: '12px',
maxHeight: '300px',
}}
>
{code}
</SyntaxHighlighter>
</div>
);
}
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 (
<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 <CodeBlock language={match[1]} code={codeString} />;
}
return (
<code