- Phase 0: 实现服务端回复导出策略矩阵引擎 (replyexport.EvaluatePolicy)、数据库迁移 (2026-09-11-message-export-policy.sql)、SSE done 事件挂载与能力探测 API - Phase 1: 构建前端 ReplyDocumentIR 语法树解析与渲染管道,支持 Markdown / DOCX 文件下载及带格式富文本剪贴板复制 - Phase 2: 实现私密自由文档 (freeform_documents) 领域服务、PostgreSQL 乐观锁版本管理、ACL 隐私隔离、一键共享与右侧栏编辑联动 - 专项修复: 1. 修复后台 API 路由注册并加入双向拉取同步,确保私密保存实时进入我的私密列表 2. 实现轻量回复专属 DOCX 生成与静默下载逻辑 (downloadDocxReply) 3. 将 LaTeX 公式转为标准 MathML 矢量标记,支持粘贴到 Microsoft Word / WPS 自动识别为原生公式
192 lines
5.0 KiB
TypeScript
192 lines
5.0 KiB
TypeScript
import type { ChatErrorInfo } from '../types/workbench';
|
|
import type { MessageExportCapability } from '../types/replyExport';
|
|
import { fallbackErrorClass } from './chatRetry';
|
|
|
|
export type StreamEvent = {
|
|
id?: string;
|
|
type: string;
|
|
phase?: {
|
|
phase_id?: string;
|
|
message?: string;
|
|
step_index?: number;
|
|
total_steps?: number;
|
|
progress?: number;
|
|
};
|
|
status?: { message?: string; intent?: string };
|
|
delta?: string;
|
|
error?: string;
|
|
deliverable?: {
|
|
task_key?: string;
|
|
title?: string;
|
|
file_path?: string;
|
|
word_count?: number;
|
|
quality_score?: number;
|
|
};
|
|
error_info?: {
|
|
user_message?: string;
|
|
code?: string;
|
|
class?: string;
|
|
phase_id?: string;
|
|
upstream?: string;
|
|
request_id?: string;
|
|
session_id?: string;
|
|
workspace_id?: string;
|
|
actions?: string[];
|
|
};
|
|
message_id?: number;
|
|
user_message_id?: number;
|
|
content_revision?: number;
|
|
content_sha256?: string;
|
|
export_capability?: MessageExportCapability;
|
|
done?: {
|
|
session_id?: string;
|
|
duration_ms?: number;
|
|
total_events?: number;
|
|
workspace_id?: string;
|
|
message_id?: number;
|
|
user_message_id?: number;
|
|
content_revision?: number;
|
|
content_sha256?: string;
|
|
export_capability?: MessageExportCapability;
|
|
};
|
|
};
|
|
|
|
type ErrorAction = ChatErrorInfo['actions'][number];
|
|
|
|
const VALID_ACTIONS: readonly ErrorAction[] = ['retry', 'continue', 'restart'];
|
|
|
|
function isErrorClass(value: unknown): value is ChatErrorInfo['class'] {
|
|
return (
|
|
value === 'transient' ||
|
|
value === 'resumable' ||
|
|
value === 'fatal' ||
|
|
value === 'cancelled'
|
|
);
|
|
}
|
|
|
|
function defaultActionsForClass(cls: ChatErrorInfo['class']): ChatErrorInfo['actions'] {
|
|
switch (cls) {
|
|
case 'transient':
|
|
return ['retry'];
|
|
case 'resumable':
|
|
return ['continue', 'restart'];
|
|
case 'fatal':
|
|
return ['restart'];
|
|
case 'cancelled':
|
|
return ['retry'];
|
|
default: {
|
|
const _exhaustive: never = cls;
|
|
return _exhaustive;
|
|
}
|
|
}
|
|
}
|
|
|
|
function parseActions(raw?: string[]): ChatErrorInfo['actions'] {
|
|
if (!raw?.length) return [];
|
|
return raw.filter((a): a is ErrorAction => (VALID_ACTIONS as readonly string[]).includes(a));
|
|
}
|
|
|
|
function parseFrame(frame: string): StreamEvent | null {
|
|
if (!frame.trim()) return null;
|
|
|
|
let dataLine: string | undefined;
|
|
for (const line of frame.split('\n')) {
|
|
if (line.startsWith('data:')) {
|
|
dataLine = line.slice(5).trim();
|
|
}
|
|
}
|
|
if (!dataLine) return null;
|
|
|
|
return JSON.parse(dataLine) as StreamEvent;
|
|
}
|
|
|
|
export function parseSSEChunk(
|
|
buffer: string,
|
|
options?: { flush?: boolean },
|
|
): { events: StreamEvent[]; rest: string } {
|
|
const events: StreamEvent[] = [];
|
|
if (!buffer) return { events, rest: '' };
|
|
|
|
const frames = buffer.split('\n\n');
|
|
const hasTrailingDelimiter = buffer.endsWith('\n\n');
|
|
let processCount = hasTrailingDelimiter ? frames.length : Math.max(0, frames.length - 1);
|
|
|
|
if (options?.flush && !hasTrailingDelimiter && frames.length > 0) {
|
|
processCount = frames.length;
|
|
}
|
|
|
|
for (let i = 0; i < processCount; i++) {
|
|
const ev = parseFrame(frames[i]);
|
|
if (ev) events.push(ev);
|
|
}
|
|
|
|
const rest = hasTrailingDelimiter || options?.flush ? '' : (frames[frames.length - 1] ?? '');
|
|
return { events, rest };
|
|
}
|
|
|
|
export async function streamChat(
|
|
body: Record<string, unknown>,
|
|
handlers: {
|
|
onEvent: (ev: StreamEvent) => void;
|
|
onRequestId?: (id: string) => void;
|
|
},
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
const resp = await fetch('/api/v1/chat/stream', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
body: JSON.stringify(body),
|
|
signal,
|
|
});
|
|
const rid = resp.headers.get('X-Request-Id') || resp.headers.get('x-request-id');
|
|
if (rid) handlers.onRequestId?.(rid);
|
|
if (!resp.ok || !resp.body) {
|
|
throw Object.assign(new Error(`HTTP ${resp.status}`), {
|
|
httpStatus: resp.status,
|
|
requestId: rid,
|
|
});
|
|
}
|
|
const reader = resp.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
const parsed = parseSSEChunk(buf);
|
|
buf = parsed.rest;
|
|
for (const ev of parsed.events) handlers.onEvent(ev);
|
|
}
|
|
if (buf) {
|
|
const flushed = parseSSEChunk(buf, { flush: true });
|
|
for (const ev of flushed.events) handlers.onEvent(ev);
|
|
}
|
|
}
|
|
|
|
export function mapErrorInfo(ev: StreamEvent): ChatErrorInfo {
|
|
const ei = ev.error_info ?? {};
|
|
const userMessage = ei.user_message ?? ev.error ?? '发生未知错误';
|
|
const code = ei.code ?? 'UNKNOWN_ERROR';
|
|
|
|
const cls: ChatErrorInfo['class'] = isErrorClass(ei.class)
|
|
? ei.class
|
|
: fallbackErrorClass({ workspaceId: ei.workspace_id });
|
|
|
|
let actions = parseActions(ei.actions);
|
|
if (!actions.length) {
|
|
actions = defaultActionsForClass(cls);
|
|
}
|
|
|
|
return {
|
|
userMessage,
|
|
code,
|
|
class: cls,
|
|
phaseId: ei.phase_id,
|
|
upstream: ei.upstream,
|
|
requestId: ei.request_id,
|
|
sessionId: ei.session_id,
|
|
workspaceId: ei.workspace_id,
|
|
actions,
|
|
};
|
|
}
|