dsh-oc-tui 0.2.0-pre.13 → 0.2.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/README.md +13 -6
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +39 -6
- package/dsh-plugin.json +5 -1
- package/lib/diagnostics.js +120 -0
- package/lib/git-branch.js +157 -0
- package/lib/image.js +5 -1
- package/lib/index.js +904 -19
- package/lib/locales/ar.js +637 -560
- package/lib/locales/de.js +606 -529
- package/lib/locales/en.js +79 -0
- package/lib/locales/es.js +606 -529
- package/lib/locales/fr.js +606 -529
- package/lib/locales/id.js +608 -531
- package/lib/locales/it.js +606 -529
- package/lib/locales/ja.js +606 -529
- package/lib/locales/ko.js +606 -529
- package/lib/locales/nl.js +606 -529
- package/lib/locales/pl.js +620 -543
- package/lib/locales/pt-BR.js +606 -529
- package/lib/locales/ru.js +620 -543
- package/lib/locales/tr.js +606 -529
- package/lib/locales/vi.js +608 -531
- package/lib/locales/zh-CN.js +607 -530
- package/lib/locales/zh-TW.js +607 -530
- package/lib/mermaid-ascii.js +20 -1
- package/lib/mermaid.js +39 -20
- package/lib/providers.js +712 -0
- package/lib/splash-opencode.js +27 -9
- package/lib/std/command-list.js +2 -1
- package/lib/term.js +13 -1
- package/lib/ui.js +326 -20
- package/lib/util.js +49 -0
- package/lib/web-settings.js +182 -142
- package/package.json +5 -4
package/lib/util.js
CHANGED
|
@@ -242,6 +242,55 @@ export function localImagePath(text) {
|
|
|
242
242
|
return s
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
// What a bracketed paste contributes to a single-line text field.
|
|
246
|
+
//
|
|
247
|
+
// An image has no text form — decoding its bytes would type mojibake — so it
|
|
248
|
+
// yields null and the caller drops it. A newline run is removed entirely for a
|
|
249
|
+
// secret (a key pasted across lines is still one key) and folded into a single
|
|
250
|
+
// space otherwise, which leaves a pasted JSON blob parseable. Remaining control
|
|
251
|
+
// characters are stripped: some terminals pad their clipboard payload with NULs.
|
|
252
|
+
export function fieldPasteText(bytes, { secret = false } = {}) {
|
|
253
|
+
if (!bytes || bytes.length === 0) return null
|
|
254
|
+
if (detectImageMediaType(bytes)) return null
|
|
255
|
+
const raw = bytes.toString('utf8')
|
|
256
|
+
const folded = secret ? raw.replace(/[\r\n]+/g, '') : raw.replace(/[\r\n]+/g, ' ')
|
|
257
|
+
const clean = folded.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
|
|
258
|
+
return clean === '' ? null : clean
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Reasoning as it should be displayed, with the transport's thinking signature
|
|
262
|
+
// removed.
|
|
263
|
+
//
|
|
264
|
+
// Some endpoints prefix the reasoning text with `<signature>…</signature>`: the
|
|
265
|
+
// opaque blob a later request replays to prove the chain of thought is the one
|
|
266
|
+
// the model produced. It is routing data, not prose, and it is long enough to
|
|
267
|
+
// fill the whole thinking box on its own. Mirrors the host's own
|
|
268
|
+
// `reasoningText()` so both frontends show the same thing.
|
|
269
|
+
//
|
|
270
|
+
// Streaming is why this is a function of the raw text rather than a one-shot
|
|
271
|
+
// strip: a delta can stop anywhere, including inside the tag itself. Anything
|
|
272
|
+
// that could still turn out to be the opening tag — a lone `<`, `<sig`, the
|
|
273
|
+
// complete opening tag with no close yet — yields empty text, so the box stays
|
|
274
|
+
// hidden instead of flashing a partial tag and then vanishing. Text that merely
|
|
275
|
+
// starts with `<` but cannot be that tag is returned untouched.
|
|
276
|
+
export function reasoningText(text) {
|
|
277
|
+
if (typeof text !== 'string' || text[0] !== '<') return text ?? ''
|
|
278
|
+
const opening = '<signature>'
|
|
279
|
+
if (opening.startsWith(text)) return ''
|
|
280
|
+
if (!text.startsWith(opening)) return text
|
|
281
|
+
const end = text.indexOf('</signature>', opening.length)
|
|
282
|
+
if (end === -1) return ''
|
|
283
|
+
let start = end + '</signature>'.length
|
|
284
|
+
// The signature sits on its own lines; dropping the break that follows it
|
|
285
|
+
// keeps the first real paragraph from inheriting its indentation.
|
|
286
|
+
while (start < text.length) {
|
|
287
|
+
if (text[start] === '\n') start++
|
|
288
|
+
else if (text[start] === '\r' && text[start + 1] === '\n') start += 2
|
|
289
|
+
else break
|
|
290
|
+
}
|
|
291
|
+
return text.slice(start)
|
|
292
|
+
}
|
|
293
|
+
|
|
245
294
|
// A stored image reference is usable in message content only while it still
|
|
246
295
|
// carries the media type every adapter reads to describe the image. The
|
|
247
296
|
// attachment store returns the complete reference; a partial object reduced to
|
package/lib/web-settings.js
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
3
|
import { collectServers } from './mcp.js'
|
|
4
|
-
import { LOCALES, isLocaleId, localeInfo, resolveLocale, setLocale, t, tv, valueChoices } from './i18n.js'
|
|
4
|
+
import { LOCALES, isLocaleId, localeInfo, resolveLocale, setLocale, t, tn, tv, valueChoices } from './i18n.js'
|
|
5
|
+
import {
|
|
6
|
+
ACCOUNT_ROUTE,
|
|
7
|
+
URL_FAMILIES,
|
|
8
|
+
adoptDiscovered,
|
|
9
|
+
capabilitySuffix,
|
|
10
|
+
capacitySuffix,
|
|
11
|
+
fetchProviderModels,
|
|
12
|
+
hasPath,
|
|
13
|
+
mergeModelSelection,
|
|
14
|
+
modelEntryId,
|
|
15
|
+
normalizeModels,
|
|
16
|
+
protocolChoices,
|
|
17
|
+
providerDirectory,
|
|
18
|
+
routeKeyRef,
|
|
19
|
+
valueAt,
|
|
20
|
+
} from './providers.js'
|
|
5
21
|
|
|
6
22
|
// The settings this plugin owns. Each is declared twice on purpose: as a
|
|
7
23
|
// namespace for a 0.1.x settings provider (which has a registry and stores a
|
|
@@ -63,84 +79,11 @@ export const SETTINGS_MENU = [
|
|
|
63
79
|
|
|
64
80
|
const MCP_MENU_INDEX = SETTINGS_MENU.findIndex((entry) => entry.id === 'mcp')
|
|
65
81
|
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
return value
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function hasPath(source, path) {
|
|
76
|
-
let value = source
|
|
77
|
-
for (const part of path) {
|
|
78
|
-
if (typeof value !== 'object' || value === null || !Object.hasOwn(value, part)) return false
|
|
79
|
-
value = value[part]
|
|
80
|
-
}
|
|
81
|
-
return true
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function keyRef(provider) {
|
|
85
|
-
return provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_API_KEY'
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// A provider's stored model catalog tolerates both string entries and
|
|
89
|
-
// `{ id, ... }` objects; normalize to objects with an id so the merged view
|
|
90
|
-
// and the picker can treat them uniformly.
|
|
91
|
-
function normalizeModels(models) {
|
|
92
|
-
if (!Array.isArray(models)) return []
|
|
93
|
-
const out = []
|
|
94
|
-
for (const entry of models) {
|
|
95
|
-
if (typeof entry === 'string' && entry) out.push({ id: entry })
|
|
96
|
-
else if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) out.push(entry)
|
|
97
|
-
}
|
|
98
|
-
return out
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function modelEntryId(entry) {
|
|
102
|
-
return typeof entry === 'string' ? entry : entry?.id
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Picker/choice annotation: a glanceable capability marker per model.
|
|
106
|
-
function capabilitySuffix(model) {
|
|
107
|
-
const capabilities = []
|
|
108
|
-
if (Array.isArray(model.inputModalities) && model.inputModalities.includes('image')) capabilities.push('vision')
|
|
109
|
-
if (model.reasoning && Array.isArray(model.reasoning.efforts) && model.reasoning.efforts.length > 0) capabilities.push('thinking')
|
|
110
|
-
return capabilities.length > 0 ? ' (' + capabilities.join(', ') + ')' : ''
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Discover the models a configurable provider advertises. Uses the
|
|
114
|
-
// model-discovery seam (`ctx.llm.discoverModels`): it answers from the
|
|
115
|
-
// installed catalog for a known route and interrogates a custom/dormant
|
|
116
|
-
// route's endpoint — resolving the stored credential itself. `listModels`
|
|
117
|
-
// only covers registered adapters, so it silently missed user-added
|
|
118
|
-
// providers that are not currently registered; it is kept as the fallback for
|
|
119
|
-
// namespaces that register no discovery (e.g. the built-in DeepSeek adapter).
|
|
120
|
-
// Returns the normalized list plus whether discovery failed.
|
|
121
|
-
async function fetchProviderModels(ctx, entry, record) {
|
|
122
|
-
const llm = ctx.get('llm')
|
|
123
|
-
let fetched = null
|
|
124
|
-
let failed = false
|
|
125
|
-
if (llm?.discoverModels) {
|
|
126
|
-
const request = {
|
|
127
|
-
...(entry.provider === undefined ? {} : { provider: entry.provider }),
|
|
128
|
-
...(typeof record.baseURL === 'string' && record.baseURL.trim().length > 0 ? { baseURL: record.baseURL } : {}),
|
|
129
|
-
...(typeof record.api === 'string' && record.api.trim().length > 0 ? { api: record.api } : {}),
|
|
130
|
-
}
|
|
131
|
-
try {
|
|
132
|
-
fetched = await llm.discoverModels(entry.settingsNs, request)
|
|
133
|
-
} catch (error) {
|
|
134
|
-
// `NO_DISCOVERY` means the namespace registered no discovery; its
|
|
135
|
-
// registered adapter answers from its own catalog below.
|
|
136
|
-
if (error?.code !== 'NO_DISCOVERY') failed = true
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
if (!Array.isArray(fetched) && !failed && llm?.listModels) {
|
|
140
|
-
try { fetched = await llm.listModels(entry.provider) } catch { failed = true }
|
|
141
|
-
}
|
|
142
|
-
if (!Array.isArray(fetched) && !failed) failed = true
|
|
143
|
-
return { models: normalizeModels(fetched), failed }
|
|
82
|
+
function modelPickerAnnotations(models) {
|
|
83
|
+
return models.map((model) => {
|
|
84
|
+
const suffix = capabilitySuffix(model)
|
|
85
|
+
return suffix ? { label: model.id + suffix, value: model.id } : model.id
|
|
86
|
+
})
|
|
144
87
|
}
|
|
145
88
|
|
|
146
89
|
function effortOptions(models, selectedModel) {
|
|
@@ -368,14 +311,16 @@ export function applyLocalePreference(ctx, config) {
|
|
|
368
311
|
}
|
|
369
312
|
// Model menu tab: the merged provider + model settings. Per provider:
|
|
370
313
|
// Provider URL, Provider API key, Models (the saved selection, listed below
|
|
371
|
-
// the row; Enter auto-fetches the catalog and opens the selection window)
|
|
314
|
+
// the row; Enter auto-fetches the catalog and opens the selection window), a
|
|
315
|
+
// manual "add model by id" row for an endpoint whose listing lies, and the
|
|
316
|
+
// removal. The last row is the add flow itself.
|
|
372
317
|
export async function loadModelSettings(ctx) {
|
|
373
318
|
installWebSettingSchemas(ctx)
|
|
374
|
-
const
|
|
375
|
-
if (!
|
|
319
|
+
const directory = providerDirectory(ctx)
|
|
320
|
+
if (!directory) {
|
|
376
321
|
return { settings: null, items: [{ label: t('model.title'), value: t('common.unavailable'), disabled: true }], title: t('model.title'), menu: SETTINGS_MENU, menuIndex: 1 }
|
|
377
322
|
}
|
|
378
|
-
const
|
|
323
|
+
const { settings, descriptors, llm, configured, addable } = directory
|
|
379
324
|
const items = []
|
|
380
325
|
const add = (ns, field, label, options, extra = {}) => {
|
|
381
326
|
const descriptor = descriptors.get(ns)
|
|
@@ -383,24 +328,8 @@ export async function loadModelSettings(ctx) {
|
|
|
383
328
|
const value = descriptor.value[field]
|
|
384
329
|
items.push({ kind: options?.length ? 'choice' : 'text', ns, field, label, value: value === undefined ? 'system' : String(value), options, revision: descriptor.revision, disabled: !settings.writable, ...extra })
|
|
385
330
|
}
|
|
386
|
-
const llm = ctx.get('llm')
|
|
387
331
|
const credentials = ctx.get('credentials')
|
|
388
332
|
|
|
389
|
-
// Only providers the user has actually added are listed. A provider counts
|
|
390
|
-
// as added when its profile is present in the user settings layer; a
|
|
391
|
-
// built-in namespace provider (empty settings path) counts only when the
|
|
392
|
-
// user configured something in that namespace. Preset providers that were
|
|
393
|
-
// never added stay hidden.
|
|
394
|
-
const configurable = (llm?.listConfigurableProviders?.() ?? []).filter((entry) => {
|
|
395
|
-
const descriptor = descriptors.get(entry.settingsNs)
|
|
396
|
-
if (!descriptor) return false
|
|
397
|
-
if (entry.settingsPath.length === 0) {
|
|
398
|
-
return descriptor.user && typeof descriptor.user === 'object'
|
|
399
|
-
&& Object.keys(descriptor.user).length > 0
|
|
400
|
-
}
|
|
401
|
-
return hasPath(descriptor.user, entry.settingsPath)
|
|
402
|
-
})
|
|
403
|
-
|
|
404
333
|
// ---- default route ----------------------------------------------------
|
|
405
334
|
items.push({ kind: 'header', label: t('model.defaultSection'), value: '', disabled: true })
|
|
406
335
|
const providers = descriptors.get('llm-pi-ai')?.value?.providers
|
|
@@ -416,12 +345,10 @@ export async function loadModelSettings(ctx) {
|
|
|
416
345
|
// would diverge from the saved selection shown in the provider block below.
|
|
417
346
|
// The auto-fetched catalog stays available behind the Models row (Enter →
|
|
418
347
|
// loadProviderModels).
|
|
419
|
-
const selectedEntry =
|
|
348
|
+
const selectedEntry = configured.find((row) => row.entry.provider === selectedProvider)
|
|
420
349
|
let models = []
|
|
421
350
|
if (selectedEntry) {
|
|
422
|
-
const
|
|
423
|
-
const selectedProfile = selectedDescriptor ? valueAt(selectedDescriptor.value, selectedEntry.settingsPath) : undefined
|
|
424
|
-
const selectedRecord = typeof selectedProfile === 'object' && selectedProfile !== null ? selectedProfile : {}
|
|
351
|
+
const selectedRecord = typeof selectedEntry.profile === 'object' && selectedEntry.profile !== null ? selectedEntry.profile : {}
|
|
425
352
|
models = normalizeModels(selectedRecord.models)
|
|
426
353
|
} else {
|
|
427
354
|
models = normalizeModels(providerEntries.find(([id]) => id === selectedProvider)?.[1]?.models)
|
|
@@ -430,10 +357,7 @@ export async function loadModelSettings(ctx) {
|
|
|
430
357
|
// The picker annotates each candidate with its capabilities, so a user can
|
|
431
358
|
// tell at a glance which models take image input and which can adjust their
|
|
432
359
|
// thinking strength instead of discovering the refusal after sending.
|
|
433
|
-
const modelOptions = models
|
|
434
|
-
const suffix = capabilitySuffix(model)
|
|
435
|
-
return suffix ? { label: model.id + suffix, value: model.id } : model.id
|
|
436
|
-
})
|
|
360
|
+
const modelOptions = modelPickerAnnotations(models)
|
|
437
361
|
add('agent-default-model', 'model', t('model.defaultModel'), modelOptions, { kind: 'default-model' })
|
|
438
362
|
let reasoningOptions = effortOptions(models, selectedModel)
|
|
439
363
|
let reasoningDefault
|
|
@@ -454,22 +378,45 @@ export async function loadModelSettings(ctx) {
|
|
|
454
378
|
add('agent-default-model', 'reasoningEffort', t('model.reasoning'), reasoningOptions, reasoningExtra)
|
|
455
379
|
|
|
456
380
|
// ---- merged provider blocks --------------------------------------------
|
|
457
|
-
for (const
|
|
458
|
-
const descriptor =
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
381
|
+
for (const row of configured) {
|
|
382
|
+
const { entry, descriptor, path: settingsPath } = row
|
|
383
|
+
// A whole-section profile (`settingsPath: []`) is the plugin entry's own
|
|
384
|
+
// Config: there is no per-route record, and its `models` is the adapter's
|
|
385
|
+
// catalog rather than a selection, so it gets no model editing here.
|
|
386
|
+
const wholeSection = settingsPath.length === 0
|
|
387
|
+
const profile = row.profile
|
|
463
388
|
const record = typeof profile === 'object' && profile !== null ? profile : {}
|
|
464
|
-
const
|
|
389
|
+
const isAccount = entry.provider === ACCOUNT_ROUTE
|
|
390
|
+
items.push({ kind: 'header', label: entry.displayName, value: '', disabled: true })
|
|
391
|
+
// A catalog diagnostic the adapter reported (a stored profile its installed
|
|
392
|
+
// catalog can no longer serve) is the whole reason a request from this route
|
|
393
|
+
// would fail, so it belongs on the row instead of in a log the TUI cannot
|
|
394
|
+
// show.
|
|
395
|
+
if (typeof entry.error === 'string' && entry.error !== '') {
|
|
396
|
+
items.push({ kind: 'provider-error', label: entry.error, wide: true, indent: 1, tone: 'warning', disabled: true, selectable: false })
|
|
397
|
+
}
|
|
398
|
+
// The account route authenticates through a sign-in, not a stored key, so it
|
|
399
|
+
// has no reference to describe and nothing to remove.
|
|
400
|
+
const ref = isAccount
|
|
401
|
+
? undefined
|
|
402
|
+
: (typeof record.apiKeyEnv === 'string' && record.apiKeyEnv ? record.apiKeyEnv : routeKeyRef(entry.provider))
|
|
465
403
|
let credential
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
404
|
+
if (ref !== undefined) {
|
|
405
|
+
try { credential = credentials ? await credentials.describe(ref) : undefined } catch { credential = undefined }
|
|
406
|
+
}
|
|
407
|
+
if (URL_FAMILIES.has(entry.settingsNs)) items.push({ kind: 'path', label: t('model.providerUrl'), value: typeof record.baseURL === 'string' ? record.baseURL : 'default', ns: entry.settingsNs, path: [...settingsPath, 'baseURL'], revision: descriptor.revision, disabled: !settings.writable })
|
|
408
|
+
if (isAccount) {
|
|
409
|
+
items.push({ kind: 'provider-note', label: t('provider.accountManaged'), wide: true, indent: 1, disabled: true, selectable: false })
|
|
410
|
+
} else {
|
|
411
|
+
items.push({ kind: 'secret', label: t('model.providerApiKey'), value: credential?.configured ? t('model.credentialConfigured', { source: credential.source ?? 'stored' }) : t('model.credentialMissing'), credentialRef: ref, disabled: !credentials || credential?.writable === false })
|
|
412
|
+
}
|
|
469
413
|
if (entry.settingsNs === 'llm-pi-ai' && entry.declared === true) {
|
|
470
|
-
items.push({ kind: 'path', label: t('model.displayName'), value: typeof record.displayName === 'string' ? record.displayName : entry.displayName, ns: entry.settingsNs, path: [...
|
|
471
|
-
|
|
472
|
-
|
|
414
|
+
items.push({ kind: 'path', label: t('model.displayName'), value: typeof record.displayName === 'string' ? record.displayName : entry.displayName, ns: entry.settingsNs, path: [...settingsPath, 'displayName'], revision: descriptor.revision, disabled: !settings.writable })
|
|
415
|
+
// The protocols offered are the ones this adapter can serve, read out of
|
|
416
|
+
// its own schema: a hardcoded list offered `google-generative-ai`, which
|
|
417
|
+
// the family has no implementation for and would refuse at request time.
|
|
418
|
+
const choices = protocolChoices(descriptor)
|
|
419
|
+
if (choices.length > 0) items.push({ kind: 'path-choice', label: t('model.wireProtocol'), value: typeof record.api === 'string' ? record.api : choices[0], options: choices, ns: entry.settingsNs, path: [...settingsPath, 'api'], revision: descriptor.revision, disabled: !settings.writable })
|
|
473
420
|
}
|
|
474
421
|
// Route-level request modalities. This is what makes a hand-declared vision
|
|
475
422
|
// model usable: pi-ai under-claims text by default (refusing the image before
|
|
@@ -477,7 +424,7 @@ export async function loadModelSettings(ctx) {
|
|
|
477
424
|
// serving vision models declares `[text, image]` once here instead of on
|
|
478
425
|
// every entry. Catalog models keep the modalities the catalog records for
|
|
479
426
|
// them; this value never narrows one.
|
|
480
|
-
if (entry.settingsNs === 'llm-pi-ai') {
|
|
427
|
+
if (entry.settingsNs === 'llm-pi-ai' && !wholeSection) {
|
|
481
428
|
const declaredInput = Array.isArray(record.defaultInput) ? record.defaultInput : undefined
|
|
482
429
|
items.push({
|
|
483
430
|
kind: 'input-modalities',
|
|
@@ -485,7 +432,7 @@ export async function loadModelSettings(ctx) {
|
|
|
485
432
|
value: declaredInput?.includes('image') ? 'text + image' : 'text only',
|
|
486
433
|
options: ['text only', 'text + image'].map((id) => ({ label: tv(id), value: id })),
|
|
487
434
|
ns: entry.settingsNs,
|
|
488
|
-
path: [...
|
|
435
|
+
path: [...settingsPath, 'defaultInput'],
|
|
489
436
|
revision: descriptor.revision,
|
|
490
437
|
disabled: !settings.writable,
|
|
491
438
|
})
|
|
@@ -493,13 +440,16 @@ export async function loadModelSettings(ctx) {
|
|
|
493
440
|
// Models: Enter auto-fetches the provider catalog and opens the selection
|
|
494
441
|
// window; the saved selection is listed right below, one row per model.
|
|
495
442
|
const stored = normalizeModels(record.models)
|
|
496
|
-
|
|
443
|
+
if (!wholeSection) {
|
|
444
|
+
items.push({ kind: 'provider-models', label: t('model.models'), value: stored.length > 0 ? t('model.selectedCount', { count: stored.length }) : t('model.fetch'), providerId: entry.provider, disabled: false })
|
|
445
|
+
}
|
|
497
446
|
for (const model of stored) {
|
|
498
447
|
const isDefault = entry.provider === selectedProvider && model.id === selectedModel
|
|
448
|
+
const suffix = capabilitySuffix(model)
|
|
499
449
|
items.push({
|
|
500
450
|
kind: 'provider-model',
|
|
501
451
|
label: model.id,
|
|
502
|
-
value: isDefault ? t('model.defaultTag') :
|
|
452
|
+
value: isDefault ? t('model.defaultTag') + suffix : suffix,
|
|
503
453
|
indent: 1,
|
|
504
454
|
modelId: model.id,
|
|
505
455
|
providerId: entry.provider,
|
|
@@ -508,8 +458,37 @@ export async function loadModelSettings(ctx) {
|
|
|
508
458
|
disabled: false,
|
|
509
459
|
})
|
|
510
460
|
}
|
|
511
|
-
|
|
461
|
+
// A hand-declared route's endpoint may advertise nothing usable, so a model
|
|
462
|
+
// id has to be typeable without a discovery round trip.
|
|
463
|
+
if (!wholeSection && entry.settingsNs === 'llm-pi-ai') {
|
|
464
|
+
items.push({
|
|
465
|
+
kind: 'provider-model-add',
|
|
466
|
+
label: t('provider.field.addModel'),
|
|
467
|
+
value: '',
|
|
468
|
+
indent: 1,
|
|
469
|
+
ns: entry.settingsNs,
|
|
470
|
+
path: settingsPath,
|
|
471
|
+
storedModels: stored,
|
|
472
|
+
revision: descriptor.revision,
|
|
473
|
+
disabled: !settings.writable,
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
const removable = !wholeSection && hasPath(descriptor.user, settingsPath) && !hasPath(descriptor.base, settingsPath)
|
|
477
|
+
if (removable) items.push({ kind: 'remove-provider', label: t('model.removeProvider'), value: 'Enter', ns: entry.settingsNs, path: settingsPath, revision: descriptor.revision, credentialRef: credential?.configured && credential?.writable ? ref : undefined, confirmText: t('model.removeProviderConfirm', { name: entry.displayName }), disabled: !settings.writable })
|
|
478
|
+
}
|
|
479
|
+
// ---- add a provider ---------------------------------------------------
|
|
480
|
+
// The WebUI's add card, as a row: with nothing configured it is also the only
|
|
481
|
+
// way out of an otherwise empty Model tab, which is exactly the state a fresh
|
|
482
|
+
// install starts in.
|
|
483
|
+
if (configured.length === 0) {
|
|
484
|
+
items.push({ kind: 'provider-none', label: t('provider.empty'), wide: true, disabled: true, selectable: false })
|
|
512
485
|
}
|
|
486
|
+
items.push({
|
|
487
|
+
kind: 'provider-add',
|
|
488
|
+
label: t('provider.add'),
|
|
489
|
+
value: tn('model.addCount', addable.length, { count: addable.length }),
|
|
490
|
+
disabled: !settings.writable || addable.length === 0,
|
|
491
|
+
})
|
|
513
492
|
return { settings, items, title: t('model.title'), subtitle: t('model.subtitle'), menu: SETTINGS_MENU, menuIndex: 1 }
|
|
514
493
|
}
|
|
515
494
|
|
|
@@ -532,18 +511,33 @@ export async function loadProviderModels(ctx, providerId) {
|
|
|
532
511
|
const fetched = discovered.models
|
|
533
512
|
const fetchFailed = discovered.failed
|
|
534
513
|
const items = []
|
|
514
|
+
// The refusal, verbatim: "answered 401; check the API key" and "no model
|
|
515
|
+
// listing for this protocol" are the difference between a fetch that looks
|
|
516
|
+
// broken and one a user can fix with the next keystroke.
|
|
517
|
+
if (discovered.error) {
|
|
518
|
+
items.push({ kind: 'model-error', label: discovered.error, wide: true, indent: 1, tone: 'warning', disabled: true, selectable: false })
|
|
519
|
+
}
|
|
535
520
|
const seen = new Set()
|
|
521
|
+
const rows = []
|
|
536
522
|
const pushRow = (model) => {
|
|
537
523
|
if (!model?.id || seen.has(model.id)) return
|
|
538
524
|
seen.add(model.id)
|
|
539
525
|
const checked = storedIds.has(model.id)
|
|
540
|
-
|
|
526
|
+
const saved = stored.find((candidate) => candidate.id === model.id)
|
|
527
|
+
rows.push({
|
|
541
528
|
kind: 'model-toggle',
|
|
542
529
|
label: model.id + capabilitySuffix(model),
|
|
543
|
-
|
|
530
|
+
// The saved entry owns the capacities: a model the user corrected must
|
|
531
|
+
// read back as corrected, not as what the endpoint advertises.
|
|
532
|
+
value: (checked ? '[x]' : '[ ]') + capacitySuffix(saved ?? model),
|
|
544
533
|
modelId: model.id,
|
|
545
534
|
checked,
|
|
546
|
-
|
|
535
|
+
// What the entry would be if adopted: the endpoint's metadata, or the
|
|
536
|
+
// stored entry itself when the row came from the saved list.
|
|
537
|
+
candidate: model,
|
|
538
|
+
// Position in the saved list, or -1 for a model this provider lists
|
|
539
|
+
// nowhere yet. The editor addresses the array by position.
|
|
540
|
+
storedIndex: saved === undefined ? -1 : stored.indexOf(saved),
|
|
547
541
|
storedModels: stored,
|
|
548
542
|
providerId,
|
|
549
543
|
settingsNs: entry.settingsNs,
|
|
@@ -555,16 +549,42 @@ export async function loadProviderModels(ctx, providerId) {
|
|
|
555
549
|
for (const model of fetched) pushRow(model)
|
|
556
550
|
// Saved models that the catalog no longer returns stay toggleable.
|
|
557
551
|
for (const model of stored) pushRow(model)
|
|
558
|
-
if (
|
|
552
|
+
if (fetched.length > 0) {
|
|
553
|
+
const picked = fetched.filter((model) => storedIds.has(model.id)).length
|
|
554
|
+
items.push({
|
|
555
|
+
kind: 'model-select-all',
|
|
556
|
+
label: t('model.selectAll'),
|
|
557
|
+
value: (picked === fetched.length ? '[x] ' : '[ ] ') + picked + '/' + fetched.length,
|
|
558
|
+
allPicked: picked === fetched.length,
|
|
559
|
+
candidates: fetched,
|
|
560
|
+
storedModels: stored,
|
|
561
|
+
providerId,
|
|
562
|
+
settingsNs: entry.settingsNs,
|
|
563
|
+
providerPath: entry.settingsPath,
|
|
564
|
+
revision: descriptor.revision,
|
|
565
|
+
disabled: !settings.writable,
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
items.push(...rows)
|
|
569
|
+
if (rows.length === 0) {
|
|
559
570
|
items.push({ label: fetchFailed ? t('model.noModels') : t('model.noModelsReturned'), value: '', disabled: true })
|
|
560
571
|
}
|
|
572
|
+
// The two actions a discovery round trip cannot cover: a model the endpoint
|
|
573
|
+
// does not advertise (or one whose metadata needs correcting), and asking
|
|
574
|
+
// again now that the endpoint came up.
|
|
575
|
+
items.push({ kind: 'model-add', label: t('provider.field.addModel'), value: 'Enter', storedModels: stored, providerId, settingsNs: entry.settingsNs, providerPath: entry.settingsPath, revision: descriptor.revision, disabled: !settings.writable })
|
|
576
|
+
items.push({ kind: 'model-refresh', label: t('model.refresh'), value: 'Enter', providerId, disabled: !settings.writable })
|
|
561
577
|
return {
|
|
562
578
|
settings,
|
|
563
579
|
items,
|
|
580
|
+
storedModels: stored,
|
|
581
|
+
fetched,
|
|
582
|
+
fetchFailed,
|
|
583
|
+
fetchError: discovered.error,
|
|
564
584
|
title: entry.displayName + ' · ' + t('model.models').toLowerCase(),
|
|
565
585
|
subtitle: fetchFailed
|
|
566
|
-
? t('model.fetchUnavailable')
|
|
567
|
-
: t('model.fetchOk'),
|
|
586
|
+
? t('model.fetchUnavailable') + ' · ' + t('model.editHint')
|
|
587
|
+
: t('model.fetchOk') + ' · ' + t('model.editHint'),
|
|
568
588
|
}
|
|
569
589
|
}
|
|
570
590
|
|
|
@@ -633,21 +653,41 @@ export async function saveWebSetting(ctx, settings, item, value) {
|
|
|
633
653
|
], item.revision)
|
|
634
654
|
}
|
|
635
655
|
// The auto-fetched selection window: toggle one model in or out of the
|
|
636
|
-
// provider's saved models list. Existing stored entries keep their shape
|
|
637
|
-
//
|
|
656
|
+
// provider's saved models list. Existing stored entries keep their shape
|
|
657
|
+
// (and their position, so nothing the user corrected is rewritten);
|
|
658
|
+
// an adopted one keeps what the endpoint advertised.
|
|
638
659
|
if (item.kind === 'model-toggle') {
|
|
639
|
-
const
|
|
640
|
-
if (item.checked)
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
}
|
|
646
|
-
for (const id of ids) {
|
|
647
|
-
if (!next.some((entry) => modelEntryId(entry) === id)) next.push({ id })
|
|
660
|
+
const next = [...(item.storedModels ?? [])]
|
|
661
|
+
if (item.checked) {
|
|
662
|
+
const index = next.findIndex((entry) => modelEntryId(entry) === item.modelId)
|
|
663
|
+
if (index >= 0) next.splice(index, 1)
|
|
664
|
+
} else if (!next.some((entry) => modelEntryId(entry) === item.modelId)) {
|
|
665
|
+
next.push(adoptDiscovered(item.candidate) ?? { id: item.modelId })
|
|
648
666
|
}
|
|
649
667
|
return settings.mutate(item.settingsNs, [{ op: 'set', path: [...item.providerPath, 'models'], value: next }], item.revision)
|
|
650
668
|
}
|
|
669
|
+
// A model id typed by hand joins the saved list. Existing entries keep their
|
|
670
|
+
// shape and their order; the new row is a plain `{ id }` record, which is all
|
|
671
|
+
// a catalog model needs (the adapter fills the capacities it knows).
|
|
672
|
+
if (item.kind === 'provider-model-add') {
|
|
673
|
+
const id = value.trim()
|
|
674
|
+
if (id === '') return
|
|
675
|
+
const next = [...(item.storedModels ?? [])]
|
|
676
|
+
if (next.some((entry) => modelEntryId(entry) === id)) throw new Error(t('provider.error.modelDuplicate', { id }))
|
|
677
|
+
next.push({ id })
|
|
678
|
+
return settings.mutate(item.ns, [{ op: 'set', path: [...item.path, 'models'], value: next }], item.revision)
|
|
679
|
+
}
|
|
680
|
+
// The whole-selection toggle: every model the endpoint advertised, or none.
|
|
681
|
+
//
|
|
682
|
+
// Deselecting means the empty list — the stored rows go too, which is the only
|
|
683
|
+
// reading of "deselect all" that leaves nothing ticked. For a catalog route the
|
|
684
|
+
// unset is also "serve your own catalog again"; a hand-declared route has no
|
|
685
|
+
// catalog to fall back to, so the adapter refuses that write and says why.
|
|
686
|
+
if (item.kind === 'model-select-all') {
|
|
687
|
+
const next = item.allPicked ? [] : mergeModelSelection(item.storedModels ?? [], item.candidates ?? [])
|
|
688
|
+
const path = [...item.providerPath, 'models']
|
|
689
|
+
return settings.mutate(item.settingsNs, [next.length === 0 ? { op: 'unset', path } : { op: 'set', path, value: next }], item.revision)
|
|
690
|
+
}
|
|
651
691
|
if (!item.ns || !item.field) return
|
|
652
692
|
if (item.ns === 'locale' && item.field === 'preference' && value === 'system') return settings.replace(item.ns, {}, item.revision)
|
|
653
693
|
// A TUI-owned field set back to the value it defaults to is cleared rather
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-oc-tui",
|
|
3
|
-
"version": "0.2.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A terminal UI (TUI) for DeepSeek Harness — an opencode-inspired chat client that boots as a dsh profile app plugin. | DeepSeek Harness 的终端界面(TUI),受 opencode 启发,以 dsh profile 应用插件形式启动。",
|
|
6
6
|
"keywords": [
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"author": "rayafriandion",
|
|
26
26
|
"license": "LGPL-3.0-or-later",
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node tests/smoke.test.mjs && node tests/i18n.test.mjs && node tests/locale.test.mjs && node tests/locale-forms.test.mjs && node tests/graphics.test.mjs && node tests/render.test.mjs && node tests/rail.test.mjs && node tests/rewind.test.mjs && node tests/std.test.mjs && node tests/resume.test.mjs && node tests/mcp.test.mjs && node tests/mcp-form.test.mjs && node tests/mcp-sse-bridge.test.mjs && node tests/mcp-skill.test.mjs && node tests/mcp-live.test.mjs && node tests/esc-questions.test.mjs && node tests/deadline.test.mjs && node tests/secret-prompt.test.mjs && node tests/test-mermaid-ascii.mjs",
|
|
29
|
-
"check": "node --check lib/index.js && node --check lib/ui.js && node --check lib/term.js && node --check lib/caps.js && node --check lib/image.js && node --check lib/mcp.js && node --check lib/mcp-skill.js && node --check lib/mermaid.js && node --check lib/mermaid-ascii.js && node --check lib/util.js && node --check lib/metrics.js && node --check lib/interrupt.js && node --check lib/web-settings.js && node --check lib/updates.js && node --check lib/rewind.js && node --check lib/bridge.js && node --check lib/facet.js && node --check lib/i18n.js && node --check lib/locales/en.js && node --check lib/locales/zh-CN.js && node --check lib/locales/zh-TW.js && node --check lib/locales/ja.js && node --check lib/locales/ko.js && node --check lib/locales/de.js && node --check lib/locales/fr.js && node --check lib/locales/es.js && node --check lib/locales/pt-BR.js && node --check lib/locales/ru.js && node --check lib/locales/it.js && node --check lib/locales/tr.js && node --check lib/locales/vi.js && node --check lib/locales/id.js && node --check lib/locales/pl.js && node --check lib/locales/nl.js && node --check lib/locales/ar.js && node --check lib/std/adapt.js && node --check lib/std/presentation.js && node --check lib/std/command-list.js && node --check lib/std/commands.js && node --check bin/dsh-oc-tui.js && node --check bin/apply-update.cjs && node --check bin/mcp-sse-bridge.mjs"
|
|
28
|
+
"test": "node tests/smoke.test.mjs && node tests/i18n.test.mjs && node tests/locale.test.mjs && node tests/locale-forms.test.mjs && node tests/provider-form.test.mjs && node tests/graphics.test.mjs && node tests/render.test.mjs && node tests/reasoning.test.mjs && node tests/diagnostics.test.mjs && node tests/media.test.mjs && node tests/rail.test.mjs && node tests/rewind.test.mjs && node tests/git-branch.test.mjs && node tests/std.test.mjs && node tests/resume.test.mjs && node tests/mcp.test.mjs && node tests/mcp-form.test.mjs && node tests/mcp-sse-bridge.test.mjs && node tests/mcp-skill.test.mjs && node tests/mcp-live.test.mjs && node tests/esc-questions.test.mjs && node tests/deadline.test.mjs && node tests/secret-prompt.test.mjs && node tests/settings-paste.test.mjs && node tests/test-mermaid-ascii.mjs",
|
|
29
|
+
"check": "node --check lib/index.js && node --check lib/ui.js && node --check lib/term.js && node --check lib/diagnostics.js && node --check lib/caps.js && node --check lib/image.js && node --check lib/mcp.js && node --check lib/mcp-skill.js && node --check lib/mermaid.js && node --check lib/mermaid-ascii.js && node --check lib/providers.js && node --check lib/util.js && node --check lib/metrics.js && node --check lib/interrupt.js && node --check lib/web-settings.js && node --check lib/updates.js && node --check lib/rewind.js && node --check lib/git-branch.js && node --check lib/bridge.js && node --check lib/facet.js && node --check lib/i18n.js && node --check lib/locales/en.js && node --check lib/locales/zh-CN.js && node --check lib/locales/zh-TW.js && node --check lib/locales/ja.js && node --check lib/locales/ko.js && node --check lib/locales/de.js && node --check lib/locales/fr.js && node --check lib/locales/es.js && node --check lib/locales/pt-BR.js && node --check lib/locales/ru.js && node --check lib/locales/it.js && node --check lib/locales/tr.js && node --check lib/locales/vi.js && node --check lib/locales/id.js && node --check lib/locales/pl.js && node --check lib/locales/nl.js && node --check lib/locales/ar.js && node --check lib/std/adapt.js && node --check lib/std/presentation.js && node --check lib/std/command-list.js && node --check lib/std/commands.js && node --check bin/dsh-oc-tui.js && node --check bin/apply-update.cjs && node --check bin/mcp-sse-bridge.mjs"
|
|
30
30
|
},
|
|
31
31
|
"main": "lib/index.js",
|
|
32
32
|
"exports": {
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"docs/用户手册.md"
|
|
46
46
|
],
|
|
47
47
|
"engines": {
|
|
48
|
-
"node": "^22.19 || >=24"
|
|
48
|
+
"node": "^22.19 || >=24",
|
|
49
|
+
"dsh": ">=0.1.1-rc.2 <0.3.0-0"
|
|
49
50
|
},
|
|
50
51
|
"peerDependencies": {
|
|
51
52
|
"@deepseek-ai/cordis": "*",
|