diff --git a/src/renderer/features/fill-form/FillForm.tsx b/src/renderer/features/fill-form/FillForm.tsx index 0785ad6..9ce1693 100644 --- a/src/renderer/features/fill-form/FillForm.tsx +++ b/src/renderer/features/fill-form/FillForm.tsx @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react' import { useStore } from '../../store' import { useSpeechRecording } from './hooks' -import { fillTemplate, fetchModels } from '../../lib/api' -import { saveLastOutputPath } from '../../lib/storage' +import { fillTemplate, fetchModels, pullModel } from '../../lib/api' +import { saveLastOutputPath, loadSelectedModel, saveSelectedModel } from '../../lib/storage' import { pluralize } from '../../lib/utils' import { TYPE_VALUE_TO_LABEL } from '../../lib/constants' import { WeatherModal } from '../weather-forecast/WeatherForecast' @@ -22,9 +22,14 @@ export function FillForm() { const del = useDeleteTemplate() const [inputText, setInputText] = useState('') + const AVAILABLE_MODELS = ['qwen2.5:1.5b', 'qwen2.5:3b', 'qwen2.5:7b', 'llama3.2:3b', 'mistral:7b'] + const [model, setModel] = useState('') const [models, setModels] = useState([]) const [defaultModel, setDefaultModel] = useState('') + const [isInstallingModel, setIsInstallingModel] = useState(false) + const [pullPercent, setPullPercent] = useState(0) + const [pullStatus, setPullStatus] = useState('') const [status, setStatus] = useState({ message: '', type: '' }) const [jsonResponse, setJsonResponse] = useState(null) const [selectionError, setSelectionError] = useState(false) @@ -51,13 +56,64 @@ export function FillForm() { .then(data => { setModels(data.models || []) setDefaultModel(data.default || '') - setModel(data.default || '') + const saved = loadSelectedModel() + if (saved) { + setModel(saved) + } else { + setModel(data.default || '') + } }) .catch(() => { // leave default empty — server will use its default }) }, []) + async function handleModelChange(selected: string) { + if (!selected) return + saveSelectedModel(selected) + setModel(selected) + + // Check if the selected model is already in our list of installed models. + const isInstalled = models.some(m => m.toLowerCase().includes(selected.toLowerCase())) + if (!isInstalled) { + setIsInstallingModel(true) + setPullPercent(0) + setPullStatus('Starting download…') + setStatus({ + message: `Downloading "${selected}" — this may take several minutes.`, + type: 'info', + }) + try { + await pullModel(selected, (percent, statusText) => { + setPullPercent(percent) + setPullStatus(statusText || 'Downloading…') + }) + // Refresh models list + const data = await fetchModels() + setModels(data.models || []) + setPullPercent(100) + setPullStatus('Done!') + setStatus({ + message: `Model "${selected}" downloaded and installed successfully!`, + type: 'success', + }) + } catch (e: unknown) { + setStatus({ + message: `Failed to install model "${selected}": ${(e as Error).message}`, + type: 'error', + }) + const saved = loadSelectedModel() + setModel(saved || defaultModel) + } finally { + setIsInstallingModel(false) + setPullPercent(0) + setPullStatus('') + } + } else { + setStatus({ message: `Switched to model: ${selected}`, type: 'info' }) + } + } + const count = selectedFillIds.length async function handleSubmit(e: React.FormEvent) { @@ -237,19 +293,47 @@ export function FillForm() { + {isInstallingModel && ( +
+
+ {pullStatus} + {pullPercent}% +
+
+
+
+
+ )} +

External APIs

@@ -284,7 +368,8 @@ export function FillForm() { diff --git a/src/renderer/lib/api.ts b/src/renderer/lib/api.ts index 96297e8..a577d87 100644 --- a/src/renderer/lib/api.ts +++ b/src/renderer/lib/api.ts @@ -111,6 +111,56 @@ export async function fetchModels(): Promise<{ models: string[]; default: string return body as { models: string[]; default: string } } +export async function pullModel( + model: string, + onProgress: (percent: number, status: string) => void +): Promise { + const response = await fetch(`${API_BASE_URL}/forms/pull`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model }), + }) + + if (!response.ok) { + const body = await parseJsonResponse(response) + throw new Error(extractErrorMessage(body, response.status)) + } + + const reader = response.body?.getReader() + if (!reader) return + + 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) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const data = JSON.parse(trimmed) as Record + if (typeof data['error'] === 'string') throw new Error(data['error']) + const status = typeof data['status'] === 'string' ? data['status'] : '' + const completed = typeof data['completed'] === 'number' ? data['completed'] : 0 + const total = typeof data['total'] === 'number' ? data['total'] : 0 + const percent = total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0 + onProgress(percent, status) + } catch (err) { + if (err instanceof Error && err.message && !err.message.startsWith('JSON')) { + throw err + } + // ignore incomplete JSON fragments + } + } + } +} + export async function fillTemplate(payload: { template_id: number input_text: string diff --git a/src/renderer/lib/storage.ts b/src/renderer/lib/storage.ts index 3a009ec..7a2cfbd 100644 --- a/src/renderer/lib/storage.ts +++ b/src/renderer/lib/storage.ts @@ -36,3 +36,13 @@ export function loadTemplatesView(): TemplatesView { export function saveTemplatesView(view: TemplatesView): void { localStorage.setItem(TEMPLATES_VIEW_KEY, view) } + +const SELECTED_MODEL_KEY = 'fireform.selectedModel.v1' + +export function loadSelectedModel(): string { + return localStorage.getItem(SELECTED_MODEL_KEY) || '' +} + +export function saveSelectedModel(model: string): void { + localStorage.setItem(SELECTED_MODEL_KEY, model) +} diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 1de1637..6562439 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -852,6 +852,55 @@ button.is-disabled:hover { margin-bottom: 16px; } +/* ── Model pull progress bar ────────────────────────────────────── */ +.model-pull-progress { + margin: 8px 0 16px; + padding: 10px 14px; + background: var(--bg-secondary, rgba(255,255,255,0.05)); + border: 1px solid var(--border, rgba(255,255,255,0.1)); + border-radius: 8px; +} + +.model-pull-progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 0.8rem; + gap: 8px; +} + +.model-pull-status { + color: var(--text-secondary, #aaa); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.model-pull-percent { + font-weight: 600; + color: var(--accent, #e74c3c); + min-width: 36px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.model-pull-bar-track { + height: 6px; + background: var(--border, rgba(255,255,255,0.12)); + border-radius: 999px; + overflow: hidden; +} + +.model-pull-bar-fill { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #e74c3c, #ff6b6b); + transition: width 0.3s ease; + min-width: 2px; +} + @keyframes fadeIn { from {