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 { MessageThread } from './MessageThread';
|
||||||
import { ContextBar } from './ContextBar';
|
import { ContextBar } from './ContextBar';
|
||||||
import { estimateTokens, getModelContextWindow } from '../../lib/format';
|
import { estimateTokens, getModelContextWindow } from '../../lib/format';
|
||||||
|
import { autoDetectDefaults, classifyTask, resolveModel } from '../../lib/router';
|
||||||
|
import type { ModelDefaults } from '../../lib/types';
|
||||||
|
|
||||||
interface ConversationViewProps {
|
interface ConversationViewProps {
|
||||||
conversation: Conversation;
|
conversation: Conversation;
|
||||||
@ -27,9 +29,11 @@ export function ConversationView({
|
|||||||
const [title, setTitle] = useState(conversation.title);
|
const [title, setTitle] = useState(conversation.title);
|
||||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||||
const [streaming, setStreaming] = useState(false);
|
const [streaming, setStreaming] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState(conversation.model);
|
const [selectedModel, setSelectedModel] = useState(conversation.model || '__auto__');
|
||||||
const [models, setModels] = useState<string[]>([]);
|
const [models, setModels] = useState<string[]>([]);
|
||||||
|
const [runningModels, setRunningModels] = useState<string[]>([]);
|
||||||
const [showModels, setShowModels] = useState(false);
|
const [showModels, setShowModels] = useState(false);
|
||||||
|
const [modelDefaults, setModelDefaults] = useState<ModelDefaults | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const usedTokens = useMemo(() => {
|
const usedTokens = useMemo(() => {
|
||||||
@ -39,11 +43,28 @@ export function ConversationView({
|
|||||||
const maxTokens = getModelContextWindow(selectedModel);
|
const maxTokens = getModelContextWindow(selectedModel);
|
||||||
|
|
||||||
const ensureModels = async () => {
|
const ensureModels = async () => {
|
||||||
if (models.length > 0) return;
|
if (models.length > 0 && modelDefaults) return;
|
||||||
const res = await fetch('/api/ollama');
|
const res = await fetch('/api/ollama');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = (await res.json()) as OllamaData;
|
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[]) => {
|
const persistConversationMeta = async (nextMessages: Message[]) => {
|
||||||
@ -86,6 +107,18 @@ export function ConversationView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onSend = async (text: string) => {
|
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 now = Date.now();
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@ -93,7 +126,7 @@ export function ConversationView({
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
content: text,
|
content: text,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
model: selectedModel,
|
model: routedModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
await addMessage(userMessage);
|
await addMessage(userMessage);
|
||||||
@ -115,7 +148,12 @@ export function ConversationView({
|
|||||||
const res = await fetch('/api/ollama/chat', {
|
const res = await fetch('/api/ollama/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -154,7 +192,7 @@ export function ConversationView({
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantContent,
|
content: assistantContent,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
model: selectedModel,
|
model: routedModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
@ -177,7 +215,7 @@ export function ConversationView({
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantContent,
|
content: assistantContent,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
model: selectedModel,
|
model: routedModel,
|
||||||
metrics: {
|
metrics: {
|
||||||
tokensPerSec,
|
tokensPerSec,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
@ -202,7 +240,7 @@ export function ConversationView({
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `Error: ${String(err)}`,
|
content: `Error: ${String(err)}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
model: selectedModel,
|
model: routedModel,
|
||||||
};
|
};
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const withoutTemp = prev.filter(m => m.id !== assistantId);
|
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)]"
|
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} />
|
<ChevronDown size={12} />
|
||||||
</button>
|
</button>
|
||||||
{showModels && models.length > 0 ? (
|
{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">
|
<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 => (
|
{models.map(model => (
|
||||||
<button
|
<button
|
||||||
key={model}
|
key={model}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Send, Square } from 'lucide-react';
|
import { ImagePlus, Mic, Paperclip, Send, Square, X } from 'lucide-react';
|
||||||
|
import type { Attachment } from '../../lib/types';
|
||||||
|
|
||||||
interface InputBarProps {
|
interface InputBarProps {
|
||||||
onSend: (text: string) => Promise<void>;
|
onSend: (text: string, attachments?: Attachment[]) => Promise<void>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
@ -21,6 +22,12 @@ export function InputBar({
|
|||||||
examplePrompts = [],
|
examplePrompts = [],
|
||||||
}: InputBarProps) {
|
}: InputBarProps) {
|
||||||
const [text, setText] = useState(initialText);
|
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(() => {
|
useEffect(() => {
|
||||||
setText(initialText);
|
setText(initialText);
|
||||||
@ -28,13 +35,95 @@ export function InputBar({
|
|||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || disabled || streaming) return;
|
if ((!trimmed && attachments.length === 0) || disabled || streaming) return;
|
||||||
await onSend(trimmed);
|
await onSend(trimmed, attachments);
|
||||||
setText('');
|
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 (
|
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() && (
|
{examplePrompts.length > 0 && !text.trim() && (
|
||||||
<div className="mb-2 flex flex-wrap gap-1">
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
{examplePrompts.slice(0, 4).map(prompt => (
|
{examplePrompts.slice(0, 4).map(prompt => (
|
||||||
@ -49,7 +138,87 @@ export function InputBar({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 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
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={e => setText(e.target.value)}
|
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