feat: implement PI core chat timeline

This commit is contained in:
2026-08-24 00:34:03 +08:00
parent 54d8bb2e4a
commit 612135f911
40 changed files with 3881 additions and 119 deletions

View File

@@ -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 (

View File

@@ -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,

View 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;
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 =

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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(() => '');
}