From da1c3ce8eaa47732dfe74e13e0b6363a8737cecf Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 8 Jun 2026 20:48:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8Dharness=20agent=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/runtime/codewiz-process.ts | 92 ++++++++++++ src/lib/runtime/codewiz-runtime.ts | 215 +++++++++++++++++++++++++++++ src/types/index.ts | 4 + 3 files changed, 311 insertions(+) create mode 100644 src/lib/runtime/codewiz-process.ts create mode 100644 src/lib/runtime/codewiz-runtime.ts diff --git a/src/lib/runtime/codewiz-process.ts b/src/lib/runtime/codewiz-process.ts new file mode 100644 index 0000000..7db5267 --- /dev/null +++ b/src/lib/runtime/codewiz-process.ts @@ -0,0 +1,92 @@ +/** + * runtime/codewiz-process.ts — Node.js-only process management for codewiz-agent. + * + * This file is imported via dynamic `import()` so it only runs in Node.js, + * not in the browser. Place all child_process / Node.js built-in usage here. + */ + +import path from 'path'; +import { fileURLToPath } from 'url'; + +const DEFAULT_PORT = 18732; +const STARTUP_TIMEOUT_MS = 15_000; + +let pythonProcess: ReturnType | null = null; +let fastApiReady = false; +let fastApiFailed = false; +let startupPromise: Promise | null = null; + +function getPySrc(): string { + // __dirname equivalent for ESM + const currentFile = fileURLToPath(import.meta.url); + return path.join(path.dirname(currentFile), '..', 'lib', 'codewiz-agent'); +} + +function getPort(): number { + return Number(process.env.FASTAPI_PORT ?? DEFAULT_PORT); +} + +export function ensureFastApi(): Promise { + if (fastApiReady) return Promise.resolve(); + if (fastApiFailed) return Promise.reject(new Error('FastAPI backend failed to start')); + if (startupPromise) return startupPromise; + startupPromise = _startFastApi().then(() => { startupPromise = null; }); + return startupPromise; +} + +async function _startFastApi(): Promise { + const { spawn } = await import('child_process'); + const port = getPort(); + const pySrc = getPySrc(); + + console.log('[codewiz-runtime] Starting FastAPI backend on port', port); + + pythonProcess = spawn('python', ['-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', String(port)], { + cwd: pySrc, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + env: { ...process.env, PYTHONPATH: pySrc }, + }); + + let stderr = ''; + pythonProcess.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8'); + }); + + const deadline = Date.now() + STARTUP_TIMEOUT_MS; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 500)); + try { + const res = await fetch(`http://127.0.0.1:${port}/health`); + if (res.ok) { + fastApiReady = true; + console.log('[codewiz-runtime] FastAPI backend ready'); + return; + } + } catch { + // not ready yet + } + if (pythonProcess?.exitCode !== null) { + fastApiFailed = true; + console.error('[codewiz-runtime] FastAPI process exited early:\n' + stderr); + throw new Error(`FastAPI exited with code ${pythonProcess.exitCode}`); + } + } + fastApiFailed = true; + pythonProcess?.kill(); + throw new Error(`FastAPI did not start within ${STARTUP_TIMEOUT_MS}ms`); +} + +export function disposeFastApi(): void { + if (pythonProcess) { + pythonProcess.kill('SIGTERM'); + pythonProcess = null; + } + fastApiReady = false; + fastApiFailed = false; + startupPromise = null; +} + +export function getFastApiPort(): number { + return getPort(); +} diff --git a/src/lib/runtime/codewiz-runtime.ts b/src/lib/runtime/codewiz-runtime.ts new file mode 100644 index 0000000..797339a --- /dev/null +++ b/src/lib/runtime/codewiz-runtime.ts @@ -0,0 +1,215 @@ +/** + * runtime/codewiz-runtime.ts — TypeScript Runtime for codewiz-agent FastAPI backend. + * + * Manages the FastAPI subprocess lifecycle, translates SSE events from FastAPI's + * HarnessEvent format to CodeWiz's SSEEventType format, and implements the + * AgentRuntime interface so codewiz-agent sessions flow through the same + * chat/route.ts pipeline as other runtimes. + * + * Architecture: + * Next.js (chat API) → codewizRuntime.stream() → HTTP POST to FastAPI SSE + * FastAPI → SSE → codewiz-runtime (translate) → SSEEvent → client + * + * Node.js-only code (child_process) is isolated in codewiz-process.ts and + * imported dynamically so this module stays compatible with the browser build. + */ + +import type { AgentRuntime, RuntimeStreamOptions } from './types'; + +const activeAbortControllers = new Map(); +const activeFetches = new Map(); + +/** Translate FastAPI HarnessEvent type to CodeWiz SSEEventType + payload. */ +function translateEvent(rawType: string, data: unknown): { type: string; data: string } | null { + switch (rawType) { + case 'message_start': + return null; + + case 'message_chunk': { + const chunk = typeof data === 'object' && data !== null ? (data as { chunk?: string }).chunk ?? '' : ''; + return { type: 'text', data: chunk }; + } + + case 'think_start': + return { type: 'thinking', data: '<' }; + + case 'think_chunk': { + const chunk = typeof data === 'object' && data !== null ? (data as { chunk?: string }).chunk ?? '' : ''; + return { type: 'thinking', data: chunk }; + } + + case 'think_end': + return { type: 'thinking', data: '>' }; + + case 'tool_start': { + const d = typeof data === 'object' ? data as { name?: string; id?: string } : {}; + return { type: 'tool_use', data: JSON.stringify({ name: d.name, id: d.id }) }; + } + + case 'tool_input': { + const d = typeof data === 'object' ? data as { name?: string; input?: unknown; id?: string } : {}; + return { + type: 'tool_result', + data: JSON.stringify({ name: d.name, input: d.input, id: d.id, content: `Input: ${JSON.stringify(d.input ?? {})}` }), + }; + } + + case 'tool_output': { + const d = typeof data === 'object' ? data as { name?: string; output?: unknown; id?: string } : {}; + return { + type: 'tool_result', + data: JSON.stringify({ name: d.name, output: d.output, id: d.id, content: typeof d.output === 'string' ? d.output : JSON.stringify(d.output ?? '') }), + }; + } + + case 'tool_end': + return null; + + case 'context_info': + return null; + + case 'error': { + const msg = typeof data === 'object' && data !== null ? (data as { message?: string }).message ?? String(data) : String(data); + return { type: 'error', data: msg }; + } + + default: + return null; + } +} + +export const codewizRuntime: AgentRuntime = { + id: 'codewiz-agent', + displayName: 'Agent (codewiz)', + description: 'Tool-calling agent powered by codewiz-agent (FastAPI + MiniMax/LLM).', + + stream(options: RuntimeStreamOptions): ReadableStream { + const sessionId = options.sessionId; + + const abortCtrl = new AbortController(); + activeAbortControllers.set(sessionId, abortCtrl); + activeFetches.set(sessionId, abortCtrl); + + return new ReadableStream({ + async start(controller) { + try { + // Dynamic import keeps Node.js-only code out of the browser bundle + const { ensureFastApi, getFastApiPort } = await import('./codewiz-process'); + const port = getFastApiPort(); + await ensureFastApi(); + + // Forward abort signal + options.abortController?.signal.addEventListener('abort', () => abortCtrl.abort()); + + const resp = await fetch( + `http://127.0.0.1:${port}/api/bridge/chat/${sessionId}/stream`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, + body: JSON.stringify({ message: options.prompt, session_id: sessionId }), + signal: abortCtrl.signal, + } + ); + + if (!resp.ok) { + const errText = await resp.text(); + controller.enqueue(`data: ${JSON.stringify({ type: 'error', data: `HTTP ${resp.status}: ${errText}` })}\n\n`); + controller.enqueue(`data: ${JSON.stringify({ type: 'done', data: '' })}\n\n`); + controller.close(); + return; + } + + if (!resp.body) { + controller.enqueue(`data: ${JSON.stringify({ type: 'error', data: 'No response body from FastAPI' })}\n\n`); + controller.enqueue(`data: ${JSON.stringify({ type: 'done', data: '' })}\n\n`); + controller.close(); + return; + } + + let lineBuffer = ''; + const reader = resp.body.getReader(); + const decoder = new TextDecoder('utf-8'); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = lineBuffer + decoder.decode(value, { stream: true }); + const lines = text.split('\n'); + lineBuffer = lines.pop() ?? ''; + + for (const rawLine of lines) { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith('data: ')) continue; + try { + const parsed = JSON.parse(trimmed.slice(6)); + const { type: rawType, data } = parsed; + const translated = translateEvent(rawType as string, data); + if (translated) { + controller.enqueue(`data: ${JSON.stringify(translated)}\n\n`); + } + } catch { + // skip malformed line + } + } + } + + // Flush buffer + if (lineBuffer.trim().startsWith('data: ')) { + try { + const parsed = JSON.parse(lineBuffer.trim().slice(6)); + const { type: rawType, data } = parsed; + const translated = translateEvent(rawType as string, data); + if (translated) { + controller.enqueue(`data: ${JSON.stringify(translated)}\n\n`); + } + } catch { /* skip */ } + } + } finally { + reader.releaseLock(); + } + + controller.enqueue(`data: ${JSON.stringify({ type: 'done', data: '' })}\n\n`); + controller.close(); + } catch (err) { + if ((err as Error).name === 'AbortError') { + controller.enqueue(`data: ${JSON.stringify({ type: 'done', data: '' })}\n\n`); + } else { + controller.enqueue(`data: ${JSON.stringify({ type: 'error', data: String(err) })}\n\n`); + controller.enqueue(`data: ${JSON.stringify({ type: 'done', data: '' })}\n\n`); + } + controller.close(); + } finally { + activeAbortControllers.delete(sessionId); + activeFetches.delete(sessionId); + } + }, + + cancel() { + abortCtrl.abort(); + activeAbortControllers.delete(sessionId); + activeFetches.delete(sessionId); + }, + }); + }, + + interrupt(sessionId: string): void { + const ctrl = activeAbortControllers.get(sessionId) ?? activeFetches.get(sessionId); + if (ctrl) ctrl.abort(); + }, + + isAvailable(): boolean { + return true; + }, + + dispose(): void { + for (const [, ctrl] of activeAbortControllers) ctrl.abort(); + for (const [, ctrl] of activeFetches) ctrl.abort(); + activeAbortControllers.clear(); + activeFetches.clear(); + // Import and call disposal synchronously — safe since dispose() is only + // called on server shutdown, not in the browser. + import('./codewiz-process').then(({ disposeFastApi }) => disposeFastApi()).catch(() => {}); + }, +}; diff --git a/src/types/index.ts b/src/types/index.ts index 21ed7a6..f22581a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,8 @@ export interface ChatSession { project_name: string; status: 'active' | 'archived'; mode?: 'code' | 'plan' | 'ask'; + /** Agent backend: 'claude' (Claude Code SDK) or 'agent' (codewiz-agent FastAPI) */ + agent_mode?: 'claude' | 'agent'; needs_approval?: boolean; provider_name: string; provider_id: string; @@ -325,6 +327,8 @@ export interface CreateSessionRequest { mode?: string; provider_id?: string; permission_profile?: string; + /** 'claude' (default) or 'agent' (codewiz-agent FastAPI backend) */ + agent_mode?: 'claude' | 'agent'; } export interface SendMessageRequest {