feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取

This commit is contained in:
2026-08-04 14:41:40 +08:00
parent f07ffdc64c
commit 50c44e4410
51 changed files with 11588 additions and 175 deletions

View File

@@ -4,10 +4,16 @@ import {
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';
@@ -26,6 +32,10 @@ interface AiSsePayload {
summary?: string | null;
durationMs?: number | null;
attachment?: AiAttachment;
form?: AiFormSchema;
review?: AiReviewSchema;
chart?: AiChartSchema;
retry?: AiModelRetryInfo;
message?:
| string
| {
@@ -50,9 +60,63 @@ function emptyAssistant(): AiChatMessage {
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 mergeReviews(
current: AiReviewSchema[] | undefined,
incoming: AiReviewSchema | AiReviewSchema[] | undefined,
): AiReviewSchema[] {
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;
}
function mergeCharts(
current: AiChartSchema[] | undefined,
incoming: AiChartSchema | AiChartSchema[] | undefined,
): AiChartSchema[] {
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;
@@ -115,14 +179,36 @@ export function reduceAiSseMessage(
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
message.forms = mergeForms(
message.forms,
(nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeReviews(
message.reviews,
(nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeCharts(
message.charts,
(nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
message.feedback = nested?.feedback ?? message.feedback;
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
message.metadata = nested?.metadata ?? message.metadata;
} 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 = mergeReviews(message.reviews, payload.review);
} else if (event === 'ui.chart' && payload.chart) {
message.charts = mergeCharts(message.charts, payload.chart);
} else if (event === 'tool.started') {
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
} else if (event === 'tool.completed') {
@@ -141,14 +227,29 @@ export function reduceAiSseMessage(
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
message.attachments = nested?.attachments ?? message.attachments;
message.forms = mergeForms(
message.forms,
(nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
);
message.reviews = mergeReviews(
message.reviews,
(nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
);
message.charts = mergeCharts(
message.charts,
(nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
);
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
message.feedback = nested?.feedback ?? message.feedback;
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
message.metadata = nested?.metadata ?? message.metadata;
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 ||
@@ -157,9 +258,12 @@ export function reduceAiSseMessage(
return message;
}
async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
export async function authenticatedFetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
const headers = new Headers(init?.headers);
const token = localStorage.getItem('token');
const token = useUserStore.getState().token;
if (token) headers.set('Authorization', `Bearer ${token}`);
headers.set('Accept', 'text/event-stream');
let requestInput = input;
@@ -171,13 +275,37 @@ async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit):
requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.regenerateMessageId}/regenerate/stream`;
requestInit = {
...init,
body: JSON.stringify({ clientRequestId: body.clientRequestId }),
body: JSON.stringify({
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,
formSubmission: _formSubmission,
reviewSubmission: _reviewSubmission,
...payload
} = body;
requestInit = { ...init, body: JSON.stringify(payload) };
@@ -188,9 +316,8 @@ async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit):
}
const response = await fetch(requestInput, { ...requestInit, headers });
if (response.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
useUserStore.getState().logout();
usePermissionStore.getState().clearPermissions();
window.location.href = '/login';
}
return response;
@@ -201,17 +328,27 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
AiChatInput,
AiSseChunk
> {
constructor(url: string, onSettled?: () => void) {
/** 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: 120_000,
streamTimeout: 1_800_000,
callbacks: {
onUpdate: () => undefined,
onSuccess: () => onSettled?.(),
onError: () => onSettled?.(),
onSuccess: () => onSettled?.({ ok: true }),
onError: (error) =>
onSettled?.({
ok: false,
aborted: error?.name === 'AbortError',
}),
},
}),
});
@@ -227,13 +364,44 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
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,
reloadMessage: requestParams.reloadMessage,
};
}
transformLocalMessage(requestParams: Partial<AiChatInput>): AiChatMessage {
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() || '',
@@ -244,6 +412,18 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
}
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);
}
}