minimal-workflow 0.2.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/index.html +12 -0
- package/package.json +44 -0
- package/public/manifest.fallback.json +482 -0
- package/scripts/cli.ts +318 -0
- package/scripts/server.ts +131 -0
- package/src/App.tsx +119 -0
- package/src/api/files.ts +32 -0
- package/src/api/manifest.ts +17 -0
- package/src/canvas/WorkflowCanvas.tsx +161 -0
- package/src/canvas/edges/SequentialEdge.tsx +14 -0
- package/src/canvas/nodes/AssertNode.tsx +17 -0
- package/src/canvas/nodes/BaseNode.tsx +55 -0
- package/src/canvas/nodes/BranchNode.tsx +21 -0
- package/src/canvas/nodes/LlmNode.tsx +29 -0
- package/src/canvas/nodes/LoopNode.tsx +22 -0
- package/src/canvas/nodes/PauseNode.tsx +17 -0
- package/src/canvas/nodes/SkillNode.tsx +27 -0
- package/src/canvas/nodes/ToolNode.tsx +27 -0
- package/src/index.css +379 -0
- package/src/inspector/Inspector.tsx +224 -0
- package/src/inspector/LlmNodeForm.tsx +59 -0
- package/src/inspector/SkillNodeForm.tsx +62 -0
- package/src/inspector/ToolNodeForm.tsx +120 -0
- package/src/main.tsx +10 -0
- package/src/palette/ControlFlowSection.tsx +45 -0
- package/src/palette/Palette.tsx +65 -0
- package/src/palette/SkillPaletteSection.tsx +31 -0
- package/src/palette/ToolPaletteSection.tsx +31 -0
- package/src/store/filesStore.ts +29 -0
- package/src/store/manifestStore.ts +25 -0
- package/src/store/workflowStore.ts +140 -0
- package/src/types.ts +83 -0
- package/src/utils/safePath.ts +49 -0
- package/src/yaml-view/YamlEditor.tsx +71 -0
- package/src/yaml-view/sync.ts +246 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +21 -0
package/scripts/cli.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* workflow-ui CLI 入口
|
|
4
|
+
*
|
|
5
|
+
* 用法: workflow-ui [project-dir] [options]
|
|
6
|
+
*
|
|
7
|
+
* 负责:参数解析、manifest 新鲜度检查、spawn IO 服务 + Vite dev server。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
11
|
+
import { existsSync, readFileSync, statSync, readdirSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
// editorRoot:cli.ts 位于 editor/scripts/,所以上两级
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = path.dirname(__filename);
|
|
18
|
+
const editorRoot = path.resolve(__dirname, '..');
|
|
19
|
+
|
|
20
|
+
interface CliOptions {
|
|
21
|
+
projectDir: string;
|
|
22
|
+
port: number;
|
|
23
|
+
serverPort: number;
|
|
24
|
+
refreshManifest: boolean;
|
|
25
|
+
noManifestRefresh: boolean;
|
|
26
|
+
noOpen: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function printHelp(): void {
|
|
30
|
+
console.log(`USAGE
|
|
31
|
+
workflow-ui [project-dir] [options]
|
|
32
|
+
|
|
33
|
+
ARGUMENTS
|
|
34
|
+
project-dir Path to a minimal-agent project (must contain workflows/).
|
|
35
|
+
Defaults to current working directory.
|
|
36
|
+
|
|
37
|
+
OPTIONS
|
|
38
|
+
--port <n> Editor (Vite) port. Default: 5173
|
|
39
|
+
--server-port <n> File IO server port. Default: 5174
|
|
40
|
+
--refresh-manifest Force regenerate <project>/.editor-cache/manifest.json
|
|
41
|
+
--no-manifest-refresh Skip manifest freshness check
|
|
42
|
+
--no-open Do not auto-open browser
|
|
43
|
+
-h, --help Show this help and exit
|
|
44
|
+
-v, --version Print version and exit
|
|
45
|
+
|
|
46
|
+
EXAMPLES
|
|
47
|
+
workflow-ui # use cwd as project
|
|
48
|
+
workflow-ui ./my-project # use ./my-project
|
|
49
|
+
workflow-ui . --port 5180 # custom editor port
|
|
50
|
+
workflow-ui . --refresh-manifest # force manifest rebuild
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function printVersion(): void {
|
|
55
|
+
try {
|
|
56
|
+
const pkgPath = path.join(editorRoot, 'package.json');
|
|
57
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string };
|
|
58
|
+
console.log(pkg.version ?? '0.0.0');
|
|
59
|
+
} catch {
|
|
60
|
+
console.log('0.0.0');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseArgs(argv: string[]): CliOptions {
|
|
65
|
+
const opts: CliOptions = {
|
|
66
|
+
projectDir: process.cwd(),
|
|
67
|
+
port: 5173,
|
|
68
|
+
serverPort: 5174,
|
|
69
|
+
refreshManifest: false,
|
|
70
|
+
noManifestRefresh: false,
|
|
71
|
+
noOpen: false,
|
|
72
|
+
};
|
|
73
|
+
let projectSeen = false;
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < argv.length; i++) {
|
|
76
|
+
const a = argv[i];
|
|
77
|
+
switch (a) {
|
|
78
|
+
case '-h':
|
|
79
|
+
case '--help':
|
|
80
|
+
printHelp();
|
|
81
|
+
process.exit(0);
|
|
82
|
+
// biome-ignore lint/correctness/noFallthroughSwitchClause: exit above
|
|
83
|
+
case '-v':
|
|
84
|
+
case '--version':
|
|
85
|
+
printVersion();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
// biome-ignore lint/correctness/noFallthroughSwitchClause: exit above
|
|
88
|
+
case '--port': {
|
|
89
|
+
const next = argv[++i];
|
|
90
|
+
if (!next) {
|
|
91
|
+
console.error('✗ --port requires a value');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const n = Number(next);
|
|
95
|
+
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
96
|
+
console.error(`✗ invalid --port: ${next}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
opts.port = n;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case '--server-port': {
|
|
103
|
+
const next = argv[++i];
|
|
104
|
+
if (!next) {
|
|
105
|
+
console.error('✗ --server-port requires a value');
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
const n = Number(next);
|
|
109
|
+
if (!Number.isFinite(n) || n <= 0 || n > 65535) {
|
|
110
|
+
console.error(`✗ invalid --server-port: ${next}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
opts.serverPort = n;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case '--refresh-manifest':
|
|
117
|
+
opts.refreshManifest = true;
|
|
118
|
+
break;
|
|
119
|
+
case '--no-manifest-refresh':
|
|
120
|
+
opts.noManifestRefresh = true;
|
|
121
|
+
break;
|
|
122
|
+
case '--no-open':
|
|
123
|
+
opts.noOpen = true;
|
|
124
|
+
break;
|
|
125
|
+
default:
|
|
126
|
+
if (a.startsWith('-')) {
|
|
127
|
+
console.error(`✗ unknown option: ${a}`);
|
|
128
|
+
printHelp();
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
if (!projectSeen) {
|
|
132
|
+
opts.projectDir = a;
|
|
133
|
+
projectSeen = true;
|
|
134
|
+
} else {
|
|
135
|
+
console.error(`✗ unexpected positional argument: ${a}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return opts;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 递归收集目录下所有文件的最新 mtime(毫秒);目录不存在返回 0。 */
|
|
144
|
+
function latestMtime(dir: string): number {
|
|
145
|
+
if (!existsSync(dir)) return 0;
|
|
146
|
+
let latest = 0;
|
|
147
|
+
const walk = (p: string): void => {
|
|
148
|
+
let entries: import('node:fs').Dirent[];
|
|
149
|
+
try {
|
|
150
|
+
entries = readdirSync(p, { withFileTypes: true });
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const e of entries) {
|
|
155
|
+
const full = path.join(p, e.name);
|
|
156
|
+
if (e.isDirectory()) {
|
|
157
|
+
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
158
|
+
walk(full);
|
|
159
|
+
} else if (e.isFile()) {
|
|
160
|
+
try {
|
|
161
|
+
const m = statSync(full).mtimeMs;
|
|
162
|
+
if (m > latest) latest = m;
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
walk(dir);
|
|
168
|
+
return latest;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isManifestStale(projectDirAbs: string, manifestPath: string): boolean {
|
|
172
|
+
if (!existsSync(manifestPath)) return true;
|
|
173
|
+
let manifestMtime: number;
|
|
174
|
+
try {
|
|
175
|
+
manifestMtime = statSync(manifestPath).mtimeMs;
|
|
176
|
+
} catch {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
const toolsMtime = latestMtime(path.join(projectDirAbs, 'src', 'tools'));
|
|
180
|
+
const skillsMtime = latestMtime(path.join(projectDirAbs, 'skills'));
|
|
181
|
+
return toolsMtime > manifestMtime || skillsMtime > manifestMtime;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function maybeRegenerateManifest(projectDirAbs: string, opts: CliOptions): Promise<void> {
|
|
185
|
+
if (opts.noManifestRefresh) return;
|
|
186
|
+
|
|
187
|
+
const manifestPath = path.join(projectDirAbs, '.editor-cache', 'manifest.json');
|
|
188
|
+
const exporterPath = path.join(projectDirAbs, 'scripts', 'export-manifest.ts');
|
|
189
|
+
const needRebuild =
|
|
190
|
+
opts.refreshManifest || isManifestStale(projectDirAbs, manifestPath);
|
|
191
|
+
if (!needRebuild) return;
|
|
192
|
+
|
|
193
|
+
if (!existsSync(exporterPath)) {
|
|
194
|
+
console.warn('⚠ Could not refresh manifest; editor will use fallback snapshot');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await new Promise<void>((resolve) => {
|
|
199
|
+
const child = spawn('bun', [exporterPath], {
|
|
200
|
+
cwd: projectDirAbs,
|
|
201
|
+
stdio: 'inherit',
|
|
202
|
+
env: process.env,
|
|
203
|
+
shell: process.platform === 'win32',
|
|
204
|
+
});
|
|
205
|
+
child.on('exit', (code) => {
|
|
206
|
+
if (code === 0) {
|
|
207
|
+
console.log('✓ Regenerated manifest');
|
|
208
|
+
} else {
|
|
209
|
+
console.warn('⚠ Could not refresh manifest; editor will use fallback snapshot');
|
|
210
|
+
}
|
|
211
|
+
resolve();
|
|
212
|
+
});
|
|
213
|
+
child.on('error', () => {
|
|
214
|
+
console.warn('⚠ Could not refresh manifest; editor will use fallback snapshot');
|
|
215
|
+
resolve();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main(): Promise<void> {
|
|
221
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
222
|
+
const projectDirAbs = path.resolve(opts.projectDir);
|
|
223
|
+
|
|
224
|
+
if (!existsSync(projectDirAbs)) {
|
|
225
|
+
console.error(`✗ project dir does not exist: ${projectDirAbs}`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
const workflowsDir = path.join(projectDirAbs, 'workflows');
|
|
229
|
+
if (!existsSync(workflowsDir)) {
|
|
230
|
+
console.error(`✗ workflows/ subdir not found in ${projectDirAbs}`);
|
|
231
|
+
console.error(' (workflow-ui needs a minimal-agent project with workflows/*.yaml)');
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await maybeRegenerateManifest(projectDirAbs, opts);
|
|
236
|
+
|
|
237
|
+
// 注入环境变量,server.ts 启动时读取
|
|
238
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
239
|
+
...process.env,
|
|
240
|
+
WORKFLOW_UI_PROJECT_DIR: projectDirAbs,
|
|
241
|
+
WORKFLOW_UI_SERVER_PORT: String(opts.serverPort),
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const serverScript = path.join(editorRoot, 'scripts', 'server.ts');
|
|
245
|
+
|
|
246
|
+
// spawn IO 服务: bun <editorRoot>/scripts/server.ts
|
|
247
|
+
const server: ChildProcess = spawn('bun', [serverScript], {
|
|
248
|
+
cwd: editorRoot,
|
|
249
|
+
stdio: 'inherit',
|
|
250
|
+
env: childEnv,
|
|
251
|
+
shell: process.platform === 'win32',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// spawn Vite: bunx --bun vite --port <port>
|
|
255
|
+
const vite: ChildProcess = spawn('bunx', ['--bun', 'vite', '--port', String(opts.port)], {
|
|
256
|
+
cwd: editorRoot,
|
|
257
|
+
stdio: 'inherit',
|
|
258
|
+
env: childEnv,
|
|
259
|
+
shell: process.platform === 'win32',
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const editorUrl = `http://127.0.0.1:${opts.port}`;
|
|
263
|
+
console.log(`> workflow-ui ready at ${editorUrl}`);
|
|
264
|
+
|
|
265
|
+
if (!opts.noOpen) {
|
|
266
|
+
setTimeout(() => openBrowser(editorUrl), 1500);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let shuttingDown = false;
|
|
270
|
+
const shutdown = (signal: NodeJS.Signals): void => {
|
|
271
|
+
if (shuttingDown) return;
|
|
272
|
+
shuttingDown = true;
|
|
273
|
+
console.log(`\n> received ${signal}, shutting down...`);
|
|
274
|
+
try {
|
|
275
|
+
server.kill(signal);
|
|
276
|
+
} catch {}
|
|
277
|
+
try {
|
|
278
|
+
vite.kill(signal);
|
|
279
|
+
} catch {}
|
|
280
|
+
setTimeout(() => process.exit(0), 500);
|
|
281
|
+
};
|
|
282
|
+
process.on('SIGINT', shutdown);
|
|
283
|
+
process.on('SIGTERM', shutdown);
|
|
284
|
+
|
|
285
|
+
const onChildExit = (name: string) => (code: number | null) => {
|
|
286
|
+
if (shuttingDown) return;
|
|
287
|
+
console.error(`✗ ${name} exited with code ${code}`);
|
|
288
|
+
shutdown('SIGTERM');
|
|
289
|
+
};
|
|
290
|
+
server.on('exit', onChildExit('server'));
|
|
291
|
+
vite.on('exit', onChildExit('vite'));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function openBrowser(url: string): void {
|
|
295
|
+
const platform = process.platform;
|
|
296
|
+
let cmd: string;
|
|
297
|
+
let args: string[];
|
|
298
|
+
if (platform === 'win32') {
|
|
299
|
+
cmd = 'cmd';
|
|
300
|
+
args = ['/c', 'start', '""', url];
|
|
301
|
+
} else if (platform === 'darwin') {
|
|
302
|
+
cmd = 'open';
|
|
303
|
+
args = [url];
|
|
304
|
+
} else {
|
|
305
|
+
cmd = 'xdg-open';
|
|
306
|
+
args = [url];
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
310
|
+
} catch {
|
|
311
|
+
// 静默 —— 用户可以手动点 URL
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
main().catch((e) => {
|
|
316
|
+
console.error('✗ workflow-ui failed:', e);
|
|
317
|
+
process.exit(1);
|
|
318
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Workflow Editor IO Server
|
|
4
|
+
*
|
|
5
|
+
* 本地 HTTP 服务,监听 127.0.0.1:5174,为前端编辑器提供 4 个文件 IO 端点。
|
|
6
|
+
* 仅用于 workflow-ui 编辑器本地开发,绝不暴露外网。
|
|
7
|
+
*
|
|
8
|
+
* 项目目录通过环境变量 WORKFLOW_UI_PROJECT_DIR 注入(由 cli.ts 设置)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { safeResolveWorkflowFile } from '../src/utils/safePath.ts';
|
|
16
|
+
|
|
17
|
+
const CORS_HEADERS: Record<string, string> = {
|
|
18
|
+
'Access-Control-Allow-Origin': '*',
|
|
19
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
20
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const JSON_HEADERS: Record<string, string> = {
|
|
24
|
+
...CORS_HEADERS,
|
|
25
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
29
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function errorResponse(message: string, status: number): Response {
|
|
33
|
+
return jsonResponse({ error: message }, status);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ----- 启动校验 -----
|
|
37
|
+
|
|
38
|
+
const projectDir = process.env.WORKFLOW_UI_PROJECT_DIR;
|
|
39
|
+
if (!projectDir) {
|
|
40
|
+
console.error('✗ WORKFLOW_UI_PROJECT_DIR env var is required');
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const projectDirAbs = path.resolve(projectDir);
|
|
44
|
+
if (!existsSync(projectDirAbs)) {
|
|
45
|
+
console.error(`✗ project dir does not exist: ${projectDirAbs}`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const workflowsDir = path.resolve(projectDirAbs, 'workflows');
|
|
49
|
+
if (!existsSync(workflowsDir)) {
|
|
50
|
+
console.error(`✗ workflows/ subdir not found in ${projectDirAbs}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const manifestPath = path.resolve(projectDirAbs, '.editor-cache', 'manifest.json');
|
|
54
|
+
|
|
55
|
+
const PORT = Number(process.env.WORKFLOW_UI_SERVER_PORT ?? 5174);
|
|
56
|
+
|
|
57
|
+
// ----- 路由 -----
|
|
58
|
+
|
|
59
|
+
async function handleList(): Promise<Response> {
|
|
60
|
+
try {
|
|
61
|
+
const entries = await readdir(workflowsDir, { withFileTypes: true });
|
|
62
|
+
const files = entries
|
|
63
|
+
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.yaml'))
|
|
64
|
+
.map((e) => ({ name: e.name }));
|
|
65
|
+
return jsonResponse(files);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return errorResponse(`list failed: ${(e as Error).message}`, 500);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function handleRead(url: URL): Promise<Response> {
|
|
72
|
+
const file = url.searchParams.get('file');
|
|
73
|
+
const check = safeResolveWorkflowFile(workflowsDir, file);
|
|
74
|
+
if (!check.ok) return errorResponse(check.reason, 400);
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(check.abs)) return errorResponse('file not found', 404);
|
|
77
|
+
const content = await readFile(check.abs, 'utf8');
|
|
78
|
+
return jsonResponse({ content });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return errorResponse(`read failed: ${(e as Error).message}`, 500);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function handleWrite(req: Request): Promise<Response> {
|
|
85
|
+
let body: unknown;
|
|
86
|
+
try {
|
|
87
|
+
body = await req.json();
|
|
88
|
+
} catch {
|
|
89
|
+
return errorResponse('invalid JSON body', 400);
|
|
90
|
+
}
|
|
91
|
+
if (!body || typeof body !== 'object') return errorResponse('body must be an object', 400);
|
|
92
|
+
const { file, content } = body as { file?: unknown; content?: unknown };
|
|
93
|
+
const check = safeResolveWorkflowFile(workflowsDir, file);
|
|
94
|
+
if (!check.ok) return errorResponse(check.reason, 400);
|
|
95
|
+
if (typeof content !== 'string') return errorResponse('content must be a string', 400);
|
|
96
|
+
try {
|
|
97
|
+
await mkdir(workflowsDir, { recursive: true });
|
|
98
|
+
await writeFile(check.abs, content, 'utf8');
|
|
99
|
+
return jsonResponse({ ok: true });
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return errorResponse(`write failed: ${(e as Error).message}`, 500);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function handleManifest(): Promise<Response> {
|
|
106
|
+
try {
|
|
107
|
+
if (!existsSync(manifestPath)) return errorResponse('manifest not found', 404);
|
|
108
|
+
const content = await readFile(manifestPath, 'utf8');
|
|
109
|
+
return new Response(content, { status: 200, headers: JSON_HEADERS });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return errorResponse(`manifest read failed: ${(e as Error).message}`, 500);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
Bun.serve({
|
|
116
|
+
hostname: '127.0.0.1',
|
|
117
|
+
port: PORT,
|
|
118
|
+
async fetch(req: Request): Promise<Response> {
|
|
119
|
+
const url = new URL(req.url);
|
|
120
|
+
if (req.method === 'OPTIONS') {
|
|
121
|
+
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
|
122
|
+
}
|
|
123
|
+
if (req.method === 'GET' && url.pathname === '/api/list') return handleList();
|
|
124
|
+
if (req.method === 'GET' && url.pathname === '/api/read') return handleRead(url);
|
|
125
|
+
if (req.method === 'POST' && url.pathname === '/api/write') return handleWrite(req);
|
|
126
|
+
if (req.method === 'GET' && url.pathname === '/api/manifest') return handleManifest();
|
|
127
|
+
return errorResponse('not found', 404);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
console.log(`✓ File IO server listening on http://127.0.0.1:${PORT} (project: ${projectDirAbs})`);
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Palette } from './palette/Palette.tsx';
|
|
3
|
+
import { WorkflowCanvas } from './canvas/WorkflowCanvas.tsx';
|
|
4
|
+
import { Inspector } from './inspector/Inspector.tsx';
|
|
5
|
+
import { YamlEditor } from './yaml-view/YamlEditor.tsx';
|
|
6
|
+
import { useManifestStore } from './store/manifestStore.ts';
|
|
7
|
+
import { useWorkflowStore } from './store/workflowStore.ts';
|
|
8
|
+
import { useFilesStore } from './store/filesStore.ts';
|
|
9
|
+
import { stringifyWorkflowYaml, validateWorkflow } from './yaml-view/sync.ts';
|
|
10
|
+
|
|
11
|
+
export default function App() {
|
|
12
|
+
const loadManifest = useManifestStore((s) => s.load);
|
|
13
|
+
const manifestError = useManifestStore((s) => s.error);
|
|
14
|
+
const manifestLoading = useManifestStore((s) => s.loading);
|
|
15
|
+
|
|
16
|
+
const currentFile = useWorkflowStore((s) => s.currentFile);
|
|
17
|
+
const dirty = useWorkflowStore((s) => s.dirty);
|
|
18
|
+
const meta = useWorkflowStore((s) => s.meta);
|
|
19
|
+
const loadWorkflow = useWorkflowStore((s) => s.loadWorkflow);
|
|
20
|
+
const setCurrentFile = useWorkflowStore((s) => s.setCurrentFile);
|
|
21
|
+
const toWorkflowDef = useWorkflowStore((s) => s.toWorkflowDef);
|
|
22
|
+
const markDirty = useWorkflowStore((s) => s.markDirty);
|
|
23
|
+
|
|
24
|
+
const saveFile = useFilesStore((s) => s.save);
|
|
25
|
+
const refreshFiles = useFilesStore((s) => s.refresh);
|
|
26
|
+
|
|
27
|
+
const [yamlOpen, setYamlOpen] = useState(false);
|
|
28
|
+
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
loadManifest().catch(() => {});
|
|
32
|
+
}, [loadManifest]);
|
|
33
|
+
|
|
34
|
+
const onNewWorkflow = () => {
|
|
35
|
+
if (dirty && !confirm('当前修改未保存,确定要新建?')) return;
|
|
36
|
+
loadWorkflow({
|
|
37
|
+
name: 'untitled',
|
|
38
|
+
description: '',
|
|
39
|
+
version: '0.1',
|
|
40
|
+
inputs: [],
|
|
41
|
+
steps: [],
|
|
42
|
+
});
|
|
43
|
+
setCurrentFile(null);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const onSave = async () => {
|
|
47
|
+
let targetFile = currentFile;
|
|
48
|
+
if (!targetFile) {
|
|
49
|
+
const input = prompt(
|
|
50
|
+
'保存为(文件名,自动追加 .yaml):',
|
|
51
|
+
meta.name || 'untitled',
|
|
52
|
+
);
|
|
53
|
+
if (!input) return;
|
|
54
|
+
targetFile = input.endsWith('.yaml') ? input : `${input}.yaml`;
|
|
55
|
+
}
|
|
56
|
+
const def = toWorkflowDef();
|
|
57
|
+
const errs = validateWorkflow(def);
|
|
58
|
+
if (errs.length > 0) {
|
|
59
|
+
if (
|
|
60
|
+
!confirm(
|
|
61
|
+
`检测到 ${errs.length} 个问题,仍要保存吗?\n\n${errs.join('\n')}`,
|
|
62
|
+
)
|
|
63
|
+
) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const text = stringifyWorkflowYaml(def);
|
|
69
|
+
await saveFile(targetFile, text);
|
|
70
|
+
setCurrentFile(targetFile);
|
|
71
|
+
markDirty(false);
|
|
72
|
+
setSaveStatus(`✓ 已保存 ${targetFile}`);
|
|
73
|
+
setTimeout(() => setSaveStatus(null), 2500);
|
|
74
|
+
refreshFiles().catch(() => {});
|
|
75
|
+
} catch (e) {
|
|
76
|
+
setSaveStatus(`✗ 保存失败: ${(e as Error).message}`);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="app-shell">
|
|
82
|
+
<div className="toolbar">
|
|
83
|
+
<strong>workflow-ui</strong>
|
|
84
|
+
<span style={{ opacity: 0.5 }}>·</span>
|
|
85
|
+
<span className="file-label">
|
|
86
|
+
{currentFile ?? '(未命名)'}
|
|
87
|
+
{dirty && <span style={{ color: '#dc2626' }}> *</span>}
|
|
88
|
+
</span>
|
|
89
|
+
<span style={{ flex: 1 }} />
|
|
90
|
+
{manifestLoading && (
|
|
91
|
+
<span style={{ color: '#6b7280', fontSize: 11 }}>
|
|
92
|
+
加载工具元数据…
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
{manifestError && (
|
|
96
|
+
<span style={{ color: '#dc2626', fontSize: 11 }}>
|
|
97
|
+
manifest: {manifestError}
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
{saveStatus && (
|
|
101
|
+
<span style={{ fontSize: 12, marginRight: 8 }}>{saveStatus}</span>
|
|
102
|
+
)}
|
|
103
|
+
<button onClick={onNewWorkflow}>新建</button>
|
|
104
|
+
<button onClick={() => setYamlOpen((v) => !v)}>
|
|
105
|
+
{yamlOpen ? '关闭 YAML 视图' : 'YAML 视图'}
|
|
106
|
+
</button>
|
|
107
|
+
<button onClick={onSave} className="primary">
|
|
108
|
+
保存
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<Palette />
|
|
113
|
+
<WorkflowCanvas />
|
|
114
|
+
<Inspector />
|
|
115
|
+
|
|
116
|
+
{yamlOpen && <YamlEditor onClose={() => setYamlOpen(false)} />}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
package/src/api/files.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 前端调用 server.ts 的薄包装。所有错误统一抛 Error,调用方决定如何展示。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface WorkflowFileSummary {
|
|
6
|
+
name: string; // 文件名,如 'release.yaml'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function listWorkflows(): Promise<WorkflowFileSummary[]> {
|
|
10
|
+
const res = await fetch('/api/list');
|
|
11
|
+
if (!res.ok) throw new Error(`list failed: ${res.status}`);
|
|
12
|
+
return (await res.json()) as WorkflowFileSummary[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readWorkflow(file: string): Promise<string> {
|
|
16
|
+
const res = await fetch(`/api/read?file=${encodeURIComponent(file)}`);
|
|
17
|
+
if (!res.ok) throw new Error(`read failed: ${res.status}`);
|
|
18
|
+
const data = (await res.json()) as { content: string };
|
|
19
|
+
return data.content;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeWorkflow(file: string, content: string): Promise<void> {
|
|
23
|
+
const res = await fetch('/api/write', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify({ file, content }),
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
|
30
|
+
throw new Error(data.error ?? `write failed: ${res.status}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Manifest } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 加载 manifest:优先从开发服务器 /api/manifest 拿(运行时由 editor/scripts/server.ts 提供),
|
|
5
|
+
* 失败则从 /manifest.fallback.json(vite 静态资源,public/ 里的文件)兜底。
|
|
6
|
+
*/
|
|
7
|
+
export async function loadManifest(): Promise<Manifest> {
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch('/api/manifest');
|
|
10
|
+
if (res.ok) return await res.json();
|
|
11
|
+
} catch {
|
|
12
|
+
// 服务端未启动,走 fallback
|
|
13
|
+
}
|
|
14
|
+
const res = await fetch('/manifest.fallback.json');
|
|
15
|
+
if (!res.ok) throw new Error('manifest.fallback.json 加载失败');
|
|
16
|
+
return await res.json();
|
|
17
|
+
}
|