feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
574
apps/admin/src/components/AiChat/useAiChatMessageActions.tsx
Normal file
574
apps/admin/src/components/AiChat/useAiChatMessageActions.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
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 {
|
||||
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, 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],
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user