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:
saravanakumardb1 2026-02-20 00:31:54 -08:00
parent 79cf42c8e3
commit d625be283c
3 changed files with 338 additions and 15 deletions

View File

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

View File

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

View 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'),
};
}