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 { useMemo, useRef, useState } from 'react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import type { Agent, Conversation, Message, OllamaData } from '../../lib/types';
|
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 { InputBar } from './InputBar';
|
||||||
import { MessageThread } from './MessageThread';
|
import { MessageThread } from './MessageThread';
|
||||||
import { ContextBar } from './ContextBar';
|
import { ContextBar } from './ContextBar';
|
||||||
@ -35,6 +35,11 @@ export function ConversationView({
|
|||||||
const [showModels, setShowModels] = useState(false);
|
const [showModels, setShowModels] = useState(false);
|
||||||
const [modelDefaults, setModelDefaults] = useState<ModelDefaults | null>(null);
|
const [modelDefaults, setModelDefaults] = useState<ModelDefaults | null>(null);
|
||||||
const abortRef = useRef<AbortController | 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(() => {
|
const usedTokens = useMemo(() => {
|
||||||
return messages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
return messages.reduce((sum, m) => sum + estimateTokens(m.content), 0);
|
||||||
@ -135,6 +140,10 @@ export function ConversationView({
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
|
setStreamTokens(0);
|
||||||
|
setStreamTokPerSec(0);
|
||||||
|
streamStartRef.current = Date.now();
|
||||||
|
streamTokenCountRef.current = 0;
|
||||||
|
|
||||||
const chatPayload = [...messages, userMessage].map(m => ({
|
const chatPayload = [...messages, userMessage].map(m => ({
|
||||||
role: m.role,
|
role: m.role,
|
||||||
@ -143,6 +152,7 @@ export function ConversationView({
|
|||||||
|
|
||||||
let assistantContent = '';
|
let assistantContent = '';
|
||||||
const assistantId = crypto.randomUUID();
|
const assistantId = crypto.randomUUID();
|
||||||
|
setStreamingId(assistantId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ollama/chat', {
|
const res = await fetch('/api/ollama/chat', {
|
||||||
@ -186,6 +196,10 @@ export function ConversationView({
|
|||||||
|
|
||||||
if (chunk.message?.content) {
|
if (chunk.message?.content) {
|
||||||
assistantContent += 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 = {
|
const tempAssistant: Message = {
|
||||||
id: assistantId,
|
id: assistantId,
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
@ -251,6 +265,7 @@ export function ConversationView({
|
|||||||
} finally {
|
} finally {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setStreaming(false);
|
setStreaming(false);
|
||||||
|
setStreamingId(undefined);
|
||||||
setShowModels(false);
|
setShowModels(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -259,6 +274,21 @@ export function ConversationView({
|
|||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setStreaming(false);
|
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 (
|
return (
|
||||||
@ -321,7 +351,15 @@ export function ConversationView({
|
|||||||
<ContextBar usedTokens={usedTokens} maxTokens={maxTokens} />
|
<ContextBar usedTokens={usedTokens} maxTokens={maxTokens} />
|
||||||
</header>
|
</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
|
<InputBar
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
streaming={streaming}
|
streaming={streaming}
|
||||||
|
|||||||
@ -1,19 +1,49 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, ChevronDown, Copy, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||||
import { MarkdownResponse } from '../../components/MarkdownResponse';
|
import { MarkdownResponse } from '../../components/MarkdownResponse';
|
||||||
import type { Message } from '../../lib/types';
|
import type { Message } from '../../lib/types';
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: Message;
|
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 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 (
|
return (
|
||||||
<div className={`flex ${user ? 'justify-end' : 'justify-start'}`}>
|
<div className={`group flex ${user ? 'justify-end' : 'justify-start'}`}>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: user ? 'rgba(90, 140, 255, 0.2)' : 'var(--surface-card)',
|
background: user ? 'rgba(90, 140, 255, 0.2)' : 'var(--surface-card)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
@ -25,7 +55,98 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
{user ? (
|
{user ? (
|
||||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,9 +6,23 @@ import { MessageBubble } from './MessageBubble';
|
|||||||
|
|
||||||
interface MessageThreadProps {
|
interface MessageThreadProps {
|
||||||
messages: Message[];
|
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);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -20,7 +34,18 @@ export function MessageThread({ messages }: MessageThreadProps) {
|
|||||||
{messages.length === 0 ? (
|
{messages.length === 0 ? (
|
||||||
<p className="text-sm text-[var(--text-tertiary)]">No messages yet. Ask anything.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
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 };
|
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) {
|
export function MarkdownResponse({ content, isThinkModel, streaming }: MarkdownResponseProps) {
|
||||||
const { thinking, answer } = useMemo(() => {
|
const { thinking, answer } = useMemo(() => {
|
||||||
if (!isThinkModel) return { thinking: null, answer: content };
|
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 match = /language-(\w+)/.exec(className || '');
|
||||||
const codeString = String(children).replace(/\n$/, '');
|
const codeString = String(children).replace(/\n$/, '');
|
||||||
if (match) {
|
if (match) {
|
||||||
return (
|
return <CodeBlock language={match[1]} code={codeString} />;
|
||||||
<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 (
|
return (
|
||||||
<code
|
<code
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user