diff --git a/docs/E17_AI_ASSISTANT_PROPOSAL.md b/docs/E17_AI_ASSISTANT_PROPOSAL.md new file mode 100644 index 0000000..e6cfff5 --- /dev/null +++ b/docs/E17_AI_ASSISTANT_PROPOSAL.md @@ -0,0 +1,234 @@ +# E17: AI 编码助手 — opencode + DeepSeek 集成 + +## 概述 + +编辑器内嵌 AI 对话面板,使用 opencode Agent + DeepSeek 后端,支持两种模式: +- **JSON 模式** — 修改场景配置,填充 textarea 供用户审查后接受 +- **代码模式** — 直接修改 `src/` 目录下的 Vue 组件和 CSS,Vite HMR 实时预览 + +## 完整架构 + +``` +浏览器 (Editor) + ├── AIPanel.vue 用户输入自然语言 + ├── NodeEditor.vue 接收 AI 返回的 JSON,[接受]/[撤销] + └── App.vue 管理 AI 面板状态 + + ↓ POST /api/ai { sessionId, userMessage, apiKey, mode, nodeId? } + +Vite 中间件(零状态) + └── spawn('node_modules/.bin/opencode', ['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage]) + ├── fullMessage = modePrefix + userMessage + │ JSON模式: "JSON模式:只返回修改后的 JSON 文本,不要写任何文件。需求:..." + nodeJson + │ 代码模式: "代码模式:直接修改 src/ 下的源码文件并保存。需求:..." + ├── opencode 自身管理会话上下文(--session 复用已有会话) + ├── 自动读取项目文件构建 prompt → 调 DeepSeek API + └── 返回 stdout +``` + +## JSON 模式交互时序 + +``` +用户 编辑器 Vite 中间件 opencode DeepSeek + │ │ │ │ │ + │ 选中节点+输入需求 │ │ │ │ + │─────────────────────→│ │ │ │ + │ │ POST /api/ai │ │ │ + │ │──────────────────────→│ │ │ + │ │ │ spawn opencode │ │ + │ │ │───────────────────→│ │ + │ │ │ │ 读 story.json + │ + │ │ │ │ SPEC.md + 用户需求 │ + │ │ │ │────────────────────→│ + │ │ │ │ 返回 JSON │ + │ │ │ │←────────────────────│ + │ │ │ stdout: JSON │ │ + │ │ │←───────────────────│ │ + │ │ 200 { result } │ │ │ + │ │←──────────────────────│ │ │ + │ │ │ │ │ + │ 显示 [接受] [撤销] │ │ │ │ + │─────→ textarea 填充 │ │ │ │ + │ │ │ │ │ + │ 点击 [接受] │ │ │ │ + │─────────────────────→│ │ │ │ + │ │ JSON.parse → update │ │ │ + │ │ → autoSave 写磁盘 │ │ │ +``` + +## 代码模式交互时序 + +``` +用户 编辑器 Vite 中间件 opencode DeepSeek + │ │ │ │ │ + │ 输入 UI 修改需求 │ │ │ │ + │─────────────────────→│ │ │ │ + │ │ POST /api/ai │ │ │ + │ │──────────────────────→│ │ │ + │ │ │ spawn opencode │ │ + │ │ │───────────────────→│ │ + │ │ │ │ 读 src/components/ │ + │ │ │ │ 改 Vue/CSS 文件 │ + │ │ │ │────────────────────→│ + │ │ │ │ 写文件到 src/ │ + │ │ │ │←────────────────────│ + │ │ │ stdout: done │ │ + │ │ │←───────────────────│ │ + │ │ 200 { result } │ │ │ + │ │←──────────────────────│ │ │ + │ │ │ │ │ + │ Vite HMR 检测变化 │ │ │ │ + │←─────────────────────│ │ │ │ + │ 浏览器热更新预览 │ │ │ │ +``` + +## 关键设计决策 + +| 决策 | 做法 | 原因 | +|------|------|------| +| **AI 后端** | opencode Agent + DeepSeek | 唯一覆盖 JSON + 代码双模式;自动读取项目文件构建上下文 | +| **opencode 安装** | npm 包 `opencode-ai`,作为 `devDependencies`。`npm install` 后 `node_modules/.bin/opencode` 即可用 | clone 即用,无需手动全局安装 | +| **API Key 存储** | 编辑器设置中输入,存 localStorage,每次请求传给 `/api/ai` | 不硬编码,创作者自行管理 | +| **API Key 传输** | 通过 POST body 传到 Vite 中间件,不暴露在浏览器网络日志 | XSS 只能获取 localStorage,看不到服务端日志 | +| **JSON 编辑** | AI 返回 JSON 填充 textarea,用户审查后 [接受] 才保存 | 保留创作者对故事数据的最终控制权 | +| **代码编辑** | opencode 直接写 `src/` 目录,Vite HMR 秒级刷新 | 代码修改即时可视化,不需要手动刷新 | +| **上下文构建** | opencode 读取项目文件自成上下文 | 不需要手动拼系统 prompt | +| **模式切换** | AIPanel 当前模式标签:选中节点 → "JSON 模式";未选中节点 → "代码模式"。手动可切换 | 减少用户认知负担,大部分场景自动判断正确 | +| **DeepSeek 模型** | `--model deepseek`,通过 opencode providers 配置 Key | opencode 自身管理模型路由 | + +## 会话管理 + +opencode 原生支持会话:`opencode run --session ` 复用已有上下文,`opencode session list` 列出历史。 + +### 架构原则:前端持 sessionId,中间件无状态 + +``` +AIPanel (Vue) + ├── sessionId = localStorage.getItem('editor_ai_session') || crypto.randomUUID() + ├── 每次请求 POST /api/ai 带上 sessionId + ├── "新建对话" → 新 UUID → 覆盖 localStorage + └── 历史列表 → GET /api/ai/sessions → opencode session list → 前端渲染 + +Vite 中间件 — 零状态 + └── spawn('opencode', ['run', '--session', sessionId, '--format', 'json', userMessage]) + └── 不存任何 sessionId,不维护子进程 +``` + +### 会话操作 + +| 操作 | opencode CLI | 触发方式 | +|------|-------------|---------| +| 首次对话 | `opencode run --session <新UUID> --model deepseek --format json "..."` | AIPanel 检测 localStorage 无 sessionId | +| 继续对话 | `opencode run --session <已有UUID> --model deepseek --format json "..."` | 带上同一 sessionId | +| 新建对话 | 前端生成新 UUID → 旧会话仍存在于 opencode 内部 DB | AIPanel "新建对话" 按钮 | +| 历史列表 | `opencode session list` | AIPanel 顶部下拉(可选) | + +### 为什么不用长活子进程 + +| | 长活子进程 + stdin/stdout | **独立进程 + --session** | +|------|:--:|:--:| +| 进程管理 | 需管道通信、心跳检测 | `spawn` 等待 exit,0 行管理代码 | +| 中间件状态 | 需维护 sessionId→process Map | 零状态 | +| dev server 重启 | 会话丢失 | localStorage 持久化,重启可恢复 | +| 稳定性 | 子进程可能 crash | 每次独立进程,天然隔离 | +| 延迟 | ~1-3s(管道通信) | ~5s(启动 Node + 加载会话),对编辑器 AI 场景可接受 | + +## 安全设计 + +| 层级 | 措施 | +|------|------| +| **API Key** | AES 加密存 localStorage?→ 不用,Key 本身是服务端 API 凭证,浏览器存储已是业界实践(VS Code Copilot、Cursor 同理) | +| **传输** | POST body 中传递,HTTPS 加密。仅 `/api/ai` 路由可读取,上游不打印日志 | +| **文件写入** | opencode 通过 Vite 中间件 spawn,中间件做路径白名单:JSON 模式只允许 `public/scenes/*.json`;代码模式只允许 `src/**/*.vue`、`src/**/*.ts`、`src/locales/*.json` | +| **请求频率** | 中间件加 3s 内去重(同一 mode+userMessage 不重复 spawn) | + +## 错误处理 + +| 错误场景 | 处理 | +|----------|------| +| DeepSeek API 超时(15s) | 中间件 kill 子进程,返回 `504 { error: "timeout" }` | +| opencode 进程崩溃(exit code ≠ 0) | 中间件返回 `500 { error: "opencode exited with code " + code }` | +| DeepSeek API 余额不足 | 中间件返回 `402 { error: "insufficient_quota" }` | +| opencode 返回非 JSON(代码模式) | 直接视为成功(stdout 文本无关紧要) | +| opencode 返回非 JSON(JSON 模式) | 中间件尝试用正则提取 JSON 块,无法提取则返回 `500` 给前端 | + +## 文件改动清单 + +| 文件 | 职能 | +|------|------| +| `package.json` | 新增 `opencode-ai` devDependency(`"opencode-ai": "^1.17"`) | +| `vite.config.ts` | 新增 `POST /api/ai` 中间件:接收 sessionId/userMessage/apiKey/mode,构造 modePrefix + spawn(`opencode`, `['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage]`);`GET /api/ai/sessions` → `opencode session list` | +| `editor/composables/useAI.ts` | **新建** — `sendAIRequest(sessionId, userMessage, mode)` → fetch(`/api/ai`);`listSessions()` → fetch(`/api/ai/sessions`) | +| `editor/components/AIPanel.vue` | **新建** — 消息历史 + `` + loading + "正在生成..." + **"新建对话"按钮** + 会话列表下拉 | +| `editor/stores/editorStore.ts` | 新增 `deepseekKey`(localStorage)、`showAIPanel`、`aiResult`、`sessionId` | +| `editor/App.vue` | 新增 "AI 助手" 按钮 + AIPanel 组件 + 设置面板中 API Key 输入栏 | + +--- + +## 测试用例 + +### T1: JSON 模式 — 正常流程 + +| 步骤 | 预期结果 | +|------|---------| +| 选中一个场景节点 | NodeEditor 显示该场景 JSON | +| 打开 AIPanel,输入"给这个场景添加一个 QTE" | 显示 loading 状态 | +| opencode 返回修改后 JSON | NodeEditor textarea 被填充,出现 [接受] [撤销] | +| 点击 [接受] | JSON.parse 成功,store.updateScene 执行,autoSave 写磁盘 | +| 点击 [撤销] | textarea 回滚到 AI 修改前的值 | + +### T2: JSON 模式 — opencode 返回无效 JSON + +| 步骤 | 预期结果 | +|------|---------| +| AI 返回非法 JSON | 中间件返回 `500 { error }` | +| AIPanel 显示红色错误提示 | "AI 返回格式异常,请重试",textarea 不被填充 | + +### T3: 代码模式 — 正常流程 + +| 步骤 | 预期结果 | +|------|---------| +| 未选中任何节点 | AIPanel 显示 "代码模式" 标签 | +| 输入"把按钮改成圆角 20px" | 显示 loading | +| opencode 修改 `src/components/ChoicePanel.vue` 的 CSS | 文件写磁盘成功 | +| Vite HMR 触发 | 预览窗按钮圆角即时可见 | + +### T4: 会话管理 + +| 步骤 | 预期结果 | +|------|---------| +| 首次对话 | localStorage 无 `editor_ai_session` → AIPanel 自动生成 UUID | +| 第二次对话 | 带上同一 sessionId → opencode `--session ` 恢复上下文 | +| 点击 "新建对话" | 新 UUID 写入 localStorage | +| 刷新页面 | sessionId 不丢失,历史列表可查 | + +### T5: 错误处理 + +| 步骤 | 预期结果 | +|------|---------| +| DeepSeek Key 无效 | AIPanel 显示 "API 认证失败,请检查 Key" | +| opencode 进程被 kill | 中间件 `child.on('exit', 137)` → 500 | +| 请求 15s 无响应 | 中间件 kill 子进程 → 504 | +| 同一 userMessage 3s 内发送两次 | 去重,不重复 spawn | + +### T6: 路径白名单 + +| 步骤 | 预期结果 | +|------|---------| +| 代码模式让 AI 修改 `/etc/passwd` | 中间件拒绝或 opencode 写盘失败 | +| JSON 模式让 AI 修改 `../secret.json` | 路径不在 `public/scenes/*.json` 范围内, 被拒绝 | + +### T7: API Key 管理 + +| 步骤 | 预期结果 | +|------|---------| +| 未填 Key 就发送请求 | AIPanel 提示 "请先在设置中输入 DeepSeek API Key" | +| 填入 Key → 发送请求 | Key 从 localStorage 读取,POST body 传给中间件 | +| 刷新页面 | Key 仍存在 | + +### T8: NodeEditor 覆盖保护 + +| 步骤 | 预期结果 | +|------|---------| +| 用户正在手动编辑 textarea → AI 返回结果 | AI 结果填充到 textarea(覆盖手动编辑),可 [撤销] | +| AI 返回后用户手动再编辑 → 失焦 | 手动编辑内容通过 blur 保存(不是 AI 内容) | diff --git a/docs/EDITOR_ROADMAP.md b/docs/EDITOR_ROADMAP.md index 3877a7f..0204f27 100644 --- a/docs/EDITOR_ROADMAP.md +++ b/docs/EDITOR_ROADMAP.md @@ -51,10 +51,24 @@ - [x] `@blur` → JSON.parse → `store.updateScene(id, parsed)` - [x] 解析失败 → 红色边框 + 错误提示 +## E17: AI 编码助手 — opencode + DeepSeek 集成 + +目标:编辑器内嵌 AI 对话面板。JSON 模式填充 textarea 供用户审查后接受;代码模式直接修改 Vue 组件/CSS,Vite HMR 实时预览。 + +详见 [E17_AI_ASSISTANT_PROPOSAL.md](../E17_AI_ASSISTANT_PROPOSAL.md) + +- [x] `vite.config.ts` — `POST /api/ai` 中间件(spawn opencode) +- [x] `editor/composables/useAI.ts` — DeepSeek API 调用封装 +- [x] `editor/components/AIPanel.vue` — AI 对话面板 +- [x] `editor/components/NodeEditor.vue` — `aiResult` prop + [接受]/[撤销] +- [x] `editor/stores/editorStore.ts` — `deepseekKey` + `showAIPanel` + `aiResult` +- [x] `editor/App.vue` — "AI 助手" 按钮 + API Key 设置 + **优先级建议:** | 优先级 | 编号 | 说明 | |:--:|------|------| +| **P0** | E17 | AI 编码助手 — DeepSeek 集成 ✅ 核心已完成 | | **P0** | E16 | JSON 编辑器 ✅ 已完成 | | **P0** | E10 | 内嵌快速测试 | | **P1** | E12 | JSON 校验 | diff --git a/editor/App.vue b/editor/App.vue index 1d5aefe..a75810f 100644 --- a/editor/App.vue +++ b/editor/App.vue @@ -7,6 +7,7 @@ import { useEditorStore } from './stores/editorStore' import SceneGraph from './components/SceneGraph.vue' import NodeEditor from './components/NodeEditor.vue' import PreviewPanel from './components/PreviewPanel.vue' +import AIPanel from './components/AIPanel.vue' const store = useEditorStore() const editor = useGraphEditor() @@ -111,6 +112,9 @@ onMounted(() => restoreOrLoad()) + @@ -139,6 +143,7 @@ onMounted(() => restoreOrLoad()) @delete-scene="delNode" @close="store.selectedNodeId = null" /> + diff --git a/editor/components/AIPanel.vue b/editor/components/AIPanel.vue new file mode 100644 index 0000000..98ed318 --- /dev/null +++ b/editor/components/AIPanel.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/editor/components/NodeEditor.vue b/editor/components/NodeEditor.vue index 823774c..92611ca 100644 --- a/editor/components/NodeEditor.vue +++ b/editor/components/NodeEditor.vue @@ -17,6 +17,29 @@ const store = useEditorStore() const jsonText = ref('') const errorMsg = ref('') const saved = ref(false) +const showAcceptReject = ref(false) +const preAIValue = ref('') + +watch(() => store.aiResult, (result) => { + if (!result) return + preAIValue.value = jsonText.value + jsonText.value = result + showAcceptReject.value = true + errorMsg.value = '' +}) + +function acceptAI() { + showAcceptReject.value = false + jsonText.value = jsonText.value + store.setAIResult('') + onBlur() +} + +function rejectAI() { + jsonText.value = preAIValue.value + showAcceptReject.value = false + store.setAIResult('') +} watch(() => [props.scene, store.gameData] as const, () => { errorMsg.value = '' @@ -56,10 +79,15 @@ function onBlur() {

{{ scene ? scene.id : '全局配置' }}

- 已保存 + + + + + 已保存 JSON 错误: {{ errorMsg }} - - + + +
@@ -147,6 +175,33 @@ function onBlur() { .icon-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.25); } .icon-btn.danger:hover { color: #e74c3c; border-color: #e74c3c; } +.ai-actions { + display: flex; + gap: 4px; +} + +.ai-accept-btn { + padding: 3px 8px; + font-size: 11px; + color: #fff; + background: #4caf50; + border: none; + border-radius: 3px; + cursor: pointer; +} +.ai-accept-btn:hover { background: #388e3c; } + +.ai-reject-btn { + padding: 3px 8px; + font-size: 11px; + color: #fff; + background: #e74c3c; + border: none; + border-radius: 3px; + cursor: pointer; +} +.ai-reject-btn:hover { background: #c62828; } + .json-area { flex: 1; padding: 14px 16px; diff --git a/editor/composables/useAI.ts b/editor/composables/useAI.ts new file mode 100644 index 0000000..ffbb293 --- /dev/null +++ b/editor/composables/useAI.ts @@ -0,0 +1,18 @@ +export async function sendAIRequest(sessionId: string, userMessage: string, mode: string, apiKey: string): Promise<{ result: string }> { + const resp = await fetch('/api/ai', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, userMessage, apiKey, mode }), + }) + if (!resp.ok) { + const err = await resp.json().catch(() => ({ error: 'request failed' })) + throw new Error(err.error || 'request failed') + } + return resp.json() +} + +export async function listSessions(): Promise { + const resp = await fetch('/api/ai/sessions') + if (!resp.ok) return [] + return resp.json() +} diff --git a/editor/stores/editorStore.ts b/editor/stores/editorStore.ts index 348ce50..78d8c5d 100644 --- a/editor/stores/editorStore.ts +++ b/editor/stores/editorStore.ts @@ -8,6 +8,10 @@ export const useEditorStore = defineStore('editor', () => { const startSceneId = ref('') const dirty = ref(false) const sourcePath = ref('/scenes/demo.json') + const deepseekKey = ref(localStorage.getItem('deepseek_key') || '') + const showAIPanel = ref(false) + const aiResult = ref('') + const aiSessionId = ref(localStorage.getItem('editor_ai_session') || '') const selectedScene = computed(() => { if (!selectedNodeId.value) return null @@ -132,6 +136,22 @@ export const useEditorStore = defineStore('editor', () => { function setSourcePath(p: string) { sourcePath.value = p; localStorage.setItem('editor_last_source', p) } + function setDeepseekKey(k: string) { deepseekKey.value = k; localStorage.setItem('deepseek_key', k) } + + function ensureAISession() { + if (!aiSessionId.value) { + aiSessionId.value = crypto.randomUUID() + localStorage.setItem('editor_ai_session', aiSessionId.value) + } + } + + function newAISession() { + aiSessionId.value = crypto.randomUUID() + localStorage.setItem('editor_ai_session', aiSessionId.value) + } + + function setAIResult(r: string) { aiResult.value = r } + async function autoSave() { try { await fetch('/api/save', { @@ -144,8 +164,9 @@ export const useEditorStore = defineStore('editor', () => { return { gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath, + deepseekKey, showAIPanel, aiResult, aiSessionId, markDirty, loadJSON, exportJSON, addScene, deleteScene, updateScene, addChoice, updateChoice, deleteChoice, generateId, - setSourcePath, autoSave, + setSourcePath, setDeepseekKey, ensureAISession, newAISession, setAIResult, autoSave, } }) diff --git a/package.json b/package.json index 4b9c419..476834b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", "adm-zip": "^0.5.17", + "opencode-ai": "^1.17.6", "typescript": "~5.6.0", "vite": "^5.4.0", "vue-tsc": "^2.1.0" diff --git a/src/App.vue b/src/App.vue index f7156aa..fcff64b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -114,7 +114,7 @@ function handleStartFromScene(sceneId: string) { if (sc.choices) for (const ch2 of sc.choices) if (ch2.targetScene) queue.push(ch2.targetScene) if (sc.nextScene) { if (Array.isArray(sc.nextScene)) for (const r of sc.nextScene) if (r.targetScene) queue.push(r.targetScene) - else queue.push(sc.nextScene) + else queue.push(sc.nextScene as unknown as string) } if (sc.qte) { if (sc.qte.successScene) queue.push(sc.qte.successScene); if (sc.qte.failScene) queue.push(sc.qte.failScene) } if (sc.hotspots) for (const h of sc.hotspots) if (h.targetScene) queue.push(h.targetScene) diff --git a/vite.config.ts b/vite.config.ts index b5181fc..e9d38a8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' import fs from 'fs' +import { spawn } from 'child_process' function apiSavePlugin() { return { @@ -29,6 +30,87 @@ function apiSavePlugin() { } }) }) + + const dedupMap = new Map() + + server.middlewares.use('/api/ai', (req: any, res: any) => { + if (req.method !== 'POST') { res.writeHead(405); res.end(); return } + let body = '' + req.on('data', (c: string) => body += c) + req.on('end', () => { + try { + const { sessionId, userMessage, apiKey, mode } = JSON.parse(body) + if (!userMessage || !sessionId) { res.writeHead(400); res.end(JSON.stringify({ error: 'missing fields' })); return } + + const dedupKey = `${mode}_${userMessage}` + const last = dedupMap.get(dedupKey) || 0 + if (Date.now() - last < 3000) { + res.writeHead(429) + res.end(JSON.stringify({ error: 'duplicate request' })) + return + } + dedupMap.set(dedupKey, Date.now()) + + const modePrefix = mode === 'code' + ? '代码模式:直接修改 src/ 下的源码文件并保存。需求:' + : 'JSON模式:只返回修改后的 JSON 文本,不要写任何文件。需求:' + const fullMessage = modePrefix + userMessage + + const opencodeBin = resolve(__dirname, 'node_modules', '.bin', 'opencode') + const child = spawn(opencodeBin, ['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage], { + env: { ...process.env, DEEPSEEK_API_KEY: apiKey || process.env.DEEPSEEK_API_KEY || '' }, + timeout: 15000, + }) + + let stdout = '' + let stderr = '' + child.stdout.on('data', (d: Buffer) => stdout += d.toString()) + child.stderr.on('data', (d: Buffer) => stderr += d.toString()) + + child.on('close', (code) => { + if (code !== 0) { + res.writeHead(500) + res.end(JSON.stringify({ error: 'opencode exited with code ' + code, stderr })) + return + } + if (mode === 'json') { + // try to extract JSON block from response + const match = stdout.match(/```json\n?([\s\S]*?)\n?```|\{[\s\S]*\}/) + const jsonStr = match ? (match[1] || match[0]) : stdout + try { JSON.parse(jsonStr) } catch { + res.writeHead(500) + res.end(JSON.stringify({ error: 'invalid JSON returned', raw: stdout })) + return + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ result: jsonStr })) + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ result: stdout || 'done' })) + } + }) + } catch (e: any) { + res.writeHead(400) + res.end(JSON.stringify({ error: e.message })) + } + }) + }) + + server.middlewares.use('/api/ai/sessions', (req: any, res: any) => { + try { + const opencodeBin = resolve(__dirname, 'node_modules', '.bin', 'opencode') + const child = spawn(opencodeBin, ['session', 'list'], { timeout: 5000 }) + let stdout = '' + child.stdout.on('data', (d: Buffer) => stdout += d.toString()) + child.on('close', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(stdout || '[]') + }) + } catch { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('[]') + } + }) }, } }