Files
gongxue-base/apps/admin/src/components/AiChat/provider.ts

417 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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