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:
parent
d625be283c
commit
e15a5a2f2f
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user