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 { name: 'api-save', configureServer(server: any) { const opencodeBin = resolve(__dirname, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe') server.middlewares.use('/api/save', (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 { path, data } = JSON.parse(body) if (!path || typeof path !== 'string' || !path.startsWith('/scenes/')) { res.writeHead(400) res.end(JSON.stringify({ error: 'invalid path' })) return } const safePath = resolve(__dirname, 'public', '.' + path) fs.writeFileSync(safePath, JSON.stringify(data, null, 2)) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ ok: true })) } catch (e: any) { res.writeHead(400) res.end(JSON.stringify({ error: e.message })) } }) }) const dedupMap = new Map() server.middlewares.use('/api/ai/sessions', (req: any, res: any) => { if (req.method !== 'GET') { res.writeHead(405); res.end(); return } try { const child = spawn(opencodeBin, ['session', 'list', '--format', 'json'], { timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' let responded = false child.stdout.on('data', (d: Buffer) => stdout += d.toString()) child.on('error', () => { if (responded) return responded = true res.writeHead(200, { 'Content-Type': 'application/json' }) res.end('[]') }) child.on('close', () => { if (responded) return responded = true res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(stdout || '[]') }) } catch { res.writeHead(200, { 'Content-Type': 'application/json' }) res.end('[]') } }) 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) { res.writeHead(400); res.end(JSON.stringify({ error: 'missing fields' })); return } const dedupKey = `${mode}_${sessionId || ''}_${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' ? `当前项目根目录: ${__dirname}\n代码模式:直接修改 src/ 下的源码文件并保存。需求:` : `当前项目根目录: ${__dirname}\nJSON模式:只返回修改后的 JSON 文本,不要写任何文件。需求:` const fullMessage = modePrefix + userMessage const args = ['run', '--model', 'deepseek/deepseek-v4-pro', '--format', 'json'] if (sessionId) args.push('--session', sessionId) args.push(fullMessage) const child = spawn(opencodeBin, args, { env: { ...process.env, DEEPSEEK_API_KEY: apiKey || process.env.DEEPSEEK_API_KEY || '' }, timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'], }) let stdout = '' let stderr = '' let responded = false child.stdout.on('data', (d: Buffer) => stdout += d.toString()) child.stderr.on('data', (d: Buffer) => stderr += d.toString()) child.on('error', (err: any) => { if (responded) return responded = true res.writeHead(503) res.end(JSON.stringify({ error: err?.message || 'spawn failed' })) }) child.on('close', async (code) => { if (responded) return responded = true if (code !== 0) { res.writeHead(500) res.end(JSON.stringify({ error: code === null ? 'opencode 超时,请重试或简化需求' : 'opencode exited with code ' + code, stderr })) return } let resolvedSessionId = sessionId let aiText = '' for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim() if (!line) continue try { const event = JSON.parse(line) if (!resolvedSessionId && event.sessionID) resolvedSessionId = event.sessionID if (event.type === 'text' && event.part?.text) { aiText += event.part.text } } catch { continue } } if (mode === 'json') { const codeBlock = aiText.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/) let jsonStr = codeBlock ? codeBlock[1] : aiText try { JSON.parse(jsonStr) } catch { const bareMatch = aiText.match(/(\{[\s\S]*\}|\[[\s\S]*\])/) jsonStr = bareMatch ? bareMatch[0] : aiText } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ result: jsonStr, sessionId: resolvedSessionId || '' })) } else { res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ result: aiText || 'done', sessionId: resolvedSessionId || '' })) } }) } catch (e: any) { res.writeHead(400) res.end(JSON.stringify({ error: e.message })) } }) }) }, } } export default defineConfig({ plugins: [vue(), apiSavePlugin()], resolve: { alias: { '@': resolve(__dirname, 'src'), '@engine': resolve(__dirname, 'engine'), }, }, build: { rollupOptions: { input: { main: resolve(__dirname, 'index.html'), editor: resolve(__dirname, 'editor/index.html'), }, }, }, })