import type { Plugin } from 'vite'; import fs from 'fs'; import path from 'path'; import { exec, execFile, spawn, spawnSync } from 'node:child_process'; import { commandExists, decodeOutput, getPreferredNpmCommand, getSpawnCommandSpec, runCommand, runCommandSync, } from '../scripts/utils/command-runtime.mjs'; type ProjectDefaults = { defaultTheme?: string | null; }; type ProjectInfo = { name?: string | null; description?: string | null; }; type LegacyPromptClient = 'claude' | 'cursor' | 'codex' | 'gemini' | 'opencode'; type GeniePromptClient = 'genie:claude' | 'genie:cursor' | 'genie:codex' | 'genie:gemini' | 'genie:opencode'; type LocalPromptClient = 'local:cursor' | 'local:qoder'; type PromptClient = GeniePromptClient | LocalPromptClient; type MainIDE = 'cursor' | 'trae' | 'vscode' | 'trae_cn' | 'windsurf' | 'kiro' | 'qoder' | 'antigravity'; const MAIN_IDE_VALUES: MainIDE[] = ['cursor', 'trae', 'vscode', 'trae_cn', 'windsurf', 'kiro', 'qoder', 'antigravity']; const MAIN_IDE_APP_NAMES: Record = { cursor: 'Cursor', trae: 'Trae', vscode: 'Visual Studio Code', trae_cn: 'Trae CN', windsurf: 'Windsurf', kiro: 'Kiro', qoder: 'Qoder', antigravity: 'Antigravity', }; const MAIN_IDE_WINDOWS_APP_PATH_NAMES: Record = { cursor: ['Cursor'], trae: ['Trae', 'TRAE'], vscode: ['Visual Studio Code', 'Visual Studio Code Insiders'], trae_cn: ['Trae CN', 'TRAE CN', 'Trae', 'TRAE'], windsurf: ['Windsurf'], kiro: ['Kiro'], qoder: ['Qoder'], antigravity: ['Antigravity'], }; const MAIN_IDE_WINDOWS_COMMAND_CANDIDATES: Record = { cursor: ['cursor'], trae: ['trae'], vscode: ['code', 'code-insiders'], trae_cn: ['trae-cn', 'trae_cn', 'trae'], windsurf: ['windsurf'], kiro: ['kiro'], qoder: ['qoder'], antigravity: ['antigravity'], }; const MAIN_IDE_WINDOWS_EXECUTABLE_NAMES: Record = { cursor: ['Cursor.exe'], trae: ['TRAE.exe', 'Trae.exe'], vscode: ['Code.exe', 'Code - Insiders.exe'], trae_cn: ['Trae CN.exe', 'TRAE CN.exe', 'TRAE.exe', 'Trae.exe'], windsurf: ['Windsurf.exe'], kiro: ['Kiro.exe'], qoder: ['Qoder.exe'], antigravity: ['Antigravity.exe'], }; type AutomationConfig = { defaultPromptClient?: PromptClient | null; defaultIDE?: MainIDE | null; }; type AssistantConfig = { webBaseUrl?: string | null; apiBaseUrl?: string | null; }; type AssistantRuntimeSource = 'axhub-genie' | 'config' | 'cloudcli' | 'env' | 'default'; type AssistantHealthStatus = | 'ready' | 'missing_cli' | 'cli_error' | 'runtime_unreachable' | 'needs_update'; type AssistantCommandSource = 'axhub-genie' | 'cloudcli' | 'default'; type AssistantHealthHints = { installGlobal: string; start: string; status: string; }; type AssistantHealthInfo = { status: AssistantHealthStatus; message: string; checkedAt: string; commandSource: AssistantCommandSource; hints: AssistantHealthHints; }; type AssistantRuntimeInfo = { webBaseUrl: string; apiBaseUrl: string; projectPath: string; source: AssistantRuntimeSource; health: AssistantHealthInfo; }; type AssistantRuntimeResolveOptions = { autoStart?: boolean; }; type AssistantBootstrapMode = 'install_global' | 'start_existing'; type AssistantProbeStatus = 'ready' | 'missing_cli' | 'needs_update' | 'cli_error' | 'not_running'; type AssistantProbeResult = { status: AssistantProbeStatus; message: string; commandSource: Exclude; config: AssistantConfig | null; }; const ASSISTANT_START_CHECK_DELAY_MS = 500; type SystemConfig = { server: Record; projectDefaults?: ProjectDefaults; projectInfo?: ProjectInfo; automation?: AutomationConfig; assistant?: AssistantConfig; }; type AgentDocsPaths = { configPath: string; agentsTemplatePath: string; agentsPath: string; claudePath: string; }; const DEFAULT_ASSISTANT_WEB_BASE_URL = 'http://localhost:32123'; const DEFAULT_ASSISTANT_API_BASE_URL = 'http://localhost:32123/api'; const DEFAULT_ASSISTANT_HEALTH_URL = `${DEFAULT_ASSISTANT_WEB_BASE_URL}/health`; const ASSISTANT_SERVICE_ID = '@axhub/genie'; const ASSISTANT_SERVICE_NAME = 'Axhub Genie'; const ASSISTANT_RUNTIME_LOG_PREFIX = '[assistant-runtime]'; const ASSISTANT_RUNTIME_DEBUG_LOGS_ENABLED = false; const ASSISTANT_STATUS_TIMEOUT_MS = 8_000; const COMMAND_AVAILABILITY_TIMEOUT_MS = 2_000; const PROJECT_OVERVIEW_DOC_RELATIVE_PATH = 'src/docs/project-overview.md'; function logAssistantRuntime(level: 'info' | 'warn' | 'error', message: string, meta?: Record) { if (!ASSISTANT_RUNTIME_DEBUG_LOGS_ENABLED && level !== 'error') { return; } const payload = meta ? `${ASSISTANT_RUNTIME_LOG_PREFIX} ${message}` : `${ASSISTANT_RUNTIME_LOG_PREFIX} ${message}`; if (level === 'error') { if (meta) { console.error(payload, meta); return; } console.error(payload); return; } if (level === 'warn') { if (meta) { console.warn(payload, meta); return; } console.warn(payload); return; } if (meta) { console.info(payload, meta); return; } console.info(payload); } function getAssistantHealthHints(): AssistantHealthHints { return { installGlobal: 'npx @axhub/genie', start: 'axhub-genie', status: 'axhub-genie status', }; } function normalizeOptionalString(value: unknown): string | null { if (value === null || value === undefined) return null; if (typeof value !== 'string') return null; const trimmed = value.trim(); return trimmed ? trimmed : null; } function normalizeInlineText(value: unknown): string | null { const normalized = normalizeOptionalString(value); if (!normalized) return null; return normalized.replace(/\r?\n+/g, ' ').trim() || null; } function normalizeProjectDefaults(value: unknown): ProjectDefaults { if (!value || typeof value !== 'object') { return { defaultTheme: null }; } const defaults = value as ProjectDefaults; return { defaultTheme: normalizeOptionalString(defaults.defaultTheme) }; } function normalizeProjectInfo(value: unknown): ProjectInfo { if (!value || typeof value !== 'object') { return { name: null, description: null }; } const info = value as ProjectInfo; return { name: normalizeInlineText(info.name), description: normalizeInlineText(info.description) }; } function normalizePromptClient(value: unknown): PromptClient | null { if (value === null || value === undefined) return null; if (typeof value !== 'string') return null; const normalized = value.trim().toLowerCase(); if ( normalized === 'genie:claude' || normalized === 'genie:cursor' || normalized === 'genie:codex' || normalized === 'genie:gemini' || normalized === 'genie:opencode' ) return normalized; if (normalized === 'local:cursor' || normalized === 'local:qoder') return normalized; if (normalized === 'claude') return 'genie:claude'; if (normalized === 'cursor') return 'genie:cursor'; if (normalized === 'codex') return 'genie:codex'; if (normalized === 'gemini') return 'genie:gemini'; if (normalized === 'opencode') return 'genie:opencode'; return null; } function normalizeExecutePromptClient(value: unknown): LegacyPromptClient | null { if (typeof value !== 'string') return null; const normalized = value.trim().toLowerCase(); if ( normalized === 'claude' || normalized === 'cursor' || normalized === 'codex' || normalized === 'gemini' || normalized === 'opencode' ) { return normalized; } return null; } function normalizeMainIDE(value: unknown): MainIDE | null { if (value === null || value === undefined) return null; if (typeof value !== 'string') return null; const normalized = value.trim().toLowerCase() as MainIDE; return MAIN_IDE_VALUES.includes(normalized) ? normalized : null; } function normalizeAutomationConfig(value: unknown): AutomationConfig { if (!value || typeof value !== 'object') { return { defaultPromptClient: null, defaultIDE: null }; } const config = value as AutomationConfig; return { defaultPromptClient: normalizePromptClient(config.defaultPromptClient), defaultIDE: normalizeMainIDE(config.defaultIDE), }; } function trimTrailingSlashes(value: string): string { return value.replace(/\/+$/g, ''); } function normalizeBaseUrl(value: unknown): string | null { const normalized = normalizeOptionalString(value); if (!normalized) return null; return trimTrailingSlashes(normalized); } function normalizeAssistantConfig(value: unknown): AssistantConfig { if (!value || typeof value !== 'object') { return { webBaseUrl: null, apiBaseUrl: null }; } const config = value as AssistantConfig; return { webBaseUrl: normalizeBaseUrl(config.webBaseUrl), apiBaseUrl: normalizeBaseUrl(config.apiBaseUrl), }; } function readSystemConfig(configPath: string): SystemConfig { let config: SystemConfig = { server: { host: 'localhost', allowLAN: true } }; if (fs.existsSync(configPath)) { const fileContent = fs.readFileSync(configPath, 'utf8'); config = JSON.parse(fileContent); } if (!config.server || typeof config.server !== 'object') { config.server = { host: 'localhost', allowLAN: true }; } if (config.server.allowLAN === undefined) { config.server.allowLAN = true; } config.projectDefaults = normalizeProjectDefaults(config.projectDefaults); config.projectInfo = normalizeProjectInfo(config.projectInfo); config.automation = normalizeAutomationConfig(config.automation); config.assistant = normalizeAssistantConfig(config.assistant); return config; } function extractAssistantConfigFromStatusPayload(parsed: any): AssistantConfig | null { const endpoint = parsed?.endpoint ?? parsed?.assistant?.endpoint ?? parsed?.runtime?.endpoint ?? {}; const webBaseUrl = normalizeBaseUrl( endpoint?.frontendUrl ?? endpoint?.webBaseUrl ?? endpoint?.webUrl ?? parsed?.frontendUrl ?? parsed?.webBaseUrl ?? parsed?.webUrl ); const apiBaseUrl = normalizeBaseUrl( endpoint?.apiBaseUrl ?? endpoint?.apiUrl ?? parsed?.apiBaseUrl ?? parsed?.apiUrl ); if (!webBaseUrl && !apiBaseUrl) { return null; } return { webBaseUrl, apiBaseUrl, }; } function containsNeedsUpdateHint(text: string): boolean { const normalized = text.trim(); if (!normalized) return false; return /(need\s*update|needs\s*update|outdated|upgrade|please\s*update|版本过旧|需要更新|请更新)/i.test(normalized); } function containsNotRunningHint(text: string): boolean { const normalized = text.trim(); if (!normalized) return false; return /(not\s*running|service\s*not\s*running|not\s*started|未启动|尚未启动|未运行|服务未运行)/i.test(normalized); } function containsMissingCommandHint(text: string): boolean { const normalized = text.trim(); if (!normalized) return false; return /(not\s+recognized\s+as\s+an?\s+internal|command\s+not\s+found|no\s+such\s+file|未找到|不是内部或外部命令)/i.test(normalized); } function readAssistantStatusFromCli(command: 'axhub-genie' | 'cloudcli', args: string[]): AssistantProbeResult { const commandSource: Exclude = command === 'axhub-genie' ? 'axhub-genie' : 'cloudcli'; try { if (!commandExists(command)) { return { status: 'missing_cli', message: `未检测到 ${command} 命令`, commandSource, config: null, }; } const result = runCommandSync({ command, args, timeoutMs: ASSISTANT_STATUS_TIMEOUT_MS, }); if (result.error) { const error = result.error as NodeJS.ErrnoException; const errCode = error.code; if (errCode === 'ENOENT') { return { status: 'missing_cli', message: `未检测到 ${command} 命令`, commandSource, config: null, }; } if (errCode === 'ETIMEDOUT' || /timed?\s*out/i.test(error.message || '')) { return { status: 'not_running', message: `${command} status 执行超时(>${ASSISTANT_STATUS_TIMEOUT_MS}ms),请确认服务已启动后重试`, commandSource, config: null, }; } return { status: 'cli_error', message: `${command} 执行失败: ${error.message || 'unknown error'}`, commandSource, config: null, }; } const stdout = typeof result.stdout === 'string' ? result.stdout.trim() : ''; const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : ''; const mergedOutput = [stdout, stderr].filter(Boolean).join('\n'); if (containsMissingCommandHint(mergedOutput)) { return { status: 'missing_cli', message: `未检测到 ${command} 命令`, commandSource, config: null, }; } if (result.status !== 0) { if (command === 'axhub-genie' && containsNeedsUpdateHint(mergedOutput)) { return { status: 'needs_update', message: `检测到 ${command} 版本可能过旧,请更新后重试`, commandSource, config: null, }; } if (command === 'axhub-genie' && containsNotRunningHint(mergedOutput)) { return { status: 'not_running', message: `${command} 服务未启动`, commandSource, config: null, }; } return { status: 'cli_error', message: `${command} status 执行失败${mergedOutput ? `: ${mergedOutput}` : ''}`, commandSource, config: null, }; } if (!stdout) { return { status: 'cli_error', message: `${command} status 未返回有效输出`, commandSource, config: null, }; } let parsed: any = null; try { parsed = JSON.parse(stdout); } catch { return { status: 'cli_error', message: `${command} status 返回内容无法解析为 JSON`, commandSource, config: null, }; } const config = extractAssistantConfigFromStatusPayload(parsed); const running = typeof parsed?.running === 'boolean' ? parsed.running : null; if (running === false) { return { status: 'not_running', message: `${command} 服务未启动`, commandSource, config, }; } if (!config) { return { status: 'cli_error', message: `${command} status 返回中未发现可用地址`, commandSource, config: null, }; } return { status: 'ready', message: `${command} 已就绪`, commandSource, config, }; } catch (error: any) { return { status: 'cli_error', message: `${command} status 检查失败: ${error?.message || 'unknown error'}`, commandSource, config: null, }; } } function readAxhubGenieStatus(): AssistantProbeResult { const probe = readAssistantStatusFromCli('axhub-genie', ['status', '--json']); const level = probe.status === 'ready' ? 'info' : 'warn'; logAssistantRuntime(level, 'axhub-genie status --json', { status: probe.status, message: probe.message, config: probe.config || null, }); return probe; } function readCloudCliAssistantStatus(): AssistantProbeResult { return readAssistantStatusFromCli('cloudcli', ['status', '--json']); } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function runExecutableCommandInBackground(command: string, args: string[], cwd: string): Promise { return new Promise((resolve, reject) => { const spawnSpec = getSpawnCommandSpec(command, args, process.platform); logAssistantRuntime('info', '后台执行可执行命令', { command, args, spawnCommand: spawnSpec.command, spawnArgs: spawnSpec.args, cwd, platform: process.platform, }); const child = spawn(spawnSpec.command, spawnSpec.args, { cwd, detached: true, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: spawnSpec.windowsHide, shell: false, }); if (typeof (child as any)?.once !== 'function') { if (typeof child.unref === 'function') { child.unref(); } resolve(); return; } let settled = false; let stderrText = ''; if (child.stderr && typeof (child.stderr as any).on === 'function') { (child.stderr as any).on('data', (chunk: Buffer | string) => { const text = typeof chunk === 'string' ? chunk : decodeOutput(chunk); if (!text) return; if (stderrText.length >= 4000) return; stderrText += text; }); } const finish = (error?: Error) => { if (settled) return; settled = true; if (typeof child.unref === 'function') { child.unref(); } if (error) { reject(error); return; } resolve(); }; child.once('error', (error) => { logAssistantRuntime('error', '可执行命令触发失败', { command, args, cwd, error: (error as Error)?.message || 'unknown error', }); finish(error as Error); }); child.once('spawn', () => { logAssistantRuntime('info', '可执行命令已触发', { command, args, cwd, pid: typeof child.pid === 'number' ? child.pid : null, }); if ((child.stderr as any)?.unref && typeof (child.stderr as any).unref === 'function') { (child.stderr as any).unref(); } setTimeout(() => finish(), 150); }); child.once('exit', (code, signal) => { const stderrSnippet = stderrText.trim().slice(0, 500); const exitMeta = { command, args, cwd, code, signal, stderr: stderrSnippet || null, }; if (typeof code === 'number' && code !== 0) { logAssistantRuntime('error', '可执行命令异常退出', exitMeta); finish(new Error(`命令退出 code=${code}${stderrSnippet ? ` stderr=${stderrSnippet}` : ''}`)); return; } logAssistantRuntime('warn', '可执行命令提前退出', exitMeta); finish(); }); setTimeout(() => finish(), 500); }); } async function startAxhubGenieAndWait(projectPath: string): Promise { const startCommand = getAssistantHealthHints().start; logAssistantRuntime('info', '准备自动启动 Axhub Genie', { command: startCommand, cwd: projectPath, }); try { await runExecutableCommandInBackground(startCommand, [], projectPath); } catch (error: any) { logAssistantRuntime('error', '启动命令触发失败', { command: startCommand, cwd: projectPath, error: error?.message || 'unknown error', }); return { status: 'cli_error', message: `自动启动 Axhub Genie 失败: ${error?.message || 'unknown error'}`, commandSource: 'axhub-genie', config: null, }; } await sleep(ASSISTANT_START_CHECK_DELAY_MS); const probe = readAxhubGenieStatus(); if (probe.status === 'ready') { logAssistantRuntime('info', 'Axhub Genie 自动启动成功', { config: probe.config || null, }); return { ...probe, message: 'Axhub Genie 已自动启动并就绪', }; } if (probe.status === 'missing_cli' || probe.status === 'needs_update') { logAssistantRuntime('warn', '自动启动提前结束', { reason: probe.status, message: probe.message, }); return probe; } if (probe.status === 'cli_error') { logAssistantRuntime('error', '自动启动失败(cli_error)', { message: probe.message, }); return { ...probe, status: 'not_running', message: `${probe.message}。Axhub Genie 自动启动失败,请手动执行 axhub-genie 后重试`, }; } logAssistantRuntime('error', '自动启动失败(启动后单次检查未就绪)', { status: probe.status, message: probe.message, config: probe.config || null, }); return { ...probe, status: 'not_running', message: 'Axhub Genie 自动启动失败,请手动执行 axhub-genie 后重试', }; } function resolveRuntimeEndpoints(params: { statusConfig: AssistantConfig | null; configAssistant: AssistantConfig; envAssistant: AssistantConfig; }): { webBaseUrl: string; apiBaseUrl: string; source: AssistantRuntimeSource } { const candidates: Array<{ source: AssistantRuntimeSource; value: AssistantConfig | null }> = [ { source: 'axhub-genie', value: params.statusConfig }, { source: 'config', value: params.configAssistant }, { source: 'env', value: params.envAssistant }, { source: 'default', value: { webBaseUrl: DEFAULT_ASSISTANT_WEB_BASE_URL, apiBaseUrl: DEFAULT_ASSISTANT_API_BASE_URL, }, }, ]; let webBaseUrl: string | null = null; let apiBaseUrl: string | null = null; let source: AssistantRuntimeSource = 'default'; for (const candidate of candidates) { const value = candidate.value; if (!value) continue; if (!webBaseUrl && value.webBaseUrl) { webBaseUrl = value.webBaseUrl; if (source === 'default') { source = candidate.source; } } if (!apiBaseUrl && value.apiBaseUrl) { apiBaseUrl = value.apiBaseUrl; if (source === 'default') { source = candidate.source; } } if (webBaseUrl && apiBaseUrl) { break; } } return { webBaseUrl: normalizeBaseUrl(webBaseUrl) || DEFAULT_ASSISTANT_WEB_BASE_URL, apiBaseUrl: apiBaseUrl || DEFAULT_ASSISTANT_API_BASE_URL, source, }; } function getAssistantBootstrapHints() { return getAssistantHealthHints(); } function createAssistantHealthInfo(params: { status: AssistantHealthStatus; message: string; commandSource: AssistantCommandSource; }): AssistantHealthInfo { return { status: params.status, message: params.message, checkedAt: new Date().toISOString(), commandSource: params.commandSource, hints: getAssistantHealthHints(), }; } function normalizeAssistantBootstrapMode(value: unknown): AssistantBootstrapMode | null { if (typeof value !== 'string') return null; const normalized = value.trim(); if (normalized === 'install_global' || normalized === 'start_existing') { return normalized; } return null; } function getPreferredNpmCommandForBootstrap(): string { return getPreferredNpmCommand(); } function buildAssistantBootstrapCommand(mode: AssistantBootstrapMode): string { const hints = getAssistantHealthHints(); if (mode === 'install_global') { const npmCommand = getPreferredNpmCommandForBootstrap(); return `${npmCommand} install -g @axhub/genie && ${hints.start}`; } return hints.start; } async function runAssistantBootstrap(mode: AssistantBootstrapMode, cwd: string): Promise { const startCommand = getAssistantHealthHints().start; if (mode === 'install_global') { const npmCommand = getPreferredNpmCommandForBootstrap(); const installResult = await runCommand({ command: npmCommand, args: ['install', '-g', '@axhub/genie'], cwd, capture: true, timeoutMs: 180_000, }); if (installResult.code !== 0) { throw new Error(installResult.stderr || installResult.stdout || 'npm install -g @axhub/genie failed'); } } await runExecutableCommandInBackground(startCommand, [], cwd); } function isCommandAvailable(command: string, args: string[] = ['--version']): boolean { try { if (!commandExists(command)) { return false; } const execution = runCommandSync({ command, args, timeoutMs: COMMAND_AVAILABILITY_TIMEOUT_MS, }); return execution.status === 0; } catch { return false; } } function validateBootstrapPrerequisites(mode: AssistantBootstrapMode): string | null { if (mode === 'install_global') { if (!isCommandAvailable('npm')) { return 'npm 未安装,无法自动安装 Axhub Genie'; } return null; } if (!isCommandAvailable('axhub-genie')) { return '未检测到 axhub-genie 命令,请先安装后重试'; } return null; } async function verifyAssistantHealthEndpoint(): Promise<{ ok: boolean; message: string }> { const healthUrl = DEFAULT_ASSISTANT_HEALTH_URL; try { const response = await fetch(healthUrl, { method: 'GET' }); if (!response.ok) { return { ok: false, message: `/health 探测失败: status ${response.status}` }; } const appIdentifier = response.headers.get('X-App-Identifier') || response.headers.get('x-app-identifier') || ''; let payload: any = null; try { payload = await response.json(); } catch { return { ok: false, message: '/health 响应不是有效 JSON' }; } const serviceId = payload?.service?.id || ''; const serviceName = payload?.service?.name || ''; const idMatched = serviceId === ASSISTANT_SERVICE_ID || appIdentifier === ASSISTANT_SERVICE_ID; const nameMatched = typeof serviceName === 'string' && serviceName.toLowerCase().includes(ASSISTANT_SERVICE_NAME.toLowerCase()); if (!idMatched && !nameMatched) { return { ok: false, message: '健康检查服务身份不匹配(非 Axhub Genie)' }; } if (payload?.status !== 'ok') { return { ok: false, message: `健康检查状态异常: ${String(payload?.status || 'unknown')}` }; } return { ok: true, message: 'Axhub Genie 健康检查通过' }; } catch (error: any) { return { ok: false, message: `健康检查请求失败: ${error?.message || 'unknown error'}` }; } } async function resolveAssistantRuntime( config: SystemConfig, projectPath: string, options?: AssistantRuntimeResolveOptions, ): Promise { const shouldAutoStart = options?.autoStart !== false; logAssistantRuntime('info', '开始解析助手运行时', { projectPath, autoStart: shouldAutoStart }); const healthProbe = await verifyAssistantHealthEndpoint(); logAssistantRuntime(healthProbe.ok ? 'info' : 'warn', '固定地址健康检查结果', { healthUrl: DEFAULT_ASSISTANT_HEALTH_URL, ok: healthProbe.ok, message: healthProbe.message, }); if (healthProbe.ok) { return { webBaseUrl: DEFAULT_ASSISTANT_WEB_BASE_URL, apiBaseUrl: DEFAULT_ASSISTANT_API_BASE_URL, projectPath, source: 'default', health: createAssistantHealthInfo({ status: 'ready', message: healthProbe.message, commandSource: 'default', }), }; } const configAssistant = normalizeAssistantConfig(config.assistant); const envAssistant: AssistantConfig = { webBaseUrl: normalizeBaseUrl(process.env.AXHUB_ASSISTANT_WEB_BASE_URL), apiBaseUrl: normalizeBaseUrl(process.env.AXHUB_ASSISTANT_API_BASE_URL), }; let axhubGenieStatus = readAxhubGenieStatus(); if (axhubGenieStatus.status === 'not_running' && shouldAutoStart) { axhubGenieStatus = await startAxhubGenieAndWait(projectPath); } else if (axhubGenieStatus.status === 'not_running' && !shouldAutoStart) { logAssistantRuntime('info', '检测到未启动,但本次请求禁用自动启动', { projectPath, }); } const resolvedEndpoints = resolveRuntimeEndpoints({ statusConfig: axhubGenieStatus.status === 'ready' ? axhubGenieStatus.config : null, configAssistant, envAssistant, }); logAssistantRuntime('info', '地址解析结果', { source: resolvedEndpoints.source, webBaseUrl: resolvedEndpoints.webBaseUrl, apiBaseUrl: resolvedEndpoints.apiBaseUrl, status: axhubGenieStatus.status, }); let healthStatus: AssistantHealthStatus = 'runtime_unreachable'; let healthMessage = '未找到可用的助手地址,请确认 Axhub Genie 已启动'; const commandSource: AssistantCommandSource = 'axhub-genie'; if (axhubGenieStatus.status === 'ready') { healthStatus = 'ready'; healthMessage = `已通过 axhub-genie status --json 获取服务地址(默认 /health 探测失败:${healthProbe.message})`; } else if (axhubGenieStatus.status === 'missing_cli') { healthStatus = 'missing_cli'; healthMessage = '未检测到 axhub-genie 命令,请先安装后重试'; } else if (axhubGenieStatus.status === 'needs_update') { healthStatus = 'needs_update'; healthMessage = axhubGenieStatus.message; } else if (axhubGenieStatus.status === 'not_running') { healthStatus = 'runtime_unreachable'; healthMessage = 'Axhub Genie 自动启动失败,请手动执行 axhub-genie 后重试'; } else if (axhubGenieStatus.status === 'cli_error') { healthStatus = 'cli_error'; healthMessage = axhubGenieStatus.message; } const runtime = { webBaseUrl: resolvedEndpoints.webBaseUrl, apiBaseUrl: resolvedEndpoints.apiBaseUrl, projectPath, source: resolvedEndpoints.source, health: createAssistantHealthInfo({ status: healthStatus, message: healthMessage, commandSource, }), }; logAssistantRuntime(healthStatus === 'ready' ? 'info' : 'warn', '运行时判定完成', { healthStatus, healthMessage, commandSource, source: runtime.source, webBaseUrl: runtime.webBaseUrl, apiBaseUrl: runtime.apiBaseUrl, }); return runtime; } export const __assistantRuntimeTestUtils = { extractAssistantConfigFromStatusPayload, containsNeedsUpdateHint, normalizeAssistantBootstrapMode, buildAssistantBootstrapCommand, getAssistantBootstrapHints, validateBootstrapPrerequisites, readAssistantStatusFromCli, startAxhubGenieAndWait, resolveRuntimeEndpoints, resolveAssistantRuntime, verifyAssistantHealthEndpoint, }; function buildAgentApiUrl(apiBaseUrl: string): string { const normalized = trimTrailingSlashes(apiBaseUrl); if (/\/api$/i.test(normalized)) { return `${normalized}/agent`; } return `${normalized}/api/agent`; } type AgentStreamNavigation = { sessionId: string; sessionUrl: string; }; function extractAgentStreamError(payload: unknown): string | null { if (!payload || typeof payload !== 'object') { return null; } const record = payload as Record; if (typeof record.error === 'string' && record.error.trim()) { return record.error.trim(); } if (typeof record.message === 'string' && record.message.trim()) { return record.message.trim(); } const data = record.data; if (!data || typeof data !== 'object') { return null; } const nested = data as Record; if (typeof nested.error === 'string' && nested.error.trim()) { return nested.error.trim(); } if (typeof nested.message === 'string' && nested.message.trim()) { return nested.message.trim(); } return null; } function parseSseJsonEvent(rawBlock: string): Record | null { const trimmedBlock = rawBlock.trim(); if (!trimmedBlock) { return null; } const dataLines = trimmedBlock .split(/\r?\n/) .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trim()); if (dataLines.length === 0) { return null; } const payloadText = dataLines.join('\n').trim(); if (!payloadText) { return null; } try { const parsed = JSON.parse(payloadText); if (!parsed || typeof parsed !== 'object') { return null; } return parsed as Record; } catch { return null; } } async function readAgentNavigationFromSse(response: Response, webBaseUrl: string): Promise { if (!response.body) { throw new Error('Agent API 未返回可读取的数据流'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let lastKnownSessionId = ''; let lastKnownSessionUrl = ''; let terminalError: string | null = null; const resolveNavigation = (): AgentStreamNavigation | null => { if (!lastKnownSessionId && !lastKnownSessionUrl) { return null; } const resolvedUrl = lastKnownSessionUrl || (lastKnownSessionId ? `${webBaseUrl}/session/${encodeURIComponent(lastKnownSessionId)}` : ''); if (!resolvedUrl) { return null; } return { sessionId: lastKnownSessionId, sessionUrl: resolvedUrl, }; }; const consumeEvent = (payload: Record) => { const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''; const sessionUrl = typeof payload.sessionUrl === 'string' ? payload.sessionUrl.trim() : ''; if (sessionId) { lastKnownSessionId = sessionId; } if (sessionUrl) { lastKnownSessionUrl = sessionUrl; } const eventType = typeof payload.type === 'string' ? payload.type.trim() : ''; if (eventType === 'session-created' || eventType === 'open-only') { return resolveNavigation(); } if (eventType === 'session-aborted') { terminalError = 'Session aborted'; return null; } if (eventType === 'codex-error' || eventType === 'claude-error' || eventType === 'error') { terminalError = extractAgentStreamError(payload) || terminalError; return null; } return resolveNavigation(); }; try { while (true) { const { done, value } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); let separatorIndex = buffer.search(/\r?\n\r?\n/); while (separatorIndex >= 0) { const rawBlock = buffer.slice(0, separatorIndex); buffer = buffer.slice(separatorIndex + (buffer[separatorIndex] === '\r' ? 4 : 2)); const payload = parseSseJsonEvent(rawBlock); if (payload) { const navigation = consumeEvent(payload); if (navigation) { void reader.cancel().catch(() => undefined); return navigation; } } separatorIndex = buffer.search(/\r?\n\r?\n/); } if (done) { const tailPayload = parseSseJsonEvent(buffer); if (tailPayload) { const navigation = consumeEvent(tailPayload); if (navigation) { return navigation; } } break; } } } finally { reader.releaseLock(); } if (terminalError) { throw new Error(`Agent API 调用失败: ${terminalError}`); } throw new Error('Agent API 返回缺少 sessionUrl/sessionId'); } function quoteForShell(value: string) { return `"${String(value).replace(/["\\$`]/g, '\\$&')}"`; } function quoteForPowerShellSingle(value: string) { return `'${String(value).replace(/'/g, "''")}'`; } function resolveWindowsExecutablePath(candidates: string[]): string | null { for (const candidate of candidates) { const trimmed = candidate.trim(); if (!trimmed) continue; const result = spawnSync('where', [trimmed], { encoding: 'utf8', windowsHide: true, }); if (result.status !== 0) continue; const lines = String(result.stdout || '') .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); if (!lines.length) continue; const exePath = lines.find((line) => /\.exe$/i.test(line)); if (exePath) { return exePath; } const commandWrapper = lines.find((line) => /\.(cmd|bat)$/i.test(line)); if (commandWrapper) { const inferredExePath = commandWrapper.replace(/\.(cmd|bat)$/i, '.exe'); if (fs.existsSync(inferredExePath)) { return inferredExePath; } return commandWrapper; } return lines[0] || null; } return null; } function resolveWindowsExecutableFromRegistry(executableNames: string[]): string | null { const keyRoots = [ 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths', 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths', 'HKLM\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\App Paths', ]; for (const executableName of executableNames) { const normalizedName = executableName.trim(); if (!normalizedName) continue; for (const keyRoot of keyRoots) { const key = `${keyRoot}\\${normalizedName}`; const query = spawnSync('reg', ['query', key, '/ve'], { encoding: 'utf8', windowsHide: true, }); if (query.status !== 0 || query.error) { continue; } const output = String(query.stdout || ''); const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const matchedLine = lines.find((line) => /REG_\w+/i.test(line)); if (!matchedLine) { continue; } const valueMatch = matchedLine.match(/REG_\w+\s+(.+)$/i); if (!valueMatch || !valueMatch[1]) { continue; } const resolvedPath = valueMatch[1].trim().replace(/^"|"$/g, ''); if (resolvedPath && fs.existsSync(resolvedPath)) { return resolvedPath; } } } return null; } function tryOpenWindowsIDEByAppPathNames( appPathNames: string[], targetPath: string, callback: (error: Error | null, stdout?: string | Buffer, stderr?: string | Buffer) => void, ) { const candidates = appPathNames.map((name) => name.trim()).filter(Boolean); const tryNext = (index: number) => { if (index >= candidates.length) { callback(new Error('No compatible Windows app path name found')); return; } execFile( 'powershell', [ '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', `Start-Process -FilePath ${quoteForPowerShellSingle(candidates[index])} -ArgumentList ${quoteForPowerShellSingle(targetPath)} -ErrorAction Stop`, ], { windowsHide: true }, (error, stdout, stderr) => { if (!error) { callback(null, stdout, stderr); return; } tryNext(index + 1); }, ); }; tryNext(0); } function buildProjectInfoSection( projectInfo: ProjectInfo, projectDefaults: ProjectDefaults, hasProjectOverviewDoc: boolean ): string { const lines: string[] = []; const projectName = normalizeInlineText(projectInfo.name); const projectDescription = normalizeInlineText(projectInfo.description); const defaultTheme = normalizeOptionalString(projectDefaults.defaultTheme); if (projectName) lines.push(`- 项目名称:${projectName}`); if (projectDescription) lines.push(`- 项目简介:${projectDescription}`); if (hasProjectOverviewDoc) lines.push(`- 项目总文档:\`${PROJECT_OVERVIEW_DOC_RELATIVE_PATH}\``); if (defaultTheme) lines.push(`- 默认主题:\`src/themes/${defaultTheme}\``); if (!lines.length) return ''; return ['## 📌 项目信息', '', ...lines].join('\n'); } function renderAgentsTemplate( template: string, projectInfo: ProjectInfo, projectDefaults: ProjectDefaults, hasProjectOverviewDoc: boolean ) { const projectInfoSection = buildProjectInfoSection(projectInfo, projectDefaults, hasProjectOverviewDoc); let content = template; if (content.includes('{{PROJECT_INFO_SECTION}}')) { content = content.replace('{{PROJECT_INFO_SECTION}}', projectInfoSection); return content; } const sectionRegex = /^## 📌 项目信息[\s\S]*?(?=^##\s|\s*$)/m; if (sectionRegex.test(content)) { return content.replace(sectionRegex, projectInfoSection); } return content; } function writeAgentDocs( templatePath: string, agentsPath: string, claudePath: string, projectInfo: ProjectInfo, projectDefaults: ProjectDefaults ): boolean { if (!fs.existsSync(templatePath)) return false; const template = fs.readFileSync(templatePath, 'utf8'); const projectRoot = path.dirname(templatePath); const projectOverviewDocPath = path.resolve(projectRoot, PROJECT_OVERVIEW_DOC_RELATIVE_PATH); const nextAgentsContent = renderAgentsTemplate( template, projectInfo, projectDefaults, fs.existsSync(projectOverviewDocPath) ); fs.writeFileSync(agentsPath, nextAgentsContent, 'utf8'); fs.writeFileSync(claudePath, nextAgentsContent, 'utf8'); return true; } function syncAgentDocsFromConfig(paths: AgentDocsPaths): void { const { configPath, agentsTemplatePath, agentsPath, claudePath } = paths; let config: SystemConfig = { server: { host: 'localhost', allowLAN: true } }; if (fs.existsSync(configPath)) { const fileContent = fs.readFileSync(configPath, 'utf8'); config = JSON.parse(fileContent); } const projectDefaults = normalizeProjectDefaults(config.projectDefaults); const projectInfo = normalizeProjectInfo(config.projectInfo); writeAgentDocs(agentsTemplatePath, agentsPath, claudePath, projectInfo, projectDefaults); } /** * 配置管理 API 插件 * 提供配置文件的读取和保存功能 */ export function configApiPlugin(): Plugin { const projectRoot = path.resolve(__dirname, '..'); const configPath = path.resolve(projectRoot, '.axhub', 'make', 'axhub.config.json'); const agentsPath = path.resolve(projectRoot, 'AGENTS.md'); const claudePath = path.resolve(projectRoot, 'CLAUDE.md'); const agentsTemplatePath = path.resolve(projectRoot, 'AGENTS.template.md'); return { name: 'config-api-plugin', configureServer(server: any) { try { syncAgentDocsFromConfig({ configPath, agentsTemplatePath, agentsPath, claudePath }); } catch (e: any) { console.warn('Failed to sync AGENTS.md on server start:', e?.message || e); } // GET /api/config - 读取配置 server.middlewares.use(async (req: any, res: any, next: any) => { if (req.method === 'GET' && req.url === '/api/config') { try { const config = readSystemConfig(configPath); // 移除 port 字段(不对外暴露,固定使用 51720 起始) if (config.server && 'port' in config.server) { delete config.server.port; } res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ ...config, projectPath: projectRoot, })); } catch (e: any) { console.error('Error reading config:', e); res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: e?.message || 'Failed to read config' })); } return; } if (req.method === 'GET' && req.url?.startsWith('/api/assistant/runtime')) { try { const url = new URL(req.url, 'http://localhost'); if (url.pathname !== '/api/assistant/runtime') { next(); return; } const autoStartParam = (url.searchParams.get('autoStart') || '').trim().toLowerCase(); const shouldAutoStart = !(autoStartParam === '0' || autoStartParam === 'false' || autoStartParam === 'no'); const config = readSystemConfig(configPath); const runtime = await resolveAssistantRuntime(config, projectRoot, { autoStart: shouldAutoStart, }); // When accessed via LAN, rewrite localhost URLs so remote clients can reach the API const requestHost = String(req.headers?.host || '').split(':')[0]; if (requestHost && requestHost !== 'localhost' && requestHost !== '127.0.0.1') { const rewriteLocalhostUrl = (urlStr: string): string => { try { const parsed = new URL(urlStr); if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') { parsed.hostname = requestHost; return parsed.toString().replace(/\/+$/, ''); } } catch { /* keep original */ } return urlStr; }; if (runtime.apiBaseUrl) runtime.apiBaseUrl = rewriteLocalhostUrl(runtime.apiBaseUrl); if (runtime.webBaseUrl) runtime.webBaseUrl = rewriteLocalhostUrl(runtime.webBaseUrl); } res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(runtime)); } catch (e: any) { const requestHost = String(req.headers?.host || '').split(':')[0]; const fallbackApiHost = (requestHost && requestHost !== 'localhost' && requestHost !== '127.0.0.1') ? requestHost : 'localhost'; const fallbackWebHost = fallbackApiHost; const fallback: AssistantRuntimeInfo = { webBaseUrl: `http://${fallbackWebHost}:${DEFAULT_ASSISTANT_WEB_BASE_URL.match(/:(\d+)/)?.[1] || '32123'}`, apiBaseUrl: `http://${fallbackApiHost}:32123/api`, projectPath: projectRoot, source: 'default', health: createAssistantHealthInfo({ status: 'runtime_unreachable', message: '助手运行时检查失败,请稍后重试', commandSource: 'default', }), }; res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(fallback)); } return; } if (req.method === 'POST' && req.url === '/api/assistant/bootstrap') { const chunks: Buffer[] = []; let totalLength = 0; req.on('data', (chunk: Buffer) => { totalLength += chunk.length; if (totalLength > 1024 * 10) { res.statusCode = 413; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Payload too large' })); req.destroy(); return; } chunks.push(chunk); }); req.on('end', async () => { try { const raw = Buffer.concat(chunks).toString('utf8'); const body = raw ? JSON.parse(raw) : {}; const mode = normalizeAssistantBootstrapMode(body?.mode); if (!mode) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Invalid bootstrap mode', hints: getAssistantBootstrapHints() })); return; } const prerequisiteError = validateBootstrapPrerequisites(mode); if (prerequisiteError) { res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: prerequisiteError, hints: getAssistantBootstrapHints() })); return; } await runAssistantBootstrap(mode, projectRoot); const config = readSystemConfig(configPath); const runtime = await resolveAssistantRuntime(config, projectRoot); res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ success: true, mode, message: '已触发启动,请稍后重试', runtime, })); } catch (e: any) { res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: e?.message || '无法自动启动 Axhub Genie', hints: getAssistantBootstrapHints(), })); } }); return; } // POST /api/config - 保存配置 if (req.method === 'POST' && req.url === '/api/config') { const chunks: Buffer[] = []; let totalLength = 0; req.on('data', (chunk: Buffer) => { totalLength += chunk.length; if (totalLength > 1024 * 10) { // 10KB 限制 res.statusCode = 413; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Payload too large' })); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { try { const raw = Buffer.concat(chunks).toString('utf8'); const newConfig: SystemConfig = JSON.parse(raw); // 验证配置格式 if (!newConfig.server || typeof newConfig.server !== 'object') { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Invalid config format' })); return; } // 移除 port 字段(不允许配置,固定使用 51720 起始) if (newConfig.server && 'port' in newConfig.server) { delete newConfig.server.port; } // 校验/归一化 projectDefaults const projectDefaults = normalizeProjectDefaults(newConfig.projectDefaults); const projectInfo = normalizeProjectInfo(newConfig.projectInfo); const automation = normalizeAutomationConfig(newConfig.automation); const assistant = normalizeAssistantConfig(newConfig.assistant); newConfig.projectDefaults = projectDefaults; newConfig.projectInfo = projectInfo; newConfig.automation = automation; newConfig.assistant = assistant; // 使用模板生成 AGENTS.md(项目参考规范) if (!writeAgentDocs(agentsTemplatePath, agentsPath, claudePath, projectInfo, projectDefaults)) { console.warn('AGENTS.template.md not found, skip regenerating AGENTS.md'); } // 保存配置文件 fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), 'utf8'); res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ success: true, message: '配置已保存(已根据模板同步 AGENTS.md)' })); } catch (e: any) { console.error('Error saving config:', e); res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: e?.message || 'Failed to save config' })); } }); return; } if (req.method === 'POST' && req.url === '/api/ide/open') { const chunks: Buffer[] = []; let totalLength = 0; req.on('data', (chunk: Buffer) => { totalLength += chunk.length; if (totalLength > 1024 * 10) { res.statusCode = 413; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Payload too large' })); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { try { const raw = Buffer.concat(chunks).toString('utf8'); const body = JSON.parse(raw || '{}'); let configuredIDE: MainIDE | null = null; if (fs.existsSync(configPath)) { const fileContent = fs.readFileSync(configPath, 'utf8'); const savedConfig = JSON.parse(fileContent) as SystemConfig; configuredIDE = normalizeMainIDE(savedConfig?.automation?.defaultIDE); } const ide = normalizeMainIDE(body?.ide) || configuredIDE; if (!ide) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Main IDE is not configured' })); return; } const rawTargetPath = typeof body?.targetPath === 'string' ? body.targetPath.trim() : ''; const targetPath = rawTargetPath ? rawTargetPath : projectRoot; const absoluteTargetPath = path.isAbsolute(targetPath) ? targetPath : path.resolve(projectRoot, targetPath); const ideAppName = MAIN_IDE_APP_NAMES[ide]; const windowsAppPathNames = MAIN_IDE_WINDOWS_APP_PATH_NAMES[ide] || [ideAppName]; const command = process.platform === 'win32' ? `powershell -NoProfile -Command Start-Process -FilePath ${quoteForPowerShellSingle(windowsAppPathNames[0] || ideAppName)} -ArgumentList ${quoteForPowerShellSingle(absoluteTargetPath)} -ErrorAction Stop` : `open -a ${quoteForShell(ideAppName)} ${quoteForShell(absoluteTargetPath)}`; const handleOpenCommandResult = (error: Error | null, _stdout?: string | Buffer, stderr?: string | Buffer) => { const stderrText = typeof stderr === 'string' ? stderr.trim() : Buffer.isBuffer(stderr) ? stderr.toString('utf8').trim() : ''; if (error && stderrText) { res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: `打开 ${ideAppName} 失败: ${stderrText || error.message}` })); return; } res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ success: true, ide, targetPath: absoluteTargetPath, command, })); }; if (process.platform === 'win32') { const executableCandidates = [ ...(MAIN_IDE_WINDOWS_COMMAND_CANDIDATES[ide] || []), ideAppName, ]; const executableNameCandidates = MAIN_IDE_WINDOWS_EXECUTABLE_NAMES[ide] || []; const executablePath = resolveWindowsExecutablePath(executableCandidates) || resolveWindowsExecutablePath(executableNameCandidates) || resolveWindowsExecutableFromRegistry(executableNameCandidates); if (executablePath) { const spawnSpec = getSpawnCommandSpec(executablePath, [absoluteTargetPath], process.platform); const child = spawn(spawnSpec.command, spawnSpec.args, { detached: true, stdio: 'ignore', windowsHide: spawnSpec.windowsHide, shell: false, }); child.once('error', (spawnError) => { res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: `打开 ${ideAppName} 失败: ${spawnError.message}` })); }); child.once('spawn', () => { child.unref(); res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ success: true, ide, targetPath: absoluteTargetPath, command: `${quoteForShell(executablePath)} ${quoteForShell(absoluteTargetPath)}`, })); }); return; } tryOpenWindowsIDEByAppPathNames( windowsAppPathNames, absoluteTargetPath, handleOpenCommandResult, ); } else { exec(command, handleOpenCommandResult); } } catch (e: any) { res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: e?.message || 'Failed to open IDE' })); } }); return; } if (req.method === 'POST' && req.url === '/api/prompt/execute') { const chunks: Buffer[] = []; let totalLength = 0; req.on('data', (chunk: Buffer) => { totalLength += chunk.length; if (totalLength > 1024 * 1024) { res.statusCode = 413; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Payload too large' })); req.destroy(); return; } chunks.push(chunk); }); req.on('end', async () => { try { const raw = Buffer.concat(chunks).toString('utf8'); const body = JSON.parse(raw || '{}'); const client = normalizeExecutePromptClient(body?.client); const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''; const scene = typeof body?.scene === 'string' ? body.scene.trim() : ''; if (!client) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Invalid client' })); return; } if (!prompt) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Prompt is required' })); return; } if (!scene) { res.statusCode = 400; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: 'Scene is required' })); return; } const config = readSystemConfig(configPath); const assistantRuntime = await resolveAssistantRuntime(config, projectRoot); if (assistantRuntime.health.status !== 'ready') { throw new Error(`${assistantRuntime.health.message}。可尝试:${getAssistantHealthHints().installGlobal}`); } const provider = client; const agentApiUrl = buildAgentApiUrl(assistantRuntime.apiBaseUrl); const upstreamResponse = await fetch(agentApiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream, application/json', }, body: JSON.stringify({ projectPath: assistantRuntime.projectPath, provider, message: prompt, stream: true, }), }); if (!upstreamResponse.ok) { const upstreamText = await upstreamResponse.text(); let upstreamData: any = null; try { upstreamData = upstreamText ? JSON.parse(upstreamText) : null; } catch { upstreamData = null; } const upstreamError = upstreamData?.error || upstreamData?.message || upstreamText || `status ${upstreamResponse.status}`; throw new Error(`Agent API 调用失败: ${upstreamError}`); } const navigation = await readAgentNavigationFromSse(upstreamResponse, assistantRuntime.webBaseUrl); res.statusCode = 200; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ success: true, url: navigation.sessionUrl, sessionId: navigation.sessionId, scene, provider, })); } catch (e: any) { res.statusCode = 500; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify({ error: e?.message || 'Failed to execute prompt' })); } }); return; } next(); }); } }; }