@bitovi/vybit 0.8.2 → 0.9.1
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 +540 -211
- package/package.json +9 -2
- package/panel/dist/assets/DesignMode-D6tjMRjM.js +511 -0
- package/panel/dist/assets/index-BdUOBofL.css +1 -0
- package/panel/dist/assets/index-D5VCKvC6.js +62 -0
- package/panel/dist/index.html +2 -2
- package/server/app.ts +88 -1
- package/server/ghost-cache.ts +154 -0
- 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 +12 -0
- package/shared/types.ts +92 -3
- package/storybook-addon/index.js +1 -0
- package/storybook-addon/manager.tsx +33 -0
- package/storybook-addon/package.json +10 -0
- package/storybook-addon/preset.js +10 -0
- package/storybook-addon/preview.ts +29 -0
- 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-D5VCKvC6.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,17 +4,23 @@ 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";
|
|
12
|
+
import { loadCache, getAllCachedGhosts, setCachedGhost, invalidateAll as invalidateGhostCache } from "./ghost-cache.js";
|
|
10
13
|
import type { PatchStatus } from "../shared/types.js";
|
|
11
14
|
|
|
12
15
|
const VALID_STATUSES = new Set<string>(['staged', 'committed', 'implementing', 'implemented', 'error']);
|
|
13
16
|
|
|
14
|
-
export function createApp(packageRoot: string): express.Express {
|
|
17
|
+
export function createApp(packageRoot: string, storybookUrl: string | null = null): express.Express {
|
|
15
18
|
const app = express();
|
|
16
19
|
app.use(cors());
|
|
17
20
|
|
|
21
|
+
// Load ghost cache from disk on startup
|
|
22
|
+
loadCache();
|
|
23
|
+
|
|
18
24
|
app.get("/overlay.js", (_req, res) => {
|
|
19
25
|
const overlayPath = path.join(packageRoot, "overlay", "dist", "overlay.js");
|
|
20
26
|
res.sendFile(overlayPath, (err) => {
|
|
@@ -82,6 +88,87 @@ export function createApp(packageRoot: string): express.Express {
|
|
|
82
88
|
res.json({ ok: true });
|
|
83
89
|
});
|
|
84
90
|
|
|
91
|
+
// --- Ghost cache REST endpoints ---
|
|
92
|
+
app.get('/api/ghost-cache', (_req, res) => {
|
|
93
|
+
res.json(getAllCachedGhosts());
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
app.post('/api/ghost-cache', express.json({ limit: '1mb' }), (req, res) => {
|
|
97
|
+
const { storyId, args, ghostHtml, hostStyles, storyBackground, componentName, componentPath } = req.body;
|
|
98
|
+
if (!storyId || typeof ghostHtml !== 'string') {
|
|
99
|
+
res.status(400).json({ error: 'storyId and ghostHtml are required' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setCachedGhost({ storyId, args, ghostHtml, hostStyles: hostStyles ?? {}, storyBackground, componentName: componentName ?? '', componentPath });
|
|
103
|
+
res.json({ ok: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
app.delete('/api/ghost-cache', (_req, res) => {
|
|
107
|
+
invalidateGhostCache();
|
|
108
|
+
res.json({ ok: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// --- Storybook proxy ---
|
|
112
|
+
if (storybookUrl) {
|
|
113
|
+
// Vite's dev server serves assets at many root-absolute path prefixes:
|
|
114
|
+
// /@vite/client, /@react-refresh, /@id/..., /node_modules/.cache/..., /src/...
|
|
115
|
+
// Rather than enumerate them all, proxy everything that isn't ours.
|
|
116
|
+
const OWN_PATHS = new Set(['/panel', '/overlay.js', '/api', '/patches', '/css', '/tailwind-config']);
|
|
117
|
+
const isOwnPath = (p: string) =>
|
|
118
|
+
OWN_PATHS.has(p) ||
|
|
119
|
+
[...OWN_PATHS].some(own => p.startsWith(own + '/')) ||
|
|
120
|
+
p === '/';
|
|
121
|
+
|
|
122
|
+
app.use('/storybook', createProxyMiddleware({
|
|
123
|
+
target: storybookUrl,
|
|
124
|
+
changeOrigin: true,
|
|
125
|
+
pathRewrite: { '^/storybook': '' },
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
app.use(createProxyMiddleware({
|
|
129
|
+
target: storybookUrl,
|
|
130
|
+
changeOrigin: true,
|
|
131
|
+
pathFilter: (pathname) => !isOwnPath(pathname),
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
console.error(`[storybook] Proxying /storybook + Vite asset paths → ${storybookUrl}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
app.get('/api/storybook-status', (_req, res) => {
|
|
138
|
+
res.json({ url: storybookUrl ? '/storybook' : null, directUrl: storybookUrl ?? null });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
app.get('/api/storybook-argtypes', async (_req, res) => {
|
|
142
|
+
if (!storybookUrl) { res.json({}); return; }
|
|
143
|
+
try {
|
|
144
|
+
const argTypes = await loadStoryArgTypes(storybookUrl);
|
|
145
|
+
res.json(argTypes);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error('[storybook] /api/storybook-argtypes failed:', err);
|
|
148
|
+
res.json({});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Single endpoint: everything the Draw tab panel needs in one request.
|
|
153
|
+
app.get('/api/storybook-data', async (_req, res) => {
|
|
154
|
+
if (!storybookUrl) {
|
|
155
|
+
res.json({ available: false });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const [indexRes, argTypes] = await Promise.all([
|
|
160
|
+
fetch(`${storybookUrl}/index.json`, { signal: AbortSignal.timeout(5000) }),
|
|
161
|
+
loadStoryArgTypes(storybookUrl),
|
|
162
|
+
]);
|
|
163
|
+
if (!indexRes.ok) { res.json({ available: false }); return; }
|
|
164
|
+
const index = await indexRes.json();
|
|
165
|
+
res.json({ available: true, directUrl: storybookUrl, index, argTypes });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('[storybook] /api/storybook-data failed:', err);
|
|
168
|
+
res.json({ available: false });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
85
172
|
// --- Serve Panel app ---
|
|
86
173
|
if (process.env.PANEL_DEV) {
|
|
87
174
|
const panelDevPort = Number(process.env.PANEL_DEV_PORT) || 5174;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
|
|
5
|
+
export interface GhostCacheEntry {
|
|
6
|
+
storyId: string;
|
|
7
|
+
argsHash: string;
|
|
8
|
+
ghostHtml: string;
|
|
9
|
+
hostStyles: Record<string, string>;
|
|
10
|
+
storyBackground?: string;
|
|
11
|
+
componentName: string;
|
|
12
|
+
componentPath?: string;
|
|
13
|
+
extractedAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CacheFile {
|
|
17
|
+
entries: GhostCacheEntry[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_ENTRIES = 200;
|
|
21
|
+
const MAX_TOTAL_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
22
|
+
|
|
23
|
+
const cache = new Map<string, GhostCacheEntry>();
|
|
24
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
let cacheDir: string | null = null;
|
|
26
|
+
let cacheFilePath: string | null = null;
|
|
27
|
+
|
|
28
|
+
function getCacheDir(): string {
|
|
29
|
+
if (!cacheDir) {
|
|
30
|
+
cacheDir = path.join(process.cwd(), "node_modules", ".cache", "vybit");
|
|
31
|
+
}
|
|
32
|
+
return cacheDir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getCacheFilePath(): string {
|
|
36
|
+
if (!cacheFilePath) {
|
|
37
|
+
cacheFilePath = path.join(getCacheDir(), "ghost-cache.json");
|
|
38
|
+
}
|
|
39
|
+
return cacheFilePath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function computeArgsHash(args?: Record<string, unknown>): string {
|
|
43
|
+
if (!args || Object.keys(args).length === 0) return "";
|
|
44
|
+
const sorted = JSON.stringify(args, Object.keys(args).sort());
|
|
45
|
+
return crypto.createHash("sha1").update(sorted).digest("hex").slice(0, 12);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cacheKey(storyId: string, argsHash: string): string {
|
|
49
|
+
return argsHash ? `${storyId}::${argsHash}` : storyId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Load the cache from disk into memory. Safe to call multiple times. */
|
|
53
|
+
export function loadCache(): void {
|
|
54
|
+
cache.clear();
|
|
55
|
+
const filePath = getCacheFilePath();
|
|
56
|
+
try {
|
|
57
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
58
|
+
const data: CacheFile = JSON.parse(raw);
|
|
59
|
+
if (Array.isArray(data.entries)) {
|
|
60
|
+
for (const entry of data.entries) {
|
|
61
|
+
cache.set(cacheKey(entry.storyId, entry.argsHash), entry);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// File doesn't exist or is corrupt — start fresh
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Write the cache to disk. Creates the directory if needed. */
|
|
70
|
+
function flushToDisk(): void {
|
|
71
|
+
const filePath = getCacheFilePath();
|
|
72
|
+
const dir = getCacheDir();
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
const data: CacheFile = { entries: Array.from(cache.values()) };
|
|
76
|
+
fs.writeFileSync(filePath, JSON.stringify(data), "utf-8");
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error("[ghost-cache] Failed to write cache:", err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scheduleFlush(): void {
|
|
83
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
84
|
+
flushTimer = setTimeout(() => {
|
|
85
|
+
flushTimer = null;
|
|
86
|
+
flushToDisk();
|
|
87
|
+
}, 500);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Evict oldest entries until we're under the size and count limits. */
|
|
91
|
+
function evictIfNeeded(): void {
|
|
92
|
+
// Evict by count
|
|
93
|
+
while (cache.size > MAX_ENTRIES) {
|
|
94
|
+
let oldestKey: string | null = null;
|
|
95
|
+
let oldestTime = Infinity;
|
|
96
|
+
for (const [key, entry] of cache) {
|
|
97
|
+
if (entry.extractedAt < oldestTime) {
|
|
98
|
+
oldestTime = entry.extractedAt;
|
|
99
|
+
oldestKey = key;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (oldestKey) cache.delete(oldestKey);
|
|
103
|
+
else break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Evict by total size
|
|
107
|
+
let totalBytes = () => {
|
|
108
|
+
let sum = 0;
|
|
109
|
+
for (const entry of cache.values()) sum += entry.ghostHtml.length;
|
|
110
|
+
return sum;
|
|
111
|
+
};
|
|
112
|
+
while (totalBytes() > MAX_TOTAL_BYTES && cache.size > 0) {
|
|
113
|
+
let oldestKey: string | null = null;
|
|
114
|
+
let oldestTime = Infinity;
|
|
115
|
+
for (const [key, entry] of cache) {
|
|
116
|
+
if (entry.extractedAt < oldestTime) {
|
|
117
|
+
oldestTime = entry.extractedAt;
|
|
118
|
+
oldestKey = key;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (oldestKey) cache.delete(oldestKey);
|
|
122
|
+
else break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getCachedGhost(storyId: string, argsHash: string): GhostCacheEntry | null {
|
|
127
|
+
return cache.get(cacheKey(storyId, argsHash)) ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function setCachedGhost(entry: Omit<GhostCacheEntry, "argsHash" | "extractedAt"> & { args?: Record<string, unknown> }): void {
|
|
131
|
+
const argsHash = computeArgsHash(entry.args);
|
|
132
|
+
const full: GhostCacheEntry = {
|
|
133
|
+
storyId: entry.storyId,
|
|
134
|
+
argsHash,
|
|
135
|
+
ghostHtml: entry.ghostHtml,
|
|
136
|
+
hostStyles: entry.hostStyles,
|
|
137
|
+
storyBackground: entry.storyBackground,
|
|
138
|
+
componentName: entry.componentName,
|
|
139
|
+
componentPath: entry.componentPath,
|
|
140
|
+
extractedAt: Date.now(),
|
|
141
|
+
};
|
|
142
|
+
cache.set(cacheKey(full.storyId, argsHash), full);
|
|
143
|
+
evictIfNeeded();
|
|
144
|
+
scheduleFlush();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getAllCachedGhosts(): GhostCacheEntry[] {
|
|
148
|
+
return Array.from(cache.values());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function invalidateAll(): void {
|
|
152
|
+
cache.clear();
|
|
153
|
+
scheduleFlush();
|
|
154
|
+
}
|
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,13 @@ 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 === "RESET_SELECTION") {
|
|
123
|
+
broadcastTo("panel", { type: "RESET_SELECTION" }, ws);
|
|
124
|
+
console.error(`[ws] Reset selection broadcast to panels`);
|
|
125
|
+
} else if (msg.type === "COMPONENT_DROPPED") {
|
|
126
|
+
const patch = addPatch({ ...msg.patch, kind: msg.patch.kind ?? 'component-drop' });
|
|
127
|
+
console.error(`[ws] Component-drop patch staged: #${patch.id}`);
|
|
128
|
+
broadcastPatchUpdate();
|
|
117
129
|
}
|
|
118
130
|
} catch (err) {
|
|
119
131
|
console.error("[ws] Bad message:", err);
|