refactor(dsh): stage the settings card with DSH's form model

The memory settings card carried its own draft state machine: a Draft
object, a parser that re-validated every field, a hand-written diff into
path operations, and its own revision fence. DSH ships exactly that as
SettingsFormModel, exported by the primitives package this plugin already
depends on and injects since 0.1.7-rc.2.

The card now builds the model once, reads shell()/field() while rendering
and hands every edit to actions(), so what is left is the controls plus
one field-spec table in settings-specs.ts. The revision fence, the
dirty/invalid/saving/failed states, keeping drafts after a refused save,
and clearing only the fields the user layer carries all come from the
model.

The one visible change: reset now stages clears for the fields the user
layer actually overrides, which is what that control always meant.

Tests: settings-specs.test.mjs covers every spec on its own, and the card
test runs DSH's real SettingsFormModel (evaluated from the installed
primitives bundle, which cannot be imported in Node because it loads CSS
modules and icons). The packaged-plugin check exercises that against all
six supported DSH releases.
This commit is contained in:
LiangshouX 2026-10-28 18:22:01 +08:00
parent fdb9cc3d70
commit 64d4bbd2c6
5 changed files with 447 additions and 347 deletions

View file

@ -1,8 +1,9 @@
import { useState, useSyncExternalStore } from "react";
import { useEffect, useReducer, useState, useSyncExternalStore } from "react";
import { SettingsFormModel } from "@deepseek-ai/dsh-client-ui-primitives";
import type { SettingsFieldState } from "@deepseek-ai/dsh-client-ui-primitives";
import type { ConfigForm } from "@deepseek-ai/dsh-client-ui-settings/client";
import type { ReMeSettings } from "../types.js";
import { validEndpoint } from "../endpoint.js";
import {
ReMeEnvironmentTab,
envEn,
@ -17,36 +18,19 @@ import {
type StatusRpc,
type StatusTranslator,
} from "./status-page.js";
import { SETTINGS_SPECS } from "./settings-specs.js";
import { styles } from "./styles.js";
const NS = "reme.settings";
const STATUS_NS = "reme.status";
const ENV_NS = "reme.environment";
const SETTINGS_NS = "reme-memory";
const FIELDS = [
"endpoint",
"requestTimeoutMs",
"backgroundTimeoutMs",
"shutdownTimeoutMs",
"autoMemoryRetryMs",
"autoMemoryRetryMaxMs",
"autoMemoryEnabled",
"autoMemoryInterval",
"autoDreamEnabled",
"dreamCron",
"dreamHint",
"rootAgentsOnly",
"language",
"searchLimit",
"timezone",
] as const satisfies readonly (keyof ReMeSettings)[];
type Field = (typeof FIELDS)[number];
type Translator = (key: keyof typeof en) => string;
interface ClientContext {
get(name: string): { rpc: StatusRpc };
effect(factory: () => (() => void) | void, label: string): void;
effect(factory: () => void | (() => void), label: string): void;
locale: {
bind(namespace: typeof NS): Translator;
bind(namespace: typeof STATUS_NS): StatusTranslator;
@ -72,23 +56,8 @@ interface ClientContext {
};
}
interface Draft {
endpoint: string;
requestTimeoutMs: string;
backgroundTimeoutMs: string;
shutdownTimeoutMs: string;
autoMemoryRetryMs: string;
autoMemoryRetryMaxMs: string;
autoMemoryEnabled: boolean;
autoMemoryInterval: string;
autoDreamEnabled: boolean;
dreamTime: string;
dreamHint: string;
rootAgentsOnly: boolean;
language: "en" | "zh";
searchLimit: string;
timezone: string;
}
/** One control as the model currently stages it. */
type FieldState = SettingsFieldState;
interface ReMeCardProps {
scope: ConfigForm<ReMeSettings>;
@ -324,79 +293,62 @@ function ReMeSettingsCard({
(listener) => scope.subscribe(listener),
() => scope.getSnapshot(),
);
const [draftOverride, setDraft] = useState<{
value: Draft;
revision: number | undefined;
}>();
const [resetAll, setResetAll] = useState(false);
const [saving, setSaving] = useState(false);
const [saveState, setSaveState] = useState<"" | "saved" | "failed">("");
// DSH's form model stages the drafts, marks the ones that are invalid and
// writes every staged edit as one revision-fenced mutation, so this page owns
// only its controls and the field specs behind them.
const [model] = useState(
() => new SettingsFormModel<ReMeSettings>(scope, SETTINGS_SPECS),
);
const [, publish] = useReducer((tick: number) => tick + 1, 0);
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [tab, setTab] = useState<"memory" | "environment">("memory");
const value = snapshot.value;
const draft =
draftOverride?.value ??
(value === undefined ? undefined : draftFrom(value));
useEffect(() => () => model.dispose(), [model]);
if (view === "summary") return <>{t("description")}</>;
if (snapshot.status === "unavailable") return null;
const dirty =
resetAll ||
(value !== undefined &&
draft !== undefined &&
!sameDraft(draft, draftFrom(value)));
const validation = draft === undefined ? undefined : parseDraft(draft);
const update = <K extends keyof Draft>(field: K, next: Draft[K]) => {
if (value === undefined) return;
setDraft((current) => ({
value: { ...(current?.value ?? draftFrom(value)), [field]: next },
revision: current ? current.revision : snapshot.revision,
}));
setResetAll(false);
setSaveState("");
const value = snapshot.value;
const shell = model.shell();
const actions = model.actions();
const field = (name: string): FieldState => model.field(name);
// "The maximum retry delay is at least the initial one" is a relation between
// two fields, which a per-field spec cannot state, so the page keeps it.
const retryMax = field("autoMemoryRetryMaxMs").text.trim();
const retryBase = field("autoMemoryRetryMs").text.trim();
const inconsistent =
retryMax !== "" && retryBase !== "" && Number(retryMax) < Number(retryBase);
const blocked = shell.invalid || inconsistent;
const edit = (name: string, text: string): void => {
setSaved(false);
actions.edit(name, text);
// The model publishes to its bound stores; this page reads it directly, so
// every staged edit repaints the controls itself.
publish();
};
const save = async () => {
if (
value === undefined ||
draft === undefined ||
validation === undefined ||
!snapshot.writable ||
!dirty ||
saving
)
return;
setSaving(true);
setSaveState("");
const save = async (): Promise<void> => {
if (!shell.writable || !shell.dirty || blocked || busy) return;
setSaved(false);
setBusy(true);
try {
const ops = resetAll
? FIELDS.map((field) => ({ op: "unset" as const, path: [field] }))
: FIELDS.flatMap((field) =>
Object.is(value[field], validation[field])
? []
: [
{
op: "set" as const,
path: [field],
value: validation[field],
},
],
);
const success = await scope.mutate(
ops,
draftOverride?.revision ?? snapshot.revision,
);
setSaveState(success ? "saved" : "failed");
if (success) {
setDraft(undefined);
setResetAll(false);
}
} catch {
setSaveState("failed");
await model.save();
const after = model.shell();
setSaved(!after.failed && !after.dirty);
} finally {
setSaving(false);
setBusy(false);
publish();
}
};
const reset = (): void => {
setSaved(false);
for (const spec of SETTINGS_SPECS) actions.resetField(spec.field);
publish();
};
const discard = (): void => {
setSaved(false);
actions.discard();
publish();
};
return (
<div className="reme-settings-card">
@ -427,89 +379,63 @@ function ReMeSettingsCard({
) : (
<p className="reme-settings-muted">{t("loading")}</p>
)
) : value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
) : (
<>
{dirty ? (
{shell.dirty ? (
<p className="reme-settings-pending">{t("unsaved")}</p>
) : null}
{snapshot.status === "loading" ||
draft === undefined ||
value === undefined ? (
<p className="reme-settings-muted">{t("loading")}</p>
) : (
<>
<fieldset
className="reme-settings-fields"
disabled={saving || !snapshot.writable}
>
<SettingsForm draft={draft} update={update} t={t} />
</fieldset>
{!snapshot.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
<fieldset
className="reme-settings-fields"
disabled={busy || !shell.writable}
>
<SettingsForm draft={field} edit={edit} t={t} />
</fieldset>
{!shell.writable ? (
<p className="reme-settings-error" role="status">
{t("readOnly")}
</p>
) : null}
{blocked ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!shell.writable || busy}
onClick={reset}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{shell.failed ? (
<span className="reme-settings-error">{t("saveFailed")}</span>
) : null}
{validation === undefined ? (
<p className="reme-settings-error" role="status">
{t("invalid")}
</p>
{saved && !shell.dirty ? (
<span className="reme-settings-success">{t("saved")}</span>
) : null}
<div className="reme-settings-actions">
<button
type="button"
className="reme-settings-reset"
disabled={!snapshot.writable || saving}
onClick={() => {
setDraft({
value: draftFrom(baseSettings(snapshot.base, value)),
revision: snapshot.revision,
});
setResetAll(true);
setSaveState("");
}}
>
{t("reset")}
</button>
<span className="reme-settings-result" role="status">
{saveState === "failed" ? (
<span className="reme-settings-error">
{t("saveFailed")}
</span>
) : null}
{saveState === "saved" ? (
<span className="reme-settings-success">
{t("saved")}
</span>
) : null}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!dirty || saving}
onClick={() => {
setDraft(undefined);
setResetAll(false);
setSaveState("");
}}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={
!snapshot.writable ||
!dirty ||
validation === undefined ||
saving
}
onClick={() => void save()}
>
{t(saving ? "saving" : "save")}
</button>
</div>
</>
)}
</span>
<button
type="button"
className="reme-settings-discard"
disabled={!shell.dirty || busy}
onClick={discard}
>
{t("discard")}
</button>
<button
type="button"
className="reme-settings-save"
disabled={!shell.writable || !shell.dirty || blocked || busy}
onClick={() => void save()}
>
{t(busy ? "saving" : "save")}
</button>
</div>
</>
)}
</div>
@ -517,15 +443,19 @@ function ReMeSettingsCard({
);
}
/** The memory-settings controls, all reading and writing through the model. */
function SettingsForm({
draft,
update,
edit,
t,
}: {
draft: Draft;
update: <K extends keyof Draft>(field: K, value: Draft[K]) => void;
draft: (name: string) => FieldState;
edit: (name: string, text: string) => void;
t: Translator;
}): JSX.Element {
const text = (name: string): string => draft(name).text;
const invalid = (name: string): boolean => draft(name).invalid;
const checked = (name: string): boolean => draft(name).text === "true";
return (
<>
<section className="reme-settings-section">
@ -533,17 +463,15 @@ function SettingsForm({
<div className="reme-settings-grid">
<Field wide label={t("endpoint")} hint={t("endpointHint")}>
<input
value={draft.endpoint}
aria-invalid={!validEndpoint(draft.endpoint)}
onChange={(event) => update("endpoint", event.target.value)}
value={text("endpoint")}
aria-invalid={invalid("endpoint")}
onChange={(event) => edit("endpoint", event.target.value)}
/>
</Field>
<Field label={t("language")}>
<select
value={draft.language}
onChange={(event) =>
update("language", event.target.value as "en" | "zh")
}
value={text("language")}
onChange={(event) => edit("language", event.target.value)}
>
<option value="zh">中文</option>
<option value="en">English</option>
@ -554,8 +482,8 @@ function SettingsForm({
type="number"
min="1"
max="50"
value={draft.searchLimit}
onChange={(event) => update("searchLimit", event.target.value)}
value={text("searchLimit")}
onChange={(event) => edit("searchLimit", event.target.value)}
/>
</Field>
<Field label={t("requestTimeout")}>
@ -563,10 +491,8 @@ function SettingsForm({
type="number"
min="1000"
max="120000"
value={draft.requestTimeoutMs}
onChange={(event) =>
update("requestTimeoutMs", event.target.value)
}
value={text("requestTimeoutMs")}
onChange={(event) => edit("requestTimeoutMs", event.target.value)}
/>
</Field>
</div>
@ -576,30 +502,32 @@ function SettingsForm({
<div className="reme-settings-grid">
<Check
label={t("autoMemoryEnabled")}
checked={draft.autoMemoryEnabled}
onChange={(value) => update("autoMemoryEnabled", value)}
checked={checked("autoMemoryEnabled")}
onChange={(next) =>
edit("autoMemoryEnabled", next ? "true" : "false")
}
/>
<Check
label={t("rootOnly")}
checked={draft.rootAgentsOnly}
onChange={(value) => update("rootAgentsOnly", value)}
checked={checked("rootAgentsOnly")}
onChange={(next) => edit("rootAgentsOnly", next ? "true" : "false")}
/>
<Field label={t("autoMemoryInterval")}>
<input
type="number"
min="1"
max="1000"
value={draft.autoMemoryInterval}
value={text("autoMemoryInterval")}
onChange={(event) =>
update("autoMemoryInterval", event.target.value)
edit("autoMemoryInterval", event.target.value)
}
/>
</Field>
<Field label={t("timezone")}>
<input
value={draft.timezone}
aria-invalid={!validTimezone(draft.timezone)}
onChange={(event) => update("timezone", event.target.value)}
value={text("timezone")}
aria-invalid={invalid("timezone")}
onChange={(event) => edit("timezone", event.target.value)}
/>
</Field>
</div>
@ -609,20 +537,22 @@ function SettingsForm({
<div className="reme-settings-grid">
<Check
label={t("autoDreamEnabled")}
checked={draft.autoDreamEnabled}
onChange={(value) => update("autoDreamEnabled", value)}
checked={checked("autoDreamEnabled")}
onChange={(next) =>
edit("autoDreamEnabled", next ? "true" : "false")
}
/>
<Field label={t("dreamTime")}>
<input
type="time"
value={draft.dreamTime}
onChange={(event) => update("dreamTime", event.target.value)}
value={text("dreamCron")}
onChange={(event) => edit("dreamCron", event.target.value)}
/>
</Field>
<Field wide label={t("dreamHint")}>
<textarea
value={draft.dreamHint}
onChange={(event) => update("dreamHint", event.target.value)}
value={text("dreamHint")}
onChange={(event) => edit("dreamHint", event.target.value)}
/>
</Field>
</div>
@ -635,31 +565,31 @@ function SettingsForm({
type="number"
min="1000"
max="3600000"
value={draft.backgroundTimeoutMs}
value={text("backgroundTimeoutMs")}
onChange={(event) =>
update("backgroundTimeoutMs", event.target.value)
edit("backgroundTimeoutMs", event.target.value)
}
/>
</Field>
<Field label={t("retryDelay")}>
<input
type="number"
min={1000}
max={60000}
value={draft.autoMemoryRetryMs}
min="1000"
max="60000"
value={text("autoMemoryRetryMs")}
onChange={(event) =>
update("autoMemoryRetryMs", event.target.value)
edit("autoMemoryRetryMs", event.target.value)
}
/>
</Field>
<Field label={t("retryMaxDelay")}>
<input
type="number"
min={1000}
max={3600000}
value={draft.autoMemoryRetryMaxMs}
min="1000"
max="3600000"
value={text("autoMemoryRetryMaxMs")}
onChange={(event) =>
update("autoMemoryRetryMaxMs", event.target.value)
edit("autoMemoryRetryMaxMs", event.target.value)
}
/>
</Field>
@ -668,9 +598,9 @@ function SettingsForm({
type="number"
min="100"
max="60000"
value={draft.shutdownTimeoutMs}
value={text("shutdownTimeoutMs")}
onChange={(event) =>
update("shutdownTimeoutMs", event.target.value)
edit("shutdownTimeoutMs", event.target.value)
}
/>
</Field>
@ -721,131 +651,6 @@ function Check({
);
}
function draftFrom(value: ReMeSettings): Draft {
return {
endpoint: value.endpoint,
requestTimeoutMs: String(value.requestTimeoutMs),
backgroundTimeoutMs: String(value.backgroundTimeoutMs),
shutdownTimeoutMs: String(value.shutdownTimeoutMs),
autoMemoryRetryMs: String(value.autoMemoryRetryMs),
autoMemoryRetryMaxMs: String(value.autoMemoryRetryMaxMs),
autoMemoryEnabled: value.autoMemoryEnabled,
autoMemoryInterval: String(value.autoMemoryInterval),
autoDreamEnabled: value.autoDreamEnabled,
dreamTime: timeFromCron(value.dreamCron),
dreamHint: value.dreamHint,
rootAgentsOnly: value.rootAgentsOnly,
language: value.language,
searchLimit: String(value.searchLimit),
timezone: value.timezone,
};
}
/**
* Validate the memory-settings drafts. Only the fields this page's `FIELDS`
* list writes are parsed: the interpreter, workspace and auto-start values
* belong to the Environment tab, which writes them on its own.
*/
function parseDraft(draft: Draft): Pick<ReMeSettings, Field> | undefined {
const requestTimeoutMs = boundedInteger(draft.requestTimeoutMs, 1000, 120000);
const backgroundTimeoutMs = boundedInteger(
draft.backgroundTimeoutMs,
1000,
3600000,
);
const shutdownTimeoutMs = boundedInteger(draft.shutdownTimeoutMs, 100, 60000);
const autoMemoryRetryMs = boundedInteger(
draft.autoMemoryRetryMs,
1000,
60000,
);
const autoMemoryRetryMaxMs = boundedInteger(
draft.autoMemoryRetryMaxMs,
autoMemoryRetryMs ?? 1000,
3600000,
);
const autoMemoryInterval = boundedInteger(draft.autoMemoryInterval, 1, 1000);
const searchLimit = boundedInteger(draft.searchLimit, 1, 50);
if (
!validEndpoint(draft.endpoint) ||
!validTimezone(draft.timezone) ||
!/^\d{2}:\d{2}$/.test(draft.dreamTime)
)
return undefined;
if (
[
requestTimeoutMs,
backgroundTimeoutMs,
shutdownTimeoutMs,
autoMemoryRetryMs,
autoMemoryRetryMaxMs,
autoMemoryInterval,
searchLimit,
].some((value) => value === undefined)
)
return undefined;
const [hour, minute] = draft.dreamTime.split(":");
return {
endpoint: draft.endpoint.trim().replace(/\/+$/, ""),
requestTimeoutMs: requestTimeoutMs!,
backgroundTimeoutMs: backgroundTimeoutMs!,
shutdownTimeoutMs: shutdownTimeoutMs!,
autoMemoryRetryMs: autoMemoryRetryMs!,
autoMemoryRetryMaxMs: autoMemoryRetryMaxMs!,
autoMemoryEnabled: draft.autoMemoryEnabled,
autoMemoryInterval: autoMemoryInterval!,
autoDreamEnabled: draft.autoDreamEnabled,
dreamCron: `${Number(minute)} ${Number(hour)} * * *`,
dreamHint: draft.dreamHint,
rootAgentsOnly: draft.rootAgentsOnly,
language: draft.language,
searchLimit: searchLimit!,
timezone: draft.timezone.trim(),
};
}
function boundedInteger(
text: string,
minimum: number,
maximum: number,
): number | undefined {
const value = Number(text);
return Number.isInteger(value) && value >= minimum && value <= maximum
? value
: undefined;
}
function validTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat("en", { timeZone: value.trim() }).format(0);
return value.trim().length > 0;
} catch {
return false;
}
}
function timeFromCron(cron: string): string {
const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(cron.trim());
if (match === null) return "";
return `${String(Number(match[2])).padStart(2, "0")}:${String(
Number(match[1]),
).padStart(2, "0")}`;
}
function baseSettings(base: unknown, fallback: ReMeSettings): ReMeSettings {
return isRecord(base) ? (base as unknown as ReMeSettings) : fallback;
}
function sameDraft(left: Draft, right: Draft): boolean {
return (Object.keys(left) as (keyof Draft)[]).every((key) =>
Object.is(left[key], right[key]),
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function installStyles(): () => void {
const tag = document.createElement("style");
tag.dataset.pluginCss = "@agentscope-ai/reme-dsh-plugin/settings";

View file

@ -0,0 +1,138 @@
import type { SettingsFieldSpec } from "@deepseek-ai/dsh-client-ui-primitives";
import { validEndpoint } from "../endpoint.js";
/**
* How each memory-settings field converts between its stored value and the text
* a control shows, in display and reset order.
*
* DSH's `SettingsFormModel` stages these drafts, reports which ones are invalid
* and writes them in one revision-fenced mutation; the specs below are the only
* per-field code this plugin owns.
*/
export const SETTING_FIELDS = [
"endpoint",
"requestTimeoutMs",
"backgroundTimeoutMs",
"shutdownTimeoutMs",
"autoMemoryRetryMs",
"autoMemoryRetryMaxMs",
"autoMemoryEnabled",
"autoMemoryInterval",
"autoDreamEnabled",
"dreamCron",
"dreamHint",
"rootAgentsOnly",
"language",
"searchLimit",
"timezone",
] as const;
export type SettingField = (typeof SETTING_FIELDS)[number];
/** A whole number inside the range the Host accepts; empty clears the field. */
function boundedInteger(
field: SettingField,
minimum: number,
maximum: number,
): SettingsFieldSpec {
return {
field,
format: (value) => (typeof value === "number" ? String(value) : ""),
parse: (text) => {
const trimmed = text.trim();
if (trimmed === "") return { kind: "clear" };
const value = Number(trimmed);
if (!Number.isInteger(value) || value < minimum || value > maximum)
return undefined;
return { kind: "set", value };
},
};
}
/** A checkbox: the control shows and writes "true" or "false". */
function flag(field: SettingField): SettingsFieldSpec {
return {
field,
format: (value) => (value === true ? "true" : "false"),
parse: (text) => ({ kind: "set", value: text === "true" }),
};
}
/** "HH:MM" in the control, a five-field cron expression in the document. */
function timeOfDay(field: SettingField): SettingsFieldSpec {
return {
field,
format: (value) => (typeof value === "string" ? timeFromCron(value) : ""),
parse: (text) => {
const match = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
if (match === null) return undefined;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return undefined;
return { kind: "set", value: `${minute} ${hour} * * *` };
},
};
}
/** "HH:MM" for a daily `m h * * *` schedule; anything else shows nothing. */
export function timeFromCron(cron: string): string {
const match = /^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+\*$/.exec(cron.trim());
if (match === null) return "";
return `${String(Number(match[2])).padStart(2, "0")}:${String(
Number(match[1]),
).padStart(2, "0")}`;
}
/** A timezone the browser's `Intl` accepts, which is what DSH validates with. */
export function validTimezone(value: string): boolean {
try {
new Intl.DateTimeFormat("en", { timeZone: value.trim() }).format(0);
return value.trim().length > 0;
} catch {
return false;
}
}
export const SETTINGS_SPECS: SettingsFieldSpec[] = [
{
// The endpoint loses its trailing slashes on the way in, the way the
// client appends job names to it.
field: "endpoint",
format: (value) => (typeof value === "string" ? value : ""),
parse: (text) => {
const trimmed = text.trim().replace(/\/+$/, "");
return validEndpoint(trimmed)
? { kind: "set", value: trimmed }
: undefined;
},
},
boundedInteger("requestTimeoutMs", 1000, 120000),
boundedInteger("backgroundTimeoutMs", 1000, 3600000),
boundedInteger("shutdownTimeoutMs", 100, 60000),
boundedInteger("autoMemoryRetryMs", 1000, 60000),
boundedInteger("autoMemoryRetryMaxMs", 1000, 3600000),
flag("autoMemoryEnabled"),
boundedInteger("autoMemoryInterval", 1, 1000),
flag("autoDreamEnabled"),
timeOfDay("dreamCron"),
{
field: "dreamHint",
format: (value) => (typeof value === "string" ? value : ""),
parse: (text) => ({ kind: "set", value: text }),
},
flag("rootAgentsOnly"),
{
field: "language",
format: (value) => (value === "en" ? "en" : "zh"),
parse: (text) =>
text === "en" || text === "zh" ? { kind: "set", value: text } : undefined,
},
boundedInteger("searchLimit", 1, 50),
{
field: "timezone",
format: (value) => (typeof value === "string" ? value : ""),
parse: (text) =>
validTimezone(text) ? { kind: "set", value: text.trim() } : undefined,
},
];

View file

@ -102,6 +102,14 @@ test("builds a lazy DSH browser module for the ReMe settings card", async () =>
assert.match(bundle, /Personal Knowledge Base/);
assert.match(statusPage, /个人知识库/);
assert.match(bundle, /health_check/);
// The settings card stages its drafts with DSH's own form model, shipped by
// the primitives package this bundle keeps external.
assert.match(
bundle,
/require\("@deepseek-ai\/dsh-client-ui-primitives"\)/,
"the client bundle resolves the primitives package at runtime",
);
assert.match(bundle, /SettingsFormModel/);
});
test("registers configuration on the ReMe bundle row", async () => {

View file

@ -1,12 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import test from "node:test";
import vm from "node:vm";
import React from "react";
import { act, create } from "react-test-renderer";
import { resolveConfig } from "../dist/config.js";
/**
* Evaluate the SettingsFormModel this plugin links against, straight out of the
* shipped primitives bundle.
*
* The package cannot be imported in Node (it loads CSS modules, icons and the
* syntax highlighter), but the form model region is plain JavaScript. Running
* that region means the card is exercised against DSH's real model instead of a
* re-implementation of it; a changed bundle layout fails here loudly.
*/
function loadSettingsFormModel() {
// Resolve the installed package rather than a fixed path: the packaged-plugin
// check installs it hoisted above this package, while the repository keeps it
// inside the package's own node_modules.
const manifest = createRequire(import.meta.url).resolve(
"@deepseek-ai/dsh-client-ui-primitives/package.json",
);
const source = readFileSync(
join(dirname(manifest), "lib", "index.js"),
"utf8",
);
const start = source.indexOf(
"//#region lib/types/settings-form/form-model.js",
);
const end = source.indexOf("//#endregion", start + 1);
assert.ok(
start >= 0 && end > start,
"unexpected @deepseek-ai/dsh-client-ui-primitives bundle layout",
);
return new Function(
`${source.slice(start, end)}\nreturn SettingsFormModel;`,
)();
}
async function editor(mutate) {
let module;
const bundle = await readFile(
@ -29,13 +64,18 @@ async function editor(mutate) {
});
const nodeRequire = createRequire(import.meta.url);
const client = module.factory((name) =>
name === "@deepseek-ai/dsh-client-ui-primitives" ? {} : nodeRequire(name),
name === "@deepseek-ai/dsh-client-ui-primitives"
? { SettingsFormModel: loadSettingsFormModel() }
: nodeRequire(name),
);
let Component;
let snapshot = {
status: "ready",
value: resolveConfig({}, {}),
base: resolveConfig({}, {}),
// The Host reports the user layer it stored; every field here is an
// override, which is what "reset to defaults" clears.
user: resolveConfig({}, {}),
revision: 7,
writable: true,
};

View file

@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SETTINGS_SPECS,
SETTING_FIELDS,
timeFromCron,
validTimezone,
} from "../dist/client/settings-specs.js";
const spec = (field) => {
const found = SETTINGS_SPECS.find((item) => item.field === field);
assert.ok(found, `missing spec for ${field}`);
return found;
};
/** The write a draft stages, or the string "invalid" when it is refused. */
const parse = (field, text) => {
const write = spec(field).parse(text);
return write === undefined ? "invalid" : write;
};
test("one spec per setting, in display order", () => {
assert.deepEqual(
SETTINGS_SPECS.map((item) => item.field),
[...SETTING_FIELDS],
);
assert.equal(SETTINGS_SPECS.length, 15);
});
test("numbers accept a bounded whole number and clear on empty", () => {
assert.deepEqual(parse("searchLimit", "8"), { kind: "set", value: 8 });
assert.deepEqual(parse("searchLimit", " 12 "), { kind: "set", value: 12 });
assert.deepEqual(parse("searchLimit", ""), { kind: "clear" });
// Out of range, not whole, or not a number blocks the save.
assert.equal(parse("searchLimit", "0"), "invalid");
assert.equal(parse("searchLimit", "51"), "invalid");
assert.equal(parse("searchLimit", "8.5"), "invalid");
assert.equal(parse("searchLimit", "eight"), "invalid");
assert.equal(parse("shutdownTimeoutMs", "99"), "invalid");
assert.equal(parse("requestTimeoutMs", "120001"), "invalid");
});
test("booleans round-trip through their control text", () => {
assert.deepEqual(parse("autoMemoryEnabled", "true"), {
kind: "set",
value: true,
});
assert.deepEqual(parse("rootAgentsOnly", "false"), {
kind: "set",
value: false,
});
assert.equal(spec("autoDreamEnabled").format(true), "true");
assert.equal(spec("autoDreamEnabled").format(false), "false");
});
test("the endpoint keeps its URL shape and rejects credentials", () => {
assert.deepEqual(parse("endpoint", " http://127.0.0.1:2333/ "), {
kind: "set",
value: "http://127.0.0.1:2333",
});
for (const bad of [
"not-a-url",
"http://alice:SECRET@localhost:2333",
"http://localhost:2333?token=SECRET",
"http://localhost:2333#search",
" ",
])
assert.equal(parse("endpoint", bad), "invalid", bad);
});
test("the daily time control writes a cron expression", () => {
assert.deepEqual(parse("dreamCron", "09:30"), {
kind: "set",
value: "30 9 * * *",
});
assert.deepEqual(parse("dreamCron", "0:05"), {
kind: "set",
value: "5 0 * * *",
});
assert.equal(spec("dreamCron").format("30 9 * * *"), "09:30");
assert.equal(spec("dreamCron").format("*/5 * * * *"), "");
assert.equal(parse("dreamCron", "24:00"), "invalid");
assert.equal(parse("dreamCron", "09:60"), "invalid");
assert.equal(parse("dreamCron", ""), "invalid");
assert.equal(timeFromCron("5 0 * * *"), "00:05");
});
test("language and timezone only accept what the Host accepts", () => {
assert.deepEqual(parse("language", "en"), { kind: "set", value: "en" });
assert.deepEqual(parse("language", "zh"), { kind: "set", value: "zh" });
assert.equal(parse("language", "fr"), "invalid");
assert.equal(spec("language").format(undefined), "zh");
assert.deepEqual(parse("timezone", " Asia/Shanghai "), {
kind: "set",
value: "Asia/Shanghai",
});
assert.equal(parse("timezone", "Mars/Olympus"), "invalid");
assert.equal(parse("timezone", ""), "invalid");
assert.equal(validTimezone("Europe/Berlin"), true);
});
test("the guidance text is taken verbatim", () => {
assert.deepEqual(parse("dreamHint", "summarise the week"), {
kind: "set",
value: "summarise the week",
});
assert.deepEqual(parse("dreamHint", ""), { kind: "set", value: "" });
});