import type { IncomingMessage, ServerResponse } from 'http'; import { dialog, nativeImage } from 'electron'; import crypto from 'node:crypto'; import { extname, join } from 'node:path'; import { homedir } from 'node:os'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { readProjectConfig } from '../../opencode/project-config'; import { loadGameAssetCandidates } from '../../opencode/game-asset-browser'; import { GameAssetReviewConflictError, loadGameAssetReview, recordGameAssetReviewActions, recordGameAssetReviewAction, } from '../../opencode/game-asset-review'; const EXT_MIME_MAP: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac', '.pdf': 'application/pdf', '.zip': 'application/zip', '.gz': 'application/gzip', '.tar': 'application/x-tar', '.7z': 'application/x-7z-compressed', '.rar': 'application/vnd.rar', '.json': 'application/json', '.xml': 'application/xml', '.csv': 'text/csv', '.txt': 'text/plain', '.md': 'text/markdown', '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.ts': 'text/typescript', '.py': 'text/x-python', }; function getMimeType(ext: string): string { return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream'; } function mimeToExt(mimeType: string): string { for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) { if (mime === mimeType) return ext; } return ''; } function parseCandidateIds(value: string | null): string[] | null { if (value === null) return null; try { const parsed = JSON.parse(value) as unknown; if (!Array.isArray(parsed)) return []; return [...new Set(parsed.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean))]; } catch { return []; } } const OUTBOUND_DIR = join(homedir(), '.niancode', 'media', 'outbound'); async function generateImagePreview(filePath: string, mimeType: string): Promise { try { const img = nativeImage.createFromPath(filePath); if (img.isEmpty()) return null; const size = img.getSize(); const maxDim = 512; if (size.width > maxDim || size.height > maxDim) { const resized = size.width >= size.height ? img.resize({ width: maxDim }) : img.resize({ height: maxDim }); return `data:image/png;base64,${resized.toPNG().toString('base64')}`; } const { readFile } = await import('node:fs/promises'); const buf = await readFile(filePath); return `data:${mimeType};base64,${buf.toString('base64')}`; } catch { return null; } } /** * Resolve a runtime-emitted outgoing-media URL to the original file on disk. * Mirror of `electron/main/ipc-handlers.ts::resolveOutgoingMediaUrl` 鈥?kept * in sync so the host-api HTTP path serves the same data as the IPC path. */ async function resolveOutgoingMediaUrl( runtimeUrl: string, ): Promise<{ path: string; mimeType: string } | null> { try { const m = runtimeUrl.match(/\/api\/chat\/media\/outgoing\/[^/]+\/([^/]+)\//); if (!m) return null; const attachmentId = decodeURIComponent(m[1]); if (!/^[A-Za-z0-9._-]+$/.test(attachmentId)) return null; const recordPath = join(homedir(), '.niancode', 'media', 'outgoing', 'records', `${attachmentId}.json`); const fsP = await import('node:fs/promises'); const raw = await fsP.readFile(recordPath, 'utf8'); const record = JSON.parse(raw) as { original?: { path?: string; contentType?: string }; }; const original = record?.original; if (!original?.path) return null; return { path: original.path, mimeType: typeof original.contentType === 'string' && original.contentType ? original.contentType : 'application/octet-stream', }; } catch { return null; } } export async function handleFileRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { if (url.pathname === '/api/files/game-asset-candidates' && req.method === 'GET') { try { const activeProject = await ctx.opencodeProjectStore.getActiveProject(); if (!activeProject) { sendJson(res, 409, { success: false, error: 'No active project selected' }); return true; } const config = await readProjectConfig(activeProject.path); if (config.status !== 'valid' || !config.config.initialized) { sendJson(res, 403, { success: false, error: 'Game asset browsing is unavailable for an uninitialized project' }); return true; } sendJson(res, 200, { assets: await loadGameAssetCandidates(activeProject.path) }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname === '/api/files/game-asset-review' && (req.method === 'GET' || req.method === 'POST')) { try { const activeProject = await ctx.opencodeProjectStore.getActiveProject(); if (!activeProject) { sendJson(res, 409, { success: false, error: 'No active project selected' }); return true; } const config = await readProjectConfig(activeProject.path); if (config.status !== 'valid' || !config.config.initialized) { sendJson(res, 403, { success: false, error: 'Game asset review is unavailable for an uninitialized project' }); return true; } if (req.method === 'GET') { const invocationId = url.searchParams.get('invocationId')?.trim() ?? ''; if (!invocationId) { sendJson(res, 400, { success: false, error: 'invocationId is required' }); return true; } const allAssets = await loadGameAssetCandidates(activeProject.path); const submittedIds = parseCandidateIds(url.searchParams.get('candidateIds')); const planCandidateIds = allAssets.map((asset) => asset.id); const requestedIds = submittedIds === null || submittedIds.length === 0 ? planCandidateIds : submittedIds; const matchedIds = requestedIds.filter((id) => allAssets.some((asset) => asset.id === id)); const candidateIds = submittedIds !== null && submittedIds.length > 0 && matchedIds.length === 0 ? planCandidateIds : matchedIds; const review = await loadGameAssetReview(activeProject.path, invocationId, candidateIds); const pendingIds = new Set(review.pendingAssetIds); sendJson(res, 200, { review, assets: allAssets.filter((asset) => pendingIds.has(asset.id)), }); return true; } const body = await parseJsonBody<{ invocationId?: string; assetId?: string; action?: 'approve' | 'discard' | 'replace'; candidateIds?: string[]; decisions?: Array<{ assetId?: string; action?: 'approve' | 'discard' | 'replace' }>; }>(req); if (!body.invocationId) { sendJson(res, 400, { success: false, error: 'invocationId is required' }); return true; } if (Array.isArray(body.decisions)) { if (body.decisions.length === 0) { sendJson(res, 400, { success: false, error: 'decisions must not be empty' }); return true; } if (body.decisions.some((decision) => !decision.assetId || !decision.action)) { sendJson(res, 400, { success: false, error: 'every decision requires assetId and action' }); return true; } const review = await recordGameAssetReviewActions(activeProject.path, { invocationId: body.invocationId, candidateIds: Array.isArray(body.candidateIds) ? body.candidateIds : [], decisions: body.decisions.map((decision) => ({ assetId: decision.assetId as string, action: decision.action as 'approve' | 'discard' | 'replace', })), }); sendJson(res, 200, { success: true, review }); return true; } if (!body.assetId || !body.action) { sendJson(res, 400, { success: false, error: 'assetId and action are required' }); return true; } const review = await recordGameAssetReviewAction(activeProject.path, { invocationId: body.invocationId ?? '', assetId: body.assetId, action: body.action, candidateIds: Array.isArray(body.candidateIds) ? body.candidateIds : [], }); sendJson(res, 200, { success: true, review }); } catch (error) { if (error instanceof GameAssetReviewConflictError) { sendJson(res, error.statusCode, { success: false, error: error.message }); } else { sendJson(res, 500, { success: false, error: String(error) }); } } return true; } if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') { try { const body = await parseJsonBody<{ filePaths: string[] }>(req); const fsP = await import('node:fs/promises'); await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); const results = []; for (const filePath of body.filePaths) { const id = crypto.randomUUID(); const ext = extname(filePath); const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); await fsP.copyFile(filePath, stagedPath); const s = await fsP.stat(stagedPath); const mimeType = getMimeType(ext); const fileName = filePath.split(/[\\/]/).pop() || 'file'; const preview = mimeType.startsWith('image/') ? await generateImagePreview(stagedPath, mimeType) : null; results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview }); } sendJson(res, 200, results); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') { try { const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req); const fsP = await import('node:fs/promises'); await fsP.mkdir(OUTBOUND_DIR, { recursive: true }); const id = crypto.randomUUID(); const ext = extname(body.fileName) || mimeToExt(body.mimeType); const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`); const buffer = Buffer.from(body.base64, 'base64'); await fsP.writeFile(stagedPath, buffer); const mimeType = body.mimeType || getMimeType(ext); const preview = mimeType.startsWith('image/') ? await generateImagePreview(stagedPath, mimeType) : null; sendJson(res, 200, { id, fileName: body.fileName, mimeType, fileSize: buffer.length, stagedPath, preview, }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') { try { const body = await parseJsonBody<{ paths: Array<{ filePath?: string; runtimeUrl?: string; mimeType: string }>; }>(req); const fsP = await import('node:fs/promises'); const results: Record = {}; for (const entry of body.paths) { if (entry.filePath) { try { const s = await fsP.stat(entry.filePath); const preview = entry.mimeType.startsWith('image/') ? await generateImagePreview(entry.filePath, entry.mimeType) : null; results[entry.filePath] = { preview, fileSize: s.size }; } catch { results[entry.filePath] = { preview: null, fileSize: 0 }; } continue; } if (entry.runtimeUrl) { const resolved = await resolveOutgoingMediaUrl(entry.runtimeUrl); if (!resolved) { results[entry.runtimeUrl] = { preview: null, fileSize: 0 }; continue; } try { const s = await fsP.stat(resolved.path); const preview = resolved.mimeType.startsWith('image/') ? await generateImagePreview(resolved.path, resolved.mimeType) : null; results[entry.runtimeUrl] = { preview, fileSize: s.size }; } catch { results[entry.runtimeUrl] = { preview: null, fileSize: 0 }; } } } sendJson(res, 200, results); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname === '/api/files/save-image' && req.method === 'POST') { try { const body = await parseJsonBody<{ base64?: string; mimeType?: string; filePath?: string; defaultFileName: string; }>(req); const ext = body.defaultFileName.includes('.') ? body.defaultFileName.split('.').pop()! : (body.mimeType?.split('/')[1] || 'png'); const result = await dialog.showSaveDialog({ defaultPath: join(homedir(), 'Downloads', body.defaultFileName), filters: [ { name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] }, { name: 'All Files', extensions: ['*'] }, ], }); if (result.canceled || !result.filePath) { sendJson(res, 200, { success: false }); return true; } const fsP = await import('node:fs/promises'); if (body.filePath) { await fsP.copyFile(body.filePath, result.filePath); } else if (body.base64) { await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64')); } else { sendJson(res, 400, { success: false, error: 'No image data provided' }); return true; } sendJson(res, 200, { success: true, savedPath: result.filePath }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } return false; }