Files
Agent-System/web/src/api/chatStream.ts
T
WeiCHEN 4962cefdae feat(export): 实现单次智能体回复导出、私密文档保存与隐私防泄露全套闭环 (Phase 0 - Phase 2)
- 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 自动识别为原生公式
2026-09-10 23:04:04 +08:00

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