Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 97 additions & 12 deletions src/renderer/features/fill-form/FillForm.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<string[]>([])
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<unknown>(null)
const [selectionError, setSelectionError] = useState(false)
Expand All @@ -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) {
Expand Down Expand Up @@ -237,19 +293,47 @@ export function FillForm() {
<select
id="fillModel"
value={model}
onChange={e => setModel(e.target.value)}
onChange={e => handleModelChange(e.target.value)}
disabled={isInstallingModel}
>
{models.length === 0 ? (
<option value="">(default model)</option>
) : (
models.map(m => (
{Array.from(
new Set([
defaultModel,
...models,
...AVAILABLE_MODELS,
].filter(Boolean))
).map(m => {
const isInstalled = models.some(inst => inst.toLowerCase() === m.toLowerCase()) || m === defaultModel
const suffix = isInstallingModel && m === model
? ' (installing...)'
: !isInstalled
? ' (not installed)'
: m === defaultModel
? ' (default)'
: ''
return (
<option key={m} value={m}>
{m === defaultModel ? `${m} (default)` : m}
{m}{suffix}
</option>
))
)}
)
})}
</select>

{isInstallingModel && (
<div className="model-pull-progress" aria-live="polite">
<div className="model-pull-progress-header">
<span className="model-pull-status">{pullStatus}</span>
<span className="model-pull-percent">{pullPercent}%</span>
</div>
<div className="model-pull-bar-track" role="progressbar" aria-valuenow={pullPercent} aria-valuemin={0} aria-valuemax={100}>
<div
className="model-pull-bar-fill"
style={{ width: `${pullPercent}%` }}
/>
</div>
</div>
)}

<p><b>External APIs</b></p>

<div className="horizontal-layout">
Expand Down Expand Up @@ -284,7 +368,8 @@ export function FillForm() {

<button
type="submit"
className={count === 0 ? 'is-disabled' : ''}
className={count === 0 || isInstallingModel ? 'is-disabled' : ''}
disabled={count === 0 || isInstallingModel}
>
{count > 1 ? `Fill ${count} Forms` : 'Fill Form'}
</button>
Expand Down
50 changes: 50 additions & 0 deletions src/renderer/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string, unknown>
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
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
49 changes: 49 additions & 0 deletions src/renderer/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down