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:
parent
588d21c70e
commit
44ad8a6301
1333
__LOCAL_LLMs/dashboard/package-lock.json
generated
1333
__LOCAL_LLMs/dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
213
__LOCAL_LLMs/dashboard/src/app/components/MarkdownResponse.tsx
Normal file
213
__LOCAL_LLMs/dashboard/src/app/components/MarkdownResponse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)' }}
|
||||
>
|
||||
×
|
||||
</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)' }}>
|
||||
⌘+Enter to send · 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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user