feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具

This commit is contained in:
2026-08-05 17:11:00 +08:00
parent 644c35ce53
commit 0e6e3e2d96
64 changed files with 8395 additions and 6434 deletions

View 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,
};
}