Files
dsh-slopo/client/client.js
T
dsh-slopo agent 9ebd484e60
Release / release (push) Successful in 6s
fix: draw the API key once, and let status answer the dimensions question
Two things a user setting this up hit immediately.

The card drew `embeddingApiKey` twice. The render loop walked every entry in
FIELDS, and the secret — which is drawn by hand, as a write-only password
control — was one of them, so a second control appeared beside it: an ordinary
text input, echoing the key in clear as you typed. One key, two boxes, one of
them visible. The loop skips secrets now, and the tests count controls rather
than checking that each expected label is present, because a duplicate beside
the real control passed the old assertions.

`status` could not answer the one question a new user cannot look up: how wide
is this model? The probe that reports it was gated on every required setting
being present, so in exactly the state that needs the answer — dimensions
unset — it reported nothing and said "Missing: embedding_dimensions". It now
runs whenever the endpoint can be named (a model and a base URL), and the
status message names the width it observed:

  Missing: embedding_dimensions. The endpoint is reachable and returned 768
  dimensions, so set embeddingDimensions to 768.

The probe also tells "not set yet" apart from "set wrong", because the two have
different remedies.

Also documents both ways to read the width by hand, since a wrong value is
frozen into the index at first use and costs a full re-embed to correct.
2026-09-26 07:05:36 +01:00

450 lines
19 KiB
JavaScript

/**
* dsh-slopo — browser half: the bundle's configuration page on the Plugins page.
*
* Where the card lives, and why it moved. Up to dsh 0.1.6 the settings surfaces
* were: a settings namespace a plugin registered for itself, plus a
* `settings.plugin.item` card the plugin's client half registered under that
* namespace key, rendered inside Settings → Plugins → Plugin configuration.
* 0.1.7-rc.2 removed both halves of that contract:
*
* * `ctx.settings.installSection(...)` no longer exists, and a namespace is no
* longer chosen by the plugin — it IS the Loader profile entry id, which the
* bundle's own patch declares (`dsh-slopo`).
* * the `settings.plugin.item` slot and the `settingsScope` service no longer
* exist either. Configuration now lives on the Plugins page, and a bundle's
* own settings register into `plugins.bundle.config` keyed by the bundle's
* PACKAGE NAME, which the page only dispatches for a package it lists.
*
* The service that replaced `settingsScope` is `configForms`, and the object it
* hands out for one entry (`configForms.get(id)`) is API-compatible with what
* this card used before: `getSnapshot()`, `subscribe()`, `mutate(ops, revision)`.
* What actually changed is the chrome. The framework now ships the form
* vocabulary this card hand-rolled — `SettingsFormModel` stages the edits and
* fences the save with the revision the drafts were read at, `SettingsForm`
* draws the frame and the single Save, and the field controls carry the
* overridden badge, the reset and the invalid-edit block. Reproducing that by
* hand is what the file used to do; using it is the point.
*
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
* step and no bundler. `dsh plugin add git+…` fetches sources rather than build
* output, so a `prepare` script would make every user allowlist a build before
* the first install succeeds. The imports are `react` and
* `@deepseek-ai/dsh-client-ui-primitives`, both platform seed words; anything
* outside that table would have to be bundled into this file or declared in
* `dsh.client.external`.
*
* Styling is inline on purpose, for the same reason: there is no stylesheet this
* file may reach.
*/
window.__ModuleLoader__.load({
id: 'dsh-slopo',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const h = react.createElement
const { Checkbox, SettingsForm, SettingsFormModel, SettingsSecretField, SettingsValueField } = primitives
const { settingsNumberField, settingsTextField } = primitives
/**
* The bundle's package name.
*
* One string plays three roles and they must agree: it is the npm package
* the Plugins page lists, the Loader entry id inside that bundle's patch
* (and therefore the settings namespace `configForms` addresses), and the
* `plugins.bundle.config` key this card registers under.
*/
const BUNDLE = 'dsh-slopo'
/** Dictionary namespace owned by this plugin. */
const NS = 'dsh-slopo'
/** The one write-only field: a credential the wire never carries back. */
const API_KEY_FIELD = 'embeddingApiKey'
const DESCRIPTION =
'Embedding backend, index scope and the budget that stops a large embed.'
/** The card's own copy. */
const en = {
description: DESCRIPTION,
overridden: 'Overridden',
reset: 'Reset to default',
readOnly: 'This deployment stores settings read-only.',
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
save: 'Save',
saving: 'Saving…',
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
invalidNumber: 'Enter a number, or leave blank to use the default.',
apiKeySet: 'A key is stored.',
apiKeyUnset: 'No key is stored.',
listHint: 'Comma separated.',
'binary.label': 'slopo executable',
'binary.hint': 'Empty discovers it: managed install, then PATH, then ~/.local/bin.',
'autoInstall.label': 'Install slopo with uv when missing',
'autoInstall.hint': 'Installs into $DSH_HOME with uv, so the executable survives a container recreation.',
'embeddingModel.label': 'Embedding model',
'embeddingModel.hint':
'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
'embeddingDimensions.label': 'Embedding dimensions',
'embeddingDimensions.hint': 'Must match what the model returns. Frozen into the index at first use.',
'embeddingApiBase.label': 'Embedding API base',
'embeddingApiBase.hint': 'e.g. http://127.0.0.1:11434',
'embeddingApiKey.label': 'Embedding API key',
'embeddingApiKey.hint':
'Passed to slopo in its environment and never written into the slopo config file. It is write-only here: the deployment reports whether one is stored, never the key.',
'embeddingParamsJson.label': 'Extra embedding parameters',
'embeddingParamsJson.hint': 'A JSON object, merged into embedding_params.',
'sourceDir.label': 'Source directory',
'sourceDir.hint': "Empty uses the session's working directory.",
'sourceDirExclude.label': 'Extra exclude patterns',
'sourceDirExclude.hint': 'Gitignore style, comma separated. Added to the defaults rather than replacing them.',
'useDefaultExcludes.label': 'Exclude node_modules and build output',
'useDefaultExcludes.hint':
'Off means slopo parses your dependencies too — on a repo with node_modules, the difference between ~1,500 and ~114,000 code units.',
'maxIndexUnits.label': 'Refuse to embed more than this many units',
'maxIndexUnits.hint': '0 removes the limit. Makes a large scope a question rather than an hours-long embed.',
'ignoreFile.label': 'Ignore file',
'ignoreFile.hint': 'Point this at the repository to version the dismissed clusters.',
'stateDir.label': 'State directory',
'stateDir.hint': 'Empty uses $DSH_HOME/dsh-slopo/workspaces/<workspace>.',
'embeddingRequestDelay.label': 'Delay between embedding requests (ms)',
'embeddingRequestDelay.hint': 'Rate limit for a provider that rejects bursts. 0 sends as fast as it can.',
'timeoutMs.label': 'Foreground timeout (ms)',
'timeoutMs.hint': 'How long one foreground slopo run may take before it is killed.',
}
/**
* The fields this card edits, in display order.
*
* This list mirrors the Host schema in index.js, including `kind`. It is
* duplicated rather than fetched because the card renders its own controls —
* there is no schema-to-form renderer in the framework — and a field the
* Host does not know is rejected there anyway, so a drift shows up as a
* clear error rather than silent data loss. Every key must be a `volatile()`
* field of that schema or the write is refused.
*/
const FIELDS = [
{ key: 'embeddingModel', kind: 'text' },
{ key: 'embeddingDimensions', kind: 'number' },
{ key: 'embeddingApiBase', kind: 'text' },
{ key: 'embeddingApiKey', kind: 'secret' },
{ key: 'embeddingParamsJson', kind: 'text' },
{ key: 'binary', kind: 'text' },
{ key: 'autoInstall', kind: 'boolean' },
{ key: 'sourceDir', kind: 'text' },
{ key: 'sourceDirExclude', kind: 'list' },
{ key: 'useDefaultExcludes', kind: 'boolean' },
{ key: 'maxIndexUnits', kind: 'number' },
{ key: 'ignoreFile', kind: 'text' },
{ key: 'stateDir', kind: 'text' },
{ key: 'embeddingRequestDelay', kind: 'number' },
{ key: 'timeoutMs', kind: 'number' },
]
/** A label or hint from this card's dictionary. */
const copy = (t, key, part) => t(`${key}.${part}`)
/** Every field's label and hint, resolving against the active locale. */
const describeField = (t, field) => ({
key: field.key,
kind: field.kind,
label: copy(t, field.key, 'label'),
hint: copy(t, field.key, 'hint'),
id: `plugin-config-${BUNDLE}-${field.key}`,
})
/**
* A boolean field's draft conversion.
*
* The shared kit exports number and text specs only; a switch is a set of
* `true`/`false`, so the control has nothing to parse but the two literals
* it renders. Anything else is an invalid draft, which blocks the save
* rather than silently writing `false`.
*/
const settingsBooleanField = (field) => ({
field,
format: (value) => (value === true ? 'true' : 'false'),
parse: (text) => {
const trimmed = text.trim()
if (trimmed === 'true') return { kind: 'set', value: true }
if (trimmed === 'false') return { kind: 'set', value: false }
return undefined
},
})
/**
* A string-array field's draft conversion: a comma-separated list.
*
* An empty draft is an empty list rather than a clear: the schema declares
* this field's default as `[]`, so "no extra patterns" and "fall back to
* the composition layer" are the same answer.
*/
const settingsListField = (field) => ({
field,
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
parse: (text) => ({
kind: 'set',
value: text
.split(/[,\n]/)
.map((part) => part.trim())
.filter((part) => part !== ''),
}),
})
/** The spec one field's drafts are converted with. */
function specFor(field) {
if (field.kind === 'number') return settingsNumberField(field.key)
if (field.kind === 'boolean') return settingsBooleanField(field.key)
if (field.kind === 'list') return settingsListField(field.key)
return settingsTextField(field.key)
}
/** The form frame's copy, read from this card's dictionary. */
const formLabels = (t) => ({
unavailable: t('unavailable'),
readOnly: t('readOnly'),
saveFailed: t('saveFailed'),
save: t('save'),
saving: t('saving'),
})
/** The field-control copy shared by every row. */
const fieldLabels = (t) => ({
overriddenLabel: t('overridden'),
resetLabel: t('reset'),
invalidLabel: t('invalidNumber'),
})
/** One staged text row. */
function Row(props) {
const { t, state, disabled, edit, resetField, meta } = props
return h(SettingsValueField, {
id: meta.id,
label: meta.label,
hint: meta.hint,
disabled,
numeric: meta.kind === 'number',
...fieldLabels(t),
...state,
onEdit: (text) => edit(meta.key, text),
onReset: () => resetField(meta.key),
})
}
/**
* One staged switch row.
*
* `Checkbox` owns native keyboard and form semantics but no override
* affordance, so the badge and the reset sit beside it — the same pair a
* `SettingsValueField` draws for a text row.
*/
function SwitchRow(props) {
const { t, state, disabled, edit, resetField, meta } = props
const checked = state.text === 'true'
return h(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
h(
'div',
{ style: { display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap' } },
h(Checkbox, {
label: meta.label,
checked,
disabled,
onChange: (next) => edit(meta.key, next ? 'true' : 'false'),
}),
state.overridden
? h(
'button',
{
type: 'button',
disabled,
onClick: () => resetField(meta.key),
style: {
font: 'inherit',
fontSize: '11px',
cursor: 'pointer',
background: 'transparent',
color: 'inherit',
opacity: 0.75,
border: 'none',
padding: 0,
},
},
`${t('overridden')} · ${t('reset')}`,
)
: null,
),
h('div', { style: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 } }, meta.hint),
)
}
/**
* Render the bundle's page: the form, or nothing while the deployment does
* not serve the entry.
*
* @param props - the card's state snapshot, the form actions, and locale copy.
* @returns the settings form.
*/
function SlopoCard(props) {
const { t } = props
const state = props.useSlopoCard((snapshot) => snapshot)
// `view` is `page` for a bundle's configuration slot; the bundle's
// one-liner is the package's own description, so there is no summary view
// to answer here.
const disabled = !state.writable
const children = []
for (const field of FIELDS) {
// The secret is drawn once, by hand, below. Walking it here as well gave
// it a second control — an ordinary text input, echoing the key in
// clear — sitting beside the write-only one.
if (field.kind === 'secret') continue
const meta = describeField(t, field)
const shared = { key: field.key, t, state: state[field.key], disabled, edit: props.edit, resetField: props.resetField, meta }
children.push(field.kind === 'boolean' ? h(SwitchRow, shared) : h(Row, shared))
}
return h(
SettingsForm,
{ labels: formLabels(t), state, onSave: props.save, onDiscard: props.discard },
h(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: '14px' } },
// The key is write-only, so it is not one of the rows above.
h(SettingsSecretField, {
id: `plugin-config-${BUNDLE}-${API_KEY_FIELD}`,
label: copy(t, API_KEY_FIELD, 'label'),
hint: copy(t, API_KEY_FIELD, 'hint'),
disabled,
text: state.apiKey.text,
configured: state.apiKeyConfigured,
stateLabel: state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset'),
onEdit: (text) => props.edit(API_KEY_FIELD, text),
}),
...children,
),
)
}
/**
* The card's staged form over the `dsh-slopo` profile entry.
*
* `SettingsFormModel` is what makes Save the only write: a draft is staged
* against the revision it was read at, an invalid draft blocks the save
* instead of being dropped, and a refused save keeps its drafts so the user
* can correct them. The model subscribes to the scope itself and republishes
* through {@link SettingsFormModel.bind}, so this class only has to describe
* what one snapshot contains.
*/
class SlopoCardController {
/**
* @param ctx - the browser plugin context, whose `configForms` reports the entry's secret slots.
* @param scope - the shared configuration form for the `dsh-slopo` entry.
*/
constructor(ctx, scope) {
const specs = FIELDS.filter((field) => field.kind !== 'secret').map(specFor)
const secrets = [
{
field: API_KEY_FIELD,
// The credential is a settings field, not a separate credential
// reference, so its one-way write is the same fenced mutation the
// ordinary rows use — with the value never coming back.
write: (text) => scope.mutate([{ op: 'set', path: [API_KEY_FIELD], value: text }]),
},
]
this.ctx = ctx
this.form = new SettingsFormModel(scope, specs, secrets)
this.store = this.form.bind(() => this.projection())
}
/** Whether the deployment reports a stored key; the value itself never rides. */
readApiKeyConfigured() {
const view = this.ctx.configForms.describe().getSnapshot().view
const entry = view?.namespaces?.find((row) => row.ns === BUNDLE)
const slot = entry?.secrets?.find(
(secret) => Array.isArray(secret.path) && secret.path.length === 1 && secret.path[0] === API_KEY_FIELD,
)
return slot?.set === true
}
/** Build the snapshot the card renders from the form's current reads. */
projection() {
const state = {
...this.form.shell(),
apiKey: this.form.field(API_KEY_FIELD),
apiKeyConfigured: this.readApiKeyConfigured(),
}
for (const field of FIELDS) {
if (field.kind !== 'secret') state[field.key] = this.form.field(field.key)
}
return state
}
/** Build the face the slot registration injects: the card's state and its actions. */
inject() {
return { hooks: { slopoCard: this.store }, ...this.form.actions() }
}
/** Release the form's accepted-value subscription. */
dispose() {
this.form.dispose()
}
}
/**
* Register the card, but only once the settings transport exists.
*
* `ctx.inject(['configForms'], …)` rather than a top-level `inject`: a
* required dependency that is unsatisfied means this plugin never applies at
* all, and the card would simply not exist on a deployment that composes no
* settings surface — which is the correct outcome, not a load failure.
*
* `whileServed` is the second gate. The registration only stands while the
* deployment actually serves the `dsh-slopo` namespace, so an uninstalled or
* disabled bundle leaves no card behind, and the Plugins page's own
* `plugins.bundle.config` ledger stays in step with what the Host serves.
*/
function apply(ctx) {
ctx.effect(
() =>
ctx.locale.register(NS, {
en,
}),
'dsh-slopo: card dictionary',
)
ctx.inject(['configForms'], (child) => {
const forms = child.configForms
if (forms === undefined) return
const controller = new SlopoCardController(child, forms.get(BUNDLE))
child.effect(
() => () => controller.dispose(),
'dsh-slopo: form subscription',
)
child.effect(
() =>
forms.whileServed([BUNDLE], () =>
child.slots.inject('plugins.bundle.config', () =>
child.slots.register(
{
name: 'plugins.bundle.config',
key: BUNDLE,
locale: NS,
inject: () => controller.inject(),
},
SlopoCard,
),
),
),
'dsh-slopo: Plugins page card',
)
})
}
module.exports = { name: BUNDLE, inject: ['slots', 'locale'], apply }
return module.exports
},
})