Files
kevin-dsh-enhance/client.js
T
2026-09-05 00:27:41 +08:00

321 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Browser half of the EPSE regeneration guard — a settings card on the
* 插件配置 page. Plain-JS bundle (no build step): it registers through the
* client module loader and draws its own card, mirroring the stock Terminal /
* Agent loop / Web search cards so the layout and controls look identical.
*
* Registered into `settings.plugin.item` keyed by the guard's settings
* namespace; the configurable-plugins tab only dispatches a card when the Host
* also serves that namespace, which the settings registration in index.js
* provides (via the settings service's `installSection`, when mounted). The
* two inputs write straight to the durable settings document.
*/
window.__ModuleLoader__.load({
id: "@kevin/dsh-enhance",
factory: (require) => {
const React = require("react");
const { createElement: h, useState } = React;
const { IconChevronDownOutline14 } = require("@deepseek-ai/dsh-client-ui-primitives");
const NS = "kevin-dsh-enhance-guard";
const LOCALE_NS = "kevin-dsh-enhance-guard";
const cardCss = `
.epg_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
.epg_card:hover{border-color:var(--dsw-alias-label-dimmed)}
.epg_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
.epg_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
.epg_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
.epg_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
.epg_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
.epg_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
.epg_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}
.epg_chevronOpen{transform:rotate(180deg)}
.epg_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
.epg_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5}
.epg_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
.epg_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex}
.epg_failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5}
.epg_discard,.epg_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5}
.epg_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}
.epg_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}
.epg_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
.epg_discard:disabled,.epg_save:disabled{opacity:.4;cursor:default}
.epg_discard:focus-visible,.epg_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
`;
const fieldCss = `
.epg_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}
.epg_field+.epg_field{border-top:1px solid var(--dsw-alias-border-l2)}
.epg_head{align-items:center;gap:8px;display:flex}
.epg_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
.epg_badges{align-items:center;gap:8px;display:inline-flex}
.epg_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}
.epg_badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}
.epg_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}
.epg_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}
.epg_reset:disabled{cursor:default}
.epg_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}
.epg_input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}
.epg_input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}
.epg_inputInvalid{border-color:var(--dsw-alias-label-error)}
.epg_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}
.epg_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}
`;
const TAG = "@kevin/dsh-enhance/card.module.css";
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(TAG) + "]") === null) {
const tag = document.createElement("style");
tag.dataset.plugin = "@kevin/dsh-enhance";
tag.dataset.pluginCss = TAG;
tag.textContent = cardCss + fieldCss;
document.head.appendChild(tag);
}
const c = {
card: "epg_card", cardOpen: "epg_cardOpen", header: "epg_header",
headText: "epg_headText", name: "epg_name", description: "epg_description",
chevron: "epg_chevron", chevronOpen: "epg_chevronOpen", body: "epg_body",
readOnly: "epg_readOnly", pending: "epg_pending", footer: "epg_footer",
failed: "epg_failed", discard: "epg_discard", save: "epg_save",
field: "epg_field", head: "epg_head", label: "epg_label",
badges: "epg_badges", badge: "epg_badge", badgeMuted: "epg_badgeMuted",
reset: "epg_reset", input: "epg_input", inputInvalid: "epg_inputInvalid",
invalid: "epg_invalid", hint: "epg_hint",
};
const en = {
title: "EPSE leakage guard",
description: "Companion Plugin Settings.",
maxRegenerations: "Regenerations per step",
maxRegenerationsHint: "How many times one step may be forced to regenerate after a framed reply (min 1).",
targetProviders: "Target providers / models",
targetProvidersHint: "Comma-separated provider or model names. Leave blank to apply to every agent.",
unsaved: "Unsaved",
readOnly: "This deployment stores settings read-only.",
expand: "Show settings",
collapse: "Hide settings",
save: "Save",
saving: "Saving…",
discard: "Discard",
saveFailed: "The deployment did not accept these values; they were left for you to correct.",
overridden: "Overridden",
reset: "Reset to default",
invalidNumber: "Enter a whole number, or leave blank to use the default.",
};
const zh = {
title: "EPSE 泄露守护",
description: "DS2api 辅助插件设置。",
maxRegenerations: "每步重生成次数",
maxRegenerationsHint: "模型把工具调用框架写进文本时,同一 step 最多强制重生成多少次(最小 1)。",
targetProviders: "限定的 provider / model",
targetProvidersHint: "逗号分隔的 provider 或 model 名。留空表示对所有 agent 生效。",
unsaved: "未保存",
readOnly: "本部署的设置为只读。",
expand: "展开设置",
collapse: "收起设置",
save: "保存",
saving: "保存中…",
discard: "放弃修改",
saveFailed: "本部署没有接受这些值,已保留供你修改。",
overridden: "已覆盖",
reset: "恢复默认",
invalidNumber: "请填整数;留空表示使用默认值。",
};
const cls = (...parts) => parts.filter(Boolean).join(" ");
const formatMax = (value) => (value == null ? "" : String(value));
const formatProviders = (value) => (Array.isArray(value) ? value.join(", ") : "");
const parseMax = (text) => {
if (text.trim() === "") return { kind: "clear" };
const n = Number(text);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return undefined;
return { kind: "set", value: n };
};
const parseProviders = (text) => {
const parts = text.split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean);
return { kind: "set", value: parts };
};
class CardController {
constructor(scope) {
this.scope = scope;
this.staged = {};
this.saving = false;
this.failed = false;
this.listeners = new Set();
this.parsers = { maxRegenerationsPerTurn: parseMax, targetProviders: parseProviders };
this.state = this.compute();
this.unsubscribe = scope.subscribe(() => {
this.state = this.compute();
this.publish();
});
}
compute() {
const snap = this.scope.getSnapshot();
const available = snap.status === "ready";
const writable = snap.writable === true;
const user = snap.user && typeof snap.user === "object" ? snap.user : {};
const value = snap.value && typeof snap.value === "object" ? snap.value : {};
const staged = this.staged;
const maxText = Object.hasOwn(staged, "maxRegenerationsPerTurn") ? staged.maxRegenerationsPerTurn : formatMax(value.maxRegenerationsPerTurn);
const maxOverridden = Object.hasOwn(staged, "maxRegenerationsPerTurn") || Object.hasOwn(user, "maxRegenerationsPerTurn");
const maxInvalid = Object.hasOwn(staged, "maxRegenerationsPerTurn") ? parseMax(staged.maxRegenerationsPerTurn) === undefined : false;
const provText = Object.hasOwn(staged, "targetProviders") ? staged.targetProviders : formatProviders(value.targetProviders);
const provOverridden = Object.hasOwn(staged, "targetProviders") || Object.hasOwn(user, "targetProviders");
const provInvalid = false;
const dirty = Object.keys(staged).length > 0;
return {
available,
writable,
dirty,
invalid: maxInvalid || provInvalid,
saving: this.saving,
failed: this.failed,
maxRegenerationsPerTurn: { text: maxText, overridden: maxOverridden, invalid: maxInvalid },
targetProviders: { text: provText, overridden: provOverridden, invalid: provInvalid },
};
}
getSnapshot() { return this.state; }
subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
publish() { for (const listener of this.listeners) listener(); }
dispose() { this.unsubscribe(); this.listeners.clear(); }
edit(field, text) { this.staged[field] = text; this.state = this.compute(); this.publish(); }
resetField(field) { this.staged[field] = ""; this.state = this.compute(); this.publish(); }
discard() { this.staged = {}; this.state = this.compute(); this.publish(); }
async save() {
if (this.saving) return;
this.saving = true;
this.failed = false;
this.state = this.compute();
this.publish();
try {
for (const field of Object.keys(this.staged)) {
const result = this.parsers[field](this.staged[field]);
if (result === undefined) continue;
if (result.kind === "set") await this.scope.set(field, result.value);
else if (result.kind === "clear") await this.scope.unset(field);
}
this.staged = {};
this.saving = false;
this.failed = false;
this.state = this.compute();
this.publish();
} catch (error) {
this.saving = false;
this.failed = true;
this.state = this.compute();
this.publish();
}
}
inject() {
return {
hooks: { epseCard: this },
edit: (field, text) => this.edit(field, text),
resetField: (field) => this.resetField(field),
save: () => this.save(),
discard: () => this.discard(),
};
}
}
function ValueField(props) {
return h("div", { className: c.field }, [
h("div", { className: c.head }, [
h("label", { className: c.label, htmlFor: props.id }, props.label),
props.overridden ? h("span", { className: c.badges }, [
h("span", { className: c.badge }, props.overriddenLabel),
h("button", { type: "button", className: c.reset, disabled: props.disabled, onClick: props.onReset }, props.resetLabel),
]) : null,
]),
h("input", {
id: props.id,
className: props.invalid ? c.inputInvalid : c.input,
type: "text",
...(props.numeric === true ? { inputMode: "numeric" } : {}),
...(props.invalid ? { "aria-invalid": true } : {}),
value: props.text,
placeholder: props.placeholder ?? "",
disabled: props.disabled,
onChange: (event) => props.onEdit(event.target.value),
}),
h("p", { className: props.invalid ? c.invalid : c.hint }, props.invalid ? props.invalidLabel : props.hint),
]);
}
function EpseGuardCard(props) {
const [open, setOpen] = useState(false);
const state = props.useEpseCard((snapshot) => snapshot);
if (!state.available) return null;
const title = props.t("title");
const blocked = !state.dirty || state.invalid || state.saving;
return h("li", { className: cls(c.card, open && c.cardOpen) }, [
h("button", {
type: "button",
className: c.header,
"aria-expanded": open,
"aria-label": `${props.t(open ? "collapse" : "expand")}: ${title}`,
onClick: () => setOpen(!open),
}, [
h("span", { className: c.headText }, [
h("span", { className: c.name }, title),
h("span", { className: c.description }, props.t("description")),
]),
state.dirty ? h("span", { className: c.pending }, props.t("unsaved")) : null,
h(IconChevronDownOutline14, { className: cls(c.chevron, open && c.chevronOpen) }),
]),
open ? h("div", { className: c.body }, [
!state.writable ? h("p", { className: c.readOnly, role: "status" }, props.t("readOnly")) : null,
h(ValueField, {
id: "plugin-config-epse-max",
label: props.t("maxRegenerations"),
hint: props.t("maxRegenerationsHint"),
overriddenLabel: props.t("overridden"),
resetLabel: props.t("reset"),
invalidLabel: props.t("invalidNumber"),
numeric: true,
disabled: !state.writable,
...state.maxRegenerationsPerTurn,
onEdit: (text) => props.edit("maxRegenerationsPerTurn", text),
onReset: () => props.resetField("maxRegenerationsPerTurn"),
}),
h(ValueField, {
id: "plugin-config-epse-targets",
label: props.t("targetProviders"),
hint: props.t("targetProvidersHint"),
overriddenLabel: props.t("overridden"),
resetLabel: props.t("reset"),
invalidLabel: "",
numeric: false,
disabled: !state.writable,
...state.targetProviders,
onEdit: (text) => props.edit("targetProviders", text),
onReset: () => props.resetField("targetProviders"),
}),
h("div", { className: c.footer }, [
state.failed ? h("p", { className: c.failed, role: "status" }, props.t("saveFailed")) : null,
h("button", { type: "button", className: c.discard, disabled: !state.dirty || state.saving, onClick: props.discard }, props.t("discard")),
h("button", { type: "button", className: c.save, disabled: blocked, onClick: props.save }, props.t(state.saving ? "saving" : "save")),
]),
]) : null,
]);
}
const inject = ["slots", "settingsScope", "locale"];
function apply(ctx) {
ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), "kevin-dsh-enhance-guard: locale");
const scope = ctx.settingsScope.bind({ namespace: NS });
const controller = new CardController(scope);
ctx.effect(() => () => controller.dispose(), "kevin-dsh-enhance-guard: card controller");
ctx.effect(() => ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
name: "settings.plugin.item",
key: NS,
locale: LOCALE_NS,
inject: () => controller.inject(),
}, EpseGuardCard)), "kevin-dsh-enhance-guard: settings card");
}
return { apply, inject };
},
});