- 删除 preflight_import 工具、预检卡、resolve 接口与 ui.import_preflight 事件 - 删除 imports.preflight 解析器与 PreflightReport 类型 - SYSTEM_PROMPT 改为上传 Excel 后直接确认列映射/策略并调用 start_import_wizard - 前端同步移除预检类型/组件/测试,保留导入向导
581 lines
19 KiB
TypeScript
581 lines
19 KiB
TypeScript
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,
|
||
};
|
||
}
|