Skip to content

Commit 197d4e8

Browse files
committed
feat(ui): Add model pipeline editor
This creates a new model config page. Presently just allows configuring pipelines, but can be extending the future to other types of models. However pipelines are quite easy to create a form for and require editing to create. Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 9a9da06 commit 197d4e8

17 files changed

Lines changed: 494 additions & 35 deletions

core/config/model_config.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,10 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
660660
if c.Backend != "whisper" {
661661
return false
662662
}
663+
// whisper models with vad_only option are VAD, not transcription
664+
if slices.Contains(c.Options, "vad_only") {
665+
return false
666+
}
663667
}
664668
if (u & FLAG_TTS) == FLAG_TTS {
665669
ttsBackends := []string{"piper", "transformers-musicgen", "kokoro"}
@@ -689,7 +693,7 @@ func (c *ModelConfig) GuessUsecases(u ModelConfigUsecase) bool {
689693
}
690694

691695
if (u & FLAG_VAD) == FLAG_VAD {
692-
if c.Backend != "silero-vad" {
696+
if c.Backend != "silero-vad" && !(c.Backend == "whisper" && slices.Contains(c.Options, "vad_only")) {
693697
return false
694698
}
695699
}

core/http/react-ui/bun.lock

Lines changed: 185 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/http/react-ui/src/components/SearchableModelSelect.jsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
2727
m.id.toLowerCase().includes(query.toLowerCase())
2828
)
2929

30+
// Which item Enter will select — matches SearchableSelect behavior
31+
const enterTargetIndex = focusIndex >= 0 ? focusIndex
32+
: filtered.length > 0 ? 0
33+
: -1
34+
3035
const commit = useCallback((val) => {
3136
setQuery(val)
3237
onChange(val)
@@ -39,6 +44,11 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
3944
setOpen(true)
4045
return
4146
}
47+
if (!open && e.key === 'Enter') {
48+
e.preventDefault()
49+
commit(query)
50+
return
51+
}
4252
if (!open) return
4353

4454
if (e.key === 'ArrowDown') {
@@ -49,8 +59,8 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
4959
setFocusIndex(i => Math.max(i - 1, 0))
5060
} else if (e.key === 'Enter') {
5161
e.preventDefault()
52-
if (focusIndex >= 0 && focusIndex < filtered.length) {
53-
commit(filtered[focusIndex].id)
62+
if (enterTargetIndex >= 0) {
63+
commit(filtered[enterTargetIndex].id)
5464
} else {
5565
commit(query)
5666
}
@@ -96,9 +106,9 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
96106
padding: 6px 10px;
97107
font-size: 0.8125rem;
98108
cursor: pointer;
99-
white-space: nowrap;
100-
overflow: hidden;
101-
text-overflow: ellipsis;
109+
display: flex;
110+
align-items: center;
111+
gap: 6px;
102112
}
103113
.sms-item:hover, .sms-item.sms-focused {
104114
background: var(--color-bg-tertiary);
@@ -134,19 +144,25 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
134144
{query ? 'No matching models — value will be used as-is' : 'No models available'}
135145
</div>
136146
) : (
137-
filtered.map((m, i) => (
138-
<div
139-
key={m.id}
140-
className={`sms-item${i === focusIndex ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
141-
onMouseEnter={() => setFocusIndex(i)}
142-
onMouseDown={(e) => {
143-
e.preventDefault()
144-
commit(m.id)
145-
}}
146-
>
147-
{m.id}
148-
</div>
149-
))
147+
filtered.map((m, i) => {
148+
const isEnterTarget = i === enterTargetIndex
149+
return (
150+
<div
151+
key={m.id}
152+
className={`sms-item${i === focusIndex || isEnterTarget ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
153+
onMouseEnter={() => setFocusIndex(i)}
154+
onMouseDown={(e) => {
155+
e.preventDefault()
156+
commit(m.id)
157+
}}
158+
>
159+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.id}</span>
160+
{isEnterTarget && (
161+
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', flexShrink: 0 }}></span>
162+
)}
163+
</div>
164+
)
165+
})
150166
)}
151167
</div>
152168
)}

core/http/react-ui/src/pages/AgentCreate.jsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'
22
import { useParams, useNavigate, useLocation, useOutletContext } from 'react-router-dom'
33
import { agentsApi } from '../utils/api'
44
import SearchableModelSelect from '../components/SearchableModelSelect'
5+
import { CAP_CHAT, CAP_TRANSCRIPT, CAP_TTS } from '../utils/capabilities'
56
import Toggle from '../components/Toggle'
67
import SettingRow from '../components/SettingRow'
78

@@ -115,10 +116,10 @@ function FormField({ field, value, onChange, disabled }) {
115116
const isModelField = /^(model|multimodal_model|transcription_model|tts_model|embedding_model)$/.test(field.name)
116117
if (isModelField && !disabled && !field.disabled) {
117118
const capabilityMap = {
118-
model: 'FLAG_CHAT',
119-
multimodal_model: 'FLAG_CHAT',
120-
transcription_model: 'FLAG_TRANSCRIPT',
121-
tts_model: 'FLAG_TTS',
119+
model: CAP_CHAT,
120+
multimodal_model: CAP_CHAT,
121+
transcription_model: CAP_TRANSCRIPT,
122+
tts_model: CAP_TTS,
122123
embedding_model: undefined,
123124
}
124125
return (

core/http/react-ui/src/pages/AgentTaskDetails.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useParams, useNavigate, useOutletContext, useLocation } from 'react-rou
33
import { agentJobsApi } from '../utils/api'
44
import { basePath } from '../utils/basePath'
55
import ModelSelector from '../components/ModelSelector'
6+
import { CAP_CHAT } from '../utils/capabilities'
67
import LoadingSpinner from '../components/LoadingSpinner'
78

89
export default function AgentTaskDetails() {
@@ -322,7 +323,7 @@ export default function AgentTaskDetails() {
322323
</div>
323324
<div className="form-group">
324325
<label className="form-label">Model</label>
325-
<ModelSelector value={task.model} onChange={(model) => updateField('model', model)} capability="FLAG_CHAT" />
326+
<ModelSelector value={task.model} onChange={(model) => updateField('model', model)} capability={CAP_CHAT} />
326327
</div>
327328
</div>
328329
<div className="form-group">

core/http/react-ui/src/pages/Chat.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { renderMarkdown, highlightAll } from '../utils/markdown'
66
import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
77
import CanvasPanel from '../components/CanvasPanel'
88
import { fileToBase64, modelsApi, mcpApi } from '../utils/api'
9+
import { CAP_CHAT } from '../utils/capabilities'
910
import { useMCPClient } from '../hooks/useMCPClient'
1011
import MCPAppFrame from '../components/MCPAppFrame'
1112
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
@@ -875,7 +876,7 @@ export default function Chat() {
875876
<ModelSelector
876877
value={activeChat.model}
877878
onChange={(model) => updateChatSettings(activeChat.id, { model })}
878-
capability="FLAG_CHAT"
879+
capability={CAP_CHAT}
879880
style={{ flex: '1 1 0', minWidth: 120 }}
880881
/>
881882
<div className="chat-header-actions">

core/http/react-ui/src/pages/Home.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
22
import { useNavigate, useOutletContext } from 'react-router-dom'
33
import { apiUrl } from '../utils/basePath'
44
import ModelSelector from '../components/ModelSelector'
5+
import { CAP_CHAT } from '../utils/capabilities'
56
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
67
import { useResources } from '../hooks/useResources'
78
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi } from '../utils/api'
@@ -237,7 +238,7 @@ export default function Home() {
237238
<form onSubmit={handleSubmit}>
238239
{/* Model selector + MCP toggle */}
239240
<div className="home-model-row">
240-
<ModelSelector value={selectedModel} onChange={setSelectedModel} capability="FLAG_CHAT" />
241+
<ModelSelector value={selectedModel} onChange={setSelectedModel} capability={CAP_CHAT} />
241242
<UnifiedMCPDropdown
242243
serverMCPAvailable={mcpAvailable}
243244
mcpServerList={mcpServerList}

core/http/react-ui/src/pages/ImageGen.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useRef } from 'react'
22
import { useParams, useOutletContext } from 'react-router-dom'
33
import ModelSelector from '../components/ModelSelector'
4+
import { CAP_IMAGE } from '../utils/capabilities'
45
import LoadingSpinner from '../components/LoadingSpinner'
56
import { imageApi, fileToBase64 } from '../utils/api'
67

@@ -73,7 +74,7 @@ export default function ImageGen() {
7374
<form onSubmit={handleGenerate}>
7475
<div className="form-group">
7576
<label className="form-label">Model</label>
76-
<ModelSelector value={model} onChange={setModel} capability="FLAG_IMAGE" />
77+
<ModelSelector value={model} onChange={setModel} capability={CAP_IMAGE} />
7778
</div>
7879
<div className="form-group">
7980
<label className="form-label">Prompt</label>

core/http/react-ui/src/pages/ImportModel.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,10 @@ export default function ImportModel() {
203203
{isAdvancedMode ? 'Configure your model settings using YAML' : 'Import a model from URI with preferences'}
204204
</p>
205205
</div>
206-
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
206+
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
207+
<button className="btn btn-secondary" onClick={() => navigate('/app/pipeline-editor')}>
208+
<i className="fas fa-diagram-project" /> Create Pipeline Model
209+
</button>
207210
<button className="btn btn-secondary" onClick={() => setIsAdvancedMode(!isAdvancedMode)}>
208211
<i className={`fas ${isAdvancedMode ? 'fa-magic' : 'fa-code'}`} />
209212
{isAdvancedMode ? ' Simple Mode' : ' Advanced Mode'}

0 commit comments

Comments
 (0)