Makelore 2.0 initial clean snapshot
This commit is contained in:
commit
b8ca3f8eea
694 files changed
+139782
No files matched your search
@@ -0,0 +1,373 @@
|
||||
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<string, string> = {
|
||||
'.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<string | null> {
|
||||
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<boolean> {
|
||||
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 || config.config.templateId !== 'game-development') {
|
||||
sendJson(res, 403, { success: false, error: 'Game asset browsing is unavailable for this project template' });
|
||||
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 || config.config.templateId !== 'game-development') {
|
||||
sendJson(res, 403, { success: false, error: 'Game asset review is unavailable for this project template' });
|
||||
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<string, { preview: string | null; fileSize: number }> = {};
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user