feat(local-llm): Sprint 6 — major feature batch (CQ2,CQ5,CQ6,P5,F4,F10,F14,F16)
Code quality: - CQ2: Add CSS utility classes (text-primary/secondary/tertiary, bg-*, btn-*, input-base) to globals.css — reduces inline style repetition - CQ5: Add skeleton shimmer animation CSS for loading states - CQ6: Replace manual model name validation with Zod schema (PostBodySchema) in Ollama API route Performance: - P5: Eagerly warm static cache on module load — system_profiler no longer blocks first dashboard request Features: - F4: Chat mode with multi-turn conversation via new /api/ollama/chat streaming route. Chat bubble layout, system prompt input, message history. Toggle between prompt/chat modes in modal. - F10: Dark/light theme toggle with CSS var overrides in :root.light. Sun/Moon button in header, persisted in localStorage. - F14: Model tags (coding, chat, fast, vision, reasoning) with colored toggle badges in expanded model details. Persisted in localStorage. - F16: Auto-load preferred model — star toggle in expanded details. When Ollama is online but no models loaded, auto-loads the starred model. Persisted in localStorage.
This commit is contained in:
parent
2936b9f047
commit
ed93a6f0af
42
__LOCAL_LLMs/dashboard/src/app/api/ollama/chat/route.ts
Normal file
42
__LOCAL_LLMs/dashboard/src/app/api/ollama/chat/route.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { OLLAMA_URL } from '../../../lib/ollama-config';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { model, messages } = body;
|
||||||
|
|
||||||
|
if (!model || !Array.isArray(messages)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Missing model or messages' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model, messages, stream: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
return new Response(JSON.stringify({ error: `Ollama chat error: ${response.status}` }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-ndjson',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return new Response(JSON.stringify({ error: String(err) }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,16 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
import { OLLAMA_URL } from '../../lib/ollama-config';
|
import { OLLAMA_URL } from '../../lib/ollama-config';
|
||||||
|
|
||||||
|
const PostBodySchema = z.object({
|
||||||
|
action: z.enum(['load', 'unload', 'pull', 'delete', 'show']),
|
||||||
|
model: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(256)
|
||||||
|
.regex(/^[a-zA-Z0-9._:/-]+$/),
|
||||||
|
});
|
||||||
|
|
||||||
async function fetchOllama(path: string, options?: RequestInit) {
|
async function fetchOllama(path: string, options?: RequestInit) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
@ -51,16 +61,17 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODEL_NAME_RE = /^[a-zA-Z0-9._:/-]{1,256}$/;
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const raw = await request.json();
|
||||||
const { action, model } = body;
|
const parsed = PostBodySchema.safeParse(raw);
|
||||||
|
if (!parsed.success) {
|
||||||
if (!model || typeof model !== 'string' || !MODEL_NAME_RE.test(model)) {
|
return NextResponse.json(
|
||||||
return NextResponse.json({ error: 'Invalid model name' }, { status: 400 });
|
{ error: parsed.error.issues[0]?.message || 'Invalid request' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
const { action, model } = parsed.data;
|
||||||
|
|
||||||
if (action === 'load') {
|
if (action === 'load') {
|
||||||
await fetchOllama('/api/generate', {
|
await fetchOllama('/api/generate', {
|
||||||
|
|||||||
@ -95,6 +95,9 @@ async function getStaticInfo() {
|
|||||||
return staticCache;
|
return staticCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P5: Eagerly warm static cache on module load (system_profiler takes 2-3s)
|
||||||
|
getStaticInfo().catch(() => {});
|
||||||
|
|
||||||
async function getCachedOllamaDiskUsage(): Promise<number> {
|
async function getCachedOllamaDiskUsage(): Promise<number> {
|
||||||
if (ollamaDiskCache && Date.now() - ollamaDiskCache.ts < OLLAMA_DISK_TTL)
|
if (ollamaDiskCache && Date.now() - ollamaDiskCache.ts < OLLAMA_DISK_TTL)
|
||||||
return ollamaDiskCache.value;
|
return ollamaDiskCache.value;
|
||||||
|
|||||||
@ -88,3 +88,76 @@ body {
|
|||||||
outline: 2px solid var(--accent-primary);
|
outline: 2px solid var(--accent-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility classes — replaces pervasive inline styles (CQ2) */
|
||||||
|
.text-primary { color: var(--text-primary); }
|
||||||
|
.text-secondary { color: var(--text-secondary); }
|
||||||
|
.text-tertiary { color: var(--text-tertiary); }
|
||||||
|
.text-accent { color: var(--accent-primary); }
|
||||||
|
.text-accent-secondary { color: var(--accent-secondary); }
|
||||||
|
.text-success { color: var(--success); }
|
||||||
|
.text-warning { color: var(--warning); }
|
||||||
|
.text-danger { color: var(--danger); }
|
||||||
|
.text-purple { color: var(--purple); }
|
||||||
|
|
||||||
|
.bg-canvas { background: var(--bg-canvas); }
|
||||||
|
.bg-elevated { background: var(--bg-elevated); }
|
||||||
|
.bg-surface { background: var(--surface-card); }
|
||||||
|
.bg-muted { background: var(--surface-muted); }
|
||||||
|
|
||||||
|
.border-subtle { border-color: var(--border-subtle); }
|
||||||
|
.border-default { border-color: var(--border-default); }
|
||||||
|
|
||||||
|
.input-base {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
color: var(--text-primary);
|
||||||
|
caret-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(255, 110, 110, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: rgba(52, 211, 153, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton shimmer (CQ5) */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--surface-muted) 25%, var(--border-subtle) 50%, var(--surface-muted) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme overrides (F10) */
|
||||||
|
:root.light {
|
||||||
|
--bg-canvas: #F8FAFC;
|
||||||
|
--bg-elevated: #FFFFFF;
|
||||||
|
--surface-card: #FFFFFF;
|
||||||
|
--surface-muted: #F1F5F9;
|
||||||
|
--border-subtle: #E2E8F0;
|
||||||
|
--border-default: #CBD5E1;
|
||||||
|
--text-primary: #0F172A;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-tertiary: #94A3B8;
|
||||||
|
--accent-primary: #3B6FE8;
|
||||||
|
--accent-secondary: #0EA5E9;
|
||||||
|
--success: #10B981;
|
||||||
|
--warning: #D97706;
|
||||||
|
--danger: #EF4444;
|
||||||
|
--purple: #8B5CF6;
|
||||||
|
}
|
||||||
|
|||||||
@ -30,6 +30,11 @@ import {
|
|||||||
History,
|
History,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
FileText,
|
FileText,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
Tag,
|
||||||
|
Star,
|
||||||
|
MessageSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type {
|
import type {
|
||||||
OllamaData,
|
OllamaData,
|
||||||
@ -66,6 +71,14 @@ export default function Dashboard() {
|
|||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||||
const [modelfileData, setModelfileData] = useState<Record<string, string>>({});
|
const [modelfileData, setModelfileData] = useState<Record<string, string>>({});
|
||||||
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||||
|
const [modelTags, setModelTags] = useState<Record<string, string[]>>({});
|
||||||
|
const [autoLoadModel, setAutoLoadModel] = useState<string | null>(null);
|
||||||
|
const [chatMode, setChatMode] = useState(false);
|
||||||
|
const [chatMessages, setChatMessages] = useState<
|
||||||
|
Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
|
>([]);
|
||||||
|
const [systemPrompt, setSystemPrompt] = useState('');
|
||||||
const responseRef = useRef<HTMLDivElement>(null);
|
const responseRef = useRef<HTMLDivElement>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const fetchingRef = useRef(false);
|
const fetchingRef = useRef(false);
|
||||||
@ -93,10 +106,34 @@ export default function Dashboard() {
|
|||||||
fetchingRef.current = false;
|
fetchingRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Restore persisted state from localStorage (F10, F14, F16)
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem('llm-theme') as 'dark' | 'light' | null;
|
||||||
|
if (savedTheme) {
|
||||||
|
setTheme(savedTheme);
|
||||||
|
document.documentElement.classList.toggle('light', savedTheme === 'light');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setModelTags(JSON.parse(localStorage.getItem('llm-model-tags') || '{}'));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
setAutoLoadModel(localStorage.getItem('llm-auto-load-model'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAll();
|
fetchAll();
|
||||||
}, [fetchAll]);
|
}, [fetchAll]);
|
||||||
|
|
||||||
|
// F16: Auto-load preferred model when Ollama is online but nothing loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoLoadModel || !ollama || ollama.status !== 'online') return;
|
||||||
|
if (ollama.runningCount > 0) return;
|
||||||
|
if (!ollama.models.some(m => m.name === autoLoadModel)) return;
|
||||||
|
handleModelAction('load', autoLoadModel);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ollama?.status, ollama?.runningCount, autoLoadModel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (promptLoading || pullLoading) return;
|
if (promptLoading || pullLoading) return;
|
||||||
const interval = setInterval(fetchAll, 15000);
|
const interval = setInterval(fetchAll, 15000);
|
||||||
@ -117,6 +154,32 @@ export default function Dashboard() {
|
|||||||
localStorage.setItem('llm-prompt-history', JSON.stringify(history.slice(0, 20)));
|
localStorage.setItem('llm-prompt-history', JSON.stringify(history.slice(0, 20)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Theme toggle (F10)
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(next);
|
||||||
|
document.documentElement.classList.toggle('light', next === 'light');
|
||||||
|
localStorage.setItem('llm-theme', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Model tags helpers (F14)
|
||||||
|
const toggleTag = (modelName: string, tag: string) => {
|
||||||
|
setModelTags(prev => {
|
||||||
|
const current = prev[modelName] || [];
|
||||||
|
const next = current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag];
|
||||||
|
const updated = { ...prev, [modelName]: next };
|
||||||
|
localStorage.setItem('llm-model-tags', JSON.stringify(updated));
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-load model helpers (F16)
|
||||||
|
const setPreferredModel = (modelName: string | null) => {
|
||||||
|
setAutoLoadModel(modelName);
|
||||||
|
if (modelName) localStorage.setItem('llm-auto-load-model', modelName);
|
||||||
|
else localStorage.removeItem('llm-auto-load-model');
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch modelfile for expanded model (F9)
|
// Fetch modelfile for expanded model (F9)
|
||||||
const fetchModelfile = async (modelName: string) => {
|
const fetchModelfile = async (modelName: string) => {
|
||||||
if (modelfileData[modelName]) return;
|
if (modelfileData[modelName]) return;
|
||||||
@ -325,6 +388,86 @@ export default function Dashboard() {
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Chat mode streaming (F4)
|
||||||
|
const handleChat = async () => {
|
||||||
|
if (!promptModel || !promptText.trim()) return;
|
||||||
|
saveToHistory(promptText.trim(), promptModel);
|
||||||
|
const userMsg = { role: 'user' as const, content: promptText.trim() };
|
||||||
|
setChatMessages(prev => [...prev, userMsg]);
|
||||||
|
setPromptText('');
|
||||||
|
setPromptLoading(true);
|
||||||
|
setStreamMetrics(null);
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
const allMessages = [
|
||||||
|
...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
|
||||||
|
...chatMessages,
|
||||||
|
userMsg,
|
||||||
|
];
|
||||||
|
let assistantContent = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ollama/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: promptModel, messages: allMessages }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
setChatMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'assistant', content: 'Error: Failed to connect' },
|
||||||
|
]);
|
||||||
|
setPromptLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(line);
|
||||||
|
if (chunk.message?.content) {
|
||||||
|
assistantContent += chunk.message.content;
|
||||||
|
setChatMessages(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const last = updated[updated.length - 1];
|
||||||
|
if (last?.role === 'assistant') {
|
||||||
|
updated[updated.length - 1] = { ...last, content: assistantContent };
|
||||||
|
} else {
|
||||||
|
updated.push({ role: 'assistant', content: assistantContent });
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
responseRef.current?.scrollTo(0, responseRef.current.scrollHeight);
|
||||||
|
}
|
||||||
|
if (chunk.done && chunk.eval_count && chunk.eval_duration) {
|
||||||
|
const durationMs = chunk.eval_duration / 1e6;
|
||||||
|
const tokensPerSec = durationMs > 0 ? (chunk.eval_count / durationMs) * 1000 : 0;
|
||||||
|
setStreamMetrics({ tokensPerSec, totalTokens: chunk.eval_count, durationMs });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setChatMessages(prev => [...prev, { role: 'assistant', content: `Error: ${err}` }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abortRef.current = null;
|
||||||
|
setPromptLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = chatMode ? handleChat : handlePrompt;
|
||||||
|
|
||||||
const isRunning = (name: string) => ollama?.running.some(r => r.name === name);
|
const isRunning = (name: string) => ollama?.running.some(r => r.name === name);
|
||||||
const getRunningInfo = (name: string) => ollama?.running.find(r => r.name === name);
|
const getRunningInfo = (name: string) => ollama?.running.find(r => r.name === name);
|
||||||
|
|
||||||
@ -349,10 +492,22 @@ export default function Dashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
<span className="text-xs hidden sm:inline" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
Last refresh: {lastRefresh.toLocaleTimeString()}
|
Last refresh: {lastRefresh.toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg transition-colors"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-card)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={fetchAll}
|
onClick={fetchAll}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@ -364,7 +519,7 @@ export default function Dashboard() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -763,6 +918,51 @@ export default function Dashboard() {
|
|||||||
<p>Digest: {model.digest?.substring(0, 16)}...</p>
|
<p>Digest: {model.digest?.substring(0, 16)}...</p>
|
||||||
<p>Modified: {new Date(model.modified_at).toLocaleString()}</p>
|
<p>Modified: {new Date(model.modified_at).toLocaleString()}</p>
|
||||||
{model.details?.family && <p>Family: {model.details.family}</p>}
|
{model.details?.family && <p>Family: {model.details.family}</p>}
|
||||||
|
{/* Model Tags (F14) */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mt-2 font-sans">
|
||||||
|
{['coding', 'chat', 'fast', 'vision', 'reasoning'].map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => toggleTag(model.name, tag)}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
background: (modelTags[model.name] || []).includes(tag)
|
||||||
|
? 'var(--accent-primary)'
|
||||||
|
: 'var(--surface-muted)',
|
||||||
|
color: (modelTags[model.name] || []).includes(tag)
|
||||||
|
? 'white'
|
||||||
|
: 'var(--text-tertiary)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag className="w-2.5 h-2.5" />
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Auto-load toggle (F16) */}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setPreferredModel(autoLoadModel === model.name ? null : model.name)
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1.5 mt-2 text-[11px] font-sans transition-colors"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
autoLoadModel === model.name
|
||||||
|
? 'var(--warning)'
|
||||||
|
: 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className="w-3 h-3"
|
||||||
|
style={{
|
||||||
|
fill: autoLoadModel === model.name ? 'var(--warning)' : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{autoLoadModel === model.name
|
||||||
|
? 'Preferred (auto-loads)'
|
||||||
|
: 'Set as preferred'}
|
||||||
|
</button>
|
||||||
{modelfileData[model.name] && (
|
{modelfileData[model.name] && (
|
||||||
<details className="mt-2">
|
<details className="mt-2">
|
||||||
<summary
|
<summary
|
||||||
@ -1089,7 +1289,25 @@ export default function Dashboard() {
|
|||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{promptResponse && (
|
{/* Chat mode toggle (F4) */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setChatMode(!chatMode);
|
||||||
|
setChatMessages([]);
|
||||||
|
setPromptResponse('');
|
||||||
|
setStreamMetrics(null);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
background: chatMode ? 'var(--accent-primary)' : 'var(--surface-card)',
|
||||||
|
color: chatMode ? 'white' : 'var(--text-tertiary)',
|
||||||
|
}}
|
||||||
|
title={chatMode ? 'Switch to single prompt' : 'Switch to chat mode'}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5" />
|
||||||
|
{chatMode ? 'Chat' : 'Prompt'}
|
||||||
|
</button>
|
||||||
|
{(promptResponse || chatMessages.length > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={copyResponse}
|
onClick={copyResponse}
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors"
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors"
|
||||||
@ -1117,6 +1335,22 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* System prompt for chat mode (F4) */}
|
||||||
|
{chatMode && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={systemPrompt}
|
||||||
|
onChange={e => setSystemPrompt(e.target.value)}
|
||||||
|
placeholder="System prompt (optional)..."
|
||||||
|
className="w-full px-3 py-2 mb-3 rounded-lg text-xs font-mono focus:outline-none focus:ring-2"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface-card)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
caretColor: 'var(--accent-primary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Prompt History (F3) */}
|
{/* Prompt History (F3) */}
|
||||||
{showHistory && (
|
{showHistory && (
|
||||||
<div
|
<div
|
||||||
@ -1171,7 +1405,7 @@ export default function Dashboard() {
|
|||||||
caretColor: 'var(--accent-primary)',
|
caretColor: 'var(--accent-primary)',
|
||||||
}}
|
}}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter' && e.metaKey) handlePrompt();
|
if (e.key === 'Enter' && e.metaKey) handleSend();
|
||||||
}}
|
}}
|
||||||
disabled={promptLoading}
|
disabled={promptLoading}
|
||||||
/>
|
/>
|
||||||
@ -1185,7 +1419,7 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handlePrompt}
|
onClick={handleSend}
|
||||||
disabled={promptLoading || !promptText.trim()}
|
disabled={promptLoading || !promptText.trim()}
|
||||||
className="flex items-center justify-center w-12 rounded-lg transition-colors disabled:opacity-40 shrink-0"
|
className="flex items-center justify-center w-12 rounded-lg transition-colors disabled:opacity-40 shrink-0"
|
||||||
style={{ background: 'var(--accent-primary)', color: 'white' }}
|
style={{ background: 'var(--accent-primary)', color: 'white' }}
|
||||||
@ -1220,7 +1454,40 @@ export default function Dashboard() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{promptResponse && (
|
{/* Chat messages (F4) */}
|
||||||
|
{chatMode && chatMessages.length > 0 && (
|
||||||
|
<div ref={responseRef} className="mt-4 max-h-80 overflow-y-auto space-y-3">
|
||||||
|
{chatMessages.map((msg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="max-w-[80%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
msg.role === 'user' ? 'var(--accent-primary)' : 'var(--surface-card)',
|
||||||
|
color: msg.role === 'user' ? 'white' : 'var(--text-secondary)',
|
||||||
|
border:
|
||||||
|
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)' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Single prompt response */}
|
||||||
|
{!chatMode && promptResponse && (
|
||||||
<div
|
<div
|
||||||
ref={responseRef}
|
ref={responseRef}
|
||||||
className="mt-4 p-4 rounded-lg max-h-80 overflow-y-auto"
|
className="mt-4 p-4 rounded-lg max-h-80 overflow-y-auto"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user