feat: implement PI core chat timeline
This commit is contained in:
@@ -9,6 +9,8 @@ export function shouldUseLoopbackHostApi(path: string, method = 'GET'): boolean
|
||||
if (pathname === '/api/events'
|
||||
|| pathname === '/api/opencode/events'
|
||||
|| pathname === '/api/coding/events') return true;
|
||||
if (pathname === '/api/coding/attachments'
|
||||
|| pathname.startsWith('/api/coding/attachments/')) return true;
|
||||
if (pathname.startsWith('/api/ai-proxy/')) return true;
|
||||
if (pathname.includes('/events') && pathname.startsWith('/api/image-workspace/')) return true;
|
||||
if (
|
||||
|
||||
@@ -18,6 +18,7 @@ import { handleFileRoutes } from './routes/files';
|
||||
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
|
||||
import { handleAgentBrowserRoutes } from './routes/agent-browser';
|
||||
import { handleCodingFileRoutes } from './routes/coding-files';
|
||||
import { handleCodingAttachmentRoutes } from './routes/coding-attachments';
|
||||
import { handleCodingProjectRoutes } from './routes/coding-projects';
|
||||
import { handleCodingConversationRoutes } from './routes/coding-conversations';
|
||||
|
||||
@@ -45,6 +46,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
handleUserSyncRoutes,
|
||||
handleCodingAttachmentRoutes,
|
||||
handleCodingProjectRoutes,
|
||||
handleCodingConversationRoutes,
|
||||
handleCodingFileRoutes,
|
||||
|
||||
109
electron/api/routes/coding-attachments.ts
Normal file
109
electron/api/routes/coding-attachments.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
|
||||
const SUPPORTED_IMAGE_MIMES = new Set([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]);
|
||||
|
||||
function contentType(req: IncomingMessage): string {
|
||||
const value = req.headers['content-type'];
|
||||
return (Array.isArray(value) ? value[0] : value)?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
|
||||
async function readBoundedBody(req: IncomingMessage): Promise<Uint8Array> {
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (Number.isFinite(declared) && (declared <= 0 || declared > MAX_ATTACHMENT_BYTES)) {
|
||||
throw Object.assign(new Error('Attachment size is invalid'), { status: 413 });
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let byteLength = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
byteLength += buffer.byteLength;
|
||||
if (byteLength > MAX_ATTACHMENT_BYTES) {
|
||||
throw Object.assign(new Error('Attachment size is invalid'), { status: 413 });
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
if (byteLength === 0) throw Object.assign(new Error('Attachment is empty'), { status: 400 });
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function sendAttachmentError(res: ServerResponse, error: unknown): void {
|
||||
const status = typeof (error as { status?: unknown })?.status === 'number'
|
||||
? (error as { status: number }).status
|
||||
: (error as NodeJS.ErrnoException)?.code === 'ENOENT'
|
||||
? 404
|
||||
: 500;
|
||||
const message = status === 413
|
||||
? '图片不能超过 16 MB。'
|
||||
: status === 404
|
||||
? '图片附件不存在。'
|
||||
: status === 400
|
||||
? '图片附件无效。'
|
||||
: '图片附件暂时无法读取。';
|
||||
sendJson(res, status, {
|
||||
success: false,
|
||||
code: status === 413
|
||||
? 'CODING_ATTACHMENT_TOO_LARGE'
|
||||
: status === 404
|
||||
? 'CODING_ATTACHMENT_NOT_FOUND'
|
||||
: status === 400
|
||||
? 'CODING_ATTACHMENT_INVALID'
|
||||
: 'CODING_ATTACHMENT_STORAGE_FAILED',
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCodingAttachmentRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
const upload = url.pathname === '/api/coding/attachments' && req.method === 'POST';
|
||||
const contentMatch = url.pathname.match(/^\/api\/coding\/attachments\/([^/]+)\/content$/);
|
||||
const content = Boolean(contentMatch && req.method === 'GET');
|
||||
if (!upload && !content) return false;
|
||||
const attachments = ctx.codingProducts?.attachments;
|
||||
if (!attachments) {
|
||||
sendJson(res, 503, {
|
||||
success: false,
|
||||
code: 'CODING_CORE_UNAVAILABLE',
|
||||
error: '本地编程服务暂时不可用。',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (upload) {
|
||||
const mime = contentType(req);
|
||||
if (!SUPPORTED_IMAGE_MIMES.has(mime)) {
|
||||
throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 });
|
||||
}
|
||||
const attachment = await attachments.put(await readBoundedBody(req), mime);
|
||||
sendJson(res, 201, attachment);
|
||||
return true;
|
||||
}
|
||||
|
||||
const attachmentId = decodeURIComponent(contentMatch?.[1] ?? '');
|
||||
const record = await attachments.read(attachmentId);
|
||||
if (!SUPPORTED_IMAGE_MIMES.has(record.mime)) {
|
||||
throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 });
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', record.mime);
|
||||
res.setHeader('Content-Length', String(record.byteLength));
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
res.end(record.data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
sendAttachmentError(res, error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export async function handleCodingConversationRoutes(
|
||||
res,
|
||||
event.type,
|
||||
event,
|
||||
`${event.conversationId}:${event.workerGeneration}:${event.seq}`,
|
||||
`${event.conversationId}:${event.workerGeneration}:${event.toSeq}`,
|
||||
)) break;
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user