/** * 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/.' }, { 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 }, })