dsh-generative-ui 0.0.0 → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +18568 -0
- package/lib/client.js.map +62 -0
- package/lib/index.js +1597 -0
- package/lib/types/client/canvas/CanvasLauncher.d.ts +6 -0
- package/lib/types/client/canvas/CanvasPanel.d.ts +88 -0
- package/lib/types/client/canvas/collect.d.ts +45 -0
- package/lib/types/client/canvas/index.d.ts +43 -0
- package/lib/types/client/canvas/mount.d.ts +30 -0
- package/lib/types/client/canvas/panel-css.d.ts +1 -0
- package/lib/types/client/canvas/read.d.ts +12 -0
- package/lib/types/client/canvas/subpages.d.ts +20 -0
- package/lib/types/client/canvas/useDismissable.d.ts +15 -0
- package/lib/types/client/index.d.ts +20 -0
- package/lib/types/client/runtime/GenUISurface.d.ts +159 -0
- package/lib/types/client/runtime/bindings.d.ts +143 -0
- package/lib/types/client/runtime/compiler.d.ts +35 -0
- package/lib/types/client/runtime/inline-fence.d.ts +23 -0
- package/lib/types/client/runtime/observe.d.ts +30 -0
- package/lib/types/client/runtime/register.d.ts +2 -0
- package/lib/types/client/runtime/registry.d.ts +7 -0
- package/lib/types/client/runtime/report-error.d.ts +17 -0
- package/lib/types/client/runtime/segments.d.ts +18 -0
- package/lib/types/client/runtime/state.d.ts +18 -0
- package/lib/types/client/runtime/uno-config.d.ts +16 -0
- package/lib/types/client/runtime/uno.d.ts +50 -0
- package/lib/types/client/session.d.ts +26 -0
- package/lib/types/contract-assets.d.ts +41 -0
- package/lib/types/contract.d.ts +56 -0
- package/lib/types/index.d.ts +255 -0
- package/lib/types/prompt.d.ts +13 -0
- package/lib/types/skill.d.ts +27 -0
- package/package.json +135 -9
- package/src/client/canvas/CanvasLauncher.tsx +52 -0
- package/src/client/canvas/CanvasPanel.tsx +238 -0
- package/src/client/canvas/collect.ts +188 -0
- package/src/client/canvas/index.ts +255 -0
- package/src/client/canvas/mount.ts +91 -0
- package/src/client/canvas/panel-css.ts +2 -0
- package/src/client/canvas/panel.css +242 -0
- package/src/client/canvas/read.ts +55 -0
- package/src/client/canvas/subpages.ts +109 -0
- package/src/client/canvas/useDismissable.ts +37 -0
- package/src/client/index.ts +217 -0
- package/src/client/runtime/GenUISurface.tsx +359 -0
- package/src/client/runtime/bindings.ts +292 -0
- package/src/client/runtime/compiler.ts +80 -0
- package/src/client/runtime/inline-fence.ts +222 -0
- package/src/client/runtime/observe.ts +65 -0
- package/src/client/runtime/register.ts +57 -0
- package/src/client/runtime/registry.ts +65 -0
- package/src/client/runtime/report-error.ts +79 -0
- package/src/client/runtime/segments.ts +116 -0
- package/src/client/runtime/state.ts +47 -0
- package/src/client/runtime/uno-config.ts +71 -0
- package/src/client/runtime/uno.ts +124 -0
- package/src/client/session.ts +46 -0
- package/src/contract-assets.ts +46 -0
- package/src/contract.ts +111 -0
- package/src/index.ts +583 -0
- package/src/prompt.ts +377 -0
- package/src/skill.ts +931 -0
- package/types/README.md +34 -0
- package/types/ai.d.ts +14 -0
- package/types/chat.d.ts +14 -0
- package/types/check.ts +39 -0
- package/types/exec.d.ts +17 -0
- package/types/fs.d.ts +17 -0
- package/types/importmap.json +10 -0
- package/types/standalone/ai.js +7 -0
- package/types/standalone/chat.js +6 -0
- package/types/standalone/exec.js +7 -0
- package/types/standalone/fs.js +18 -0
- package/types/standalone/importmap.json +10 -0
- package/types/standalone/state.js +24 -0
- package/types/standalone/web.js +7 -0
- package/types/state.d.ts +25 -0
- package/types/web.d.ts +31 -0
- package/index.js +0 -1
package/package.json
CHANGED
|
@@ -1,11 +1,137 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-generative-ui",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Generative UI for DeepSeek Harness — the agent writes TSX, dsh web renders it live, inline in chat and in a canvas panel",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./lib/types/client/index.d.ts",
|
|
15
|
+
"default": "./lib/client.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib",
|
|
21
|
+
"src",
|
|
22
|
+
"types",
|
|
23
|
+
"cordis.patch.yml"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"generative-ui",
|
|
27
|
+
"genui",
|
|
28
|
+
"ui4a",
|
|
29
|
+
"artifacts",
|
|
30
|
+
"canvas",
|
|
31
|
+
"dsh",
|
|
32
|
+
"dsh-plugin",
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"cordis",
|
|
35
|
+
"cordis-plugin",
|
|
36
|
+
"react",
|
|
37
|
+
"tsx",
|
|
38
|
+
"streaming",
|
|
39
|
+
"ai-agents",
|
|
40
|
+
"llm"
|
|
41
|
+
],
|
|
42
|
+
"dsh": {
|
|
43
|
+
"bundle": {
|
|
44
|
+
"patch": "./cordis.patch.yml"
|
|
45
|
+
},
|
|
46
|
+
"client": {
|
|
47
|
+
"inject": [
|
|
48
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
49
|
+
"@deepseek-ai/dsh-client-locale",
|
|
50
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
51
|
+
],
|
|
52
|
+
"platform": "web"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "bun scripts/gen-standalone.ts && bun scripts/build.ts && tsc -p tsconfig.types.json",
|
|
57
|
+
"prepare": "bun scripts/gen-standalone.ts && bun scripts/build.ts && tsc -p tsconfig.types.json",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"test": "bun test",
|
|
60
|
+
"loads": "sh scripts/loads.sh",
|
|
61
|
+
"smoke": "bun scripts/smoke.ts",
|
|
62
|
+
"check": "bun run lint && bun run typecheck && bun run test && bun run test:shuffled && bun run audit && bun run replay && bun run cards && bun run paint && bun run build && bun run smoke && bun run loads",
|
|
63
|
+
"check:ci": "bun run lint && bun run typecheck && bun run test && bun run test:shuffled && bun run audit && bun run replay && bun run cards && bun run paint && bun run build && bun run smoke",
|
|
64
|
+
"lint": "oxlint",
|
|
65
|
+
"fmt": "oxfmt",
|
|
66
|
+
"gen:standalone": "bun scripts/gen-standalone.ts",
|
|
67
|
+
"audit": "bun scripts/fresh-rates.ts 2>/dev/null | head -1 > /tmp/fresh-total.txt; python3 scripts/audit-record.py CLAUDE.md && bun scripts/corpus-rates.ts 2>/dev/null | python3 scripts/audit-rates.py",
|
|
68
|
+
"replay": "bun scripts/replay-stream.ts",
|
|
69
|
+
"cards": "bun scripts/compile-cards.ts",
|
|
70
|
+
"paint": "bun scripts/paint-cards.ts",
|
|
71
|
+
"cross-tab": "bun scripts/cross-tab.ts",
|
|
72
|
+
"inject": "bun scripts/inject.ts",
|
|
73
|
+
"hollow": "bun scripts/hollow.ts",
|
|
74
|
+
"test:shuffled": "sh scripts/test-shuffled.sh"
|
|
75
|
+
},
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"@esm.sh/tsx": "^1.5.3",
|
|
78
|
+
"@unocss/core": "^66.8.1",
|
|
79
|
+
"@unocss/preset-wind4": "^66.8.1",
|
|
80
|
+
"partial-react": "~0.0.6",
|
|
81
|
+
"partial-tsx": "~0.0.3"
|
|
82
|
+
},
|
|
83
|
+
"peerDependencies": {
|
|
84
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
85
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
86
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
87
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.7",
|
|
88
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
89
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
|
|
90
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
|
|
91
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
92
|
+
"@deepseek-ai/dsh-skill": "^0.1.0-rc.7",
|
|
93
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
94
|
+
"react": "^18.2.0"
|
|
95
|
+
},
|
|
96
|
+
"devDependencies": {
|
|
97
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
98
|
+
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
|
|
99
|
+
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
|
|
100
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.8",
|
|
101
|
+
"@deepseek-ai/dsh-client-ui-layout": "0.1.0-rc.8",
|
|
102
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.8",
|
|
103
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
|
|
104
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
|
|
105
|
+
"@deepseek-ai/dsh-settings": "0.1.0-rc.8",
|
|
106
|
+
"@deepseek-ai/dsh-skill": "0.1.0-rc.8",
|
|
107
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.0-rc.8",
|
|
108
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
109
|
+
"@types/node": "^26.3.0",
|
|
110
|
+
"@types/react": "~18.3.31",
|
|
111
|
+
"@types/react-dom": "~18.3.7",
|
|
112
|
+
"micromatch": "^4.0.8",
|
|
113
|
+
"minimatch": "^10.2.6",
|
|
114
|
+
"motion": "^13.1.1",
|
|
115
|
+
"oxfmt": "^0.64.0",
|
|
116
|
+
"oxlint": "^1.80.0",
|
|
117
|
+
"picomatch": "^4.0.7",
|
|
118
|
+
"pkg-pr-new": "^0.0.88",
|
|
119
|
+
"react": "^18.3.1",
|
|
120
|
+
"react-dom": "^18.3.1",
|
|
121
|
+
"typescript": "^7.0.2"
|
|
122
|
+
},
|
|
123
|
+
"peerDependenciesMeta": {
|
|
124
|
+
"@deepseek-ai/dsh-host-webserver": {
|
|
125
|
+
"optional": true
|
|
126
|
+
},
|
|
127
|
+
"@deepseek-ai/dsh-skill": {
|
|
128
|
+
"optional": true
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"repository": {
|
|
132
|
+
"type": "git",
|
|
133
|
+
"url": "git+https://github.com/CNSeniorious000/dsh-generative-ui.git"
|
|
134
|
+
},
|
|
135
|
+
"homepage": "https://github.com/CNSeniorious000/dsh-generative-ui#readme",
|
|
136
|
+
"bugs": "https://github.com/CNSeniorious000/dsh-generative-ui/issues"
|
|
137
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The way back after closing the panel.
|
|
3
|
+
*
|
|
4
|
+
* Closing a canvas hides it without deleting anything, and a canvas outlives the session
|
|
5
|
+
* that wrote it, so without an entry point the work is stranded: the file is still on
|
|
6
|
+
* disk, but nothing on screen reaches it and the only recovery is making the model write
|
|
7
|
+
* it again.
|
|
8
|
+
*/
|
|
9
|
+
import { useDismissable } from "./useDismissable.ts";
|
|
10
|
+
|
|
11
|
+
export type CanvasLauncherProps = {
|
|
12
|
+
/** Every canvas in the workspace, including ones this session never wrote. */
|
|
13
|
+
ids: readonly string[];
|
|
14
|
+
onOpen: (id: string) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function CanvasLauncher({ ids, onOpen }: CanvasLauncherProps) {
|
|
18
|
+
const { open, setOpen, anchor } = useDismissable();
|
|
19
|
+
// One canvas needs no menu — the button is the canvas.
|
|
20
|
+
const single = ids.length === 1 ? ids[0] : undefined;
|
|
21
|
+
const label = single === undefined ? `打开画布(${ids.length})` : `打开画布 ${single}`;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="dgu-launcher-dock" ref={anchor}>
|
|
25
|
+
{open && single === undefined && (
|
|
26
|
+
<ul className="dgu-launcher-menu">
|
|
27
|
+
{ids.map((id) => (
|
|
28
|
+
<li key={id}>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
className="dgu-launcher-item"
|
|
32
|
+
onClick={() => {
|
|
33
|
+
setOpen(false);
|
|
34
|
+
onOpen(id);
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{id}
|
|
38
|
+
</button>
|
|
39
|
+
</li>
|
|
40
|
+
))}
|
|
41
|
+
</ul>
|
|
42
|
+
)}
|
|
43
|
+
<button type="button" className="dgu-launcher" aria-label={label} title={label} aria-expanded={single === undefined ? open : undefined} onClick={() => (single === undefined ? setOpen(!open) : onOpen(single))}>
|
|
44
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
45
|
+
<rect x="1.75" y="2.25" width="10.5" height="9.5" rx="1.75" stroke="currentColor" strokeWidth="1.3" />
|
|
46
|
+
<path d="M8.5 2.5v9" stroke="currentColor" strokeWidth="1.3" />
|
|
47
|
+
</svg>
|
|
48
|
+
{ids.length > 1 && <span className="dgu-launcher-count">{ids.length}</span>}
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas column: one mini app per `ui4a/canvases/<id>.ui4a.tsx`, rendered beside the
|
|
3
|
+
* conversation rather than inside it.
|
|
4
|
+
*
|
|
5
|
+
* Composed rather than configured — the panel owns chrome and selection, `GenUISurface`
|
|
6
|
+
* owns rendering, and the caller owns where canvases come from. That is what lets the
|
|
7
|
+
* same surface back both this and the inline cards.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState } from "react";
|
|
10
|
+
import { compiler, GenUISurface } from "../runtime/GenUISurface.tsx";
|
|
11
|
+
import { canvasPath } from "../../contract.ts";
|
|
12
|
+
import { readCanvasChild } from "./read.ts";
|
|
13
|
+
import { importsSibling, inlineSubPages } from "./subpages.ts";
|
|
14
|
+
import { useDismissable } from "./useDismissable.ts";
|
|
15
|
+
|
|
16
|
+
export type Canvas = { id: string; code: string; streaming: boolean };
|
|
17
|
+
|
|
18
|
+
export type CanvasPanelProps = {
|
|
19
|
+
canvases: readonly Canvas[];
|
|
20
|
+
/** Every canvas in the workspace, so the header can reach ones this session never wrote. */
|
|
21
|
+
offerable: readonly string[];
|
|
22
|
+
/** The session's workspace, needed to fetch a canvas's sub-page files. */
|
|
23
|
+
cwd: string | undefined;
|
|
24
|
+
onOpen: (id: string) => void;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
/** Reports the panel's width so the host frame can reserve matching space. */
|
|
27
|
+
onWidth: (width: number) => void;
|
|
28
|
+
/** A canvas that failed to compile; see `runtime/report-error.ts`. */
|
|
29
|
+
onCardError?: (message: string, phase: string) => void;
|
|
30
|
+
/** A canvas that painted; see `runtime/report-error.ts`. */
|
|
31
|
+
onCardRendered?: () => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Rewrites a canvas's relative sub-page imports into blob URLs before it is compiled.
|
|
36
|
+
*
|
|
37
|
+
* Only for a settled canvas: mid-stream the sibling files are usually not written yet, and a
|
|
38
|
+
* canvas does not stream under the default PTC mode anyway (see CLAUDE.md §3.6). Until the
|
|
39
|
+
* rewrite lands the original source is rendered, which fails exactly as it does today rather
|
|
40
|
+
* than blanking a canvas that was working.
|
|
41
|
+
*/
|
|
42
|
+
export function useSubPages(cwd: string | undefined, canvas: Canvas | undefined) {
|
|
43
|
+
const [resolved, setResolved] = useState<{ key: string; code: string } | null>(null);
|
|
44
|
+
// Destructured so the effect depends on scalars: the store rebuilds `canvas` on every sweep,
|
|
45
|
+
// and depending on the object would re-run the whole resolve for an unchanged canvas.
|
|
46
|
+
const id = canvas?.id;
|
|
47
|
+
const code = canvas?.code;
|
|
48
|
+
const streaming = canvas?.streaming;
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (cwd === undefined || id === undefined || code === undefined) return;
|
|
52
|
+
if (!needsResolve(code, streaming)) return;
|
|
53
|
+
const key = `${id}:${code.length}`;
|
|
54
|
+
let live = true;
|
|
55
|
+
const urls: string[] = [];
|
|
56
|
+
const compile = async (filename: string, source: string) => (await compiler().compile(source, { filename })).code;
|
|
57
|
+
void inlineSubPages(code, canvasPath(id), (specifier, from) => readCanvasChild(cwd, id, specifier, from), compile, urls).then((next) => {
|
|
58
|
+
// Resolved after teardown: nobody will ever render these, so revoke them here — the
|
|
59
|
+
// disposer already ran and cannot see urls the pass appended after it.
|
|
60
|
+
if (!live) return void revokeAll(urls);
|
|
61
|
+
setResolved({ key, code: next });
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
live = false;
|
|
65
|
+
// The surface holds the previous code, so its blobs stay reachable until it re-renders;
|
|
66
|
+
// revoking on the next resolve rather than here would leak one set per edit.
|
|
67
|
+
revokeAll(urls);
|
|
68
|
+
};
|
|
69
|
+
}, [cwd, id, code, streaming]);
|
|
70
|
+
|
|
71
|
+
if (id === undefined || code === undefined) return "";
|
|
72
|
+
return resolved !== null && resolved.key === `${id}:${code.length}` ? resolved.code : code;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Revoke every blob URL in a list, exactly once each.
|
|
77
|
+
*
|
|
78
|
+
* Both callers hand it the SAME array — the disposer, and the resolve that lands after it. The
|
|
79
|
+
* array is filled by `inlineSubPages` as it goes, so the two can see different lengths, and a
|
|
80
|
+
* URL revoked twice is harmless while one revoked never is a leak per edit. Emptying the list
|
|
81
|
+
* as it goes makes the pair idempotent regardless of which runs first or what arrived between.
|
|
82
|
+
*/
|
|
83
|
+
/**
|
|
84
|
+
* Whether this canvas needs the sub-page pass at all. Extracted from the effect because the
|
|
85
|
+
* mutation audit could not constrain it there — flipping `streaming` or dropping the
|
|
86
|
+
* `importsSibling` check survived every test, and both are load-bearing: resolving mid-stream
|
|
87
|
+
* inlines a prefix that the next frame supersedes, and a canvas with no sibling imports would
|
|
88
|
+
* pay a compile per frame to produce the code it already had.
|
|
89
|
+
*
|
|
90
|
+
* The `undefined` checks stay in the effect: a type predicate narrows only one parameter, and
|
|
91
|
+
* moving them here would cost the compiler its knowledge that `cwd` and `id` are strings after.
|
|
92
|
+
*/
|
|
93
|
+
export const needsResolve = (code: string, streaming: boolean | undefined): boolean => !streaming && importsSibling(code);
|
|
94
|
+
|
|
95
|
+
export const revokeAll = (urls: string[]): void => {
|
|
96
|
+
while (urls.length > 0) URL.revokeObjectURL(urls.pop()!);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Cheap gate: only a canvas that actually writes a relative import pays for the pass. */
|
|
100
|
+
const MIN_WIDTH = 320;
|
|
101
|
+
const MAX_WIDTH = 720;
|
|
102
|
+
/** Exported for `test/panel-css.test.ts`: the default must be draggable-to. */
|
|
103
|
+
export { MIN_WIDTH as MIN_WIDTH_FOR_TEST, MAX_WIDTH as MAX_WIDTH_FOR_TEST };
|
|
104
|
+
/**
|
|
105
|
+
* Must equal the `--dgu-panel-width` fallback in `panel.css`.
|
|
106
|
+
*
|
|
107
|
+
* The CSS default is what the panel is painted at before React's inline style lands; this is
|
|
108
|
+
* what the resize state starts from. Different values mean the panel visibly jumps on its first
|
|
109
|
+
* frame — a `panel-css.test.ts` assertion holds them together, since a stylesheet cannot import
|
|
110
|
+
* a constant.
|
|
111
|
+
*/
|
|
112
|
+
export const DEFAULT_WIDTH = 420;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The panel's width for a pointer at `clientX`, clamped to what is usable.
|
|
116
|
+
*
|
|
117
|
+
* Extracted from the drag handler because it is the whole of the arithmetic and none of the
|
|
118
|
+
* DOM: a swapped bound or a flipped subtraction gives a panel that snaps shut or eats the
|
|
119
|
+
* conversation, and the drag itself cannot be exercised without a browser.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* Which canvas the panel shows, and which ids the "other canvases" menu offers.
|
|
123
|
+
*
|
|
124
|
+
* Both are pure functions of the props and both decide what the reader is looking at, so they
|
|
125
|
+
* are here rather than inline: the fallback is what covers a selected canvas disappearing
|
|
126
|
+
* mid-stream (no cleanup needed — clearing the state as well would be a second render saying
|
|
127
|
+
* the same thing), and `offerable` minus the tabs is what keeps the menu from listing what is
|
|
128
|
+
* already on screen.
|
|
129
|
+
*/
|
|
130
|
+
export const activeCanvas = (canvases: readonly Canvas[], activeId: string | null) => canvases.find((canvas) => canvas.id === activeId) ?? canvases[canvases.length - 1];
|
|
131
|
+
|
|
132
|
+
export const otherCanvases = (canvases: readonly Canvas[], offerable: readonly string[]) => offerable.filter((id) => !canvases.some((canvas) => canvas.id === id));
|
|
133
|
+
|
|
134
|
+
export const widthForPointer = (clientX: number, viewportWidth: number) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, viewportWidth - clientX));
|
|
135
|
+
|
|
136
|
+
/** Drag-to-resize on the panel's left edge, mirroring the host's own invisible hit strip. */
|
|
137
|
+
export function useResize(initial: number) {
|
|
138
|
+
const [width, setWidth] = useState(initial);
|
|
139
|
+
const frame = useRef(0);
|
|
140
|
+
const start = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
141
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
142
|
+
event.currentTarget.setAttribute("data-dragging", "");
|
|
143
|
+
const move = (moveEvent: PointerEvent) => {
|
|
144
|
+
// One update per frame: pointermove fires far faster than the panel can lay out.
|
|
145
|
+
if (frame.current !== 0) return;
|
|
146
|
+
frame.current = requestAnimationFrame(() => {
|
|
147
|
+
frame.current = 0;
|
|
148
|
+
setWidth(widthForPointer(moveEvent.clientX, window.innerWidth));
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
const stop = () => {
|
|
152
|
+
window.removeEventListener("pointermove", move);
|
|
153
|
+
window.removeEventListener("pointerup", stop);
|
|
154
|
+
document.querySelector("[data-dragging]")?.removeAttribute("data-dragging");
|
|
155
|
+
if (frame.current !== 0) cancelAnimationFrame(frame.current);
|
|
156
|
+
frame.current = 0;
|
|
157
|
+
};
|
|
158
|
+
window.addEventListener("pointermove", move);
|
|
159
|
+
window.addEventListener("pointerup", stop);
|
|
160
|
+
};
|
|
161
|
+
return { width, start };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function CanvasPanel({ canvases, offerable, cwd, onOpen, onClose, onWidth, onCardError, onCardRendered }: CanvasPanelProps) {
|
|
165
|
+
const { width, start } = useResize(DEFAULT_WIDTH);
|
|
166
|
+
useEffect(() => onWidth(width), [width, onWidth]);
|
|
167
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
168
|
+
const { open: picking, setOpen: setPicking, anchor: picker } = useDismissable();
|
|
169
|
+
// Only what is not already a tab: the tabs cover this session, this covers the rest.
|
|
170
|
+
const elsewhere = otherCanvases(canvases, offerable);
|
|
171
|
+
|
|
172
|
+
// Follow the newest canvas unless the reader has picked one that still exists. A
|
|
173
|
+
// selection that disappears needs no cleanup: the fallback already covers it, and
|
|
174
|
+
// clearing the state as well would only be a second render saying the same thing.
|
|
175
|
+
const active = activeCanvas(canvases, activeId);
|
|
176
|
+
const resolved = useSubPages(cwd, active);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className="dgu-panel" style={{ "--dgu-panel-width": `${width}px` } as React.CSSProperties}>
|
|
180
|
+
<div className="dgu-resize" onPointerDown={start} style={{ left: 0 }} />
|
|
181
|
+
<div className="dgu-header">
|
|
182
|
+
<span className="dgu-title">{active?.id ?? "Canvas"}</span>
|
|
183
|
+
<span className="dgu-spacer" />
|
|
184
|
+
{elsewhere.length > 0 && (
|
|
185
|
+
<div className="dgu-picker" ref={picker}>
|
|
186
|
+
<button type="button" className="dgu-icon-button" onClick={() => setPicking(!picking)} aria-expanded={picking} aria-label={`其他画布(${elsewhere.length})`} title={`其他画布(${elsewhere.length})`}>
|
|
187
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
188
|
+
<path d="M3 5.5l4 3.5 4-3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
189
|
+
</svg>
|
|
190
|
+
</button>
|
|
191
|
+
{picking && (
|
|
192
|
+
<ul className="dgu-launcher-menu dgu-picker-menu">
|
|
193
|
+
{elsewhere.map((id) => (
|
|
194
|
+
<li key={id}>
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
className="dgu-launcher-item"
|
|
198
|
+
onClick={() => {
|
|
199
|
+
setPicking(false);
|
|
200
|
+
setActiveId(id);
|
|
201
|
+
onOpen(id);
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
{id}
|
|
205
|
+
</button>
|
|
206
|
+
</li>
|
|
207
|
+
))}
|
|
208
|
+
</ul>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
<button type="button" className="dgu-icon-button" onClick={onClose} aria-label="关闭画布" title="关闭画布">
|
|
213
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
214
|
+
<path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
215
|
+
</svg>
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
{canvases.length > 1 && (
|
|
219
|
+
<div className="dgu-tabs" role="tablist">
|
|
220
|
+
{canvases.map((canvas) => (
|
|
221
|
+
<button key={canvas.id} type="button" role="tab" className="dgu-tab" aria-selected={canvas.id === active?.id} onClick={() => setActiveId(canvas.id)}>
|
|
222
|
+
{canvas.id}
|
|
223
|
+
</button>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
<div className="dgu-body">
|
|
228
|
+
{active === undefined ? (
|
|
229
|
+
<div className="dgu-empty">写入 ui4a/canvases/<id>.ui4a.tsx 后,画布会出现在这里</div>
|
|
230
|
+
) : (
|
|
231
|
+
// A canvas arrives as whole files, so recompiles replace rather than extend —
|
|
232
|
+
// preserving state would make an edited canvas silently keep the old render.
|
|
233
|
+
<GenUISurface key={active.id} code={resolved} streaming={active.streaming} preserveState={false} onError={(error, phase) => onCardError?.(error.message, phase)} onRendered={onCardRendered} />
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds canvases in the transcript's tool calls.
|
|
3
|
+
*
|
|
4
|
+
* The model writes `ui4a/canvases/<id>.ui4a.tsx` with the host's ordinary file tools — no
|
|
5
|
+
* bespoke tool, no new session event, nothing for the node half to do. `argsRaw` grows
|
|
6
|
+
* token by token while the call streams, which is what makes a canvas render as it is
|
|
7
|
+
* written (the same "streaming from tool arguments" contract as ui4a-playground).
|
|
8
|
+
*
|
|
9
|
+
* Parsing has to survive half a JSON object. Rather than depend on a partial-JSON parser
|
|
10
|
+
* for two fields, the two we need are pulled out directly: the path decides whether this
|
|
11
|
+
* is a canvas at all, and the content is a JSON string literal we can decode up to
|
|
12
|
+
* wherever the stream currently ends.
|
|
13
|
+
*/
|
|
14
|
+
import { owningCanvasIdOf } from "../../contract.ts";
|
|
15
|
+
import type { Canvas } from "./CanvasPanel.tsx";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A call is classified by the SHAPE of its arguments, never by the tool's name.
|
|
19
|
+
*
|
|
20
|
+
* Enumerating names would mean a canvas silently stops rendering the day the host adds or
|
|
21
|
+
* renames a file tool — no error, no stale marker, just an invisible no-op. What actually
|
|
22
|
+
* matters is answerable from the arguments alone: does this call name a canvas path, and
|
|
23
|
+
* does it carry the whole file or only a patch of it.
|
|
24
|
+
*/
|
|
25
|
+
const PATH_KEYS = ["path", "file_path", "filename", "file", "target_file"];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keys that carry a whole file body.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately excludes the patch-shaped keys (`old_str`/`new_str`, `old_string`/
|
|
31
|
+
* `new_string`, `diff`, `patch`): a call carrying one of those changes the canvas without
|
|
32
|
+
* describing it, so the file on disk becomes the only correct source. `str_replace_editor`
|
|
33
|
+
* is exactly this case — its `new_str` is the replacement snippet, not the file, and
|
|
34
|
+
* treating it as a body replaces the whole canvas with a few lines.
|
|
35
|
+
*/
|
|
36
|
+
const CONTENT_KEYS = ["content", "contents", "text", "file_text"];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Keys that mean "this call changed the file without saying what it now contains".
|
|
40
|
+
*
|
|
41
|
+
* The counterpart to CONTENT_KEYS, and the thing that separates an edit from a read: both
|
|
42
|
+
* name a canvas path and neither carries a body, so without this a plain `read` would mark
|
|
43
|
+
* the canvas stale and — for a canvas this session never wrote — open a panel the user
|
|
44
|
+
* never asked for, just for looking at the file.
|
|
45
|
+
*/
|
|
46
|
+
const PATCH_KEYS = ["old_str", "new_str", "old_string", "new_string", "diff", "patch", "edits", "replacements"];
|
|
47
|
+
|
|
48
|
+
const ESCAPES: Record<string, string> = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f" };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The value of whichever key appears EARLIEST in the arguments, from complete JSON or a
|
|
52
|
+
* truncated prefix of it.
|
|
53
|
+
*
|
|
54
|
+
* Earliest position rather than first-in-`keys`: a file body routinely contains the
|
|
55
|
+
* literal text of another candidate key (a canvas that renders the word `"content"`, say),
|
|
56
|
+
* and scanning key by key would find that occurrence inside the body and decode the rest
|
|
57
|
+
* of the body as if it were the value — silently rendering a fragment as the whole canvas.
|
|
58
|
+
* The real key can only precede its own value, so the leftmost hit is the right one.
|
|
59
|
+
*/
|
|
60
|
+
function readString(argsRaw: string, keys: readonly string[]): string | undefined {
|
|
61
|
+
let at = -1;
|
|
62
|
+
let key: string | undefined;
|
|
63
|
+
for (const candidate of keys) {
|
|
64
|
+
const found = argsRaw.indexOf(`"${candidate}"`);
|
|
65
|
+
if (found !== -1 && (at === -1 || found < at)) {
|
|
66
|
+
at = found;
|
|
67
|
+
key = candidate;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (key === undefined) return undefined;
|
|
71
|
+
const colon = argsRaw.indexOf(":", at + key.length + 2);
|
|
72
|
+
if (colon === -1) return undefined;
|
|
73
|
+
const quote = argsRaw.indexOf('"', colon + 1);
|
|
74
|
+
if (quote === -1) return undefined;
|
|
75
|
+
// Walk the string literal by hand: it may simply stop mid-escape while streaming.
|
|
76
|
+
let out = "";
|
|
77
|
+
for (let i = quote + 1; i < argsRaw.length; i += 1) {
|
|
78
|
+
const char = argsRaw[i];
|
|
79
|
+
if (char === "\\") {
|
|
80
|
+
const escape = argsRaw[i + 1];
|
|
81
|
+
if (escape === undefined) return out;
|
|
82
|
+
if (escape === "u") {
|
|
83
|
+
const hex = argsRaw.slice(i + 2, i + 6);
|
|
84
|
+
// A \u escape cut in half by the stream carries no character yet; the next frame has it.
|
|
85
|
+
if (hex.length < 4) return out;
|
|
86
|
+
out += String.fromCharCode(Number.parseInt(hex, 16) || 0);
|
|
87
|
+
i += 5;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out += ESCAPES[escape] ?? escape;
|
|
91
|
+
i += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (char === '"') return out;
|
|
95
|
+
out += char;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type ToolCallView = { name: string; argsRaw: string; settled: boolean };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* One call in the tree a `tool-call` node owns.
|
|
104
|
+
*
|
|
105
|
+
* A running call carries its arguments inline; a settled one wraps them in `call`. Either
|
|
106
|
+
* may own `subCalls` — `run_code` dispatches the file tools as children, so a canvas
|
|
107
|
+
* written from inside a code block is nested rather than top-level.
|
|
108
|
+
*/
|
|
109
|
+
export type CallBlock = {
|
|
110
|
+
kind?: string;
|
|
111
|
+
name?: string;
|
|
112
|
+
argsRaw?: string;
|
|
113
|
+
call?: { name?: string; argsRaw?: string };
|
|
114
|
+
subCalls?: readonly CallBlock[];
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** The call's own identity, ignoring its children. */
|
|
118
|
+
function viewOf(block: CallBlock): ToolCallView | null {
|
|
119
|
+
const name = block.call?.name ?? block.name;
|
|
120
|
+
const argsRaw = block.call?.argsRaw ?? block.argsRaw;
|
|
121
|
+
if (name === undefined || argsRaw === undefined) return null;
|
|
122
|
+
// `tool-result` is the settled shape; anything else is still running.
|
|
123
|
+
return { name, argsRaw, settled: block.kind === "tool-result" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Every call a `tool-call` node contains, parents before children.
|
|
128
|
+
*
|
|
129
|
+
* Flattening rather than reading only the root is what lets a canvas written through
|
|
130
|
+
* `run_code` (or any other dispatching tool) be found: the write is a sub-call, and the
|
|
131
|
+
* root's own arguments are the code that ran, not the file.
|
|
132
|
+
*/
|
|
133
|
+
export function toolCallsOf(data: unknown): ToolCallView[] {
|
|
134
|
+
const root = (data as { root?: CallBlock } | undefined)?.root;
|
|
135
|
+
if (root === undefined) return [];
|
|
136
|
+
const found: ToolCallView[] = [];
|
|
137
|
+
const walk = (block: CallBlock) => {
|
|
138
|
+
const view = viewOf(block);
|
|
139
|
+
if (view !== null) found.push(view);
|
|
140
|
+
for (const child of block.subCalls ?? []) walk(child);
|
|
141
|
+
};
|
|
142
|
+
walk(root);
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Latest canvas per id, in first-seen order.
|
|
148
|
+
* @param calls - every tool call in the transcript, oldest first.
|
|
149
|
+
*/
|
|
150
|
+
export type CollectedCanvases = {
|
|
151
|
+
canvases: Canvas[];
|
|
152
|
+
/**
|
|
153
|
+
* Canvases whose newest change was a patch, mapped to how many patches they have seen.
|
|
154
|
+
* The count is a cache version: the caller re-reads the file when it changes, and
|
|
155
|
+
* reuses what it already has when it does not.
|
|
156
|
+
*/
|
|
157
|
+
stale: Map<string, number>;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export function collectCanvases(calls: readonly ToolCallView[]): CollectedCanvases {
|
|
161
|
+
const byId = new Map<string, Canvas>();
|
|
162
|
+
const stale = new Map<string, number>();
|
|
163
|
+
for (const call of calls) {
|
|
164
|
+
// Reads name a canvas too; only a call that writes one is interesting.
|
|
165
|
+
const path = readString(call.argsRaw, PATH_KEYS);
|
|
166
|
+
if (path === undefined) continue;
|
|
167
|
+
const id = owningCanvasIdOf(path);
|
|
168
|
+
if (id === null) continue;
|
|
169
|
+
const code = readString(call.argsRaw, CONTENT_KEYS);
|
|
170
|
+
const isWrite = code !== undefined && code !== "";
|
|
171
|
+
|
|
172
|
+
if (!isWrite) {
|
|
173
|
+
// A patch changed the file without describing it, so the body on disk is now the only
|
|
174
|
+
// correct source. A call with neither a body nor a patch only read it — leave it alone.
|
|
175
|
+
const patches = PATCH_KEYS.some((key) => call.argsRaw.includes(`"${key}"`));
|
|
176
|
+
if (patches && call.settled) stale.set(id, (stale.get(id) ?? 0) + 1);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// A later write to the same id replaces the earlier one; insertion order is kept.
|
|
180
|
+
byId.delete(id);
|
|
181
|
+
byId.set(id, { id, code, streaming: !call.settled });
|
|
182
|
+
// A whole-file write supersedes every patch before it.
|
|
183
|
+
stale.delete(id);
|
|
184
|
+
}
|
|
185
|
+
// An edit to a canvas never written in this session still deserves a panel.
|
|
186
|
+
for (const id of stale.keys()) if (!byId.has(id)) byId.set(id, { id, code: "", streaming: false });
|
|
187
|
+
return { canvases: [...byId.values()], stale };
|
|
188
|
+
}
|