deepseek-harness-intelligent-ui 0.2.2 → 0.2.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/CHANGELOG.md +43 -1
- package/README.md +325 -110
- package/README.zh-CN.md +313 -100
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/client/client.js +17 -17
- package/package.json +15 -7
- package/renderer/renderer.js +500 -0
- package/src/catalog/patch.js +283 -0
- package/src/compat.js +1 -1
- package/src/guidance.js +6 -1
- package/src/plugin.js +6 -2
- package/src/portable/artifacts.js +87 -0
- package/src/tools/patch.js +139 -0
- package/src/tools/render.js +4 -2
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental updates of an existing `ui_render` UI (`ui_patch`).
|
|
3
|
+
*
|
|
4
|
+
* A UI is identified by `uiId` (the call id of the `ui_render` that created
|
|
5
|
+
* it) and versioned: the render is version 1 and every accepted patch adds
|
|
6
|
+
* one. Components keep their ids across versions, so the browser keeps the
|
|
7
|
+
* interaction state (sort, tab, form values, slider positions) of every
|
|
8
|
+
* component a patch did not replace.
|
|
9
|
+
*
|
|
10
|
+
* Nothing is stored on the Host: {@link collectUis} replays this session's own
|
|
11
|
+
* history (the messages the model sees), which makes the outcome deterministic
|
|
12
|
+
* across restarts and confines patches to UIs of the same conversation.
|
|
13
|
+
* @module deepseek-harness-intelligent-ui/catalog/patch
|
|
14
|
+
*/
|
|
15
|
+
import { LIMITS, validateTree } from './catalog.js';
|
|
16
|
+
|
|
17
|
+
export const PATCH_OPS = Object.freeze(['setProps', 'replaceNode', 'insertChild', 'removeNode']);
|
|
18
|
+
|
|
19
|
+
/** Upper bounds for one patch call. */
|
|
20
|
+
export const PATCH_LIMITS = Object.freeze({ ops: 50, nodesPerOp: LIMITS.components });
|
|
21
|
+
|
|
22
|
+
/** Allowed fields per op (besides `op`). */
|
|
23
|
+
const OP_FIELDS = Object.freeze({
|
|
24
|
+
setProps: ['id', 'props', 'unset'],
|
|
25
|
+
replaceNode: ['id', 'node', 'nodes'],
|
|
26
|
+
insertChild: ['parent', 'id', 'index', 'nodes'],
|
|
27
|
+
removeNode: ['id'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {{ id: string, type: string, props?: Record<string, unknown>, children?: string[] }} UiNode
|
|
32
|
+
* @typedef {{ uiId: string, version: number, title?: string, root: string, components: UiNode[] }} UiSnapshot
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
function isPlainObject(v) {
|
|
36
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const clone = v => JSON.parse(JSON.stringify(v));
|
|
40
|
+
|
|
41
|
+
/** Same leniency as validateTree: `props.children` becomes the node's children. */
|
|
42
|
+
function normalize(node) {
|
|
43
|
+
if (isPlainObject(node.props) && 'children' in node.props) {
|
|
44
|
+
if (node.children === undefined && Array.isArray(node.props.children)) node.children = node.props.children;
|
|
45
|
+
delete node.props.children;
|
|
46
|
+
}
|
|
47
|
+
return node;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize a validated `ui_render` payload into a snapshot.
|
|
52
|
+
* @param {string} uiId
|
|
53
|
+
* @param {any} args - `ui_render` arguments that passed {@link validateTree}.
|
|
54
|
+
* @returns {UiSnapshot}
|
|
55
|
+
*/
|
|
56
|
+
export function snapshotOf(uiId, args) {
|
|
57
|
+
const tree = clone(args);
|
|
58
|
+
const byId = new Map(tree.components.map(n => [n.id, normalize(n)]));
|
|
59
|
+
return {
|
|
60
|
+
uiId,
|
|
61
|
+
version: 1,
|
|
62
|
+
...(typeof tree.title === 'string' && tree.title ? { title: tree.title } : {}),
|
|
63
|
+
root: tree.root,
|
|
64
|
+
components: reachable(tree.root, byId),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Components reachable from `root`, parents before children (renders progressively). */
|
|
69
|
+
function reachable(root, byId) {
|
|
70
|
+
const out = [];
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const walk = (id) => {
|
|
73
|
+
const node = byId.get(id);
|
|
74
|
+
if (!node || seen.has(id)) return;
|
|
75
|
+
seen.add(id);
|
|
76
|
+
out.push(node);
|
|
77
|
+
for (const c of node.children ?? []) walk(c);
|
|
78
|
+
};
|
|
79
|
+
walk(root);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check a new component definition (shape only; props are checked on the whole tree).
|
|
85
|
+
* @returns {string | undefined} a problem, or undefined.
|
|
86
|
+
*/
|
|
87
|
+
function checkNewNode(node, at) {
|
|
88
|
+
if (!isPlainObject(node)) return `${at}: expected a component object`;
|
|
89
|
+
if (typeof node.id !== 'string' || node.id === '') return `${at}.id: expected non-empty string`;
|
|
90
|
+
if (typeof node.type !== 'string') return `${at}(${node.id}).type: expected string`;
|
|
91
|
+
if (node.children !== undefined && (!Array.isArray(node.children) || node.children.some(c => typeof c !== 'string'))) {
|
|
92
|
+
return `${at}(${node.id}).children: expected array of component ids`;
|
|
93
|
+
}
|
|
94
|
+
const extra = Object.keys(node).find(k => !['id', 'type', 'props', 'children'].includes(k));
|
|
95
|
+
return extra ? `${at}(${node.id}).${extra}: unknown field` : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply one `ui_patch` call to the current snapshot. All-or-nothing: on any
|
|
100
|
+
* problem the snapshot is left untouched and the errors say what to fix.
|
|
101
|
+
* @param {UiSnapshot} ui - current version.
|
|
102
|
+
* @param {any} args - `ui_patch` arguments.
|
|
103
|
+
* @returns {{ ok: true, ui: UiSnapshot, changed: string[], warnings: string[] } | { ok: false, errors: string[], code: 'invalid' | 'stale' }}
|
|
104
|
+
*/
|
|
105
|
+
export function applyPatch(ui, args) {
|
|
106
|
+
const fail = (errors, code = 'invalid') => ({ ok: false, errors, code });
|
|
107
|
+
if (!isPlainObject(args)) return fail(['arguments: expected object']);
|
|
108
|
+
for (const key of Object.keys(args)) {
|
|
109
|
+
if (!['uiId', 'baseVersion', 'title', 'ops'].includes(key)) return fail([`${key}: unknown field`]);
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isInteger(args.baseVersion)) return fail([`baseVersion: expected the integer version you are patching (current: ${ui.version})`]);
|
|
112
|
+
if (args.baseVersion !== ui.version) {
|
|
113
|
+
return fail([`baseVersion: UI ${JSON.stringify(ui.uiId)} is at version ${ui.version}, not ${args.baseVersion}; it changed since you read it. Re-derive the patch from version ${ui.version} and call ui_patch again with baseVersion ${ui.version}.`], 'stale');
|
|
114
|
+
}
|
|
115
|
+
if (args.title !== undefined && typeof args.title !== 'string') return fail(['title: expected string']);
|
|
116
|
+
if (!Array.isArray(args.ops) || args.ops.length === 0) return fail(['ops: expected a non-empty array']);
|
|
117
|
+
if (args.ops.length > PATCH_LIMITS.ops) return fail([`ops: more than ${PATCH_LIMITS.ops} operations; call ui_render with the complete UI instead`]);
|
|
118
|
+
|
|
119
|
+
const nodes = new Map(clone(ui.components).map(n => [n.id, n]));
|
|
120
|
+
const parentOf = new Map();
|
|
121
|
+
for (const n of nodes.values()) for (const c of n.children ?? []) parentOf.set(c, n.id);
|
|
122
|
+
const changed = new Set();
|
|
123
|
+
const added = new Set();
|
|
124
|
+
|
|
125
|
+
const addNodes = (list, at) => {
|
|
126
|
+
if (list === undefined) return undefined;
|
|
127
|
+
if (!Array.isArray(list)) return `${at}: expected array of new components`;
|
|
128
|
+
if (list.length > PATCH_LIMITS.nodesPerOp) return `${at}: more than ${PATCH_LIMITS.nodesPerOp} components`;
|
|
129
|
+
for (let j = 0; j < list.length; j++) {
|
|
130
|
+
const problem = checkNewNode(list[j], `${at}[${j}]`);
|
|
131
|
+
if (problem) return problem;
|
|
132
|
+
if (nodes.has(list[j].id)) return `${at}[${j}].id: ${JSON.stringify(list[j].id)} already exists; give new components new ids, or use setProps/replaceNode on the existing one`;
|
|
133
|
+
const node = normalize(clone(list[j]));
|
|
134
|
+
nodes.set(node.id, node);
|
|
135
|
+
added.add(node.id);
|
|
136
|
+
changed.add(node.id);
|
|
137
|
+
}
|
|
138
|
+
// New components may reference each other; their parents are wired by the tree check below.
|
|
139
|
+
for (const id of list.map(n => n.id)) for (const c of nodes.get(id).children ?? []) parentOf.set(c, id);
|
|
140
|
+
return undefined;
|
|
141
|
+
};
|
|
142
|
+
const existing = (id, at) => {
|
|
143
|
+
if (typeof id !== 'string' || id === '') return `${at}: expected a component id`;
|
|
144
|
+
if (!nodes.has(id)) return `${at}: unknown component id ${JSON.stringify(id)} in UI ${JSON.stringify(ui.uiId)} version ${ui.version}`;
|
|
145
|
+
return undefined;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < args.ops.length; i++) {
|
|
149
|
+
const op = args.ops[i];
|
|
150
|
+
const at = `ops[${i}]`;
|
|
151
|
+
if (!isPlainObject(op)) return fail([`${at}: expected object`]);
|
|
152
|
+
if (!PATCH_OPS.includes(op.op)) return fail([`${at}.op: expected one of ${PATCH_OPS.join(' | ')}, got ${JSON.stringify(op.op)}`]);
|
|
153
|
+
const extra = Object.keys(op).find(k => k !== 'op' && !OP_FIELDS[op.op].includes(k));
|
|
154
|
+
if (extra) return fail([`${at}.${extra}: not a field of ${op.op} (allowed: ${OP_FIELDS[op.op].join(', ')})`]);
|
|
155
|
+
let problem;
|
|
156
|
+
switch (op.op) {
|
|
157
|
+
case 'setProps': {
|
|
158
|
+
problem = existing(op.id, `${at}.id`);
|
|
159
|
+
if (problem) break;
|
|
160
|
+
if (op.props === undefined && op.unset === undefined) { problem = `${at}: give props and/or unset`; break; }
|
|
161
|
+
if (op.props !== undefined && !isPlainObject(op.props)) { problem = `${at}.props: expected object`; break; }
|
|
162
|
+
if (op.props && 'children' in op.props) { problem = `${at}.props.children: children are not props; use insertChild/removeNode/replaceNode`; break; }
|
|
163
|
+
if (op.unset !== undefined && (!Array.isArray(op.unset) || op.unset.some(k => typeof k !== 'string'))) { problem = `${at}.unset: expected array of prop names`; break; }
|
|
164
|
+
const node = nodes.get(op.id);
|
|
165
|
+
const props = { ...(node.props ?? {}), ...clone(op.props ?? {}) };
|
|
166
|
+
for (const k of op.unset ?? []) delete props[k];
|
|
167
|
+
node.props = props;
|
|
168
|
+
changed.add(op.id);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case 'replaceNode': {
|
|
172
|
+
problem = existing(op.id, `${at}.id`);
|
|
173
|
+
if (problem) break;
|
|
174
|
+
if (!isPlainObject(op.node)) { problem = `${at}.node: expected { type, props, children }`; break; }
|
|
175
|
+
if (op.node.id !== undefined && op.node.id !== op.id) { problem = `${at}.node.id: must equal ${JSON.stringify(op.id)} (a replaced component keeps its id; remove it and insert a new one to change ids)`; break; }
|
|
176
|
+
problem = checkNewNode({ ...op.node, id: op.id }, `${at}.node`) ?? addNodes(op.nodes, `${at}.nodes`);
|
|
177
|
+
if (problem) break;
|
|
178
|
+
const prev = nodes.get(op.id);
|
|
179
|
+
for (const c of prev.children ?? []) if (parentOf.get(c) === op.id) parentOf.delete(c);
|
|
180
|
+
const next = normalize({ id: op.id, type: op.node.type, ...(op.node.props !== undefined ? { props: clone(op.node.props) } : {}), ...(op.node.children !== undefined ? { children: [...op.node.children] } : {}) });
|
|
181
|
+
nodes.set(op.id, next);
|
|
182
|
+
for (const c of next.children ?? []) parentOf.set(c, op.id);
|
|
183
|
+
changed.add(op.id);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'insertChild': {
|
|
187
|
+
problem = existing(op.parent, `${at}.parent`) ?? addNodes(op.nodes, `${at}.nodes`) ?? existing(op.id, `${at}.id`);
|
|
188
|
+
if (problem) break;
|
|
189
|
+
if (op.id === ui.root) { problem = `${at}.id: the root cannot be inserted as a child`; break; }
|
|
190
|
+
if (parentOf.has(op.id)) {
|
|
191
|
+
problem = `${at}.id: ${JSON.stringify(op.id)} already has a parent (${parentOf.get(op.id)}); removeNode it first or insert a new component`;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
const parent = nodes.get(op.parent);
|
|
195
|
+
const kids = [...(parent.children ?? [])];
|
|
196
|
+
const index = op.index === undefined ? kids.length : op.index;
|
|
197
|
+
if (!Number.isInteger(index) || index < 0 || index > kids.length) { problem = `${at}.index: expected an integer from 0 to ${kids.length}`; break; }
|
|
198
|
+
kids.splice(index, 0, op.id);
|
|
199
|
+
parent.children = kids;
|
|
200
|
+
parentOf.set(op.id, op.parent);
|
|
201
|
+
changed.add(op.parent);
|
|
202
|
+
changed.add(op.id);
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'removeNode': {
|
|
206
|
+
problem = existing(op.id, `${at}.id`);
|
|
207
|
+
if (problem) break;
|
|
208
|
+
if (op.id === ui.root) { problem = `${at}.id: the root cannot be removed; replaceNode it or call ui_render with a new UI`; break; }
|
|
209
|
+
const parentId = parentOf.get(op.id);
|
|
210
|
+
const parent = parentId === undefined ? undefined : nodes.get(parentId);
|
|
211
|
+
if (parent) {
|
|
212
|
+
parent.children = (parent.children ?? []).filter(c => c !== op.id);
|
|
213
|
+
changed.add(parentId);
|
|
214
|
+
}
|
|
215
|
+
parentOf.delete(op.id);
|
|
216
|
+
nodes.delete(op.id);
|
|
217
|
+
changed.delete(op.id);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (problem) return fail([problem]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const title = args.title !== undefined ? args.title : ui.title;
|
|
225
|
+
const components = reachable(ui.root, nodes);
|
|
226
|
+
// The whole next version passes the same checks as a fresh ui_render.
|
|
227
|
+
const check = validateTree(clone({ ...(title ? { title } : {}), root: ui.root, components }));
|
|
228
|
+
if (!check.ok) return fail(check.errors.slice(0, 40));
|
|
229
|
+
const reached = new Set(components.map(n => n.id));
|
|
230
|
+
const dropped = [...added].filter(id => !reached.has(id));
|
|
231
|
+
const warnings = dropped.length ? [`new components not attached anywhere (dropped): ${dropped.join(', ')}`] : [];
|
|
232
|
+
return {
|
|
233
|
+
ok: true,
|
|
234
|
+
ui: { uiId: ui.uiId, version: ui.version + 1, ...(title ? { title } : {}), root: ui.root, components },
|
|
235
|
+
changed: [...changed].filter(id => reached.has(id)),
|
|
236
|
+
warnings,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Rebuild the current version of every UI in one conversation from its message
|
|
242
|
+
* history: each `ui_render` call that settled without error and validates, then
|
|
243
|
+
* each settled `ui_patch` call in result order. Patches are deterministic, so
|
|
244
|
+
* replaying them reproduces exactly the versions the user saw. Calls without a
|
|
245
|
+
* result yet (including the one being executed) are ignored.
|
|
246
|
+
* @param {readonly any[]} messages - `Session.deriveMessages()`.
|
|
247
|
+
* @param {{ render: string, patch: string }} tools - tool names.
|
|
248
|
+
* @returns {Map<string, UiSnapshot>}
|
|
249
|
+
*/
|
|
250
|
+
export function collectUis(messages, tools) {
|
|
251
|
+
const calls = new Map();
|
|
252
|
+
const uis = new Map();
|
|
253
|
+
for (const m of messages) {
|
|
254
|
+
if (m?.role === 'assistant' && Array.isArray(m.content)) {
|
|
255
|
+
for (const b of m.content) {
|
|
256
|
+
if (b?.type === 'tool-call' && (b.name === tools.render || b.name === tools.patch)) calls.set(b.id, b);
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (m?.role !== 'tool' || m.isError === true) continue;
|
|
261
|
+
const call = calls.get(m.toolCallId);
|
|
262
|
+
if (!call) continue;
|
|
263
|
+
calls.delete(m.toolCallId);
|
|
264
|
+
let args;
|
|
265
|
+
try { args = typeof call.arguments === 'string' ? JSON.parse(call.arguments) : call.arguments; } catch { continue; }
|
|
266
|
+
if (call.name === tools.render) {
|
|
267
|
+
if (validateTree(clone(args ?? null)).ok) uis.set(call.id, snapshotOf(call.id, args));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const ui = isPlainObject(args) && typeof args.uiId === 'string' ? uis.get(args.uiId) : undefined;
|
|
271
|
+
if (!ui) continue;
|
|
272
|
+
const result = applyPatch(ui, args);
|
|
273
|
+
if (result.ok) uis.set(ui.uiId, result.ui);
|
|
274
|
+
}
|
|
275
|
+
return uis;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Compact `id:Type` outline the model can address in its next patch. */
|
|
279
|
+
export function outline(ui, max = 80) {
|
|
280
|
+
const parts = ui.components.slice(0, max).map(n => `${n.id}:${n.type}`);
|
|
281
|
+
if (ui.components.length > max) parts.push(`… ${ui.components.length - max} more`);
|
|
282
|
+
return parts.join(', ');
|
|
283
|
+
}
|
package/src/compat.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
export const SUPPORTED_DSH_RANGE = '>=0.1.7-rc.1 <0.3.0-0 || >=0.2.0-0 <0.2.0 || >=0.2.1-0 <0.2.1';
|
|
13
13
|
|
|
14
14
|
/** Versions the CI compatibility matrix installs the packed plugin into. */
|
|
15
|
-
export const TESTED_DSH_VERSIONS = Object.freeze(['0.1.7-rc.2', '0.2.0-rc.1', '0.2.0-rc.2', '0.2.1-alpha.2']);
|
|
15
|
+
export const TESTED_DSH_VERSIONS = Object.freeze(['0.1.7-rc.2', '0.2.0-rc.1', '0.2.0-rc.2', '0.2.1-alpha.2', '0.2.1-alpha.4']);
|
|
16
16
|
|
|
17
17
|
/** Lowest Node.js version DSH itself requires. */
|
|
18
18
|
export const MIN_NODE_VERSION = '22.13.0';
|
package/src/guidance.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* @module deepseek-harness-intelligent-ui/guidance
|
|
7
7
|
*/
|
|
8
8
|
import { RENDER_TOOL } from './tools/render.js';
|
|
9
|
+
import { PATCH_TOOL } from './tools/patch.js';
|
|
9
10
|
import { WIDGET_TOOL } from './tools/widget.js';
|
|
10
11
|
|
|
11
12
|
/** Placement right after MCP server instructions (3100) and before the tools SDK (5000). */
|
|
@@ -43,7 +44,11 @@ export function guidanceText(config) {
|
|
|
43
44
|
lines.push('', 'Interaction design:');
|
|
44
45
|
lines.push('- Everything that can be computed in the browser happens there: sliders, toggles, rotation, zoom, filtering, sorting and recomputing formulas update the UI instantly without a message to you.');
|
|
45
46
|
lines.push('- Send a message to yourself (Button, Form, Bridge.sendPrompt) only for explicit user requests that need you: an explanation, new data, a decision, the next step. Interactions reach you as the user\'s NEXT message; never trigger them automatically and do not wait for them in the current turn.');
|
|
46
|
-
|
|
47
|
+
if (config.enableCatalog) {
|
|
48
|
+
lines.push(`- When the user asks to change a UI you showed (${RENDER_TOOL}), prefer \`${PATCH_TOOL}\`: send only the changed components by id (e.g. switch one Chart from bar to line), with the uiId and version from the last result for that UI. Untouched components keep the user's sorting, tab, form input and slider positions.`);
|
|
49
|
+
lines.push(`- Re-send the complete UI with ${RENDER_TOOL} only for a new UI, when most of it changes, or when ${PATCH_TOOL} reports the UI is unavailable.`);
|
|
50
|
+
}
|
|
51
|
+
if (config.enableWidget) lines.push(`- To change a widget, call ${WIDGET_TOOL} again with the complete updated widget.`);
|
|
47
52
|
lines.push('', 'Quality:');
|
|
48
53
|
lines.push('- One UI call per answer is usually enough. Real data only: never invent numbers to fill a chart; if data is missing, say so or ask.');
|
|
49
54
|
lines.push('- Keep UI component trees clean and concise (e.g. 4-8 milestones for Timeline, 4-8 rows for Table). Keep text props succinct and avoid excessively long paragraphs inside components.');
|
package/src/plugin.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DeepSeek Harness Intelligent UI — Host half.
|
|
3
3
|
*
|
|
4
|
-
* Registers the generative-UI tools (`ui_render`, `ui_render_widget`), the
|
|
4
|
+
* Registers the generative-UI tools (`ui_render`, `ui_patch`, `ui_render_widget`), the
|
|
5
5
|
* `/ui` command and an optional system-prompt section. The browser half
|
|
6
6
|
* (`./client`) renders each tool call in the `tool.call.toolview` slot.
|
|
7
7
|
* @module deepseek-harness-intelligent-ui
|
|
8
8
|
*/
|
|
9
9
|
import { Config as ConfigSchema, resolveConfig } from './config.js';
|
|
10
10
|
import { createRenderTool } from './tools/render.js';
|
|
11
|
+
import { createPatchTool } from './tools/patch.js';
|
|
11
12
|
import { createWidgetTool } from './tools/widget.js';
|
|
12
13
|
import { GUIDANCE_ORDER, guidanceText } from './guidance.js';
|
|
13
14
|
import { registerCommands } from './commands.js';
|
|
@@ -24,7 +25,10 @@ export function apply(ctx, rawConfig) {
|
|
|
24
25
|
const config = resolveConfig(rawConfig);
|
|
25
26
|
// tools.register / commands.register are fiber effects: unloading this
|
|
26
27
|
// plugin unregisters everything, so no manual disposer bookkeeping.
|
|
27
|
-
if (config.enableCatalog)
|
|
28
|
+
if (config.enableCatalog) {
|
|
29
|
+
ctx.tools.register(createRenderTool(config));
|
|
30
|
+
ctx.tools.register(createPatchTool(config));
|
|
31
|
+
}
|
|
28
32
|
if (config.enableWidget) ctx.tools.register(createWidgetTool(config));
|
|
29
33
|
registerCommands(ctx.commands, config);
|
|
30
34
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-neutral native DSH event projection. Never infer UI data from text.
|
|
3
|
+
* Only successful tool result metadata can authorize a render or atomic patch.
|
|
4
|
+
*/
|
|
5
|
+
import { validateTree } from '../catalog/catalog.js';
|
|
6
|
+
|
|
7
|
+
const MAX_ARGUMENT_CHARS = 480_000;
|
|
8
|
+
const TOOLS = new Set(['ui_render', 'ui_patch', 'ui_render_widget']);
|
|
9
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
const text = value => typeof value === 'string' && value.length > 0;
|
|
11
|
+
const clone = value => JSON.parse(JSON.stringify(value));
|
|
12
|
+
|
|
13
|
+
function argumentsOf(input) {
|
|
14
|
+
if (typeof input === 'string' && input.length <= MAX_ARGUMENT_CHARS) {
|
|
15
|
+
try { const parsed = JSON.parse(input); return object(parsed) ? parsed : undefined; }
|
|
16
|
+
catch { return undefined; }
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
function validTree(input) {
|
|
21
|
+
try { return object(input) && validateTree(clone(input)).ok; }
|
|
22
|
+
catch { return false; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Project one session's native history and/or mux event records.
|
|
27
|
+
* Events may be SessionEvent[] or session.history rows with an event field.
|
|
28
|
+
* Correctness does not depend on input order; correlation follows callId.
|
|
29
|
+
*
|
|
30
|
+
* @param {readonly any[]} events
|
|
31
|
+
* @param {{hostId:string,sessionId:string}} scope
|
|
32
|
+
*/
|
|
33
|
+
export function projectNativeUiEvents(events, { hostId, sessionId }) {
|
|
34
|
+
if (!text(hostId) || !text(sessionId)) return [];
|
|
35
|
+
const calls = new Map();
|
|
36
|
+
const settled = [];
|
|
37
|
+
const sorted = events.map(row => row?.event ?? row)
|
|
38
|
+
.filter(event => object(event) && typeof event.seq === 'number')
|
|
39
|
+
.sort((a, b) => a.seq - b.seq);
|
|
40
|
+
for (const event of sorted) {
|
|
41
|
+
const data = object(event.data) ? event.data : {};
|
|
42
|
+
if (event.type === 'tool/call' && TOOLS.has(data.name) && text(data.callId)) {
|
|
43
|
+
const args = argumentsOf(data.arguments);
|
|
44
|
+
if (args) calls.set(data.callId, { kind: data.name, args, turn: data.turn });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (event.type !== 'tool/result') continue;
|
|
48
|
+
const message = object(data.message) ? data.message : {};
|
|
49
|
+
const id = object(message.source) ? message.source.callId : data.callId;
|
|
50
|
+
if (!text(id)) continue;
|
|
51
|
+
const call = calls.get(id);
|
|
52
|
+
if (!call) continue;
|
|
53
|
+
const meta = data.meta;
|
|
54
|
+
if (!object(meta) || meta.ok !== true || message.isError === true || data.isError === true || object(data.error)) continue;
|
|
55
|
+
settled.push({ id, call, meta, seq: event.seq });
|
|
56
|
+
}
|
|
57
|
+
const latest = new Map();
|
|
58
|
+
for (const { id, call, meta, seq } of settled.sort((a, b) => a.seq - b.seq)) {
|
|
59
|
+
const args = call.args;
|
|
60
|
+
const policy = object(meta.policy) ? meta.policy : undefined;
|
|
61
|
+
const turn = call.turn === undefined ? undefined : String(call.turn);
|
|
62
|
+
if (call.kind === 'ui_render' && validTree(args)) {
|
|
63
|
+
if (latest.has(id)) continue;
|
|
64
|
+
latest.set(id, { kind: 'declarative', hostId, sessionId, uiId: id, version: 1,
|
|
65
|
+
title: typeof args.title === 'string' ? args.title : undefined,
|
|
66
|
+
definition: args, policy, turn, seq });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (call.kind === 'ui_render_widget' && text(args.html)) {
|
|
70
|
+
if (latest.has(id)) continue;
|
|
71
|
+
latest.set(id, { kind: 'widget', hostId, sessionId, uiId: id, version: 1,
|
|
72
|
+
title: typeof args.title === 'string' ? args.title : undefined,
|
|
73
|
+
definition: args, policy, turn, seq });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (call.kind !== 'ui_patch' || !text(args.uiId) || !Number.isSafeInteger(args.baseVersion)) continue;
|
|
77
|
+
const prior = latest.get(args.uiId);
|
|
78
|
+
const next = meta.ui;
|
|
79
|
+
if (!prior || prior.kind !== 'declarative' || prior.version !== args.baseVersion
|
|
80
|
+
|| !object(next) || next.uiId !== args.uiId || next.version !== prior.version + 1
|
|
81
|
+
|| !validTree(next)) continue;
|
|
82
|
+
latest.set(prior.uiId, { ...prior, version: next.version, definition: next,
|
|
83
|
+
title: typeof next.title === 'string' ? next.title : prior.title,
|
|
84
|
+
policy: policy ?? prior.policy, turn: prior.turn, updatedTurn: turn ?? prior.updatedTurn, seq });
|
|
85
|
+
}
|
|
86
|
+
return [...latest.values()].sort((a, b) => a.seq - b.seq);
|
|
87
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ui_patch`: update a UI shown earlier in this conversation in place, by
|
|
3
|
+
* component id, instead of re-sending the whole tree. execute() rebuilds the
|
|
4
|
+
* UI's current version from the session's own history (see catalog/patch.js),
|
|
5
|
+
* applies the operations atomically and returns the complete next version in
|
|
6
|
+
* `presentationMeta`, so the browser renders it without replaying anything and
|
|
7
|
+
* a reload reproduces it from the durable `tool/result`.
|
|
8
|
+
* @module deepseek-harness-intelligent-ui/tools/patch
|
|
9
|
+
*/
|
|
10
|
+
import { COMPONENT_TYPES } from '../catalog/catalog.js';
|
|
11
|
+
import { applyPatch, collectUis, outline, PATCH_LIMITS, PATCH_OPS } from '../catalog/patch.js';
|
|
12
|
+
import { clientPolicy } from '../config.js';
|
|
13
|
+
import { RENDER_TOOL } from './render.js';
|
|
14
|
+
|
|
15
|
+
export const PATCH_TOOL = 'ui_patch';
|
|
16
|
+
|
|
17
|
+
const NODE = {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
id: { type: 'string', description: 'Unique new component id.' },
|
|
21
|
+
type: { type: 'string', enum: [...COMPONENT_TYPES] },
|
|
22
|
+
props: { type: 'object' },
|
|
23
|
+
children: { type: 'array', items: { type: 'string' } },
|
|
24
|
+
},
|
|
25
|
+
required: ['id', 'type'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The UIs of the calling conversation, or an explanation why there are none.
|
|
30
|
+
* @param {any} exec - ToolRunContext.
|
|
31
|
+
*/
|
|
32
|
+
function conversationUis(exec) {
|
|
33
|
+
const session = exec?.agent?.session;
|
|
34
|
+
if (!session || typeof session.deriveMessages !== 'function') return undefined;
|
|
35
|
+
return collectUis(session.deriveMessages(), { render: RENDER_TOOL, patch: PATCH_TOOL });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {ReturnType<import('../config.js').resolveConfig>} config
|
|
40
|
+
*/
|
|
41
|
+
export function createPatchTool(config) {
|
|
42
|
+
const policy = clientPolicy(config);
|
|
43
|
+
return {
|
|
44
|
+
name: PATCH_TOOL,
|
|
45
|
+
description: [
|
|
46
|
+
`Change a UI that ${RENDER_TOOL} (or an earlier ${PATCH_TOOL}) already showed in THIS conversation, in place, without re-sending the whole UI.`,
|
|
47
|
+
'Address it by its uiId and the version you are changing (both are in the tool result that showed it); address components by their ids.',
|
|
48
|
+
'Components you do not touch keep their content and the user\'s state (table sort, selected tab, form input, slider positions).',
|
|
49
|
+
'Operations, applied in order and all-or-nothing:',
|
|
50
|
+
'- setProps {id, props, unset?}: merge props into a component (e.g. {"id":"chart","props":{"kind":"line"}}); unset lists prop names to remove.',
|
|
51
|
+
'- replaceNode {id, node:{type, props, children?}, nodes?}: replace one component\'s type/props/children, keeping its id (its state resets).',
|
|
52
|
+
'- insertChild {parent, id, index?, nodes}: add the new component `id` (defined in `nodes`, with any descendants) under `parent`, at `index` (default: last).',
|
|
53
|
+
'- removeNode {id}: remove a component and everything under it (not the root). Keep Tabs labels in step with their children in the same patch.',
|
|
54
|
+
`On ok=false nothing changed; fix the listed problems and retry. A stale baseVersion means the UI changed: patch the current version. Use ${RENDER_TOOL} instead for a new UI, a different root, or when most of the UI changes. Widgets (ui_render_widget) cannot be patched; render them again.`,
|
|
55
|
+
].join('\n'),
|
|
56
|
+
parameters: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
uiId: { type: 'string', description: 'uiId of the UI to change, from the tool result that showed it.' },
|
|
60
|
+
baseVersion: { type: 'integer', description: 'The UI version this patch is based on (1 after ui_render; each patch result reports the new version).' },
|
|
61
|
+
title: { type: 'string', description: 'Optional new card title.' },
|
|
62
|
+
ops: {
|
|
63
|
+
type: 'array',
|
|
64
|
+
description: `1-${PATCH_LIMITS.ops} operations, applied in order.`,
|
|
65
|
+
items: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
op: { type: 'string', enum: [...PATCH_OPS] },
|
|
69
|
+
id: { type: 'string', description: 'Target component id (insertChild: the new child id).' },
|
|
70
|
+
props: { type: 'object', description: 'setProps: props to merge.' },
|
|
71
|
+
unset: { type: 'array', items: { type: 'string' }, description: 'setProps: prop names to remove.' },
|
|
72
|
+
node: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
description: 'replaceNode: the replacement (same id).',
|
|
75
|
+
properties: {
|
|
76
|
+
type: { type: 'string', enum: [...COMPONENT_TYPES] },
|
|
77
|
+
props: { type: 'object' },
|
|
78
|
+
children: { type: 'array', items: { type: 'string' } },
|
|
79
|
+
},
|
|
80
|
+
required: ['type'],
|
|
81
|
+
},
|
|
82
|
+
parent: { type: 'string', description: 'insertChild: the container to insert into.' },
|
|
83
|
+
index: { type: 'integer', description: 'insertChild: position among the parent\'s children.' },
|
|
84
|
+
nodes: { type: 'array', items: NODE, description: 'insertChild/replaceNode: new components (flat list, children by id).' },
|
|
85
|
+
},
|
|
86
|
+
required: ['op'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
required: ['uiId', 'baseVersion', 'ops'],
|
|
91
|
+
},
|
|
92
|
+
output: {
|
|
93
|
+
schema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
ok: { type: 'boolean' },
|
|
97
|
+
summary: { type: 'string' },
|
|
98
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
99
|
+
code: { type: 'string' },
|
|
100
|
+
ui: { type: 'object' },
|
|
101
|
+
},
|
|
102
|
+
required: ['ok', 'summary'],
|
|
103
|
+
},
|
|
104
|
+
render: (_args, value) => [{ type: 'text', text: value.ok ? value.summary : `${value.summary}\n${(value.errors || []).map(e => `- ${e}`).join('\n')}` }],
|
|
105
|
+
// The complete next version travels to the browser; the model only sees the summary.
|
|
106
|
+
presentationMeta: (_args, value) => (value.ok ? { ok: true, policy, ui: value.ui } : { ok: false, policy, ...(value.code ? { code: value.code } : {}) }),
|
|
107
|
+
},
|
|
108
|
+
// Exclusive: patches of one UI must apply in a single, replayable order.
|
|
109
|
+
isConcurrencySafe: () => false,
|
|
110
|
+
async execute(args, exec) {
|
|
111
|
+
const refuse = (errors, code = 'invalid') => ({
|
|
112
|
+
ok: false,
|
|
113
|
+
code,
|
|
114
|
+
summary: `UI not changed: ${errors.length} problem(s). The user still sees the previous version.`,
|
|
115
|
+
errors,
|
|
116
|
+
});
|
|
117
|
+
if (typeof args !== 'object' || args === null || typeof args.uiId !== 'string' || args.uiId === '') {
|
|
118
|
+
return refuse(['uiId: expected the uiId of a UI shown earlier in this conversation']);
|
|
119
|
+
}
|
|
120
|
+
const uis = conversationUis(exec);
|
|
121
|
+
if (!uis) return refuse([`${PATCH_TOOL} is only available inside a conversation; call ${RENDER_TOOL} with the complete UI`], 'unavailable');
|
|
122
|
+
const ui = uis.get(args.uiId);
|
|
123
|
+
if (!ui) {
|
|
124
|
+
const known = [...uis.values()].slice(-5).map(u => `${u.uiId} (v${u.version}${u.title ? ` "${u.title}"` : ''})`);
|
|
125
|
+
return refuse([`uiId: no UI ${JSON.stringify(args.uiId)} in this conversation${known.length ? `; known: ${known.join(', ')}` : ''}. If it is no longer available, call ${RENDER_TOOL} with the complete UI.`], 'unknown');
|
|
126
|
+
}
|
|
127
|
+
const result = applyPatch(ui, args);
|
|
128
|
+
if (!result.ok) return refuse(result.errors, result.code);
|
|
129
|
+
const next = result.ui;
|
|
130
|
+
const title = next.title ? ` "${next.title}"` : '';
|
|
131
|
+
const warn = result.warnings.length ? ` Warning: ${result.warnings.join('; ')}.` : '';
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
summary: `UI${title} updated in place: uiId ${next.uiId}, now version ${next.version} (changed: ${result.changed.join(', ') || 'title'}).${warn} Components: ${outline(next)}.`,
|
|
135
|
+
ui: next,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
package/src/tools/render.js
CHANGED
|
@@ -63,7 +63,7 @@ export function createRenderTool(config) {
|
|
|
63
63
|
presentationMeta: (_args, value) => ({ ok: value.ok, policy }),
|
|
64
64
|
},
|
|
65
65
|
isConcurrencySafe: () => true,
|
|
66
|
-
async execute(args) {
|
|
66
|
+
async execute(args, exec) {
|
|
67
67
|
const result = validateTree(args);
|
|
68
68
|
if (!result.ok) {
|
|
69
69
|
return {
|
|
@@ -75,9 +75,11 @@ export function createRenderTool(config) {
|
|
|
75
75
|
const kinds = Object.entries(result.types).map(([t, n]) => (n > 1 ? `${n}x ${t}` : t)).join(', ');
|
|
76
76
|
const title = typeof args.title === 'string' && args.title ? ` "${args.title}"` : '';
|
|
77
77
|
const warn = result.warnings.length ? ` Warning: ${result.warnings.join('; ')}.` : '';
|
|
78
|
+
// The call id doubles as the UI's identity for later in-place patches.
|
|
79
|
+
const handle = exec?.callId ? ` uiId ${exec.callId}, version 1 (change it later with ui_patch).` : '';
|
|
78
80
|
return {
|
|
79
81
|
ok: true,
|
|
80
|
-
summary: `UI${title} is now displayed to the user (${result.count} components: ${kinds}).${warn} User interactions will arrive as new user messages.`,
|
|
82
|
+
summary: `UI${title} is now displayed to the user (${result.count} components: ${kinds}).${warn}${handle} User interactions will arrive as new user messages.`,
|
|
81
83
|
};
|
|
82
84
|
},
|
|
83
85
|
};
|