The settings namespace was registered but nothing drew a form for it, because the Plugin configuration tab renders the INTERSECTION of the namespaces the Host serves and the cards a browser half claims. A served namespace with no card renders nothing at all. This is the missing card. Plain ESM in the module-loader wrapper, no build step, requiring only `react` — which is in the platform seed table. Anything outside that table would have to be bundled into this file or declared in `dsh.client.external`, and the tests fail loudly on any other specifier. It stages edits and writes them on Save; a cleared field is an `unset` rather than a write of "", so the value re-inherits the composition layer instead of becoming empty, and the user layer's presence is what marks a field overridden. Reset clears every override. A namespace this client cannot reach renders nothing, so a deployment without the Host half shows no trace. package.json gains an `exports` map (the loader reads `exports["./client"]`; a bare `main` would not be enough) and the `dsh.client` declaration.
295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
/**
|
|
* dsh-slopo — browser half: the card in Settings → Plugins → Plugin configuration.
|
|
*
|
|
* The Host half registers a `dsh-slopo` settings namespace. This file registers
|
|
* a card under that exact namespace key. Both are required: the tab renders the
|
|
* INTERSECTION of the namespaces the Host serves and the cards the browser
|
|
* claims, so a served namespace with no card draws nothing at all, and a card
|
|
* whose namespace is not served is never dispatched.
|
|
*
|
|
* 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 only import is `react`, which is in the
|
|
* platform seed table; anything outside that table would have to be bundled
|
|
* into this file or declared in `dsh.client.external`.
|
|
*
|
|
* Styling is inline on purpose. The client bundle purity gate forbids
|
|
* cross-plugin value imports, so 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 NS = 'dsh-slopo'
|
|
const h = react.createElement
|
|
|
|
/**
|
|
* The settings this card edits.
|
|
*
|
|
* This list mirrors the Host schema in index.js. It is duplicated rather
|
|
* than fetched because the card renders its own controls — there is no
|
|
* generic 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.
|
|
*/
|
|
const FIELDS = [
|
|
{
|
|
key: 'embeddingModel',
|
|
label: 'Embedding model',
|
|
hint: 'LiteLLM string, provider prefix included — e.g. ollama/unclemusclez/jina-embeddings-v2-base-code:latest',
|
|
},
|
|
{ key: 'embeddingDimensions', label: 'Embedding dimensions', kind: 'number', hint: 'Must match what the model returns. Frozen into the index at first use.' },
|
|
{ key: 'embeddingApiBase', label: 'Embedding API base', hint: 'e.g. http://127.0.0.1:11434' },
|
|
{
|
|
key: 'embeddingApiKey',
|
|
label: 'Embedding API key',
|
|
kind: 'password',
|
|
hint: 'Passed to slopo in its environment and never written into the slopo config file.',
|
|
},
|
|
{ key: 'embeddingParamsJson', label: 'Extra embedding parameters', hint: 'A JSON object, merged into embedding_params.' },
|
|
{ key: 'binary', label: 'slopo executable', hint: 'Empty discovers it: managed install, then PATH, then ~/.local/bin.' },
|
|
{ key: 'autoInstall', label: 'Install slopo with uv when missing', kind: 'boolean' },
|
|
{ key: 'sourceDir', label: 'Source directory', hint: 'Empty uses the session working directory.' },
|
|
{ key: 'sourceDirExclude', label: 'Exclude patterns', kind: 'list', hint: 'Comma separated, gitignore style.' },
|
|
{ key: 'stateDir', label: 'State directory', hint: 'Empty uses $DSH_HOME/dsh-slopo/workspaces/<workspace>.' },
|
|
{ key: 'ignoreFile', label: 'Ignore file', hint: 'Point this at the repository to version the dismissed clusters.' },
|
|
{ key: 'embeddingRequestDelay', label: 'Delay between embedding requests (ms)', kind: 'number' },
|
|
{ key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' },
|
|
]
|
|
|
|
const S = {
|
|
card: { border: '1px solid var(--dsh-border, #3a3a3a)', borderRadius: '8px', padding: '16px', margin: '8px 0' },
|
|
head: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '12px', marginBottom: '4px' },
|
|
title: { fontSize: '14px', fontWeight: 600 },
|
|
badge: { fontSize: '11px', opacity: 0.7 },
|
|
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
|
|
row: { marginTop: '12px' },
|
|
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
|
|
input: {
|
|
width: '100%',
|
|
boxSizing: 'border-box',
|
|
padding: '6px 8px',
|
|
fontSize: '12px',
|
|
borderRadius: '6px',
|
|
border: '1px solid var(--dsh-border, #3a3a3a)',
|
|
background: 'transparent',
|
|
color: 'inherit',
|
|
},
|
|
actions: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '16px', flexWrap: 'wrap' },
|
|
button: {
|
|
padding: '6px 12px',
|
|
fontSize: '12px',
|
|
borderRadius: '6px',
|
|
border: '1px solid var(--dsh-border, #3a3a3a)',
|
|
background: 'transparent',
|
|
color: 'inherit',
|
|
cursor: 'pointer',
|
|
},
|
|
primary: { fontWeight: 600 },
|
|
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
|
|
dirty: { fontSize: '11px', opacity: 0.8 },
|
|
}
|
|
|
|
/** The editable string form of one field's value. */
|
|
function toText(field, value) {
|
|
if (field.kind === 'list') return Array.isArray(value) ? value.join(', ') : ''
|
|
if (field.kind === 'boolean') return value === true ? 'true' : 'false'
|
|
if (value === undefined || value === null) return ''
|
|
return String(value)
|
|
}
|
|
|
|
/** The value to write for one field, from its string form. */
|
|
function fromText(field, text) {
|
|
const trimmed = typeof text === 'string' ? text.trim() : ''
|
|
if (field.kind === 'number') return Number(trimmed)
|
|
if (field.kind === 'boolean') return trimmed === 'true'
|
|
if (field.kind === 'list') {
|
|
return trimmed === ''
|
|
? []
|
|
: trimmed
|
|
.split(/[,\n]/)
|
|
.map((part) => part.trim())
|
|
.filter((part) => part !== '')
|
|
}
|
|
return text
|
|
}
|
|
|
|
/** Build a draft of every field from a resolved section. */
|
|
function toDraft(value) {
|
|
const draft = {}
|
|
for (const field of FIELDS) draft[field.key] = toText(field, value?.[field.key])
|
|
return draft
|
|
}
|
|
|
|
function Field(props) {
|
|
const { field, value, overridden, disabled, onChange } = props
|
|
const common = {
|
|
style: S.input,
|
|
value,
|
|
disabled,
|
|
'aria-label': field.label,
|
|
onChange: (event) => onChange(event.target.value),
|
|
}
|
|
return h(
|
|
'div',
|
|
{ style: S.row },
|
|
h(
|
|
'label',
|
|
{ style: S.label },
|
|
field.label,
|
|
overridden ? h('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' · overridden') : null,
|
|
),
|
|
field.kind === 'boolean'
|
|
? h('input', {
|
|
type: 'checkbox',
|
|
checked: value === 'true',
|
|
disabled,
|
|
'aria-label': field.label,
|
|
onChange: (event) => onChange(event.target.checked ? 'true' : 'false'),
|
|
})
|
|
: h('input', { ...common, type: field.kind === 'password' ? 'password' : 'text' }),
|
|
field.hint === undefined ? null : h('div', { style: S.hint }, field.hint),
|
|
)
|
|
}
|
|
|
|
function Card(props) {
|
|
const scope = props.scope
|
|
const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot())
|
|
const [draft, setDraft] = react.useState(null)
|
|
const [busy, setBusy] = react.useState(false)
|
|
const [note, setNote] = react.useState('')
|
|
|
|
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
|
|
|
|
const status = snapshot.status
|
|
const value = snapshot.value
|
|
const baseline = react.useMemo(() => toDraft(value), [value])
|
|
const current = draft ?? baseline
|
|
const user = snapshot.user
|
|
|
|
if (status === 'unavailable') {
|
|
// A deployment without the Host half, or a remote browser: show no trace.
|
|
return null
|
|
}
|
|
|
|
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
|
|
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
|
|
|
|
const save = async () => {
|
|
setBusy(true)
|
|
setNote('')
|
|
try {
|
|
for (const field of FIELDS) {
|
|
const next = current[field.key]
|
|
if (next === baseline[field.key]) continue
|
|
// Clearing a field is an unset, not a write of "", so the value
|
|
// re-inherits the composition layer instead of becoming empty.
|
|
if (String(next).trim() === '') await scope.unset(field.key)
|
|
else await scope.set(field.key, fromText(field, next))
|
|
}
|
|
setDraft(null)
|
|
setNote('Saved.')
|
|
} catch (error) {
|
|
setNote(`Save failed: ${error?.message ?? error}`)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const reset = async () => {
|
|
setBusy(true)
|
|
setNote('')
|
|
try {
|
|
for (const key of overriddenKeys) await scope.unset(key)
|
|
setDraft(null)
|
|
setNote('Reset to the deployment defaults.')
|
|
} catch (error) {
|
|
setNote(`Reset failed: ${error?.message ?? error}`)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return h(
|
|
'div',
|
|
{ style: S.card },
|
|
h(
|
|
'div',
|
|
{ style: S.head },
|
|
h('div', { style: S.title }, 'Slopo'),
|
|
h(
|
|
'div',
|
|
{ style: S.badge },
|
|
status === 'loading' ? 'loading…' : 'configuration',
|
|
snapshot.writable === true ? '' : ' · read-only',
|
|
),
|
|
),
|
|
h(
|
|
'div',
|
|
{ style: S.hint },
|
|
'Slopo runs as a separate process. Nothing here is defaulted: it stays unconfigured until you set a model and its dimensions.',
|
|
),
|
|
...FIELDS.map((field) =>
|
|
h(Field, {
|
|
key: field.key,
|
|
field,
|
|
value: current[field.key],
|
|
overridden: overriddenKeys.includes(field.key),
|
|
disabled: busy || snapshot.writable !== true,
|
|
onChange: (next) => setDraft({ ...current, [field.key]: next }),
|
|
}),
|
|
),
|
|
h(
|
|
'div',
|
|
{ style: S.actions },
|
|
h(
|
|
'button',
|
|
{ type: 'button', style: { ...S.button, ...S.primary }, disabled: busy || dirtyKeys.length === 0, onClick: save },
|
|
dirtyKeys.length === 0 ? 'Save' : `Save ${dirtyKeys.length} change${dirtyKeys.length === 1 ? '' : 's'}`,
|
|
),
|
|
h(
|
|
'button',
|
|
{ type: 'button', style: S.button, disabled: busy || overriddenKeys.length === 0, onClick: reset },
|
|
`Reset ${overriddenKeys.length} override${overriddenKeys.length === 1 ? '' : 's'}`,
|
|
),
|
|
note === '' ? null : h('span', { style: S.note }, note),
|
|
),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Register the card, but only once a settings transport exists.
|
|
*
|
|
* `ctx.inject(['settingsScope'], …)` rather than a top-level `inject`,
|
|
* because a required dependency that is unsatisfied means this plugin never
|
|
* applies at all — the card would vanish along with everything else instead
|
|
* of simply not appearing.
|
|
*/
|
|
function apply(ctx) {
|
|
ctx.inject(['settingsScope'], (scoped) => {
|
|
const binder = scoped.settingsScope
|
|
if (binder === undefined) return
|
|
const scope = binder.bind({ namespace: NS })
|
|
scoped.slots.inject('settings.plugin.item', () =>
|
|
scoped.slots.register(
|
|
{
|
|
name: 'settings.plugin.item',
|
|
key: NS,
|
|
inject: () => ({ scope }),
|
|
},
|
|
(props) => h(Card, props),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
|
|
module.exports = { name: NS, inject: ['slots'], apply }
|
|
return module.exports
|
|
},
|
|
})
|