- 通知中心改为游标分页 + 加载更多,历史通知不再被 50 条上限截断; 加载失败显示错误态与重试 - AI 流式请求 401 被登出时,登录页提示"登录已过期",不再无声踢出 - AI 附件/引用来源打开失败时给出明确错误提示,不再"点了没反应" - 新增 useDownload hook:统一导出/下载的防重复、loading 与成功/失败反馈; 接入学生/账单/房间/入住/费用五个页面的模板下载与导出按钮 - 学生页移除不检查响应状态的私有下载实现,统一走 downloadBlob
223 lines
7.5 KiB
TypeScript
223 lines
7.5 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 { AiArtifactSchema, AiChatInput, AiChatMessage, AiReviewSchema, AiSseChunk } from './types';
|
||
import { emptyAssistant, parseSsePayload, reduceAiSseMessage } from './sseReducer';
|
||
|
||
export { parseSsePayload, reduceAiSseMessage };
|
||
|
||
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) {
|
||
// 提示由登录页读取展示:直接弹 toast 会被跳转销毁
|
||
sessionStorage.setItem('login_expired_hint', '1');
|
||
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;
|
||
onExternalArtifact?: (messageId: number, artifact: AiArtifactSchema) => 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.artifact' &&
|
||
payload.artifact &&
|
||
typeof payload.messageId === 'number' &&
|
||
info.originMessage?.id !== payload.messageId
|
||
) {
|
||
this.onExternalArtifact?.(payload.messageId, payload.artifact);
|
||
return info.originMessage ?? emptyAssistant();
|
||
}
|
||
if (
|
||
event === 'ui.form' &&
|
||
payload.form &&
|
||
typeof payload.messageId === 'number' &&
|
||
info.originMessage?.id !== payload.messageId
|
||
) {
|
||
this.onExternalArtifact?.(payload.messageId, {
|
||
id: payload.form.id,
|
||
type: 'form',
|
||
status: payload.form.status ?? 'pending',
|
||
messageId: payload.messageId,
|
||
payload: payload.form,
|
||
});
|
||
return info.originMessage ?? emptyAssistant();
|
||
}
|
||
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);
|
||
}
|
||
}
|