dsh-oc-tui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +504 -0
- package/README.md +247 -0
- package/bin/dsh-oc-tui.js +219 -0
- package/cordis.patch.yml +24 -0
- package/docs/max-thinking.gif +0 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +324 -0
- package/lib/index.js +1771 -0
- package/lib/interrupt.js +31 -0
- package/lib/markdown.js +297 -0
- package/lib/metrics.js +70 -0
- package/lib/startup.js +42 -0
- package/lib/term.js +506 -0
- package/lib/ui.js +1646 -0
- package/lib/util.js +281 -0
- package/lib/web-settings.js +450 -0
- package/package.json +61 -0
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery'
|
|
2
|
+
|
|
3
|
+
const WEB_SETTING_SCHEMAS = [
|
|
4
|
+
['ui-theme', z.object({ preference: z.union(['light', 'dark', 'system']).default('system') })],
|
|
5
|
+
['locale', z.object({ preference: z.union(['zh', 'en']).required(false) })],
|
|
6
|
+
['ui-conversation', z.object({ busyEnter: z.union(['queue', 'steer']).default('queue') })],
|
|
7
|
+
['agent-presets', z.object({ default: z.string().required(false) })],
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
// The settings dialog's left menu: Main keeps the general settings, Model
|
|
11
|
+
// carries the merged provider + model settings. Tab switches the entries.
|
|
12
|
+
export const SETTINGS_MENU = [
|
|
13
|
+
{ id: 'main', label: 'Main' },
|
|
14
|
+
{ id: 'model', label: 'Model' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
function valueAt(source, path) {
|
|
18
|
+
let value = source
|
|
19
|
+
for (const part of path) {
|
|
20
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
21
|
+
value = value[part]
|
|
22
|
+
}
|
|
23
|
+
return value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasPath(source, path) {
|
|
27
|
+
let value = source
|
|
28
|
+
for (const part of path) {
|
|
29
|
+
if (typeof value !== 'object' || value === null || !Object.hasOwn(value, part)) return false
|
|
30
|
+
value = value[part]
|
|
31
|
+
}
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function keyRef(provider) {
|
|
36
|
+
return provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A provider's stored model catalog tolerates both string entries and
|
|
40
|
+
// `{ id, ... }` objects; normalize to objects with an id so the merged view
|
|
41
|
+
// and the picker can treat them uniformly.
|
|
42
|
+
function normalizeModels(models) {
|
|
43
|
+
if (!Array.isArray(models)) return []
|
|
44
|
+
const out = []
|
|
45
|
+
for (const entry of models) {
|
|
46
|
+
if (typeof entry === 'string' && entry) out.push({ id: entry })
|
|
47
|
+
else if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) out.push(entry)
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function modelEntryId(entry) {
|
|
53
|
+
return typeof entry === 'string' ? entry : entry?.id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Picker/choice annotation: a glanceable capability marker per model.
|
|
57
|
+
function capabilitySuffix(model) {
|
|
58
|
+
const capabilities = []
|
|
59
|
+
if (Array.isArray(model.inputModalities) && model.inputModalities.includes('image')) capabilities.push('vision')
|
|
60
|
+
if (model.reasoning && Array.isArray(model.reasoning.efforts) && model.reasoning.efforts.length > 0) capabilities.push('thinking')
|
|
61
|
+
return capabilities.length > 0 ? ' (' + capabilities.join(', ') + ')' : ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Discover the models a configurable provider advertises. Uses the
|
|
65
|
+
// model-discovery seam (`ctx.llm.discoverModels`): it answers from the
|
|
66
|
+
// installed catalog for a known route and interrogates a custom/dormant
|
|
67
|
+
// route's endpoint — resolving the stored credential itself. `listModels`
|
|
68
|
+
// only covers registered adapters, so it silently missed user-added
|
|
69
|
+
// providers that are not currently registered; it is kept as the fallback for
|
|
70
|
+
// namespaces that register no discovery (e.g. the built-in DeepSeek adapter).
|
|
71
|
+
// Returns the normalized list plus whether discovery failed.
|
|
72
|
+
async function fetchProviderModels(ctx, entry, record) {
|
|
73
|
+
const llm = ctx.get('llm')
|
|
74
|
+
let fetched = null
|
|
75
|
+
let failed = false
|
|
76
|
+
if (llm?.discoverModels) {
|
|
77
|
+
const request = {
|
|
78
|
+
...(entry.provider === undefined ? {} : { provider: entry.provider }),
|
|
79
|
+
...(typeof record.baseURL === 'string' && record.baseURL.trim().length > 0 ? { baseURL: record.baseURL } : {}),
|
|
80
|
+
...(typeof record.api === 'string' && record.api.trim().length > 0 ? { api: record.api } : {}),
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
fetched = await llm.discoverModels(entry.settingsNs, request)
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// `NO_DISCOVERY` means the namespace registered no discovery; its
|
|
86
|
+
// registered adapter answers from its own catalog below.
|
|
87
|
+
if (error?.code !== 'NO_DISCOVERY') failed = true
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(fetched) && !failed && llm?.listModels) {
|
|
91
|
+
try { fetched = await llm.listModels(entry.provider) } catch { failed = true }
|
|
92
|
+
}
|
|
93
|
+
if (!Array.isArray(fetched) && !failed) failed = true
|
|
94
|
+
return { models: normalizeModels(fetched), failed }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function effortOptions(models, selectedModel) {
|
|
98
|
+
const model = models.find((candidate) => candidate.id === selectedModel)
|
|
99
|
+
// The adapter-reported reasoning listing (LlmModelInfo.reasoning) is the
|
|
100
|
+
// authoritative "can this model's thinking strength be adjusted?" answer.
|
|
101
|
+
if (model?.reasoning && Array.isArray(model.reasoning.efforts)) {
|
|
102
|
+
return model.reasoning.efforts.map((effort) => String(effort.id))
|
|
103
|
+
}
|
|
104
|
+
const efforts = model?.reasoningEfforts
|
|
105
|
+
if (Array.isArray(efforts)) return efforts.map(String)
|
|
106
|
+
if (efforts && typeof efforts === 'object') return Object.keys(efforts)
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function installWebSettingSchemas(ctx) {
|
|
111
|
+
const settings = ctx.get('settings')
|
|
112
|
+
if (!settings) return
|
|
113
|
+
const registered = new Set(settings.describe().map((entry) => String(entry.ns)))
|
|
114
|
+
for (const [namespace, schema] of WEB_SETTING_SCHEMAS) {
|
|
115
|
+
if (!registered.has(namespace)) settings.register(namespace, schema)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Main menu tab: the general settings (the models section moved to the Model
|
|
120
|
+
// tab together with the provider settings).
|
|
121
|
+
export async function loadWebSettings(ctx) {
|
|
122
|
+
installWebSettingSchemas(ctx)
|
|
123
|
+
const settings = ctx.get('settings')
|
|
124
|
+
if (!settings) {
|
|
125
|
+
return { settings: null, items: [{ label: 'DSH settings', value: 'unavailable', disabled: true }], title: 'Settings', menu: SETTINGS_MENU, menuIndex: 0 }
|
|
126
|
+
}
|
|
127
|
+
const descriptors = new Map(settings.describe({ redactSecrets: true }).map((entry) => [String(entry.ns), entry]))
|
|
128
|
+
const items = []
|
|
129
|
+
const add = (ns, field, label, options, extra = {}) => {
|
|
130
|
+
const descriptor = descriptors.get(ns)
|
|
131
|
+
if (!descriptor || typeof descriptor.value !== 'object' || descriptor.value === null) return
|
|
132
|
+
const value = descriptor.value[field]
|
|
133
|
+
items.push({ kind: options?.length ? 'choice' : 'text', ns, field, label, value: value === undefined ? 'system' : String(value), options, revision: descriptor.revision, disabled: !settings.writable, ...extra })
|
|
134
|
+
}
|
|
135
|
+
// `ui-theme` (General · Appearance) and `locale` (General · Language) are
|
|
136
|
+
// WebUI-only settings; they have no effect inside the TUI, so they are not
|
|
137
|
+
// projected here.
|
|
138
|
+
items.push({ kind: 'header', label: 'General', value: '', disabled: true })
|
|
139
|
+
add('ui-conversation', 'busyEnter', 'Busy Enter', ['queue', 'steer'])
|
|
140
|
+
const agentPresets = ctx.get('agentPresets')
|
|
141
|
+
let presetOptions
|
|
142
|
+
if (agentPresets) {
|
|
143
|
+
try {
|
|
144
|
+
// The roster is best-effort: an unreadable root must not take down the
|
|
145
|
+
// whole settings panel. Broken presets are excluded because no session
|
|
146
|
+
// can be composed from one.
|
|
147
|
+
presetOptions = (await agentPresets.list())
|
|
148
|
+
.filter((preset) => preset.broken === undefined)
|
|
149
|
+
.map((preset) => preset.id)
|
|
150
|
+
} catch { /* roster unavailable; fall back to the free-text row below */ }
|
|
151
|
+
}
|
|
152
|
+
add('agent-presets', 'default', 'Default preset', presetOptions, presetOptions?.length ? { kind: 'agent-preset', value: agentPresets.defaultId } : undefined)
|
|
153
|
+
const permissionPresets = ctx.get('permissionPresets')
|
|
154
|
+
add('permission', 'defaultPreset', 'Permission preset', permissionPresets ? [...permissionPresets.names] : undefined, { confirmValue: 'danger-full-access', confirmText: 'Enable unrestricted tool and file access?' })
|
|
155
|
+
|
|
156
|
+
items.push({ kind: 'header', label: 'Sessions', value: '', disabled: true })
|
|
157
|
+
items.push({ kind: 'new-session', label: 'New session', value: 'Enter', disabled: false })
|
|
158
|
+
items.push({ kind: 'manage-sessions', label: 'Manage sessions', value: 'Enter', disabled: false })
|
|
159
|
+
items.push({ kind: 'header', label: 'System', value: '', disabled: true })
|
|
160
|
+
items.push({ kind: 'provider-config-info', label: 'Provider API config', value: 'Model tab', disabled: true })
|
|
161
|
+
if (settings.documentPath) items.push({ label: 'Settings file', value: settings.documentPath, disabled: true })
|
|
162
|
+
return { settings, items, title: 'Settings', menu: SETTINGS_MENU, menuIndex: 0 }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Model menu tab: the merged provider + model settings. Per provider:
|
|
166
|
+
// Provider URL, Provider API key, Models (the saved selection, listed below
|
|
167
|
+
// the row; Enter auto-fetches the catalog and opens the selection window).
|
|
168
|
+
export async function loadModelSettings(ctx) {
|
|
169
|
+
installWebSettingSchemas(ctx)
|
|
170
|
+
const settings = ctx.get('settings')
|
|
171
|
+
if (!settings) {
|
|
172
|
+
return { settings: null, items: [{ label: 'Model settings', value: 'unavailable', disabled: true }], title: 'Model', menu: SETTINGS_MENU, menuIndex: 1 }
|
|
173
|
+
}
|
|
174
|
+
const descriptors = new Map(settings.describe({ redactSecrets: true }).map((entry) => [String(entry.ns), entry]))
|
|
175
|
+
const items = []
|
|
176
|
+
const add = (ns, field, label, options, extra = {}) => {
|
|
177
|
+
const descriptor = descriptors.get(ns)
|
|
178
|
+
if (!descriptor || typeof descriptor.value !== 'object' || descriptor.value === null) return
|
|
179
|
+
const value = descriptor.value[field]
|
|
180
|
+
items.push({ kind: options?.length ? 'choice' : 'text', ns, field, label, value: value === undefined ? 'system' : String(value), options, revision: descriptor.revision, disabled: !settings.writable, ...extra })
|
|
181
|
+
}
|
|
182
|
+
const llm = ctx.get('llm')
|
|
183
|
+
const credentials = ctx.get('credentials')
|
|
184
|
+
|
|
185
|
+
// Only providers the user has actually added are listed. A provider counts
|
|
186
|
+
// as added when its profile is present in the user settings layer; a
|
|
187
|
+
// built-in namespace provider (empty settings path) counts only when the
|
|
188
|
+
// user configured something in that namespace. Preset providers that were
|
|
189
|
+
// never added stay hidden.
|
|
190
|
+
const configurable = (llm?.listConfigurableProviders?.() ?? []).filter((entry) => {
|
|
191
|
+
const descriptor = descriptors.get(entry.settingsNs)
|
|
192
|
+
if (!descriptor) return false
|
|
193
|
+
if (entry.settingsPath.length === 0) {
|
|
194
|
+
return descriptor.user && typeof descriptor.user === 'object'
|
|
195
|
+
&& Object.keys(descriptor.user).length > 0
|
|
196
|
+
}
|
|
197
|
+
return hasPath(descriptor.user, entry.settingsPath)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// ---- default route ----------------------------------------------------
|
|
201
|
+
items.push({ kind: 'header', label: 'Default', value: '', disabled: true })
|
|
202
|
+
const providers = descriptors.get('llm-pi-ai')?.value?.providers
|
|
203
|
+
const providerEntries = providers && typeof providers === 'object' ? Object.entries(providers) : []
|
|
204
|
+
const defaultModel = descriptors.get('agent-default-model')?.value
|
|
205
|
+
const selectedProvider = defaultModel && typeof defaultModel === 'object' ? defaultModel.provider : undefined
|
|
206
|
+
const selectedModel = defaultModel && typeof defaultModel === 'object' ? defaultModel.model : undefined
|
|
207
|
+
const liveProviders = llm?.listProviders?.() ?? []
|
|
208
|
+
const providerOptions = liveProviders.length > 0 ? liveProviders.map((provider) => provider.id) : providerEntries.map(([id]) => id)
|
|
209
|
+
// The default-model options are exactly the models the user has saved under
|
|
210
|
+
// the selected provider (its `models` list). Do not auto-fetch the provider
|
|
211
|
+
// catalog here: that catalog can include models the user never added, and it
|
|
212
|
+
// would diverge from the saved selection shown in the provider block below.
|
|
213
|
+
// The auto-fetched catalog stays available behind the Models row (Enter →
|
|
214
|
+
// loadProviderModels).
|
|
215
|
+
const selectedEntry = configurable.find((entry) => entry.provider === selectedProvider)
|
|
216
|
+
let models = []
|
|
217
|
+
if (selectedEntry) {
|
|
218
|
+
const selectedDescriptor = descriptors.get(selectedEntry.settingsNs)
|
|
219
|
+
const selectedProfile = selectedDescriptor ? valueAt(selectedDescriptor.value, selectedEntry.settingsPath) : undefined
|
|
220
|
+
const selectedRecord = typeof selectedProfile === 'object' && selectedProfile !== null ? selectedProfile : {}
|
|
221
|
+
models = normalizeModels(selectedRecord.models)
|
|
222
|
+
} else {
|
|
223
|
+
models = normalizeModels(providerEntries.find(([id]) => id === selectedProvider)?.[1]?.models)
|
|
224
|
+
}
|
|
225
|
+
add('agent-default-model', 'provider', 'Default provider', providerOptions, { kind: 'default-provider' })
|
|
226
|
+
// The picker annotates each candidate with its capabilities, so a user can
|
|
227
|
+
// tell at a glance which models take image input and which can adjust their
|
|
228
|
+
// thinking strength instead of discovering the refusal after sending.
|
|
229
|
+
const modelOptions = models.map((model) => {
|
|
230
|
+
const suffix = capabilitySuffix(model)
|
|
231
|
+
return suffix ? { label: model.id + suffix, value: model.id } : model.id
|
|
232
|
+
})
|
|
233
|
+
add('agent-default-model', 'model', 'Default model', modelOptions, { kind: 'default-model' })
|
|
234
|
+
let reasoningOptions = effortOptions(models, selectedModel)
|
|
235
|
+
let reasoningDefault
|
|
236
|
+
if (llm?.resolveModelInfo && selectedProvider && selectedModel) {
|
|
237
|
+
try {
|
|
238
|
+
const info = await llm.resolveModelInfo(selectedProvider, selectedModel)
|
|
239
|
+
reasoningOptions = info.reasoning?.efforts.map((effort) => String(effort.id))
|
|
240
|
+
reasoningDefault = info.reasoning?.defaultEffort === undefined ? undefined : String(info.reasoning.defaultEffort)
|
|
241
|
+
} catch { /* keep catalog fallback */ }
|
|
242
|
+
}
|
|
243
|
+
const configuredEffort = defaultModel?.reasoningEffort === undefined ? undefined : String(defaultModel.reasoningEffort)
|
|
244
|
+
const reasoningValue = reasoningOptions?.includes(configuredEffort)
|
|
245
|
+
? configuredEffort
|
|
246
|
+
: reasoningOptions?.includes(reasoningDefault) ? reasoningDefault : undefined
|
|
247
|
+
const reasoningExtra = reasoningOptions?.length
|
|
248
|
+
? { kind: 'effort', ...reasoningValue === undefined ? {} : { value: reasoningValue } }
|
|
249
|
+
: undefined
|
|
250
|
+
add('agent-default-model', 'reasoningEffort', 'Reasoning', reasoningOptions, reasoningExtra)
|
|
251
|
+
|
|
252
|
+
// ---- merged provider blocks --------------------------------------------
|
|
253
|
+
for (const entry of configurable) {
|
|
254
|
+
const descriptor = descriptors.get(entry.settingsNs)
|
|
255
|
+
if (!descriptor) continue
|
|
256
|
+
items.push({ kind: 'header', label: entry.displayName, value: '', disabled: true })
|
|
257
|
+
const profile = valueAt(descriptor.value, entry.settingsPath)
|
|
258
|
+
const removable = entry.settingsPath.length > 0 && hasPath(descriptor.user, entry.settingsPath) && !hasPath(descriptor.base, entry.settingsPath)
|
|
259
|
+
const record = typeof profile === 'object' && profile !== null ? profile : {}
|
|
260
|
+
const ref = typeof record.apiKeyEnv === 'string' && record.apiKeyEnv ? record.apiKeyEnv : keyRef(entry.provider)
|
|
261
|
+
let credential
|
|
262
|
+
try { credential = credentials ? await credentials.describe(ref) : undefined } catch { credential = undefined }
|
|
263
|
+
if (entry.settingsNs === 'llm-deepseek' || entry.settingsNs === 'llm-pi-ai') items.push({ kind: 'path', label: 'Provider URL', value: typeof record.baseURL === 'string' ? record.baseURL : 'default', ns: entry.settingsNs, path: [...entry.settingsPath, 'baseURL'], revision: descriptor.revision, disabled: !settings.writable })
|
|
264
|
+
items.push({ kind: 'secret', label: 'Provider API key', value: credential?.configured ? 'configured (' + (credential.source ?? 'stored') + ')' : 'not configured', credentialRef: ref, disabled: !credentials || credential?.writable === false })
|
|
265
|
+
if (entry.settingsNs === 'llm-pi-ai' && entry.declared === true) {
|
|
266
|
+
items.push({ kind: 'path', label: 'Display name', value: typeof record.displayName === 'string' ? record.displayName : entry.displayName, ns: entry.settingsNs, path: [...entry.settingsPath, 'displayName'], revision: descriptor.revision, disabled: !settings.writable })
|
|
267
|
+
const choices = ['openai-completions', 'openai-responses', 'anthropic-messages', 'google-generative-ai']
|
|
268
|
+
items.push({ kind: 'path-choice', label: 'Wire protocol', value: typeof record.api === 'string' ? record.api : choices[0], options: choices, ns: entry.settingsNs, path: [...entry.settingsPath, 'api'], revision: descriptor.revision, disabled: !settings.writable })
|
|
269
|
+
}
|
|
270
|
+
// Route-level request modalities. This is what makes a hand-declared vision
|
|
271
|
+
// model usable: pi-ai under-claims text by default (refusing the image before
|
|
272
|
+
// it is attached is the safe answer when nothing is declared), so a gateway
|
|
273
|
+
// serving vision models declares `[text, image]` once here instead of on
|
|
274
|
+
// every entry. Catalog models keep the modalities the catalog records for
|
|
275
|
+
// them; this value never narrows one.
|
|
276
|
+
if (entry.settingsNs === 'llm-pi-ai') {
|
|
277
|
+
const declaredInput = Array.isArray(record.defaultInput) ? record.defaultInput : undefined
|
|
278
|
+
items.push({
|
|
279
|
+
kind: 'input-modalities',
|
|
280
|
+
label: 'Default image input',
|
|
281
|
+
value: declaredInput?.includes('image') ? 'text + image' : 'text only',
|
|
282
|
+
options: ['text only', 'text + image'],
|
|
283
|
+
ns: entry.settingsNs,
|
|
284
|
+
path: [...entry.settingsPath, 'defaultInput'],
|
|
285
|
+
revision: descriptor.revision,
|
|
286
|
+
disabled: !settings.writable,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
// Models: Enter auto-fetches the provider catalog and opens the selection
|
|
290
|
+
// window; the saved selection is listed right below, one row per model.
|
|
291
|
+
const stored = normalizeModels(record.models)
|
|
292
|
+
items.push({ kind: 'provider-models', label: 'Models', value: stored.length > 0 ? stored.length + ' selected' : 'fetch…', providerId: entry.provider, disabled: false })
|
|
293
|
+
for (const model of stored) {
|
|
294
|
+
const isDefault = entry.provider === selectedProvider && model.id === selectedModel
|
|
295
|
+
items.push({
|
|
296
|
+
kind: 'provider-model',
|
|
297
|
+
label: model.id,
|
|
298
|
+
value: isDefault ? 'default' : '',
|
|
299
|
+
indent: 1,
|
|
300
|
+
modelId: model.id,
|
|
301
|
+
providerId: entry.provider,
|
|
302
|
+
ns: 'agent-default-model',
|
|
303
|
+
revision: descriptors.get('agent-default-model')?.revision,
|
|
304
|
+
disabled: false,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
if (removable) items.push({ kind: 'remove-provider', label: 'Remove provider', value: 'Enter', ns: entry.settingsNs, path: entry.settingsPath, revision: descriptor.revision, credentialRef: credential?.configured && credential?.writable ? ref : undefined, confirmText: 'Remove ' + entry.displayName + ' and its managed credential?', disabled: !settings.writable })
|
|
308
|
+
}
|
|
309
|
+
return { settings, items, title: 'Model', subtitle: 'Enter selects a listed model as default', menu: SETTINGS_MENU, menuIndex: 1 }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The auto-fetched model selection window for one provider: the provider's
|
|
313
|
+
// discovered catalog (`ctx.llm.discoverModels`, with a registered-adapter
|
|
314
|
+
// `listModels` fallback) as checkbox rows marking the saved selection. Enter
|
|
315
|
+
// toggles a model in or out of the provider's saved models list. A failed
|
|
316
|
+
// fetch falls back to the saved selection so stored models stay manageable.
|
|
317
|
+
export async function loadProviderModels(ctx, providerId) {
|
|
318
|
+
const settings = ctx.get('settings')
|
|
319
|
+
const entry = ctx.get('llm')?.listConfigurableProviders?.().find((candidate) => candidate.provider === providerId)
|
|
320
|
+
if (!settings || !entry) throw new Error('provider is no longer available')
|
|
321
|
+
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === entry.settingsNs)
|
|
322
|
+
if (!descriptor) throw new Error('provider settings namespace is unavailable')
|
|
323
|
+
const profile = valueAt(descriptor.value, entry.settingsPath)
|
|
324
|
+
const record = typeof profile === 'object' && profile !== null ? profile : {}
|
|
325
|
+
const stored = normalizeModels(record.models)
|
|
326
|
+
const storedIds = new Set(stored.map((model) => model.id))
|
|
327
|
+
const discovered = await fetchProviderModels(ctx, entry, record)
|
|
328
|
+
const fetched = discovered.models
|
|
329
|
+
const fetchFailed = discovered.failed
|
|
330
|
+
const items = []
|
|
331
|
+
const seen = new Set()
|
|
332
|
+
const pushRow = (model) => {
|
|
333
|
+
if (!model?.id || seen.has(model.id)) return
|
|
334
|
+
seen.add(model.id)
|
|
335
|
+
const checked = storedIds.has(model.id)
|
|
336
|
+
items.push({
|
|
337
|
+
kind: 'model-toggle',
|
|
338
|
+
label: model.id + capabilitySuffix(model),
|
|
339
|
+
value: checked ? '[x]' : '[ ]',
|
|
340
|
+
modelId: model.id,
|
|
341
|
+
checked,
|
|
342
|
+
selectedIds: [...storedIds],
|
|
343
|
+
storedModels: stored,
|
|
344
|
+
providerId,
|
|
345
|
+
settingsNs: entry.settingsNs,
|
|
346
|
+
providerPath: entry.settingsPath,
|
|
347
|
+
revision: descriptor.revision,
|
|
348
|
+
disabled: !settings.writable,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
for (const model of fetched) pushRow(model)
|
|
352
|
+
// Saved models that the catalog no longer returns stay toggleable.
|
|
353
|
+
for (const model of stored) pushRow(model)
|
|
354
|
+
if (items.length === 0) {
|
|
355
|
+
items.push({ label: fetchFailed ? 'no models available' : 'provider returned no models', value: '', disabled: true })
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
settings,
|
|
359
|
+
items,
|
|
360
|
+
title: entry.displayName + ' · models',
|
|
361
|
+
subtitle: fetchFailed
|
|
362
|
+
? 'auto-fetch unavailable — saved models · Enter toggle'
|
|
363
|
+
: 'auto-fetched · Enter toggle · Esc back',
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function saveWebSetting(ctx, settings, item, value) {
|
|
368
|
+
if (!item || item.disabled) return
|
|
369
|
+
if (item.kind === 'secret') {
|
|
370
|
+
if (!value.trim()) throw new Error('API key cannot be empty')
|
|
371
|
+
const credentials = ctx.get('credentials')
|
|
372
|
+
if (!credentials) throw new Error('credentials service is unavailable')
|
|
373
|
+
await credentials.set(item.credentialRef, value.trim())
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
if (item.kind === 'path' || item.kind === 'path-choice') {
|
|
377
|
+
const trimmed = value.trim()
|
|
378
|
+
const op = trimmed === '' || trimmed === 'default' ? { op: 'unset', path: item.path } : { op: 'set', path: item.path, value: trimmed }
|
|
379
|
+
await settings.mutate(item.ns, [op], item.revision)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
if (item.kind === 'input-modalities') {
|
|
383
|
+
const modalities = value.trim() === 'text + image' ? ['text', 'image'] : ['text']
|
|
384
|
+
await settings.mutate(item.ns, [{ op: 'set', path: item.path, value: modalities }], item.revision)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
if (item.kind === 'enable-provider') return settings.mutate(item.ns, [{ op: 'set', path: item.path, value: {} }], item.revision)
|
|
388
|
+
if (item.kind === 'remove-provider') {
|
|
389
|
+
const credentials = ctx.get('credentials')
|
|
390
|
+
if (item.credentialRef && credentials) await credentials.unset(item.credentialRef)
|
|
391
|
+
return settings.mutate(item.ns, [{ op: 'unset', path: item.path }], item.revision)
|
|
392
|
+
}
|
|
393
|
+
if (item.kind === 'default-provider') {
|
|
394
|
+
// Resolve the new provider's first advertised model through the same
|
|
395
|
+
// discovery seam as the model pickers, so a user-added (dormant) provider
|
|
396
|
+
// answers its full catalog instead of failing on `listModels`.
|
|
397
|
+
const llm = ctx.get('llm')
|
|
398
|
+
let model
|
|
399
|
+
const entry = llm?.listConfigurableProviders?.().find((candidate) => candidate.provider === value)
|
|
400
|
+
if (entry) {
|
|
401
|
+
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === entry.settingsNs)
|
|
402
|
+
const profile = descriptor ? valueAt(descriptor.value, entry.settingsPath) : undefined
|
|
403
|
+
const record = typeof profile === 'object' && profile !== null ? profile : {}
|
|
404
|
+
const discovered = await fetchProviderModels(ctx, entry, record)
|
|
405
|
+
model = discovered.models[0]?.id
|
|
406
|
+
} else {
|
|
407
|
+
const models = await llm?.listModels?.(value)
|
|
408
|
+
model = models?.[0]?.id
|
|
409
|
+
}
|
|
410
|
+
if (!model) throw new Error('selected provider has no available models')
|
|
411
|
+
return settings.mutate(item.ns, [
|
|
412
|
+
{ op: 'set', path: ['provider'], value },
|
|
413
|
+
{ op: 'set', path: ['model'], value: model },
|
|
414
|
+
{ op: 'unset', path: ['reasoningEffort'] },
|
|
415
|
+
], item.revision)
|
|
416
|
+
}
|
|
417
|
+
if (item.kind === 'default-model') {
|
|
418
|
+
return settings.mutate(item.ns, [
|
|
419
|
+
{ op: 'set', path: ['model'], value },
|
|
420
|
+
{ op: 'unset', path: ['reasoningEffort'] },
|
|
421
|
+
], item.revision)
|
|
422
|
+
}
|
|
423
|
+
// A model listed under its provider becomes the default route.
|
|
424
|
+
if (item.kind === 'provider-model') {
|
|
425
|
+
return settings.mutate(item.ns, [
|
|
426
|
+
{ op: 'set', path: ['provider'], value: item.providerId },
|
|
427
|
+
{ op: 'set', path: ['model'], value: item.modelId },
|
|
428
|
+
{ op: 'unset', path: ['reasoningEffort'] },
|
|
429
|
+
], item.revision)
|
|
430
|
+
}
|
|
431
|
+
// The auto-fetched selection window: toggle one model in or out of the
|
|
432
|
+
// provider's saved models list. Existing stored entries keep their shape;
|
|
433
|
+
// additions are plain `{ id }` records.
|
|
434
|
+
if (item.kind === 'model-toggle') {
|
|
435
|
+
const ids = new Set(item.selectedIds ?? [])
|
|
436
|
+
if (item.checked) ids.delete(item.modelId)
|
|
437
|
+
else ids.add(item.modelId)
|
|
438
|
+
const next = []
|
|
439
|
+
for (const entry of item.storedModels ?? []) {
|
|
440
|
+
if (ids.has(modelEntryId(entry))) next.push(entry)
|
|
441
|
+
}
|
|
442
|
+
for (const id of ids) {
|
|
443
|
+
if (!next.some((entry) => modelEntryId(entry) === id)) next.push({ id })
|
|
444
|
+
}
|
|
445
|
+
return settings.mutate(item.settingsNs, [{ op: 'set', path: [...item.providerPath, 'models'], value: next }], item.revision)
|
|
446
|
+
}
|
|
447
|
+
if (!item.ns || !item.field) return
|
|
448
|
+
if (item.ns === 'locale' && item.field === 'preference' && value === 'system') return settings.replace(item.ns, {}, item.revision)
|
|
449
|
+
return settings.update(item.ns, { [item.field]: value }, item.revision)
|
|
450
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-oc-tui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A terminal UI (TUI) for DeepSeek Harness — an opencode-inspired chat client that boots as a dsh profile app plugin.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node tests/smoke.test.mjs",
|
|
8
|
+
"check": "node --check lib/index.js && node --check lib/ui.js && node --check lib/term.js && node --check lib/metrics.js && node --check lib/interrupt.js && node --check lib/web-settings.js"
|
|
9
|
+
},
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./lib/index.js",
|
|
13
|
+
"./startup": "./lib/startup.js"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"dsh-oc-tui": "bin/dsh-oc-tui.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib",
|
|
20
|
+
"bin",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"docs/用户手册.md",
|
|
23
|
+
"docs/max-thinking.gif"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@deepseek-ai/cordis": "*",
|
|
30
|
+
"@deepseek-ai/dsh-agent": "*",
|
|
31
|
+
"@deepseek-ai/dsh-agent-loop": "*",
|
|
32
|
+
"@deepseek-ai/dsh-agent-presets": "*",
|
|
33
|
+
"@deepseek-ai/dsh-commands": "*",
|
|
34
|
+
"@deepseek-ai/dsh-cmdline": "*",
|
|
35
|
+
"@deepseek-ai/dsh-llm": "*",
|
|
36
|
+
"@deepseek-ai/dsh-session": "*",
|
|
37
|
+
"@deepseek-ai/dsh-session-persistence": "*",
|
|
38
|
+
"@deepseek-ai/dsh-user-approval": "*"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@deepseek-ai/cordis": { "optional": false },
|
|
42
|
+
"@deepseek-ai/dsh-agent": { "optional": true },
|
|
43
|
+
"@deepseek-ai/dsh-agent-loop": { "optional": true },
|
|
44
|
+
"@deepseek-ai/dsh-agent-presets": { "optional": true },
|
|
45
|
+
"@deepseek-ai/dsh-commands": { "optional": true },
|
|
46
|
+
"@deepseek-ai/dsh-cmdline": { "optional": true },
|
|
47
|
+
"@deepseek-ai/dsh-llm": { "optional": true },
|
|
48
|
+
"@deepseek-ai/dsh-session": { "optional": true },
|
|
49
|
+
"@deepseek-ai/dsh-session-persistence": { "optional": true },
|
|
50
|
+
"@deepseek-ai/dsh-user-approval": { "optional": true }
|
|
51
|
+
},
|
|
52
|
+
"dsh": {
|
|
53
|
+
"bundle": {
|
|
54
|
+
"patch": "./cordis.patch.yml"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@deepseek-ai/schemastery": "^0.1.0",
|
|
59
|
+
"commander": "^15.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|