@mars.liu/dsh-cybernetics 0.7.8 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +210 -192
- package/README.zh-CN.md +213 -0
- package/cordis.patch.yml +8 -0
- package/docs/CHANGELOG.md +139 -0
- package/lib/client.js +332 -36
- package/package.json +2 -1
- package/src/controller.ts +49 -6
- package/src/index.ts +77 -11
- package/workflows/round.js +96 -19
package/lib/client.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// dsh-cybernetics client module: the「目标 / Goal」conversation tab, the
|
|
2
|
-
//「控制」tab, and (v0.7.2+, fixed in v0.7.3
|
|
3
|
-
//
|
|
4
|
-
// plugins.
|
|
5
|
-
//
|
|
2
|
+
//「控制」tab, and (v0.7.2+, fixed in v0.7.3, full volatile coverage in v0.8.1,
|
|
3
|
+
// triageAgentModel added in v0.8.2) the settings card seated on the official
|
|
4
|
+
// Plugins page (plugins.item / plugins.row.config / plugins.bundle.config,
|
|
5
|
+
// bound to the row's ConfigForm in either of the two shapes the seats deliver
|
|
6
|
+
// — volatile writes stay live without a restart). The card is the ONLY GUI
|
|
7
|
+
// surface for the plugin's seven volatile fields: the host Plugins page draws
|
|
8
|
+
// no fields of its own and dsh-client-ui-settings ships the configForms
|
|
9
|
+
// service, not a renderer.
|
|
6
10
|
// Rendered from the session's `goal` projection (dsh-goal), pushed live by the
|
|
7
11
|
// host — no plugin HTTP route needed. Hand-written ModuleLoader wrapper, same
|
|
8
12
|
// format the host bundler emits (see dsh-context / dsh-maze client.js).
|
|
@@ -82,6 +86,35 @@ window.__ModuleLoader__.load({
|
|
|
82
86
|
// surfaces the host's exact RemoteError message (the shared
|
|
83
87
|
// ConfigFormController swallows it into a bare `false`).
|
|
84
88
|
'settings.saveFailedDetail': '保存未生效,宿主拒绝原因:{message}',
|
|
89
|
+
// v0.8.1: the remaining five volatile fields. The card is the ONLY
|
|
90
|
+
// GUI surface for them (see the card region comment): the host has
|
|
91
|
+
// no generic schema-driven field renderer, so every field the card
|
|
92
|
+
// does not draw is invisible everywhere.
|
|
93
|
+
'settings.taskAgentModel': 'Task agent 模型',
|
|
94
|
+
'settings.verifyAgentModel': 'Verify agent 模型',
|
|
95
|
+
// v0.8.3: the three model fields are dropdown-backed (datalist over the
|
|
96
|
+
// host's live model catalog, remote.session.modelCatalog) with free
|
|
97
|
+
// manual entry preserved — the datalist input accepts any text.
|
|
98
|
+
'settings.modelFieldHint': '可从下拉选择当前系统模型(显示 provider · provider/model),也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。回车或移开焦点即保存。',
|
|
99
|
+
// v0.8.3: configured value no longer in the live catalog = deleted
|
|
100
|
+
// subscription. Warn + one-click clear; NEVER auto-unset (the value
|
|
101
|
+
// may be valid again after resubscribing, and silent rewrites of
|
|
102
|
+
// user config are a side effect) — the runtime already falls back to
|
|
103
|
+
// the session agent model per v0.8.0, so the warning is advisory.
|
|
104
|
+
'settings.modelUnavailable': '该模型已不在当前系统模型列表中,运行时将回退到 session agent 模型。',
|
|
105
|
+
'settings.modelClear': '清除',
|
|
106
|
+
// v0.8.2: the triage role's flat model field — triage reads the
|
|
107
|
+
// task/verify results and emits the round's convergence decision
|
|
108
|
+
// (met/continue/human-input/...); quick rounds never invoke it.
|
|
109
|
+
'settings.triageAgentModel': 'Triage agent 模型',
|
|
110
|
+
'settings.triageModelHint': '可从下拉选择当前系统模型,也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。triage 角色读 task/verify 结果输出本轮收敛决策(met/continue/human-input 等),quick 轮不启用。回车或移开焦点即保存。',
|
|
111
|
+
'settings.roundsPerSubgoal': '子目标轮数上限',
|
|
112
|
+
'settings.roundsPerSubgoalHint': '同一子目标的最大连续轮数(整数 ≥1,缺省 3);留空恢复默认。回车或移开焦点即保存。',
|
|
113
|
+
'settings.maxConcurrentRounds': '并行轮数上限',
|
|
114
|
+
'settings.maxConcurrentRoundsHint': '全局并行轮数上限(整数 ≥0,缺省 0 = 不限);留空恢复默认。回车或移开焦点即保存。',
|
|
115
|
+
// v0.8.4: roles 高级配置不再上卡片(手工编辑 JSON 对用户不友善)——
|
|
116
|
+
// 由 agent 指导用户直接编辑 profile 配置 yaml(见 README)。
|
|
117
|
+
'settings.invalidNumber': '请输入符合范围的整数,或留空恢复默认。',
|
|
85
118
|
};
|
|
86
119
|
const en = {
|
|
87
120
|
'view.goal': 'Goal',
|
|
@@ -133,6 +166,20 @@ window.__ModuleLoader__.load({
|
|
|
133
166
|
'settings.saving': 'Saving…',
|
|
134
167
|
'settings.saveFailed': 'Save did not take effect (the host refused the write).',
|
|
135
168
|
'settings.saveFailedDetail': 'Save did not take effect — host refusal: {message}',
|
|
169
|
+
// v0.8.1: the remaining five volatile fields (see zh for the rationale).
|
|
170
|
+
'settings.taskAgentModel': 'Task agent model',
|
|
171
|
+
'settings.verifyAgentModel': 'Verify agent model',
|
|
172
|
+
'settings.modelFieldHint': 'Pick from the dropdown of currently available system models (shown as provider · provider/model), or type "provider/model" or a bare model name; empty = inherit the session agent. Enter or blur saves.',
|
|
173
|
+
'settings.modelUnavailable': 'This model is no longer in the system model list; at runtime the round falls back to the session agent model.',
|
|
174
|
+
'settings.modelClear': 'Clear',
|
|
175
|
+
// v0.8.2: the triage role's flat model field (see zh for the rationale).
|
|
176
|
+
'settings.triageAgentModel': 'Triage agent model',
|
|
177
|
+
'settings.triageModelHint': 'Pick from the dropdown of available system models, or type "provider/model" or a bare model name; empty = inherit the session agent. The triage role reads the task/verify results and emits the round\'s convergence decision (met/continue/human-input/...); quick rounds never invoke it. Enter or blur saves.',
|
|
178
|
+
'settings.roundsPerSubgoal': 'Rounds per subgoal',
|
|
179
|
+
'settings.roundsPerSubgoalHint': 'Max consecutive rounds on one subgoal (integer ≥1, default 3); empty restores the default. Enter or blur saves.',
|
|
180
|
+
'settings.maxConcurrentRounds': 'Concurrent round cap',
|
|
181
|
+
'settings.maxConcurrentRoundsHint': 'Global cap on rounds in flight (integer ≥0, default 0 = unlimited); empty restores the default. Enter or blur saves.',
|
|
182
|
+
'settings.invalidNumber': 'Enter an integer in range, or leave blank to restore the default.',
|
|
136
183
|
};
|
|
137
184
|
//#endregion
|
|
138
185
|
|
|
@@ -446,7 +493,7 @@ window.__ModuleLoader__.load({
|
|
|
446
493
|
const noopSubscribe = () => () => {};
|
|
447
494
|
|
|
448
495
|
var settingsStyles = {
|
|
449
|
-
card: { display: 'flex', flexDirection: 'column', gap:
|
|
496
|
+
card: { display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 0' },
|
|
450
497
|
row: { display: 'flex', alignItems: 'center', gap: 12 },
|
|
451
498
|
main: { display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 },
|
|
452
499
|
title: { fontSize: 13, fontWeight: 600 },
|
|
@@ -465,15 +512,123 @@ window.__ModuleLoader__.load({
|
|
|
465
512
|
knobOn: { left: 20, background: '#fff' },
|
|
466
513
|
knobOff: { left: 2, background: 'currentColor' },
|
|
467
514
|
unavailable: { fontSize: 12, opacity: 0.6 },
|
|
515
|
+
// v0.8.1: editable volatile fields (text / number inputs).
|
|
516
|
+
field: { display: 'flex', flexDirection: 'column', gap: 4, paddingTop: 6, borderTop: '1px solid rgba(127,127,127,0.18)' },
|
|
517
|
+
input: {
|
|
518
|
+
font: 'inherit', fontSize: 12, color: 'inherit', maxWidth: 320,
|
|
519
|
+
background: 'transparent', border: '1px solid rgba(127,127,127,0.4)',
|
|
520
|
+
borderRadius: 4, padding: '3px 8px', outline: 'none',
|
|
521
|
+
},
|
|
522
|
+
inputInvalid: { borderColor: '#d29922' },
|
|
523
|
+
invalid: { fontSize: 11, color: '#d29922' },
|
|
468
524
|
};
|
|
469
525
|
|
|
526
|
+
// v0.8.1: the card renders ALL volatile fields, not just the
|
|
527
|
+
// allGoalsMode toggle (v0.8.2 adds triageAgentModel → seven). Verified
|
|
528
|
+
// against the running host's sources (2026-11-04): the Plugins page
|
|
529
|
+
// detail view (ItemDetail in @deepseek-ai/dsh-client-ui-plugin-manager
|
|
530
|
+
// lib/client.js) renders a plugin's configuration ONLY through its slot
|
|
531
|
+
// cards — the page itself draws no fields;
|
|
532
|
+
// @deepseek-ai/dsh-client-ui-settings ships the configForms SERVICE
|
|
533
|
+
// (transport + volatileForm serve/write gates) but no generic field
|
|
534
|
+
// renderer; dsh-client-ui-settings-plugins is a read-only inventory
|
|
535
|
+
// shell ("Configuring a built-in plugin happens on the sidebar's
|
|
536
|
+
// Plugins page, where each official plugin's own companion package
|
|
537
|
+
// registers its page"). Official plugins each draw their own card
|
|
538
|
+
// (agent-loop / web-search / …). So a volatile field this card does not
|
|
539
|
+
// render is editable NOWHERE in the GUI — that was the v0.7.9–v0.8.0
|
|
540
|
+
// panel gap. Field types follow the schema (src/index.ts Config):
|
|
541
|
+
// allGoalsMode boolean toggle; taskAgentModel / verifyAgentModel /
|
|
542
|
+
// triageAgentModel text ('' = inherit); roundsPerSubgoal /
|
|
543
|
+
// maxConcurrentRounds integers (≥1 / ≥0; blank restores the default via
|
|
544
|
+
// an `unset` op). v0.8.4: roles is no longer on the card — hand-editing
|
|
545
|
+
// a JSON object in the GUI is unfriendly; roles became non-volatile and
|
|
546
|
+
// is configured directly in the profile yaml (agent-guided, README).
|
|
547
|
+
// The card's field list is back to six:
|
|
548
|
+
// allGoalsMode / taskAgentModel / verifyAgentModel / triageAgentModel /
|
|
549
|
+
// roundsPerSubgoal / maxConcurrentRounds.
|
|
550
|
+
const SETTINGS_FIELDS = [
|
|
551
|
+
// v0.8.3: the three model fields are kind 'model' — datalist-backed
|
|
552
|
+
// dropdown over the live host catalog, manual entry still accepted
|
|
553
|
+
// (a datalist input is a free text input); invalid configured values
|
|
554
|
+
// render the unavailable warning + a one-click clear.
|
|
555
|
+
{ field: 'taskAgentModel', labelKey: 'settings.taskAgentModel', hintKey: 'settings.modelFieldHint', kind: 'model' },
|
|
556
|
+
{ field: 'verifyAgentModel', labelKey: 'settings.verifyAgentModel', hintKey: 'settings.modelFieldHint', kind: 'model' },
|
|
557
|
+
{ field: 'triageAgentModel', labelKey: 'settings.triageAgentModel', hintKey: 'settings.triageModelHint', kind: 'model' },
|
|
558
|
+
{ field: 'roundsPerSubgoal', labelKey: 'settings.roundsPerSubgoal', hintKey: 'settings.roundsPerSubgoalHint', kind: 'number', min: 1 },
|
|
559
|
+
{ field: 'maxConcurrentRounds', labelKey: 'settings.maxConcurrentRounds', hintKey: 'settings.maxConcurrentRoundsHint', kind: 'number', min: 0 },
|
|
560
|
+
];
|
|
561
|
+
/** Sentinel for "blank input = restore the schema default" (an unset op). */
|
|
562
|
+
const UNSET = {};
|
|
563
|
+
//#region v0.8.3 model catalog store (dropdown data + invalid-model detection)
|
|
564
|
+
// Host model directory, read the same way the official 子智能体 settings
|
|
565
|
+
// page does (@deepseek-ai/dsh-client-ui-settings-subagent): the
|
|
566
|
+
// `remote.session` namespace answers `modelCatalog()` with
|
|
567
|
+
// { ok, value: { groups: [{id, name, models: [{id, name}]}], failures } }
|
|
568
|
+
// — every currently routable model, grouped by provider. Provider
|
|
569
|
+
// invalidations arrive as `llm/adapters-updated` on ctx.remote. OPTIONAL
|
|
570
|
+
// everywhere: a host without the namespace leaves the store absent, the
|
|
571
|
+
// dropdown empty and the text entry intact — no crash, no warnings.
|
|
572
|
+
function createModelCatalogStore() {
|
|
573
|
+
let snap = { status: 'idle', options: [] };
|
|
574
|
+
const subs = new Set();
|
|
575
|
+
return {
|
|
576
|
+
subscribe(cb) { subs.add(cb); return () => subs.delete(cb); },
|
|
577
|
+
getSnapshot() { return snap; },
|
|
578
|
+
set(next) { snap = next; for (const cb of subs) cb(); },
|
|
579
|
+
load: async (session) => {
|
|
580
|
+
snap = { status: 'loading', options: snap.options };
|
|
581
|
+
for (const cb of subs) cb();
|
|
582
|
+
try {
|
|
583
|
+
const response = await session.modelCatalog();
|
|
584
|
+
if (response && response.ok === true && response.value && Array.isArray(response.value.groups)) {
|
|
585
|
+
const options = [];
|
|
586
|
+
for (const group of response.value.groups) {
|
|
587
|
+
for (const model of (group.models ?? [])) {
|
|
588
|
+
options.push({
|
|
589
|
+
value: group.id + '/' + model.id,
|
|
590
|
+
provider: group.id,
|
|
591
|
+
model: model.id,
|
|
592
|
+
label: (group.name ?? group.id) + ' · ' + group.id + '/' + model.id,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
snap = { status: 'ready', options };
|
|
597
|
+
} else {
|
|
598
|
+
snap = { status: 'error', options: [] };
|
|
599
|
+
}
|
|
600
|
+
} catch {
|
|
601
|
+
snap = { status: 'error', options: [] };
|
|
602
|
+
}
|
|
603
|
+
for (const cb of subs) cb();
|
|
604
|
+
},
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Whether a configured model value still resolves against the live
|
|
609
|
+
* catalog. Accepts the same shapes the runtime does (parseAgentModelSpec):
|
|
610
|
+
* `provider/model` (exact option match) or a bare model name (matches any
|
|
611
|
+
* provider's model id). An empty value or an absent/failed catalog never
|
|
612
|
+
* flags — the warning must be provable, not a guess.
|
|
613
|
+
*/
|
|
614
|
+
function isConfiguredModelAvailable(value, catalogSnap) {
|
|
615
|
+
const text = (value ?? '').trim();
|
|
616
|
+
if (text === '') return true;
|
|
617
|
+
if (!catalogSnap || catalogSnap.status !== 'ready' || !Array.isArray(catalogSnap.options) || catalogSnap.options.length === 0) return true;
|
|
618
|
+
if (text.indexOf('/') >= 0) {
|
|
619
|
+
return catalogSnap.options.some((opt) => opt.value === text);
|
|
620
|
+
}
|
|
621
|
+
return catalogSnap.options.some((opt) => opt.model === text);
|
|
622
|
+
}
|
|
623
|
+
//#endregion
|
|
624
|
+
|
|
470
625
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
626
|
+
* The settings card; shared by all three seats. Two form shapes reach
|
|
627
|
+
* it, and the card must speak both:
|
|
473
628
|
* - the LIVE ConfigForm this module injects (ctx.configForms.get):
|
|
474
|
-
* getSnapshot / subscribe / set — the only shape on
|
|
475
|
-
* plugins.bundle.config page and in summary rows, whose owners
|
|
476
|
-
* no form of their own;
|
|
629
|
+
* getSnapshot / subscribe / set / unset / mutate — the only shape on
|
|
630
|
+
* the plugins.bundle.config page and in summary rows, whose owners
|
|
631
|
+
* pass no form of their own;
|
|
477
632
|
* - the HOST's ConfigPageForm { state, mutate }: the plugins.item and
|
|
478
633
|
* plugins.row.config detail pages compute it from the same shared
|
|
479
634
|
* mirror (configForms.get(id).getSnapshot()/mutate) and pass it as
|
|
@@ -516,44 +671,71 @@ window.__ModuleLoader__.load({
|
|
|
516
671
|
// `false` = no failure; `true` = failure without a host message;
|
|
517
672
|
// a string = the host's exact refusal (RemoteError.message).
|
|
518
673
|
const [failed, setFailed] = react.useState(false);
|
|
674
|
+
// v0.8.1: per-field edit drafts (field → text; absent = untouched,
|
|
675
|
+
// the control shows the snapshot value) and the field whose current
|
|
676
|
+
// draft failed local validation ('' = none). A failed SAVE keeps
|
|
677
|
+
// the draft for correction; an accepted write clears it.
|
|
678
|
+
const [drafts, setDrafts] = react.useState({});
|
|
679
|
+
const [invalid, setInvalid] = react.useState('');
|
|
680
|
+
// v0.8.3: the live model catalog face (optional prop; absent on hosts
|
|
681
|
+
// without remote.session — the dropdown then renders empty and no
|
|
682
|
+
// availability warning is ever shown).
|
|
683
|
+
const catalog = props.catalog;
|
|
684
|
+
const catalogSnap = react.useSyncExternalStore(
|
|
685
|
+
catalog ? (cb) => catalog.subscribe(cb) : noopSubscribe,
|
|
686
|
+
catalog ? () => catalog.getSnapshot() : () => NO_FORM_SNAP,
|
|
687
|
+
);
|
|
688
|
+
const catalogOptions = catalogSnap && Array.isArray(catalogSnap.options) ? catalogSnap.options : [];
|
|
519
689
|
const on = snap.status === 'ready' && snap.value != null && snap.value.allGoalsMode === true;
|
|
690
|
+
const cfg = snap.status === 'ready' && snap.value !== null && typeof snap.value === 'object' ? snap.value : {};
|
|
520
691
|
const disabled = (live === undefined && host === undefined) || snap.status !== 'ready' || snap.writable === false || saving;
|
|
692
|
+
const setDraft = (field, text) => setDrafts((d) => ({ ...d, [field]: text }));
|
|
693
|
+
const clearDraft = (field) => setDrafts((d) => {
|
|
694
|
+
if (!(field in d)) return d;
|
|
695
|
+
const next = { ...d };
|
|
696
|
+
delete next[field];
|
|
697
|
+
return next;
|
|
698
|
+
});
|
|
521
699
|
|
|
522
|
-
|
|
700
|
+
// One write for any volatile field; the toggle is
|
|
701
|
+
// writeField('allGoalsMode', !on). Both write paths land in the same
|
|
702
|
+
// host mutation: ConfigForm.set/unset are one-field sugar over
|
|
703
|
+
// ConfigForm.mutate, and the host's ConfigPageForm.mutate IS that
|
|
704
|
+
// form's mutate (the expected revision carried explicitly). The
|
|
705
|
+
// host commits the write into the running plugin instance
|
|
706
|
+
// (volatile) and the next snapshot / page render pushes the
|
|
707
|
+
// accepted value back here.
|
|
708
|
+
// Failure policy (v0.7.4): the transport can REFUSE (settles
|
|
709
|
+
// false) or stall (the observed "保存中" hang). Neither is
|
|
710
|
+
// silent: a refused write shows saveFailed for a few seconds,
|
|
711
|
+
// and a stalled write releases the controls after 15 s with the
|
|
712
|
+
// same hint — every control always returns to the snapshot value,
|
|
713
|
+
// which is the only state the host confirmed.
|
|
714
|
+
// v0.7.6: a refused write is retried ONCE through the direct
|
|
715
|
+
// settings transport (props.directMutate, no expectedRevision).
|
|
716
|
+
// Same落点 (user patch row via configEditor), unconditional
|
|
717
|
+
// revision — a stale-revision conflict therefore heals, and any
|
|
718
|
+
// remaining refusal reports its host message verbatim.
|
|
719
|
+
const writeField = (field, value) => {
|
|
523
720
|
if (disabled) return;
|
|
524
721
|
setSaving(true);
|
|
525
722
|
setFailed(false);
|
|
526
|
-
|
|
527
|
-
// is one-field sugar over ConfigForm.mutate, and the host's
|
|
528
|
-
// ConfigPageForm.mutate IS that form's mutate (the expected
|
|
529
|
-
// revision carried explicitly). The host commits the write into
|
|
530
|
-
// the running plugin instance (volatile) and the next snapshot /
|
|
531
|
-
// page render pushes the accepted value back here.
|
|
532
|
-
// Failure policy (v0.7.4): the transport can REFUSE (settles
|
|
533
|
-
// false) or stall (the observed "保存中" hang). Neither is
|
|
534
|
-
// silent: a refused write shows saveFailed for a few seconds,
|
|
535
|
-
// and a stalled write releases the switch after 15 s with the
|
|
536
|
-
// same hint — the switch always returns to the snapshot value,
|
|
537
|
-
// which is the only state the host confirmed.
|
|
538
|
-
// v0.7.6: a refused write is retried ONCE through the direct
|
|
539
|
-
// settings transport (props.directMutate, no expectedRevision).
|
|
540
|
-
// Same落点 (user patch row via configEditor), unconditional
|
|
541
|
-
// revision — a stale-revision conflict therefore heals, and any
|
|
542
|
-
// remaining refusal reports its host message verbatim.
|
|
723
|
+
setInvalid('');
|
|
543
724
|
let settled = false;
|
|
544
725
|
const finish = (ok, detail) => {
|
|
545
726
|
if (settled) return;
|
|
546
727
|
settled = true;
|
|
547
728
|
setSaving(false);
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
729
|
+
if (ok) { clearDraft(field); return; }
|
|
730
|
+
setFailed(detail || true);
|
|
731
|
+
setTimeout(() => setFailed(false), 6000);
|
|
552
732
|
};
|
|
553
733
|
const guard = setTimeout(() => finish(false), 15000);
|
|
554
|
-
const op =
|
|
734
|
+
const op = value === UNSET
|
|
735
|
+
? { op: 'unset', path: [field] }
|
|
736
|
+
: { op: 'set', path: [field], value };
|
|
555
737
|
const write = live !== undefined
|
|
556
|
-
? live.set(
|
|
738
|
+
? (value === UNSET ? live.unset(field) : live.set(field, value))
|
|
557
739
|
: host.mutate([op], snap.revision);
|
|
558
740
|
Promise.resolve(write)
|
|
559
741
|
.then(async (accepted) => {
|
|
@@ -572,11 +754,98 @@ window.__ModuleLoader__.load({
|
|
|
572
754
|
})
|
|
573
755
|
.catch((error) => { clearTimeout(guard); finish(false, error && error.message); });
|
|
574
756
|
};
|
|
757
|
+
const toggle = () => writeField('allGoalsMode', !on);
|
|
758
|
+
|
|
759
|
+
// Text/number commit (Enter or blur): an untouched field never
|
|
760
|
+
// writes; a draft equal to the snapshot just clears; a blank draft
|
|
761
|
+
// restores the schema default through an `unset` op; a number
|
|
762
|
+
// outside its range flags the field instead of writing.
|
|
763
|
+
const commitField = (spec) => {
|
|
764
|
+
const draft = drafts[spec.field];
|
|
765
|
+
if (draft === undefined) return;
|
|
766
|
+
const current = cfg[spec.field];
|
|
767
|
+
const text = draft.trim();
|
|
768
|
+
if (text === (current === undefined || current === null ? '' : String(current))) {
|
|
769
|
+
clearDraft(spec.field);
|
|
770
|
+
setInvalid('');
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (text === '') { writeField(spec.field, UNSET); return; }
|
|
774
|
+
if (spec.kind === 'number') {
|
|
775
|
+
const n = Number(text);
|
|
776
|
+
if (!Number.isInteger(n) || n < spec.min) { setInvalid(spec.field); return; }
|
|
777
|
+
writeField(spec.field, n);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
writeField(spec.field, text);
|
|
781
|
+
};
|
|
575
782
|
|
|
576
783
|
if (view === 'summary') {
|
|
577
784
|
return jsx.jsx('span', { children: snap.status === 'ready' ? t(on ? 'settings.summaryOn' : 'settings.summaryOff') : t('settings.unavailable') });
|
|
578
785
|
}
|
|
579
786
|
|
|
787
|
+
const fieldRow = (spec) => {
|
|
788
|
+
// v0.8.3: availability verdict for the configured (snapshot) value —
|
|
789
|
+
// drafts are uncommitted text and never warn. Only a loaded catalog
|
|
790
|
+
// can flag; an empty/failed catalog keeps every value "available".
|
|
791
|
+
const configuredValue = cfg[spec.field] === undefined || cfg[spec.field] === null ? '' : String(cfg[spec.field]);
|
|
792
|
+
const modelUnavailable = spec.kind === 'model'
|
|
793
|
+
&& !isConfiguredModelAvailable(configuredValue, catalogSnap);
|
|
794
|
+
const listId = 'cybernetics-models-' + spec.field;
|
|
795
|
+
return jsx.jsxs('div', { style: settingsStyles.field, children: [
|
|
796
|
+
jsx.jsx('div', { style: settingsStyles.title, children: t(spec.labelKey) }),
|
|
797
|
+
jsx.jsx('div', { style: settingsStyles.desc, children: t(spec.hintKey) }),
|
|
798
|
+
snap.status === 'ready'
|
|
799
|
+
? jsx.jsxs('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 }, children: [
|
|
800
|
+
jsx.jsx('input', {
|
|
801
|
+
type: 'text',
|
|
802
|
+
// datalist: native dropdown over the catalog options; the
|
|
803
|
+
// input stays a free text field, so manual entry is the
|
|
804
|
+
// built-in fallback (and values off-catalog still commit).
|
|
805
|
+
list: spec.kind === 'model' && catalogOptions.length > 0 ? listId : undefined,
|
|
806
|
+
inputMode: spec.kind === 'number' ? 'numeric' : undefined,
|
|
807
|
+
'aria-label': t(spec.labelKey),
|
|
808
|
+
'aria-invalid': invalid === spec.field || undefined,
|
|
809
|
+
disabled,
|
|
810
|
+
value: drafts[spec.field] !== undefined
|
|
811
|
+
? drafts[spec.field]
|
|
812
|
+
: configuredValue,
|
|
813
|
+
onChange: (e) => setDraft(spec.field, e && e.target ? String(e.target.value) : ''),
|
|
814
|
+
onKeyDown: (e) => {
|
|
815
|
+
if (!e) return;
|
|
816
|
+
if (e.key === 'Enter') commitField(spec);
|
|
817
|
+
if (e.key === 'Escape') { clearDraft(spec.field); setInvalid(''); }
|
|
818
|
+
},
|
|
819
|
+
onBlur: () => commitField(spec),
|
|
820
|
+
style: { ...settingsStyles.input, ...(invalid === spec.field ? settingsStyles.inputInvalid : {}) },
|
|
821
|
+
}),
|
|
822
|
+
spec.kind === 'model' && catalogOptions.length > 0
|
|
823
|
+
? jsx.jsx('datalist', { id: listId, children: catalogOptions.map((opt) =>
|
|
824
|
+
jsx.jsx('option', { value: opt.value, label: opt.label ?? opt.value }, opt.value)) })
|
|
825
|
+
: null,
|
|
826
|
+
] })
|
|
827
|
+
: null,
|
|
828
|
+
modelUnavailable
|
|
829
|
+
? jsx.jsxs('div', { style: { ...settingsStyles.invalid, color: '#d29922', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }, children: [
|
|
830
|
+
jsx.jsx('span', { children: t('settings.modelUnavailable') }),
|
|
831
|
+
// One-click clear = an explicit user action writing an unset
|
|
832
|
+
// op (same writeField path as blanking the field) — never a
|
|
833
|
+
// silent auto-unset of stored config (recorded trade-off in
|
|
834
|
+
// docs/CHANGELOG.md v0.8.3).
|
|
835
|
+
jsx.jsx('button', {
|
|
836
|
+
type: 'button',
|
|
837
|
+
'aria-label': t('settings.modelClear') + ' ' + t(spec.labelKey),
|
|
838
|
+
disabled,
|
|
839
|
+
onClick: () => writeField(spec.field, UNSET),
|
|
840
|
+
style: { ...settingsStyles.apply, alignSelf: 'auto' },
|
|
841
|
+
children: t('settings.modelClear'),
|
|
842
|
+
}),
|
|
843
|
+
] })
|
|
844
|
+
: null,
|
|
845
|
+
invalid === spec.field ? jsx.jsx('div', { style: settingsStyles.invalid, children: t('settings.invalidNumber') }) : null,
|
|
846
|
+
] }, spec.field);
|
|
847
|
+
};
|
|
848
|
+
|
|
580
849
|
return jsx.jsxs('div', { style: settingsStyles.card, children: [
|
|
581
850
|
jsx.jsxs('div', { style: settingsStyles.row, children: [
|
|
582
851
|
jsx.jsxs('div', { style: settingsStyles.main, children: [
|
|
@@ -592,6 +861,7 @@ window.__ModuleLoader__.load({
|
|
|
592
861
|
})
|
|
593
862
|
: null,
|
|
594
863
|
] }),
|
|
864
|
+
...SETTINGS_FIELDS.map(fieldRow),
|
|
595
865
|
snap.status !== 'ready' ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.unavailable') }) : null,
|
|
596
866
|
saving ? jsx.jsx('div', { style: settingsStyles.unavailable, children: t('settings.saving') }) : null,
|
|
597
867
|
!saving && failed ? jsx.jsx('div', { style: { ...settingsStyles.unavailable, color: '#d29922' }, children: failed === true ? t('settings.saveFailed') : t('settings.saveFailedDetail', { message: failed }) }) : null,
|
|
@@ -612,7 +882,12 @@ window.__ModuleLoader__.load({
|
|
|
612
882
|
// fallback. Optional at runtime (guarded in apply): a host without
|
|
613
883
|
// the namespace parks this client module exactly as it would
|
|
614
884
|
// ui-settings itself — the web profile always serves it.
|
|
615
|
-
'remote', 'remote.settings'
|
|
885
|
+
'remote', 'remote.settings',
|
|
886
|
+
// v0.8.3: the settings card's model dropdown reads the host model
|
|
887
|
+
// catalog through the session namespace (same service the official
|
|
888
|
+
// ui-settings-subagent page declares). Guarded at runtime: absent →
|
|
889
|
+
// no dropdown, no warnings, text entry as before.
|
|
890
|
+
'remote.session'];
|
|
616
891
|
|
|
617
892
|
function apply(ctx) {
|
|
618
893
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-cybernetics: dictionaries');
|
|
@@ -641,7 +916,28 @@ window.__ModuleLoader__.load({
|
|
|
641
916
|
} catch {
|
|
642
917
|
settingsDirectMutate = undefined;
|
|
643
918
|
}
|
|
644
|
-
|
|
919
|
+
// v0.8.3: the live model catalog for the settings card's dropdown +
|
|
920
|
+
// invalid-model warnings. OPTIONAL: a host without the remote.session
|
|
921
|
+
// namespace (or without modelCatalog) leaves it undefined — the card
|
|
922
|
+
// renders text-only fields exactly as pre-v0.8.3, no crash, no
|
|
923
|
+
// false warnings. Refresh on `llm/adapters-updated` (the same
|
|
924
|
+
// invalidation the 子智能体 settings page listens to), so a model
|
|
925
|
+
// deleted/added in the model settings updates the dropdown live.
|
|
926
|
+
let catalogStore;
|
|
927
|
+
try {
|
|
928
|
+
if (ctx.remote && ctx.remote.session && typeof ctx.remote.session.modelCatalog === 'function') {
|
|
929
|
+
catalogStore = createModelCatalogStore();
|
|
930
|
+
ctx.effect(() => {
|
|
931
|
+
catalogStore.load(ctx.remote.session);
|
|
932
|
+
if (ctx.remote.$on && typeof ctx.remote.$on === 'function') {
|
|
933
|
+
ctx.remote.$on('llm/adapters-updated', () => { catalogStore.load(ctx.remote.session); });
|
|
934
|
+
}
|
|
935
|
+
}, 'dsh-cybernetics: model catalog');
|
|
936
|
+
}
|
|
937
|
+
} catch {
|
|
938
|
+
catalogStore = undefined;
|
|
939
|
+
}
|
|
940
|
+
const settingsInject = () => ({ form: settingsForm, directMutate: settingsDirectMutate, catalog: catalogStore });
|
|
645
941
|
// v0.7.2 (the actual gap): the official Plugins page renders the
|
|
646
942
|
// 内置插件配置 group cards ONLY from the `plugins.item` slot
|
|
647
943
|
// (ItemCard in @deepseek-ai/dsh-client-ui-plugin-manager client.js:
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -15,6 +15,31 @@ import type { WorkflowEngine } from '@deepseek-ai/dsh-workflow'
|
|
|
15
15
|
/** Directory holding per-round audit logs, relative to the workspace root. */
|
|
16
16
|
export const STATE_DIR = '.cybernetics'
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* One call-level model fallback within a round (v0.8): a role's configured
|
|
20
|
+
* model override failed to be called (availability failure) and the role's
|
|
21
|
+
* sub-agent was re-run on the session agent's model (no provider/model opts).
|
|
22
|
+
*/
|
|
23
|
+
export interface RoleFallback {
|
|
24
|
+
/** Role whose override was bypassed ('default'/named task role, 'verify', 'triage'). */
|
|
25
|
+
role: string
|
|
26
|
+
/** The configured override that failed. */
|
|
27
|
+
from: { provider?: string; model?: string }
|
|
28
|
+
/**
|
|
29
|
+
* Signal layer (workflow runtime agent() hook, @deepseek-ai/dsh-workflow-ptc):
|
|
30
|
+
* 'availability-error' = the hook THREW a WorkflowError whose code/message
|
|
31
|
+
* matches an availability failure (AGENT_START always; AGENT_RESULT or
|
|
32
|
+
* uncoded errors on quota/rate-limit/auth/5xx/network/provider patterns);
|
|
33
|
+
* 'null-result' = the hook returned null (the runtime flattens a child run
|
|
34
|
+
* that ends stopReason ≠ 'completed' — e.g. a mid-run LLM call failure via
|
|
35
|
+
* dsh-subagent agent/error — into null without the cause), retried only
|
|
36
|
+
* because an override was in effect.
|
|
37
|
+
*/
|
|
38
|
+
trigger: 'availability-error' | 'null-result'
|
|
39
|
+
/** Human-readable reason (first line of the runtime error, or the null semantics). */
|
|
40
|
+
reason: string
|
|
41
|
+
}
|
|
42
|
+
|
|
18
43
|
/** One executed round, as returned by the workflow run. */
|
|
19
44
|
export interface RoundResult {
|
|
20
45
|
round: number
|
|
@@ -22,6 +47,8 @@ export interface RoundResult {
|
|
|
22
47
|
verdict?: unknown
|
|
23
48
|
triage?: unknown
|
|
24
49
|
failed?: string
|
|
50
|
+
/** Call-level model fallbacks that occurred in this round (v0.8 audit). */
|
|
51
|
+
fallbacks?: RoleFallback[]
|
|
25
52
|
/** Execution substrate used for this round (v0.6 audit). */
|
|
26
53
|
executor?: 'ephemeral' | 'persistent'
|
|
27
54
|
}
|
|
@@ -354,26 +381,42 @@ export async function runRound(
|
|
|
354
381
|
}
|
|
355
382
|
}
|
|
356
383
|
|
|
384
|
+
/**
|
|
385
|
+
* v0.8 visibility: render the round's call-level model fallbacks as a decision
|
|
386
|
+
* note suffix, so the tool result names every fallback (role, failed override,
|
|
387
|
+
* trigger) instead of only living in the audit log.
|
|
388
|
+
*/
|
|
389
|
+
function fallbackSuffix(result: RoundResult): string {
|
|
390
|
+
const fallbacks = result.fallbacks ?? []
|
|
391
|
+
if (fallbacks.length === 0) return ''
|
|
392
|
+
const items = fallbacks.map((f) => {
|
|
393
|
+
const from = [f.from.provider, f.from.model].filter(Boolean).join('/') || '(inherit)'
|
|
394
|
+
return `${f.role}(${from}→session 模型,${f.trigger === 'null-result' ? '无产出' : '调用失败'})`
|
|
395
|
+
})
|
|
396
|
+
return `;⚠ 模型回退 ${fallbacks.length} 次:${items.join(',')}`
|
|
397
|
+
}
|
|
398
|
+
|
|
357
399
|
/** Fold one round result into a continue/pause decision. Pure. */
|
|
358
400
|
function decide(result: RoundResult, state: LoopState, config: ControllerConfig): ControllerDecision {
|
|
401
|
+
const fb = fallbackSuffix(result)
|
|
359
402
|
if (result.failed) {
|
|
360
|
-
return { action: 'pause', reason: 'failed', note: `第 ${result.round} 轮失败:${result.failed}` }
|
|
403
|
+
return { action: 'pause', reason: 'failed', note: `第 ${result.round} 轮失败:${result.failed}${fb}` }
|
|
361
404
|
}
|
|
362
405
|
const verdict = result.verdict as { met?: boolean; goalItselfQuestionable?: boolean; gaps?: string[] } | undefined
|
|
363
406
|
if (verdict && verdict.goalItselfQuestionable) {
|
|
364
|
-
return { action: 'pause', reason: 'goal-questionable', note:
|
|
407
|
+
return { action: 'pause', reason: 'goal-questionable', note: `verify 判定目标本身可能有问题,需通过对话重新校准${fb}` }
|
|
365
408
|
}
|
|
366
409
|
if (verdict?.met) {
|
|
367
|
-
return { action: 'pause', reason: 'met', note:
|
|
410
|
+
return { action: 'pause', reason: 'met', note: `验收全部通过;等待用户确认完成${fb}` }
|
|
368
411
|
}
|
|
369
412
|
const triage = result.triage as { human?: { question?: string; step: string }[]; auto?: { step: string }[] } | undefined
|
|
370
413
|
const human = triage?.human ?? []
|
|
371
414
|
if (human.length > 0) {
|
|
372
|
-
return { action: 'pause', reason: 'human-input', note: `待用户裁定:${human.map((h) => h.question ?? h.step).join(';')}` }
|
|
415
|
+
return { action: 'pause', reason: 'human-input', note: `待用户裁定:${human.map((h) => h.question ?? h.step).join(';')}${fb}` }
|
|
373
416
|
}
|
|
374
417
|
const cap = config.roundsPerSubgoal ?? 3
|
|
375
418
|
if (state.round >= cap) {
|
|
376
|
-
return { action: 'pause', reason: 'round-cap', note: `同一子目标已迭代 ${state.round}
|
|
419
|
+
return { action: 'pause', reason: 'round-cap', note: `同一子目标已迭代 ${state.round} 轮仍未达成,升级用户${fb}` }
|
|
377
420
|
}
|
|
378
421
|
const gaps = verdict?.gaps ?? []
|
|
379
422
|
const auto = triage?.auto ?? []
|
|
@@ -382,5 +425,5 @@ function decide(result: RoundResult, state: LoopState, config: ControllerConfig)
|
|
|
382
425
|
: auto.length > 0
|
|
383
426
|
? auto.map((s) => s.step).join(';')
|
|
384
427
|
: state.subgoal
|
|
385
|
-
return { action: 'continue', subgoal: nextSubgoal, note: `进入第 ${state.round + 1}
|
|
428
|
+
return { action: 'continue', subgoal: nextSubgoal, note: `进入第 ${state.round + 1} 轮${fb}` }
|
|
386
429
|
}
|