feat: implement PI core chat timeline
This commit is contained in:
43
src/lib/coding-attachments.ts
Normal file
43
src/lib/coding-attachments.ts
Normal 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 };
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
48
src/lib/coding-projects.ts
Normal file
48
src/lib/coding-projects.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user