feat(local-llm): Phase D model router + multi-modal input (D1-D7)
- add lib/router.ts with task classifier, model hint mapping, resolve fallback chain, and auto-detect defaults - integrate auto-routing mode in conversation model selector with __auto__ option - persist/read model defaults from localStorage (llm-model-defaults) - route prompts to selected/routed model before streaming - add multi-modal input controls (attach file, image, voice) - support attachment chips, removal, drag-and-drop file attach - add audio transcription flow via /api/whisper/transcribe and append result to input - support sending attachments payload alongside text from InputBar
This commit is contained in:
parent
79cf42c8e3
commit
d625be283c
@ -8,6 +8,8 @@ import { InputBar } from './InputBar';
|
||||
import { MessageThread } from './MessageThread';
|
||||
import { ContextBar } from './ContextBar';
|
||||
import { estimateTokens, getModelContextWindow } from '../../lib/format';
|
||||
import { autoDetectDefaults, classifyTask, resolveModel } from '../../lib/router';
|
||||
import type { ModelDefaults } from '../../lib/types';
|
||||
|
||||
interface ConversationViewProps {
|
||||
conversation: Conversation;
|
||||
@ -27,9 +29,11 @@ export function ConversationView({
|
||||
const [title, setTitle] = useState(conversation.title);
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState(conversation.model);
|
||||
const [selectedModel, setSelectedModel] = useState(conversation.model || '__auto__');
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [runningModels, setRunningModels] = useState<string[]>([]);
|
||||
const [showModels, setShowModels] = useState(false);
|
||||
const [modelDefaults, setModelDefaults] = useState<ModelDefaults | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const usedTokens = useMemo(() => {
|
||||
@ -39,11 +43,28 @@ export function ConversationView({
|
||||
const maxTokens = getModelContextWindow(selectedModel);
|
||||
|
||||
const ensureModels = async () => {
|
||||
if (models.length > 0) return;
|
||||
if (models.length > 0 && modelDefaults) return;
|
||||
const res = await fetch('/api/ollama');
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as OllamaData;
|
||||
setModels(data.models.map(m => m.name));
|
||||
const installed = data.models.map(m => m.name);
|
||||
const running = data.running.map(m => m.name);
|
||||
setModels(installed);
|
||||
setRunningModels(running);
|
||||
|
||||
const stored = localStorage.getItem('llm-model-defaults');
|
||||
if (stored) {
|
||||
try {
|
||||
setModelDefaults(JSON.parse(stored) as ModelDefaults);
|
||||
return;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const detected = autoDetectDefaults(installed);
|
||||
setModelDefaults(detected);
|
||||
localStorage.setItem('llm-model-defaults', JSON.stringify(detected));
|
||||
};
|
||||
|
||||
const persistConversationMeta = async (nextMessages: Message[]) => {
|
||||
@ -86,6 +107,18 @@ export function ConversationView({
|
||||
};
|
||||
|
||||
const onSend = async (text: string) => {
|
||||
await ensureModels();
|
||||
|
||||
const routedModel = (() => {
|
||||
if (selectedModel !== '__auto__') return selectedModel;
|
||||
const defaults = modelDefaults || autoDetectDefaults(models);
|
||||
const taskType = classifyTask(text);
|
||||
const resolved = resolveModel(taskType, defaults, runningModels, models);
|
||||
return resolved.model;
|
||||
})();
|
||||
|
||||
if (!routedModel) return;
|
||||
|
||||
const now = Date.now();
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
@ -93,7 +126,7 @@ export function ConversationView({
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: now,
|
||||
model: selectedModel,
|
||||
model: routedModel,
|
||||
};
|
||||
|
||||
await addMessage(userMessage);
|
||||
@ -115,7 +148,12 @@ export function ConversationView({
|
||||
const res = await fetch('/api/ollama/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: selectedModel, messages: chatPayload }),
|
||||
body: JSON.stringify({
|
||||
model: routedModel,
|
||||
modelDefaults: modelDefaults,
|
||||
taskType: classifyTask(text),
|
||||
messages: chatPayload,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@ -154,7 +192,7 @@ export function ConversationView({
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
timestamp: Date.now(),
|
||||
model: selectedModel,
|
||||
model: routedModel,
|
||||
};
|
||||
|
||||
setMessages(prev => {
|
||||
@ -177,7 +215,7 @@ export function ConversationView({
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
timestamp: Date.now(),
|
||||
model: selectedModel,
|
||||
model: routedModel,
|
||||
metrics: {
|
||||
tokensPerSec,
|
||||
totalTokens,
|
||||
@ -202,7 +240,7 @@ export function ConversationView({
|
||||
role: 'assistant',
|
||||
content: `Error: ${String(err)}`,
|
||||
timestamp: Date.now(),
|
||||
model: selectedModel,
|
||||
model: routedModel,
|
||||
};
|
||||
setMessages(prev => {
|
||||
const withoutTemp = prev.filter(m => m.id !== assistantId);
|
||||
@ -247,11 +285,21 @@ export function ConversationView({
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded border border-white/10 bg-[var(--surface-card)] px-2 py-1 text-xs text-[var(--text-secondary)]"
|
||||
>
|
||||
{selectedModel || 'Select model'}
|
||||
{selectedModel === '__auto__' ? 'Auto' : selectedModel || 'Select model'}
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{showModels && models.length > 0 ? (
|
||||
<div className="absolute right-0 z-20 mt-1 max-h-64 w-64 overflow-auto rounded border border-white/10 bg-[var(--surface-card)] p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedModel('__auto__');
|
||||
setShowModels(false);
|
||||
}}
|
||||
className="block w-full rounded px-2 py-1 text-left text-xs text-[var(--accent-secondary)] hover:bg-white/5"
|
||||
>
|
||||
Auto (recommended)
|
||||
</button>
|
||||
{models.map(model => (
|
||||
<button
|
||||
key={model}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Send, Square } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ImagePlus, Mic, Paperclip, Send, Square, X } from 'lucide-react';
|
||||
import type { Attachment } from '../../lib/types';
|
||||
|
||||
interface InputBarProps {
|
||||
onSend: (text: string) => Promise<void>;
|
||||
onSend: (text: string, attachments?: Attachment[]) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
streaming?: boolean;
|
||||
onCancel?: () => void;
|
||||
@ -21,6 +22,12 @@ export function InputBar({
|
||||
examplePrompts = [],
|
||||
}: InputBarProps) {
|
||||
const [text, setText] = useState(initialText);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [voiceLoading, setVoiceLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const audioInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setText(initialText);
|
||||
@ -28,13 +35,95 @@ export function InputBar({
|
||||
|
||||
const handleSend = async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || disabled || streaming) return;
|
||||
await onSend(trimmed);
|
||||
if ((!trimmed && attachments.length === 0) || disabled || streaming) return;
|
||||
await onSend(trimmed, attachments);
|
||||
setText('');
|
||||
setAttachments([]);
|
||||
};
|
||||
|
||||
const readFiles = async (files: FileList | null, forceType?: 'image' | 'file'): Promise<void> => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const next: Attachment[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
if (file.size > 10 * 1024 * 1024) continue;
|
||||
|
||||
const isImage = forceType === 'image' || file.type.startsWith('image/');
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
if (isImage) {
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
reader.readAsText(file);
|
||||
}
|
||||
});
|
||||
|
||||
next.push({
|
||||
type: isImage ? 'image' : 'file',
|
||||
name: file.name,
|
||||
data,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
|
||||
setAttachments(prev => [...prev, ...next]);
|
||||
};
|
||||
|
||||
const transcribeAudio = async (file: File): Promise<void> => {
|
||||
setVoiceLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', file);
|
||||
const res = await fetch('/api/whisper/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = (await res.json()) as { text?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
setText(prev => {
|
||||
const next = data.error || 'Voice transcription failed';
|
||||
return prev ? `${prev}\n${next}` : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const transcribed = (data.text || '').trim();
|
||||
if (transcribed) {
|
||||
setText(prev => (prev ? `${prev}\n${transcribed}` : transcribed));
|
||||
}
|
||||
} catch {
|
||||
setText(prev =>
|
||||
prev ? `${prev}\nVoice transcription failed` : 'Voice transcription failed'
|
||||
);
|
||||
} finally {
|
||||
setVoiceLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-white/10 bg-[var(--bg-elevated)] p-3">
|
||||
<div
|
||||
className="border-t border-white/10 bg-[var(--bg-elevated)] p-3"
|
||||
onDragOver={e => {
|
||||
e.preventDefault();
|
||||
setDragActive(true);
|
||||
}}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
void readFiles(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
{dragActive && (
|
||||
<div className="mb-2 rounded border border-dashed border-[var(--accent-secondary)] px-3 py-2 text-xs text-[var(--accent-secondary)]">
|
||||
Drop files to attach
|
||||
</div>
|
||||
)}
|
||||
|
||||
{examplePrompts.length > 0 && !text.trim() && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{examplePrompts.slice(0, 4).map(prompt => (
|
||||
@ -49,7 +138,87 @@ export function InputBar({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{attachments.map((att, idx) => (
|
||||
<span
|
||||
key={`${att.name}-${idx}`}
|
||||
className="inline-flex items-center gap-1 rounded border border-white/10 bg-[var(--surface-muted)] px-2 py-1 text-[11px] text-[var(--text-secondary)]"
|
||||
title={att.name}
|
||||
>
|
||||
{att.type === 'image' ? '🖼' : '📎'}
|
||||
<span className="max-w-[160px] truncate">{att.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachments(prev => prev.filter((_, i) => i !== idx))}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded border border-white/10 bg-[var(--surface-card)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title="Attach file"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded border border-white/10 bg-[var(--surface-card)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title="Attach image"
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => audioInputRef.current?.click()}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded border border-white/10 bg-[var(--surface-card)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
title="Voice input"
|
||||
disabled={voiceLoading}
|
||||
>
|
||||
<Mic size={14} className={voiceLoading ? 'animate-pulse' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => void readFiles(e.target.files, 'file')}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => void readFiles(e.target.files, 'image')}
|
||||
/>
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
void transcribeAudio(file);
|
||||
}
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
|
||||
106
__LOCAL_LLMs/dashboard/src/app/lib/router.ts
Normal file
106
__LOCAL_LLMs/dashboard/src/app/lib/router.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import type { Attachment, ModelDefaults } from './types';
|
||||
|
||||
export type TaskType =
|
||||
| 'code'
|
||||
| 'code_review'
|
||||
| 'debugging'
|
||||
| 'reasoning'
|
||||
| 'math'
|
||||
| 'simple'
|
||||
| 'translation'
|
||||
| 'creative'
|
||||
| 'general';
|
||||
|
||||
export function classifyTask(input: string, attachments?: Attachment[]): TaskType {
|
||||
if (attachments?.some(a => a.type === 'image')) return 'code';
|
||||
|
||||
const lower = input.toLowerCase();
|
||||
|
||||
if (/```|function |class |const |import |def |=>|\{\s*\n/.test(input)) return 'code';
|
||||
if (/error:|traceback|exception|crashed?|enoent|sigterm/.test(lower)) return 'debugging';
|
||||
|
||||
if (/review|refactor|optimize|lint/.test(lower)) return 'code_review';
|
||||
if (/debug|fix.*bug|why.*(fail|break|wrong)/.test(lower)) return 'debugging';
|
||||
if (/think.*through|step.by.step|analyze|compare|pros.*cons|trade.?off/.test(lower)) {
|
||||
return 'reasoning';
|
||||
}
|
||||
if (/calculate|math|equation|proof|probability|statistic/.test(lower)) return 'math';
|
||||
if (/translate|translation|in (spanish|french|german|japanese|chinese|korean)/.test(lower)) {
|
||||
return 'translation';
|
||||
}
|
||||
if (/brainstorm|creative|story|poem|write.*about|imagine/.test(lower)) return 'creative';
|
||||
|
||||
if (input.length < 80) return 'simple';
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
export function taskTypeToHint(taskType: TaskType): keyof ModelDefaults {
|
||||
if (taskType === 'code' || taskType === 'code_review' || taskType === 'debugging')
|
||||
return 'coding';
|
||||
if (taskType === 'reasoning' || taskType === 'math') return 'reasoning';
|
||||
if (taskType === 'translation' || taskType === 'creative') return 'chat';
|
||||
if (taskType === 'simple') return 'fast';
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
export function matchesHint(modelName: string, hint: keyof ModelDefaults): boolean {
|
||||
const n = modelName.toLowerCase();
|
||||
if (hint === 'coding') return /coder|code|deepseek|starcoder|codestral/.test(n);
|
||||
if (hint === 'reasoning') return /r1|reason|think|deepseek/.test(n);
|
||||
if (hint === 'vision') return /llava|vision|vl|moondream/.test(n);
|
||||
if (hint === 'fast') return /mini|tiny|1b|3b|7b/.test(n);
|
||||
if (hint === 'chat') return /llama|mistral|qwen|chat/.test(n);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function resolveModel(
|
||||
taskType: TaskType,
|
||||
defaults: ModelDefaults,
|
||||
loadedModels: string[],
|
||||
installedModels: string[]
|
||||
): { model: string; reason: string; loaded: boolean } {
|
||||
const hint = taskTypeToHint(taskType);
|
||||
const preferred = defaults[hint];
|
||||
|
||||
if (preferred && loadedModels.includes(preferred)) {
|
||||
return { model: preferred, reason: `${taskType} detected`, loaded: true };
|
||||
}
|
||||
|
||||
const loadedMatch = loadedModels.find(m => matchesHint(m, hint));
|
||||
if (loadedMatch) {
|
||||
return {
|
||||
model: loadedMatch,
|
||||
reason: `${taskType} detected (using loaded model)`,
|
||||
loaded: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (preferred && installedModels.includes(preferred)) {
|
||||
return { model: preferred, reason: `${taskType} detected (will load model)`, loaded: false };
|
||||
}
|
||||
|
||||
if (loadedModels.length > 0) {
|
||||
return { model: loadedModels[0], reason: 'Using currently loaded model', loaded: true };
|
||||
}
|
||||
|
||||
return {
|
||||
model: installedModels[0] || '',
|
||||
reason: installedModels.length > 0 ? 'Using first available model' : 'No model available',
|
||||
loaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function autoDetectDefaults(models: string[]): ModelDefaults {
|
||||
const pick = (hint: keyof ModelDefaults): string => {
|
||||
return models.find(m => matchesHint(m, hint)) || models[0] || '';
|
||||
};
|
||||
|
||||
return {
|
||||
fast: pick('fast'),
|
||||
coding: pick('coding'),
|
||||
reasoning: pick('reasoning'),
|
||||
chat: pick('chat'),
|
||||
vision: pick('vision'),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user