417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
import {
|
||
AbstractChatProvider,
|
||
XRequest,
|
||
type TransformMessage,
|
||
type XRequestOptions,
|
||
} from '@ant-design/x-sdk';
|
||
import { usePermissionStore } from '../../store/permission/permissionStore';
|
||
import { useUserStore } from '../../store/user/userStore';
|
||
import type {
|
||
AiAttachment,
|
||
AiChatInput,
|
||
AiChatMessage,
|
||
AiChartSchema,
|
||
AiFormSchema,
|
||
AiModelRetryInfo,
|
||
AiReviewSchema,
|
||
AiSseChunk,
|
||
AiToolRun,
|
||
} from './types';
|
||
|
||
interface AiSsePayload {
|
||
messageId?: number;
|
||
userMessageId?: number;
|
||
assistantMessageId?: number;
|
||
delta?: string;
|
||
content?: string;
|
||
reasoningContent?: string | null;
|
||
toolCallId?: string;
|
||
toolName?: string;
|
||
skillKey?: string | null;
|
||
status?: string;
|
||
summary?: string | null;
|
||
durationMs?: number | null;
|
||
attachment?: AiAttachment;
|
||
form?: AiFormSchema;
|
||
review?: AiReviewSchema;
|
||
chart?: AiChartSchema;
|
||
wizard?: unknown;
|
||
retry?: AiModelRetryInfo;
|
||
message?:
|
||
| string
|
||
| {
|
||
id?: number;
|
||
content?: string;
|
||
reasoningContent?: string | null;
|
||
status?: string;
|
||
toolRuns?: AiToolRun[];
|
||
attachments?: AiAttachment[];
|
||
replyToMessageId?: number | null;
|
||
metadata?: Record<string, unknown> | null;
|
||
};
|
||
error?: string;
|
||
}
|
||
|
||
function emptyAssistant(): AiChatMessage {
|
||
return {
|
||
role: 'assistant',
|
||
content: '',
|
||
reasoningContent: '',
|
||
toolRuns: [],
|
||
attachments: [],
|
||
forms: [],
|
||
};
|
||
}
|
||
|
||
function mergeForms(
|
||
current: AiFormSchema[] | undefined,
|
||
incoming: AiFormSchema | AiFormSchema[] | undefined,
|
||
): AiFormSchema[] {
|
||
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
|
||
if (!items.length) return current ?? [];
|
||
const next = [...(current ?? [])];
|
||
for (const item of items) {
|
||
if (item && typeof item === 'object' && !next.some((existing) => existing.id === item.id)) {
|
||
next.push(item);
|
||
}
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function mergeById<T extends { id: string }>(
|
||
current: T[] | undefined,
|
||
incoming: T | T[] | undefined,
|
||
): T[] {
|
||
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
|
||
if (!items.length) return current ?? [];
|
||
const next = [...(current ?? [])];
|
||
for (const item of items) {
|
||
if (!item || typeof item !== 'object') continue;
|
||
const index = next.findIndex((existing) => existing.id === item.id);
|
||
if (index === -1) {
|
||
next.push(item);
|
||
} else {
|
||
next[index] = item;
|
||
}
|
||
}
|
||
return next;
|
||
}
|
||
|
||
export function parseSsePayload(chunk?: AiSseChunk): {
|
||
event: string;
|
||
payload: AiSsePayload;
|
||
} {
|
||
if (!chunk) return { event: '', payload: {} };
|
||
const event = chunk.event?.trim() || 'message';
|
||
if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} };
|
||
try {
|
||
const parsed: unknown = JSON.parse(chunk.data);
|
||
return {
|
||
event,
|
||
payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {},
|
||
};
|
||
} catch {
|
||
return { event, payload: { delta: chunk.data } };
|
||
}
|
||
}
|
||
|
||
function upsertToolRun(
|
||
toolRuns: AiToolRun[],
|
||
payload: AiSsePayload,
|
||
fallbackStatus: AiToolRun['status'],
|
||
): AiToolRun[] {
|
||
const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;
|
||
const next: AiToolRun = {
|
||
toolCallId,
|
||
toolName: payload.toolName || '查询工具',
|
||
skillKey: payload.skillKey,
|
||
status: (payload.status as AiToolRun['status']) || fallbackStatus,
|
||
summary: payload.summary,
|
||
resultSummary: fallbackStatus === 'running' ? undefined : payload.summary,
|
||
argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined,
|
||
durationMs: payload.durationMs,
|
||
};
|
||
const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);
|
||
if (index === -1) return [...toolRuns, next];
|
||
return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));
|
||
}
|
||
|
||
function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] {
|
||
if (!toolRuns) return fallback;
|
||
return toolRuns.map((tool) => ({
|
||
...tool,
|
||
status: tool.status === 'error' ? 'failed' : tool.status,
|
||
summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary,
|
||
}));
|
||
}
|
||
|
||
function applyMessagePayload(
|
||
message: AiChatMessage,
|
||
nested: AiSsePayload['message'],
|
||
payload: AiSsePayload,
|
||
): void {
|
||
if (typeof nested !== 'object' || nested === null) return;
|
||
message.forms = mergeForms(
|
||
message.forms,
|
||
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
|
||
);
|
||
message.reviews = mergeById<AiReviewSchema>(
|
||
message.reviews,
|
||
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
|
||
);
|
||
message.charts = mergeById<AiChartSchema>(
|
||
message.charts,
|
||
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
|
||
);
|
||
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
|
||
message.metadata = nested.metadata ?? message.metadata;
|
||
}
|
||
|
||
export function reduceAiSseMessage(
|
||
originMessage: AiChatMessage | undefined,
|
||
chunk?: AiSseChunk,
|
||
): AiChatMessage {
|
||
const message = originMessage ? { ...originMessage } : emptyAssistant();
|
||
const { event, payload } = parseSsePayload(chunk);
|
||
|
||
if (event === 'message.created') {
|
||
const nested = typeof payload.message === 'object' ? payload.message : undefined;
|
||
message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id;
|
||
message.content = nested?.content ?? message.content;
|
||
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
|
||
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
||
message.attachments = nested?.attachments ?? message.attachments;
|
||
applyMessagePayload(message, nested, payload);
|
||
} else if (event === 'reasoning.delta') {
|
||
message.retrying = null;
|
||
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
|
||
} else if (event === 'content.delta') {
|
||
message.retrying = null;
|
||
message.content += payload.delta ?? payload.content ?? '';
|
||
} else if (event === 'model.retrying' && payload.retry) {
|
||
message.retrying = payload.retry;
|
||
} else if (event === 'ui.form' && payload.form) {
|
||
message.forms = mergeForms(message.forms, payload.form);
|
||
} else if (event === 'ui.review' && payload.review) {
|
||
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
|
||
} else if (event === 'ui.chart' && payload.chart) {
|
||
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
||
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
||
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
||
} else if (event === 'tool.started') {
|
||
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
|
||
} else if (event === 'tool.completed') {
|
||
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success');
|
||
} else if (event === 'tool.failed') {
|
||
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed');
|
||
} else if (event === 'attachment.processed' && payload.attachment) {
|
||
if (!message.attachments.some((item) => item.id === payload.attachment?.id)) {
|
||
message.attachments = [...message.attachments, payload.attachment];
|
||
}
|
||
} else if (event === 'message.completed') {
|
||
const nested = typeof payload.message === 'object' ? payload.message : undefined;
|
||
message.id = nested?.id ?? payload.messageId ?? message.id;
|
||
message.content = nested?.content ?? payload.content ?? message.content;
|
||
message.reasoningContent =
|
||
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
|
||
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
||
message.attachments = nested?.attachments ?? message.attachments;
|
||
applyMessagePayload(message, nested, payload);
|
||
message.retrying = null;
|
||
} else if (event === 'message.cancelled') {
|
||
message.id = payload.messageId ?? message.id;
|
||
message.cancelled = true;
|
||
message.retrying = null;
|
||
} else if (event === 'error') {
|
||
message.retrying = null;
|
||
message.error =
|
||
(typeof payload.message === 'string' ? payload.message : undefined) ||
|
||
payload.error ||
|
||
'AI 回答生成失败';
|
||
}
|
||
return message;
|
||
}
|
||
|
||
export async function authenticatedFetch(
|
||
input: RequestInfo | URL,
|
||
init?: RequestInit,
|
||
): Promise<Response> {
|
||
const headers = new Headers(init?.headers);
|
||
const token = useUserStore.getState().token;
|
||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||
headers.set('Accept', 'text/event-stream');
|
||
let requestInput = input;
|
||
let requestInit = init;
|
||
if (typeof init?.body === 'string') {
|
||
try {
|
||
const body = JSON.parse(init.body) as AiChatInput;
|
||
if (body.regenerateMessageId) {
|
||
requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.regenerateMessageId}/regenerate/stream`;
|
||
requestInit = {
|
||
...init,
|
||
body: JSON.stringify({
|
||
clientRequestId: body.clientRequestId,
|
||
reasoningEffort: body.reasoningEffort,
|
||
}),
|
||
};
|
||
} else if (body.editMessageId) {
|
||
requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.editMessageId}/edit/stream`;
|
||
requestInit = {
|
||
...init,
|
||
body: JSON.stringify({
|
||
content: body.message,
|
||
clientRequestId: body.clientRequestId,
|
||
reasoningEffort: body.reasoningEffort,
|
||
}),
|
||
};
|
||
} else if (body.formSubmission) {
|
||
requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/forms/${body.formSubmission.formId}/submit/stream`;
|
||
requestInit = {
|
||
...init,
|
||
body: JSON.stringify({
|
||
clientRequestId: body.clientRequestId,
|
||
values: body.formSubmission.values,
|
||
reasoningEffort: body.reasoningEffort,
|
||
}),
|
||
};
|
||
} else if (body.reviewSubmission) {
|
||
requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/reviews/${body.reviewSubmission.reviewId}/submit/stream`;
|
||
requestInit = {
|
||
...init,
|
||
body: JSON.stringify({
|
||
clientRequestId: body.clientRequestId,
|
||
reasoningEffort: body.reasoningEffort,
|
||
}),
|
||
};
|
||
} else {
|
||
const {
|
||
localAttachments: _localAttachments,
|
||
reloadMessage: _reloadMessage,
|
||
regenerateMessageId: _regenerateMessageId,
|
||
editMessageId: _editMessageId,
|
||
formSubmission: _formSubmission,
|
||
reviewSubmission: _reviewSubmission,
|
||
...payload
|
||
} = body;
|
||
requestInit = { ...init, body: JSON.stringify(payload) };
|
||
}
|
||
} catch {
|
||
requestInit = init;
|
||
}
|
||
}
|
||
const response = await fetch(requestInput, { ...requestInit, headers });
|
||
if (response.status === 401) {
|
||
useUserStore.getState().logout();
|
||
usePermissionStore.getState().clearPermissions();
|
||
window.location.href = '/login';
|
||
}
|
||
return response;
|
||
}
|
||
|
||
export class GongxueAiChatProvider extends AbstractChatProvider<
|
||
AiChatMessage,
|
||
AiChatInput,
|
||
AiSseChunk
|
||
> {
|
||
/** Routes events that target another (already streamed) message. */
|
||
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
|
||
|
||
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
|
||
super({
|
||
request: XRequest<AiChatInput, AiSseChunk, AiChatMessage>(url, {
|
||
manual: true,
|
||
fetch: authenticatedFetch,
|
||
timeout: 15_000,
|
||
streamTimeout: 1_800_000,
|
||
callbacks: {
|
||
onUpdate: () => undefined,
|
||
onSuccess: () => onSettled?.({ ok: true }),
|
||
onError: (error) =>
|
||
onSettled?.({
|
||
ok: false,
|
||
aborted: error?.name === 'AbortError',
|
||
}),
|
||
},
|
||
}),
|
||
});
|
||
}
|
||
|
||
transformParams(
|
||
requestParams: Partial<AiChatInput>,
|
||
options: XRequestOptions<AiChatInput, AiSseChunk, AiChatMessage>,
|
||
): AiChatInput {
|
||
return {
|
||
...options.params,
|
||
message: requestParams.message?.trim() || '',
|
||
attachmentIds: requestParams.attachmentIds ?? [],
|
||
skillKey: requestParams.skillKey ?? null,
|
||
clientRequestId: requestParams.clientRequestId || crypto.randomUUID(),
|
||
reasoningEffort: requestParams.reasoningEffort,
|
||
localAttachments: requestParams.localAttachments,
|
||
formSubmission: requestParams.formSubmission,
|
||
reviewSubmission: requestParams.reviewSubmission,
|
||
regenerateMessageId: requestParams.regenerateMessageId,
|
||
editMessageId: requestParams.editMessageId,
|
||
reloadMessage: requestParams.reloadMessage,
|
||
};
|
||
}
|
||
|
||
transformLocalMessage(requestParams: Partial<AiChatInput>): AiChatMessage | AiChatMessage[] {
|
||
if (requestParams.editMessageId) {
|
||
// 编辑消息不需要新增用户气泡,store 里已原位更新原消息。
|
||
return [];
|
||
}
|
||
if (requestParams.formSubmission) {
|
||
return {
|
||
role: 'user',
|
||
content: '',
|
||
reasoningContent: '',
|
||
toolRuns: [],
|
||
attachments: requestParams.localAttachments ?? [],
|
||
metadata: {
|
||
a2uiSubmit: {
|
||
formTitle: requestParams.formSubmission.formTitle,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
if (requestParams.reviewSubmission) {
|
||
return {
|
||
role: 'user',
|
||
content: '',
|
||
reasoningContent: '',
|
||
toolRuns: [],
|
||
attachments: requestParams.localAttachments ?? [],
|
||
metadata: {
|
||
a2uiReviewSubmit: {
|
||
reviewTitle: requestParams.reviewSubmission.reviewTitle,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
return {
|
||
role: 'user',
|
||
content: requestParams.message?.trim() || '',
|
||
reasoningContent: '',
|
||
toolRuns: [],
|
||
attachments: requestParams.localAttachments ?? [],
|
||
};
|
||
}
|
||
|
||
transformMessage(info: TransformMessage<AiChatMessage, AiSseChunk>): AiChatMessage {
|
||
const { event, payload } = parseSsePayload(info.chunk);
|
||
if (
|
||
event === 'ui.review' &&
|
||
payload.review &&
|
||
typeof payload.messageId === 'number' &&
|
||
info.originMessage?.id !== payload.messageId
|
||
) {
|
||
// The submitted review belongs to the original assistant message;
|
||
// do not merge it into the message currently being streamed.
|
||
this.onExternalReview?.(payload.messageId, payload.review);
|
||
return info.originMessage ?? emptyAssistant();
|
||
}
|
||
return reduceAiSseMessage(info.originMessage, info.chunk);
|
||
}
|
||
}
|