Skip to content

Commit 44c8c81

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 e0ab1a8 commit 44c8c81

17 files changed

Lines changed: 496 additions & 37 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: 36 additions & 20 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
}
@@ -97,9 +107,9 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
97107
padding: 6px 10px;
98108
font-size: 0.8125rem;
99109
cursor: pointer;
100-
white-space: nowrap;
101-
overflow: hidden;
102-
text-overflow: ellipsis;
110+
display: flex;
111+
align-items: center;
112+
gap: 6px;
103113
}
104114
.sms-item:hover, .sms-item.sms-focused {
105115
background: var(--color-bg-tertiary);
@@ -137,21 +147,27 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
137147
{query ? 'No matching models — value will be used as-is' : 'No models available'}
138148
</div>
139149
) : (
140-
filtered.map((m, i) => (
141-
<div
142-
key={m.id}
143-
role="option"
144-
aria-selected={m.id === value}
145-
className={`sms-item${i === focusIndex ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
146-
onMouseEnter={() => setFocusIndex(i)}
147-
onMouseDown={(e) => {
148-
e.preventDefault()
149-
commit(m.id)
150-
}}
151-
>
152-
{m.id}
153-
</div>
154-
))
150+
filtered.map((m, i) => {
151+
const isEnterTarget = i === enterTargetIndex
152+
return (
153+
<div
154+
key={m.id}
155+
role="option"
156+
aria-selected={m.id === value}
157+
className={`sms-item${i === focusIndex || isEnterTarget ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
158+
onMouseEnter={() => setFocusIndex(i)}
159+
onMouseDown={(e) => {
160+
e.preventDefault()
161+
commit(m.id)
162+
}}
163+
>
164+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.id}</span>
165+
{isEnterTarget && (
166+
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', flexShrink: 0 }}></span>
167+
)}
168+
</div>
169+
)
170+
})
155171
)}
156172
</div>
157173
)}

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, useSearchParams } 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'
@@ -898,7 +899,7 @@ export default function Chat() {
898899
<ModelSelector
899900
value={activeChat.model}
900901
onChange={(model) => updateChatSettings(activeChat.id, { model })}
901-
capability="FLAG_CHAT"
902+
capability={CAP_CHAT}
902903
style={{ flex: '1 1 0', minWidth: 120 }}
903904
/>
904905
<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
@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext } from 'react-router-dom'
33
import { apiUrl } from '../utils/basePath'
44
import { useAuth } from '../context/AuthContext'
55
import ModelSelector from '../components/ModelSelector'
6+
import { CAP_CHAT } from '../utils/capabilities'
67
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
78
import ConfirmDialog from '../components/ConfirmDialog'
89
import { useResources } from '../hooks/useResources'
@@ -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)