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"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@ -4,12 +4,12 @@ import { OLLAMA_URL } from '../../../lib/ollama-config';
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { model, prompt } = body;
|
const { model, prompt, images } = body;
|
||||||
|
|
||||||
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
const response = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
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 { ProgressBar } from './components/ProgressBar';
|
||||||
import { Sparkline } from './components/Sparkline';
|
import { Sparkline } from './components/Sparkline';
|
||||||
import { RamBudgetBar } from './components/RamBudgetBar';
|
import { RamBudgetBar } from './components/RamBudgetBar';
|
||||||
|
import { MarkdownResponse } from './components/MarkdownResponse';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [ollama, setOllama] = useState<OllamaData | null>(null);
|
const [ollama, setOllama] = useState<OllamaData | null>(null);
|
||||||
@ -114,6 +115,7 @@ export default function Dashboard() {
|
|||||||
Record<string, { prompts: number; tokens: number }>
|
Record<string, { prompts: number; tokens: number }>
|
||||||
>({});
|
>({});
|
||||||
const [countdownTick, setCountdownTick] = useState(0);
|
const [countdownTick, setCountdownTick] = useState(0);
|
||||||
|
const [visionImages, setVisionImages] = useState<string[]>([]);
|
||||||
const responseRef = useRef<HTMLDivElement>(null);
|
const responseRef = useRef<HTMLDivElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const compareAbortRef = 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', {
|
const res = await fetch('/api/ollama/stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
@ -971,7 +977,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pull Model Input */}
|
{/* Pull Model Input + F28: Library Link */}
|
||||||
{ollama?.status === 'online' && (
|
{ollama?.status === 'online' && (
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4">
|
||||||
<input
|
<input
|
||||||
@ -1003,6 +1009,16 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
Pull
|
Pull
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2038,6 +2054,58 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<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)' }}>
|
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
⌘+Enter to send · Esc to close
|
⌘+Enter to send · Esc to close
|
||||||
</span>
|
</span>
|
||||||
@ -2078,15 +2146,19 @@ export default function Dashboard() {
|
|||||||
msg.role === 'assistant' ? '1px solid var(--border-subtle)' : 'none',
|
msg.role === 'assistant' ? '1px solid var(--border-subtle)' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{msg.content}
|
{msg.role === 'assistant' ? (
|
||||||
{i === chatMessages.length - 1 &&
|
<MarkdownResponse
|
||||||
msg.role === 'assistant' &&
|
content={msg.content}
|
||||||
promptLoading && (
|
isThinkModel={
|
||||||
<span
|
promptModel
|
||||||
className="inline-block w-2 h-4 ml-0.5 animate-pulse"
|
? getModelBadges(promptModel).some(b => b.label === '<think>')
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
: false
|
||||||
/>
|
}
|
||||||
)}
|
streaming={i === chatMessages.length - 1 && promptLoading}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
msg.content
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -2112,21 +2184,15 @@ export default function Dashboard() {
|
|||||||
{promptModel}
|
{promptModel}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<pre
|
<MarkdownResponse
|
||||||
className="text-sm whitespace-pre-wrap"
|
content={promptResponse}
|
||||||
style={{
|
isThinkModel={
|
||||||
color: 'var(--text-secondary)',
|
promptModel
|
||||||
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
|
? getModelBadges(promptModel).some(b => b.label === '<think>')
|
||||||
}}
|
: false
|
||||||
>
|
}
|
||||||
{promptResponse}
|
streaming={promptLoading}
|
||||||
{promptLoading && (
|
/>
|
||||||
<span
|
|
||||||
className="inline-block w-2 h-4 ml-0.5 animate-pulse"
|
|
||||||
style={{ background: 'var(--accent-primary)' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
{compareModel && (
|
{compareModel && (
|
||||||
<div
|
<div
|
||||||
@ -2142,15 +2208,14 @@ export default function Dashboard() {
|
|||||||
>
|
>
|
||||||
{compareModel}
|
{compareModel}
|
||||||
</p>
|
</p>
|
||||||
<pre
|
<MarkdownResponse
|
||||||
className="text-sm whitespace-pre-wrap"
|
content={compareResponse || 'Generating...'}
|
||||||
style={{
|
isThinkModel={
|
||||||
color: 'var(--text-secondary)',
|
compareModel
|
||||||
fontFamily: "'SF Mono', 'Fira Code', ui-monospace, monospace",
|
? getModelBadges(compareModel).some(b => b.label === '<think>')
|
||||||
}}
|
: false
|
||||||
>
|
}
|
||||||
{compareResponse || 'Generating...'}
|
/>
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user