@bitovi/vybit 0.8.2 → 0.9.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/overlay/dist/overlay.js +535 -211
- package/package.json +3 -1
- package/panel/dist/assets/DesignMode-CZvwfLMn.js +511 -0
- package/panel/dist/assets/index-BdUOBofL.css +1 -0
- package/panel/dist/assets/index-CzGpZV08.js +62 -0
- package/panel/dist/index.html +2 -2
- package/server/app.ts +64 -1
- package/server/index.ts +5 -1
- package/server/mcp-tools.ts +120 -14
- package/server/queue.ts +4 -0
- package/server/storybook.ts +110 -0
- package/server/websocket.ts +9 -0
- package/shared/types.ts +72 -3
- package/panel/dist/assets/DesignMode-DHfdZ_OH.js +0 -510
- package/panel/dist/assets/index-Bk7q5Yfb.css +0 -1
- package/panel/dist/assets/index-DwDMVGkn.js +0 -49
package/panel/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
font-size: 12px;
|
|
17
17
|
}
|
|
18
18
|
</style>
|
|
19
|
-
<script type="module" crossorigin src="/panel/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/panel/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/panel/assets/index-CzGpZV08.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/panel/assets/index-BdUOBofL.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<div id="root"></div>
|
package/server/app.ts
CHANGED
|
@@ -4,14 +4,16 @@ import express from "express";
|
|
|
4
4
|
import cors from "cors";
|
|
5
5
|
import { request as makeRequest } from "http";
|
|
6
6
|
import path from "path";
|
|
7
|
+
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
7
8
|
|
|
8
9
|
import { getByStatus, getQueueUpdate, clearAll } from "./queue.js";
|
|
9
10
|
import { resolveTailwindConfig, generateCssForClasses, getTailwindVersion } from "./tailwind.js";
|
|
11
|
+
import { loadStoryArgTypes } from "./storybook.js";
|
|
10
12
|
import type { PatchStatus } from "../shared/types.js";
|
|
11
13
|
|
|
12
14
|
const VALID_STATUSES = new Set<string>(['staged', 'committed', 'implementing', 'implemented', 'error']);
|
|
13
15
|
|
|
14
|
-
export function createApp(packageRoot: string): express.Express {
|
|
16
|
+
export function createApp(packageRoot: string, storybookUrl: string | null = null): express.Express {
|
|
15
17
|
const app = express();
|
|
16
18
|
app.use(cors());
|
|
17
19
|
|
|
@@ -82,6 +84,67 @@ export function createApp(packageRoot: string): express.Express {
|
|
|
82
84
|
res.json({ ok: true });
|
|
83
85
|
});
|
|
84
86
|
|
|
87
|
+
// --- Storybook proxy ---
|
|
88
|
+
if (storybookUrl) {
|
|
89
|
+
// Vite's dev server serves assets at many root-absolute path prefixes:
|
|
90
|
+
// /@vite/client, /@react-refresh, /@id/..., /node_modules/.cache/..., /src/...
|
|
91
|
+
// Rather than enumerate them all, proxy everything that isn't ours.
|
|
92
|
+
const OWN_PATHS = new Set(['/panel', '/overlay.js', '/api', '/patches', '/css', '/tailwind-config']);
|
|
93
|
+
const isOwnPath = (p: string) =>
|
|
94
|
+
OWN_PATHS.has(p) ||
|
|
95
|
+
[...OWN_PATHS].some(own => p.startsWith(own + '/')) ||
|
|
96
|
+
p === '/';
|
|
97
|
+
|
|
98
|
+
app.use('/storybook', createProxyMiddleware({
|
|
99
|
+
target: storybookUrl,
|
|
100
|
+
changeOrigin: true,
|
|
101
|
+
pathRewrite: { '^/storybook': '' },
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
app.use(createProxyMiddleware({
|
|
105
|
+
target: storybookUrl,
|
|
106
|
+
changeOrigin: true,
|
|
107
|
+
pathFilter: (pathname) => !isOwnPath(pathname),
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
console.error(`[storybook] Proxying /storybook + Vite asset paths → ${storybookUrl}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
app.get('/api/storybook-status', (_req, res) => {
|
|
114
|
+
res.json({ url: storybookUrl ? '/storybook' : null, directUrl: storybookUrl ?? null });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
app.get('/api/storybook-argtypes', async (_req, res) => {
|
|
118
|
+
if (!storybookUrl) { res.json({}); return; }
|
|
119
|
+
try {
|
|
120
|
+
const argTypes = await loadStoryArgTypes(storybookUrl);
|
|
121
|
+
res.json(argTypes);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error('[storybook] /api/storybook-argtypes failed:', err);
|
|
124
|
+
res.json({});
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Single endpoint: everything the Draw tab panel needs in one request.
|
|
129
|
+
app.get('/api/storybook-data', async (_req, res) => {
|
|
130
|
+
if (!storybookUrl) {
|
|
131
|
+
res.json({ available: false });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const [indexRes, argTypes] = await Promise.all([
|
|
136
|
+
fetch(`${storybookUrl}/index.json`, { signal: AbortSignal.timeout(5000) }),
|
|
137
|
+
loadStoryArgTypes(storybookUrl),
|
|
138
|
+
]);
|
|
139
|
+
if (!indexRes.ok) { res.json({ available: false }); return; }
|
|
140
|
+
const index = await indexRes.json();
|
|
141
|
+
res.json({ available: true, directUrl: storybookUrl, index, argTypes });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error('[storybook] /api/storybook-data failed:', err);
|
|
144
|
+
res.json({ available: false });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
85
148
|
// --- Serve Panel app ---
|
|
86
149
|
if (process.env.PANEL_DEV) {
|
|
87
150
|
const panelDevPort = Number(process.env.PANEL_DEV_PORT) || 5174;
|
package/server/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { createApp } from "./app.js";
|
|
|
16
16
|
import { setupWebSocket } from "./websocket.js";
|
|
17
17
|
import { registerMcpTools } from "./mcp-tools.js";
|
|
18
18
|
import { checkTailwindAvailable } from "./tailwind.js";
|
|
19
|
+
import { detectStorybookUrl } from "./storybook.js";
|
|
19
20
|
|
|
20
21
|
// --- Resolve project root (precedence: --cwd flag, VYBIT_PROJECT_ROOT, cwd) ---
|
|
21
22
|
const argv = process.argv.slice(2);
|
|
@@ -57,8 +58,11 @@ const packageRoot = __dirname.includes(`${path.sep}dist${path.sep}`)
|
|
|
57
58
|
|
|
58
59
|
const port = Number(process.env.PORT) || 3333;
|
|
59
60
|
|
|
61
|
+
// --- Storybook detection ---
|
|
62
|
+
const storybookUrl = await detectStorybookUrl();
|
|
63
|
+
|
|
60
64
|
// --- HTTP + WebSocket ---
|
|
61
|
-
const app = createApp(packageRoot);
|
|
65
|
+
const app = createApp(packageRoot, storybookUrl);
|
|
62
66
|
const httpServer = createServer(app);
|
|
63
67
|
const { broadcastPatchUpdate } = setupWebSocket(httpServer);
|
|
64
68
|
|
package/server/mcp-tools.ts
CHANGED
|
@@ -28,16 +28,55 @@ const KEEPALIVE_INTERVAL_MS = 60_000;
|
|
|
28
28
|
// Prompt builders for implement_next_change
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// JSX builder: converts componentArgs to a JSX string like <Button variant="primary">Click me</Button>
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function buildJsx(componentName: string, args?: Record<string, unknown>): string {
|
|
36
|
+
if (!args || Object.keys(args).length === 0) return `<${componentName} />`;
|
|
37
|
+
|
|
38
|
+
const { children, ...rest } = args;
|
|
39
|
+
const props = Object.entries(rest)
|
|
40
|
+
.map(([key, value]) => {
|
|
41
|
+
if (typeof value === 'string') return `${key}="${value}"`;
|
|
42
|
+
if (typeof value === 'boolean') return value ? key : `${key}={false}`;
|
|
43
|
+
return `${key}={${JSON.stringify(value)}}`;
|
|
44
|
+
})
|
|
45
|
+
.join(' ');
|
|
46
|
+
|
|
47
|
+
const propsStr = props ? ` ${props}` : '';
|
|
48
|
+
|
|
49
|
+
if (children != null && children !== '') {
|
|
50
|
+
const childStr = typeof children === 'string' ? children : `{${JSON.stringify(children)}}`;
|
|
51
|
+
return `<${componentName}${propsStr}>${childStr}</${componentName}>`;
|
|
52
|
+
}
|
|
53
|
+
return `<${componentName}${propsStr} />`;
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
function buildCommitInstructions(commit: Commit, remainingCount: number): string {
|
|
32
57
|
const classChanges = commit.patches.filter(p => p.kind === 'class-change');
|
|
33
58
|
const messages = commit.patches.filter(p => p.kind === 'message');
|
|
34
59
|
const designs = commit.patches.filter(p => p.kind === 'design');
|
|
60
|
+
const componentDrops = commit.patches.filter(p => p.kind === 'component-drop');
|
|
35
61
|
const moreText = remainingCount > 0
|
|
36
62
|
? `${remainingCount} more commit${remainingCount === 1 ? '' : 's'} waiting in the queue after this one.`
|
|
37
63
|
: 'This is the last commit in the queue. After implementing it, call `implement_next_change` again to wait for future changes.';
|
|
38
64
|
|
|
39
|
-
|
|
65
|
+
// Build a map from patch ID → step number for ghost-chain references
|
|
66
|
+
const patchStepMap = new Map<string, number>();
|
|
40
67
|
let stepNum = 1;
|
|
68
|
+
for (const patch of commit.patches) {
|
|
69
|
+
patchStepMap.set(patch.id, stepNum);
|
|
70
|
+
stepNum++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let patchList = '';
|
|
74
|
+
const hasMultipleDrops = componentDrops.length > 1;
|
|
75
|
+
if (hasMultipleDrops) {
|
|
76
|
+
patchList += `> ⚠️ **Apply component insertions IN ORDER** — later drops may reference components added by earlier steps.\n\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
stepNum = 1;
|
|
41
80
|
for (const patch of commit.patches) {
|
|
42
81
|
if (patch.kind === 'class-change') {
|
|
43
82
|
const comp = patch.component?.name ?? 'unknown component';
|
|
@@ -66,6 +105,50 @@ ${patch.elementKey ? `\n_Scoped to: ${patch.elementKey}_\n` : ''}
|
|
|
66
105
|
- **Canvas size:** ${patch.canvasWidth ?? '?'}×${patch.canvasHeight ?? '?'}px
|
|
67
106
|
- The design image is included as a separate image content part below — refer to it for the visual intent.
|
|
68
107
|
${context ? `- **Context HTML:**\n\`\`\`html\n${context}\n\`\`\`\n` : ''}
|
|
108
|
+
${patch.canvasComponents && patch.canvasComponents.length > 0 ? `
|
|
109
|
+
**Components to place (positions relative to canvas top-left):**
|
|
110
|
+
|
|
111
|
+
| # | Component | Import | Props | Position | Size |
|
|
112
|
+
|---|-----------|--------|-------|----------|------|
|
|
113
|
+
${patch.canvasComponents.map((c: any, i: number) => {
|
|
114
|
+
const importPath = c.componentPath ? c.componentPath.replace(/\.tsx?$/, '') : '—';
|
|
115
|
+
const props = c.args ? Object.entries(c.args).map(([k, v]) => typeof v === 'string' ? `${k}="${v}"` : `${k}={${JSON.stringify(v)}}`).join(' ') : '—';
|
|
116
|
+
return `| ${i + 1} | \`${c.componentName}\` | \`${importPath}\` | ${props} | (${c.x}, ${c.y}) | ${c.width}×${c.height}px |`;
|
|
117
|
+
}).join('\n')}
|
|
118
|
+
|
|
119
|
+
⚠️ Import and render these React components at the indicated positions. Use the design image as a visual reference for the overall layout. Do NOT paste rendered HTML.
|
|
120
|
+
` : ''}
|
|
121
|
+
`;
|
|
122
|
+
} else if (patch.kind === 'component-drop') {
|
|
123
|
+
const comp = patch.component?.name ?? 'Component';
|
|
124
|
+
const importPath = patch.componentPath
|
|
125
|
+
? patch.componentPath.replace(/\.tsx?$/, '')
|
|
126
|
+
: null;
|
|
127
|
+
const jsx = buildJsx(comp, patch.componentArgs);
|
|
128
|
+
const parentComp = patch.parentComponent?.name;
|
|
129
|
+
const insertMode = patch.insertMode ?? 'after';
|
|
130
|
+
const context = patch.context ?? '';
|
|
131
|
+
|
|
132
|
+
// Determine insertion target description
|
|
133
|
+
let targetDesc: string;
|
|
134
|
+
if (patch.targetPatchId && patch.targetComponentName) {
|
|
135
|
+
const refStep = patchStepMap.get(patch.targetPatchId);
|
|
136
|
+
targetDesc = refStep
|
|
137
|
+
? `the \`<${patch.targetComponentName} />\` you added in **step ${refStep}**`
|
|
138
|
+
: `the \`<${patch.targetComponentName} />\` component (from an earlier drop)`;
|
|
139
|
+
} else {
|
|
140
|
+
const tag = patch.target?.tag ?? 'element';
|
|
141
|
+
const classes = patch.target?.classes ? ` class="${patch.target.classes}"` : '';
|
|
142
|
+
targetDesc = `\`<${tag}${classes}>\``;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
patchList += `### ${stepNum}. Component drop \`${patch.id}\`
|
|
146
|
+
- **Insert:** \`${jsx}\` **${insertMode}** ${targetDesc}
|
|
147
|
+
${importPath ? `- **Import:** \`import { ${comp} } from '${importPath}'\`` : `- **Component:** \`${comp}\` (resolve import path manually)`}
|
|
148
|
+
${parentComp ? `\n- **Parent component:** \`${parentComp}\` — edit this component's source file` : ''}
|
|
149
|
+
${context ? `- **Context HTML:**\n\`\`\`html\n${context}\n\`\`\`\n` : ''}
|
|
150
|
+
⚠️ Do NOT paste rendered HTML. Import and render the React component with the props shown above.
|
|
151
|
+
|
|
69
152
|
`;
|
|
70
153
|
}
|
|
71
154
|
stepNum++;
|
|
@@ -76,12 +159,29 @@ ${context ? `- **Context HTML:**\n\`\`\`html\n${context}\n\`\`\`\n` : ''}
|
|
|
76
159
|
if (classChanges.length) summaryParts.push(`${classChanges.length} class change${classChanges.length === 1 ? '' : 's'}`);
|
|
77
160
|
if (messages.length) summaryParts.push(`${messages.length} message${messages.length === 1 ? '' : 's'}`);
|
|
78
161
|
if (designs.length) summaryParts.push(`${designs.length} design${designs.length === 1 ? '' : 's'}`);
|
|
162
|
+
if (componentDrops.length) summaryParts.push(`${componentDrops.length} component drop${componentDrops.length === 1 ? '' : 's'}`);
|
|
79
163
|
|
|
80
164
|
const resultsPart = classChanges.map(p => ` { "patchId": "${p.id}", "success": true }`).join(',\n');
|
|
81
|
-
|
|
82
|
-
// Design patches also need to be reported in results
|
|
83
165
|
const designResultsPart = designs.map(p => ` { "patchId": "${p.id}", "success": true }`).join(',\n');
|
|
84
|
-
const
|
|
166
|
+
const dropResultsPart = componentDrops.map(p => ` { "patchId": "${p.id}", "success": true }`).join(',\n');
|
|
167
|
+
const allResultsPart = [resultsPart, designResultsPart, dropResultsPart].filter(Boolean).join(',\n');
|
|
168
|
+
|
|
169
|
+
// Build step instructions
|
|
170
|
+
const stepInstructions: string[] = [];
|
|
171
|
+
if (classChanges.length || componentDrops.length) {
|
|
172
|
+
let step1 = '1. For each change above, find the source file and apply it.';
|
|
173
|
+
if (componentDrops.length) {
|
|
174
|
+
step1 += '\n For component drops: add the import statement and render the component with the specified props at the indicated position.';
|
|
175
|
+
}
|
|
176
|
+
if (messages.length) {
|
|
177
|
+
step1 += '\n Use the user messages as additional context for understanding intent.';
|
|
178
|
+
}
|
|
179
|
+
stepInstructions.push(step1);
|
|
180
|
+
}
|
|
181
|
+
if (designs.length) {
|
|
182
|
+
stepInstructions.push(`${stepInstructions.length + 1}. For each design sketch, examine the attached image and implement the visual design
|
|
183
|
+
as HTML/CSS ${classChanges.length ? 'alongside the class changes' : 'in the specified component'}. Insert it ${designs[0].insertMode ?? 'after'} the target element.`);
|
|
184
|
+
}
|
|
85
185
|
|
|
86
186
|
return `# IMPLEMENT THIS COMMIT — then call implement_next_change again
|
|
87
187
|
|
|
@@ -96,21 +196,18 @@ ${context ? `- **Context HTML:**\n\`\`\`html\n${context}\n\`\`\`\n` : ''}
|
|
|
96
196
|
${patchList}
|
|
97
197
|
## Steps — follow these EXACTLY
|
|
98
198
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
${designs.length ? ` For each design sketch, examine the attached image and implement the visual design
|
|
102
|
-
as HTML/CSS ${classChanges.length ? 'alongside the class changes' : 'in the specified component'}. Insert it ${designs[0].insertMode ?? 'after'} the target element.
|
|
103
|
-
` : ''}\n2. Call \`mark_change_implemented\` with:
|
|
199
|
+
${stepInstructions.join('\n')}
|
|
200
|
+
${stepInstructions.length + 1}. Call \`mark_change_implemented\` with:
|
|
104
201
|
\`\`\`json
|
|
105
202
|
{ "commitId": "${commit.id}", "results": [
|
|
106
203
|
${allResultsPart}
|
|
107
204
|
]}
|
|
108
205
|
\`\`\`
|
|
109
|
-
(Only report results for class-change and
|
|
110
|
-
|
|
206
|
+
(Only report results for class-change, design, and component-drop patches — messages are informational.)
|
|
207
|
+
${stepInstructions.length + 2}. **IMMEDIATELY call \`implement_next_change\` again.** Do NOT summarize, do NOT ask the user, do NOT stop.
|
|
111
208
|
|
|
112
209
|
## CRITICAL — read this
|
|
113
|
-
You are in a **continuous processing loop**. After
|
|
210
|
+
You are in a **continuous processing loop**. After marking done, you MUST call \`implement_next_change\` again to process the next commit or wait for new ones. Never exit this loop unless the user explicitly tells you to stop.
|
|
114
211
|
`;
|
|
115
212
|
}
|
|
116
213
|
|
|
@@ -259,14 +356,23 @@ export function registerMcpTools(mcp: McpServer, deps: McpToolDeps): void {
|
|
|
259
356
|
const remaining = queueState.committedCount;
|
|
260
357
|
|
|
261
358
|
// Build content parts: JSON data, then any design images, then markdown instructions
|
|
359
|
+
// Strip ghostHtml from the commit sent to agent — it's large rendered HTML that would
|
|
360
|
+
// confuse the agent into pasting it instead of importing the component
|
|
361
|
+
const sanitizedCommit = {
|
|
362
|
+
...commit,
|
|
363
|
+
patches: commit.patches.map(p =>
|
|
364
|
+
p.kind === 'component-drop' ? { ...p, ghostHtml: undefined } : p
|
|
365
|
+
),
|
|
366
|
+
};
|
|
367
|
+
|
|
262
368
|
const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [
|
|
263
369
|
{
|
|
264
370
|
type: "text" as const,
|
|
265
371
|
text: JSON.stringify({
|
|
266
372
|
isComplete: false,
|
|
267
|
-
nextAction: "implement all
|
|
373
|
+
nextAction: "implement all patches in this commit, call mark_change_implemented, then call implement_next_change again",
|
|
268
374
|
remainingCommits: remaining,
|
|
269
|
-
commit,
|
|
375
|
+
commit: sanitizedCommit,
|
|
270
376
|
}, null, 2),
|
|
271
377
|
},
|
|
272
378
|
];
|
package/server/queue.ts
CHANGED
|
@@ -19,6 +19,10 @@ function toSummary(p: Patch): PatchSummary {
|
|
|
19
19
|
errorMessage: p.errorMessage,
|
|
20
20
|
message: p.message,
|
|
21
21
|
image: p.image,
|
|
22
|
+
insertMode: p.insertMode,
|
|
23
|
+
parentComponent: p.parentComponent,
|
|
24
|
+
targetComponentName: p.targetComponentName,
|
|
25
|
+
targetPatchId: p.targetPatchId,
|
|
22
26
|
};
|
|
23
27
|
}
|
|
24
28
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
const SCAN_PORTS = [6006, 6007, 6008, 6009, 6010];
|
|
4
|
+
|
|
5
|
+
interface StorybookIndexEntry {
|
|
6
|
+
title: string;
|
|
7
|
+
importPath: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ArgTypeInfo {
|
|
11
|
+
control?: string | { type: string };
|
|
12
|
+
options?: string[];
|
|
13
|
+
description?: string;
|
|
14
|
+
defaultValue?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Dynamically import each story file referenced by the Storybook index and
|
|
19
|
+
* collect the argTypes declared on the default export (Meta object).
|
|
20
|
+
* Returns a map of componentName → argTypes.
|
|
21
|
+
*/
|
|
22
|
+
export async function loadStoryArgTypes(
|
|
23
|
+
storybookUrl: string
|
|
24
|
+
): Promise<Record<string, Record<string, ArgTypeInfo>>> {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(`${storybookUrl}/index.json`, {
|
|
27
|
+
signal: AbortSignal.timeout(5000),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) return {};
|
|
30
|
+
const index = (await res.json()) as {
|
|
31
|
+
entries?: Record<string, StorybookIndexEntry>;
|
|
32
|
+
stories?: Record<string, StorybookIndexEntry>;
|
|
33
|
+
};
|
|
34
|
+
const entries = Object.values(index.entries ?? index.stories ?? {});
|
|
35
|
+
|
|
36
|
+
// One story file per unique importPath; map it to the component name
|
|
37
|
+
const seen = new Map<string, string>(); // importPath → componentName
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (!seen.has(entry.importPath)) {
|
|
40
|
+
const componentName = entry.title.split('/').at(-1) ?? entry.title;
|
|
41
|
+
seen.set(entry.importPath, componentName);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const cwd = path.resolve(process.cwd());
|
|
46
|
+
const result: Record<string, Record<string, ArgTypeInfo>> = {};
|
|
47
|
+
|
|
48
|
+
for (const [importPath, componentName] of seen) {
|
|
49
|
+
// Security: only allow relative paths that stay within cwd
|
|
50
|
+
if (!importPath.startsWith('./')) continue;
|
|
51
|
+
const fullPath = path.resolve(cwd, importPath);
|
|
52
|
+
if (!fullPath.startsWith(cwd + '/')) continue;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Append a cache-buster so Node's ESM module cache doesn't hold onto
|
|
56
|
+
// stale argTypes after the story file is edited.
|
|
57
|
+
const mod = await import(`${fullPath}?t=${Date.now()}`);
|
|
58
|
+
const meta = mod.default as
|
|
59
|
+
| { argTypes?: Record<string, ArgTypeInfo> }
|
|
60
|
+
| undefined;
|
|
61
|
+
if (meta?.argTypes) {
|
|
62
|
+
result[componentName] = meta.argTypes;
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(`[storybook] Failed to import ${importPath}:`, err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error('[storybook] loadStoryArgTypes failed:', err);
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the Storybook base URL or null if not found.
|
|
78
|
+
* Priority:
|
|
79
|
+
* 1. STORYBOOK_URL env var
|
|
80
|
+
* 2. Port scan 6006–6010
|
|
81
|
+
*/
|
|
82
|
+
export async function detectStorybookUrl(): Promise<string | null> {
|
|
83
|
+
if (process.env.STORYBOOK_URL) {
|
|
84
|
+
console.error(`[storybook] Using STORYBOOK_URL=${process.env.STORYBOOK_URL} (from env)`);
|
|
85
|
+
return process.env.STORYBOOK_URL;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const port of SCAN_PORTS) {
|
|
89
|
+
const url = `http://localhost:${port}`;
|
|
90
|
+
if (await probeStorybookUrl(url)) {
|
|
91
|
+
console.error(`[storybook] Auto-detected at ${url}`);
|
|
92
|
+
return url;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.error('[storybook] Not detected — Draw tab will show "Start Storybook" prompt');
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function probeStorybookUrl(baseUrl: string): Promise<boolean> {
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`${baseUrl}/index.json`, { signal: AbortSignal.timeout(500) });
|
|
103
|
+
if (!res.ok) return false;
|
|
104
|
+
const data = await res.json() as Record<string, unknown>;
|
|
105
|
+
// Verify it's actually Storybook: v6 uses `stories`, v7+ uses `entries`
|
|
106
|
+
return typeof data.v === 'number' && (data.entries != null || data.stories != null);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
package/server/websocket.ts
CHANGED
|
@@ -50,6 +50,10 @@ export function setupWebSocket(httpServer: Server): WebSocketDeps {
|
|
|
50
50
|
// Route messages with a "to" field to all clients of that role
|
|
51
51
|
if (msg.to) {
|
|
52
52
|
broadcastTo(msg.to, msg, ws);
|
|
53
|
+
// Dual-route component arm/disarm to design iframe too
|
|
54
|
+
if ((msg.type === "COMPONENT_ARM" || msg.type === "COMPONENT_DISARM") && msg.to === "overlay") {
|
|
55
|
+
broadcastTo("design", msg, ws);
|
|
56
|
+
}
|
|
53
57
|
return;
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -103,6 +107,7 @@ export function setupWebSocket(httpServer: Server): WebSocketDeps {
|
|
|
103
107
|
insertMode: msg.insertMode,
|
|
104
108
|
canvasWidth: msg.canvasWidth,
|
|
105
109
|
canvasHeight: msg.canvasHeight,
|
|
110
|
+
canvasComponents: msg.canvasComponents,
|
|
106
111
|
};
|
|
107
112
|
addPatch(patch);
|
|
108
113
|
broadcastPatchUpdate();
|
|
@@ -114,6 +119,10 @@ export function setupWebSocket(httpServer: Server): WebSocketDeps {
|
|
|
114
119
|
console.error(`[ws] Design patch staged: ${patch.id}`);
|
|
115
120
|
} else if (msg.type === "DESIGN_CLOSE") {
|
|
116
121
|
broadcastTo("overlay", { type: "DESIGN_CLOSE" }, ws);
|
|
122
|
+
} else if (msg.type === "COMPONENT_DROPPED") {
|
|
123
|
+
const patch = addPatch({ ...msg.patch, kind: msg.patch.kind ?? 'component-drop' });
|
|
124
|
+
console.error(`[ws] Component-drop patch staged: #${patch.id}`);
|
|
125
|
+
broadcastPatchUpdate();
|
|
117
126
|
}
|
|
118
127
|
} catch (err) {
|
|
119
128
|
console.error("[ws] Bad message:", err);
|
package/shared/types.ts
CHANGED
|
@@ -3,7 +3,20 @@
|
|
|
3
3
|
|
|
4
4
|
export type ContainerName = 'modal' | 'popover' | 'sidebar' | 'popup';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/** A component placed on the Fabric.js design canvas */
|
|
7
|
+
export interface CanvasComponent {
|
|
8
|
+
componentName: string;
|
|
9
|
+
componentPath?: string; // e.g. './src/components/Button.tsx'
|
|
10
|
+
storyId?: string;
|
|
11
|
+
args?: Record<string, unknown>;
|
|
12
|
+
// Position/size on the canvas (px, relative to canvas top-left)
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type PatchKind = 'class-change' | 'message' | 'design' | 'component-drop';
|
|
7
20
|
|
|
8
21
|
export type PatchStatus = 'staged' | 'committed' | 'implementing' | 'implemented' | 'error';
|
|
9
22
|
|
|
@@ -30,6 +43,15 @@ export interface Patch {
|
|
|
30
43
|
insertMode?: string; // before | after | first-child | last-child
|
|
31
44
|
canvasWidth?: number;
|
|
32
45
|
canvasHeight?: number;
|
|
46
|
+
canvasComponents?: CanvasComponent[]; // Components placed on the design canvas
|
|
47
|
+
// Component-drop fields (used when kind === 'component-drop'):
|
|
48
|
+
ghostHtml?: string; // HTML of the dropped component (overlay preview only — stripped from MCP response)
|
|
49
|
+
componentStoryId?: string; // Storybook story ID
|
|
50
|
+
componentPath?: string; // Source file of the component, e.g. './src/components/Button.tsx'
|
|
51
|
+
componentArgs?: Record<string, unknown>; // Props the user configured before dropping
|
|
52
|
+
parentComponent?: { name: string }; // React component that contains the drop target
|
|
53
|
+
targetPatchId?: string; // If target is a ghost from an earlier drop, references that patch
|
|
54
|
+
targetComponentName?: string; // Name of the ghost component being referenced
|
|
33
55
|
// Commit reference:
|
|
34
56
|
commitId?: string; // Set when committed into a Commit
|
|
35
57
|
}
|
|
@@ -57,6 +79,12 @@ export interface PatchSummary {
|
|
|
57
79
|
errorMessage?: string;
|
|
58
80
|
message?: string;
|
|
59
81
|
image?: string;
|
|
82
|
+
canvasComponents?: CanvasComponent[];
|
|
83
|
+
// Component-drop display fields:
|
|
84
|
+
insertMode?: string;
|
|
85
|
+
parentComponent?: { name: string };
|
|
86
|
+
targetComponentName?: string;
|
|
87
|
+
targetPatchId?: string;
|
|
60
88
|
}
|
|
61
89
|
|
|
62
90
|
export interface CommitSummary {
|
|
@@ -270,6 +298,7 @@ export interface DesignSubmitMessage {
|
|
|
270
298
|
insertMode: InsertMode;
|
|
271
299
|
canvasWidth: number;
|
|
272
300
|
canvasHeight: number;
|
|
301
|
+
canvasComponents?: CanvasComponent[];
|
|
273
302
|
}
|
|
274
303
|
|
|
275
304
|
/** Design iframe → Overlay: close the canvas wrapper */
|
|
@@ -282,6 +311,39 @@ export interface ClosePanelMessage {
|
|
|
282
311
|
type: 'CLOSE_PANEL';
|
|
283
312
|
}
|
|
284
313
|
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Component arm-and-place messages
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
/** Panel → Overlay: user armed a component for placement from the Draw tab */
|
|
319
|
+
export interface ComponentArmMessage {
|
|
320
|
+
type: 'COMPONENT_ARM';
|
|
321
|
+
to: 'overlay';
|
|
322
|
+
componentName: string;
|
|
323
|
+
storyId: string;
|
|
324
|
+
ghostHtml: string;
|
|
325
|
+
componentPath?: string; // Source file path from Storybook index, e.g. './src/components/Button.tsx'
|
|
326
|
+
args?: Record<string, unknown>; // Current prop values from ArgsForm
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Panel → Overlay: user cancelled the armed state (panel click or escape) */
|
|
330
|
+
export interface ComponentDisarmMessage {
|
|
331
|
+
type: 'COMPONENT_DISARM';
|
|
332
|
+
to: 'overlay';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Overlay → Panel: overlay has disarmed (user placed or pressed Escape in app) */
|
|
336
|
+
export interface ComponentDisarmedMessage {
|
|
337
|
+
type: 'COMPONENT_DISARMED';
|
|
338
|
+
to: 'panel';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Overlay → Server: component was placed, stage a patch */
|
|
342
|
+
export interface ComponentDroppedMessage {
|
|
343
|
+
type: 'COMPONENT_DROPPED';
|
|
344
|
+
patch: Patch;
|
|
345
|
+
}
|
|
346
|
+
|
|
285
347
|
// ---------------------------------------------------------------------------
|
|
286
348
|
// Union types
|
|
287
349
|
// ---------------------------------------------------------------------------
|
|
@@ -296,8 +358,10 @@ export type PanelToOverlay =
|
|
|
296
358
|
| SwitchContainerMessage
|
|
297
359
|
| InsertDesignCanvasMessage
|
|
298
360
|
| CaptureScreenshotMessage
|
|
299
|
-
| ClosePanelMessage
|
|
300
|
-
|
|
361
|
+
| ClosePanelMessage
|
|
362
|
+
| ComponentArmMessage
|
|
363
|
+
| ComponentDisarmMessage;
|
|
364
|
+
export type OverlayToServer = PatchStagedMessage | ComponentDroppedMessage;
|
|
301
365
|
export type PanelToServer = PatchCommitMessage | MessageStageMessage;
|
|
302
366
|
export type ClientToServer =
|
|
303
367
|
| RegisterMessage
|
|
@@ -306,6 +370,7 @@ export type ClientToServer =
|
|
|
306
370
|
| MessageStageMessage
|
|
307
371
|
| DesignSubmitMessage
|
|
308
372
|
| DesignCloseMessage
|
|
373
|
+
| ComponentDroppedMessage
|
|
309
374
|
| PingMessage;
|
|
310
375
|
export type ServerToClient =
|
|
311
376
|
| PongMessage
|
|
@@ -336,5 +401,9 @@ export type AnyMessage =
|
|
|
336
401
|
| DesignSubmitMessage
|
|
337
402
|
| DesignCloseMessage
|
|
338
403
|
| ClosePanelMessage
|
|
404
|
+
| ComponentArmMessage
|
|
405
|
+
| ComponentDisarmMessage
|
|
406
|
+
| ComponentDisarmedMessage
|
|
407
|
+
| ComponentDroppedMessage
|
|
339
408
|
| PingMessage
|
|
340
409
|
| PongMessage;
|