mirror of
https://github.com/zhu1090093659/dsh-web.git
synced 2026-09-28 14:24:03 +08:00
The official family published 0.1.7-alpha.1 while the alpha branch stood on 0.1.6-alpha.2, and the machine's DSH moved to the new cohort. Four changes in the release reach this family: - The settings subsystem is now keyed by the profile entry: ctx.settingsScope, SettingsScope* and SettingsProvider.installSection/register are gone, replaced by ctx.configForms, ConfigForm* and SettingsForms. Every family host half carries its settings on its own Config with volatile fields, every browser half binds through the webUiSettings service (which resolves a family namespace to the owning profile entry id and then binds natively, with the loopback bridge as the fallback), and the shared card/form layer speaks the new contract including its boolean refusal answers. - The agent-preset domain became declarative: directory discovery is gone, a preset is a declaration a plugin registers at runtime. dsh-preset-center now declares installed presets from its host half (install = declare) and dsh-liangshen declares its own preset instead of syncing files into ~/.dsh/.agent-presets. - Session V4 moved the tool-result failure flag to the message root; dsh-pet read the removed content block and silently rendered failed turns as successes. - The client design system renamed its icons and reshaped SessionListState; the affected call sites and fixtures follow. Cohort: every consumed @deepseek-ai/dsh-* range, the scaffold, the README badges, the CI and release mount-smoke pins, the desktop runtime pin and both lockfiles, and the docs move together; the vendor pins follow the cohort's own release (cordis 4.0.3, cosmokit 1.8.4, schemastery 3.18.3, cordis-plugin-include 1.0.8, cordis-plugin-loader 1.0.4). The workspace packageExtensions restore @deepseek-ai/dsh-util-workspace-path, which dsh-client-ui-primitives now imports from its emitted lib while declaring no dependencies. scripts/e2e-mount.sh, scripts/e2e-mount-rewrite and its test, and scripts/publish-legacy-aggregate.mjs pass GNU tar --force-local on the Windows/MSYS lane, where a C:\ output path is otherwise read as a remote host. The mount smoke does not pass on this workstation: the local dsh CLI is a symlink into a DSH checkout whose built lib/ predates this cohort's own multi-file dsh.bundle.patch support, so profile boot crashes on the official dsh-web-app array before any family row is evaluated. That is host-side build staleness outside this repository; the lane passes once the DSH checkout is rebuilt. Recorded with the rest of the decision in .agents/notes/implemented/architecture/2026-09-22-sdk-cohort-0.1.7-alpha.1.md.
442 lines
17 KiB
TypeScript
442 lines
17 KiB
TypeScript
/**
|
|
* Staged form model behind the plugin settings card. A card stages what the
|
|
* user types and writes it only when they save — the settings write is a
|
|
* durable, revision-fenced document mutation, so staging keeps what is on
|
|
* screen exactly what a save would store. Family-shared slice inlined into
|
|
* each plugin's client bundle; mirrors the official ui-plugin-config
|
|
* card-store pattern.
|
|
*/
|
|
|
|
import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
|
|
/** The write one field's staged text performs when the card is saved. */
|
|
export type FieldWrite =
|
|
| { kind: 'set'; value: unknown }
|
|
| { kind: 'clear' }
|
|
|
|
/** How one field converts between its stored value and its draft text. */
|
|
export interface FieldSpec {
|
|
/** Field name inside the namespace section. */
|
|
field: string
|
|
/**
|
|
* Whether the Host treats this field as a secret and redacts its value from
|
|
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
* never compared against the draft on save: the Host strips them from every
|
|
* wire view layer, so the settled snapshot carries nothing to read back. A
|
|
* staged secret set is judged by the mutation settling; the rest of its
|
|
* batch, when one exists, is still judged by read-back, and the atomic
|
|
* mutation lands every write or none.
|
|
*/
|
|
secret?: boolean
|
|
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
format: (value: unknown) => string
|
|
/**
|
|
* The write this draft text stages, or undefined when the text is not a
|
|
* value this field accepts — which blocks the save rather than discarding it.
|
|
*/
|
|
parse: (text: string) => FieldWrite | undefined
|
|
}
|
|
|
|
/** One field as the card renders it. */
|
|
export interface FieldState {
|
|
/** Draft text the control renders. */
|
|
text: string
|
|
/** Whether saving would leave a user-layer entry for this field. */
|
|
overridden: boolean
|
|
/** Whether the draft is not a value this field accepts, which blocks saving. */
|
|
invalid: boolean
|
|
}
|
|
|
|
/** Form state every plugin settings card shares. */
|
|
export interface CardShell {
|
|
/** False while the namespace is still loading; the card renders nothing. */
|
|
available: boolean
|
|
/**
|
|
* Whether the namespace is actually served to this client. False when the
|
|
* Host deployment does not expose it (e.g. the owning plugin's settings
|
|
* domain is not mounted): the card renders an explanation
|
|
* instead of its form, so a missing namespace never looks like a missing
|
|
* plugin.
|
|
*/
|
|
exposed: boolean
|
|
/** Whether the Host document accepts writes. */
|
|
writable: boolean
|
|
/** Whether the form holds edits that a save would write. */
|
|
dirty: boolean
|
|
/** Whether any staged draft is invalid, which blocks the save. */
|
|
invalid: boolean
|
|
/** Whether a save is crossing the wire. */
|
|
saving: boolean
|
|
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
|
failed: boolean
|
|
/**
|
|
* The rejection code/message the Host returned for the last failed save,
|
|
* surfaced next to the generic failure text. Undefined while no save has
|
|
* failed (or the failure carried no server reason).
|
|
*/
|
|
failedReason?: string
|
|
}
|
|
|
|
/** The write actions the card's slot entry injects. */
|
|
export interface CardActions {
|
|
/** Stage draft text for one field. */
|
|
edit: (field: string, text: string) => void
|
|
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
|
|
resetField: (field: string) => void
|
|
/** Write every staged edit, then re-seed from what the Host accepted. */
|
|
save: () => void
|
|
/** Drop every staged edit. */
|
|
discard: () => void
|
|
}
|
|
|
|
/** One field's staged edit. */
|
|
interface StagedEdit {
|
|
/** Draft text the control renders. */
|
|
text: string
|
|
/** True when this edit clears the field whatever text it shows. */
|
|
clear: boolean
|
|
}
|
|
|
|
/** One staged edit resolved into the write a save performs. */
|
|
interface PlannedWrite {
|
|
/** Field this entry writes. */
|
|
field: string
|
|
/** The durable write this entry performs, inside the save's one atomic mutation. */
|
|
op: BatchedWrite
|
|
/**
|
|
* Read the settled snapshot back and report whether the Host holds this
|
|
* write's effect. Undefined when the draft is not a value the field
|
|
* accepts: there is nothing to write, and the entry blocks the save.
|
|
*/
|
|
judge: (() => boolean) | undefined
|
|
}
|
|
|
|
/** One durable write inside the save's atomic form mutation. */
|
|
export interface BatchedWrite {
|
|
/** Field this entry writes. */
|
|
field: string
|
|
/** set stores a value; unset drops the leaf. */
|
|
op: 'set' | 'unset'
|
|
/** Value for op set (absent for unset). */
|
|
value?: unknown
|
|
}
|
|
|
|
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
export interface NumberConstraints {
|
|
/** The accepted value must be a whole number. */
|
|
integer?: boolean
|
|
/** The accepted value must be at least this. */
|
|
min?: number
|
|
}
|
|
|
|
/** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
|
|
export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
|
|
const { integer = false, min } = constraints
|
|
return {
|
|
field,
|
|
format: value => typeof value === 'number' ? String(value) : '',
|
|
parse: (text) => {
|
|
const trimmed = text.trim()
|
|
if (trimmed === '') return { kind: 'clear' }
|
|
const parsed = Number(trimmed)
|
|
if (!Number.isFinite(parsed)) return undefined
|
|
if (integer && !Number.isInteger(parsed)) return undefined
|
|
if (min !== undefined && parsed < min) return undefined
|
|
return { kind: 'set', value: parsed }
|
|
},
|
|
}
|
|
}
|
|
|
|
/** A free-text field. An empty draft clears the field. */
|
|
export function textField(field: string): FieldSpec {
|
|
return {
|
|
field,
|
|
format: value => typeof value === 'string' ? value : '',
|
|
parse: (text) => {
|
|
const trimmed = text.trim()
|
|
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
* (role('secret') in the section schema). The card still edits it like text,
|
|
* but a save never compares the redacted value back: the staged set is judged
|
|
* by the mutation settling (see {@link FieldSpec.secret}).
|
|
*/
|
|
export function secretField(field: string): FieldSpec {
|
|
return { ...textField(field), secret: true }
|
|
}
|
|
|
|
/** A boolean field, edited through true/false draft text. */
|
|
export function booleanField(field: string): FieldSpec {
|
|
return {
|
|
field,
|
|
format: value => typeof value === 'boolean' ? String(value) : '',
|
|
parse: (text) => {
|
|
const trimmed = text.trim()
|
|
if (trimmed === '') return { kind: 'clear' }
|
|
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
return undefined
|
|
},
|
|
}
|
|
}
|
|
|
|
/** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
|
|
export function choiceField(field: string, choices: readonly string[]): FieldSpec {
|
|
return {
|
|
field,
|
|
format: value => typeof value === 'string' && choices.includes(value) ? value : '',
|
|
parse: (text) => {
|
|
if (text === '') return { kind: 'clear' }
|
|
return choices.includes(text) ? { kind: 'set', value: text } : undefined
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stages one card's edits over one settings namespace and writes them on save.
|
|
*
|
|
* The Host is the only authority on whether a value was accepted — its
|
|
* validators own the constraints no schema can express — so the outcome is
|
|
* read back from the section rather than predicted here. A save that did not
|
|
* land keeps its drafts, so the user can correct them instead of retyping.
|
|
*/
|
|
export class CardForm<T> {
|
|
private readonly specs: Map<string, FieldSpec>
|
|
private readonly staged = new Map<string, StagedEdit>()
|
|
private readonly listeners = new Set<() => void>()
|
|
/** The form subscription installed in the constructor; released by dispose(). */
|
|
private readonly disposeForm: () => void
|
|
private disposed = false
|
|
private saving = false
|
|
private failed = false
|
|
private failedReason: string | undefined
|
|
|
|
/** @param scope - the bound configuration form for this card's namespace. */
|
|
constructor(
|
|
private readonly scope: ConfigForm<T>,
|
|
specs: FieldSpec[],
|
|
) {
|
|
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
this.disposeForm = scope.subscribe(() => { this.publish() })
|
|
}
|
|
|
|
/**
|
|
* Release the form subscription and every bound store listener. The card
|
|
* must call this on teardown; later calls are no-ops.
|
|
*/
|
|
dispose(): void {
|
|
if (this.disposed) return
|
|
this.disposed = true
|
|
this.disposeForm()
|
|
this.listeners.clear()
|
|
}
|
|
|
|
/** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
|
|
bind<S>(project: () => S): SnapshotStore<S> {
|
|
const store = createSnapshotStore(project())
|
|
this.listeners.add(() => { store.set(project()) })
|
|
return store
|
|
}
|
|
|
|
/** Read the card-level state: what the Host serves, and what a save would do. */
|
|
shell(): CardShell {
|
|
const snapshot = this.scope.getSnapshot()
|
|
const plan = this.plan()
|
|
return {
|
|
available: snapshot.status !== 'loading',
|
|
exposed: snapshot.status === 'ready',
|
|
writable: snapshot.writable,
|
|
dirty: plan.length > 0,
|
|
invalid: plan.some(item => item.judge === undefined),
|
|
saving: this.saving,
|
|
failed: this.failed,
|
|
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
}
|
|
}
|
|
|
|
/** Read one field's state from the effective section and its staged draft. */
|
|
field(field: string): FieldState {
|
|
const spec = this.specOf(field)
|
|
const staged = this.staged.get(field)
|
|
if (staged === undefined) {
|
|
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
|
}
|
|
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
|
return {
|
|
text: staged.text,
|
|
overridden: write?.kind === 'set',
|
|
invalid: write === undefined,
|
|
}
|
|
}
|
|
|
|
/** The actions the card's slot registration injects. */
|
|
actions(): CardActions {
|
|
return {
|
|
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
|
resetField: (field) => {
|
|
this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
|
|
},
|
|
save: () => { void this.save() },
|
|
discard: () => {
|
|
if (this.staged.size === 0 && !this.failed) return
|
|
this.staged.clear()
|
|
this.failed = false
|
|
this.failedReason = undefined
|
|
this.publish()
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write every staged edit in one atomic form mutation, then re-seed from
|
|
* what the Host accepted.
|
|
*
|
|
* The whole batch rides one mutate, so cross-field validate hooks
|
|
* (baseURL+model) judge it as a unit: the Host either applies every write
|
|
* or refuses the batch. The form contract answers a refusal or a skipped
|
|
* write with `false` (it recovers with a fresh Host view instead of
|
|
* throwing), so the outcome is judged twice: the answer itself, and then the
|
|
* settled snapshot read back one planned write at a time. One missed write
|
|
* fails the whole save. A transport that rejects instead (the dsh-web bridge
|
|
* controller on a dead connection) reports through the same failure path
|
|
* with its rejection message. A save that did not land keeps its drafts, so
|
|
* the user can correct them instead of retyping.
|
|
* @returns settlement after the mutation and the read-back.
|
|
*/
|
|
async save(): Promise<void> {
|
|
const plan = this.plan()
|
|
const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
|
|
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
// Snapshot the staged entries this save writes, so an edit staged while it
|
|
// is in flight (which replaces the same key) survives: only delete the key
|
|
// when the entry is still the one this save started from.
|
|
const pending = new Map<string, StagedEdit | undefined>()
|
|
for (const item of plan) pending.set(item.field, this.staged.get(item.field))
|
|
this.saving = true
|
|
this.failed = false
|
|
this.failedReason = undefined
|
|
this.publish()
|
|
// One atomic namespace mutation: the form contract takes ordered path
|
|
// operations, so the whole staged batch is validated, persisted, and
|
|
// recovered together — either every write lands or none does.
|
|
const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
|
|
? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
|
|
: { op: 'unset', path: [item.field] })
|
|
let failedReason: string | undefined
|
|
let accepted = false
|
|
try {
|
|
accepted = await this.scope.mutate(ops)
|
|
} catch (error) {
|
|
failedReason = error instanceof Error ? error.message : String(error)
|
|
}
|
|
// `false` is the contract's refusal/skip answer, and the form still
|
|
// resolves after a refusal (it recovers with a fresh view), so the answer
|
|
// alone is not enough: judge every planned write against the settled
|
|
// snapshot as well. The mutation is atomic, so one missed write fails the
|
|
// whole save and keeps the drafts.
|
|
const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
|
|
for (const [field, before] of pending) {
|
|
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
}
|
|
this.saving = false
|
|
this.failed = !landed
|
|
// A refused or unlanded batch carries no server reason: the card surfaces
|
|
// its generic failure copy; a rejecting transport (the bridge) adds its
|
|
// message.
|
|
this.failedReason = failedReason
|
|
this.publish()
|
|
}
|
|
|
|
/**
|
|
* Every staged edit a save would write. An entry whose draft is not a value
|
|
* its field accepts carries no write: the form is still dirty, and the save
|
|
* refuses rather than dropping the edit. A staged edit that matches the
|
|
* effective section is not a write at all.
|
|
* @returns the planned writes, in the order the fields were staged.
|
|
*/
|
|
private plan(): PlannedWrite[] {
|
|
const plan: PlannedWrite[] = []
|
|
for (const [field, staged] of this.staged) {
|
|
const spec = this.specOf(field)
|
|
if (staged.clear) {
|
|
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
|
|
continue
|
|
}
|
|
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
const write = spec.parse(staged.text)
|
|
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, judge: undefined })
|
|
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
|
|
else plan.push({ field, op: { field, op: 'set', value: write.value }, judge: () => this.landedSet(field, write.value) })
|
|
}
|
|
return plan
|
|
}
|
|
|
|
/**
|
|
* Read-back judgment for a planned set: the user layer must hold the
|
|
* intended value once the mutation has settled.
|
|
*/
|
|
private landedSet(field: string, value: unknown): boolean {
|
|
// A redacted secret never appears in any wire view layer: the Host strips
|
|
// role('secret') fields and reports them through a sidecar the form
|
|
// snapshot does not expose, so there is nothing to compare the draft
|
|
// against. Settling is the only signal the form has; the rest of the
|
|
// batch, when one exists, still carries the atomic verdict by read-back.
|
|
if (this.specOf(field).secret) return true
|
|
return this.userLayer()?.[field] === value
|
|
}
|
|
|
|
/**
|
|
* Read-back judgment for a planned unset: the field must be gone from the
|
|
* user layer once the mutation has settled.
|
|
*/
|
|
private landedUnset(field: string): boolean {
|
|
return !this.stored(field)
|
|
}
|
|
|
|
private stage(field: string, edit: StagedEdit): void {
|
|
this.staged.set(field, edit)
|
|
this.failed = false
|
|
this.failedReason = undefined
|
|
this.publish()
|
|
}
|
|
|
|
private specOf(field: string): FieldSpec {
|
|
const spec = this.specs.get(field)
|
|
// Every call site names a field this card declared; a missing one is a
|
|
// wiring mistake that must not degrade into a silently inert control.
|
|
if (spec === undefined) throw new Error(`settings card has no field ${field}`)
|
|
return spec
|
|
}
|
|
|
|
private snapshotOf(): ConfigFormSnapshot<T> {
|
|
return this.scope.getSnapshot()
|
|
}
|
|
|
|
private sectionValue(field: string): unknown {
|
|
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
|
}
|
|
|
|
private baseValue(field: string): unknown {
|
|
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
|
}
|
|
|
|
private userLayer(): Record<string, unknown> | undefined {
|
|
return this.snapshotOf().user as Record<string, unknown> | undefined
|
|
}
|
|
|
|
private stored(field: string): boolean {
|
|
const user = this.userLayer()
|
|
return user !== undefined && Object.hasOwn(user, field)
|
|
}
|
|
|
|
private publish(): void {
|
|
for (const listener of this.listeners) listener()
|
|
}
|
|
}
|