Files
gongxue-base/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx
wangziqi c72ff2cb8a fix(ai): 清理 aislop 扫描告警
- 拆分 SSE reducer 到 sseReducer.ts,provider.ts 降至 220 行
- artifact 合并工具抽到 uiArtifacts.ts,去除双重类型断言
- 业务上下文注册表改用 field/relation/entity 构建器收敛重复
- aislop 分数 71 → 81(Healthy),AI Slop 0 告警
2026-08-06 15:35:12 +08:00

609 lines
20 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 { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react';
import { CopyOutlined, DeleteOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons';
import type { BubbleItemType, PromptsItemType } from '@ant-design/x';
import { useXChat, type MessageInfo } from '@ant-design/x-sdk';
import { App } from 'antd';
import type { UploadFile, UploadProps } from 'antd';
import { message } from '../../ui/app-message';
import { useSettingsStore } from '../../store/settings/settingsStore';
import { aiChatApi, resolveImportPreflight, type ResolveImportPreflightInput } from './api';
import { AiMessageContent } from './AiMessageContent';
import { mapHistoryMessage } from './message-mappers';
import { GongxueAiChatProvider } from './provider';
import { mergeArtifactIntoMessage } from './uiArtifacts';
import {
emptyAssistant,
MessageHoverActions,
resolveUserMessageId,
toConversationData,
toUploadFile,
type ConversationData,
} from './AiChatDrawer.helpers';
import type {
AiAttachment,
AiChatInput,
AiChatMessage,
AiChatMessageStatus,
AiFormSchema,
AiImportPreflight,
AiReviewSchema,
AiReviewSection,
AiReviewSectionType,
AiSkill,
AiSseChunk,
} from './types';
interface UseAiChatMessageActionsParams {
activeConversation: ConversationData | undefined;
activeId: number | null;
provider: GongxueAiChatProvider | undefined;
requestAbortRef: MutableRefObject<Map<number, () => void>>;
markConversationRunning: (conversationId: number) => void;
addConversation: (conversation: ConversationData, placement?: 'prepend' | 'append') => boolean;
setActiveConversationKey: (key: string) => boolean;
refreshConversations: () => Promise<void>;
skills: AiSkill[];
lockedSkill: AiSkill | undefined;
setImportWizardRunId: (runId: string | null) => void;
}
export function useAiChatMessageActions({
activeConversation,
activeId,
provider,
requestAbortRef,
markConversationRunning,
addConversation,
setActiveConversationKey,
refreshConversations,
skills,
lockedSkill,
setImportWizardRunId,
}: UseAiChatMessageActionsParams) {
const { modal } = App.useApp();
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<AiAttachment[]>([]);
const [editingMessageId, setEditingMessageId] = useState<number | string | null>(null);
const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking);
const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking);
const requestingRef = useRef(false);
const abortRef = useRef<() => void>(() => undefined);
const attachmentsRef = useRef<AiAttachment[]>([]);
const pendingDraftConversationIdRef = useRef<number | null>(null);
const messagesRef = useRef<MessageInfo<AiChatMessage>[]>([]);
const {
messages,
onRequest,
onReload,
isRequesting,
abort,
setMessage,
removeMessage,
queueRequest,
} = useXChat<AiChatMessage, AiChatMessage, AiChatInput, AiSseChunk>({
provider,
conversationKey: activeConversation?.key || 'no-conversation',
defaultMessages: async () => {
if (!activeId) return [];
const page = await aiChatApi.listMessages(activeId);
return page.items.map(mapHistoryMessage);
},
requestPlaceholder: emptyAssistant(),
requestFallback: (
params: Partial<AiChatInput>,
{ error, messageInfo }: { error: Error; messageInfo: MessageInfo<AiChatMessage> },
) => ({
...(params.reloadMessage || messageInfo?.message || emptyAssistant()),
error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试',
cancelled: error.name === 'AbortError',
}),
});
useEffect(() => {
if (!provider) return;
provider.onExternalReview = (messageId, review) => {
setMessage(messageId, (info) => ({
message: {
...info.message,
reviews: (info.message.reviews ?? []).some((item) => item.id === review.id)
? (info.message.reviews ?? []).map((item) => (item.id === review.id ? review : item))
: [...(info.message.reviews ?? []), review],
},
}));
};
provider.onExternalArtifact = (messageId, artifact) => {
setMessage(messageId, (info) => ({
message: mergeArtifactIntoMessage(info.message, artifact),
}));
};
}, [provider, setMessage]);
requestingRef.current = isRequesting;
abortRef.current = abort;
attachmentsRef.current = attachments;
messagesRef.current = messages;
const stopRequest = useCallback(() => {
if (requestingRef.current) abortRef.current();
}, []);
const requestWithStatus = useCallback(
(params: AiChatInput) => {
if (!activeId || !provider) return;
requestAbortRef.current.set(activeId, () => provider.request.abort());
markConversationRunning(activeId);
onRequest(params);
},
[activeId, markConversationRunning, onRequest, provider, requestAbortRef],
);
const reloadWithStatus = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
if (!activeId || !provider || typeof messageInfo.message.id !== 'number') return;
requestAbortRef.current.set(activeId, () => provider.request.abort());
markConversationRunning(activeId);
onReload(messageInfo.id, {
message: '',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
regenerateMessageId: messageInfo.message.id,
reloadMessage: messageInfo.message,
});
},
[
activeConversation?.lockedSkillKey,
activeId,
deepThinking,
markConversationRunning,
onReload,
provider,
requestAbortRef,
],
);
const discardPendingAttachments = useCallback(() => {
const pending = attachmentsRef.current;
attachmentsRef.current = [];
setAttachments([]);
for (const attachment of pending) {
void aiChatApi.deleteAttachment(attachment.id).catch(() => undefined);
}
}, []);
const submit = useCallback(
(value: string) => {
const text = value.trim();
if (!text || isRequesting) return;
const submittedAttachments = attachmentsRef.current;
attachmentsRef.current = [];
setAttachments([]);
setInput('');
const params: AiChatInput = {
message: text,
attachmentIds: submittedAttachments.map((item) => item.id),
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
localAttachments: submittedAttachments,
};
if (activeId != null) {
requestWithStatus(params);
return;
}
// 草稿态:先创建 session再发送第一条消息
void (async () => {
try {
const created = toConversationData(await aiChatApi.createConversation());
addConversation(created, 'prepend');
pendingDraftConversationIdRef.current = created.id;
markConversationRunning(created.id);
// 通过 XChat 的队列机制发送:等会话 key 切换并加载完成后再真正发出,
// 保证消息写入新会话的 store界面能正常显示对话内容。
queueRequest(created.key, params);
setActiveConversationKey(created.key);
} catch {
message.error('创建会话失败,请重试');
attachmentsRef.current = submittedAttachments;
setAttachments(submittedAttachments);
setInput(text);
}
})();
},
[
activeConversation?.lockedSkillKey,
activeId,
addConversation,
deepThinking,
isRequesting,
markConversationRunning,
queueRequest,
requestWithStatus,
setActiveConversationKey,
],
);
// 草稿 session 创建完成、provider 就绪后注册中止句柄
useEffect(() => {
if (activeId == null || !provider) return;
if (activeId !== pendingDraftConversationIdRef.current) return;
pendingDraftConversationIdRef.current = null;
requestAbortRef.current.set(activeId, () => provider.request.abort());
}, [activeId, provider, requestAbortRef]);
const reloadMessage = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
reloadWithStatus(messageInfo);
},
[reloadWithStatus],
);
const copyMessage = useCallback((message: AiChatMessage) => {
if (!message.content) return;
void navigator.clipboard.writeText(message.content);
}, []);
const confirmDeleteMessage = useCallback(
(messageInfo: MessageInfo<AiChatMessage>) => {
if (!activeId || isRequesting) return;
const messageId = resolveUserMessageId(messageInfo, messagesRef.current);
if (messageId == null) return;
const scopeLabel =
messageInfo.message.role === 'user' ? '这条消息及其 AI 回答' : '这条 AI 回答';
modal.confirm({
title: '删除消息',
content: `将删除${scopeLabel},此操作不可恢复。`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
const result = await aiChatApi.deleteMessage(activeId, messageId);
const storeIds = new Map<number, number | string>();
for (const item of messagesRef.current) {
if (typeof item.message.id === 'number') {
storeIds.set(item.message.id, item.id);
}
}
// 当前会话内新发送的用户消息没有服务端 ID但可映射到本地 msg_N key
storeIds.set(messageId, messageInfo.id);
for (const id of result.deletedIds) removeMessage(storeIds.get(id) ?? id);
void refreshConversations();
} catch (error) {
console.error('删除消息失败', error);
message.error('删除消息失败');
}
},
});
},
[activeId, isRequesting, refreshConversations, removeMessage, modal],
);
const confirmEditMessage = useCallback(
(messageInfo: MessageInfo<AiChatMessage>, value: string) => {
if (!activeId) return;
const content = value.trim();
if (!content) {
message.warning('消息内容不能为空');
return;
}
const messageId = resolveUserMessageId(messageInfo, messagesRef.current);
if (messageId == null) {
message.warning('消息尚未同步,请稍后重试');
return;
}
setEditingMessageId(null);
if (content === messageInfo.message.content) return;
setMessage(messageInfo.id, (info) => ({
message: {
...info.message,
content,
metadata: { ...info.message.metadata, edited: true },
},
}));
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
if (index >= 0) {
for (const item of messagesRef.current.slice(index + 1)) removeMessage(item.id);
}
requestWithStatus({
message: content,
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
editMessageId: messageId,
});
},
[
activeConversation?.lockedSkillKey,
activeId,
deepThinking,
removeMessage,
requestWithStatus,
setMessage,
],
);
const submitForm = useCallback(
(form: AiFormSchema, values: Record<string, unknown>) => {
if (!activeId || isRequesting) return;
requestWithStatus({
message: '表单提交',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
formSubmission: { formId: form.id, values, formTitle: form.title },
});
},
[activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus],
);
const submitReview = useCallback(
(reviewId: string, reviewTitle?: string) => {
if (!activeId || isRequesting) return;
requestWithStatus({
message: '确认批量导入',
attachmentIds: [],
skillKey: activeConversation?.lockedSkillKey ?? null,
clientRequestId: crypto.randomUUID(),
reasoningEffort: deepThinking ? 'high' : null,
reviewSubmission: { reviewId, reviewTitle },
});
},
[activeConversation?.lockedSkillKey, activeId, deepThinking, isRequesting, requestWithStatus],
);
const confirmReviewStep = useCallback(
async (
messageId: number | undefined,
reviewId: string,
sectionKey: AiReviewSection['key'],
): Promise<AiReviewSchema> => {
const updated = await aiChatApi.confirmReviewStep(reviewId, sectionKey);
const apply = (review: AiReviewSchema) => {
if (provider?.onExternalReview && typeof messageId === 'number') {
provider.onExternalReview(messageId, review);
} else if (typeof messageId === 'number') {
setMessage(messageId, (info) => {
const reviews = info.message.reviews ?? [];
const exists = reviews.some((item) => item.id === review.id);
return {
message: {
...info.message,
reviews: exists
? reviews.map((item) => (item.id === review.id ? review : item))
: [...reviews, review],
},
};
});
}
};
apply(updated);
return updated;
},
[provider, setMessage],
);
const confirmReviewGroup = useCallback(
async (
messageId: number | undefined,
reviewId: string,
type: AiReviewSectionType,
): Promise<AiReviewSchema> => {
const updated = await aiChatApi.confirmReviewGroup(reviewId, type);
if (provider?.onExternalReview && typeof messageId === 'number') {
provider.onExternalReview(messageId, updated);
} else if (typeof messageId === 'number') {
setMessage(messageId, (info) => {
const reviews = info.message.reviews ?? [];
const exists = reviews.some((item) => item.id === updated.id);
return {
message: {
...info.message,
reviews: exists
? reviews.map((item) => (item.id === updated.id ? updated : item))
: [...reviews, updated],
},
};
});
}
return updated;
},
[provider, setMessage],
);
const resolvePreflight = useCallback(
async (
messageId: number | undefined,
_preflight: AiImportPreflight,
input: ResolveImportPreflightInput,
): Promise<void> => {
if (typeof messageId !== 'number') {
throw new Error('消息尚未完成生成,请稍后再试');
}
await resolveImportPreflight(messageId, input, (update) => {
setMessage(messageId, (info) => ({
message: {
...info.message,
metadata: {
...info.message.metadata,
...(update.preflight ? { a2uiImportPreflight: update.preflight } : {}),
...(update.wizard ? { a2uiImportWizard: update.wizard } : {}),
},
},
}));
});
},
[setMessage],
);
const customUpload = useCallback<NonNullable<UploadProps['customRequest']>>(async (options) => {
const file = options.file as File;
if (attachmentsRef.current.length >= 5) {
const error = new Error('每条消息最多添加 5 个附件');
options.onError?.(error);
message.warning(error.message);
return;
}
try {
const uploaded = await aiChatApi.uploadAttachment(file);
setAttachments((items) => [...items, uploaded]);
options.onSuccess?.(uploaded, file);
} catch (error) {
options.onError?.(error instanceof Error ? error : new Error('附件上传失败'));
message.error('附件上传失败');
}
}, []);
const removeAttachment = useCallback(async (file: UploadFile<AiAttachment>) => {
const attachment = file.response;
if (!attachment) return true;
try {
await aiChatApi.deleteAttachment(attachment.id);
setAttachments((items) => items.filter((item) => item.id !== attachment.id));
return true;
} catch {
message.error('删除附件失败');
return false;
}
}, []);
const uploadItems = useMemo(() => attachments.map(toUploadFile), [attachments]);
const promptItems = useMemo<PromptsItemType[]>(
() =>
(lockedSkill ? [lockedSkill] : skills)
.flatMap((skill) =>
skill.examples.slice(0, lockedSkill ? 4 : 1).map((example) => ({ skill, example })),
)
.slice(0, 5)
.map(({ skill, example }) => ({
key: `${skill.key}-${example}`,
label: example,
description: skill.name,
})),
[lockedSkill, skills],
);
const bubbleItems = useMemo<BubbleItemType[]>(
() =>
messages.map((info) => ({
key: info.id,
role: info.message.role === 'assistant' ? 'assistant' : 'user',
status: info.status,
content: info.message,
extra:
info.status !== 'loading' && info.status !== 'updating' && !isRequesting ? (
info.message.role === 'user' ? (
editingMessageId === info.id ? undefined : (
<MessageHoverActions
items={[
{
key: 'copy',
title: '复制',
icon: <CopyOutlined />,
onClick: () => copyMessage(info.message),
},
{
key: 'edit',
title: '编辑',
icon: <EditOutlined />,
onClick: () => setEditingMessageId(info.id),
},
{
key: 'delete',
title: '删除',
icon: <DeleteOutlined />,
danger: true,
onClick: () => void confirmDeleteMessage(info),
},
]}
/>
)
) : (
<MessageHoverActions
items={[
{
key: 'copy',
title: '复制',
icon: <CopyOutlined />,
onClick: () => copyMessage(info.message),
},
{
key: 'reload',
title: '重新生成',
icon: <ReloadOutlined />,
onClick: () => reloadMessage(info),
},
]}
/>
)
) : undefined,
contentRender: (content: AiChatMessage) => (
<AiMessageContent
message={content}
status={info.status as AiChatMessageStatus}
editing={content.role === 'user' && editingMessageId === info.id}
onEditConfirm={
content.role === 'user' ? (value) => confirmEditMessage(info, value) : undefined
}
onEditCancel={content.role === 'user' ? () => setEditingMessageId(null) : undefined}
onSubmitForm={submitForm}
onSubmitReview={submitReview}
onConfirmReviewStep={confirmReviewStep}
onConfirmReviewGroup={confirmReviewGroup}
onOpenImportWizard={setImportWizardRunId}
onResolveImportPreflight={resolvePreflight}
/>
),
})),
[
copyMessage,
confirmDeleteMessage,
confirmEditMessage,
confirmReviewGroup,
confirmReviewStep,
editingMessageId,
isRequesting,
messages,
reloadMessage,
resolvePreflight,
setImportWizardRunId,
submitForm,
submitReview,
],
);
return {
input,
setInput,
attachments,
setAttachments,
editingMessageId,
setEditingMessageId,
deepThinking,
setDeepThinking,
isRequesting,
messages,
stopRequest,
submit,
reloadMessage,
copyMessage,
confirmDeleteMessage,
confirmEditMessage,
submitForm,
submitReview,
confirmReviewStep,
confirmReviewGroup,
customUpload,
removeAttachment,
discardPendingAttachments,
uploadItems,
promptItems,
bubbleItems,
};
}