dsh-motion-ui 0.0.0-stage → 0.5.2

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/DESIGN.zh-CN.md +56 -0
  3. package/LICENSE +21 -0
  4. package/README.md +162 -2
  5. package/RELEASE-PLAN.zh-CN.md +52 -0
  6. package/VERIFICATION.md +133 -0
  7. package/client.js +23645 -0
  8. package/cordis.patch.yml +3 -0
  9. package/examples/agent-architecture.json +48 -0
  10. package/examples/bicycle-explorer.json +108 -0
  11. package/examples/calculator.json +94 -0
  12. package/examples/comparison.json +59 -0
  13. package/examples/conditional-budget.json +148 -0
  14. package/examples/live-memory-curve.json +251 -0
  15. package/examples/scene-comparison.json +86 -0
  16. package/examples/scene-dashboard.json +92 -0
  17. package/examples/scene-guide.json +56 -0
  18. package/examples/scene-report.json +61 -0
  19. package/examples/scene-status.json +70 -0
  20. package/examples/scene-workbench.json +105 -0
  21. package/examples/sine-motion.json +49 -0
  22. package/examples/speed-memory-scatter.json +113 -0
  23. package/index.js +69 -0
  24. package/lib/author-expression.js +60 -0
  25. package/lib/build-info.js +2 -0
  26. package/lib/canvas-design.js +10 -0
  27. package/lib/canvas-example.js +46 -0
  28. package/lib/canvas-export.js +9 -0
  29. package/lib/canvas-preview.js +31 -0
  30. package/lib/canvas-system.js +14 -0
  31. package/lib/catalog.js +92 -0
  32. package/lib/components.js +28 -0
  33. package/lib/export-runtime-bundle.js +2 -0
  34. package/lib/format-number.js +7 -0
  35. package/lib/freeform.js +13 -0
  36. package/lib/inspection.js +13 -0
  37. package/lib/presentation.js +6 -0
  38. package/lib/protocol.js +295 -0
  39. package/lib/refinement.js +31 -0
  40. package/lib/sandbox.js +52 -0
  41. package/lib/scenes.js +50 -0
  42. package/lib/store.js +176 -0
  43. package/lib/stream.js +87 -0
  44. package/lib/tool-json.js +7 -0
  45. package/lib/ui-intent.js +14 -0
  46. package/lib/ui-skill.js +46 -0
  47. package/lib/visual-audit.js +18 -0
  48. package/lib/visual-examples.js +28 -0
  49. package/package.json +75 -4
  50. package/skills/intelligent-ui/SKILL.md +86 -0
@@ -0,0 +1,31 @@
1
+ import { componentFields } from './components.js';
2
+ import { validateDocument } from './protocol.js';
3
+ export const AUDIT_MESSAGES = Object.freeze({ overflow: '内容超出视口宽度', smallText: '文字实际显示尺寸小于 12px', contrast: '文字与背景对比度偏低', missingLabel: '交互控件缺少可访问名称',runtimeError:'画布报告运行异常' });
4
+ export function refineDocument(document, changes) {
5
+ if (!Array.isArray(changes) || !changes.length || changes.length > 32) throw new Error('DSH UI: changes requires 1..32 node edits');
6
+ const next = structuredClone(document), nodes = new Map();
7
+ const walk = list => list.forEach(n => { nodes.set(n.id,n); if(n.children)walk(n.children); });walk(next.nodes);
8
+ const seen = new Set();
9
+ for(const change of changes){
10
+ if(!change || Object.keys(change).some(k=>!['id','props'].includes(k)) || seen.has(change.id) || !nodes.has(change.id))throw new Error('DSH UI: edit needs a unique existing node id');
11
+ seen.add(change.id);const node=nodes.get(change.id),props=change.props;
12
+ if(!props || typeof props!=='object' || Array.isArray(props) || !Object.keys(props).length)throw new Error('DSH UI: edit props required');
13
+ const allowed=componentFields(node.type).filter(k=>!['id','type','children','bind','action'].includes(k));
14
+ if(Object.keys(props).some(k=>!allowed.includes(k)))throw new Error('DSH UI: edits cannot change node identity, structure, bindings or actions');
15
+ Object.assign(node,props);
16
+ }
17
+ return validateDocument(next);
18
+ }
19
+ export function validateAudit(value){
20
+ if(!value || !Number.isInteger(value.width) || value.width<1 || value.width>8192 || !['light','dark'].includes(value.theme) || !Array.isArray(value.issues) || value.issues.length>40)throw new Error('DSH UI: invalid audit');
21
+ const coverage=value.coverage??[];
22
+ if(!Array.isArray(coverage)||coverage.length>32)throw new Error('DSH UI: invalid audit coverage');
23
+ const checkedCoverage=coverage.map(c=>{
24
+ if(!c||typeof c.nodeId!=='string'||c.nodeId.length>64||typeof c.truncated!=='boolean'||['elements','contrastChecked','contrastUncovered'].some(k=>!Number.isInteger(c[k])||c[k]<0||c[k]>2000))throw new Error('DSH UI: invalid audit coverage');
25
+ return {nodeId:c.nodeId,elements:c.elements,truncated:c.truncated,contrastChecked:c.contrastChecked,contrastUncovered:c.contrastUncovered};
26
+ });
27
+ return {width:value.width,theme:value.theme,coverage:checkedCoverage,issues:value.issues.map(i=>{
28
+ if(!i || !Object.hasOwn(AUDIT_MESSAGES,i.code) || typeof i.nodeId!=='string' || i.nodeId.length>64 || (i.value!==undefined && (!Number.isFinite(i.value)||Math.abs(i.value)>100000)))throw new Error('DSH UI: invalid audit issue');
29
+ return {code:i.code,nodeId:i.nodeId,message:AUDIT_MESSAGES[i.code],...(i.value===undefined?{}:{value:i.value})};
30
+ })};
31
+ }
package/lib/sandbox.js ADDED
@@ -0,0 +1,52 @@
1
+ import { measureVisual } from './visual-audit.js';
2
+ import { formatNumber } from './format-number.js';
3
+ import { CANVAS_SYSTEM_CSS } from './canvas-system.js';
4
+ // Shared bounds and document construction. Generated code runs only in an opaque-origin frame.
5
+ export const SANDBOX_LIMITS = Object.freeze({ source: 49152, height: 1000, message: 16384, rate: 30 });
6
+ export function canvasSnapshot(node, state, data, derived, disabled, theme) {
7
+ const pick = (keys, values) => Object.fromEntries((keys ?? []).map(k => [k, values[k]]));
8
+ return { state: pick(node.stateKeys, state), data: pick(node.dataKeys, data), derived: pick(node.derivedKeys, derived), disabled, theme };
9
+ }
10
+ export function canvasPatch(message, node) {
11
+ if (!message || typeof message !== 'object' || Array.isArray(message)) throw new Error('Invalid canvas patch');
12
+ const keys = Object.keys(message);
13
+ if (!keys.length || keys.length > 64 || keys.some(k => !(node.stateKeys ?? []).includes(k))) throw new Error('Canvas state key is not declared');
14
+ if (JSON.stringify(message).length > SANDBOX_LIMITS.message) throw new Error('Canvas patch is too large');
15
+ return message;
16
+ }
17
+ export function sandboxDocument(node, channel, nonce) {
18
+ const json = value => JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
19
+ // Only bootstrap/generated script elements receive a nonce. Markup scripts/handlers stay inert.
20
+ return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; base-uri 'none'; form-action 'none'"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${CANVAS_SYSTEM_CSS}</style></head><body><main id="canvas"></main><script nonce="${nonce}">(()=>{
21
+ const channel=${json(channel)}, source=${json({ html: node.html, css: node.css ?? '', script: node.script ?? '' })};
22
+ let snapshot={state:{},data:{},derived:{},disabled:true,theme:{}}, started=false;
23
+ const listeners=new Set(), send=(type,payload)=>parent.postMessage({dshCanvas:1,channel,type,payload},'*');
24
+ const report=e=>send('error',String(e?.message??e).slice(0,1000));
25
+ const audit=${measureVisual.toString()};let auditTimer;const scheduleAudit=()=>{clearTimeout(auditTimer);auditTimer=setTimeout(()=>{const issues=audit(document.getElementById('canvas'),${json(node.id??'')});send('audit',{issues,coverage:issues.coverage})},400)};
26
+ addEventListener('resize',scheduleAudit);
27
+ addEventListener('error',e=>report(e.error??e.message));addEventListener('unhandledrejection',e=>report(e.reason));
28
+ const animations=new Set(), motion=matchMedia('(prefers-reduced-motion: reduce)');
29
+ let raf=0,last=0,time=0;
30
+ const visible=()=>snapshot.visible!==false&&!document.hidden;
31
+ const canAnimate=()=>visible()&&!snapshot.disabled&&!motion.matches;
32
+ const draw=delta=>{for(const fn of animations)try{fn({time,delta,reducedMotion:motion.matches,visible:visible()})}catch(e){animations.delete(fn);report(e)}};
33
+ const tick=t=>{raf=0;if(!canAnimate()||!animations.size){last=0;draw(0);return}const delta=last?Math.min(.05,(t-last)/1000):0;last=t;time+=delta;draw(delta);if(animations.size)raf=requestAnimationFrame(tick)};
34
+ const refresh=()=>{draw(0);if(canAnimate()&&animations.size){if(!raf){last=0;raf=requestAnimationFrame(tick)}}else{cancelAnimationFrame(raf);raf=0;last=0}};
35
+ motion.addEventListener('change',refresh);document.addEventListener('visibilitychange',refresh);
36
+ const formatNumber=${formatNumber.toString()};
37
+ const api={formatNumber,get snapshot(){return structuredClone(snapshot)},subscribe(fn){if(typeof fn!=='function')throw Error('Listener required');listeners.add(fn);fn(api.snapshot);return()=>listeners.delete(fn)},setState(patch){if(!snapshot.disabled)send('patch',patch)},animate(fn){if(typeof fn!=='function')throw Error('Animation callback required');animations.add(fn);refresh();return()=>{animations.delete(fn);if(!animations.size){cancelAnimationFrame(raf);raf=0;last=0}}}};
38
+ Object.defineProperty(window,'DSHUI',{value:Object.freeze(api),writable:false});
39
+ for(const type of ['click','input','change','keydown','pointerdown'])document.addEventListener(type,e=>{if(snapshot.disabled){e.preventDefault();e.stopImmediatePropagation()}},true);
40
+ addEventListener('message',event=>{
41
+ const m=event.data;if(event.source!==parent||m?.dshCanvas!==1||m.channel!==channel||m.type!=='snapshot')return;
42
+ snapshot=m.payload;for(const [k,v] of Object.entries(snapshot.theme??{}))document.documentElement.style.setProperty('--canvas-'+k,v);
43
+ document.documentElement.style.colorScheme=snapshot.theme?.scheme??'light';document.documentElement.dataset.canvasScheme=snapshot.theme?.scheme??'light';
44
+ const wasStarted=started;
45
+ if(!started){started=true;document.getElementById('canvas').innerHTML=source.html;const style=document.createElement('style');style.textContent=source.css;document.head.append(style);const script=document.createElement('script');script.nonce=${json(nonce)};script.textContent=source.script;document.body.append(script)}
46
+ if(wasStarted)for(const fn of listeners)try{fn(api.snapshot)}catch(e){report(e)}
47
+ refresh();if(!wasStarted)send('mounted');scheduleAudit();
48
+ });
49
+ let resizePending=false;new ResizeObserver(()=>{if(!started||resizePending)return;resizePending=true;requestAnimationFrame(()=>{resizePending=false;send('height',Math.ceil(document.getElementById('canvas').getBoundingClientRect().height))})}).observe(document.getElementById('canvas'));
50
+ send('ready');
51
+ })();</script></body></html>`;
52
+ }
package/lib/scenes.js ADDED
@@ -0,0 +1,50 @@
1
+ // Honest, portable examples: none of these represent real measurements or tasks.
2
+ const copy = (id, text, tone) => ({ id, type: 'text', text, ...(tone ? { tone } : {}) });
3
+ const metric = (id, label, value, unit, tone = 'accent') => ({ id, type: 'metric', label, value, unit, tone, decimals: 0 });
4
+ const card = (id, title, children, surface = 'outline') => ({ id, type: 'card', title, children, surface });
5
+ const example = (layout, accent, title, nodes, extra = {}) => ({ version: 1, title, fallback: '这是布局与交互示例,数据为演示数据;不代表实际测试、任务进度或推荐结论。', presentation: { layout, accent }, nodes, ...extra });
6
+ export const sceneExamples = [
7
+ example('dashboard', 'blue', '分析仪表盘 · 示例', [
8
+ { id: 'source', type: 'badge', text: '演示数据', tone: 'neutral', icon: 'database' },
9
+ { id: 'metrics', type: 'row', children: [metric('count', '测试样本', 120, '项'), metric('pass', '通过样本', 108, '项', 'success'), metric('review', '待复核', 12, '项', 'warning')] },
10
+ { id: 'chart', type: 'chart', kind: 'bar', label: '各组通过样本', unit: '项', rows: { ref: 'data.groups' }, labelKey: 'name', valueKey: 'passed' },
11
+ card('conclusion', '观察与下一步', [copy('observation', 'B 组通过样本较少;需要查看失败类型和各组总样本,才能判断差异原因。')], 'soft')
12
+ ], { data: { groups: [{ name: 'A 组', passed: 38 }, { name: 'B 组', passed: 32 }, { name: 'C 组', passed: 38 }] } }),
13
+ example('workbench', 'teal', '参数工作台 · 下载时间估算', [
14
+ copy('assumption', '理想持续速度估算,不含协议开销、限速与波动。', 'warning'),
15
+ card('parameters', '输入参数', [{ id: 'size', type: 'input', label: '文件大小 GiB', bind: 'state.size' }, { id: 'speed', type: 'slider', label: '持续速度 MiB/s', bind: 'state.speed' }]),
16
+ { id: 'time', type: 'metric', label: '理论用时', value: { ref: 'derived.minutes' }, unit: '分钟', decimals: 1, icon: 'gauge' },
17
+ { id: 'detail', type: 'details', title: '计算方式', children: [copy('formula', '分钟 = GiB × 1024 ÷ MiB/s ÷ 60。速度需使用实际持续传输速度。')] }
18
+ ], { state: { size: { type: 'number', default: 24, min: 1, max: 500, step: 1 }, speed: { type: 'number', default: 80, min: 5, max: 200, step: 5 } }, derived: { minutes: { op: 'div', args: [{ op: 'mul', args: [{ ref: 'state.size' }, { const: 1024 }] }, { op: 'mul', args: [{ ref: 'state.speed' }, { const: 60 }] }] } } }),
19
+ example('comparison', 'violet', '方案对比 · 存储取舍示例', [
20
+ copy('note', '以下为假设场景;应结合实际读写、价格和数据重要性决策。', 'warning'),
21
+ { id: 'plans', type: 'row', children: [
22
+ card('local', '本地方案', [{ id: 'localTag', type: 'badge', text: '优先低延迟', tone: 'accent', icon: 'cpu' }, metric('capacityA', '示例可用容量', 2, 'TiB'), copy('localCopy', '直接访问、延迟低。需要自行安排异地备份。')]),
23
+ card('hybrid', '混合方案', [{ id: 'hybridTag', type: 'badge', text: '优先异地副本', tone: 'accent', icon: 'layers' }, metric('capacityB', '示例可用容量', 2, 'TiB'), copy('hybridCopy', '本地工作副本与远端备份。需考虑带宽和持续费用。')], 'featured')
24
+ ] },
25
+ copy('decision', '强调卡片表示当前讨论重点,不自动代表最佳或最便宜的方案。')
26
+ ]),
27
+ example('guide', 'amber', '步骤指南 · 开发环境准备', [
28
+ { id: 'tag', type: 'badge', text: '操作清单示例', tone: 'neutral', icon: 'target' },
29
+ copy('note', '步骤状态仅用于演示;插件不会自动执行这些操作。'),
30
+ { id: 'steps', type: 'steps', items: [
31
+ { title: '确认版本', description: '记录宿主版本、插件版本和所用配置目录。', status: 'pending' },
32
+ { title: '准备隔离环境', description: '备份配置,使用独立开发会话和演示数据。', status: 'pending' },
33
+ { title: '构建并验收', description: '验证交互、刷新恢复、深浅主题和窄屏布局。', status: 'pending' }
34
+ ] },
35
+ { id: 'details', type: 'details', title: '验收时记录什么', children: [copy('criteria', '记录明确的输入、可观察结果、错误以及尚未验证的范围。')] }
36
+ ]),
37
+ example('status', 'teal', '状态面板 · 批次统计示例', [
38
+ { id: 'tag', type: 'badge', text: '演示快照 · 非实时任务', tone: 'warning', icon: 'info' },
39
+ { id: 'counts', type: 'row', children: [metric('done', '示例已处理', 72, '项', 'success'), metric('remaining', '示例待处理', 28, '项')] },
40
+ { id: 'progress', type: 'progress', label: '示例处理进度', value: 72, max: 100, unit: '项', tone: 'accent' },
41
+ { id: 'steps', type: 'steps', items: [{ title: '读取示例清单', status: 'done' }, { title: '处理示例批次', description: '当前快照停留在这一阶段。', status: 'active' }, { title: '汇总结果', status: 'pending' }] }
42
+ ]),
43
+ example('report', 'rose', '阅读报告 · 评估结论组织', [
44
+ { id: 'tag', type: 'badge', text: '报告结构示例', tone: 'neutral', icon: 'info' },
45
+ copy('lead', '先写结论及适用条件,再展示支持它的数据。将事实、推测与待验证项分开陈述,方便读者判断结论的边界。'),
46
+ card('findings', '主要发现', [copy('findingText', '本示例展示信息层级,不包含真实模型评估结论。正式报告应说明样本、设置与测量方式。')]),
47
+ card('limits', '适用范围', [copy('limitText', '单个样例无法代表整体能力;速度与质量结果应关联具体配置。')]),
48
+ { id: 'appendix', type: 'details', title: '方法与复现信息', children: [copy('method', '记录版本、提示词、采样参数、样本来源和失败案例,提供可复核证据。')] }
49
+ ])
50
+ ];
package/lib/store.js ADDED
@@ -0,0 +1,176 @@
1
+ import { mkdir, readFile, writeFile, rename, unlink, readdir } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { validateDocument, validateState, initialState } from './protocol.js';
5
+ import { refineDocument, validateAudit } from './refinement.js';
6
+ export class UiStoreError extends Error {
7
+ constructor(status, message) { super(message); this.status = status; }
8
+ }
9
+ const idPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
10
+ export class UiStore {
11
+ constructor(directory) { this.directory = directory; this.locks = new Map(); }
12
+ documentPath(id) { if (!idPattern.test(id)) throw new UiStoreError(400, 'Invalid UI document ID'); return join(this.directory, `${id}.json`); }
13
+ statePath(id, sessionId) {
14
+ this.documentPath(id);
15
+ if (typeof sessionId !== 'string' || !sessionId.trim() || sessionId.length > 256) throw new UiStoreError(400, 'Invalid session ID');
16
+ const hash = createHash('sha256').update(id + '\0' + sessionId).digest('hex');
17
+ return join(this.directory, `state-${hash}.json`);
18
+ }
19
+ async atomic(path, value) {
20
+ await mkdir(dirname(path), { recursive: true }); const temp = `${path}.${randomUUID()}.tmp`;
21
+ try { await writeFile(temp, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 }); await rename(temp, path); }
22
+ finally { await unlink(temp).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
23
+ }
24
+ async create(document, context) {
25
+ const record = { version: 1, documentRevision: 0, documentId: randomUUID(), sessionId: context.sessionId, callId: context.callId, document: validateDocument(document), createdAt:new Date().toISOString() };
26
+ await this.atomic(this.documentPath(record.documentId), record); return record;
27
+ }
28
+ async readDocument(id) {
29
+ try {
30
+ const record = JSON.parse(await readFile(this.documentPath(id), 'utf8'));
31
+ if (record.version !== 1 || record.documentId !== id || (record.documentRevision!==undefined&&(!Number.isSafeInteger(record.documentRevision)||record.documentRevision<0))) throw new Error('Invalid saved UI record');
32
+ record.document = validateDocument(record.document); return record;
33
+ } catch (e) { if (e.code === 'ENOENT') throw new UiStoreError(404, 'UI document is no longer stored'); throw e; }
34
+ }
35
+ async readState(id, sessionId) {
36
+ const path = this.statePath(id, sessionId), record = await this.readDocument(id);
37
+ try {
38
+ const saved = JSON.parse(await readFile(path, 'utf8'));
39
+ if (saved.version !== 1 || !Number.isSafeInteger(saved.revision) || saved.revision < 1) throw new Error('Invalid saved UI state');
40
+ return { version: 1, revision: saved.revision, state: validateState(record.document, saved.state) };
41
+ } catch (e) { if (e.code !== 'ENOENT') throw e; return { version: 1, revision: 0, state: initialState(record.document) }; }
42
+ }
43
+ async owned(id,sessionId){const record=await this.readDocument(id);if(record.sessionId!==sessionId)throw new UiStoreError(403,'UI belongs to another conversation');return record;}
44
+ historyPath(id, revision) {
45
+ this.documentPath(id);
46
+ if (!Number.isSafeInteger(revision) || revision < 0) throw new UiStoreError(400, 'Invalid history revision');
47
+ return join(this.directory, 'history', id, `${revision}.json`);
48
+ }
49
+ async archive(record) {
50
+ const path = this.historyPath(record.documentId, record.documentRevision ?? 0);
51
+ await mkdir(dirname(path), { recursive: true });
52
+ // The current head is itself the newest version. Archive it before replacing
53
+ // the head, so a failed write cannot create a fictitious future revision.
54
+ const temp = `${path}.${randomUUID()}.tmp`;
55
+ try {
56
+ await writeFile(temp, JSON.stringify(record), { encoding: 'utf8', mode: 0o600 });
57
+ const { link } = await import('node:fs/promises');
58
+ try { await link(temp, path); } catch (e) {
59
+ if (e.code !== 'EEXIST') throw e;
60
+ if (await readFile(path, 'utf8') !== JSON.stringify(record)) throw new UiStoreError(500, 'History record differs from saved version');
61
+ }
62
+ } finally { await unlink(temp).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
63
+ }
64
+ async history(id, sessionId) {
65
+ const head = await this.owned(id, sessionId), revision = head.documentRevision ?? 0;
66
+ let names = []; try { names = await readdir(dirname(this.historyPath(id, 0))); } catch (e) { if (e.code !== 'ENOENT') throw e; }
67
+ const revisions = names.filter(n => /^(0|[1-9]\d*)\.json$/.test(n)).map(n => Number(n.slice(0, -5))).filter(n => n < revision);
68
+ const records = await Promise.all(revisions.sort((a,b) => b-a).slice(0,199).map(n => this.readVersion(id,sessionId,n,head)));
69
+ return { documentId:id, documentRevision:revision, truncated:revisions.length>199, versions:[head,...records].map(r=>({revision:r.documentRevision??0,title:r.document.title,createdAt:r.createdAt??null,restoredFrom:r.restoredFrom??null})) };
70
+ }
71
+ async readVersion(id, sessionId, revision, knownHead) {
72
+ const head = knownHead ?? await this.owned(id, sessionId);
73
+ this.historyPath(id, revision);
74
+ if (revision === (head.documentRevision ?? 0)) return head;
75
+ if (revision > (head.documentRevision ?? 0)) throw new UiStoreError(404, 'History version not found');
76
+ try {
77
+ const record = JSON.parse(await readFile(this.historyPath(id, revision), 'utf8'));
78
+ if (record.documentId !== id || record.sessionId !== sessionId || record.version !== 1 || (record.documentRevision??0) !== revision) throw new Error('Invalid history record');
79
+ record.document = validateDocument(record.document); return record;
80
+ } catch (e) { if (e.code === 'ENOENT') throw new UiStoreError(404, 'History version not found'); throw e; }
81
+ }
82
+ async restore(id,sessionId,revision,targetRevision) {
83
+ const path=this.documentPath(id),previous=this.locks.get(path)??Promise.resolve();
84
+ const pending=previous.catch(()=>{}).then(async()=>{
85
+ const record=await this.owned(id,sessionId);
86
+ if (!Number.isSafeInteger(revision) || revision !== (record.documentRevision??0)) throw new UiStoreError(409,'Document changed; reload history before restoring');
87
+ const target=await this.readVersion(id,sessionId,targetRevision,record);
88
+ if (JSON.stringify(target.document.state)!==JSON.stringify(record.document.state)) throw new UiStoreError(400,'This version has incompatible input definitions');
89
+ const saved=await this.readState(id,sessionId);validateState(target.document,saved.state);
90
+ const next={...record,documentRevision:revision+1,document:target.document,restoredFrom:targetRevision,createdAt:new Date().toISOString()};
91
+ await this.archive(record);await this.atomic(path,next);return next;
92
+ });
93
+ this.locks.set(path,pending);try{return await pending}finally{if(this.locks.get(path)===pending)this.locks.delete(path)}
94
+ }
95
+ async refine(id,sessionId,revision,changes){
96
+ const path=this.documentPath(id),previous=this.locks.get(path)??Promise.resolve();
97
+ const pending=previous.catch(()=>{}).then(async()=>{const record=await this.owned(id,sessionId);if(!Number.isSafeInteger(revision)||revision<0)throw new UiStoreError(400,'Invalid document revision');if((record.documentRevision??0)!==revision)throw new UiStoreError(409,'Document changed; inspect_ui before retrying');const next={...record,documentRevision:revision+1,document:refineDocument(record.document,changes),createdAt:new Date().toISOString()};delete next.restoredFrom;await this.archive(record);await this.atomic(path,next);return next;});
98
+ this.locks.set(path,pending);try{return await pending}finally{if(this.locks.get(path)===pending)this.locks.delete(path)}
99
+ }
100
+ async saveAudit(id,sessionId,revision,value){
101
+ const record=await this.owned(id,sessionId);if((record.documentRevision??0)!==revision)throw new UiStoreError(409,'Audit is for an old document revision');
102
+ const report={...validateAudit(value),documentRevision:revision,observedAt:new Date().toISOString()};
103
+ // One file per viewport/theme avoids lost updates from simultaneous visual checks.
104
+ const bucket=report.width<600?'mobile':report.width<1000?'tablet':'desktop';await this.atomic(join(this.directory,`${id}-audit-${bucket}-${report.theme}.json`),report);return report;
105
+ }
106
+ async audits(id){
107
+ const {readdir}=await import('node:fs/promises');const record=await this.readDocument(id),names=(await readdir(this.directory)).filter(n=>n.startsWith(id+'-audit-')&&n.endsWith('.json'));
108
+ const reports=await Promise.all(names.slice(-24).map(async n=>JSON.parse(await readFile(join(this.directory,n),'utf8'))));
109
+ return reports.filter(r=>r.documentRevision===(record.documentRevision??0)).sort((a,b)=>b.observedAt.localeCompare(a.observedAt)).slice(0,6);
110
+ }
111
+ async saveState(id, sessionId, revision, state) {
112
+ const path = this.statePath(id, sessionId), previous = this.locks.get(path) ?? Promise.resolve();
113
+ const pending = previous.catch(() => {}).then(async () => {
114
+ if (!Number.isSafeInteger(revision) || revision < 0) throw new UiStoreError(400, 'Invalid state revision');
115
+ const record = await this.readDocument(id), checked = validateState(record.document, state);
116
+ const current = await this.readState(id, sessionId);
117
+ if (current.revision !== revision) throw new UiStoreError(409, 'State changed in another view. Reload before saving.');
118
+ const next = { version: 1, revision: revision + 1, state: checked }; await this.atomic(path, next); return next;
119
+ });
120
+ this.locks.set(path, pending);
121
+ try { return await pending; } finally { if (this.locks.get(path) === pending) this.locks.delete(path); }
122
+ }
123
+ }
124
+ export function historyRoute(store) {
125
+ return async request => {
126
+ try {
127
+ const url=new URL(request.url),id=url.searchParams.get('documentId'),sessionId=url.searchParams.get('sessionId');
128
+ let value;
129
+ if(request.method==='GET') value=url.searchParams.has('revision')?await store.readVersion(id,sessionId,Number(url.searchParams.get('revision'))):await store.history(id,sessionId);
130
+ else {
131
+ if(request.method!=='POST')throw new UiStoreError(405,'Method not allowed');
132
+ if(!request.headers.get('content-type')?.startsWith('application/json'))throw new UiStoreError(415,'JSON body required');
133
+ const text=await request.text();if(Buffer.byteLength(text)>1024)throw new UiStoreError(413,'History request too large');
134
+ let input;try{input=JSON.parse(text)}catch{throw new UiStoreError(400,'Invalid JSON body')}
135
+ if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['documentRevision','targetRevision'].includes(k)))throw new UiStoreError(400,'Invalid restore body');
136
+ value=await store.restore(id,sessionId,input.documentRevision,input.targetRevision);
137
+ }
138
+ return Response.json(value,{headers:{'Cache-Control':'no-store'}});
139
+ } catch(e) {const status=e.status??500;return Response.json({error:status===500?'Could not access UI history':e.message},{status,headers:{'Cache-Control':'no-store'}});}
140
+ };
141
+ }
142
+ export function documentRoute(store){
143
+ return async request=>{
144
+ try{
145
+ const url=new URL(request.url),id=url.searchParams.get('documentId'),sessionId=url.searchParams.get('sessionId');
146
+ if(request.method==='GET')return Response.json(await store.owned(id,sessionId),{headers:{'Cache-Control':'no-store'}});
147
+ if(request.method!=='POST')throw new UiStoreError(405,'Method not allowed');
148
+ if(!request.headers.get('content-type')?.startsWith('application/json'))throw new UiStoreError(415,'JSON body required');
149
+ const text=await request.text();if(Buffer.byteLength(text)>16384)throw new UiStoreError(413,'Audit too large');
150
+ let input;try{input=JSON.parse(text)}catch{throw new UiStoreError(400,'Invalid JSON body')}
151
+ if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['documentRevision','report'].includes(k)))throw new UiStoreError(400,'Invalid audit body');
152
+ return Response.json(await store.saveAudit(id,sessionId,input.documentRevision,input.report),{headers:{'Cache-Control':'no-store'}});
153
+ }catch(e){const status=e.status??(e.message?.startsWith('DSH UI:')?400:500);return Response.json({error:status===500?'Could not access document':e.message},{status});}
154
+ };
155
+ }
156
+ export function stateRoute(store) {
157
+ return async request => {
158
+ const response = (value, status = 200) => Response.json(value, { status, headers: { 'Cache-Control': 'no-store' } });
159
+ try {
160
+ if (request.method === 'GET') {
161
+ const url = new URL(request.url);
162
+ return response(await store.readState(url.searchParams.get('documentId'), url.searchParams.get('sessionId')));
163
+ }
164
+ if (request.method !== 'POST') return response({ error: 'Method not allowed' }, 405);
165
+ if (!request.headers.get('content-type')?.startsWith('application/json')) return response({ error: 'JSON body required' }, 415);
166
+ const raw = await request.text();
167
+ if (new TextEncoder().encode(raw).length > 65536) return response({ error: 'State body too large' }, 413);
168
+ let input; try { input = JSON.parse(raw); } catch { return response({ error: 'Invalid JSON body' }, 400); }
169
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(k => !['documentId', 'sessionId', 'revision', 'state'].includes(k))) return response({ error: 'Invalid state request' }, 400);
170
+ return response(await store.saveState(input.documentId, input.sessionId, input.revision, input.state));
171
+ } catch (e) {
172
+ const status = e.status ?? (e.message?.startsWith('DSH UI:') ? 400 : 500);
173
+ return response({ error: status === 500 ? 'Could not access saved UI state' : e.message }, status);
174
+ }
175
+ };
176
+ }
package/lib/stream.js ADDED
@@ -0,0 +1,87 @@
1
+ import { LIMITS, validateDocument } from './protocol.js';
2
+
3
+ // Parse complete JSON tokens only. Never finish a string, number or model expression.
4
+ // Partial containers retain completed members, but only the document wrapper and
5
+ // its top-level nodes array are allowed to use those partial values.
6
+ function parseToolArguments(raw) {
7
+ if (typeof raw !== 'string' || raw.length > LIMITS.bytes || new TextEncoder().encode(raw).length > LIMITS.bytes) return null;
8
+ let i = 0;
9
+ const ws = () => { while (/\s/.test(raw[i] ?? '') && i < raw.length) i++; };
10
+ function parse(depth = 0) {
11
+ if (depth > 48) throw Error('depth');
12
+ ws(); const start = i, c = raw[i];
13
+ if (c === '{' || c === '[') {
14
+ const array = c === '[', value = array ? [] : {}, members = new Map(), close = array ? ']' : '}'; i++; ws();
15
+ if (raw[i] === close) { i++; return { value, complete: true, members }; }
16
+ let count = 0;
17
+ while (i < raw.length) {
18
+ if (++count > 500) throw Error('items');
19
+ let k;
20
+ if (!array) {
21
+ const name = parse(depth + 1); if (!name.complete) return { value, complete: false, members };
22
+ if (typeof name.value !== 'string' || Object.hasOwn(value, name.value) || ['__proto__', 'constructor', 'prototype'].includes(name.value)) throw Error('key');
23
+ k = name.value; ws(); if (i === raw.length) return { value, complete: false, members };
24
+ if (raw[i++] !== ':') throw Error('colon');
25
+ }
26
+ const child = parse(depth + 1);
27
+ if (array) { if (child.complete) value.push(child.value); }
28
+ else { members.set(k, child); if (child.complete) value[k] = child.value; }
29
+ if (!child.complete) return { value, complete: false, members };
30
+ ws(); if (i === raw.length) return { value, complete: false, members };
31
+ const separator = raw[i++]; if (separator === close) return { value, complete: true, members };
32
+ if (separator !== ',') throw Error('separator');
33
+ ws(); if (raw[i] === close) throw Error('trailing comma');
34
+ }
35
+ return { value, complete: false, members };
36
+ }
37
+ if (c === '"') {
38
+ i++; let safe = i;
39
+ while (i < raw.length) {
40
+ const ch = raw[i++];
41
+ if (ch === '"') return { value: JSON.parse(raw.slice(start, i)), complete: true };
42
+ if (ch === '\\') {
43
+ if(i===raw.length)break;
44
+ const escaped=raw[i++];
45
+ if(escaped==='u'){if(raw.length-i<4){i=raw.length;break;}if(!/^[0-9a-f]{4}$/i.test(raw.slice(i,i+4)))throw Error('escape');i+=4;}
46
+ else if(!'"\\/bfnrt'.includes(escaped))throw Error('escape');
47
+ } else if(ch.charCodeAt(0)<32)throw Error('string');
48
+ safe=i;
49
+ }
50
+ return { value: JSON.parse(raw.slice(start,safe)+'"'), complete: false };
51
+ }
52
+ while (i < raw.length && !/[\s,}\]]/.test(raw[i])) i++;
53
+ const token = raw.slice(start, i);
54
+ if (i === raw.length) return { complete: false };
55
+ return { value: JSON.parse(token), complete: true };
56
+ }
57
+ try {
58
+ const root = parse(); ws(); if (i !== raw.length) return null; return root;
59
+ } catch { return null; }
60
+ }
61
+ export function previewDocument(raw) {
62
+ try {
63
+ const root=parseToolArguments(raw);if(!root)return null;
64
+ const document = root.members?.get('document');
65
+ if(document?.complete&&typeof document.value==='string'&&root.members.size===1)return validateDocument(JSON.parse(document.value));
66
+ if (!document?.members) return null;
67
+ if ([...root.members.keys()].some(k => k !== 'document')) return null;
68
+ const doc = { ...document.value }, nodes = document.members.get('nodes');
69
+ if (!nodes || !Array.isArray(nodes.value) || !nodes.value.length) return null;
70
+ doc.nodes = nodes.value;
71
+ // Only whole, valid top-level subtrees can appear. Forward references can
72
+ // wait for a later complete data/actions field; Host remains authoritative.
73
+ for (let n = doc.nodes.length; n > 0; n--) {
74
+ try { return validateDocument({ ...doc, nodes: doc.nodes.slice(0, n) }); } catch { /* wait for more input */ }
75
+ }
76
+ } catch { /* malformed and incomplete input never becomes executable UI */ }
77
+ return null;
78
+ }
79
+
80
+ // This description is used only by the inert preview, never as committed metadata.
81
+ export function previewCanvas(raw) {
82
+ const root=parseToolArguments(raw);if(!root?.members)return null;
83
+ const get=k=>root.members.get(k),html=get('html'),css=get('css');
84
+ if(!get('title')?.complete||!get('fallback')?.complete||typeof html?.value!=='string'||!html.value||!css?.complete||typeof css.value!=='string')return null;
85
+ if(html.value.length>49152||css.value.length>49152)return null;
86
+ return {id:'canvas-preview',title:String(get('title').value).slice(0,160),fallback:String(get('fallback').value).slice(0,2000),html:html.value,css:css.value,height:Number.isFinite(get('height')?.value)?Math.max(160,Math.min(1000,get('height').value)):420};
87
+ }
@@ -0,0 +1,7 @@
1
+ // DSH adapters may represent `type: json` fields as JSON strings.
2
+ // Decode only the tool field, once; strict protocol validation still applies.
3
+ export function toolJson(value, field, maxBytes=131072){
4
+ if(typeof value!=='string')return value;
5
+ if(Buffer.byteLength(value,'utf8')>maxBytes)throw new Error(`DSH UI: ${field} JSON exceeds size limit`);
6
+ try{return JSON.parse(value)}catch{throw new Error(`DSH UI: ${field} must contain valid JSON. Fix quoting/escaping. For a standalone HTML/SVG canvas, render_canvas accepts separate html/css/script fields.`)}
7
+ }
@@ -0,0 +1,14 @@
1
+ // Shared behavior, not a visual template: free mode keeps the same intent policy.
2
+ export const UI_INTENT_POLICY = `## 表达选择与首次交付
3
+ 简单公式可用 {expr:"state.V * state.V / state.R"},无需堆叠 op/args。支持显式 state/data/derived/item 引用、四则运算、比较、三元条件及 min/max/round/abs;编译后仍按原协议校验。函数、语句和其他 JavaScript 不受支持,复杂动画使用画布。
4
+ 无需等待用户说“UI”“可视化”或指定工具。先用一个简短判断选择表达方式,然后直接回答或提交界面,不向用户复述设计推理:
5
+ - 用户明确要求交互界面、图解或动画:交付对应 UI;已要求纯文字、不要界面、只给代码时遵从用户。
6
+ - 用户没提 UI,但问题的关键是参数变化如何影响结果、多个方案的权衡、时间趋势、空间结构或流程关系:当图形或操作比文字明显更易懂时主动使用 UI。不能只因出现数字、多个条目或技术名词就触发。
7
+ - 简单事实、单次数值换算、短定义、普通代码排错:直接文字。一个静态小表格足够时不必创建交互应用。不确定有无收益时选更简单的表达,不询问“要不要我做个 UI”。
8
+ 对照:问“复利是什么,简短解释”→文字;问“5万元按3%、5%、7%存10年,为什么后期差距更大”→可主动给增长曲线与年数调整,配准确数值和简短解释;问“解释七速自行车各档如何影响踏频和行进”→可主动给传动示意与档位选择;问“1 GiB是多少字节”→文字。例子只指导时机,不规定构图或配色。
9
+ 首次交付只解决一个核心关系。立即选择最小完整方案并调用工具,不穷举设计选项,不先写长篇视觉规划,不等待“完美”再提交。默认一幅主图、1至3个必要控件、清楚的初始结果与说明;用户的必要要求不能省略。可以少而精,不能用漂亮静态壳冒充可交互成品。
10
+ 仅数值试算优先原生组件;计算超出 derived 白名单(例如幂、三角函数)时不要臆造算子或堆复杂表达式,改用小画布中的单一计算函数同步图与数值。结构或运动讲解选择一块自由画布,直接使用 render_canvas 的独立源码字段。避免首次创建多页应用、自制组件库或庞大绘图引擎。除非用户或宿主另有要求,对话内的简短交互讲解无需建立项目、编辑文件或制定任务清单。工具提交前检查绑定、初始图可见、改变参数有反馈、标签/单位和 fallback;不为简单任务反复读全部目录。
11
+ 必要的布局与排版从首次提交就做好:DSH 以 PC 横屏为主要使用场景,优先保证 1365px/1920px 桌面视口中实际聊天区域的构图与可读性,不假设画布等于整个浏览器宽度。主体突出、文字可读、控件有标签,装饰不挤占内容;手机适配是补充,不为窄屏牺牲桌面主体。Canvas 2D 按实际 CSS 像素布局,不整体缩放到文字难辨;动态数值和关键标签可放 HTML,绘图高度匹配内容,避免大块空白。
12
+ 首屏应能看见主要关系、关键操作及当前结果。用户问“为什么变化不同”,优先用能对照变化的图形;发光、粒子或装饰不能代替趋势或关系证据。主图紧邻控件,推导、完整表格和次要说明随后呈现;同一数值不在指标、图注、卡片和正文反复展示。不要把所有可用组件都用一遍,也不为减少滚动而缩小字。比较不同单位的量,分别标轴或明确使用相对同一基准的无量纲倍数,不能把原始数值放在同一未说明尺度上暗示快慢。这些是信息优先级要求,具体图形、构图与风格仍按问题自行设计。
13
+ 不把“最小”理解成粗糙或缺功能。成功后简短说明如何操作和假设;针对实际问题再局部修改。主动增强最多一次且确有价值,自动纠错仍最多两轮。无法交付时提供有效解释并如实说明,不声称已展示。
14
+ 这是一项行为要求,不能保证模型推理时长;宿主没有提供真实计时信息时不要虚报交付秒数。`;
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { PROTOCOL_GUIDE } from './catalog.js';
3
+ import { CANVAS_DESIGN_GUIDE } from './canvas-design.js';
4
+ import { FREE_UI_CONTRACT } from './freeform.js';
5
+ import { UI_INTENT_POLICY } from './ui-intent.js';
6
+ import { CANVAS_SYSTEM_GUIDE } from './canvas-system.js';
7
+
8
+ // Authored examples are deliberately small enough to be present in every model's context.
9
+ export const SKILL_EXAMPLES = [
10
+ {
11
+ version: 1, title: '示例 · 数量与费用', fallback: '演示:单价 12 元,数量默认 2,总额 24 元。不是报价。',
12
+ presentation: { layout: 'workbench', accent: 'teal', density: 'comfortable' },
13
+ state: { quantity: { type: 'number', default: 2, min: 1, max: 10, step: 1 } },
14
+ derived: { total: { op: 'mul', args: [{ ref: 'state.quantity' }, { const: 12 }] } },
15
+ nodes: [
16
+ { id: 'quantity', type: 'slider', label: '数量(件)', bind: 'state.quantity' },
17
+ { id: 'total', type: 'metric', label: '演示总额', value: { ref: 'derived.total' }, unit: '元', decimals: 0 },
18
+ { id: 'note', type: 'text', text: '示例单价,非实际报价。' }
19
+ ]
20
+ },
21
+ {
22
+ version: 1, title: '示例 · 周期运动', fallback: '一个圆点沿横向做周期运动。速度可调,运行可暂停;数学动画示意,非真实物理仿真。',
23
+ presentation: { layout: 'workbench', accent: 'blue' },
24
+ state: { speed: { type: 'number', default: 1, min: 1, max: 3, step: 1 }, running: { type: 'boolean', default: false } },
25
+ nodes: [
26
+ { id: 'motion', type: 'sandbox', title: '周期运动示意', height: 220, stateKeys: ['speed', 'running'], fallback: '圆点沿直线往返。运行开关控制暂停,速度从 1 到 3。',
27
+ html: '<svg viewBox="0 0 360 140" role="img" aria-label="圆点沿直线往返"><path d="M40 70H320" stroke="var(--canvas-line)"/><circle id="dot" cx="180" cy="70" r="16" fill="var(--canvas-accent)"/></svg><p id="caption"></p>',
28
+ css: 'svg{display:block;width:100%;height:150px}p{margin:8px 12px;font-size:14px;color:var(--canvas-ink)}',
29
+ script: "let state={speed:1,running:false},phase=0;const dot=document.getElementById('dot'),caption=document.getElementById('caption');const unsubscribe=DSHUI.subscribe(s=>{state=s.state;caption.textContent=(state.running?'运行':'暂停')+' · 速度 '+state.speed+' · 数学示意,非物理仿真'});const stop=DSHUI.animate(({delta,reducedMotion,visible})=>{if(state.running&&!reducedMotion&&visible)phase+=delta*state.speed;dot.setAttribute('cx',String(180+120*Math.sin(phase)))});addEventListener('pagehide',()=>{stop();unsubscribe()},{once:true});"
30
+ },
31
+ { id: 'speed', type: 'slider', label: '演示速度(倍)', bind: 'state.speed' },
32
+ { id: 'running', type: 'toggle', label: '运行(关闭后保持当前位置)', bind: 'state.running' }
33
+ ]
34
+ }
35
+ ];
36
+
37
+ const skill = readFileSync(new URL('../skills/intelligent-ui/SKILL.md', import.meta.url), 'utf8');
38
+ export const UI_SKILL_SECTIONS = Object.freeze([
39
+ { name: 'generative-ui:design-skill', order: 850, interpolate: false, text: UI_INTENT_POLICY + '\n\n' + skill + '\n\nCanvas composition details:\n' + CANVAS_DESIGN_GUIDE + '\n\nConstruction handbook:\n' + CANVAS_SYSTEM_GUIDE },
40
+ { name: 'generative-ui:protocol', order: 851, interpolate: false, text: PROTOCOL_GUIDE },
41
+ { name: 'generative-ui:worked-examples', order: 852, interpolate: false, text: 'These two valid render_ui document examples teach composition and exact syntax. Adapt the idea to the user; do not copy their demo claims as real data. Submit as render_ui({document: ...}).\n' + SKILL_EXAMPLES.map(doc => JSON.stringify(doc)).join('\n') }
42
+ ]);
43
+ export function promptSections(mode='guided'){
44
+ if(!['guided','free'].includes(mode))throw new Error('DSH UI: designMode must be guided or free');
45
+ return mode==='guided'?UI_SKILL_SECTIONS:UI_SKILL_SECTIONS.map((s,i)=>({...s,text:i===0?UI_INTENT_POLICY+'\n\n'+FREE_UI_CONTRACT:''}));
46
+ }
@@ -0,0 +1,18 @@
1
+ // Serialized into the opaque frame; it never reads host content.
2
+ export function measureVisual(root, nodeId='') {
3
+ const issues=[],seen=new Set(),coverage={nodeId,elements:0,truncated:false,contrastChecked:0,contrastUncovered:0};
4
+ const add=(code,value,el)=>{const id=el?.closest('[data-ui-node]')?.getAttribute('data-ui-node')??nodeId;const k=id+':'+code+':'+Math.round(value??0);if(!seen.has(k)&&issues.length<40){seen.add(k);issues.push({code,nodeId:id,...(value===undefined?{}:{value:Number(value.toFixed(2))})});}};
5
+ const color=s=>{let m=s.match(/^rgba?\(([^)]+)\)/);if(m){const a=m[1].split(/[\s,\/]+/).map(Number);return[a[0]/255,a[1]/255,a[2]/255,a[3]??1]}m=s.match(/^color\(srgb ([^)]+)\)/);if(m){const a=m[1].split(/[\s\/]+/).map(Number);return[a[0],a[1],a[2],a[3]??1]}return null;};
6
+ const luminance=c=>c.slice(0,3).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0);
7
+ const widthRoot=root.id==='canvas'?document.documentElement:root;if(widthRoot.scrollWidth>widthRoot.clientWidth+2)add('overflow',widthRoot.scrollWidth-widthRoot.clientWidth);
8
+ const walker=document.createTreeWalker(root,NodeFilter.SHOW_ELEMENT);let el;while((el=walker.nextNode())){if(coverage.elements>=2000){coverage.truncated=true;break}coverage.elements++;if(el.matches('[data-canvas-phase=error]'))add('runtimeError',undefined,el);
9
+ const css=getComputedStyle(el),box=el.getBoundingClientRect();if(!box.width||!box.height||css.visibility==='hidden'||css.display==='none')continue;
10
+ const text=[...el.childNodes].some(n=>n.nodeType===3&&n.textContent.trim());
11
+ if(text){let size=parseFloat(css.fontSize);if(el instanceof SVGElement&&el.getScreenCTM){const m=el.getScreenCTM();if(m)size*=Math.hypot(m.c,m.d)}if(size<11.9)add('smallText',size,el);
12
+ const fg=color(css.color);let ancestor=el,bg=null,depth=0;while(ancestor&&depth++<64){const style=getComputedStyle(ancestor);if(style.backgroundImage!=='none')break;const c=color(style.backgroundColor);if(c?.[3]===1){bg=c;break}if(c?.[3]>0)break;ancestor=ancestor.parentElement;}
13
+ if(!(el instanceof SVGElement)&&fg?.[3]===1&&bg){coverage.contrastChecked++;const a=luminance(fg),b=luminance(bg),ratio=(Math.max(a,b)+.05)/(Math.min(a,b)+.05);if(ratio<(size>=24||(size>=18.66&&parseFloat(css.fontWeight)>=700)?3:4.5))add('contrast',ratio,el);}else coverage.contrastUncovered++;
14
+ }
15
+ if(el.matches('input:not([type=hidden]),select,textarea,button,[role=button]')&&!el.getAttribute('aria-label')&&!el.getAttribute('aria-labelledby')&&!el.labels?.length&&!el.textContent.trim()&&!el.getAttribute('title'))add('missingLabel',undefined,el);
16
+ }
17
+ Object.defineProperty(issues,'coverage',{value:coverage});return issues;
18
+ }
@@ -0,0 +1,28 @@
1
+ export const architectureExample = {
2
+ version: 1, title: '一次 Agent 请求如何流转', presentation: { layout: 'report', accent: 'blue' },
3
+ fallback: '概念结构图:消息经过上下文整理送入模型;模型可以请求工具,工具结果再进入上下文。这是通用教学示意,不表示正在监控实际 DSH 请求。',
4
+ state: { focus: { type: 'string', default: 'all', options: ['all', 'request', 'context', 'model', 'tools'] } },
5
+ nodes: [{ id: 'scope', type: 'text', text: '从一条消息开始,观察上下文、模型和工具如何协作。概念示意,不是实时任务监控。' },
6
+ { id: 'architecture', type: 'sandbox', title: 'Agent 消息流向', height: 370, stateKeys: ['focus'],
7
+ fallback: '消息 → 上下文 → 模型 → 工具 → 上下文。参考资料也可进入上下文。选择某一环节查看职责;箭头表示概念数据流。',
8
+ html: `<div class="map"><div class="kicker">REQUEST / CONTEXT / ACTION</div><div class="diagram-scroll" tabindex="0" role="region" aria-label="结构图,窄屏可横向滚动"><svg viewBox="0 0 640 320" role="img" aria-labelledby="mapTitle mapDesc"><title id="mapTitle">消息、上下文、模型和工具之间的数据流</title><desc id="mapDesc">消息与参考资料送入上下文,上下文送入模型,模型请求工具,工具结果返回上下文。下方选择框提供相同的环节选择。</desc><defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0 0L8 4L0 8Z" fill="var(--canvas-muted)"/></marker></defs><g class="links" marker-end="url(#arrow)"><path d="M140 146H220"/><path d="M380 136H430V66H480"/><path d="M550 92V215"/><path d="M480 241H416V158H380"/><path d="M300 245V172"/></g><g class="labels"><text x="169" y="135">输入</text><text x="437" y="53">推理</text><text x="559" y="155">调用</text><text x="421" y="197">结果</text><text x="308" y="218">补充</text></g><g data-node="request" class="node" tabindex="0" role="button" aria-label="消息环节"><rect x="20" y="120" width="120" height="52" rx="8"/><text x="80" y="151">用户消息</text></g><g data-node="context" class="node" tabindex="0" role="button" aria-label="上下文环节"><rect x="220" y="120" width="160" height="52" rx="8"/><text x="300" y="151">上下文整理</text></g><g data-node="model" class="node" tabindex="0" role="button" aria-label="模型环节"><rect x="480" y="40" width="140" height="52" rx="8"/><text x="550" y="71">模型</text></g><g data-node="tools" class="node" tabindex="0" role="button" aria-label="工具环节"><rect x="480" y="215" width="140" height="52" rx="8"/><text x="550" y="246">受授权的工具</text></g><g class="source"><rect x="220" y="245" width="160" height="52" rx="8"/><text x="300" y="276">参考资料</text></g></svg></div><p id="description" aria-live="polite"></p></div>`,
9
+ css: '.map{padding:10px 0}.diagram-scroll{max-width:100%;overflow-x:auto}.diagram-scroll svg{min-width:580px}.kicker{font-size:10px;letter-spacing:1.5px;color:var(--canvas-muted)}svg{display:block;width:100%;margin-top:12px}.links{fill:none;stroke:var(--canvas-muted);stroke-width:1.4;stroke-linejoin:round}.labels{fill:var(--canvas-muted);font-size:11px}.node{cursor:pointer}.node rect,.source rect{fill:var(--canvas-soft);stroke:var(--canvas-line);stroke-width:1.5}.node text,.source text{fill:var(--canvas-ink);font-size:13px;text-anchor:middle}.source rect{stroke-dasharray:3 3}.source text{fill:var(--canvas-muted)}.node[data-selected=true] rect{stroke:var(--canvas-accent);stroke-width:2.5}.node:focus-visible{outline:none}.node:focus-visible rect{stroke:var(--canvas-accent);stroke-width:3}#description{font-size:13px;line-height:1.8;color:var(--canvas-muted);border-left:2px solid var(--canvas-accent);padding-left:12px;min-height:48px}@media(max-width:420px){.labels{font-size:12px}.node text,.source text{font-size:15px}}',
10
+ script: `const descriptions={all:'各环节通过上下文形成反馈循环;是否执行工具仍取决于宿主的权限流程。',request:'消息环节:表达问题、目标和约束。它是本次请求的起点。',context:'上下文环节:组织消息、相关资料和工具结果,为下一次模型推理提供依据。',model:'模型环节:依据上下文生成回答,或提出需要由宿主处理的工具请求。',tools:'工具环节:在宿主授权范围内执行操作,将结果送回上下文。图形不会自行执行工具。'};for(const node of document.querySelectorAll('[data-node]')){const choose=()=>DSHUI.setState({focus:node.dataset.node});node.addEventListener('click',choose);node.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();choose()}})}DSHUI.subscribe(s=>{for(const node of document.querySelectorAll('[data-node]')){const selected=s.state.focus===node.dataset.node;node.dataset.selected=String(selected);node.setAttribute('aria-pressed',String(selected))}document.getElementById('description').textContent=descriptions[s.state.focus]});`
11
+ }, { id: 'focus', type: 'select', label: '选择要了解的环节', bind: 'state.focus' }]
12
+ };
13
+
14
+ export const scatterExample = {
15
+ version: 1, title: '看清速度与内存的取舍', presentation: { layout: 'dashboard', accent: 'teal' },
16
+ fallback: '全部为演示数据,不能据此推荐真实模型。方案 A:12 GiB、35 tokens/s;B:20 GiB、60 tokens/s;C:28 GiB、45 tokens/s。倍率只做假设计算,内存固定。',
17
+ state: { multiplier: { type: 'number', default: 1, min: 1, max: 3, step: 0.25 } },
18
+ data: { samples: [{ name: 'A', memory: 12, speed: 35 }, { name: 'B', memory: 20, speed: 60 }, { name: 'C', memory: 28, speed: 45 }] },
19
+ derived: { series: { map: { source: 'data.samples', fields: { name: { ref: 'item.name' }, memory: { ref: 'item.memory' }, speed: { op: 'mul', args: [{ ref: 'item.speed' }, { ref: 'state.multiplier' }] } } } } },
20
+ nodes: [{ id: 'notice', type: 'text', tone: 'warning', text: '演示数据,不是模型实测。假设吞吐倍率不代表真实优化收益。' },
21
+ { id: 'scatter', type: 'sandbox', title: '内存与速度散点图', height: 330, derivedKeys: ['series'],
22
+ fallback: '横轴为演示内存 GiB,纵轴为演示速度 tokens/s。三个方案各一个点,右侧表格提供当前精确数值;倍率提高时速度按同一公式变化。',
23
+ html: '<div class="plot"><div class="caption"><strong>空间与速度</strong><span>假设数据 · 同一计算结果</span></div><div class="scatter-scroll" tabindex="0" role="region" aria-label="散点图,窄屏可横向滚动"><svg viewBox="0 0 600 300" role="img" aria-label="演示内存与速度散点图,当前精确值见下方表格"><g id="axes"></g><g id="points"></g><text class="axis-label" x="330" y="292" text-anchor="middle">演示内存 / GiB</text><text class="axis-label" x="18" y="145" transform="rotate(-90 18 145)" text-anchor="middle">演示速度 / tokens/s</text></svg></div></div>',
24
+ css: '.plot{padding:12px 0}.scatter-scroll{max-width:100%;overflow-x:auto}.scatter-scroll svg{min-width:520px}.caption{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap}.caption strong{font-size:16px}.caption span{font-size:11px;color:var(--canvas-muted)}svg{display:block;width:100%;margin-top:8px}.grid{stroke:var(--canvas-line);stroke-dasharray:3 5}.tick,.axis-label{fill:var(--canvas-muted);font-size:12px}.value{fill:var(--canvas-ink);font-size:13px;font-variant-numeric:tabular-nums}.dot{fill:var(--canvas-accent);stroke:var(--canvas-bg);stroke-width:2}',
25
+ script: `const NS='http://www.w3.org/2000/svg',axes=document.getElementById('axes'),points=document.getElementById('points');const add=(tag,attrs,text,parent)=>{const el=document.createElementNS(NS,tag);for(const[k,v]of Object.entries(attrs))el.setAttribute(k,v);if(text!==undefined)el.textContent=text;parent.append(el);return el};for(const value of[0,60,120,180]){const y=250-value/180*216;add('line',{x1:65,x2:570,y1:y,y2:y,class:'grid'},undefined,axes);add('text',{x:54,y:y+4,'text-anchor':'end',class:'tick'},value,axes)}for(const value of[0,8,16,24,32])add('text',{x:65+value/32*505,y:272,'text-anchor':'middle',class:'tick'},value,axes);DSHUI.subscribe(s=>{points.replaceChildren();for(const row of s.derived.series){const x=65+row.memory/32*505,y=250-row.speed/180*216;const circle=add('circle',{cx:x,cy:y,r:7,class:'dot'},undefined,points);add('title',{},row.name+': '+row.memory+' GiB, '+row.speed+' tokens/s',circle);add('text',{x:x+11,y:y-11,class:'value'},row.name+' · '+row.speed,points)}});`
26
+ }, { id: 'multiplier', type: 'slider', label: '假设吞吐倍率(内存固定)', bind: 'state.multiplier' },
27
+ { id: 'values', type: 'table', rows: { ref: 'derived.series' }, columns: [{ key: 'name', label: '演示方案' }, { key: 'memory', label: '演示内存', unit: 'GiB' }, { key: 'speed', label: '假设速度', unit: 'tokens/s' }] }]
28
+ };