pmx-canvas 0.1.0
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 +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- package/src/shared/semantic-attention.ts +598 -0
|
@@ -0,0 +1,3846 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone canvas server — extracted from PMX web-canvas/server.ts.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - GET /workbench -> canvas SPA HTML
|
|
6
|
+
* - GET /api/file?path=... -> read markdown content
|
|
7
|
+
* - POST /api/file/save -> persist markdown edits
|
|
8
|
+
* - POST /api/render -> server-side markdown render (marked)
|
|
9
|
+
* - GET /api/canvas/state -> canvas layout
|
|
10
|
+
* - POST /api/canvas/update -> batch node updates
|
|
11
|
+
* - POST /api/canvas/edge -> add edge
|
|
12
|
+
* - DELETE /api/canvas/edge -> remove edge
|
|
13
|
+
* - POST /api/canvas/prompt -> canvas prompt
|
|
14
|
+
* - POST /api/canvas/context-pins -> update context pins
|
|
15
|
+
* - GET /api/canvas/pinned-context -> get pinned context preamble
|
|
16
|
+
* - GET /api/canvas/spatial-context -> spatial analysis (clusters, reading order, neighborhoods)
|
|
17
|
+
* - GET /api/canvas/search?q=... -> full-text search across nodes
|
|
18
|
+
* - GET /api/canvas/code-graph -> auto-detected file dependency graph
|
|
19
|
+
* - POST /api/canvas/undo -> undo last mutation
|
|
20
|
+
* - POST /api/canvas/redo -> redo last undone mutation
|
|
21
|
+
* - GET /api/canvas/history -> mutation history timeline
|
|
22
|
+
* - POST /api/canvas/json-render -> create a native json-render node
|
|
23
|
+
* - POST /api/canvas/graph -> create a native graph node
|
|
24
|
+
* - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
|
|
25
|
+
* - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
|
|
26
|
+
* - GET /api/workbench/events -> SSE event stream
|
|
27
|
+
* - GET /api/workbench/state -> workbench state snapshot
|
|
28
|
+
* - POST /api/workbench/intent -> workbench intents
|
|
29
|
+
* - GET /api/workbench/webview -> Bun.WebView automation status
|
|
30
|
+
* - POST /api/workbench/webview/start -> start Bun.WebView automation session
|
|
31
|
+
* - POST /api/workbench/webview/evaluate -> evaluate JS in Bun.WebView automation session
|
|
32
|
+
* - POST /api/workbench/webview/resize -> resize Bun.WebView automation viewport
|
|
33
|
+
* - POST /api/workbench/webview/screenshot -> capture Bun.WebView automation screenshot
|
|
34
|
+
* - DELETE /api/workbench/webview -> stop Bun.WebView automation session
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { spawnSync } from 'node:child_process';
|
|
38
|
+
import { existsSync, readFileSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
39
|
+
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
40
|
+
import * as marked from 'marked';
|
|
41
|
+
import type {
|
|
42
|
+
ListPromptsResult,
|
|
43
|
+
ListResourcesResult,
|
|
44
|
+
ListResourceTemplatesResult,
|
|
45
|
+
ListToolsResult,
|
|
46
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
47
|
+
import { type CanvasEdge, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
48
|
+
import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
|
|
49
|
+
import { getMcpAppHostSnapshot } from './mcp-app-host.js';
|
|
50
|
+
import {
|
|
51
|
+
callMcpAppTool,
|
|
52
|
+
closeMcpAppSession,
|
|
53
|
+
closeAllMcpAppSessions,
|
|
54
|
+
listMcpAppPrompts,
|
|
55
|
+
listMcpAppResources,
|
|
56
|
+
listMcpAppResourceTemplates,
|
|
57
|
+
listMcpAppTools,
|
|
58
|
+
openMcpApp,
|
|
59
|
+
readMcpAppResource,
|
|
60
|
+
type ExternalMcpTransportConfig,
|
|
61
|
+
} from './mcp-app-runtime.js';
|
|
62
|
+
import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
|
|
63
|
+
import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
|
|
64
|
+
import { diffLayouts, formatDiff, mutationHistory } from './mutation-history.js';
|
|
65
|
+
import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from './canvas-serialization.js';
|
|
66
|
+
import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
|
|
67
|
+
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
68
|
+
import {
|
|
69
|
+
addCanvasNode,
|
|
70
|
+
addCanvasEdge,
|
|
71
|
+
applyCanvasNodeUpdates,
|
|
72
|
+
arrangeCanvasNodes,
|
|
73
|
+
clearCanvas,
|
|
74
|
+
createCanvasGraphNode,
|
|
75
|
+
createCanvasGroup,
|
|
76
|
+
createCanvasJsonRenderNode,
|
|
77
|
+
deleteCanvasSnapshot,
|
|
78
|
+
executeCanvasBatch,
|
|
79
|
+
groupCanvasNodes,
|
|
80
|
+
listCanvasSnapshots,
|
|
81
|
+
refreshCanvasWebpageNode,
|
|
82
|
+
removeCanvasNode,
|
|
83
|
+
removeCanvasEdge,
|
|
84
|
+
restoreCanvasSnapshot,
|
|
85
|
+
saveCanvasSnapshot,
|
|
86
|
+
scheduleCodeGraphRecompute,
|
|
87
|
+
primeCanvasRuntimeBackends,
|
|
88
|
+
syncCanvasRuntimeBackends,
|
|
89
|
+
setCanvasContextPins,
|
|
90
|
+
ungroupCanvasNodes,
|
|
91
|
+
validateCanvasNodePatch,
|
|
92
|
+
} from './canvas-operations.js';
|
|
93
|
+
import { validateCanvasLayout } from './canvas-validation.js';
|
|
94
|
+
import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
|
|
95
|
+
import { buildExcalidrawOpenMcpAppInput } from './diagram-presets.js';
|
|
96
|
+
import { traceManager } from './trace-manager.js';
|
|
97
|
+
import { buildWebArtifactOnCanvas, resolveWorkspacePath } from './web-artifacts.js';
|
|
98
|
+
import {
|
|
99
|
+
buildGraphSpec,
|
|
100
|
+
buildJsonRenderViewerHtml,
|
|
101
|
+
createJsonRenderNodeData,
|
|
102
|
+
GRAPH_NODE_SIZE,
|
|
103
|
+
JSON_RENDER_NODE_SIZE,
|
|
104
|
+
normalizeAndValidateJsonRenderSpec,
|
|
105
|
+
} from '../json-render/server.js';
|
|
106
|
+
import {
|
|
107
|
+
WEBPAGE_NODE_DEFAULT_SIZE,
|
|
108
|
+
normalizeWebpageUrl,
|
|
109
|
+
} from './webpage-node.js';
|
|
110
|
+
|
|
111
|
+
const DEFAULT_HOST = '127.0.0.1';
|
|
112
|
+
const DEFAULT_PORT = 4313;
|
|
113
|
+
|
|
114
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
115
|
+
let activeWorkspaceRoot = resolve(process.cwd());
|
|
116
|
+
let primaryWorkbenchPath: string | null = null;
|
|
117
|
+
let primaryWorkbenchSessionId = `pmx-${Date.now().toString(36)}`;
|
|
118
|
+
let nextWorkbenchEventId = 1;
|
|
119
|
+
let nextWorkbenchSubscriberId = 1;
|
|
120
|
+
const workbenchSubscribers = new Map<number, ReadableStreamDefaultController<Uint8Array>>();
|
|
121
|
+
const textEncoder = new TextEncoder();
|
|
122
|
+
let primaryWorkbenchAutoOpenEnabled = true;
|
|
123
|
+
const canvasThemeSetting = (['dark', 'light', 'high-contrast'].includes(process.env.PMX_CANVAS_THEME ?? '')
|
|
124
|
+
? process.env.PMX_CANVAS_THEME!
|
|
125
|
+
: 'dark');
|
|
126
|
+
let lastWorkbenchContextCardsEnvelope: Record<string, unknown> | null = null;
|
|
127
|
+
|
|
128
|
+
export interface PrimaryWorkbenchEventPayload {
|
|
129
|
+
[key: string]: unknown;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type CanvasWebViewBackend =
|
|
133
|
+
| 'webkit'
|
|
134
|
+
| 'chrome'
|
|
135
|
+
| {
|
|
136
|
+
type: 'chrome';
|
|
137
|
+
url?: false;
|
|
138
|
+
path?: string;
|
|
139
|
+
argv?: string[];
|
|
140
|
+
stdout?: 'inherit' | 'ignore';
|
|
141
|
+
stderr?: 'inherit' | 'ignore';
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
type: 'chrome';
|
|
145
|
+
url: string;
|
|
146
|
+
}
|
|
147
|
+
| {
|
|
148
|
+
type: 'webkit';
|
|
149
|
+
stdout?: 'inherit' | 'ignore';
|
|
150
|
+
stderr?: 'inherit' | 'ignore';
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
interface CanvasWebViewLike extends EventTarget {
|
|
154
|
+
readonly url?: string;
|
|
155
|
+
readonly title?: string;
|
|
156
|
+
readonly loading?: boolean;
|
|
157
|
+
navigate(url: string): Promise<void>;
|
|
158
|
+
evaluate(expression: string): Promise<unknown>;
|
|
159
|
+
screenshot(options?: Record<string, unknown>): Promise<Uint8Array | ArrayBuffer | Blob>;
|
|
160
|
+
resize(width: number, height: number): Promise<void>;
|
|
161
|
+
close(): void | Promise<void>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface CanvasWebViewConstructor {
|
|
165
|
+
new (options?: {
|
|
166
|
+
width?: number;
|
|
167
|
+
height?: number;
|
|
168
|
+
headless?: boolean;
|
|
169
|
+
backend?: CanvasWebViewBackend;
|
|
170
|
+
url?: string;
|
|
171
|
+
dataStore?: 'ephemeral' | { directory: string };
|
|
172
|
+
}): CanvasWebViewLike;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface BunWithOptionalWebView {
|
|
176
|
+
WebView?: CanvasWebViewConstructor;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const DEFAULT_CANVAS_AUTOMATION_WEBVIEW_TIMEOUT_MS = 5000;
|
|
180
|
+
|
|
181
|
+
export interface CanvasAutomationWebViewOptions {
|
|
182
|
+
backend?: 'webkit' | 'chrome';
|
|
183
|
+
width?: number;
|
|
184
|
+
height?: number;
|
|
185
|
+
chromePath?: string;
|
|
186
|
+
chromeArgv?: string[];
|
|
187
|
+
dataStoreDir?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface CanvasAutomationWebViewStatus {
|
|
191
|
+
supported: boolean;
|
|
192
|
+
active: boolean;
|
|
193
|
+
headlessOnly: true;
|
|
194
|
+
url: string | null;
|
|
195
|
+
backend: 'webkit' | 'chrome' | null;
|
|
196
|
+
width: number | null;
|
|
197
|
+
height: number | null;
|
|
198
|
+
dataStoreDir: string | null;
|
|
199
|
+
startedAt: string | null;
|
|
200
|
+
lastError: string | null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let canvasAutomationWebView: CanvasWebViewLike | null = null;
|
|
204
|
+
let canvasAutomationWebViewStatus: Omit<CanvasAutomationWebViewStatus, 'supported' | 'active' | 'headlessOnly'> =
|
|
205
|
+
{
|
|
206
|
+
url: null,
|
|
207
|
+
backend: null,
|
|
208
|
+
width: null,
|
|
209
|
+
height: null,
|
|
210
|
+
dataStoreDir: null,
|
|
211
|
+
startedAt: null,
|
|
212
|
+
lastError: null,
|
|
213
|
+
};
|
|
214
|
+
let canvasAutomationWebViewQueue: Promise<void> = Promise.resolve();
|
|
215
|
+
|
|
216
|
+
function sessionDiagLog(tag: string, payload: Record<string, unknown>): void {
|
|
217
|
+
const logPath = String(process.env.PMX_SESSION_LOG || process.env.PMX_TEST_LOG || '').trim();
|
|
218
|
+
if (!logPath) return;
|
|
219
|
+
try {
|
|
220
|
+
appendFileSync(
|
|
221
|
+
logPath,
|
|
222
|
+
`${JSON.stringify({
|
|
223
|
+
ts: new Date().toISOString(),
|
|
224
|
+
scope: 'workbench',
|
|
225
|
+
tag,
|
|
226
|
+
...payload,
|
|
227
|
+
})}\n`,
|
|
228
|
+
'utf-8',
|
|
229
|
+
);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
console.debug('[workbench] diagnostics logging failed', error);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function logWorkbenchWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
236
|
+
console.warn(`[workbench] ${action}`, { error, ...(details ?? {}) });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function tryParseUrl(raw: string): URL | null {
|
|
240
|
+
try {
|
|
241
|
+
return new URL(raw);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.debug('[workbench] invalid URL', { raw, error });
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function getCanvasWebViewConstructor(): CanvasWebViewConstructor | null {
|
|
249
|
+
return (Bun as typeof Bun & BunWithOptionalWebView).WebView ?? null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function hasCanvasAutomationWebViewSupport(): boolean {
|
|
253
|
+
return getCanvasWebViewConstructor() !== null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getDefaultCanvasAutomationWebViewBackend(): 'webkit' | 'chrome' {
|
|
257
|
+
return process.platform === 'darwin' ? 'webkit' : 'chrome';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeCanvasAutomationWebViewOptions(
|
|
261
|
+
input: CanvasAutomationWebViewOptions = {},
|
|
262
|
+
): Required<Pick<CanvasAutomationWebViewOptions, 'width' | 'height'>> &
|
|
263
|
+
Pick<CanvasAutomationWebViewOptions, 'backend' | 'chromePath' | 'chromeArgv' | 'dataStoreDir'> {
|
|
264
|
+
return {
|
|
265
|
+
backend: input.backend,
|
|
266
|
+
width:
|
|
267
|
+
typeof input.width === 'number' && Number.isFinite(input.width) && input.width > 0
|
|
268
|
+
? Math.floor(input.width)
|
|
269
|
+
: 1280,
|
|
270
|
+
height:
|
|
271
|
+
typeof input.height === 'number' && Number.isFinite(input.height) && input.height > 0
|
|
272
|
+
? Math.floor(input.height)
|
|
273
|
+
: 800,
|
|
274
|
+
chromePath: input.chromePath?.trim() || undefined,
|
|
275
|
+
chromeArgv:
|
|
276
|
+
Array.isArray(input.chromeArgv) && input.chromeArgv.length > 0
|
|
277
|
+
? input.chromeArgv.map((value) => value.trim()).filter((value) => value.length > 0)
|
|
278
|
+
: undefined,
|
|
279
|
+
dataStoreDir: input.dataStoreDir?.trim() || undefined,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveCanvasAutomationWebViewBackend(
|
|
284
|
+
options: ReturnType<typeof normalizeCanvasAutomationWebViewOptions>,
|
|
285
|
+
): CanvasWebViewBackend {
|
|
286
|
+
if (options.backend === 'webkit' && (options.chromePath || options.chromeArgv)) {
|
|
287
|
+
throw new Error('Chrome-specific WebView options cannot be combined with the WebKit backend.');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (options.backend === 'webkit' && process.platform !== 'darwin') {
|
|
291
|
+
throw new Error('The WebKit Bun.WebView backend is only available on macOS. Use backend "chrome" instead.');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (options.chromePath || options.chromeArgv) {
|
|
295
|
+
return {
|
|
296
|
+
type: 'chrome',
|
|
297
|
+
...(options.chromePath ? { path: options.chromePath } : {}),
|
|
298
|
+
...(options.chromeArgv ? { argv: options.chromeArgv } : {}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const backend = options.backend ?? getDefaultCanvasAutomationWebViewBackend();
|
|
303
|
+
if (backend === 'webkit' && process.platform !== 'darwin') {
|
|
304
|
+
throw new Error('The WebKit Bun.WebView backend is only available on macOS. Use backend "chrome" instead.');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return backend;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function detectCanvasAutomationWebViewBackendKind(backend: CanvasWebViewBackend): 'webkit' | 'chrome' {
|
|
311
|
+
if (backend === 'webkit' || backend === 'chrome') return backend;
|
|
312
|
+
return backend.type;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function getCanvasAutomationWebViewTimeoutMs(): number {
|
|
316
|
+
const raw = Number.parseInt(process.env.PMX_CANVAS_WEBVIEW_TIMEOUT_MS ?? '', 10);
|
|
317
|
+
return Number.isFinite(raw) && raw > 0
|
|
318
|
+
? raw
|
|
319
|
+
: DEFAULT_CANVAS_AUTOMATION_WEBVIEW_TIMEOUT_MS;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function withCanvasAutomationWebViewTimeout<T>(task: Promise<T>, action: string): Promise<T> {
|
|
323
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
324
|
+
try {
|
|
325
|
+
return await Promise.race([
|
|
326
|
+
task,
|
|
327
|
+
new Promise<T>((_, reject) => {
|
|
328
|
+
timer = setTimeout(() => {
|
|
329
|
+
reject(
|
|
330
|
+
new Error(
|
|
331
|
+
`Timed out after ${getCanvasAutomationWebViewTimeoutMs()}ms while ${action}. ` +
|
|
332
|
+
'Bun.WebView may be unavailable in this environment.',
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
}, getCanvasAutomationWebViewTimeoutMs());
|
|
336
|
+
}),
|
|
337
|
+
]);
|
|
338
|
+
} finally {
|
|
339
|
+
if (timer) clearTimeout(timer);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function closeCanvasAutomationWebViewInternal(): Promise<boolean> {
|
|
344
|
+
if (!canvasAutomationWebView) return false;
|
|
345
|
+
|
|
346
|
+
const view = canvasAutomationWebView;
|
|
347
|
+
canvasAutomationWebView = null;
|
|
348
|
+
canvasAutomationWebViewStatus = {
|
|
349
|
+
...canvasAutomationWebViewStatus,
|
|
350
|
+
url: null,
|
|
351
|
+
backend: null,
|
|
352
|
+
width: null,
|
|
353
|
+
height: null,
|
|
354
|
+
dataStoreDir: null,
|
|
355
|
+
startedAt: null,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
await Promise.resolve(view.close());
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function runCanvasAutomationWebViewTask<T>(task: () => Promise<T>): Promise<T> {
|
|
363
|
+
const result = canvasAutomationWebViewQueue.then(task);
|
|
364
|
+
canvasAutomationWebViewQueue = result.then(() => undefined, () => undefined);
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function getCanvasAutomationWebViewStatus(): CanvasAutomationWebViewStatus {
|
|
369
|
+
return {
|
|
370
|
+
supported: hasCanvasAutomationWebViewSupport(),
|
|
371
|
+
active: canvasAutomationWebView !== null,
|
|
372
|
+
headlessOnly: true,
|
|
373
|
+
...canvasAutomationWebViewStatus,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function stopCanvasAutomationWebView(): Promise<boolean> {
|
|
378
|
+
return runCanvasAutomationWebViewTask(async () => {
|
|
379
|
+
try {
|
|
380
|
+
return await closeCanvasAutomationWebViewInternal();
|
|
381
|
+
} catch (error) {
|
|
382
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
383
|
+
canvasAutomationWebViewStatus = {
|
|
384
|
+
...canvasAutomationWebViewStatus,
|
|
385
|
+
lastError: message,
|
|
386
|
+
};
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function startCanvasAutomationWebView(
|
|
393
|
+
url: string,
|
|
394
|
+
options: CanvasAutomationWebViewOptions = {},
|
|
395
|
+
): Promise<CanvasAutomationWebViewStatus> {
|
|
396
|
+
return runCanvasAutomationWebViewTask(async () => {
|
|
397
|
+
const WebView = getCanvasWebViewConstructor();
|
|
398
|
+
if (!WebView) {
|
|
399
|
+
const message = 'Bun.WebView is not available in this Bun runtime. Bun >=1.3.12 is required.';
|
|
400
|
+
canvasAutomationWebViewStatus = {
|
|
401
|
+
...canvasAutomationWebViewStatus,
|
|
402
|
+
lastError: message,
|
|
403
|
+
};
|
|
404
|
+
throw new Error(message);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const normalized = normalizeCanvasAutomationWebViewOptions(options);
|
|
408
|
+
const backend = resolveCanvasAutomationWebViewBackend(normalized);
|
|
409
|
+
|
|
410
|
+
if (canvasAutomationWebView) {
|
|
411
|
+
await closeCanvasAutomationWebViewInternal();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const view = new WebView({
|
|
415
|
+
width: normalized.width,
|
|
416
|
+
height: normalized.height,
|
|
417
|
+
headless: true,
|
|
418
|
+
backend,
|
|
419
|
+
dataStore: normalized.dataStoreDir ? { directory: normalized.dataStoreDir } : 'ephemeral',
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
await withCanvasAutomationWebViewTimeout(view.navigate(url), 'starting the workbench automation WebView');
|
|
424
|
+
} catch (error) {
|
|
425
|
+
canvasAutomationWebViewStatus = {
|
|
426
|
+
...canvasAutomationWebViewStatus,
|
|
427
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
428
|
+
};
|
|
429
|
+
await Promise.resolve(view.close()).catch(() => undefined);
|
|
430
|
+
throw error;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
canvasAutomationWebView = view;
|
|
434
|
+
canvasAutomationWebViewStatus = {
|
|
435
|
+
url,
|
|
436
|
+
backend: detectCanvasAutomationWebViewBackendKind(backend),
|
|
437
|
+
width: normalized.width,
|
|
438
|
+
height: normalized.height,
|
|
439
|
+
dataStoreDir: normalized.dataStoreDir ?? null,
|
|
440
|
+
startedAt: new Date().toISOString(),
|
|
441
|
+
lastError: null,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
return getCanvasAutomationWebViewStatus();
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function requireActiveCanvasAutomationWebView(): CanvasWebViewLike {
|
|
449
|
+
if (!canvasAutomationWebView) {
|
|
450
|
+
throw new Error('Canvas automation WebView is not active. Start it before issuing automation commands.');
|
|
451
|
+
}
|
|
452
|
+
return canvasAutomationWebView;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function evaluateCanvasAutomationWebView(expression: string): Promise<unknown> {
|
|
456
|
+
return runCanvasAutomationWebViewTask(async () =>
|
|
457
|
+
withCanvasAutomationWebViewTimeout(
|
|
458
|
+
requireActiveCanvasAutomationWebView().evaluate(expression),
|
|
459
|
+
'evaluating JavaScript in the workbench automation WebView',
|
|
460
|
+
));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function resizeCanvasAutomationWebView(
|
|
464
|
+
width: number,
|
|
465
|
+
height: number,
|
|
466
|
+
): Promise<CanvasAutomationWebViewStatus> {
|
|
467
|
+
return runCanvasAutomationWebViewTask(async () => {
|
|
468
|
+
const normalizedWidth = Number.isFinite(width) && width > 0 ? Math.floor(width) : 1280;
|
|
469
|
+
const normalizedHeight = Number.isFinite(height) && height > 0 ? Math.floor(height) : 800;
|
|
470
|
+
await withCanvasAutomationWebViewTimeout(
|
|
471
|
+
requireActiveCanvasAutomationWebView().resize(normalizedWidth, normalizedHeight),
|
|
472
|
+
'resizing the workbench automation WebView',
|
|
473
|
+
);
|
|
474
|
+
canvasAutomationWebViewStatus = {
|
|
475
|
+
...canvasAutomationWebViewStatus,
|
|
476
|
+
width: normalizedWidth,
|
|
477
|
+
height: normalizedHeight,
|
|
478
|
+
};
|
|
479
|
+
return getCanvasAutomationWebViewStatus();
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export async function screenshotCanvasAutomationWebView(
|
|
484
|
+
options: Record<string, unknown> = {},
|
|
485
|
+
): Promise<Uint8Array> {
|
|
486
|
+
return runCanvasAutomationWebViewTask(async () => {
|
|
487
|
+
const result = await withCanvasAutomationWebViewTimeout(
|
|
488
|
+
requireActiveCanvasAutomationWebView().screenshot(options),
|
|
489
|
+
'capturing a screenshot from the workbench automation WebView',
|
|
490
|
+
);
|
|
491
|
+
if (result instanceof Uint8Array) return result;
|
|
492
|
+
if (result instanceof ArrayBuffer) return new Uint8Array(result);
|
|
493
|
+
if (result instanceof Blob) return new Uint8Array(await result.arrayBuffer());
|
|
494
|
+
throw new Error('Unexpected screenshot payload type from Bun.WebView.');
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export interface PrimaryWorkbenchIntent {
|
|
499
|
+
id: number;
|
|
500
|
+
type:
|
|
501
|
+
| 'focus-primary'
|
|
502
|
+
| 'refresh-artifact'
|
|
503
|
+
| 'review-artifact'
|
|
504
|
+
| 'focus-approval'
|
|
505
|
+
| 'open-aux'
|
|
506
|
+
| 'close-aux'
|
|
507
|
+
| 'mcp-app-focus'
|
|
508
|
+
| 'mcp-app-close'
|
|
509
|
+
| 'trace-toggle'
|
|
510
|
+
| 'trace-clear'
|
|
511
|
+
| 'canvas-prompt';
|
|
512
|
+
payload: PrimaryWorkbenchEventPayload;
|
|
513
|
+
createdAt: string;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export interface PrimaryWorkbenchCanvasPromptRequest {
|
|
517
|
+
nodeId: string;
|
|
518
|
+
text: string;
|
|
519
|
+
displayText: string;
|
|
520
|
+
parentNodeId?: string;
|
|
521
|
+
contextNodeIds: string[];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
type PrimaryWorkbenchCanvasPromptHandler = (
|
|
525
|
+
request: PrimaryWorkbenchCanvasPromptRequest,
|
|
526
|
+
) => Promise<void>;
|
|
527
|
+
|
|
528
|
+
let primaryWorkbenchCanvasPromptHandler: PrimaryWorkbenchCanvasPromptHandler | null = null;
|
|
529
|
+
|
|
530
|
+
const pendingWorkbenchIntents: PrimaryWorkbenchIntent[] = [];
|
|
531
|
+
let nextWorkbenchIntentId = 1;
|
|
532
|
+
const MAX_PENDING_WORKBENCH_INTENTS = 120;
|
|
533
|
+
const ALLOWED_WORKBENCH_INTENTS = new Set<PrimaryWorkbenchIntent['type']>([
|
|
534
|
+
'focus-primary',
|
|
535
|
+
'refresh-artifact',
|
|
536
|
+
'review-artifact',
|
|
537
|
+
'focus-approval',
|
|
538
|
+
'open-aux',
|
|
539
|
+
'close-aux',
|
|
540
|
+
'mcp-app-focus',
|
|
541
|
+
'mcp-app-close',
|
|
542
|
+
'trace-toggle',
|
|
543
|
+
'trace-clear',
|
|
544
|
+
'canvas-prompt',
|
|
545
|
+
]);
|
|
546
|
+
|
|
547
|
+
function normalizeWorkspaceRoot(workspaceRoot: string): string {
|
|
548
|
+
return resolve(workspaceRoot || process.cwd());
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function isMarkdownFile(pathLike: string): boolean {
|
|
552
|
+
return extname(pathLike).toLowerCase() === '.md';
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function resolveWorkspaceMarkdownPath(pathLike: string): string | null {
|
|
556
|
+
if (!pathLike || typeof pathLike !== 'string') return null;
|
|
557
|
+
const resolved = resolve(pathLike);
|
|
558
|
+
const workspaceRel = relative(activeWorkspaceRoot, resolved);
|
|
559
|
+
const insideWorkspace = !(workspaceRel.startsWith('..') || workspaceRel === '..');
|
|
560
|
+
if (!insideWorkspace) return null;
|
|
561
|
+
if (!isMarkdownFile(resolved)) return null;
|
|
562
|
+
return resolved;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function resolveWorkspaceArtifactPath(pathLike: string): string | null {
|
|
566
|
+
if (!pathLike || typeof pathLike !== 'string') return null;
|
|
567
|
+
const resolved = resolve(pathLike);
|
|
568
|
+
const workspaceRel = relative(activeWorkspaceRoot, resolved);
|
|
569
|
+
const insideWorkspace = !(workspaceRel.startsWith('..') || workspaceRel === '..');
|
|
570
|
+
return insideWorkspace ? resolved : null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function escapeHtml(text: string): string {
|
|
574
|
+
return text
|
|
575
|
+
.replaceAll('&', '&')
|
|
576
|
+
.replaceAll('<', '<')
|
|
577
|
+
.replaceAll('>', '>')
|
|
578
|
+
.replaceAll('"', '"')
|
|
579
|
+
.replaceAll("'", ''');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function hashPath(path: string): string {
|
|
583
|
+
let h = 0;
|
|
584
|
+
for (let i = 0; i < path.length; i++) {
|
|
585
|
+
h = ((h << 5) - h + path.charCodeAt(i)) | 0;
|
|
586
|
+
}
|
|
587
|
+
return Math.abs(h).toString(36);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function getMarkdownPlacement(): { x: number; y: number } {
|
|
591
|
+
return findOpenCanvasPosition(canvasState.getLayout().nodes, 720, 600);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function findCanvasExtAppNodeId(toolCallId: string): string | null {
|
|
595
|
+
const directId = `ext-app-${toolCallId}`;
|
|
596
|
+
if (canvasState.getNode(directId)) return directId;
|
|
597
|
+
for (const node of canvasState.getLayout().nodes) {
|
|
598
|
+
if (
|
|
599
|
+
node.type === 'mcp-app' &&
|
|
600
|
+
node.data.mode === 'ext-app' &&
|
|
601
|
+
node.data.toolCallId === toolCallId
|
|
602
|
+
) {
|
|
603
|
+
return node.id;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function findReusableCanvasExtAppNodeId(serverName: string, toolName: string): string | null {
|
|
610
|
+
for (const node of canvasState.getLayout().nodes) {
|
|
611
|
+
if (
|
|
612
|
+
node.type === 'mcp-app' &&
|
|
613
|
+
node.data.mode === 'ext-app' &&
|
|
614
|
+
node.data.serverName === serverName &&
|
|
615
|
+
node.data.toolName === toolName &&
|
|
616
|
+
!node.data.toolResult
|
|
617
|
+
) {
|
|
618
|
+
return node.id;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function findOnlyPendingCanvasExtAppNodeId(serverName: string, toolName: string): string | null {
|
|
625
|
+
let matchId: string | null = null;
|
|
626
|
+
for (const node of canvasState.getLayout().nodes) {
|
|
627
|
+
if (
|
|
628
|
+
node.type === 'mcp-app' &&
|
|
629
|
+
node.data.mode === 'ext-app' &&
|
|
630
|
+
node.data.serverName === serverName &&
|
|
631
|
+
node.data.toolName === toolName &&
|
|
632
|
+
!node.data.toolResult
|
|
633
|
+
) {
|
|
634
|
+
if (matchId) return null;
|
|
635
|
+
matchId = node.id;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return matchId;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function toSseFrame(event: string, payload: PrimaryWorkbenchEventPayload): Uint8Array {
|
|
642
|
+
const id = nextWorkbenchEventId++;
|
|
643
|
+
const lines = [`id: ${id}`, `event: ${event}`, `data: ${JSON.stringify(payload)}`, ''];
|
|
644
|
+
return textEncoder.encode(`${lines.join('\n')}\n`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPayload): void {
|
|
648
|
+
const frame = toSseFrame(event, payload);
|
|
649
|
+
for (const [subscriberId, controller] of workbenchSubscribers.entries()) {
|
|
650
|
+
try {
|
|
651
|
+
controller.enqueue(frame);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
sessionDiagLog('drop-subscriber-after-enqueue-failure', {
|
|
654
|
+
subscriberId,
|
|
655
|
+
event,
|
|
656
|
+
error: error instanceof Error ? error.message : String(error),
|
|
657
|
+
});
|
|
658
|
+
workbenchSubscribers.delete(subscriberId);
|
|
659
|
+
syncCanvasBrowserOpenedFromSubscribers();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function setPrimaryWorkbenchPath(safePath: string, source: string): void {
|
|
665
|
+
const resolved = resolve(safePath);
|
|
666
|
+
if (primaryWorkbenchPath === resolved) return;
|
|
667
|
+
primaryWorkbenchPath = resolved;
|
|
668
|
+
broadcastWorkbenchEvent('workbench-open', {
|
|
669
|
+
path: resolved,
|
|
670
|
+
title: basename(resolved),
|
|
671
|
+
source,
|
|
672
|
+
sessionId: primaryWorkbenchSessionId,
|
|
673
|
+
updatedAt: new Date().toISOString(),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function setPrimaryWorkbenchAutoOpenEnabled(enabled: boolean): void {
|
|
678
|
+
primaryWorkbenchAutoOpenEnabled = enabled;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function isPrimaryWorkbenchAutoOpenEnabled(): boolean {
|
|
682
|
+
return primaryWorkbenchAutoOpenEnabled;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function hasWorkbenchSubscribers(): boolean {
|
|
686
|
+
return workbenchSubscribers.size > 0;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function setPrimaryWorkbenchCanvasPromptHandler(
|
|
690
|
+
handler: PrimaryWorkbenchCanvasPromptHandler | null,
|
|
691
|
+
): void {
|
|
692
|
+
primaryWorkbenchCanvasPromptHandler = handler;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function enqueuePrimaryWorkbenchIntent(
|
|
696
|
+
type: PrimaryWorkbenchIntent['type'],
|
|
697
|
+
payload: PrimaryWorkbenchEventPayload = {},
|
|
698
|
+
): PrimaryWorkbenchIntent {
|
|
699
|
+
const intent: PrimaryWorkbenchIntent = {
|
|
700
|
+
id: nextWorkbenchIntentId++,
|
|
701
|
+
type,
|
|
702
|
+
payload,
|
|
703
|
+
createdAt: new Date().toISOString(),
|
|
704
|
+
};
|
|
705
|
+
pendingWorkbenchIntents.push(intent);
|
|
706
|
+
if (pendingWorkbenchIntents.length > MAX_PENDING_WORKBENCH_INTENTS) {
|
|
707
|
+
pendingWorkbenchIntents.splice(
|
|
708
|
+
0,
|
|
709
|
+
pendingWorkbenchIntents.length - MAX_PENDING_WORKBENCH_INTENTS,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
broadcastWorkbenchEvent('workbench-intent', { ...intent });
|
|
713
|
+
return intent;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function rotatePrimaryWorkbenchSessionIfNeeded(): void {
|
|
717
|
+
if (primaryWorkbenchSessionId) return;
|
|
718
|
+
primaryWorkbenchSessionId = `pmx-${Date.now().toString(36)}`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function readJson(req: Request): Promise<Record<string, unknown>> {
|
|
722
|
+
return req.json()
|
|
723
|
+
.then((value) => {
|
|
724
|
+
if (!value || typeof value !== 'object') return {};
|
|
725
|
+
return value as Record<string, unknown>;
|
|
726
|
+
})
|
|
727
|
+
.catch((error) => {
|
|
728
|
+
logWorkbenchWarning('readJson', error);
|
|
729
|
+
return {};
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function htmlEscape(value: string): string {
|
|
734
|
+
return value
|
|
735
|
+
.replaceAll('&', '&')
|
|
736
|
+
.replaceAll('<', '<')
|
|
737
|
+
.replaceAll('>', '>')
|
|
738
|
+
.replaceAll('"', '"')
|
|
739
|
+
.replaceAll("'", ''');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function toPreferredExcalidrawUrl(raw: string): string {
|
|
743
|
+
const parsed = tryParseUrl(raw);
|
|
744
|
+
if (!parsed) return raw;
|
|
745
|
+
const host = parsed.hostname.toLowerCase();
|
|
746
|
+
if (!host.includes('excalidraw-mcp-app')) return parsed.toString();
|
|
747
|
+
const lowerHash = parsed.hash.toLowerCase();
|
|
748
|
+
const hasPortableState =
|
|
749
|
+
lowerHash.includes('json=') ||
|
|
750
|
+
lowerHash.includes('room=') ||
|
|
751
|
+
parsed.searchParams.has('json') ||
|
|
752
|
+
parsed.searchParams.has('room');
|
|
753
|
+
if (hasPortableState) {
|
|
754
|
+
parsed.protocol = 'https:';
|
|
755
|
+
parsed.hostname = 'excalidraw.com';
|
|
756
|
+
}
|
|
757
|
+
parsed.hash = parsed.hash.replace(/\s+/g, '');
|
|
758
|
+
return parsed.toString();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function isExcalidrawUrl(url: string): boolean {
|
|
762
|
+
const parsed = tryParseUrl(url);
|
|
763
|
+
if (!parsed) return false;
|
|
764
|
+
const host = parsed.hostname.toLowerCase();
|
|
765
|
+
return host.includes('excalidraw.com') || host.includes('excalidraw-mcp-app');
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function normalizeMarkdownExternalUrls(markdown: string): string {
|
|
769
|
+
const normalizedLinks = markdown.replace(/https?:\/\/[^\s<>"'`)\]]+/gi, (url) =>
|
|
770
|
+
toPreferredExcalidrawUrl(url),
|
|
771
|
+
);
|
|
772
|
+
return normalizedLinks.replace(
|
|
773
|
+
/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/gi,
|
|
774
|
+
(full, altRaw: string, urlRaw: string) => {
|
|
775
|
+
const url = toPreferredExcalidrawUrl(urlRaw);
|
|
776
|
+
if (!isExcalidrawUrl(url)) return full;
|
|
777
|
+
const label = (altRaw || 'Open Excalidraw diagram').trim() || 'Open Excalidraw diagram';
|
|
778
|
+
return `> Excalidraw diagram: [${label}](${url})`;
|
|
779
|
+
},
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ── Canvas SPA HTML ────────────────────────────────────────────
|
|
784
|
+
|
|
785
|
+
function canvasSpaHtml(): string {
|
|
786
|
+
return `<!doctype html>
|
|
787
|
+
<html lang="en">
|
|
788
|
+
<head>
|
|
789
|
+
<meta charset="utf-8" />
|
|
790
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
791
|
+
<title>PMX Canvas</title>
|
|
792
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=focus-field" />
|
|
793
|
+
<link rel="alternate icon" href="/favicon.ico?v=focus-field" sizes="any" />
|
|
794
|
+
<style>
|
|
795
|
+
html, body {
|
|
796
|
+
margin: 0;
|
|
797
|
+
width: 100%;
|
|
798
|
+
height: 100%;
|
|
799
|
+
background: #081524;
|
|
800
|
+
color: #d9e2f2;
|
|
801
|
+
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
802
|
+
}
|
|
803
|
+
body { overflow: hidden; }
|
|
804
|
+
#app { width: 100%; height: 100%; }
|
|
805
|
+
#canvasBootstrap {
|
|
806
|
+
position: fixed;
|
|
807
|
+
inset: 0;
|
|
808
|
+
display: grid;
|
|
809
|
+
place-items: center;
|
|
810
|
+
padding: 24px;
|
|
811
|
+
background:
|
|
812
|
+
radial-gradient(circle at top left, rgba(62, 134, 255, 0.18), transparent 32%),
|
|
813
|
+
radial-gradient(circle at bottom right, rgba(0, 214, 201, 0.12), transparent 28%),
|
|
814
|
+
#081524;
|
|
815
|
+
z-index: 9999;
|
|
816
|
+
}
|
|
817
|
+
#canvasBootstrap.ready { display: none; }
|
|
818
|
+
.canvas-bootstrap-card {
|
|
819
|
+
width: min(480px, 100%);
|
|
820
|
+
padding: 22px 24px;
|
|
821
|
+
border-radius: 18px;
|
|
822
|
+
border: 1px solid rgba(132, 160, 214, 0.18);
|
|
823
|
+
background: rgba(11, 18, 29, 0.92);
|
|
824
|
+
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
|
|
825
|
+
}
|
|
826
|
+
.canvas-bootstrap-card strong {
|
|
827
|
+
display: block;
|
|
828
|
+
font-size: 18px;
|
|
829
|
+
line-height: 1.2;
|
|
830
|
+
margin-bottom: 8px;
|
|
831
|
+
}
|
|
832
|
+
.canvas-bootstrap-card p {
|
|
833
|
+
margin: 0;
|
|
834
|
+
color: #aebbd3;
|
|
835
|
+
line-height: 1.5;
|
|
836
|
+
font-size: 14px;
|
|
837
|
+
}
|
|
838
|
+
.canvas-bootstrap-actions {
|
|
839
|
+
display: flex;
|
|
840
|
+
gap: 12px;
|
|
841
|
+
margin-top: 18px;
|
|
842
|
+
}
|
|
843
|
+
.canvas-bootstrap-actions button {
|
|
844
|
+
border: 0;
|
|
845
|
+
border-radius: 999px;
|
|
846
|
+
padding: 10px 16px;
|
|
847
|
+
font: inherit;
|
|
848
|
+
cursor: pointer;
|
|
849
|
+
background: #233246;
|
|
850
|
+
color: #eef4ff;
|
|
851
|
+
}
|
|
852
|
+
</style>
|
|
853
|
+
<link rel="stylesheet" href="/canvas/global.css" />
|
|
854
|
+
</head>
|
|
855
|
+
<body>
|
|
856
|
+
<div id="canvasBootstrap">
|
|
857
|
+
<div class="canvas-bootstrap-card">
|
|
858
|
+
<strong>Opening PMX Canvas</strong>
|
|
859
|
+
<p id="canvasBootstrapCopy">Loading the shared PMX workbench...</p>
|
|
860
|
+
<div class="canvas-bootstrap-actions" id="canvasBootstrapActions" hidden>
|
|
861
|
+
<button type="button" onclick="window.location.reload()">Reload canvas</button>
|
|
862
|
+
</div>
|
|
863
|
+
</div>
|
|
864
|
+
</div>
|
|
865
|
+
<div id="app"></div>
|
|
866
|
+
<script>
|
|
867
|
+
(function () {
|
|
868
|
+
var bootstrap = document.getElementById('canvasBootstrap');
|
|
869
|
+
var copy = document.getElementById('canvasBootstrapCopy');
|
|
870
|
+
var actions = document.getElementById('canvasBootstrapActions');
|
|
871
|
+
window.__pmxCanvasBootstrapReady = function () {
|
|
872
|
+
if (!bootstrap) return;
|
|
873
|
+
bootstrap.classList.add('ready');
|
|
874
|
+
};
|
|
875
|
+
window.addEventListener('error', function (event) {
|
|
876
|
+
if (!bootstrap || !copy || !actions) return;
|
|
877
|
+
copy.textContent = event && event.message
|
|
878
|
+
? 'PMX Canvas hit a browser error while loading: ' + event.message
|
|
879
|
+
: 'PMX Canvas hit a browser error while loading.';
|
|
880
|
+
actions.hidden = false;
|
|
881
|
+
});
|
|
882
|
+
setTimeout(function () {
|
|
883
|
+
if (!bootstrap || !copy || !actions) return;
|
|
884
|
+
if (bootstrap.classList.contains('ready')) return;
|
|
885
|
+
copy.textContent = 'PMX Canvas did not finish booting. Reload the canvas or restart the server.';
|
|
886
|
+
actions.hidden = false;
|
|
887
|
+
}, 4000);
|
|
888
|
+
})();
|
|
889
|
+
</script>
|
|
890
|
+
<script type="module" src="/canvas/index.js"></script>
|
|
891
|
+
</body>
|
|
892
|
+
</html>`;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const CANVAS_STATIC_MIME: Record<string, string> = {
|
|
896
|
+
'.js': 'application/javascript',
|
|
897
|
+
'.css': 'text/css',
|
|
898
|
+
'.json': 'application/json',
|
|
899
|
+
'.map': 'application/json',
|
|
900
|
+
'.wasm': 'application/wasm',
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const CANVAS_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
904
|
+
<rect width="64" height="64" rx="14" fill="#081524"/>
|
|
905
|
+
<rect x="8" y="8" width="48" height="48" rx="7" fill="none" stroke="#4BBCFF" stroke-width="2.2" opacity="0.35"/>
|
|
906
|
+
<rect x="16" y="16" width="32" height="32" rx="5" fill="none" stroke="#4BBCFF" stroke-width="2.2" opacity="0.6"/>
|
|
907
|
+
<rect x="24" y="24" width="16" height="16" rx="3" fill="none" stroke="#4BBCFF" stroke-width="2.2"/>
|
|
908
|
+
<rect x="29" y="29" width="6" height="6" rx="1" fill="#4BBCFF"/>
|
|
909
|
+
</svg>`;
|
|
910
|
+
|
|
911
|
+
// Resolve canvas bundle directory — uses PMX_CANVAS_DIST env or fallback chain.
|
|
912
|
+
let _canvasBundleDir: string | null = null;
|
|
913
|
+
|
|
914
|
+
function resolveCanvasBundleDir(): string {
|
|
915
|
+
if (_canvasBundleDir) return _canvasBundleDir;
|
|
916
|
+
|
|
917
|
+
const candidates: string[] = [];
|
|
918
|
+
const explicitBundleDir = process.env.PMX_CANVAS_DIST?.trim();
|
|
919
|
+
|
|
920
|
+
if (explicitBundleDir) {
|
|
921
|
+
candidates.push(resolve(explicitBundleDir));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Adjacent to built module: dist/canvas/ when running dist/index.js
|
|
925
|
+
candidates.push(resolve(import.meta.dir, 'canvas'));
|
|
926
|
+
|
|
927
|
+
// Installed package layout: node_modules/pmx-canvas/dist/canvas/
|
|
928
|
+
candidates.push(resolve(import.meta.dir, '..', '..', 'dist', 'canvas'));
|
|
929
|
+
|
|
930
|
+
// cwd-based: works when cwd is the repo root
|
|
931
|
+
candidates.push(resolve(process.cwd(), 'dist', 'canvas'));
|
|
932
|
+
|
|
933
|
+
for (const dir of candidates) {
|
|
934
|
+
if (existsSync(resolve(dir, 'index.js'))) {
|
|
935
|
+
_canvasBundleDir = dir;
|
|
936
|
+
return dir;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Fallback: last candidate
|
|
941
|
+
const fallback = candidates[candidates.length - 1];
|
|
942
|
+
_canvasBundleDir = fallback;
|
|
943
|
+
return fallback;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function serveCanvasStatic(pathname: string): Response | null {
|
|
947
|
+
const fileName = pathname.slice('/canvas/'.length);
|
|
948
|
+
if (!fileName || fileName.includes('..') || fileName.startsWith('/')) return null;
|
|
949
|
+
|
|
950
|
+
const bundleDir = resolveCanvasBundleDir();
|
|
951
|
+
const distPath = resolve(bundleDir, fileName);
|
|
952
|
+
if (!distPath.startsWith(`${bundleDir}/`)) return null;
|
|
953
|
+
if (existsSync(distPath)) {
|
|
954
|
+
const ext = extname(fileName);
|
|
955
|
+
return new Response(readFileSync(distPath), {
|
|
956
|
+
headers: {
|
|
957
|
+
'Content-Type': CANVAS_STATIC_MIME[ext] ?? 'application/octet-stream',
|
|
958
|
+
'Cache-Control': 'public, max-age=300',
|
|
959
|
+
},
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function serveCanvasFavicon(): Response {
|
|
966
|
+
return new Response(CANVAS_FAVICON_SVG, {
|
|
967
|
+
headers: {
|
|
968
|
+
'Content-Type': 'image/svg+xml; charset=utf-8',
|
|
969
|
+
'Cache-Control': 'public, max-age=3600',
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// ── Canvas REST handlers ──────────────────────────────────────
|
|
975
|
+
|
|
976
|
+
async function handleCanvasUpdate(req: Request): Promise<Response> {
|
|
977
|
+
const body = await readJson(req);
|
|
978
|
+
const updates = Array.isArray(body.updates) ? body.updates : [];
|
|
979
|
+
const result = applyCanvasNodeUpdates(updates);
|
|
980
|
+
if (result.applied > 0) {
|
|
981
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
982
|
+
}
|
|
983
|
+
return responseJson({ ok: true, ...result });
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function handleCanvasViewport(req: Request): Promise<Response> {
|
|
987
|
+
const body = await readJson(req);
|
|
988
|
+
const next = {
|
|
989
|
+
x: typeof body.x === 'number' ? body.x : canvasState.viewport.x,
|
|
990
|
+
y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
|
|
991
|
+
scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
|
|
992
|
+
};
|
|
993
|
+
canvasState.setViewport(next);
|
|
994
|
+
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
995
|
+
return responseJson({ ok: true });
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ── Serve image file for image nodes ─────────────────────────
|
|
999
|
+
function handleCanvasImage(pathname: string): Response {
|
|
1000
|
+
const nodeId = pathname.replace('/api/canvas/image/', '');
|
|
1001
|
+
const node = canvasState.getNode(nodeId);
|
|
1002
|
+
if (!node || node.type !== 'image') {
|
|
1003
|
+
return responseText('Image node not found', 404);
|
|
1004
|
+
}
|
|
1005
|
+
const src = (node.data.path as string) || (node.data.src as string) || '';
|
|
1006
|
+
if (!src || src.startsWith('data:') || src.startsWith('http')) {
|
|
1007
|
+
return responseText('Not a file-based image', 400);
|
|
1008
|
+
}
|
|
1009
|
+
const safePath = resolve(src);
|
|
1010
|
+
if (!existsSync(safePath)) {
|
|
1011
|
+
return responseText('Image file not found', 404);
|
|
1012
|
+
}
|
|
1013
|
+
const ext = safePath.split('.').pop()?.toLowerCase() ?? '';
|
|
1014
|
+
const contentType = IMAGE_MIME_MAP[ext] || 'application/octet-stream';
|
|
1015
|
+
const data = readFileSync(safePath);
|
|
1016
|
+
return new Response(data, {
|
|
1017
|
+
headers: {
|
|
1018
|
+
'Content-Type': contentType,
|
|
1019
|
+
'Cache-Control': 'no-cache',
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ── Add node from client ─────────────────────────────────────
|
|
1025
|
+
const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'group']);
|
|
1026
|
+
|
|
1027
|
+
function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
|
|
1028
|
+
return {
|
|
1029
|
+
ok: true,
|
|
1030
|
+
...serializeCanvasNode(node),
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function createCanvasWebpageNode(body: Record<string, unknown>): Promise<Response> {
|
|
1035
|
+
const rawUrl = typeof body.url === 'string' && body.url.trim().length > 0
|
|
1036
|
+
? body.url
|
|
1037
|
+
: typeof body.content === 'string'
|
|
1038
|
+
? body.content
|
|
1039
|
+
: '';
|
|
1040
|
+
|
|
1041
|
+
let normalizedUrl: string;
|
|
1042
|
+
try {
|
|
1043
|
+
normalizedUrl = normalizeWebpageUrl(rawUrl);
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Invalid webpage URL.' }, 400);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
1049
|
+
? body.data as Record<string, unknown>
|
|
1050
|
+
: undefined;
|
|
1051
|
+
const { id, node } = addCanvasNode({
|
|
1052
|
+
type: 'webpage',
|
|
1053
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
1054
|
+
content: normalizedUrl,
|
|
1055
|
+
...(extraData ? { data: extraData } : {}),
|
|
1056
|
+
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1057
|
+
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
1058
|
+
...(typeof body.width === 'number' ? { width: body.width } : { width: WEBPAGE_NODE_DEFAULT_SIZE.width }),
|
|
1059
|
+
...(typeof body.height === 'number' ? { height: body.height } : { height: WEBPAGE_NODE_DEFAULT_SIZE.height }),
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1063
|
+
const refreshed = await refreshCanvasWebpageNode(id);
|
|
1064
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1065
|
+
const created = canvasState.getNode(id) ?? node;
|
|
1066
|
+
return responseJson({
|
|
1067
|
+
...buildNodeResponse(created),
|
|
1068
|
+
fetch: refreshed.ok
|
|
1069
|
+
? { ok: true }
|
|
1070
|
+
: { ok: false, error: refreshed.error ?? 'Failed to fetch webpage content.' },
|
|
1071
|
+
...(refreshed.ok ? {} : { error: refreshed.error }),
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function handleCanvasAddNode(req: Request): Promise<Response> {
|
|
1076
|
+
const body = await readJson(req);
|
|
1077
|
+
const type = (body.type as string) || 'markdown';
|
|
1078
|
+
|
|
1079
|
+
if (!VALID_NODE_TYPES.has(type)) {
|
|
1080
|
+
if (type === 'json-render') {
|
|
1081
|
+
return responseJson({
|
|
1082
|
+
ok: false,
|
|
1083
|
+
error: 'Node type "json-render" is created via POST /api/canvas/json-render. See /api/canvas/schema for the required spec shape.',
|
|
1084
|
+
}, 400);
|
|
1085
|
+
}
|
|
1086
|
+
if (type === 'graph') {
|
|
1087
|
+
return responseJson({
|
|
1088
|
+
ok: false,
|
|
1089
|
+
error: 'Node type "graph" is created via POST /api/canvas/graph. See /api/canvas/schema for graphType + data fields.',
|
|
1090
|
+
}, 400);
|
|
1091
|
+
}
|
|
1092
|
+
if (type === 'web-artifact') {
|
|
1093
|
+
return responseJson({
|
|
1094
|
+
ok: false,
|
|
1095
|
+
error: 'Node type "web-artifact" is created via POST /api/canvas/web-artifact with appTsx + title.',
|
|
1096
|
+
}, 400);
|
|
1097
|
+
}
|
|
1098
|
+
return responseJson({ ok: false, error: `Invalid node type: "${type}".` }, 400);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (type === 'webpage') {
|
|
1102
|
+
return createCanvasWebpageNode(body);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
|
|
1106
|
+
? body.data as Record<string, unknown>
|
|
1107
|
+
: undefined;
|
|
1108
|
+
const { id, node, needsCodeGraphRecompute } = addCanvasNode({
|
|
1109
|
+
type: type as CanvasNodeState['type'],
|
|
1110
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
1111
|
+
...(typeof body.content === 'string' ? { content: body.content } : {}),
|
|
1112
|
+
...(extraData ? { data: extraData } : {}),
|
|
1113
|
+
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1114
|
+
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
1115
|
+
...(typeof body.width === 'number' ? { width: body.width } : {}),
|
|
1116
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
1117
|
+
defaultWidth: 360,
|
|
1118
|
+
defaultHeight: 200,
|
|
1119
|
+
fileMode: 'auto',
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1123
|
+
if (needsCodeGraphRecompute) {
|
|
1124
|
+
scheduleCodeGraphRecompute(() => {
|
|
1125
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
return responseJson(buildNodeResponse(node));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ── Group operations ─────────────────────────────────────────
|
|
1132
|
+
async function handleCanvasCreateGroup(req: Request): Promise<Response> {
|
|
1133
|
+
const body = await readJson(req);
|
|
1134
|
+
const title = typeof body.title === 'string' ? body.title : 'Group';
|
|
1135
|
+
const childIds = Array.isArray(body.childIds) ? body.childIds.filter((id: unknown) => typeof id === 'string') : [];
|
|
1136
|
+
const color = typeof body.color === 'string' ? body.color : undefined;
|
|
1137
|
+
const x = typeof body.x === 'number' ? body.x : undefined;
|
|
1138
|
+
const y = typeof body.y === 'number' ? body.y : undefined;
|
|
1139
|
+
const width = typeof body.width === 'number' ? body.width : undefined;
|
|
1140
|
+
const height = typeof body.height === 'number' ? body.height : undefined;
|
|
1141
|
+
const childLayout =
|
|
1142
|
+
body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
|
|
1143
|
+
? body.childLayout
|
|
1144
|
+
: undefined;
|
|
1145
|
+
|
|
1146
|
+
const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
|
|
1147
|
+
|
|
1148
|
+
broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1149
|
+
return responseJson(buildNodeResponse(node));
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function handleCanvasGroupNodes(req: Request): Promise<Response> {
|
|
1153
|
+
const body = await readJson(req);
|
|
1154
|
+
const groupId = body.groupId as string;
|
|
1155
|
+
const childIds = Array.isArray(body.childIds) ? body.childIds.filter((id: unknown) => typeof id === 'string') : [];
|
|
1156
|
+
const childLayout =
|
|
1157
|
+
body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
|
|
1158
|
+
? body.childLayout
|
|
1159
|
+
: undefined;
|
|
1160
|
+
if (!groupId || childIds.length === 0) {
|
|
1161
|
+
return responseJson({ ok: false, error: 'Missing groupId or childIds.' }, 400);
|
|
1162
|
+
}
|
|
1163
|
+
const { ok } = groupCanvasNodes(groupId, childIds, childLayout ? { childLayout } : {});
|
|
1164
|
+
if (!ok) return responseJson({ ok: false, error: 'Group not found or no valid children.' }, 400);
|
|
1165
|
+
broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1166
|
+
return responseJson({ ok: true, groupId });
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function handleCanvasUngroupNodes(req: Request): Promise<Response> {
|
|
1170
|
+
const body = await readJson(req);
|
|
1171
|
+
const groupId = body.groupId as string;
|
|
1172
|
+
if (!groupId) return responseJson({ ok: false, error: 'Missing groupId.' }, 400);
|
|
1173
|
+
const { ok } = ungroupCanvasNodes(groupId);
|
|
1174
|
+
if (!ok) return responseJson({ ok: false, error: 'Group not found or empty.' }, 400);
|
|
1175
|
+
broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1176
|
+
return responseJson({ ok: true, groupId });
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const VALID_EDGE_TYPES = new Set(['relation', 'depends-on', 'flow', 'references']);
|
|
1180
|
+
const VALID_EDGE_STYLES = new Set(['solid', 'dashed', 'dotted']);
|
|
1181
|
+
|
|
1182
|
+
async function handleCanvasAddEdge(req: Request): Promise<Response> {
|
|
1183
|
+
const body = await readJson(req);
|
|
1184
|
+
const type = body.type as string;
|
|
1185
|
+
const style = typeof body.style === 'string' ? body.style : undefined;
|
|
1186
|
+
|
|
1187
|
+
if (
|
|
1188
|
+
!type ||
|
|
1189
|
+
(!body.from && !body.fromSearch) ||
|
|
1190
|
+
(!body.to && !body.toSearch)
|
|
1191
|
+
) {
|
|
1192
|
+
return responseJson({ ok: false, error: 'Missing required fields: type plus from/fromSearch and to/toSearch.' }, 400);
|
|
1193
|
+
}
|
|
1194
|
+
if (!VALID_EDGE_TYPES.has(type)) {
|
|
1195
|
+
return responseJson({ ok: false, error: `Invalid edge type: "${type}".` }, 400);
|
|
1196
|
+
}
|
|
1197
|
+
if (style && !VALID_EDGE_STYLES.has(style)) {
|
|
1198
|
+
return responseJson({ ok: false, error: `Invalid edge style: "${style}". Use solid, dashed, or dotted.` }, 400);
|
|
1199
|
+
}
|
|
1200
|
+
try {
|
|
1201
|
+
const result = addCanvasEdge({
|
|
1202
|
+
...(typeof body.from === 'string' ? { from: body.from } : {}),
|
|
1203
|
+
...(typeof body.to === 'string' ? { to: body.to } : {}),
|
|
1204
|
+
...(typeof body.fromSearch === 'string' ? { fromSearch: body.fromSearch } : {}),
|
|
1205
|
+
...(typeof body.toSearch === 'string' ? { toSearch: body.toSearch } : {}),
|
|
1206
|
+
type: type as CanvasEdge['type'],
|
|
1207
|
+
...(body.label ? { label: String(body.label) } : {}),
|
|
1208
|
+
...(style ? { style: style as CanvasEdge['style'] } : {}),
|
|
1209
|
+
...(body.animated !== undefined ? { animated: Boolean(body.animated) } : {}),
|
|
1210
|
+
});
|
|
1211
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1212
|
+
return responseJson({ ok: true, ...result });
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Duplicate or self-edge.' }, 400);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
async function handleCanvasRemoveEdge(req: Request): Promise<Response> {
|
|
1219
|
+
const body = await readJson(req);
|
|
1220
|
+
const edgeId = body.edge_id as string;
|
|
1221
|
+
if (!edgeId) {
|
|
1222
|
+
return responseJson({ ok: false, error: 'Missing edge_id.' }, 400);
|
|
1223
|
+
}
|
|
1224
|
+
const { removed } = removeCanvasEdge(edgeId);
|
|
1225
|
+
if (!removed) {
|
|
1226
|
+
return responseJson({ ok: false, error: `Edge "${edgeId}" not found.` }, 404);
|
|
1227
|
+
}
|
|
1228
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1229
|
+
return responseJson({ ok: true, removed: edgeId });
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
async function handleCanvasRefreshWebpageNode(nodeId: string, req: Request): Promise<Response> {
|
|
1233
|
+
const existing = canvasState.getNode(nodeId);
|
|
1234
|
+
if (!existing || existing.type !== 'webpage') {
|
|
1235
|
+
return responseJson({ ok: false, error: `Webpage node "${nodeId}" not found.` }, 404);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const body = await readJson(req);
|
|
1239
|
+
const rawUrl = typeof body.url === 'string' ? body.url : undefined;
|
|
1240
|
+
let url: string | undefined;
|
|
1241
|
+
if (rawUrl && rawUrl.trim().length > 0) {
|
|
1242
|
+
try {
|
|
1243
|
+
url = normalizeWebpageUrl(rawUrl);
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Invalid webpage URL.' }, 400);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const result = await refreshCanvasWebpageNode(nodeId, { ...(url ? { url } : {}) });
|
|
1250
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1251
|
+
return responseJson(result, result.ok ? 200 : 400);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// ── Individual node update (PATCH) ──────────────────────────
|
|
1255
|
+
async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Response> {
|
|
1256
|
+
const existing = canvasState.getNode(nodeId);
|
|
1257
|
+
if (!existing) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
1258
|
+
const body = await readJson(req);
|
|
1259
|
+
if (existing.type === 'webpage' && body.refresh === true) {
|
|
1260
|
+
return handleCanvasRefreshWebpageNode(nodeId, req);
|
|
1261
|
+
}
|
|
1262
|
+
const patch: Record<string, unknown> = {};
|
|
1263
|
+
if (body.position) patch.position = body.position;
|
|
1264
|
+
if (body.size) patch.size = body.size;
|
|
1265
|
+
if (body.collapsed !== undefined) patch.collapsed = body.collapsed;
|
|
1266
|
+
if (body.pinned !== undefined) patch.pinned = Boolean(body.pinned);
|
|
1267
|
+
if (body.dockPosition === null || body.dockPosition === 'left' || body.dockPosition === 'right') {
|
|
1268
|
+
patch.dockPosition = body.dockPosition;
|
|
1269
|
+
}
|
|
1270
|
+
if (body.title !== undefined || body.content !== undefined || body.data || typeof body.arrangeLocked === 'boolean') {
|
|
1271
|
+
const data = { ...existing.data };
|
|
1272
|
+
if (body.title !== undefined) {
|
|
1273
|
+
data.title = String(body.title);
|
|
1274
|
+
if (existing.type === 'webpage') {
|
|
1275
|
+
data.titleSource = 'user';
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (body.content !== undefined) data.content = String(body.content);
|
|
1279
|
+
if (typeof body.arrangeLocked === 'boolean') data.arrangeLocked = body.arrangeLocked;
|
|
1280
|
+
// Merge extra data fields (for status, context, ledger, trace nodes)
|
|
1281
|
+
if (body.data && typeof body.data === 'object' && !Array.isArray(body.data)) {
|
|
1282
|
+
Object.assign(data, body.data as Record<string, unknown>);
|
|
1283
|
+
}
|
|
1284
|
+
if (existing.type === 'webpage') {
|
|
1285
|
+
const nextUrl = typeof body.url === 'string'
|
|
1286
|
+
? body.url
|
|
1287
|
+
: typeof (body.data as Record<string, unknown> | undefined)?.url === 'string'
|
|
1288
|
+
? (body.data as Record<string, unknown>).url as string
|
|
1289
|
+
: undefined;
|
|
1290
|
+
if (typeof nextUrl === 'string' && nextUrl.trim().length > 0) {
|
|
1291
|
+
try {
|
|
1292
|
+
data.url = normalizeWebpageUrl(nextUrl);
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : 'Invalid webpage URL.' }, 400);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
patch.data = data;
|
|
1299
|
+
}
|
|
1300
|
+
const error = validateCanvasNodePatch({
|
|
1301
|
+
...(patch.position ? { position: patch.position as { x: number; y: number } } : {}),
|
|
1302
|
+
...(patch.size ? { size: patch.size as { width: number; height: number } } : {}),
|
|
1303
|
+
});
|
|
1304
|
+
if (error) return responseJson({ ok: false, error }, 400);
|
|
1305
|
+
canvasState.updateNode(nodeId, patch as Partial<CanvasNodeState>);
|
|
1306
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1307
|
+
return responseJson({ ok: true, id: nodeId });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ── Arrange nodes ───────────────────────────────────────────
|
|
1311
|
+
async function handleCanvasArrange(req: Request): Promise<Response> {
|
|
1312
|
+
const body = await readJson(req);
|
|
1313
|
+
const layout = typeof body.layout === 'string' ? body.layout : 'grid';
|
|
1314
|
+
if (!['grid', 'column', 'flow'].includes(layout)) {
|
|
1315
|
+
return responseJson({ ok: false, error: `Invalid layout: "${layout}". Use: grid, column, flow` }, 400);
|
|
1316
|
+
}
|
|
1317
|
+
const result = arrangeCanvasNodes(layout as 'grid' | 'column' | 'flow');
|
|
1318
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1319
|
+
return responseJson({ ok: true, arranged: result.arranged, layout: result.layout });
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// ── Focus on node ───────────────────────────────────────────
|
|
1323
|
+
async function handleCanvasFocus(req: Request): Promise<Response> {
|
|
1324
|
+
const body = await readJson(req);
|
|
1325
|
+
const nodeId = body.id as string;
|
|
1326
|
+
if (!nodeId) return responseJson({ ok: false, error: 'Missing id.' }, 400);
|
|
1327
|
+
const node = canvasState.getNode(nodeId);
|
|
1328
|
+
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
1329
|
+
canvasState.setViewport({ x: node.position.x - 100, y: node.position.y - 100 });
|
|
1330
|
+
emitPrimaryWorkbenchEvent('canvas-focus-node', { nodeId });
|
|
1331
|
+
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
1332
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1333
|
+
return responseJson({ ok: true, focused: nodeId });
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
|
|
1337
|
+
const body = await readJson(req);
|
|
1338
|
+
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
|
1339
|
+
const appTsx = typeof body.appTsx === 'string' ? body.appTsx : '';
|
|
1340
|
+
if (!title || !appTsx) {
|
|
1341
|
+
return responseJson({ ok: false, error: 'Missing required fields: title, appTsx.' }, 400);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const files: Record<string, string> = {};
|
|
1345
|
+
if (body.files && typeof body.files === 'object' && !Array.isArray(body.files)) {
|
|
1346
|
+
for (const [pathKey, value] of Object.entries(body.files as Record<string, unknown>)) {
|
|
1347
|
+
if (typeof value === 'string') files[pathKey] = value;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
try {
|
|
1352
|
+
const result = await buildWebArtifactOnCanvas({
|
|
1353
|
+
title,
|
|
1354
|
+
appTsx,
|
|
1355
|
+
...(typeof body.indexCss === 'string' ? { indexCss: body.indexCss } : {}),
|
|
1356
|
+
...(typeof body.mainTsx === 'string' ? { mainTsx: body.mainTsx } : {}),
|
|
1357
|
+
...(typeof body.indexHtml === 'string' ? { indexHtml: body.indexHtml } : {}),
|
|
1358
|
+
...(Object.keys(files).length > 0 ? { files } : {}),
|
|
1359
|
+
...(typeof body.projectPath === 'string'
|
|
1360
|
+
? { projectPath: resolveWorkspacePath(body.projectPath, activeWorkspaceRoot) }
|
|
1361
|
+
: {}),
|
|
1362
|
+
...(typeof body.outputPath === 'string'
|
|
1363
|
+
? { outputPath: resolveWorkspacePath(body.outputPath, activeWorkspaceRoot) }
|
|
1364
|
+
: {}),
|
|
1365
|
+
...(typeof body.initScriptPath === 'string'
|
|
1366
|
+
? { initScriptPath: body.initScriptPath }
|
|
1367
|
+
: {}),
|
|
1368
|
+
...(typeof body.bundleScriptPath === 'string'
|
|
1369
|
+
? { bundleScriptPath: body.bundleScriptPath }
|
|
1370
|
+
: {}),
|
|
1371
|
+
...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
|
|
1372
|
+
...(typeof body.openInCanvas === 'boolean' ? { openInCanvas: body.openInCanvas } : {}),
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
return responseJson({
|
|
1376
|
+
ok: true,
|
|
1377
|
+
path: result.filePath,
|
|
1378
|
+
bytes: result.fileSize,
|
|
1379
|
+
projectPath: result.projectPath,
|
|
1380
|
+
openedInCanvas: result.openedInCanvas,
|
|
1381
|
+
nodeId: result.nodeId,
|
|
1382
|
+
url: result.url,
|
|
1383
|
+
metadata: result.metadata,
|
|
1384
|
+
logs: result.logs,
|
|
1385
|
+
...(body.includeLogs === true ? {
|
|
1386
|
+
stdout: result.stdout,
|
|
1387
|
+
stderr: result.stderr,
|
|
1388
|
+
} : {}),
|
|
1389
|
+
});
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1392
|
+
return responseJson({ ok: false, error: message }, 400);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function handleCanvasDescribeSchema(): Response {
|
|
1397
|
+
return responseJson(describeCanvasSchema());
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
async function handleCanvasValidateSpec(req: Request): Promise<Response> {
|
|
1401
|
+
const body = await readJson(req);
|
|
1402
|
+
const rawType = typeof body.type === 'string' ? body.type.trim() : '';
|
|
1403
|
+
if (rawType !== 'json-render' && rawType !== 'graph') {
|
|
1404
|
+
return responseJson({ ok: false, error: 'Validation type must be "json-render" or "graph".' }, 400);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
try {
|
|
1408
|
+
if (rawType === 'json-render') {
|
|
1409
|
+
const rawSpec =
|
|
1410
|
+
body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec)
|
|
1411
|
+
? body.spec
|
|
1412
|
+
: body;
|
|
1413
|
+
return responseJson(validateStructuredCanvasPayload({
|
|
1414
|
+
type: 'json-render',
|
|
1415
|
+
spec: rawSpec,
|
|
1416
|
+
}));
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
const data = Array.isArray(body.data)
|
|
1420
|
+
? body.data.filter((item: unknown) => item && typeof item === 'object') as Array<Record<string, unknown>>
|
|
1421
|
+
: null;
|
|
1422
|
+
if (!data) {
|
|
1423
|
+
return responseJson({ ok: false, error: 'Graph validation requires a data array.' }, 400);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const aggregate =
|
|
1427
|
+
body.aggregate === 'sum' || body.aggregate === 'count' || body.aggregate === 'avg'
|
|
1428
|
+
? body.aggregate
|
|
1429
|
+
: undefined;
|
|
1430
|
+
|
|
1431
|
+
return responseJson(validateStructuredCanvasPayload({
|
|
1432
|
+
type: 'graph',
|
|
1433
|
+
graph: {
|
|
1434
|
+
title: typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph',
|
|
1435
|
+
graphType: typeof body.graphType === 'string'
|
|
1436
|
+
? body.graphType
|
|
1437
|
+
: typeof body.typeName === 'string'
|
|
1438
|
+
? body.typeName
|
|
1439
|
+
: 'line',
|
|
1440
|
+
data,
|
|
1441
|
+
...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
|
|
1442
|
+
...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
|
|
1443
|
+
...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
|
|
1444
|
+
...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
|
|
1445
|
+
...(aggregate ? { aggregate } : {}),
|
|
1446
|
+
...(typeof body.color === 'string' ? { color: body.color } : {}),
|
|
1447
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
1448
|
+
},
|
|
1449
|
+
}));
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1452
|
+
return responseJson({ ok: false, error: message, type: rawType }, 400);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
|
|
1457
|
+
const body = await readJson(req);
|
|
1458
|
+
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
|
1459
|
+
const rawSpec =
|
|
1460
|
+
body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
|
|
1461
|
+
if (!title) {
|
|
1462
|
+
return responseJson({ ok: false, error: 'Missing required field: title.' }, 400);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
try {
|
|
1466
|
+
const result = createCanvasJsonRenderNode({
|
|
1467
|
+
title,
|
|
1468
|
+
spec: rawSpec,
|
|
1469
|
+
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1470
|
+
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
1471
|
+
...(typeof body.width === 'number' ? { width: body.width } : {}),
|
|
1472
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
1473
|
+
});
|
|
1474
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1475
|
+
return responseJson({ ok: true, ...result, ...serializeCanvasNode(result.node) });
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1478
|
+
return responseJson({ ok: false, error: message }, 400);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
async function handleCanvasAddGraph(req: Request): Promise<Response> {
|
|
1483
|
+
const body = await readJson(req);
|
|
1484
|
+
const title = typeof body.title === 'string' && body.title.trim() ? body.title.trim() : 'Graph';
|
|
1485
|
+
const graphType = typeof body.graphType === 'string' ? body.graphType : typeof body.type === 'string' ? body.type : 'line';
|
|
1486
|
+
const data = Array.isArray(body.data)
|
|
1487
|
+
? body.data.filter((item: unknown) => item && typeof item === 'object') as Array<Record<string, unknown>>
|
|
1488
|
+
: null;
|
|
1489
|
+
if (!data) {
|
|
1490
|
+
return responseJson({ ok: false, error: 'Missing required field: data.' }, 400);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
try {
|
|
1494
|
+
const aggregate =
|
|
1495
|
+
body.aggregate === 'sum' || body.aggregate === 'count' || body.aggregate === 'avg'
|
|
1496
|
+
? body.aggregate
|
|
1497
|
+
: undefined;
|
|
1498
|
+
const metrics = Array.isArray(body.metrics)
|
|
1499
|
+
? body.metrics.filter((m: unknown): m is string => typeof m === 'string')
|
|
1500
|
+
: null;
|
|
1501
|
+
const series = Array.isArray(body.series)
|
|
1502
|
+
? body.series.filter((s: unknown): s is string => typeof s === 'string')
|
|
1503
|
+
: null;
|
|
1504
|
+
const result = createCanvasGraphNode({
|
|
1505
|
+
title,
|
|
1506
|
+
graphType,
|
|
1507
|
+
data,
|
|
1508
|
+
...(typeof body.xKey === 'string' ? { xKey: body.xKey } : {}),
|
|
1509
|
+
...(typeof body.yKey === 'string' ? { yKey: body.yKey } : {}),
|
|
1510
|
+
...(typeof body.zKey === 'string' ? { zKey: body.zKey } : {}),
|
|
1511
|
+
...(typeof body.nameKey === 'string' ? { nameKey: body.nameKey } : {}),
|
|
1512
|
+
...(typeof body.valueKey === 'string' ? { valueKey: body.valueKey } : {}),
|
|
1513
|
+
...(typeof body.axisKey === 'string' ? { axisKey: body.axisKey } : {}),
|
|
1514
|
+
...(metrics ? { metrics } : {}),
|
|
1515
|
+
...(series ? { series } : {}),
|
|
1516
|
+
...(typeof body.barKey === 'string' ? { barKey: body.barKey } : {}),
|
|
1517
|
+
...(typeof body.lineKey === 'string' ? { lineKey: body.lineKey } : {}),
|
|
1518
|
+
...(aggregate ? { aggregate } : {}),
|
|
1519
|
+
...(typeof body.color === 'string' ? { color: body.color } : {}),
|
|
1520
|
+
...(typeof body.barColor === 'string' ? { barColor: body.barColor } : {}),
|
|
1521
|
+
...(typeof body.lineColor === 'string' ? { lineColor: body.lineColor } : {}),
|
|
1522
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
1523
|
+
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1524
|
+
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
1525
|
+
...(typeof body.width === 'number' ? { width: body.width } : {}),
|
|
1526
|
+
...(typeof body.nodeHeight === 'number' ? { heightPx: body.nodeHeight } : {}),
|
|
1527
|
+
});
|
|
1528
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1529
|
+
return responseJson({ ok: true, ...result, ...serializeCanvasNode(result.node) });
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1532
|
+
return responseJson({ ok: false, error: message }, 400);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async function handleCanvasBatch(req: Request): Promise<Response> {
|
|
1537
|
+
const body = await readJson(req);
|
|
1538
|
+
const operations = Array.isArray(body.operations) ? body.operations : Array.isArray(body) ? body : [];
|
|
1539
|
+
const normalized = operations
|
|
1540
|
+
.filter((operation): operation is Record<string, unknown> => operation && typeof operation === 'object' && !Array.isArray(operation))
|
|
1541
|
+
.map((operation) => ({
|
|
1542
|
+
op: String(operation.op ?? ''),
|
|
1543
|
+
...(typeof operation.assign === 'string' ? { assign: operation.assign } : {}),
|
|
1544
|
+
args: operation.args && typeof operation.args === 'object' && !Array.isArray(operation.args)
|
|
1545
|
+
? operation.args as Record<string, unknown>
|
|
1546
|
+
: {},
|
|
1547
|
+
}));
|
|
1548
|
+
const result = await executeCanvasBatch(normalized);
|
|
1549
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
1550
|
+
return responseJson(result, result.ok ? 200 : 400);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function handleCanvasValidate(): Response {
|
|
1554
|
+
return responseJson(validateCanvasLayout(canvasState.getLayout()));
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function handleJsonRenderView(url: URL): Promise<Response> {
|
|
1558
|
+
const nodeId = url.searchParams.get('nodeId') ?? '';
|
|
1559
|
+
if (!nodeId) return responseText('Missing nodeId', 400);
|
|
1560
|
+
const node = canvasState.getNode(nodeId);
|
|
1561
|
+
if (!node || (node.type !== 'json-render' && node.type !== 'graph')) {
|
|
1562
|
+
return responseText('json-render node not found', 404);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const spec = node.data.spec;
|
|
1566
|
+
if (!spec || typeof spec !== 'object') {
|
|
1567
|
+
return responseText('json-render spec missing', 404);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const themeValue = url.searchParams.get('theme');
|
|
1571
|
+
const theme =
|
|
1572
|
+
themeValue === 'dark' || themeValue === 'light' || themeValue === 'high-contrast'
|
|
1573
|
+
? themeValue
|
|
1574
|
+
: undefined;
|
|
1575
|
+
const title = (node.data.title as string) || node.id;
|
|
1576
|
+
const html = await buildJsonRenderViewerHtml({
|
|
1577
|
+
title,
|
|
1578
|
+
spec: spec as { root: string; elements: Record<string, unknown>; state?: Record<string, unknown> },
|
|
1579
|
+
...(theme ? { theme } : {}),
|
|
1580
|
+
});
|
|
1581
|
+
return new Response(html, {
|
|
1582
|
+
headers: {
|
|
1583
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1584
|
+
'Cache-Control': 'no-store',
|
|
1585
|
+
},
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function responseJson(data: unknown, status = 200): Response {
|
|
1590
|
+
return Response.json(data, {
|
|
1591
|
+
status,
|
|
1592
|
+
headers: {
|
|
1593
|
+
'Cache-Control': 'no-store',
|
|
1594
|
+
},
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function responseText(text: string, status = 400): Response {
|
|
1599
|
+
return new Response(text, {
|
|
1600
|
+
status,
|
|
1601
|
+
headers: {
|
|
1602
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
1603
|
+
'Cache-Control': 'no-store',
|
|
1604
|
+
},
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function handleArtifactView(url: URL): Response {
|
|
1609
|
+
const pathLike = url.searchParams.get('path') ?? '';
|
|
1610
|
+
const safePath = resolveWorkspaceArtifactPath(pathLike);
|
|
1611
|
+
if (!safePath) return responseText('Invalid artifact path', 400);
|
|
1612
|
+
if (!existsSync(safePath)) return responseText('Artifact not found', 404);
|
|
1613
|
+
|
|
1614
|
+
const stat = statSync(safePath);
|
|
1615
|
+
if (!stat.isFile()) return responseText('Not a file', 400);
|
|
1616
|
+
|
|
1617
|
+
const ext = extname(safePath).toLowerCase();
|
|
1618
|
+
const imageExt = ext.replace(/^\./, '');
|
|
1619
|
+
if (IMAGE_MIME_MAP[imageExt]) {
|
|
1620
|
+
const data = readFileSync(safePath);
|
|
1621
|
+
return new Response(data, {
|
|
1622
|
+
headers: {
|
|
1623
|
+
'Content-Type': IMAGE_MIME_MAP[imageExt],
|
|
1624
|
+
'Cache-Control': 'no-store',
|
|
1625
|
+
},
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
if (ext === '.html' || ext === '.htm') {
|
|
1630
|
+
const content = readFileSync(safePath, 'utf-8');
|
|
1631
|
+
return new Response(content, {
|
|
1632
|
+
headers: {
|
|
1633
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1634
|
+
'Cache-Control': 'no-store',
|
|
1635
|
+
},
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const content = readFileSync(safePath, 'utf-8');
|
|
1640
|
+
const title = basename(safePath);
|
|
1641
|
+
const relPath = relative(activeWorkspaceRoot, safePath) || title;
|
|
1642
|
+
const body =
|
|
1643
|
+
ext === '.md'
|
|
1644
|
+
? `<article class="markdown-body">${marked.parse(content) as string}</article>`
|
|
1645
|
+
: `<pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>`;
|
|
1646
|
+
|
|
1647
|
+
return new Response(`<!doctype html>
|
|
1648
|
+
<html lang="en">
|
|
1649
|
+
<head>
|
|
1650
|
+
<meta charset="utf-8" />
|
|
1651
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1652
|
+
<title>${escapeHtml(title)} · PMX Artifact</title>
|
|
1653
|
+
<style>
|
|
1654
|
+
:root {
|
|
1655
|
+
color-scheme: dark;
|
|
1656
|
+
--bg: #081524;
|
|
1657
|
+
--panel: #111a2d;
|
|
1658
|
+
--line: rgba(110, 140, 190, 0.22);
|
|
1659
|
+
--text: #d9e2f2;
|
|
1660
|
+
--muted: #9da8bd;
|
|
1661
|
+
--accent: #46b6ff;
|
|
1662
|
+
}
|
|
1663
|
+
* { box-sizing: border-box; }
|
|
1664
|
+
body {
|
|
1665
|
+
margin: 0;
|
|
1666
|
+
background:
|
|
1667
|
+
radial-gradient(circle at top left, rgba(62, 134, 255, 0.12), transparent 26%),
|
|
1668
|
+
radial-gradient(circle at bottom right, rgba(0, 214, 201, 0.08), transparent 24%),
|
|
1669
|
+
var(--bg);
|
|
1670
|
+
color: var(--text);
|
|
1671
|
+
font: 15px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1672
|
+
}
|
|
1673
|
+
.shell {
|
|
1674
|
+
max-width: 1120px;
|
|
1675
|
+
margin: 0 auto;
|
|
1676
|
+
padding: 32px 20px 48px;
|
|
1677
|
+
}
|
|
1678
|
+
.header {
|
|
1679
|
+
margin-bottom: 20px;
|
|
1680
|
+
padding: 16px 18px;
|
|
1681
|
+
border: 1px solid var(--line);
|
|
1682
|
+
border-radius: 16px;
|
|
1683
|
+
background: rgba(17, 26, 45, 0.92);
|
|
1684
|
+
backdrop-filter: blur(8px);
|
|
1685
|
+
}
|
|
1686
|
+
.title {
|
|
1687
|
+
margin: 0;
|
|
1688
|
+
font-size: 18px;
|
|
1689
|
+
font-weight: 700;
|
|
1690
|
+
}
|
|
1691
|
+
.path {
|
|
1692
|
+
margin-top: 4px;
|
|
1693
|
+
color: var(--muted);
|
|
1694
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1695
|
+
font-size: 12px;
|
|
1696
|
+
}
|
|
1697
|
+
.panel {
|
|
1698
|
+
border: 1px solid var(--line);
|
|
1699
|
+
border-radius: 18px;
|
|
1700
|
+
background: rgba(17, 26, 45, 0.94);
|
|
1701
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.28);
|
|
1702
|
+
overflow: hidden;
|
|
1703
|
+
}
|
|
1704
|
+
.artifact-code, .markdown-body {
|
|
1705
|
+
margin: 0;
|
|
1706
|
+
padding: 24px;
|
|
1707
|
+
}
|
|
1708
|
+
.artifact-code {
|
|
1709
|
+
overflow: auto;
|
|
1710
|
+
white-space: pre-wrap;
|
|
1711
|
+
word-break: break-word;
|
|
1712
|
+
font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1713
|
+
}
|
|
1714
|
+
.markdown-body :first-child { margin-top: 0; }
|
|
1715
|
+
.markdown-body :last-child { margin-bottom: 0; }
|
|
1716
|
+
.markdown-body pre {
|
|
1717
|
+
overflow: auto;
|
|
1718
|
+
padding: 14px;
|
|
1719
|
+
border-radius: 12px;
|
|
1720
|
+
background: rgba(4, 10, 20, 0.88);
|
|
1721
|
+
border: 1px solid var(--line);
|
|
1722
|
+
}
|
|
1723
|
+
.markdown-body code {
|
|
1724
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1725
|
+
font-size: 0.95em;
|
|
1726
|
+
}
|
|
1727
|
+
.markdown-body a { color: var(--accent); }
|
|
1728
|
+
.markdown-body blockquote {
|
|
1729
|
+
margin: 0;
|
|
1730
|
+
padding-left: 16px;
|
|
1731
|
+
border-left: 3px solid rgba(70, 182, 255, 0.45);
|
|
1732
|
+
color: var(--muted);
|
|
1733
|
+
}
|
|
1734
|
+
</style>
|
|
1735
|
+
</head>
|
|
1736
|
+
<body>
|
|
1737
|
+
<main class="shell">
|
|
1738
|
+
<header class="header">
|
|
1739
|
+
<h1 class="title">${escapeHtml(title)}</h1>
|
|
1740
|
+
<div class="path">${escapeHtml(relPath)}</div>
|
|
1741
|
+
</header>
|
|
1742
|
+
<section class="panel">${body}</section>
|
|
1743
|
+
</main>
|
|
1744
|
+
</body>
|
|
1745
|
+
</html>`, {
|
|
1746
|
+
headers: {
|
|
1747
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1748
|
+
'Cache-Control': 'no-store',
|
|
1749
|
+
},
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
function handleRead(pathLike: string): Response {
|
|
1754
|
+
const safePath = resolveWorkspaceMarkdownPath(pathLike);
|
|
1755
|
+
if (!safePath) return responseText('Invalid path', 400);
|
|
1756
|
+
if (!existsSync(safePath)) return responseText('File not found', 404);
|
|
1757
|
+
const stat = statSync(safePath);
|
|
1758
|
+
if (!stat.isFile()) return responseText('Not a file', 400);
|
|
1759
|
+
const content = readFileSync(safePath, 'utf-8');
|
|
1760
|
+
return responseJson({
|
|
1761
|
+
path: safePath,
|
|
1762
|
+
title: basename(safePath),
|
|
1763
|
+
content,
|
|
1764
|
+
updatedAt: new Date(stat.mtimeMs).toISOString(),
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function randomExtAppToolCallId(): string {
|
|
1769
|
+
return `ext-app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function nodeAppSessionId(node: CanvasNodeState | undefined): string | null {
|
|
1773
|
+
if (!node || node.type !== 'mcp-app') return null;
|
|
1774
|
+
const sessionId = node.data.appSessionId;
|
|
1775
|
+
return typeof sessionId === 'string' && sessionId.trim().length > 0 ? sessionId : null;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
function closeNodeAppSession(node: CanvasNodeState | undefined): void {
|
|
1779
|
+
const sessionId = nodeAppSessionId(node);
|
|
1780
|
+
if (sessionId) closeMcpAppSession(sessionId);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
|
|
1784
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
1785
|
+
const entries = Object.entries(value)
|
|
1786
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
1787
|
+
.map(([key, text]) => [key, text.trim()] as const)
|
|
1788
|
+
.filter(([, text]) => text.length > 0);
|
|
1789
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function parseExternalMcpTransportConfig(body: Record<string, unknown>): ExternalMcpTransportConfig | null {
|
|
1793
|
+
const transport = body.transport;
|
|
1794
|
+
if (!transport || typeof transport !== 'object' || Array.isArray(transport)) return null;
|
|
1795
|
+
const transportRecord = transport as Record<string, unknown>;
|
|
1796
|
+
|
|
1797
|
+
const type = typeof transportRecord.type === 'string' ? transportRecord.type : '';
|
|
1798
|
+
if (type === 'http') {
|
|
1799
|
+
const url = typeof transportRecord.url === 'string' ? transportRecord.url.trim() : '';
|
|
1800
|
+
if (!url) return null;
|
|
1801
|
+
const headers = normalizeStringRecord(transportRecord.headers);
|
|
1802
|
+
return {
|
|
1803
|
+
type: 'http',
|
|
1804
|
+
url,
|
|
1805
|
+
...(headers ? { headers } : {}),
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
if (type === 'stdio') {
|
|
1810
|
+
const command = typeof transportRecord.command === 'string' ? transportRecord.command.trim() : '';
|
|
1811
|
+
if (!command) return null;
|
|
1812
|
+
const env = normalizeStringRecord(transportRecord.env);
|
|
1813
|
+
return {
|
|
1814
|
+
type: 'stdio',
|
|
1815
|
+
command,
|
|
1816
|
+
...(Array.isArray(transportRecord.args)
|
|
1817
|
+
? { args: transportRecord.args.filter((value: unknown): value is string => typeof value === 'string') }
|
|
1818
|
+
: {}),
|
|
1819
|
+
...(typeof transportRecord.cwd === 'string' && transportRecord.cwd.trim().length > 0 ? { cwd: transportRecord.cwd } : {}),
|
|
1820
|
+
...(env ? { env } : {}),
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
return null;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
interface RunAndEmitOpenMcpAppParams {
|
|
1828
|
+
transport: ExternalMcpTransportConfig;
|
|
1829
|
+
toolName: string;
|
|
1830
|
+
toolArguments?: Record<string, unknown>;
|
|
1831
|
+
serverName?: string;
|
|
1832
|
+
title?: string;
|
|
1833
|
+
x?: number;
|
|
1834
|
+
y?: number;
|
|
1835
|
+
width?: number;
|
|
1836
|
+
height?: number;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise<Response> {
|
|
1840
|
+
try {
|
|
1841
|
+
const opened = await openMcpApp({
|
|
1842
|
+
transport: params.transport,
|
|
1843
|
+
toolName: params.toolName,
|
|
1844
|
+
...(params.toolArguments ? { toolArguments: params.toolArguments } : {}),
|
|
1845
|
+
...(params.serverName ? { serverName: params.serverName } : {}),
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
const toolCallId = randomExtAppToolCallId();
|
|
1849
|
+
const nodeTitle = params.title ?? opened.tool.title ?? opened.tool.name;
|
|
1850
|
+
|
|
1851
|
+
emitPrimaryWorkbenchEvent('ext-app-open', {
|
|
1852
|
+
toolCallId,
|
|
1853
|
+
title: nodeTitle,
|
|
1854
|
+
html: opened.html,
|
|
1855
|
+
toolInput: opened.toolInput,
|
|
1856
|
+
serverName: opened.serverName,
|
|
1857
|
+
toolName: opened.toolName,
|
|
1858
|
+
appSessionId: opened.sessionId,
|
|
1859
|
+
transportConfig: params.transport,
|
|
1860
|
+
resourceUri: opened.resourceUri,
|
|
1861
|
+
toolDefinition: opened.tool,
|
|
1862
|
+
sessionStatus: 'ready',
|
|
1863
|
+
sessionError: null,
|
|
1864
|
+
...(opened.resourceMeta ? { resourceMeta: opened.resourceMeta } : {}),
|
|
1865
|
+
...(typeof params.x === 'number' ? { x: params.x } : {}),
|
|
1866
|
+
...(typeof params.y === 'number' ? { y: params.y } : {}),
|
|
1867
|
+
...(typeof params.width === 'number' ? { width: params.width } : {}),
|
|
1868
|
+
...(typeof params.height === 'number' ? { height: params.height } : {}),
|
|
1869
|
+
});
|
|
1870
|
+
emitPrimaryWorkbenchEvent('ext-app-result', {
|
|
1871
|
+
toolCallId,
|
|
1872
|
+
serverName: opened.serverName,
|
|
1873
|
+
toolName: opened.toolName,
|
|
1874
|
+
success: opened.toolResult.isError !== true,
|
|
1875
|
+
result: opened.toolResult,
|
|
1876
|
+
});
|
|
1877
|
+
const nodeId = findCanvasExtAppNodeId(toolCallId);
|
|
1878
|
+
|
|
1879
|
+
return responseJson({
|
|
1880
|
+
ok: true,
|
|
1881
|
+
nodeId,
|
|
1882
|
+
toolCallId,
|
|
1883
|
+
sessionId: opened.sessionId,
|
|
1884
|
+
resourceUri: opened.resourceUri,
|
|
1885
|
+
serverName: opened.serverName,
|
|
1886
|
+
toolName: opened.toolName,
|
|
1887
|
+
});
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
return responseJson({
|
|
1890
|
+
ok: false,
|
|
1891
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1892
|
+
}, 400);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
async function handleCanvasOpenMcpApp(req: Request): Promise<Response> {
|
|
1897
|
+
const body = await readJson(req);
|
|
1898
|
+
const transport = parseExternalMcpTransportConfig(body);
|
|
1899
|
+
const toolName = typeof body.toolName === 'string' ? body.toolName.trim() : '';
|
|
1900
|
+
if (!transport || !toolName) {
|
|
1901
|
+
return responseJson({ ok: false, error: 'Missing valid transport or toolName.' }, 400);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
const toolArguments =
|
|
1905
|
+
body.toolArguments && typeof body.toolArguments === 'object' && !Array.isArray(body.toolArguments)
|
|
1906
|
+
? body.toolArguments as Record<string, unknown>
|
|
1907
|
+
: undefined;
|
|
1908
|
+
|
|
1909
|
+
const requestedTitle = typeof body.title === 'string' && body.title.trim().length > 0
|
|
1910
|
+
? body.title.trim()
|
|
1911
|
+
: undefined;
|
|
1912
|
+
const requestedServerName = typeof body.serverName === 'string' && body.serverName.trim().length > 0
|
|
1913
|
+
? body.serverName.trim()
|
|
1914
|
+
: undefined;
|
|
1915
|
+
|
|
1916
|
+
return runAndEmitOpenMcpApp({
|
|
1917
|
+
transport,
|
|
1918
|
+
toolName,
|
|
1919
|
+
...(toolArguments ? { toolArguments } : {}),
|
|
1920
|
+
...(requestedServerName ? { serverName: requestedServerName } : {}),
|
|
1921
|
+
...(requestedTitle ? { title: requestedTitle } : {}),
|
|
1922
|
+
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1923
|
+
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
1924
|
+
...(typeof body.width === 'number' ? { width: body.width } : {}),
|
|
1925
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
async function handleCanvasAddDiagram(req: Request): Promise<Response> {
|
|
1930
|
+
const body = await readJson(req);
|
|
1931
|
+
let built;
|
|
1932
|
+
try {
|
|
1933
|
+
built = buildExcalidrawOpenMcpAppInput({
|
|
1934
|
+
elements: body.elements,
|
|
1935
|
+
...(typeof body.title === 'string' ? { title: body.title } : {}),
|
|
1936
|
+
...(typeof body.x === 'number' ? { x: body.x } : {}),
|
|
1937
|
+
...(typeof body.y === 'number' ? { y: body.y } : {}),
|
|
1938
|
+
...(typeof body.width === 'number' ? { width: body.width } : {}),
|
|
1939
|
+
...(typeof body.height === 'number' ? { height: body.height } : {}),
|
|
1940
|
+
});
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
return responseJson({
|
|
1943
|
+
ok: false,
|
|
1944
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1945
|
+
}, 400);
|
|
1946
|
+
}
|
|
1947
|
+
return runAndEmitOpenMcpApp({
|
|
1948
|
+
transport: built.transport,
|
|
1949
|
+
toolName: built.toolName,
|
|
1950
|
+
toolArguments: built.toolArguments,
|
|
1951
|
+
serverName: built.serverName,
|
|
1952
|
+
...(built.title ? { title: built.title } : {}),
|
|
1953
|
+
...(typeof built.x === 'number' ? { x: built.x } : {}),
|
|
1954
|
+
...(typeof built.y === 'number' ? { y: built.y } : {}),
|
|
1955
|
+
...(typeof built.width === 'number' ? { width: built.width } : {}),
|
|
1956
|
+
...(typeof built.height === 'number' ? { height: built.height } : {}),
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async function handleExtAppCallTool(req: Request): Promise<Response> {
|
|
1961
|
+
const body = await readJson(req);
|
|
1962
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
1963
|
+
const toolName = typeof body.toolName === 'string' ? body.toolName.trim() : '';
|
|
1964
|
+
if (!sessionId || !toolName) {
|
|
1965
|
+
return responseJson({ ok: false, error: 'Missing sessionId or toolName.' }, 400);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const args =
|
|
1969
|
+
body.arguments && typeof body.arguments === 'object' && !Array.isArray(body.arguments)
|
|
1970
|
+
? body.arguments as Record<string, unknown>
|
|
1971
|
+
: undefined;
|
|
1972
|
+
const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
|
|
1973
|
+
|
|
1974
|
+
try {
|
|
1975
|
+
const result = await callMcpAppTool(sessionId, toolName, args);
|
|
1976
|
+
if (nodeId) {
|
|
1977
|
+
const node = canvasState.getNode(nodeId);
|
|
1978
|
+
if (node?.type === 'mcp-app' && node.data.mode === 'ext-app' && node.data.appSessionId === sessionId) {
|
|
1979
|
+
const nextData: Record<string, unknown> = {
|
|
1980
|
+
...node.data,
|
|
1981
|
+
toolResult: result,
|
|
1982
|
+
};
|
|
1983
|
+
const nextModelContext: Record<string, unknown> = {};
|
|
1984
|
+
if (Array.isArray(result.content)) {
|
|
1985
|
+
nextModelContext.content = result.content;
|
|
1986
|
+
}
|
|
1987
|
+
if (result.structuredContent && typeof result.structuredContent === 'object' && !Array.isArray(result.structuredContent)) {
|
|
1988
|
+
nextModelContext.structuredContent = result.structuredContent;
|
|
1989
|
+
}
|
|
1990
|
+
if (Object.keys(nextModelContext).length > 0) {
|
|
1991
|
+
nextData.appModelContext = {
|
|
1992
|
+
...nextModelContext,
|
|
1993
|
+
updatedAt: new Date().toISOString(),
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
canvasState.updateNode(nodeId, {
|
|
1997
|
+
data: nextData,
|
|
1998
|
+
});
|
|
1999
|
+
broadcastWorkbenchEvent('canvas-layout-update', {
|
|
2000
|
+
layout: canvasState.getLayout(),
|
|
2001
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2002
|
+
timestamp: new Date().toISOString(),
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
return responseJson({ ok: true, result });
|
|
2007
|
+
} catch (error) {
|
|
2008
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
async function handleExtAppReadResource(req: Request): Promise<Response> {
|
|
2013
|
+
const body = await readJson(req);
|
|
2014
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
2015
|
+
const uri = typeof body.uri === 'string' ? body.uri.trim() : '';
|
|
2016
|
+
if (!sessionId || !uri) {
|
|
2017
|
+
return responseJson({ ok: false, error: 'Missing sessionId or uri.' }, 400);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
try {
|
|
2021
|
+
const result = await readMcpAppResource(sessionId, uri);
|
|
2022
|
+
return responseJson({ ok: true, result });
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function handleExtAppListTools(req: Request): Promise<Response> {
|
|
2029
|
+
const body = await readJson(req);
|
|
2030
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
2031
|
+
if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
|
|
2032
|
+
|
|
2033
|
+
try {
|
|
2034
|
+
const result: ListToolsResult = await listMcpAppTools(sessionId);
|
|
2035
|
+
return responseJson({ ok: true, result });
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
async function handleExtAppListResources(req: Request): Promise<Response> {
|
|
2042
|
+
const body = await readJson(req);
|
|
2043
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
2044
|
+
if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
|
|
2045
|
+
|
|
2046
|
+
try {
|
|
2047
|
+
const result: ListResourcesResult = await listMcpAppResources(sessionId);
|
|
2048
|
+
return responseJson({ ok: true, result });
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
async function handleExtAppListResourceTemplates(req: Request): Promise<Response> {
|
|
2055
|
+
const body = await readJson(req);
|
|
2056
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
2057
|
+
if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
|
|
2058
|
+
|
|
2059
|
+
try {
|
|
2060
|
+
const result: ListResourceTemplatesResult = await listMcpAppResourceTemplates(sessionId);
|
|
2061
|
+
return responseJson({ ok: true, result });
|
|
2062
|
+
} catch (error) {
|
|
2063
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
async function handleExtAppListPrompts(req: Request): Promise<Response> {
|
|
2068
|
+
const body = await readJson(req);
|
|
2069
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
2070
|
+
if (!sessionId) return responseJson({ ok: false, error: 'Missing sessionId.' }, 400);
|
|
2071
|
+
|
|
2072
|
+
try {
|
|
2073
|
+
const result: ListPromptsResult = await listMcpAppPrompts(sessionId);
|
|
2074
|
+
return responseJson({ ok: true, result });
|
|
2075
|
+
} catch (error) {
|
|
2076
|
+
return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
async function handleExtAppModelContext(req: Request): Promise<Response> {
|
|
2081
|
+
const body = await readJson(req);
|
|
2082
|
+
const nodeId = typeof body.nodeId === 'string' ? body.nodeId.trim() : '';
|
|
2083
|
+
if (!nodeId) return responseJson({ ok: false, error: 'Missing nodeId.' }, 400);
|
|
2084
|
+
|
|
2085
|
+
const node = canvasState.getNode(nodeId);
|
|
2086
|
+
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
2087
|
+
|
|
2088
|
+
canvasState.updateNode(nodeId, {
|
|
2089
|
+
data: {
|
|
2090
|
+
...node.data,
|
|
2091
|
+
appModelContext: {
|
|
2092
|
+
...(Array.isArray(body.content) ? { content: body.content } : {}),
|
|
2093
|
+
...(body.structuredContent && typeof body.structuredContent === 'object' && !Array.isArray(body.structuredContent)
|
|
2094
|
+
? { structuredContent: body.structuredContent }
|
|
2095
|
+
: {}),
|
|
2096
|
+
updatedAt: new Date().toISOString(),
|
|
2097
|
+
},
|
|
2098
|
+
},
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
broadcastWorkbenchEvent('canvas-layout-update', {
|
|
2102
|
+
layout: canvasState.getLayout(),
|
|
2103
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2104
|
+
timestamp: new Date().toISOString(),
|
|
2105
|
+
});
|
|
2106
|
+
return responseJson({ ok: true });
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function handleWorkbenchState(): Response {
|
|
2110
|
+
const mcpAppHost = getMcpAppHostSnapshot();
|
|
2111
|
+
if (!primaryWorkbenchPath) {
|
|
2112
|
+
return responseJson({
|
|
2113
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2114
|
+
path: null,
|
|
2115
|
+
title: null,
|
|
2116
|
+
mcpAppHost,
|
|
2117
|
+
updatedAt: new Date().toISOString(),
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const safePath = resolveWorkspaceMarkdownPath(primaryWorkbenchPath);
|
|
2122
|
+
if (!safePath || !existsSync(safePath)) {
|
|
2123
|
+
primaryWorkbenchPath = null;
|
|
2124
|
+
return responseJson({
|
|
2125
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2126
|
+
path: null,
|
|
2127
|
+
title: null,
|
|
2128
|
+
mcpAppHost,
|
|
2129
|
+
updatedAt: new Date().toISOString(),
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
const stat = statSync(safePath);
|
|
2134
|
+
return responseJson({
|
|
2135
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2136
|
+
path: safePath,
|
|
2137
|
+
title: basename(safePath),
|
|
2138
|
+
mcpAppHost,
|
|
2139
|
+
updatedAt: new Date(stat.mtimeMs).toISOString(),
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function parseCanvasAutomationWebViewRequestBody(
|
|
2144
|
+
body: Record<string, unknown>,
|
|
2145
|
+
): CanvasAutomationWebViewOptions {
|
|
2146
|
+
const backendValue = typeof body.backend === 'string' ? body.backend.trim() : '';
|
|
2147
|
+
const backend =
|
|
2148
|
+
backendValue === 'chrome' || backendValue === 'webkit'
|
|
2149
|
+
? backendValue
|
|
2150
|
+
: undefined;
|
|
2151
|
+
|
|
2152
|
+
const width = typeof body.width === 'number' ? body.width : undefined;
|
|
2153
|
+
const height = typeof body.height === 'number' ? body.height : undefined;
|
|
2154
|
+
const chromePath = typeof body.chromePath === 'string' ? body.chromePath : undefined;
|
|
2155
|
+
const dataStoreDir = typeof body.dataStoreDir === 'string' ? body.dataStoreDir : undefined;
|
|
2156
|
+
const chromeArgv = Array.isArray(body.chromeArgv)
|
|
2157
|
+
? body.chromeArgv.filter((value): value is string => typeof value === 'string')
|
|
2158
|
+
: undefined;
|
|
2159
|
+
|
|
2160
|
+
return {
|
|
2161
|
+
...(backend ? { backend } : {}),
|
|
2162
|
+
...(width !== undefined ? { width } : {}),
|
|
2163
|
+
...(height !== undefined ? { height } : {}),
|
|
2164
|
+
...(chromePath ? { chromePath } : {}),
|
|
2165
|
+
...(chromeArgv ? { chromeArgv } : {}),
|
|
2166
|
+
...(dataStoreDir ? { dataStoreDir } : {}),
|
|
2167
|
+
};
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function currentWorkbenchUrl(): string | null {
|
|
2171
|
+
return server && typeof server.port === 'number' ? `${loopbackBaseUrl(server.port)}/workbench` : null;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function handleWorkbenchWebViewStatus(): Response {
|
|
2175
|
+
return responseJson(getCanvasAutomationWebViewStatus());
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
async function handleWorkbenchWebViewStart(req: Request): Promise<Response> {
|
|
2179
|
+
const url = currentWorkbenchUrl();
|
|
2180
|
+
if (!url) {
|
|
2181
|
+
return responseJson({ ok: false, error: 'Canvas server is not running.' }, 503);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
const body = await readJson(req);
|
|
2185
|
+
const options = parseCanvasAutomationWebViewRequestBody(body);
|
|
2186
|
+
|
|
2187
|
+
try {
|
|
2188
|
+
const webview = await startCanvasAutomationWebView(url, options);
|
|
2189
|
+
return responseJson({ ok: true, webview });
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2192
|
+
const status = hasCanvasAutomationWebViewSupport() ? 500 : 501;
|
|
2193
|
+
return responseJson({
|
|
2194
|
+
ok: false,
|
|
2195
|
+
error: message,
|
|
2196
|
+
webview: getCanvasAutomationWebViewStatus(),
|
|
2197
|
+
}, status);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
async function handleWorkbenchWebViewStop(): Promise<Response> {
|
|
2202
|
+
try {
|
|
2203
|
+
const stopped = await stopCanvasAutomationWebView();
|
|
2204
|
+
return responseJson({
|
|
2205
|
+
ok: true,
|
|
2206
|
+
stopped,
|
|
2207
|
+
webview: getCanvasAutomationWebViewStatus(),
|
|
2208
|
+
});
|
|
2209
|
+
} catch (error) {
|
|
2210
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2211
|
+
return responseJson({
|
|
2212
|
+
ok: false,
|
|
2213
|
+
error: message,
|
|
2214
|
+
webview: getCanvasAutomationWebViewStatus(),
|
|
2215
|
+
}, 500);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
async function handleWorkbenchWebViewEvaluate(req: Request): Promise<Response> {
|
|
2220
|
+
const body = await readJson(req);
|
|
2221
|
+
const expression = typeof body.expression === 'string' ? body.expression.trim() : '';
|
|
2222
|
+
const script = typeof body.script === 'string' ? body.script.trim() : '';
|
|
2223
|
+
if ((expression ? 1 : 0) + (script ? 1 : 0) !== 1) {
|
|
2224
|
+
return responseJson({
|
|
2225
|
+
ok: false,
|
|
2226
|
+
error: 'Pass exactly one of "expression" (single JS expression) or "script" (multi-statement body, wrapped in an IIFE).',
|
|
2227
|
+
}, 400);
|
|
2228
|
+
}
|
|
2229
|
+
const source = script ? `(() => {\n${script}\n})()` : expression;
|
|
2230
|
+
|
|
2231
|
+
try {
|
|
2232
|
+
const value = await evaluateCanvasAutomationWebView(source);
|
|
2233
|
+
return responseJson({ ok: true, value });
|
|
2234
|
+
} catch (error) {
|
|
2235
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2236
|
+
return responseJson({ ok: false, error: message, webview: getCanvasAutomationWebViewStatus() }, 400);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
async function handleWorkbenchWebViewResize(req: Request): Promise<Response> {
|
|
2241
|
+
const body = await readJson(req);
|
|
2242
|
+
const width = typeof body.width === 'number' ? body.width : NaN;
|
|
2243
|
+
const height = typeof body.height === 'number' ? body.height : NaN;
|
|
2244
|
+
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
|
2245
|
+
return responseJson({ ok: false, error: 'Missing required positive numeric fields: width, height.' }, 400);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
try {
|
|
2249
|
+
const webview = await resizeCanvasAutomationWebView(width, height);
|
|
2250
|
+
return responseJson({ ok: true, webview });
|
|
2251
|
+
} catch (error) {
|
|
2252
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2253
|
+
return responseJson({ ok: false, error: message, webview: getCanvasAutomationWebViewStatus() }, 400);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
async function handleWorkbenchWebViewScreenshot(req: Request): Promise<Response> {
|
|
2258
|
+
const body = await readJson(req);
|
|
2259
|
+
const format = body.format === 'jpeg' || body.format === 'webp' || body.format === 'png'
|
|
2260
|
+
? body.format
|
|
2261
|
+
: 'png';
|
|
2262
|
+
const quality = typeof body.quality === 'number' ? body.quality : undefined;
|
|
2263
|
+
|
|
2264
|
+
try {
|
|
2265
|
+
const bytes = await screenshotCanvasAutomationWebView({
|
|
2266
|
+
format,
|
|
2267
|
+
...(quality !== undefined ? { quality } : {}),
|
|
2268
|
+
});
|
|
2269
|
+
const responseBytes = Uint8Array.from(bytes);
|
|
2270
|
+
const mimeType = format === 'jpeg'
|
|
2271
|
+
? 'image/jpeg'
|
|
2272
|
+
: format === 'webp'
|
|
2273
|
+
? 'image/webp'
|
|
2274
|
+
: 'image/png';
|
|
2275
|
+
return new Response(responseBytes.buffer, {
|
|
2276
|
+
headers: {
|
|
2277
|
+
'Content-Type': mimeType,
|
|
2278
|
+
'Cache-Control': 'no-store',
|
|
2279
|
+
},
|
|
2280
|
+
});
|
|
2281
|
+
} catch (error) {
|
|
2282
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2283
|
+
return responseJson({ ok: false, error: message, webview: getCanvasAutomationWebViewStatus() }, 400);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
async function handleWorkbenchOpen(req: Request): Promise<Response> {
|
|
2288
|
+
const body = await readJson(req);
|
|
2289
|
+
const pathLike = typeof body.path === 'string' ? body.path : '';
|
|
2290
|
+
const safePath = resolveWorkspaceMarkdownPath(pathLike);
|
|
2291
|
+
if (!safePath) return responseText('Invalid path', 400);
|
|
2292
|
+
if (!existsSync(safePath)) return responseText('File not found', 404);
|
|
2293
|
+
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
2294
|
+
setPrimaryWorkbenchPath(safePath, 'api');
|
|
2295
|
+
return handleWorkbenchState();
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
async function handleWorkbenchIntent(req: Request): Promise<Response> {
|
|
2299
|
+
const body = await readJson(req);
|
|
2300
|
+
const rawType = typeof body.type === 'string' ? body.type.trim() : '';
|
|
2301
|
+
if (!rawType) return responseText('Missing intent type', 400);
|
|
2302
|
+
if (!ALLOWED_WORKBENCH_INTENTS.has(rawType as PrimaryWorkbenchIntent['type'])) {
|
|
2303
|
+
return responseText('Unsupported intent type', 400);
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const rawPayload = body.payload;
|
|
2307
|
+
const payload =
|
|
2308
|
+
rawPayload && typeof rawPayload === 'object'
|
|
2309
|
+
? (rawPayload as PrimaryWorkbenchEventPayload)
|
|
2310
|
+
: {};
|
|
2311
|
+
|
|
2312
|
+
// Handle trace intents directly on the server
|
|
2313
|
+
if (rawType === 'trace-toggle') {
|
|
2314
|
+
const enabled = payload.enabled === true;
|
|
2315
|
+
traceManager.setEnabled(enabled);
|
|
2316
|
+
emitPrimaryWorkbenchEvent('trace-state', { enabled });
|
|
2317
|
+
return responseJson({ ok: true, traceEnabled: enabled });
|
|
2318
|
+
}
|
|
2319
|
+
if (rawType === 'trace-clear') {
|
|
2320
|
+
const count = traceManager.getTraceNodeCount();
|
|
2321
|
+
const note =
|
|
2322
|
+
count === 0 && traceManager.enabled
|
|
2323
|
+
? 'Trace is enabled, but no tool or subagent activity has been recorded yet.'
|
|
2324
|
+
: count === 0
|
|
2325
|
+
? 'Trace is already empty.'
|
|
2326
|
+
: undefined;
|
|
2327
|
+
traceManager.clearTrace();
|
|
2328
|
+
emitPrimaryWorkbenchEvent('trace-state', { enabled: traceManager.enabled });
|
|
2329
|
+
return responseJson({
|
|
2330
|
+
ok: true,
|
|
2331
|
+
removed: count,
|
|
2332
|
+
traceEnabled: traceManager.enabled,
|
|
2333
|
+
...(note ? { note } : {}),
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
const intent = enqueuePrimaryWorkbenchIntent(rawType as PrimaryWorkbenchIntent['type'], payload);
|
|
2338
|
+
return responseJson({ ok: true, intent });
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
function handleWorkbenchEvents(req: Request): Response {
|
|
2342
|
+
const reqUrl = new URL(req.url);
|
|
2343
|
+
const requestedSessionId = reqUrl.searchParams.get('session')?.trim() ?? '';
|
|
2344
|
+
const continuity =
|
|
2345
|
+
requestedSessionId.length === 0
|
|
2346
|
+
? 'fresh'
|
|
2347
|
+
: requestedSessionId === primaryWorkbenchSessionId
|
|
2348
|
+
? 'resumed'
|
|
2349
|
+
: 'rotated';
|
|
2350
|
+
const subscriberId = nextWorkbenchSubscriberId++;
|
|
2351
|
+
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
2352
|
+
|
|
2353
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
2354
|
+
start(controller) {
|
|
2355
|
+
workbenchSubscribers.set(subscriberId, controller);
|
|
2356
|
+
syncCanvasBrowserOpenedFromSubscribers();
|
|
2357
|
+
controller.enqueue(
|
|
2358
|
+
toSseFrame('connected', {
|
|
2359
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2360
|
+
requestedSessionId: requestedSessionId || null,
|
|
2361
|
+
continuity,
|
|
2362
|
+
path: primaryWorkbenchPath,
|
|
2363
|
+
theme: canvasThemeSetting,
|
|
2364
|
+
timestamp: new Date().toISOString(),
|
|
2365
|
+
}),
|
|
2366
|
+
);
|
|
2367
|
+
if (primaryWorkbenchPath) {
|
|
2368
|
+
controller.enqueue(
|
|
2369
|
+
toSseFrame('workbench-open', {
|
|
2370
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2371
|
+
path: primaryWorkbenchPath,
|
|
2372
|
+
title: basename(primaryWorkbenchPath),
|
|
2373
|
+
source: 'snapshot',
|
|
2374
|
+
updatedAt: new Date().toISOString(),
|
|
2375
|
+
}),
|
|
2376
|
+
);
|
|
2377
|
+
}
|
|
2378
|
+
if (lastWorkbenchContextCardsEnvelope) {
|
|
2379
|
+
controller.enqueue(
|
|
2380
|
+
toSseFrame('context-cards', {
|
|
2381
|
+
...lastWorkbenchContextCardsEnvelope,
|
|
2382
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2383
|
+
timestamp: new Date().toISOString(),
|
|
2384
|
+
}),
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
controller.enqueue(
|
|
2388
|
+
toSseFrame('mcp-app-host-snapshot', {
|
|
2389
|
+
reason: 'connect-snapshot',
|
|
2390
|
+
...getMcpAppHostSnapshot(),
|
|
2391
|
+
timestamp: new Date().toISOString(),
|
|
2392
|
+
}),
|
|
2393
|
+
);
|
|
2394
|
+
const layout = canvasState.getLayout();
|
|
2395
|
+
controller.enqueue(
|
|
2396
|
+
toSseFrame('canvas-layout-update', {
|
|
2397
|
+
layout,
|
|
2398
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2399
|
+
timestamp: new Date().toISOString(),
|
|
2400
|
+
}),
|
|
2401
|
+
);
|
|
2402
|
+
controller.enqueue(
|
|
2403
|
+
toSseFrame('context-pins-changed', {
|
|
2404
|
+
count: canvasState.contextPinnedNodeIds.size,
|
|
2405
|
+
nodeIds: Array.from(canvasState.contextPinnedNodeIds),
|
|
2406
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2407
|
+
timestamp: new Date().toISOString(),
|
|
2408
|
+
}),
|
|
2409
|
+
);
|
|
2410
|
+
pingTimer = setInterval(() => {
|
|
2411
|
+
try {
|
|
2412
|
+
controller.enqueue(
|
|
2413
|
+
toSseFrame('ping', {
|
|
2414
|
+
ts: Date.now(),
|
|
2415
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2416
|
+
}),
|
|
2417
|
+
);
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
sessionDiagLog('drop-subscriber-after-ping-failure', {
|
|
2420
|
+
subscriberId,
|
|
2421
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2422
|
+
});
|
|
2423
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
2424
|
+
pingTimer = null;
|
|
2425
|
+
workbenchSubscribers.delete(subscriberId);
|
|
2426
|
+
syncCanvasBrowserOpenedFromSubscribers();
|
|
2427
|
+
}
|
|
2428
|
+
}, 8000);
|
|
2429
|
+
},
|
|
2430
|
+
cancel() {
|
|
2431
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
2432
|
+
pingTimer = null;
|
|
2433
|
+
workbenchSubscribers.delete(subscriberId);
|
|
2434
|
+
syncCanvasBrowserOpenedFromSubscribers();
|
|
2435
|
+
},
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
return new Response(stream, {
|
|
2439
|
+
headers: {
|
|
2440
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
2441
|
+
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
2442
|
+
Connection: 'keep-alive',
|
|
2443
|
+
'X-Accel-Buffering': 'no',
|
|
2444
|
+
},
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
async function handleSave(req: Request): Promise<Response> {
|
|
2449
|
+
const body = await readJson(req);
|
|
2450
|
+
const pathLike = typeof body.path === 'string' ? body.path : '';
|
|
2451
|
+
const safePath = resolveWorkspaceMarkdownPath(pathLike);
|
|
2452
|
+
if (!safePath) return responseText('Invalid path', 400);
|
|
2453
|
+
|
|
2454
|
+
const content = typeof body.content === 'string' ? body.content : '';
|
|
2455
|
+
const normalized = content.replace(/\r\n?/g, '\n');
|
|
2456
|
+
writeFileSync(safePath, normalized, 'utf-8');
|
|
2457
|
+
return responseJson({
|
|
2458
|
+
ok: true,
|
|
2459
|
+
path: safePath,
|
|
2460
|
+
updatedAt: new Date().toISOString(),
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
async function handleRender(req: Request): Promise<Response> {
|
|
2465
|
+
const body = await readJson(req);
|
|
2466
|
+
const markdown = typeof body.markdown === 'string' ? body.markdown : '';
|
|
2467
|
+
const html =
|
|
2468
|
+
(marked.parse(normalizeMarkdownExternalUrls(markdown), {
|
|
2469
|
+
gfm: true,
|
|
2470
|
+
breaks: true,
|
|
2471
|
+
}) as string) || '';
|
|
2472
|
+
return responseJson({ html });
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
function buildSelectionContextPreamble(contextNodeIds: string[]): string {
|
|
2476
|
+
const nodes = contextNodeIds
|
|
2477
|
+
.map((id) => canvasState.getNode(id))
|
|
2478
|
+
.filter((node): node is CanvasNodeState => node !== undefined);
|
|
2479
|
+
return buildAgentContextPreamble(nodes, {
|
|
2480
|
+
defaultTextLength: 700,
|
|
2481
|
+
webpageTextLength: 1600,
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
async function handleCanvasPrompt(req: Request): Promise<Response> {
|
|
2486
|
+
const body = await readJson(req);
|
|
2487
|
+
const text = typeof body.text === 'string' ? body.text.trim() : '';
|
|
2488
|
+
if (!text) return responseText('Missing prompt text', 400);
|
|
2489
|
+
|
|
2490
|
+
const threadNodeId = typeof body.threadNodeId === 'string' ? body.threadNodeId : undefined;
|
|
2491
|
+
const position = body.position as { x: number; y: number } | undefined;
|
|
2492
|
+
const parentNodeId = typeof body.parentNodeId === 'string' ? body.parentNodeId : undefined;
|
|
2493
|
+
const MAX_CONTEXT_NODES = 20;
|
|
2494
|
+
let contextNodeIds = Array.isArray(body.contextNodeIds)
|
|
2495
|
+
? (body.contextNodeIds.filter((id: unknown) => typeof id === 'string') as string[]).slice(
|
|
2496
|
+
0,
|
|
2497
|
+
MAX_CONTEXT_NODES,
|
|
2498
|
+
)
|
|
2499
|
+
: [];
|
|
2500
|
+
|
|
2501
|
+
if (contextNodeIds.length === 0 && canvasState.contextPinnedNodeIds.size > 0) {
|
|
2502
|
+
contextNodeIds = Array.from(canvasState.contextPinnedNodeIds).slice(0, MAX_CONTEXT_NODES);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// ── Thread reply: append user turn to existing thread node ──
|
|
2506
|
+
if (threadNodeId) {
|
|
2507
|
+
let threadNode = canvasState.getNode(threadNodeId);
|
|
2508
|
+
if (!threadNode) {
|
|
2509
|
+
const promptCount = canvasState
|
|
2510
|
+
.getLayout()
|
|
2511
|
+
.nodes.filter((n) => n.type === 'prompt' || n.type === 'response').length;
|
|
2512
|
+
const pos = position ?? { x: 380 + promptCount * 30, y: 1260 + promptCount * 30 };
|
|
2513
|
+
canvasState.addNode({
|
|
2514
|
+
id: threadNodeId,
|
|
2515
|
+
type: 'prompt',
|
|
2516
|
+
position: pos,
|
|
2517
|
+
size: { width: 520, height: 400 },
|
|
2518
|
+
zIndex: 1,
|
|
2519
|
+
collapsed: false,
|
|
2520
|
+
pinned: false,
|
|
2521
|
+
dockPosition: null,
|
|
2522
|
+
data: {
|
|
2523
|
+
text,
|
|
2524
|
+
turns: [],
|
|
2525
|
+
threadStatus: 'draft',
|
|
2526
|
+
contextNodeIds: contextNodeIds.length > 0 ? contextNodeIds : undefined,
|
|
2527
|
+
},
|
|
2528
|
+
});
|
|
2529
|
+
if (parentNodeId && canvasState.getNode(parentNodeId)) {
|
|
2530
|
+
canvasState.addEdge({
|
|
2531
|
+
id: `edge-${parentNodeId}-${threadNodeId}`,
|
|
2532
|
+
from: parentNodeId,
|
|
2533
|
+
to: threadNodeId,
|
|
2534
|
+
type: 'flow',
|
|
2535
|
+
style: 'dashed',
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
for (const ctxId of contextNodeIds) {
|
|
2539
|
+
if (canvasState.getNode(ctxId)) {
|
|
2540
|
+
canvasState.addEdge({
|
|
2541
|
+
id: `edge-ctx-${ctxId}-${threadNodeId}`,
|
|
2542
|
+
from: ctxId,
|
|
2543
|
+
to: threadNodeId,
|
|
2544
|
+
type: 'references',
|
|
2545
|
+
style: 'dashed',
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
const createdThreadNode = canvasState.getNode(threadNodeId);
|
|
2550
|
+
if (!createdThreadNode) {
|
|
2551
|
+
return responseJson({ ok: false, error: 'Failed to initialize canvas thread.' }, 500);
|
|
2552
|
+
}
|
|
2553
|
+
threadNode = createdThreadNode;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
const MAX_THREAD_TURNS = 100;
|
|
2557
|
+
const existingTurnCount = Array.isArray(threadNode.data.turns)
|
|
2558
|
+
? (threadNode.data.turns as unknown[]).length
|
|
2559
|
+
: 0;
|
|
2560
|
+
if (existingTurnCount >= MAX_THREAD_TURNS) {
|
|
2561
|
+
return responseText('Thread has reached the maximum number of turns', 400);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
const currentTurns = Array.isArray(threadNode.data.turns)
|
|
2565
|
+
? [...(threadNode.data.turns as Array<Record<string, unknown>>)]
|
|
2566
|
+
: [];
|
|
2567
|
+
currentTurns.push({ role: 'user', text, status: 'pending' });
|
|
2568
|
+
|
|
2569
|
+
if (contextNodeIds.length === 0 && Array.isArray(threadNode.data.contextNodeIds)) {
|
|
2570
|
+
contextNodeIds = threadNode.data.contextNodeIds as string[];
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
canvasState.updateNode(threadNodeId, {
|
|
2574
|
+
data: { ...threadNode.data, turns: currentTurns, threadStatus: 'pending' },
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
let enrichedText = text;
|
|
2578
|
+
if (contextNodeIds.length > 0) {
|
|
2579
|
+
const preamble = buildSelectionContextPreamble(contextNodeIds);
|
|
2580
|
+
if (preamble) {
|
|
2581
|
+
enrichedText = `${preamble}User question: ${text}`;
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
broadcastWorkbenchEvent('canvas-prompt-created', {
|
|
2586
|
+
nodeId: threadNodeId,
|
|
2587
|
+
threadNodeId,
|
|
2588
|
+
text,
|
|
2589
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2590
|
+
timestamp: new Date().toISOString(),
|
|
2591
|
+
});
|
|
2592
|
+
|
|
2593
|
+
broadcastWorkbenchEvent('canvas-prompt-status', {
|
|
2594
|
+
nodeId: threadNodeId,
|
|
2595
|
+
status: 'pending',
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
const promptRequest: PrimaryWorkbenchCanvasPromptRequest = {
|
|
2599
|
+
nodeId: threadNodeId,
|
|
2600
|
+
text: enrichedText,
|
|
2601
|
+
displayText: text,
|
|
2602
|
+
parentNodeId: threadNodeId,
|
|
2603
|
+
contextNodeIds,
|
|
2604
|
+
};
|
|
2605
|
+
|
|
2606
|
+
if (primaryWorkbenchCanvasPromptHandler) {
|
|
2607
|
+
try {
|
|
2608
|
+
await primaryWorkbenchCanvasPromptHandler(promptRequest);
|
|
2609
|
+
} catch (error) {
|
|
2610
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2611
|
+
broadcastWorkbenchEvent('canvas-prompt-status', {
|
|
2612
|
+
nodeId: threadNodeId,
|
|
2613
|
+
status: 'error',
|
|
2614
|
+
error: message,
|
|
2615
|
+
});
|
|
2616
|
+
return responseJson({ ok: false, error: message }, 409);
|
|
2617
|
+
}
|
|
2618
|
+
} else {
|
|
2619
|
+
enqueuePrimaryWorkbenchIntent('canvas-prompt', {
|
|
2620
|
+
nodeId: threadNodeId,
|
|
2621
|
+
text: enrichedText,
|
|
2622
|
+
parentNodeId: threadNodeId,
|
|
2623
|
+
contextNodeIds,
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
return responseJson({ ok: true, nodeId: threadNodeId });
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// ── New prompt: create fresh prompt node ──
|
|
2631
|
+
const suffix = Math.random().toString(36).slice(2, 8);
|
|
2632
|
+
const nodeId = `prompt-${Date.now()}-${suffix}`;
|
|
2633
|
+
|
|
2634
|
+
const promptCount = canvasState
|
|
2635
|
+
.getLayout()
|
|
2636
|
+
.nodes.filter((n) => n.type === 'prompt' || n.type === 'response').length;
|
|
2637
|
+
const pos = position ?? { x: 380 + promptCount * 30, y: 1260 + promptCount * 30 };
|
|
2638
|
+
|
|
2639
|
+
let enrichedText = text;
|
|
2640
|
+
if (contextNodeIds.length > 0) {
|
|
2641
|
+
const preamble = buildSelectionContextPreamble(contextNodeIds);
|
|
2642
|
+
if (preamble) {
|
|
2643
|
+
enrichedText = `${preamble}User question: ${text}`;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
canvasState.addNode({
|
|
2648
|
+
id: nodeId,
|
|
2649
|
+
type: 'prompt',
|
|
2650
|
+
position: pos,
|
|
2651
|
+
size: { width: 520, height: 400 },
|
|
2652
|
+
zIndex: 1,
|
|
2653
|
+
collapsed: false,
|
|
2654
|
+
pinned: false,
|
|
2655
|
+
dockPosition: null,
|
|
2656
|
+
data: {
|
|
2657
|
+
text,
|
|
2658
|
+
turns: [{ role: 'user', text, status: 'pending' }],
|
|
2659
|
+
threadStatus: 'pending',
|
|
2660
|
+
status: 'pending',
|
|
2661
|
+
parentNodeId,
|
|
2662
|
+
contextNodeIds: contextNodeIds.length > 0 ? contextNodeIds : undefined,
|
|
2663
|
+
},
|
|
2664
|
+
});
|
|
2665
|
+
|
|
2666
|
+
if (parentNodeId && canvasState.getNode(parentNodeId)) {
|
|
2667
|
+
canvasState.addEdge({
|
|
2668
|
+
id: `edge-${parentNodeId}-${nodeId}`,
|
|
2669
|
+
from: parentNodeId,
|
|
2670
|
+
to: nodeId,
|
|
2671
|
+
type: 'flow',
|
|
2672
|
+
style: 'dashed',
|
|
2673
|
+
});
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
for (const ctxId of contextNodeIds) {
|
|
2677
|
+
if (canvasState.getNode(ctxId)) {
|
|
2678
|
+
canvasState.addEdge({
|
|
2679
|
+
id: `edge-ctx-${ctxId}-${nodeId}`,
|
|
2680
|
+
from: ctxId,
|
|
2681
|
+
to: nodeId,
|
|
2682
|
+
type: 'references',
|
|
2683
|
+
style: 'dashed',
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
broadcastWorkbenchEvent('canvas-prompt-created', {
|
|
2689
|
+
nodeId,
|
|
2690
|
+
text,
|
|
2691
|
+
position: pos,
|
|
2692
|
+
parentNodeId,
|
|
2693
|
+
contextNodeIds,
|
|
2694
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2695
|
+
timestamp: new Date().toISOString(),
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
const promptRequest: PrimaryWorkbenchCanvasPromptRequest = {
|
|
2699
|
+
nodeId,
|
|
2700
|
+
text: enrichedText,
|
|
2701
|
+
displayText: text,
|
|
2702
|
+
...(parentNodeId ? { parentNodeId } : {}),
|
|
2703
|
+
contextNodeIds,
|
|
2704
|
+
};
|
|
2705
|
+
|
|
2706
|
+
if (primaryWorkbenchCanvasPromptHandler) {
|
|
2707
|
+
try {
|
|
2708
|
+
await primaryWorkbenchCanvasPromptHandler(promptRequest);
|
|
2709
|
+
} catch (error) {
|
|
2710
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2711
|
+
broadcastWorkbenchEvent('canvas-prompt-status', {
|
|
2712
|
+
nodeId,
|
|
2713
|
+
status: 'error',
|
|
2714
|
+
error: message,
|
|
2715
|
+
});
|
|
2716
|
+
return responseJson({ ok: false, error: message }, 409);
|
|
2717
|
+
}
|
|
2718
|
+
} else {
|
|
2719
|
+
enqueuePrimaryWorkbenchIntent('canvas-prompt', {
|
|
2720
|
+
nodeId,
|
|
2721
|
+
text: enrichedText,
|
|
2722
|
+
parentNodeId,
|
|
2723
|
+
contextNodeIds,
|
|
2724
|
+
});
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
return responseJson({ ok: true, nodeId });
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
async function handleSnapshotSave(req: Request): Promise<Response> {
|
|
2731
|
+
const body = await readJson(req);
|
|
2732
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
2733
|
+
if (!name) return responseText('Missing snapshot name', 400);
|
|
2734
|
+
const snapshot = saveCanvasSnapshot(name);
|
|
2735
|
+
if (!snapshot) return responseText('Failed to save snapshot', 500);
|
|
2736
|
+
return responseJson({ ok: true, snapshot });
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
async function handleContextPinsUpdate(req: Request): Promise<Response> {
|
|
2740
|
+
const body = await readJson(req);
|
|
2741
|
+
const MAX_PINS = 20;
|
|
2742
|
+
const nodeIds = Array.isArray(body.nodeIds)
|
|
2743
|
+
? (body.nodeIds.filter((id: unknown) => typeof id === 'string') as string[]).slice(0, MAX_PINS)
|
|
2744
|
+
: [];
|
|
2745
|
+
const result = setCanvasContextPins(nodeIds, 'set');
|
|
2746
|
+
broadcastWorkbenchEvent('context-pins-changed', {
|
|
2747
|
+
count: result.count,
|
|
2748
|
+
nodeIds: result.nodeIds,
|
|
2749
|
+
sessionId: primaryWorkbenchSessionId,
|
|
2750
|
+
timestamp: new Date().toISOString(),
|
|
2751
|
+
});
|
|
2752
|
+
return responseJson({ ok: true, count: result.count });
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
function handleGetPinnedContext(): Response {
|
|
2756
|
+
const pinnedIds = Array.from(canvasState.contextPinnedNodeIds);
|
|
2757
|
+
const preamble = pinnedIds.length > 0 ? buildSelectionContextPreamble(pinnedIds) : '';
|
|
2758
|
+
const nodes = pinnedIds
|
|
2759
|
+
.map((id) => canvasState.getNode(id))
|
|
2760
|
+
.filter((node): node is CanvasNodeState => node !== undefined)
|
|
2761
|
+
.map((node) => serializeNodeForAgentContext(node, {
|
|
2762
|
+
defaultTextLength: 700,
|
|
2763
|
+
webpageTextLength: 1600,
|
|
2764
|
+
includePosition: true,
|
|
2765
|
+
}));
|
|
2766
|
+
return responseJson({ preamble, nodeIds: pinnedIds, count: pinnedIds.length, nodes });
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
// ── Port resolution ───────────────────────────────────────────
|
|
2770
|
+
|
|
2771
|
+
function buildPortCandidates(preferredPort: number): number[] {
|
|
2772
|
+
const candidates: number[] = [];
|
|
2773
|
+
const push = (value: number) => {
|
|
2774
|
+
const normalized = Number.isFinite(value) ? Math.floor(value) : 0;
|
|
2775
|
+
if (normalized < 0) return;
|
|
2776
|
+
if (candidates.includes(normalized)) return;
|
|
2777
|
+
candidates.push(normalized);
|
|
2778
|
+
};
|
|
2779
|
+
|
|
2780
|
+
push(preferredPort > 0 ? preferredPort : DEFAULT_PORT);
|
|
2781
|
+
push(DEFAULT_PORT);
|
|
2782
|
+
for (let offset = 1; offset <= 8; offset++) {
|
|
2783
|
+
push(DEFAULT_PORT + offset);
|
|
2784
|
+
}
|
|
2785
|
+
push(0);
|
|
2786
|
+
return candidates;
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
function loopbackBaseUrl(port: number): string {
|
|
2790
|
+
return `http://${DEFAULT_HOST}:${port}`;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// ── Browser opening ───────────────────────────────────────────
|
|
2794
|
+
|
|
2795
|
+
function escapeAppleScriptString(value: string): string {
|
|
2796
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
function resolveMacBrowserForCanvas(): { appName: string; appPath: string } | null {
|
|
2800
|
+
const candidates = [
|
|
2801
|
+
{ appName: 'Google Chrome', appPath: '/Applications/Google Chrome.app' },
|
|
2802
|
+
{ appName: 'Chromium', appPath: '/Applications/Chromium.app' },
|
|
2803
|
+
{ appName: 'Microsoft Edge', appPath: '/Applications/Microsoft Edge.app' },
|
|
2804
|
+
{ appName: 'Safari', appPath: '/Applications/Safari.app' },
|
|
2805
|
+
];
|
|
2806
|
+
return candidates.find((candidate) => existsSync(candidate.appPath)) ?? null;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
export function buildMacBrowserOpenScript(appName: string, url: string): string {
|
|
2810
|
+
const escapedUrl = escapeAppleScriptString(url);
|
|
2811
|
+
|
|
2812
|
+
if (appName === 'Safari') {
|
|
2813
|
+
return [
|
|
2814
|
+
`tell application "${appName}"`,
|
|
2815
|
+
'launch',
|
|
2816
|
+
`set targetUrl to "${escapedUrl}"`,
|
|
2817
|
+
'set reusedDocument to false',
|
|
2818
|
+
'set startupDocumentIndex to 0',
|
|
2819
|
+
'set documentIndex to 0',
|
|
2820
|
+
'repeat with d in documents',
|
|
2821
|
+
' set documentIndex to documentIndex + 1',
|
|
2822
|
+
' try',
|
|
2823
|
+
' set currentUrl to URL of d',
|
|
2824
|
+
' on error',
|
|
2825
|
+
' set currentUrl to ""',
|
|
2826
|
+
' end try',
|
|
2827
|
+
' if currentUrl contains "/workbench" then',
|
|
2828
|
+
' set URL of d to targetUrl',
|
|
2829
|
+
' set current tab of front window to current tab of d',
|
|
2830
|
+
' set reusedDocument to true',
|
|
2831
|
+
' exit repeat',
|
|
2832
|
+
' end if',
|
|
2833
|
+
' if startupDocumentIndex = 0 and (currentUrl is "" or currentUrl is "about:blank" or currentUrl starts with "favorites://") then',
|
|
2834
|
+
' set startupDocumentIndex to documentIndex',
|
|
2835
|
+
' end if',
|
|
2836
|
+
'end repeat',
|
|
2837
|
+
'if not reusedDocument then',
|
|
2838
|
+
' if startupDocumentIndex > 0 then',
|
|
2839
|
+
' set URL of document startupDocumentIndex to targetUrl',
|
|
2840
|
+
' else',
|
|
2841
|
+
' make new document with properties {URL:targetUrl}',
|
|
2842
|
+
' end if',
|
|
2843
|
+
'end if',
|
|
2844
|
+
'activate',
|
|
2845
|
+
'end tell',
|
|
2846
|
+
].join('\n');
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
return [
|
|
2850
|
+
`tell application "${appName}"`,
|
|
2851
|
+
'launch',
|
|
2852
|
+
`set targetUrl to "${escapedUrl}"`,
|
|
2853
|
+
'set reusedTab to false',
|
|
2854
|
+
'set startupWindowIndex to 0',
|
|
2855
|
+
'set startupTabIndex to 0',
|
|
2856
|
+
'set windowIndex to 0',
|
|
2857
|
+
'repeat with w in windows',
|
|
2858
|
+
' set windowIndex to windowIndex + 1',
|
|
2859
|
+
' set tabIndex to 0',
|
|
2860
|
+
' repeat with t in tabs of w',
|
|
2861
|
+
' set tabIndex to tabIndex + 1',
|
|
2862
|
+
' try',
|
|
2863
|
+
' set currentUrl to URL of t',
|
|
2864
|
+
' on error',
|
|
2865
|
+
' set currentUrl to ""',
|
|
2866
|
+
' end try',
|
|
2867
|
+
' if currentUrl contains "/workbench" then',
|
|
2868
|
+
' set active tab index of w to tabIndex',
|
|
2869
|
+
' set URL of active tab of w to targetUrl',
|
|
2870
|
+
' set index of w to 1',
|
|
2871
|
+
' set reusedTab to true',
|
|
2872
|
+
' exit repeat',
|
|
2873
|
+
' end if',
|
|
2874
|
+
' if startupWindowIndex = 0 and (currentUrl is "" or currentUrl is "about:blank" or currentUrl starts with "chrome://newtab" or currentUrl starts with "chrome-search://") then',
|
|
2875
|
+
' set startupWindowIndex to windowIndex',
|
|
2876
|
+
' set startupTabIndex to tabIndex',
|
|
2877
|
+
' end if',
|
|
2878
|
+
' end repeat',
|
|
2879
|
+
' if reusedTab then exit repeat',
|
|
2880
|
+
'end repeat',
|
|
2881
|
+
'if not reusedTab then',
|
|
2882
|
+
' if startupWindowIndex > 0 then',
|
|
2883
|
+
' set targetWindow to window startupWindowIndex',
|
|
2884
|
+
' set active tab index of targetWindow to startupTabIndex',
|
|
2885
|
+
' set URL of active tab of targetWindow to targetUrl',
|
|
2886
|
+
' set index of targetWindow to 1',
|
|
2887
|
+
' else if (count of windows) = 0 then',
|
|
2888
|
+
' make new window',
|
|
2889
|
+
' set URL of active tab of front window to targetUrl',
|
|
2890
|
+
' else',
|
|
2891
|
+
' tell front window',
|
|
2892
|
+
' make new tab with properties {URL:targetUrl}',
|
|
2893
|
+
' set active tab index to (count of tabs)',
|
|
2894
|
+
' end tell',
|
|
2895
|
+
' end if',
|
|
2896
|
+
'end if',
|
|
2897
|
+
'activate',
|
|
2898
|
+
'end tell',
|
|
2899
|
+
].join('\n');
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
function resolveWindowsBrowserForCanvas(): { name: string; exe: string } | null {
|
|
2903
|
+
const envDirs = [
|
|
2904
|
+
process.env.PROGRAMFILES,
|
|
2905
|
+
process.env['PROGRAMFILES(X86)'],
|
|
2906
|
+
process.env.LOCALAPPDATA,
|
|
2907
|
+
].filter((d): d is string => Boolean(d));
|
|
2908
|
+
|
|
2909
|
+
const browsers = [
|
|
2910
|
+
{ name: 'Edge', subpath: join('Microsoft', 'Edge', 'Application', 'msedge.exe') },
|
|
2911
|
+
{ name: 'Chrome', subpath: join('Google', 'Chrome', 'Application', 'chrome.exe') },
|
|
2912
|
+
];
|
|
2913
|
+
|
|
2914
|
+
for (const { name, subpath } of browsers) {
|
|
2915
|
+
for (const dir of envDirs) {
|
|
2916
|
+
const exe = join(dir, subpath);
|
|
2917
|
+
if (existsSync(exe)) return { name, exe };
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
return null;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
export function openUrlInExternalBrowser(url: string): boolean {
|
|
2924
|
+
try {
|
|
2925
|
+
if (process.env.PMX_CANVAS_DISABLE_BROWSER_OPEN === '1') {
|
|
2926
|
+
return false;
|
|
2927
|
+
}
|
|
2928
|
+
if (process.platform === 'darwin') {
|
|
2929
|
+
const browser = resolveMacBrowserForCanvas();
|
|
2930
|
+
const script = browser
|
|
2931
|
+
? buildMacBrowserOpenScript(browser.appName, url)
|
|
2932
|
+
: `open location "${escapeAppleScriptString(url)}"`;
|
|
2933
|
+
const result = spawnSync('osascript', ['-e', script], { stdio: 'ignore' });
|
|
2934
|
+
return !result.error && result.status === 0;
|
|
2935
|
+
}
|
|
2936
|
+
if (process.platform === 'win32') {
|
|
2937
|
+
const browser = resolveWindowsBrowserForCanvas();
|
|
2938
|
+
const result = browser
|
|
2939
|
+
? spawnSync(browser.exe, [url], { stdio: 'ignore' })
|
|
2940
|
+
: spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' });
|
|
2941
|
+
return !result.error && result.status === 0;
|
|
2942
|
+
}
|
|
2943
|
+
const result = spawnSync('xdg-open', [url], { stdio: 'ignore' });
|
|
2944
|
+
return !result.error && result.status === 0;
|
|
2945
|
+
} catch (error) {
|
|
2946
|
+
logWorkbenchWarning('openUrlInExternalBrowser', error, { url });
|
|
2947
|
+
return false;
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
// ── Sync SSE events to canvas state ───────────────────────────
|
|
2952
|
+
|
|
2953
|
+
function syncContextNodeToCanvasState(
|
|
2954
|
+
dataPatch: Record<string, unknown>,
|
|
2955
|
+
options: { forceCreate?: boolean } = {},
|
|
2956
|
+
): void {
|
|
2957
|
+
const id = 'context-main';
|
|
2958
|
+
const existing = canvasState.getNode(id);
|
|
2959
|
+
const mergedData = { ...(existing?.data ?? {}), ...dataPatch };
|
|
2960
|
+
const cards = Array.isArray(mergedData.cards) ? mergedData.cards : [];
|
|
2961
|
+
const auxTabs = Array.isArray(mergedData.auxTabs) ? mergedData.auxTabs : [];
|
|
2962
|
+
const hasUsage =
|
|
2963
|
+
mergedData.currentTokens !== undefined ||
|
|
2964
|
+
mergedData.tokenLimit !== undefined ||
|
|
2965
|
+
mergedData.messagesLength !== undefined ||
|
|
2966
|
+
mergedData.utilization !== undefined ||
|
|
2967
|
+
mergedData.nearLimit !== undefined;
|
|
2968
|
+
const shouldCreate =
|
|
2969
|
+
options.forceCreate === true || cards.length > 0 || auxTabs.length > 0 || hasUsage;
|
|
2970
|
+
|
|
2971
|
+
if (!existing) {
|
|
2972
|
+
if (!shouldCreate) return;
|
|
2973
|
+
canvasState.addNode({
|
|
2974
|
+
id,
|
|
2975
|
+
type: 'context',
|
|
2976
|
+
position: { x: 1130, y: 80 },
|
|
2977
|
+
size: { width: 320, height: 400 },
|
|
2978
|
+
zIndex: 1,
|
|
2979
|
+
collapsed: false,
|
|
2980
|
+
pinned: false,
|
|
2981
|
+
dockPosition: null,
|
|
2982
|
+
data: mergedData,
|
|
2983
|
+
});
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
canvasState.updateNode(id, { data: mergedData });
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
// Maps responseNodeId -> thread prompt node ID for O(1) routing of response events
|
|
2991
|
+
const serverResponseToThreadMap = new Map<string, string>();
|
|
2992
|
+
|
|
2993
|
+
function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPayload): void {
|
|
2994
|
+
if (event === 'workbench-open') {
|
|
2995
|
+
const path = payload.path as string;
|
|
2996
|
+
if (!path) return;
|
|
2997
|
+
const title = (payload.title as string) || basename(path);
|
|
2998
|
+
const id = `md-${hashPath(path)}`;
|
|
2999
|
+
|
|
3000
|
+
if (!canvasState.getNode(id)) {
|
|
3001
|
+
const placement = getMarkdownPlacement();
|
|
3002
|
+
canvasState.addNode({
|
|
3003
|
+
id,
|
|
3004
|
+
type: 'markdown',
|
|
3005
|
+
position: placement,
|
|
3006
|
+
size: { width: 720, height: 600 },
|
|
3007
|
+
zIndex: 1,
|
|
3008
|
+
collapsed: false,
|
|
3009
|
+
pinned: false,
|
|
3010
|
+
dockPosition: null,
|
|
3011
|
+
data: { path, title, content: '', rendered: '' },
|
|
3012
|
+
});
|
|
3013
|
+
} else {
|
|
3014
|
+
const existing = canvasState.getNode(id);
|
|
3015
|
+
if (!existing) return;
|
|
3016
|
+
canvasState.updateNode(id, { data: { ...existing.data, path, title } });
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
broadcastWorkbenchEvent('canvas-layout-update', {
|
|
3020
|
+
layout: canvasState.getLayout(),
|
|
3021
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3022
|
+
timestamp: new Date().toISOString(),
|
|
3023
|
+
});
|
|
3024
|
+
} else if (event === 'ext-app-open') {
|
|
3025
|
+
const toolCallId = payload.toolCallId as string;
|
|
3026
|
+
if (!toolCallId) return;
|
|
3027
|
+
const id = `ext-app-${toolCallId}`;
|
|
3028
|
+
const dataPatch = {
|
|
3029
|
+
mode: 'ext-app',
|
|
3030
|
+
toolCallId,
|
|
3031
|
+
...(typeof payload.title === 'string' && payload.title.trim().length > 0
|
|
3032
|
+
? { title: payload.title.trim() }
|
|
3033
|
+
: {}),
|
|
3034
|
+
html: payload.html,
|
|
3035
|
+
toolInput: payload.toolInput,
|
|
3036
|
+
serverName: payload.serverName,
|
|
3037
|
+
toolName: payload.toolName,
|
|
3038
|
+
appSessionId: payload.appSessionId,
|
|
3039
|
+
transportConfig: payload.transportConfig,
|
|
3040
|
+
resourceUri: payload.resourceUri,
|
|
3041
|
+
toolDefinition: payload.toolDefinition,
|
|
3042
|
+
resourceMeta: payload.resourceMeta,
|
|
3043
|
+
sessionStatus: payload.sessionStatus,
|
|
3044
|
+
sessionError: payload.sessionError,
|
|
3045
|
+
hostMode: 'hosted',
|
|
3046
|
+
trustedDomain: true,
|
|
3047
|
+
...(payload.chartConfig ? { chartConfig: payload.chartConfig } : {}),
|
|
3048
|
+
};
|
|
3049
|
+
const existing = canvasState.getNode(id);
|
|
3050
|
+
if (existing) {
|
|
3051
|
+
const previousSessionId = nodeAppSessionId(existing);
|
|
3052
|
+
const nextSessionId = typeof payload.appSessionId === 'string' ? payload.appSessionId : null;
|
|
3053
|
+
if (previousSessionId && nextSessionId && previousSessionId !== nextSessionId) {
|
|
3054
|
+
closeMcpAppSession(previousSessionId);
|
|
3055
|
+
}
|
|
3056
|
+
canvasState.updateNode(id, { data: { ...existing.data, ...dataPatch } });
|
|
3057
|
+
} else {
|
|
3058
|
+
const reusableNodeId =
|
|
3059
|
+
typeof payload.serverName === 'string' &&
|
|
3060
|
+
typeof payload.toolName === 'string' &&
|
|
3061
|
+
payload.serverName &&
|
|
3062
|
+
payload.toolName
|
|
3063
|
+
? findReusableCanvasExtAppNodeId(payload.serverName, payload.toolName)
|
|
3064
|
+
: null;
|
|
3065
|
+
if (reusableNodeId) {
|
|
3066
|
+
const reusableNode = canvasState.getNode(reusableNodeId);
|
|
3067
|
+
if (!reusableNode) return;
|
|
3068
|
+
const previousSessionId = nodeAppSessionId(reusableNode);
|
|
3069
|
+
const nextSessionId = typeof payload.appSessionId === 'string' ? payload.appSessionId : null;
|
|
3070
|
+
if (previousSessionId && nextSessionId && previousSessionId !== nextSessionId) {
|
|
3071
|
+
closeMcpAppSession(previousSessionId);
|
|
3072
|
+
}
|
|
3073
|
+
canvasState.updateNode(reusableNodeId, {
|
|
3074
|
+
data: { ...reusableNode.data, ...dataPatch },
|
|
3075
|
+
});
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
const pw = typeof payload.width === 'number' ? payload.width : 720;
|
|
3079
|
+
const ph = typeof payload.height === 'number' ? payload.height : 500;
|
|
3080
|
+
const autoPos = findOpenCanvasPosition(canvasState.getLayout().nodes, pw, ph);
|
|
3081
|
+
const px = typeof payload.x === 'number' ? payload.x : autoPos.x;
|
|
3082
|
+
const py = typeof payload.y === 'number' ? payload.y : autoPos.y;
|
|
3083
|
+
canvasState.addNode({
|
|
3084
|
+
id,
|
|
3085
|
+
type: 'mcp-app',
|
|
3086
|
+
position: { x: px, y: py },
|
|
3087
|
+
size: { width: pw, height: ph },
|
|
3088
|
+
zIndex: 1,
|
|
3089
|
+
collapsed: false,
|
|
3090
|
+
pinned: false,
|
|
3091
|
+
dockPosition: null,
|
|
3092
|
+
data: dataPatch,
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
} else if (event === 'ext-app-update') {
|
|
3096
|
+
const toolCallId = payload.toolCallId as string;
|
|
3097
|
+
if (!toolCallId) return;
|
|
3098
|
+
const id =
|
|
3099
|
+
findCanvasExtAppNodeId(toolCallId) ||
|
|
3100
|
+
(typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
|
|
3101
|
+
? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
|
|
3102
|
+
: null);
|
|
3103
|
+
if (!id) return;
|
|
3104
|
+
const existing = canvasState.getNode(id);
|
|
3105
|
+
if (existing) {
|
|
3106
|
+
canvasState.updateNode(id, { data: { ...existing.data, html: payload.html } });
|
|
3107
|
+
}
|
|
3108
|
+
} else if (event === 'ext-app-result') {
|
|
3109
|
+
const toolCallId = payload.toolCallId as string;
|
|
3110
|
+
if (!toolCallId) return;
|
|
3111
|
+
const id =
|
|
3112
|
+
findCanvasExtAppNodeId(toolCallId) ||
|
|
3113
|
+
(typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
|
|
3114
|
+
? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
|
|
3115
|
+
: null);
|
|
3116
|
+
if (!id) return;
|
|
3117
|
+
if (payload.success === false) {
|
|
3118
|
+
closeNodeAppSession(canvasState.getNode(id));
|
|
3119
|
+
canvasState.removeNode(id);
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
const existing = canvasState.getNode(id);
|
|
3123
|
+
if (existing) {
|
|
3124
|
+
canvasState.updateNode(id, {
|
|
3125
|
+
data: {
|
|
3126
|
+
...existing.data,
|
|
3127
|
+
toolResult: normalizeExtAppToolResult({
|
|
3128
|
+
result: payload.result,
|
|
3129
|
+
success: typeof payload.success === 'boolean' ? payload.success : undefined,
|
|
3130
|
+
error: typeof payload.error === 'string' ? payload.error : undefined,
|
|
3131
|
+
content: typeof payload.content === 'string' ? payload.content : undefined,
|
|
3132
|
+
detailedContent:
|
|
3133
|
+
typeof payload.detailedContent === 'string' ? payload.detailedContent : undefined,
|
|
3134
|
+
}),
|
|
3135
|
+
},
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
3138
|
+
} else if (event === 'context-cards') {
|
|
3139
|
+
syncContextNodeToCanvasState(
|
|
3140
|
+
{ cards: Array.isArray(payload.cards) ? payload.cards : [] },
|
|
3141
|
+
{ forceCreate: true },
|
|
3142
|
+
);
|
|
3143
|
+
} else if (event === 'context-usage') {
|
|
3144
|
+
syncContextNodeToCanvasState({
|
|
3145
|
+
currentTokens: payload.currentTokens,
|
|
3146
|
+
tokenLimit: payload.tokenLimit,
|
|
3147
|
+
messagesLength: payload.messagesLength,
|
|
3148
|
+
utilization: payload.utilization,
|
|
3149
|
+
nearLimit: payload.nearLimit,
|
|
3150
|
+
});
|
|
3151
|
+
} else if (event === 'aux-open') {
|
|
3152
|
+
const existing = canvasState.getNode('context-main');
|
|
3153
|
+
const auxTabs = Array.isArray(existing?.data.auxTabs)
|
|
3154
|
+
? [...(existing.data.auxTabs as Array<Record<string, unknown>>), payload]
|
|
3155
|
+
: [payload];
|
|
3156
|
+
syncContextNodeToCanvasState({ auxTabs }, { forceCreate: true });
|
|
3157
|
+
} else if (event === 'aux-close') {
|
|
3158
|
+
const existing = canvasState.getNode('context-main');
|
|
3159
|
+
if (!existing) return;
|
|
3160
|
+
if (payload.mode === 'all') {
|
|
3161
|
+
syncContextNodeToCanvasState({ auxTabs: [] });
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
const auxTabs = Array.isArray(existing.data.auxTabs)
|
|
3165
|
+
? (existing.data.auxTabs as Array<Record<string, unknown>>).filter(
|
|
3166
|
+
(tab) => tab.id !== payload.id,
|
|
3167
|
+
)
|
|
3168
|
+
: [];
|
|
3169
|
+
syncContextNodeToCanvasState({ auxTabs });
|
|
3170
|
+
} else if (event === 'canvas-status' || event === 'execution-phase') {
|
|
3171
|
+
const id = 'status-main';
|
|
3172
|
+
if (!canvasState.getNode(id)) {
|
|
3173
|
+
canvasState.addNode({
|
|
3174
|
+
id,
|
|
3175
|
+
type: 'status',
|
|
3176
|
+
position: { x: 40, y: 80 },
|
|
3177
|
+
size: { width: 300, height: 120 },
|
|
3178
|
+
zIndex: 0,
|
|
3179
|
+
collapsed: false,
|
|
3180
|
+
pinned: false,
|
|
3181
|
+
dockPosition: 'left',
|
|
3182
|
+
data: { phase: 'idle', message: '', elapsed: 0 },
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
} else if (event === 'canvas-response-start') {
|
|
3186
|
+
const responseNodeId = payload.responseNodeId as string;
|
|
3187
|
+
const promptNodeId = payload.promptNodeId as string;
|
|
3188
|
+
if (!responseNodeId) return;
|
|
3189
|
+
|
|
3190
|
+
const promptNode = promptNodeId ? canvasState.getNode(promptNodeId) : null;
|
|
3191
|
+
if (promptNode && Array.isArray(promptNode.data.turns)) {
|
|
3192
|
+
serverResponseToThreadMap.set(responseNodeId, promptNodeId);
|
|
3193
|
+
const currentTurns = [...(promptNode.data.turns as Array<Record<string, unknown>>)];
|
|
3194
|
+
currentTurns.push({ role: 'assistant', text: '', status: 'streaming' });
|
|
3195
|
+
canvasState.updateNode(promptNodeId, {
|
|
3196
|
+
data: {
|
|
3197
|
+
...promptNode.data,
|
|
3198
|
+
turns: currentTurns,
|
|
3199
|
+
threadStatus: 'streaming',
|
|
3200
|
+
_activeResponseId: responseNodeId,
|
|
3201
|
+
},
|
|
3202
|
+
});
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
const pos = promptNode
|
|
3207
|
+
? { x: promptNode.position.x, y: promptNode.position.y + promptNode.size.height + 24 }
|
|
3208
|
+
: { x: 380, y: 1480 };
|
|
3209
|
+
|
|
3210
|
+
canvasState.addNode({
|
|
3211
|
+
id: responseNodeId,
|
|
3212
|
+
type: 'response',
|
|
3213
|
+
position: pos,
|
|
3214
|
+
size: { width: 720, height: 400 },
|
|
3215
|
+
zIndex: 1,
|
|
3216
|
+
collapsed: false,
|
|
3217
|
+
pinned: false,
|
|
3218
|
+
dockPosition: null,
|
|
3219
|
+
data: { content: '', status: 'streaming', promptNodeId },
|
|
3220
|
+
});
|
|
3221
|
+
|
|
3222
|
+
if (promptNodeId) {
|
|
3223
|
+
canvasState.addEdge({
|
|
3224
|
+
id: `edge-${promptNodeId}-${responseNodeId}`,
|
|
3225
|
+
from: promptNodeId,
|
|
3226
|
+
to: responseNodeId,
|
|
3227
|
+
type: 'flow',
|
|
3228
|
+
animated: true,
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
} else if (event === 'canvas-response-delta') {
|
|
3232
|
+
const responseNodeId = payload.responseNodeId as string;
|
|
3233
|
+
if (!responseNodeId) return;
|
|
3234
|
+
|
|
3235
|
+
const threadId = serverResponseToThreadMap.get(responseNodeId);
|
|
3236
|
+
if (threadId) {
|
|
3237
|
+
const node = canvasState.getNode(threadId);
|
|
3238
|
+
if (node && Array.isArray(node.data.turns)) {
|
|
3239
|
+
const currentTurns = [...(node.data.turns as Array<Record<string, unknown>>)];
|
|
3240
|
+
const lastTurn = currentTurns[currentTurns.length - 1];
|
|
3241
|
+
if (lastTurn && lastTurn.role === 'assistant') {
|
|
3242
|
+
lastTurn.text = payload.content as string;
|
|
3243
|
+
lastTurn.status = 'streaming';
|
|
3244
|
+
}
|
|
3245
|
+
canvasState.updateNode(threadId, {
|
|
3246
|
+
data: { ...node.data, turns: currentTurns, threadStatus: 'streaming' },
|
|
3247
|
+
});
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
const existing = canvasState.getNode(responseNodeId);
|
|
3253
|
+
if (existing) {
|
|
3254
|
+
canvasState.updateNode(responseNodeId, {
|
|
3255
|
+
data: { ...existing.data, content: payload.content, status: 'streaming' },
|
|
3256
|
+
});
|
|
3257
|
+
}
|
|
3258
|
+
} else if (event === 'canvas-response-complete') {
|
|
3259
|
+
const responseNodeId = payload.responseNodeId as string;
|
|
3260
|
+
if (!responseNodeId) return;
|
|
3261
|
+
|
|
3262
|
+
const threadId = serverResponseToThreadMap.get(responseNodeId);
|
|
3263
|
+
if (threadId) {
|
|
3264
|
+
const node = canvasState.getNode(threadId);
|
|
3265
|
+
if (node && Array.isArray(node.data.turns)) {
|
|
3266
|
+
const currentTurns = [...(node.data.turns as Array<Record<string, unknown>>)];
|
|
3267
|
+
const lastTurn = currentTurns[currentTurns.length - 1];
|
|
3268
|
+
if (lastTurn && lastTurn.role === 'assistant') {
|
|
3269
|
+
lastTurn.text = payload.content as string;
|
|
3270
|
+
lastTurn.status = 'complete';
|
|
3271
|
+
}
|
|
3272
|
+
canvasState.updateNode(threadId, {
|
|
3273
|
+
data: {
|
|
3274
|
+
...node.data,
|
|
3275
|
+
turns: currentTurns,
|
|
3276
|
+
threadStatus: 'answered',
|
|
3277
|
+
_activeResponseId: undefined,
|
|
3278
|
+
},
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
serverResponseToThreadMap.delete(responseNodeId);
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
const existing = canvasState.getNode(responseNodeId);
|
|
3286
|
+
if (existing) {
|
|
3287
|
+
canvasState.updateNode(responseNodeId, {
|
|
3288
|
+
data: { ...existing.data, content: payload.content, status: 'complete' },
|
|
3289
|
+
});
|
|
3290
|
+
}
|
|
3291
|
+
const promptNodeId = existing?.data.promptNodeId as string | undefined;
|
|
3292
|
+
if (promptNodeId) {
|
|
3293
|
+
const edgeId = `edge-${promptNodeId}-${responseNodeId}`;
|
|
3294
|
+
const edge = canvasState.getEdges().find((e) => e.id === edgeId);
|
|
3295
|
+
if (edge) {
|
|
3296
|
+
canvasState.removeEdge(edgeId);
|
|
3297
|
+
canvasState.addEdge({ ...edge, animated: false });
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
if (promptNodeId) {
|
|
3302
|
+
const promptNode = canvasState.getNode(promptNodeId);
|
|
3303
|
+
if (promptNode) {
|
|
3304
|
+
canvasState.updateNode(promptNodeId, {
|
|
3305
|
+
data: { ...promptNode.data, status: 'answered', responseNodeId },
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
export function emitPrimaryWorkbenchEvent(
|
|
3313
|
+
event: string,
|
|
3314
|
+
payload: PrimaryWorkbenchEventPayload = {},
|
|
3315
|
+
): void {
|
|
3316
|
+
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
3317
|
+
const envelope = {
|
|
3318
|
+
...payload,
|
|
3319
|
+
sessionId: primaryWorkbenchSessionId,
|
|
3320
|
+
timestamp: new Date().toISOString(),
|
|
3321
|
+
};
|
|
3322
|
+
if (event === 'context-cards') {
|
|
3323
|
+
lastWorkbenchContextCardsEnvelope = { ...envelope };
|
|
3324
|
+
}
|
|
3325
|
+
syncEventToCanvasState(event, envelope);
|
|
3326
|
+
if (primaryWorkbenchAutoOpenEnabled && (event === 'workbench-open' || event === 'ext-app-open')) {
|
|
3327
|
+
ensureCanvasBrowserOpen();
|
|
3328
|
+
}
|
|
3329
|
+
broadcastWorkbenchEvent(event, envelope);
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
export function consumePrimaryWorkbenchIntents(limit = 24): PrimaryWorkbenchIntent[] {
|
|
3333
|
+
const requested = Number.isFinite(limit) ? Math.floor(limit) : 24;
|
|
3334
|
+
const count = Math.max(1, Math.min(100, requested));
|
|
3335
|
+
if (pendingWorkbenchIntents.length === 0) return [];
|
|
3336
|
+
return pendingWorkbenchIntents.splice(0, count);
|
|
3337
|
+
}
|
|
3338
|
+
|
|
3339
|
+
export function getPrimaryWorkbenchUrl(workspaceRoot = process.cwd()): string | null {
|
|
3340
|
+
const base = startCanvasServer({ workspaceRoot });
|
|
3341
|
+
if (!base) return null;
|
|
3342
|
+
return `${base}/workbench`;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
// ── Shared "canvas browser opened" flag ─────────────────────────
|
|
3346
|
+
let canvasBrowserOpened = false;
|
|
3347
|
+
let canvasBrowserOpening = false;
|
|
3348
|
+
|
|
3349
|
+
export function syncCanvasBrowserOpenedFromSubscribers(): void {
|
|
3350
|
+
canvasBrowserOpened = workbenchSubscribers.size > 0;
|
|
3351
|
+
canvasBrowserOpening = false;
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
export function isCanvasBrowserOpened(): boolean {
|
|
3355
|
+
return canvasBrowserOpened;
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
export function isCanvasBrowserOpening(): boolean {
|
|
3359
|
+
return canvasBrowserOpening;
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
export function markCanvasBrowserOpened(): void {
|
|
3363
|
+
canvasBrowserOpened = true;
|
|
3364
|
+
canvasBrowserOpening = false;
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
export function markCanvasBrowserOpening(): void {
|
|
3368
|
+
canvasBrowserOpening = true;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
function ensureCanvasBrowserOpen(): void {
|
|
3372
|
+
if (!primaryWorkbenchAutoOpenEnabled) return;
|
|
3373
|
+
if (canvasBrowserOpened) return;
|
|
3374
|
+
if (canvasBrowserOpening) return;
|
|
3375
|
+
if (workbenchSubscribers.size > 0) {
|
|
3376
|
+
canvasBrowserOpened = true;
|
|
3377
|
+
canvasBrowserOpening = false;
|
|
3378
|
+
return;
|
|
3379
|
+
}
|
|
3380
|
+
const publicUrl = getPrimaryWorkbenchUrl();
|
|
3381
|
+
if (!publicUrl || !server || typeof server.port !== 'number') return;
|
|
3382
|
+
|
|
3383
|
+
canvasBrowserOpening = true;
|
|
3384
|
+
if (openUrlInExternalBrowser(publicUrl)) {
|
|
3385
|
+
canvasBrowserOpened = true;
|
|
3386
|
+
canvasBrowserOpening = false;
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
canvasBrowserOpening = false;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
export function openPrimaryWorkbenchPath(
|
|
3393
|
+
pathLike: string,
|
|
3394
|
+
workspaceRoot = process.cwd(),
|
|
3395
|
+
): string | null {
|
|
3396
|
+
const safePath = resolve(pathLike);
|
|
3397
|
+
if (!isMarkdownFile(safePath)) return null;
|
|
3398
|
+
if (!existsSync(safePath)) return null;
|
|
3399
|
+
|
|
3400
|
+
const base = startCanvasServer({ workspaceRoot });
|
|
3401
|
+
if (!base) return null;
|
|
3402
|
+
setPrimaryWorkbenchPath(safePath, 'open-primary');
|
|
3403
|
+
return `${base}/workbench`;
|
|
3404
|
+
}
|
|
3405
|
+
|
|
3406
|
+
// ── Server startup ────────────────────────────────────────────
|
|
3407
|
+
|
|
3408
|
+
export interface CanvasServerOptions {
|
|
3409
|
+
port?: number;
|
|
3410
|
+
workspaceRoot?: string;
|
|
3411
|
+
autoOpenBrowser?: boolean;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
export function startCanvasServer(options: CanvasServerOptions = {}): string | null {
|
|
3415
|
+
if (server) {
|
|
3416
|
+
return typeof server.port === 'number' ? loopbackBaseUrl(server.port) : null;
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
const workspaceRoot = options.workspaceRoot ?? process.cwd();
|
|
3420
|
+
activeWorkspaceRoot = normalizeWorkspaceRoot(workspaceRoot);
|
|
3421
|
+
if (options.autoOpenBrowser !== undefined) {
|
|
3422
|
+
primaryWorkbenchAutoOpenEnabled = options.autoOpenBrowser;
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
// Ensure direct HTTP server usage records undo/redo history, not just PmxCanvas.start().
|
|
3426
|
+
canvasState.onMutation((info) => {
|
|
3427
|
+
mutationHistory.record({
|
|
3428
|
+
description: info.description,
|
|
3429
|
+
operationType: info.operationType,
|
|
3430
|
+
forward: info.forward,
|
|
3431
|
+
inverse: info.inverse,
|
|
3432
|
+
});
|
|
3433
|
+
});
|
|
3434
|
+
|
|
3435
|
+
// ── Canvas persistence: set workspace root and load saved state ──
|
|
3436
|
+
canvasState.setWorkspaceRoot(activeWorkspaceRoot);
|
|
3437
|
+
const loaded = canvasState.loadFromDisk({ clearExisting: true });
|
|
3438
|
+
if (loaded) {
|
|
3439
|
+
console.log(' Canvas state restored from .pmx-canvas/state.json');
|
|
3440
|
+
primeCanvasRuntimeBackends({ forceRehydrateExtApps: true });
|
|
3441
|
+
void syncCanvasRuntimeBackends({ forceRehydrateExtApps: true, alreadyPrimed: true }).finally(() => {
|
|
3442
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3443
|
+
});
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
3447
|
+
|
|
3448
|
+
const preferredPort = options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? DEFAULT_PORT);
|
|
3449
|
+
const portCandidates = buildPortCandidates(preferredPort);
|
|
3450
|
+
|
|
3451
|
+
for (const portCandidate of portCandidates) {
|
|
3452
|
+
try {
|
|
3453
|
+
server = Bun.serve({
|
|
3454
|
+
hostname: DEFAULT_HOST,
|
|
3455
|
+
port: portCandidate,
|
|
3456
|
+
idleTimeout: 0,
|
|
3457
|
+
async fetch(req) {
|
|
3458
|
+
const url = new URL(req.url);
|
|
3459
|
+
|
|
3460
|
+
if (url.pathname === '/health') {
|
|
3461
|
+
return responseJson({ ok: true, workspace: activeWorkspaceRoot });
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.svg') {
|
|
3465
|
+
return serveCanvasFavicon();
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
if (url.pathname === '/artifact' && url.searchParams.has('path')) {
|
|
3469
|
+
return handleArtifactView(url);
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
if (url.pathname === '/api/canvas/json-render/view' && req.method === 'GET') {
|
|
3473
|
+
return handleJsonRenderView(url);
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
if (url.pathname === '/' || url.pathname === '/workbench' || url.pathname === '/artifact') {
|
|
3477
|
+
return new Response(canvasSpaHtml(), {
|
|
3478
|
+
headers: {
|
|
3479
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
3480
|
+
'Cache-Control': 'no-store',
|
|
3481
|
+
},
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
if (url.pathname === '/api/file' && req.method === 'GET') {
|
|
3486
|
+
return handleRead(url.searchParams.get('path') ?? '');
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
if (url.pathname === '/api/workbench/state' && req.method === 'GET') {
|
|
3490
|
+
return handleWorkbenchState();
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
if (url.pathname === '/api/workbench/open' && req.method === 'POST') {
|
|
3494
|
+
return handleWorkbenchOpen(req);
|
|
3495
|
+
}
|
|
3496
|
+
|
|
3497
|
+
if (url.pathname === '/api/workbench/events' && req.method === 'GET') {
|
|
3498
|
+
return handleWorkbenchEvents(req);
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
if (url.pathname === '/api/workbench/intent' && req.method === 'POST') {
|
|
3502
|
+
return handleWorkbenchIntent(req);
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
if (url.pathname === '/api/workbench/webview' && req.method === 'GET') {
|
|
3506
|
+
return handleWorkbenchWebViewStatus();
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
if (url.pathname === '/api/workbench/webview/start' && req.method === 'POST') {
|
|
3510
|
+
return handleWorkbenchWebViewStart(req);
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
if (url.pathname === '/api/workbench/webview/evaluate' && req.method === 'POST') {
|
|
3514
|
+
return handleWorkbenchWebViewEvaluate(req);
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
if (url.pathname === '/api/workbench/webview/resize' && req.method === 'POST') {
|
|
3518
|
+
return handleWorkbenchWebViewResize(req);
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
if (url.pathname === '/api/workbench/webview/screenshot' && req.method === 'POST') {
|
|
3522
|
+
return handleWorkbenchWebViewScreenshot(req);
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
if (url.pathname === '/api/workbench/webview' && req.method === 'DELETE') {
|
|
3526
|
+
return handleWorkbenchWebViewStop();
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
if (url.pathname === '/api/file/save' && req.method === 'POST') {
|
|
3530
|
+
return handleSave(req);
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
if (url.pathname === '/api/render' && req.method === 'POST') {
|
|
3534
|
+
return handleRender(req);
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
// Canvas state API
|
|
3538
|
+
if (url.pathname === '/api/canvas/state' && req.method === 'GET') {
|
|
3539
|
+
return responseJson(serializeCanvasLayout(canvasState.getLayout()));
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
|
|
3543
|
+
return responseJson(buildCanvasSummary());
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
if (url.pathname === '/api/canvas/update' && req.method === 'POST') {
|
|
3547
|
+
return handleCanvasUpdate(req);
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
if (url.pathname === '/api/canvas/schema' && req.method === 'GET') {
|
|
3551
|
+
return handleCanvasDescribeSchema();
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
if (url.pathname === '/api/canvas/schema/validate' && req.method === 'POST') {
|
|
3555
|
+
return handleCanvasValidateSpec(req);
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
if (url.pathname === '/api/canvas/batch' && req.method === 'POST') {
|
|
3559
|
+
return handleCanvasBatch(req);
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
if (url.pathname === '/api/canvas/viewport' && req.method === 'POST') {
|
|
3563
|
+
return handleCanvasViewport(req);
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
if (url.pathname === '/api/canvas/node' && req.method === 'POST') {
|
|
3567
|
+
return handleCanvasAddNode(req);
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
if (url.pathname === '/api/canvas/mcp-app/open' && req.method === 'POST') {
|
|
3571
|
+
return handleCanvasOpenMcpApp(req);
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
if (url.pathname === '/api/canvas/diagram' && req.method === 'POST') {
|
|
3575
|
+
return handleCanvasAddDiagram(req);
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
if (url.pathname === '/api/canvas/web-artifact' && req.method === 'POST') {
|
|
3579
|
+
return handleCanvasBuildWebArtifact(req);
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
// Individual node GET/PATCH/DELETE
|
|
3583
|
+
if (url.pathname.startsWith('/api/canvas/node/') && url.pathname.endsWith('/refresh') && req.method === 'POST') {
|
|
3584
|
+
const nodeId = url.pathname.slice('/api/canvas/node/'.length, -'/refresh'.length);
|
|
3585
|
+
return handleCanvasRefreshWebpageNode(nodeId, req);
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'GET') {
|
|
3589
|
+
const nodeId = url.pathname.slice('/api/canvas/node/'.length);
|
|
3590
|
+
const node = canvasState.getNode(nodeId);
|
|
3591
|
+
if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
3592
|
+
return responseJson(serializeCanvasNode(node));
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {
|
|
3596
|
+
const nodeId = url.pathname.slice('/api/canvas/node/'.length);
|
|
3597
|
+
return handleCanvasUpdateNode(nodeId, req);
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'DELETE') {
|
|
3601
|
+
const nodeId = url.pathname.slice('/api/canvas/node/'.length);
|
|
3602
|
+
closeNodeAppSession(canvasState.getNode(nodeId));
|
|
3603
|
+
const result = removeCanvasNode(nodeId);
|
|
3604
|
+
if (!result.removed) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
|
|
3605
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3606
|
+
if (result.needsCodeGraphRecompute) {
|
|
3607
|
+
scheduleCodeGraphRecompute(() => {
|
|
3608
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3609
|
+
});
|
|
3610
|
+
}
|
|
3611
|
+
return responseJson({ ok: true, removed: nodeId });
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
if (url.pathname.startsWith('/api/canvas/image/') && req.method === 'GET') {
|
|
3615
|
+
return handleCanvasImage(url.pathname);
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
if (url.pathname === '/api/canvas/edge' && req.method === 'POST') {
|
|
3619
|
+
return handleCanvasAddEdge(req);
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
if (url.pathname === '/api/canvas/edge' && req.method === 'DELETE') {
|
|
3623
|
+
return handleCanvasRemoveEdge(req);
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
// Snapshot API
|
|
3627
|
+
if (url.pathname === '/api/canvas/snapshots' && req.method === 'GET') {
|
|
3628
|
+
return responseJson(listCanvasSnapshots());
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
if (url.pathname === '/api/canvas/snapshots' && req.method === 'POST') {
|
|
3632
|
+
return handleSnapshotSave(req);
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
if (url.pathname.startsWith('/api/canvas/snapshots/') && url.pathname.endsWith('/diff') && req.method === 'GET') {
|
|
3636
|
+
const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length, -'/diff'.length));
|
|
3637
|
+
const snapshot = canvasState.getSnapshotData(id);
|
|
3638
|
+
if (!snapshot) return responseJson({ ok: false, error: `Snapshot "${id}" not found.` }, 404);
|
|
3639
|
+
const diff = diffLayouts(snapshot.name, snapshot, canvasState.getLayout());
|
|
3640
|
+
return responseJson({ ok: true, text: formatDiff(diff), diff });
|
|
3641
|
+
}
|
|
3642
|
+
|
|
3643
|
+
if (url.pathname.startsWith('/api/canvas/snapshots/') && req.method === 'POST') {
|
|
3644
|
+
const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length));
|
|
3645
|
+
const result = await restoreCanvasSnapshot(id);
|
|
3646
|
+
if (!result.ok) return responseText('Snapshot not found', 404);
|
|
3647
|
+
broadcastWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3648
|
+
return responseJson({ ok: true });
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
if (url.pathname.startsWith('/api/canvas/snapshots/') && req.method === 'DELETE') {
|
|
3652
|
+
const id = url.pathname.split('/').pop() ?? '';
|
|
3653
|
+
const result = deleteCanvasSnapshot(id);
|
|
3654
|
+
if (!result.ok) return responseText('Snapshot not found', 404);
|
|
3655
|
+
return responseJson({ ok: true });
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
// Context pins API
|
|
3659
|
+
if (url.pathname === '/api/canvas/context-pins' && req.method === 'POST') {
|
|
3660
|
+
return handleContextPinsUpdate(req);
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
if (url.pathname === '/api/canvas/pinned-context' && req.method === 'GET') {
|
|
3664
|
+
return handleGetPinnedContext();
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
// Spatial context API
|
|
3668
|
+
if (url.pathname === '/api/canvas/spatial-context' && req.method === 'GET') {
|
|
3669
|
+
const layout = canvasState.getLayout();
|
|
3670
|
+
const spatial = buildSpatialContext(layout.nodes, layout.edges, canvasState.contextPinnedNodeIds);
|
|
3671
|
+
return responseJson(spatial);
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
// Search API
|
|
3675
|
+
if (url.pathname === '/api/canvas/search' && req.method === 'GET') {
|
|
3676
|
+
const q = url.searchParams.get('q') ?? '';
|
|
3677
|
+
if (!q.trim()) {
|
|
3678
|
+
return responseJson({ results: [], query: q });
|
|
3679
|
+
}
|
|
3680
|
+
const results = searchNodes(canvasState.getLayout().nodes, q);
|
|
3681
|
+
return responseJson({ results, query: q });
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
// Group API
|
|
3685
|
+
if (url.pathname === '/api/canvas/group' && req.method === 'POST') {
|
|
3686
|
+
return handleCanvasCreateGroup(req);
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
if (url.pathname === '/api/canvas/group/add' && req.method === 'POST') {
|
|
3690
|
+
return handleCanvasGroupNodes(req);
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
if (url.pathname === '/api/canvas/group/ungroup' && req.method === 'POST') {
|
|
3694
|
+
return handleCanvasUngroupNodes(req);
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
// Arrange / Focus / Clear API (for agent CLI)
|
|
3698
|
+
if (url.pathname === '/api/canvas/arrange' && req.method === 'POST') {
|
|
3699
|
+
return handleCanvasArrange(req);
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3702
|
+
if (url.pathname === '/api/canvas/focus' && req.method === 'POST') {
|
|
3703
|
+
return handleCanvasFocus(req);
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
if (url.pathname === '/api/canvas/clear' && req.method === 'POST') {
|
|
3707
|
+
for (const node of canvasState.getLayout().nodes) {
|
|
3708
|
+
closeNodeAppSession(node);
|
|
3709
|
+
}
|
|
3710
|
+
clearCanvas();
|
|
3711
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3712
|
+
return responseJson({ ok: true });
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3715
|
+
// Code graph API
|
|
3716
|
+
if (url.pathname === '/api/canvas/code-graph' && req.method === 'GET') {
|
|
3717
|
+
const summary = buildCodeGraphSummary();
|
|
3718
|
+
return responseJson(summary);
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
if (url.pathname === '/api/canvas/json-render' && req.method === 'POST') {
|
|
3722
|
+
return handleCanvasAddJsonRender(req);
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
if (url.pathname === '/api/canvas/graph' && req.method === 'POST') {
|
|
3726
|
+
return handleCanvasAddGraph(req);
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
if (url.pathname === '/api/canvas/prompt' && req.method === 'POST') {
|
|
3730
|
+
return handleCanvasPrompt(req);
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
// Undo/Redo/History API
|
|
3734
|
+
if (url.pathname === '/api/canvas/undo' && req.method === 'POST') {
|
|
3735
|
+
const entry = mutationHistory.undo();
|
|
3736
|
+
if (!entry) return responseJson({ ok: false, description: 'Nothing to undo' });
|
|
3737
|
+
await syncCanvasRuntimeBackends();
|
|
3738
|
+
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
3739
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3740
|
+
return responseJson({ ok: true, description: `Undid: ${entry.description}` });
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
if (url.pathname === '/api/canvas/redo' && req.method === 'POST') {
|
|
3744
|
+
const entry = mutationHistory.redo();
|
|
3745
|
+
if (!entry) return responseJson({ ok: false, description: 'Nothing to redo' });
|
|
3746
|
+
await syncCanvasRuntimeBackends();
|
|
3747
|
+
emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
|
|
3748
|
+
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
3749
|
+
return responseJson({ ok: true, description: `Redid: ${entry.description}` });
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
if (url.pathname === '/api/canvas/history' && req.method === 'GET') {
|
|
3753
|
+
return responseJson({
|
|
3754
|
+
text: mutationHistory.toHumanReadable(),
|
|
3755
|
+
entries: mutationHistory.getSummaries(),
|
|
3756
|
+
canUndo: mutationHistory.canUndo(),
|
|
3757
|
+
canRedo: mutationHistory.canRedo(),
|
|
3758
|
+
});
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
if (url.pathname === '/api/canvas/validate' && req.method === 'GET') {
|
|
3762
|
+
return handleCanvasValidate();
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
if (url.pathname === '/api/ext-app/call-tool' && req.method === 'POST') {
|
|
3766
|
+
return handleExtAppCallTool(req);
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
if (url.pathname === '/api/ext-app/read-resource' && req.method === 'POST') {
|
|
3770
|
+
return handleExtAppReadResource(req);
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
if (url.pathname === '/api/ext-app/list-tools' && req.method === 'POST') {
|
|
3774
|
+
return handleExtAppListTools(req);
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
if (url.pathname === '/api/ext-app/list-resources' && req.method === 'POST') {
|
|
3778
|
+
return handleExtAppListResources(req);
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
if (url.pathname === '/api/ext-app/list-resource-templates' && req.method === 'POST') {
|
|
3782
|
+
return handleExtAppListResourceTemplates(req);
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
if (url.pathname === '/api/ext-app/list-prompts' && req.method === 'POST') {
|
|
3786
|
+
return handleExtAppListPrompts(req);
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
if (url.pathname === '/api/ext-app/model-context' && req.method === 'POST') {
|
|
3790
|
+
return handleExtAppModelContext(req);
|
|
3791
|
+
}
|
|
3792
|
+
|
|
3793
|
+
// Static files for canvas SPA bundle
|
|
3794
|
+
if (url.pathname.startsWith('/canvas/')) {
|
|
3795
|
+
const staticResponse = serveCanvasStatic(url.pathname);
|
|
3796
|
+
if (staticResponse) return staticResponse;
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
return responseText('Not found', 404);
|
|
3800
|
+
},
|
|
3801
|
+
});
|
|
3802
|
+
return typeof server.port === 'number' ? loopbackBaseUrl(server.port) : null;
|
|
3803
|
+
} catch (error) {
|
|
3804
|
+
logWorkbenchWarning('startCanvasServer candidate failed', error, { portCandidate });
|
|
3805
|
+
server = null;
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
return null;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
export function stopCanvasServer(): void {
|
|
3812
|
+
canvasState.flushToDisk();
|
|
3813
|
+
closeAllMcpAppSessions();
|
|
3814
|
+
void closeCanvasAutomationWebViewInternal().catch((error) => {
|
|
3815
|
+
logWorkbenchWarning('stopCanvasServer closeCanvasAutomationWebViewInternal', error);
|
|
3816
|
+
});
|
|
3817
|
+
if (server) {
|
|
3818
|
+
server.stop(true);
|
|
3819
|
+
server = null;
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
export function getCanvasServerPort(): number | null {
|
|
3824
|
+
return server && typeof server.port === 'number' ? server.port : null;
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
// Re-exports
|
|
3828
|
+
export {
|
|
3829
|
+
closeMcpAppHostSession,
|
|
3830
|
+
focusMcpAppHostSession,
|
|
3831
|
+
getMcpAppHostSnapshot,
|
|
3832
|
+
isTrustedMcpAppDomain,
|
|
3833
|
+
listMcpAppHostSessions,
|
|
3834
|
+
markMcpAppHostSessionOpenedExternally,
|
|
3835
|
+
preRegisterKnownMcpAppHostCapabilities,
|
|
3836
|
+
registerMcpAppHostCapability,
|
|
3837
|
+
routeMcpAppCandidateToHost,
|
|
3838
|
+
} from './mcp-app-host.js';
|
|
3839
|
+
export type {
|
|
3840
|
+
McpAppCandidateInput,
|
|
3841
|
+
McpAppHostCapability,
|
|
3842
|
+
McpAppHostCapabilityState,
|
|
3843
|
+
McpAppHostRoutingResult,
|
|
3844
|
+
McpAppHostSession,
|
|
3845
|
+
McpAppHostSnapshot,
|
|
3846
|
+
} from './mcp-app-host.js';
|