Files
gongxue-base/apps/admin/src/components/AiChat/useAiChatMessageActions.tsx
wangziqi 8dc72d6e1b fix(admin): 编辑消息/切换会话保护、行内编辑撤销与上传进度反馈
- 编辑旧消息会删除其后全部消息时,先弹确认告知影响范围
- 有待发送附件时切换会话,先确认再丢弃(避免静默删除已上传文件)
- EditableCell 保存成功后提供 6 秒内"撤销"入口(把旧值重新保存),
  重新编辑时自动清除
- 导入向导/重新上传显示上传进度条与百分比,不再"假死"
- AI 附件上传透传进度给附件列表
- 租赁合同上传显示按钮 loading 与上传百分比
2026-08-07 17:38:21 +08:00

601 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 } 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;
// 编辑旧消息会删除其后的全部消息并重新生成,需先告知用户
const index = messagesRef.current.findIndex((item) => item.id === messageInfo.id);
const followingCount = index >= 0 ? messagesRef.current.length - index - 1 : 0;
const doEdit = () => {
setMessage(messageInfo.id, (info) => ({
message: {
...info.message,
content,
metadata: { ...info.message.metadata, edited: true },
},
}));
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,
});
};
if (followingCount > 0) {
modal.confirm({
title: '编辑消息将删除后续内容',
content: `编辑这条消息会删除其后的 ${followingCount} 条消息并重新生成回答,此操作不可恢复。`,
okText: '继续编辑',
cancelText: '取消',
onOk: doEdit,
});
return;
}
doEdit();
},
[
activeConversation?.lockedSkillKey,
activeId,
deepThinking,
modal,
removeMessage,
requestWithStatus,
setMessage,
],
);
const submitForm = useCallback(
async (form: AiFormSchema, values: Record<string, unknown>): Promise<void> => {
if (!activeId) throw new Error('当前会话不可用,请稍后重试');
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再提交表单');
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(
async (reviewId: string, reviewTitle?: string): Promise<void> => {
if (!activeId) throw new Error('当前会话不可用,请稍后重试');
if (isRequesting) throw new Error('请等待当前 AI 回复完成后再确认导入');
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, (percent) => {
options.onProgress?.({ percent });
});
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,
};
}