Files
gongxue-base/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx
wangziqi 9048816abc feat(ai): 移除 Excel 导入预检链路
- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件
- 删除 imports.preflight 解析器与 PreflightReport 类型
- SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard
- 前端同步移除预检类型/组件/测试,保留导入向导
2026-08-06 15:50:38 +08:00

581 lines
19 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 } 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,
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 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}
/>
),
})),
[
copyMessage,
confirmDeleteMessage,
confirmEditMessage,
confirmReviewGroup,
confirmReviewStep,
editingMessageId,
isRequesting,
messages,
reloadMessage,
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,
};
}