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 {
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { CodingConversationMetadata } from '../../shared/coding-project-contracts';
|
||||
import type { ConversationModelState, ProductModelRef } from '../coding-runtime/contracts';
|
||||
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
|
||||
import { normalizeProductModelRef } from './project-config';
|
||||
|
||||
export const CODING_CONVERSATIONS_PATH = '.niancode/conversations.json';
|
||||
|
||||
export interface CodingConversationV2 extends ConversationModelState {
|
||||
id: string;
|
||||
agentId: string;
|
||||
title: string;
|
||||
export interface CodingConversationV2 extends CodingConversationMetadata, ConversationModelState {
|
||||
piSessionId?: string;
|
||||
sessionKey?: string;
|
||||
archivedAt: string | null;
|
||||
unread: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CodingConversationFileV2 {
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
type ProjectAgentResponsibility,
|
||||
type ProjectType,
|
||||
} from '../../shared/project-config';
|
||||
import type {
|
||||
CodingProjectAgent,
|
||||
CodingProjectConfig,
|
||||
} from '../../shared/coding-project-contracts';
|
||||
import type {
|
||||
ConversationModelState,
|
||||
ConversationThinkingLevel,
|
||||
@@ -17,32 +21,11 @@ export const CODING_PROJECT_CONFIG_PATH = '.niancode/project.json';
|
||||
export const LEGACY_CONVERSATION_NOTICE_VALUES = ['none', 'pending', 'acknowledged'] as const;
|
||||
export type LegacyConversationNotice = (typeof LEGACY_CONVERSATION_NOTICE_VALUES)[number];
|
||||
|
||||
export interface CodingProjectAgentV2 extends ConversationModelState {
|
||||
id: string;
|
||||
avatarId: string;
|
||||
avatarDataUrl?: string;
|
||||
roleName: string;
|
||||
name: string;
|
||||
builtIn: boolean;
|
||||
enabled: boolean;
|
||||
skillIds: string[];
|
||||
responsibility: ProjectAgentResponsibility;
|
||||
prompt: string;
|
||||
archivedAt: string | null;
|
||||
pinned: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface CodingProjectAgentV2 extends CodingProjectAgent, ConversationModelState {}
|
||||
|
||||
export interface CodingProjectConfigV2 {
|
||||
schemaVersion: 2;
|
||||
projectType: ProjectType;
|
||||
initialized: boolean;
|
||||
export interface CodingProjectConfigV2 extends CodingProjectConfig {
|
||||
agents: CodingProjectAgentV2[];
|
||||
knowledgeDirectory: 'knowledge';
|
||||
legacyConversationNotice: LegacyConversationNotice;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type CodingProjectConfigReadResult =
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { ProjectType } from '../../shared/project-config';
|
||||
import type { CodingProjectSummary } from '../../shared/coding-project-contracts';
|
||||
import {
|
||||
createCodingProjectMetadata,
|
||||
type CodingProjectConfigV2,
|
||||
} from './project-config';
|
||||
|
||||
export interface CodingProject {
|
||||
id: string;
|
||||
export interface CodingProject extends CodingProjectSummary {
|
||||
path: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastOpenedAt: string;
|
||||
}
|
||||
|
||||
export interface CodingProjectStoreData {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CodingConversationPatchBatchEvent,
|
||||
CodingConversationRuntime,
|
||||
CodingRuntimeCommand,
|
||||
CodingRuntimeDiagnostics,
|
||||
@@ -49,6 +50,9 @@ export interface CodingConversationServiceOptions {
|
||||
projectId: string;
|
||||
sessionKey: string;
|
||||
}): Promise<void>;
|
||||
deliveryBatchWindowMs?: number;
|
||||
deliveryBatchMaxItems?: number;
|
||||
deliveryBatchMaxBytes?: number;
|
||||
}
|
||||
|
||||
export type CodingConversationStreamEvent =
|
||||
@@ -59,7 +63,7 @@ export type CodingConversationStreamEvent =
|
||||
seq: number;
|
||||
snapshot: ConversationSnapshot;
|
||||
}
|
||||
| ({ type: 'patch' } & ConversationPatchEnvelope);
|
||||
| CodingConversationPatchBatchEvent;
|
||||
|
||||
export interface CodingConversationEventStream {
|
||||
snapshots: ConversationSnapshot[];
|
||||
@@ -160,6 +164,100 @@ class PatchQueue implements AsyncIterable<CodingConversationStreamEvent> {
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingPatchBatch {
|
||||
conversationId: string;
|
||||
workerGeneration: number;
|
||||
items: CodingConversationPatchBatchEvent['items'];
|
||||
byteLength: number;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
class PatchBatchScheduler {
|
||||
private readonly pending = new Map<string, PendingPatchBatch>();
|
||||
private readonly windowMs: number;
|
||||
private readonly maxItems: number;
|
||||
private readonly maxBytes: number;
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
private readonly publish: (event: CodingConversationPatchBatchEvent) => void,
|
||||
options: CodingConversationServiceOptions,
|
||||
) {
|
||||
this.windowMs = Math.min(33, Math.max(16, options.deliveryBatchWindowMs ?? 24));
|
||||
this.maxItems = Math.max(1, options.deliveryBatchMaxItems ?? 128);
|
||||
this.maxBytes = Math.max(1, options.deliveryBatchMaxBytes ?? 256 * 1024);
|
||||
}
|
||||
|
||||
push(envelope: ConversationPatchEnvelope): void {
|
||||
if (this.closed) return;
|
||||
const key = this.key(envelope.conversationId, envelope.workerGeneration);
|
||||
const item = {
|
||||
...(envelope.runId ? { runId: envelope.runId } : {}),
|
||||
seq: envelope.seq,
|
||||
at: envelope.at,
|
||||
patch: envelope.patch,
|
||||
};
|
||||
const itemBytes = Buffer.byteLength(JSON.stringify(item), 'utf8');
|
||||
let batch = this.pending.get(key);
|
||||
if (batch && batch.items.length > 0
|
||||
&& (batch.items.length >= this.maxItems || batch.byteLength + itemBytes > this.maxBytes)) {
|
||||
this.flush(key);
|
||||
batch = undefined;
|
||||
}
|
||||
if (!batch) {
|
||||
batch = {
|
||||
conversationId: envelope.conversationId,
|
||||
workerGeneration: envelope.workerGeneration,
|
||||
items: [],
|
||||
byteLength: 0,
|
||||
timer: setTimeout(() => this.flush(key), this.windowMs),
|
||||
};
|
||||
this.pending.set(key, batch);
|
||||
}
|
||||
batch.items.push(item);
|
||||
batch.byteLength += itemBytes;
|
||||
if (batch.items.length >= this.maxItems || batch.byteLength >= this.maxBytes) {
|
||||
this.flush(key);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(conversationId: string): void {
|
||||
for (const [key, batch] of this.pending) {
|
||||
if (batch.conversationId !== conversationId) continue;
|
||||
clearTimeout(batch.timer);
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
for (const batch of this.pending.values()) clearTimeout(batch.timer);
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private key(conversationId: string, workerGeneration: number): string {
|
||||
return `${conversationId}\u0000${workerGeneration}`;
|
||||
}
|
||||
|
||||
private flush(key: string): void {
|
||||
const batch = this.pending.get(key);
|
||||
if (!batch || this.closed) return;
|
||||
this.pending.delete(key);
|
||||
clearTimeout(batch.timer);
|
||||
const first = batch.items[0];
|
||||
const last = batch.items.at(-1);
|
||||
if (!first || !last) return;
|
||||
this.publish({
|
||||
type: 'patch-batch',
|
||||
conversationId: batch.conversationId,
|
||||
workerGeneration: batch.workerGeneration,
|
||||
fromSeq: first.seq,
|
||||
toSeq: last.seq,
|
||||
items: batch.items,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CodingConversationService {
|
||||
private readonly prepareFlights = new Map<string, Promise<PrepareConversationInput>>();
|
||||
private readonly acceptances = new Map<string, AcceptanceRecord>();
|
||||
@@ -466,6 +564,7 @@ export class CodingConversationService {
|
||||
: requiredString(conversationId, 'Conversation id', 128);
|
||||
if (id) await this.projects.findActiveConversation(id);
|
||||
const queue = new PatchQueue();
|
||||
const batches = new PatchBatchScheduler((event) => queue.push(event), this.options);
|
||||
const cursors = new Map<string, ConversationSnapshot['cursor']>();
|
||||
let initialize!: () => void;
|
||||
const initialized = new Promise<void>((resolve) => { initialize = resolve; });
|
||||
@@ -479,6 +578,7 @@ export class CodingConversationService {
|
||||
if (!cursor
|
||||
|| event.workerGeneration !== cursor.workerGeneration
|
||||
|| event.seq !== cursor.seq + 1) {
|
||||
batches.cancel(event.conversationId);
|
||||
try {
|
||||
const snapshot = await this.runtime.getSnapshot(event.conversationId);
|
||||
cursors.set(event.conversationId, snapshot.cursor);
|
||||
@@ -498,7 +598,7 @@ export class CodingConversationService {
|
||||
workerGeneration: event.workerGeneration,
|
||||
seq: event.seq,
|
||||
});
|
||||
queue.push({ type: 'patch', ...event });
|
||||
batches.push(event);
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
try {
|
||||
@@ -516,12 +616,14 @@ export class CodingConversationService {
|
||||
events: queue,
|
||||
close: () => {
|
||||
unsubscribe();
|
||||
batches.close();
|
||||
queue.close();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
initialize();
|
||||
unsubscribe();
|
||||
batches.close();
|
||||
queue.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export class PiSessionRegistry {
|
||||
private readonly createConversationStore: typeof createCodingConversationStore;
|
||||
private readonly records = new Map<string, RegistryRecord>();
|
||||
private readonly prepareFlights = new Map<string, Promise<RegistryRecord>>();
|
||||
private readonly stores = new Map<string, ReturnType<typeof createCodingConversationStore>>();
|
||||
|
||||
constructor(options: PiSessionRegistryOptions) {
|
||||
this.projectStore = options.projectStore;
|
||||
@@ -126,7 +127,11 @@ export class PiSessionRegistry {
|
||||
candidate.id === input.agentId && candidate.enabled && !candidate.archivedAt
|
||||
));
|
||||
if (!agent) throw new Error('Coding Agent does not exist');
|
||||
const store = this.createConversationStore(project.path);
|
||||
let store = this.stores.get(project.path);
|
||||
if (!store) {
|
||||
store = this.createConversationStore(project.path);
|
||||
this.stores.set(project.path, store);
|
||||
}
|
||||
const conversation = await store.get(input.conversationId);
|
||||
if (!conversation || conversation.agentId !== input.agentId) {
|
||||
throw new Error('Coding Conversation does not exist for the selected Agent');
|
||||
|
||||
@@ -43,11 +43,19 @@ export function registerHostApiProxyHandlers(hostApiContext?: HostApiContext): v
|
||||
// Inject the per-session auth token so the Host API server accepts this request.
|
||||
headers['Authorization'] = `Bearer ${getHostApiToken()}`;
|
||||
headers[RENDERER_CAPABILITY_HEADER] = getRendererCapability();
|
||||
let body: string | undefined;
|
||||
let body: string | Uint8Array | undefined;
|
||||
|
||||
if (request.body !== undefined && request.body !== null) {
|
||||
if (typeof request.body === 'string') {
|
||||
body = request.body;
|
||||
} else if (request.body instanceof ArrayBuffer) {
|
||||
body = new Uint8Array(request.body);
|
||||
} else if (ArrayBuffer.isView(request.body)) {
|
||||
body = new Uint8Array(
|
||||
request.body.buffer,
|
||||
request.body.byteOffset,
|
||||
request.body.byteLength,
|
||||
);
|
||||
} else {
|
||||
body = JSON.stringify(request.body);
|
||||
}
|
||||
@@ -79,7 +87,14 @@ export function registerHostApiProxyHandlers(hostApiContext?: HostApiContext): v
|
||||
body,
|
||||
});
|
||||
|
||||
const data: { status: number; ok: boolean; json?: unknown; text?: string } = {
|
||||
const data: {
|
||||
status: number;
|
||||
ok: boolean;
|
||||
json?: unknown;
|
||||
text?: string;
|
||||
bytes?: Uint8Array;
|
||||
contentType?: string;
|
||||
} = {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
};
|
||||
@@ -88,6 +103,9 @@ export function registerHostApiProxyHandlers(hostApiContext?: HostApiContext): v
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
data.json = await response.json().catch(() => undefined);
|
||||
} else if (contentType.startsWith('image/') || contentType === 'application/octet-stream') {
|
||||
data.bytes = new Uint8Array(await response.arrayBuffer());
|
||||
data.contentType = contentType.split(';', 1)[0]?.trim() || 'application/octet-stream';
|
||||
} else {
|
||||
data.text = await response.text().catch(() => '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user