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

@@ -0,0 +1,43 @@
import { hostApiFetch, hostApiFetchBytes } from './host-api';
export const CODING_ATTACHMENT_MAX_BYTES = 16 * 1024 * 1024;
export const CODING_ATTACHMENT_MIMES = new Set([
'image/png',
'image/jpeg',
'image/webp',
'image/gif',
]);
export interface CodingAttachmentRef {
attachmentId: string;
mime: string;
byteLength: number;
}
export async function uploadCodingAttachment(file: File): Promise<CodingAttachmentRef> {
const mime = file.type.trim().toLowerCase();
if (!CODING_ATTACHMENT_MIMES.has(mime)) throw new Error('仅支持 PNG、JPEG、WebP 或 GIF 图片。');
if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) {
throw new Error('图片不能超过 16 MB。');
}
return await hostApiFetch<CodingAttachmentRef>(
'/api/coding/attachments',
{
method: 'POST',
headers: { 'Content-Type': mime },
body: await file.arrayBuffer(),
},
);
}
export async function loadCodingAttachmentPreview(
attachmentId: string,
): Promise<{ bytes: Uint8Array; mime: string }> {
const response = await hostApiFetchBytes(
`/api/coding/attachments/${encodeURIComponent(attachmentId)}/content`,
);
if (!CODING_ATTACHMENT_MIMES.has(response.contentType)) {
throw new Error('图片附件格式无效。');
}
return { bytes: response.bytes, mime: response.contentType };
}

View File

@@ -44,6 +44,15 @@ export async function submitCodingConversationPrompt(
return response.acceptance;
}
export async function recoverCodingConversation(
conversationId: string,
): Promise<void> {
await hostApiFetch(
`/api/coding/conversations/${encodeURIComponent(conversationId)}/recover`,
{ method: 'POST', body: '{}' },
);
}
export async function openCodingConversationEvents(
conversationId?: string,
): Promise<EventSource> {

View File

@@ -0,0 +1,48 @@
import { hostApiFetch } from './host-api';
import type {
CodingConversationMetadata,
CodingProjectConfigSnapshot,
CodingProjectSummary,
} from '@/types/coding-project';
export interface CodingProjectCatalog {
projects: CodingProjectSummary[];
activeProjectId: string | null;
}
export async function listCodingProjects(): Promise<CodingProjectCatalog> {
return await hostApiFetch<CodingProjectCatalog>('/api/coding/projects');
}
export async function getCodingProjectConfig(
projectId: string,
): Promise<CodingProjectConfigSnapshot> {
const response = await hostApiFetch<{ snapshot: CodingProjectConfigSnapshot }>(
`/api/coding/projects/config?projectId=${encodeURIComponent(projectId)}`,
);
return response.snapshot;
}
export async function listCodingProjectConversations(
projectId: string,
): Promise<CodingConversationMetadata[]> {
const response = await hostApiFetch<{ conversations: CodingConversationMetadata[] }>(
`/api/coding/projects/conversations?projectId=${encodeURIComponent(projectId)}`,
);
return response.conversations;
}
export async function createCodingProjectConversation(input: {
projectId: string;
agentId: string;
title: string;
}): Promise<CodingConversationMetadata> {
const response = await hostApiFetch<{ conversation: CodingConversationMetadata }>(
'/api/coding/projects/conversations',
{
method: 'POST',
body: JSON.stringify(input),
},
);
return response.conversation;
}

View File

@@ -66,6 +66,8 @@ type HostApiProxyResponse = {
ok?: boolean;
json?: unknown;
text?: string;
bytes?: Uint8Array;
contentType?: string;
};
error?: { message?: string } | string;
// backward compatibility fields
@@ -80,6 +82,8 @@ type HostApiProxyData = {
ok?: boolean;
json?: unknown;
text?: string;
bytes?: Uint8Array;
contentType?: string;
transport?: 'dispatcher' | 'loopback';
};
@@ -297,6 +301,65 @@ export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise
}
}
export interface HostApiBinaryData {
bytes: Uint8Array;
contentType: string;
}
export async function hostApiFetchBytes(path: string): Promise<HostApiBinaryData> {
const startedAt = Date.now();
try {
const response = await invokeIpc<HostApiProxyResponse>('hostapi:fetch', {
path,
method: 'GET',
headers: { Accept: 'application/octet-stream' },
body: null,
});
if (!response.ok) throw new Error(resolveProxyErrorMessage(response.error));
const data = response.data;
if (!data || data.ok === false || (typeof data.status === 'number' && data.status >= 400)) {
throw createProxyHttpError(data ?? {});
}
if (!(data.bytes instanceof Uint8Array) || !data.contentType) {
throw new Error('Host API binary response is unavailable');
}
trackUiEvent('hostapi.fetch', {
route: telemetryRoute(path),
method: 'GET',
source: 'ipc-proxy',
durationMs: Date.now() - startedAt,
status: data.status ?? 200,
});
return { bytes: data.bytes, contentType: data.contentType };
} catch (error) {
const normalized = normalizeAppError(error, { source: 'ipc-proxy', path, method: 'GET' });
if (!shouldFallbackToBrowser(normalized.message) || !allowLocalhostFallback()) {
throw normalized;
}
}
const [token, hostApiBase] = await Promise.all([getHostApiToken(), getHostApiBaseUrl()]);
const response = await fetch(`${hostApiBase}${path}`, {
headers: {
Accept: 'application/octet-stream',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw normalizeAppError(new Error(`HTTP ${response.status}`), {
source: 'browser-fallback',
path,
method: 'GET',
status: response.status,
});
}
return {
bytes: new Uint8Array(await response.arrayBuffer()),
contentType: response.headers.get('content-type')?.split(';', 1)[0]?.trim()
|| 'application/octet-stream',
};
}
export function createHostEventSource(path = '/api/events'): EventSource {
// EventSource does not support custom headers, so pass the auth token
// as a query parameter. The server accepts both mechanisms.