feat(local-llm): Phase 5 — response quality + interaction (F24-F28)

F24: Vision image upload — file picker for vision models, base64 encoding,
     passed through stream API to Ollama generate endpoint
F25: Markdown rendering — ReactMarkdown replaces raw <pre> for all
     prompt responses and chat assistant messages
F26: Syntax highlighting — Prism-based code blocks with language labels
     and oneDark theme via react-syntax-highlighter
F27: <think> block collapse — auto-detect and collapse DeepSeek R1
     reasoning traces into expandable details with word count
F28: Ollama library link — button next to Pull input opens ollama.com/library
This commit is contained in:
saravanakumardb1 2026-02-19 23:25:20 -08:00
parent 588d21c70e
commit 44ad8a6301
5 changed files with 1646 additions and 44 deletions

File diff suppressed because it is too large Load Diff

View File

@ -9,10 +9,13 @@
"lint": "eslint"
},
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"lucide-react": "^0.575.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@ -4,12 +4,12 @@ import { OLLAMA_URL } from '../../../lib/ollama-config';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { model, prompt } = body;
const { model, prompt, images } = body;
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, stream: true }),
body: JSON.stringify({ model, prompt, stream: true, ...(images ? { images } : {}) }),
});
if (!response.ok || !response.body) {

View File

@ -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>
);
}

View File

@ -55,6 +55,7 @@ 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';
export default function Dashboard() {
const [ollama, setOllama] = useState<OllamaData | null>(null);
@ -114,6 +115,7 @@ export default function Dashboard() {
Record<string, { prompts: number; tokens: number }>
>({});
const [countdownTick, setCountdownTick] = useState(0);
const [visionImages, setVisionImages] = useState<string[]>([]);
const responseRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const compareAbortRef = useRef<AbortController | null>(null);
@ -505,7 +507,11 @@ export default function Dashboard() {
const res = await fetch('/api/ollama/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: promptModel, prompt: promptText }),
body: JSON.stringify({
model: promptModel,
prompt: promptText,
...(visionImages.length > 0 ? { images: visionImages } : {}),
}),
signal: controller.signal,
});
if (!res.ok || !res.body) {
@ -971,7 +977,7 @@ export default function Dashboard() {
</div>
)}
{/* Pull Model Input */}
{/* Pull Model Input + F28: Library Link */}
{ollama?.status === 'online' && (
<div className="flex gap-2 mb-4">
<input
@ -1003,6 +1009,16 @@ export default function Dashboard() {
)}
Pull
</button>
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-3 py-2 rounded-lg text-xs font-medium transition-colors shrink-0"
style={{ background: 'var(--surface-muted)', color: 'var(--text-tertiary)' }}
title="Browse Ollama model library"
>
Library
</a>
</div>
)}
@ -2038,6 +2054,58 @@ export default function Dashboard() {
</button>
</div>
<div className="flex items-center gap-3 mt-2">
{/* F24: Vision image upload for vision models */}
{promptModel && getModelBadges(promptModel).some(b => b.label.includes('Vision')) && (
<div className="flex items-center gap-2">
{visionImages.length > 0 && (
<div className="flex items-center gap-1">
{visionImages.map((_, idx) => (
<span
key={idx}
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
background: 'var(--surface-muted)',
color: 'var(--accent-secondary)',
}}
>
img{idx + 1}
<button
onClick={() =>
setVisionImages(prev => prev.filter((__, i) => i !== idx))
}
className="ml-1"
style={{ color: 'var(--danger)' }}
>
&times;
</button>
</span>
))}
</div>
)}
<label
className="cursor-pointer text-[11px] px-2 py-1 rounded transition-colors"
style={{ background: 'var(--surface-muted)', color: 'var(--accent-secondary)' }}
>
+ Image
<input
type="file"
accept="image/*"
className="hidden"
onChange={e => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
if (base64) setVisionImages(prev => [...prev, base64]);
};
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
</div>
)}
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
&#8984;+Enter to send &middot; Esc to close
</span>
@ -2078,15 +2146,19 @@ export default function Dashboard() {
msg.role === 'assistant' ? '1px solid var(--border-subtle)' : 'none',
}}
>
{msg.content}
{i === chatMessages.length - 1 &&
msg.role === 'assistant' &&
promptLoading && (
<span
className="inline-block w-2 h-4 ml-0.5 animate-pulse"
style={{ background: 'var(--accent-primary)' }}
/>
)}
{msg.role === 'assistant' ? (
<MarkdownResponse
content={msg.content}
isThinkModel={
promptModel
? getModelBadges(promptModel).some(b => b.label === '<think>')
: false
}
streaming={i === chatMessages.length - 1 && promptLoading}
/>
) : (
msg.content
)}
</div>
</div>
))}
@ -2112,21 +2184,15 @@ export default function Dashboard() {
{promptModel}
</p>
)}
<pre
className="text-sm whitespace-pre-wrap"
style={{
color: 'var(--text-secondary)',
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
}}
>
{promptResponse}
{promptLoading && (
<span
className="inline-block w-2 h-4 ml-0.5 animate-pulse"
style={{ background: 'var(--accent-primary)' }}
/>
)}
</pre>
<MarkdownResponse
content={promptResponse}
isThinkModel={
promptModel
? getModelBadges(promptModel).some(b => b.label === '<think>')
: false
}
streaming={promptLoading}
/>
</div>
{compareModel && (
<div
@ -2142,15 +2208,14 @@ export default function Dashboard() {
>
{compareModel}
</p>
<pre
className="text-sm whitespace-pre-wrap"
style={{
color: 'var(--text-secondary)',
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
}}
>
{compareResponse || 'Generating...'}
</pre>
<MarkdownResponse
content={compareResponse || 'Generating...'}
isThinkModel={
compareModel
? getModelBadges(compareModel).some(b => b.label === '<think>')
: false
}
/>
</div>
)}
</div>