feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
122
apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx
Normal file
122
apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import type { BubbleListProps } from '@ant-design/x';
|
||||
import type { Attachment } from '@ant-design/x/es/attachments';
|
||||
import type { MessageInfo } from '@ant-design/x-sdk';
|
||||
import { Tooltip } from 'antd';
|
||||
import type { AiAttachment, AiChatMessage, AiConversation } from './types';
|
||||
|
||||
export interface ConversationData extends AiConversation {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped';
|
||||
|
||||
export function conversationStatusMeta(status: ConversationRunStatus): {
|
||||
label: string;
|
||||
color: string;
|
||||
} {
|
||||
if (status === 'running') return { label: '生成中', color: 'processing' };
|
||||
if (status === 'done') return { label: '已完成', color: 'success' };
|
||||
if (status === 'error') return { label: '失败', color: 'error' };
|
||||
return { label: '已停止', color: 'default' };
|
||||
}
|
||||
|
||||
export function sortConversations(items: AiConversation[]): AiConversation[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();
|
||||
const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
}
|
||||
|
||||
export function toConversationData(item: AiConversation): ConversationData {
|
||||
return { ...item, key: String(item.id), label: item.title };
|
||||
}
|
||||
|
||||
export function toUploadFile(attachment: AiAttachment): Attachment<AiAttachment> {
|
||||
return {
|
||||
uid: String(attachment.id),
|
||||
name: attachment.name,
|
||||
size: attachment.size,
|
||||
status:
|
||||
attachment.status === 'ready'
|
||||
? 'done'
|
||||
: attachment.status === 'failed'
|
||||
? 'error'
|
||||
: 'uploading',
|
||||
url: attachment.url,
|
||||
response: attachment,
|
||||
description: attachment.error || undefined,
|
||||
cardType: attachment.mimeType.startsWith('image/') ? 'image' : 'file',
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyAssistant(): AiChatMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会话内新发送的用户消息还没有服务端数字 ID(本地为 msg_N 临时 key),
|
||||
* 但紧随其后的 AI 回答会携带 replyToMessageId,可据此反推用户消息 ID。
|
||||
*/
|
||||
export function resolveUserMessageId(
|
||||
info: MessageInfo<AiChatMessage>,
|
||||
all: MessageInfo<AiChatMessage>[],
|
||||
): number | null {
|
||||
if (typeof info.message.id === 'number') return info.message.id;
|
||||
const index = all.findIndex((item) => item.id === info.id);
|
||||
if (index === -1) return null;
|
||||
for (const item of all.slice(index + 1)) {
|
||||
if (typeof item.message.replyToMessageId === 'number') {
|
||||
return item.message.replyToMessageId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface HoverActionItem {
|
||||
key: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** Codex Desktop 风格:hover 消息时在气泡外显示的纯图标操作,不包裹 Button */
|
||||
export function MessageHoverActions({ items }: { items: HoverActionItem[] }) {
|
||||
return (
|
||||
<div className="ai-chat-hover-actions" role="toolbar" aria-label="消息操作">
|
||||
{items.map((item) => (
|
||||
<Tooltip key={item.key} title={item.title}>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`ai-chat-hover-action${item.danger ? ' is-danger' : ''}`}
|
||||
aria-label={item.title}
|
||||
onClick={item.onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
item.onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const aiBubbleRoles: BubbleListProps['role'] = {
|
||||
user: { placement: 'end', variant: 'filled', shape: 'corner' },
|
||||
assistant: { placement: 'start', variant: 'borderless' },
|
||||
};
|
||||
232
apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx
Normal file
232
apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckSquareOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PaperClipOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Attachments from '@ant-design/x/es/attachments';
|
||||
import Conversations from '@ant-design/x/es/conversations';
|
||||
import Sender from '@ant-design/x/es/sender';
|
||||
import type { ConversationItemType } from '@ant-design/x';
|
||||
import type { AttachmentsProps } from '@ant-design/x/es/attachments';
|
||||
import { Button, Dropdown, Spin, Tooltip, Typography } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { AiSkill } from './types';
|
||||
|
||||
export interface AiChatSidebarProps {
|
||||
className?: string;
|
||||
conversationItems: ConversationItemType[];
|
||||
activeConversationKey?: string;
|
||||
selectionMode: boolean;
|
||||
selectedKeys: string[];
|
||||
loadingList: boolean;
|
||||
conversationCount: number;
|
||||
onActiveChange: (key: string) => void;
|
||||
menu?: MenuProps | ((item: ConversationItemType) => MenuProps);
|
||||
onStartNewConversation: () => void;
|
||||
onSelectAll: () => void;
|
||||
onInvertSelection: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onExitSelectionMode: () => void;
|
||||
onEnterSelectionMode: () => void;
|
||||
}
|
||||
|
||||
export const AiChatSidebar: React.FC<AiChatSidebarProps> = ({
|
||||
className,
|
||||
conversationItems,
|
||||
activeConversationKey,
|
||||
selectionMode,
|
||||
selectedKeys,
|
||||
loadingList,
|
||||
conversationCount,
|
||||
onActiveChange,
|
||||
menu,
|
||||
onStartNewConversation,
|
||||
onSelectAll,
|
||||
onInvertSelection,
|
||||
onDeleteSelected,
|
||||
onExitSelectionMode,
|
||||
onEnterSelectionMode,
|
||||
}) => {
|
||||
return (
|
||||
<aside className={className ?? 'ai-chat-sidebar'}>
|
||||
<Conversations
|
||||
items={conversationItems}
|
||||
activeKey={activeConversationKey}
|
||||
onActiveChange={onActiveChange}
|
||||
menu={selectionMode ? undefined : menu}
|
||||
creation={
|
||||
selectionMode
|
||||
? undefined
|
||||
: { label: '新对话', icon: <PlusOutlined />, onClick: onStartNewConversation }
|
||||
}
|
||||
/>
|
||||
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
|
||||
<div className="ai-chat-sidebar__footer">
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<span className="ai-chat-sidebar__selected-count">{selectedKeys.length} 已选</span>
|
||||
<Button size="small" type="text" onClick={onSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={onInvertSelection}>
|
||||
反选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
disabled={selectedKeys.length === 0}
|
||||
onClick={onDeleteSelected}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={onExitSelectionMode}>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CheckSquareOutlined />}
|
||||
disabled={conversationCount === 0}
|
||||
onClick={onEnterSelectionMode}
|
||||
>
|
||||
管理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export interface AiChatComposerProps {
|
||||
conversationTitle: string;
|
||||
input: string;
|
||||
onChange: (value: string) => void;
|
||||
isRequesting: boolean;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
uploadItems: AttachmentsProps['items'];
|
||||
onCustomUpload: AttachmentsProps['customRequest'];
|
||||
onRemoveAttachment: AttachmentsProps['onRemove'];
|
||||
deepThinking: boolean;
|
||||
onDeepThinkingChange: (value: boolean) => void;
|
||||
lockedSkill?: AiSkill;
|
||||
onClearSkill: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
sidebarOpen: boolean;
|
||||
skillMenu: MenuProps;
|
||||
}
|
||||
|
||||
export const AiChatComposer: React.FC<AiChatComposerProps> = ({
|
||||
conversationTitle,
|
||||
input,
|
||||
onChange,
|
||||
isRequesting,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
uploadItems,
|
||||
onCustomUpload,
|
||||
onRemoveAttachment,
|
||||
deepThinking,
|
||||
onDeepThinkingChange,
|
||||
lockedSkill,
|
||||
onClearSkill,
|
||||
onToggleSidebar,
|
||||
sidebarOpen,
|
||||
skillMenu,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="ai-chat-toolbar">
|
||||
<Tooltip title={sidebarOpen ? '收起会话' : '展开会话'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={sidebarOpen ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
|
||||
onClick={onToggleSidebar}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Typography.Text ellipsis>{conversationTitle}</Typography.Text>
|
||||
<Dropdown menu={skillMenu} trigger={['click']}>
|
||||
<Button size="small">{lockedSkill?.name || '自动技能'}</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="ai-chat-composer">
|
||||
<Sender
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
loading={isRequesting}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
// 中文输入法合成中的回车(确认候选词)不应触发发送。
|
||||
// 浏览器在 compositionend 后仍会派发 Enter keydown,
|
||||
// 此时 Sender 内部的 composition 标记已失效,需用
|
||||
// KeyboardEvent.isComposing / keyCode 229 兜底。
|
||||
if (e.nativeEvent.isComposing || e.keyCode === 229) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||||
placeholder="询问学生、考勤、宿舍或账单数据"
|
||||
skill={
|
||||
lockedSkill
|
||||
? {
|
||||
title: lockedSkill.name,
|
||||
value: lockedSkill.key,
|
||||
closable: { onClose: onClearSkill },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
header={
|
||||
(uploadItems ?? []).length > 0 && (
|
||||
<div className="ai-chat-sender-header">
|
||||
<Attachments
|
||||
items={uploadItems}
|
||||
customRequest={onCustomUpload}
|
||||
onRemove={onRemoveAttachment}
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
<div className="ai-chat-sender-footer">
|
||||
<Tooltip title="添加附件">
|
||||
<Attachments
|
||||
items={[]}
|
||||
customRequest={onCustomUpload}
|
||||
onRemove={onRemoveAttachment}
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||||
multiple
|
||||
placeholder={{
|
||||
title: '添加附件',
|
||||
description: '图片、PDF、Word、Excel,单个不超过 10MB',
|
||||
}}
|
||||
>
|
||||
<Button type="text" icon={<PaperClipOutlined />} aria-label="添加附件" />
|
||||
</Attachments>
|
||||
</Tooltip>
|
||||
<Sender.Switch
|
||||
checkedChildren="深度思考"
|
||||
unCheckedChildren="普通"
|
||||
value={deepThinking}
|
||||
onChange={onDeepThinkingChange}
|
||||
disabled={isRequesting}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Typography.Text type="secondary" className="ai-chat-disclaimer">
|
||||
AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,152 +1,71 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
CheckSquareOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ArrowRightOutlined,
|
||||
LoadingOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PaperClipOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Bubble from '@ant-design/x/es/bubble';
|
||||
import Prompts from '@ant-design/x/es/prompts';
|
||||
import Welcome from '@ant-design/x/es/welcome';
|
||||
import type { ConversationItemType } from '@ant-design/x';
|
||||
import { useXConversations } from '@ant-design/x-sdk';
|
||||
import {
|
||||
Attachments,
|
||||
Bubble,
|
||||
Conversations,
|
||||
Prompts,
|
||||
Sender,
|
||||
SenderSwitch,
|
||||
Welcome,
|
||||
} from '@ant-design/x';
|
||||
import type {
|
||||
BubbleItemType,
|
||||
BubbleListProps,
|
||||
ConversationItemType,
|
||||
PromptsItemType,
|
||||
} from '@ant-design/x';
|
||||
import type { Attachment } from '@ant-design/x/es/attachments';
|
||||
import { useXChat, useXConversations, type MessageInfo } from '@ant-design/x-sdk';
|
||||
import {
|
||||
Button,
|
||||
App,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Grid,
|
||||
Input,
|
||||
Modal,
|
||||
Spin,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { MenuProps, UploadFile, UploadProps } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { useSettingsStore } from '../../store/settings/settingsStore';
|
||||
import { aiChatApi, conversationStreamUrl } from './api';
|
||||
import { AiMessageContent } from './AiMessageContent';
|
||||
import { mapHistoryMessage } from './message-mappers';
|
||||
import { GongxueAiChatProvider } from './provider';
|
||||
import type {
|
||||
AiAttachment,
|
||||
AiChatInput,
|
||||
AiChatMessage,
|
||||
AiChatMessageStatus,
|
||||
AiConversation,
|
||||
AiFormSchema,
|
||||
AiReviewSchema,
|
||||
AiReviewSection,
|
||||
AiReviewSectionType,
|
||||
AiSkill,
|
||||
AiSseChunk,
|
||||
} from './types';
|
||||
import { ImportWizardModal } from '../ImportWizard/ImportWizardModal';
|
||||
import type { AiSkill } from './types';
|
||||
import { useAiChatMessageActions } from './useAiChatMessageActions';
|
||||
import { AiChatComposer, AiChatSidebar } from './AiChatDrawer.parts';
|
||||
import {
|
||||
aiBubbleRoles,
|
||||
conversationStatusMeta,
|
||||
sortConversations,
|
||||
toConversationData,
|
||||
type ConversationData,
|
||||
type ConversationRunStatus,
|
||||
} from './AiChatDrawer.helpers';
|
||||
import './style.css';
|
||||
|
||||
export {
|
||||
aiBubbleRoles,
|
||||
conversationStatusMeta,
|
||||
type ConversationData,
|
||||
type ConversationRunStatus,
|
||||
} from './AiChatDrawer.helpers';
|
||||
|
||||
interface AiChatDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRequestingChange?: (working: boolean) => void;
|
||||
}
|
||||
|
||||
interface ConversationData extends AiConversation {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type ConversationRunStatus = 'running' | 'done' | 'error' | 'stopped';
|
||||
|
||||
export function conversationStatusMeta(status: ConversationRunStatus): {
|
||||
label: string;
|
||||
color: string;
|
||||
} {
|
||||
if (status === 'running') return { label: '生成中', color: 'processing' };
|
||||
if (status === 'done') return { label: '已完成', color: 'success' };
|
||||
if (status === 'error') return { label: '失败', color: 'error' };
|
||||
return { label: '已停止', color: 'default' };
|
||||
}
|
||||
|
||||
function sortConversations(items: AiConversation[]): AiConversation[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();
|
||||
const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
}
|
||||
|
||||
function toConversationData(item: AiConversation): ConversationData {
|
||||
return { ...item, key: String(item.id), label: item.title };
|
||||
}
|
||||
|
||||
function toUploadFile(attachment: AiAttachment): Attachment<AiAttachment> {
|
||||
return {
|
||||
uid: String(attachment.id),
|
||||
name: attachment.name,
|
||||
size: attachment.size,
|
||||
status: attachment.status === 'ready' ? 'done' : attachment.status === 'failed' ? 'error' : 'uploading',
|
||||
url: attachment.url,
|
||||
response: attachment,
|
||||
description: attachment.error || undefined,
|
||||
cardType: attachment.mimeType.startsWith('image/') ? 'image' : 'file',
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAssistant(): AiChatMessage {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: '',
|
||||
toolRuns: [],
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const aiBubbleRoles: BubbleListProps['role'] = {
|
||||
user: { placement: 'end', variant: 'filled', shape: 'corner' },
|
||||
assistant: { placement: 'start', variant: 'borderless' },
|
||||
};
|
||||
|
||||
const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequestingChange }) => {
|
||||
const { modal } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm;
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isMobile);
|
||||
const [input, setInput] = useState('');
|
||||
const effectiveSidebarOpen = isMobile ? false : sidebarOpen;
|
||||
const [skills, setSkills] = useState<AiSkill[]>([]);
|
||||
const [attachments, setAttachments] = useState<AiAttachment[]>([]);
|
||||
const deepThinking = useSettingsStore((state) => state.aiChat.deepThinking);
|
||||
const setDeepThinking = useSettingsStore((state) => state.setAiChatDeepThinking);
|
||||
const [conversationStatus, setConversationStatus] = useState<
|
||||
Record<number, ConversationRunStatus>
|
||||
>({});
|
||||
const [importWizardRunId, setImportWizardRunId] = useState<string | null>(null);
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const requestingRef = useRef(false);
|
||||
const abortRef = useRef<() => void>(() => undefined);
|
||||
const attachmentsRef = useRef<AiAttachment[]>([]);
|
||||
const requestAbortRef = useRef(new Map<number, () => void>());
|
||||
const providersRef = useRef(new Map<number, GongxueAiChatProvider>());
|
||||
const loadedRef = useRef(false);
|
||||
const pendingDraftConversationIdRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
conversations,
|
||||
@@ -160,15 +79,16 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
const activeConversationKeyRef = useRef(activeConversationKey);
|
||||
|
||||
const activeConversation = useMemo(
|
||||
() => conversations.find((item) => item.key === activeConversationKey) as ConversationData | undefined,
|
||||
() =>
|
||||
conversations.find((item) => item.key === activeConversationKey) as
|
||||
| ConversationData
|
||||
| undefined,
|
||||
[activeConversationKey, conversations],
|
||||
);
|
||||
const activeId = activeConversation?.id ?? null;
|
||||
const lockedSkill = skills.find((skill) => skill.key === activeConversation?.lockedSkillKey);
|
||||
activeConversationKeyRef.current = activeConversationKey;
|
||||
|
||||
useEffect(() => setSidebarOpen(!isMobile), [isMobile]);
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);
|
||||
setConversations(items);
|
||||
@@ -193,115 +113,53 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
[],
|
||||
);
|
||||
|
||||
const provider = useMemo(
|
||||
() => {
|
||||
if (!activeId) return undefined;
|
||||
const existing = providersRef.current.get(activeId);
|
||||
if (existing) return existing;
|
||||
const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => {
|
||||
void refreshConversations();
|
||||
markConversationFinished(activeId, result);
|
||||
});
|
||||
providersRef.current.set(activeId, created);
|
||||
return created;
|
||||
},
|
||||
[activeId, markConversationFinished, refreshConversations],
|
||||
);
|
||||
const provider = useMemo(() => {
|
||||
if (!activeId) return undefined;
|
||||
const existing = providersRef.current.get(activeId);
|
||||
if (existing) return existing;
|
||||
const created = new GongxueAiChatProvider(conversationStreamUrl(activeId), (result) => {
|
||||
void refreshConversations();
|
||||
markConversationFinished(activeId, result);
|
||||
});
|
||||
providersRef.current.set(activeId, created);
|
||||
return created;
|
||||
}, [activeId, markConversationFinished, refreshConversations]);
|
||||
|
||||
const { messages, onRequest, onReload, isRequesting, abort, setMessage, queueRequest } = useXChat<
|
||||
AiChatMessage,
|
||||
AiChatMessage,
|
||||
AiChatInput,
|
||||
AiSseChunk
|
||||
>({
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
deepThinking,
|
||||
setDeepThinking,
|
||||
isRequesting,
|
||||
messages,
|
||||
stopRequest,
|
||||
submit,
|
||||
customUpload,
|
||||
removeAttachment,
|
||||
discardPendingAttachments,
|
||||
uploadItems,
|
||||
promptItems,
|
||||
bubbleItems,
|
||||
} = useAiChatMessageActions({
|
||||
activeConversation,
|
||||
activeId,
|
||||
provider,
|
||||
conversationKey: activeConversationKey || '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',
|
||||
}),
|
||||
requestAbortRef,
|
||||
markConversationRunning,
|
||||
addConversation,
|
||||
setActiveConversationKey,
|
||||
refreshConversations,
|
||||
skills,
|
||||
lockedSkill,
|
||||
setImportWizardRunId,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// isRequesting 由 @ant-design/x-sdk 的 useXChat 内部维护且没有完成回调,
|
||||
// 这里把它视为外部 SDK 状态做订阅转发,是 Effect 的合理用法。
|
||||
useEffect(() => {
|
||||
onRequestingChange?.(isRequesting);
|
||||
}, [isRequesting, onRequestingChange]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
const discardPendingAttachments = useCallback(() => {
|
||||
const pending = attachmentsRef.current;
|
||||
attachmentsRef.current = [];
|
||||
setAttachments([]);
|
||||
for (const attachment of pending) {
|
||||
void aiChatApi.deleteAttachment(attachment.id).catch(() => undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || loadedRef.current) return;
|
||||
let cancelled = false;
|
||||
@@ -322,10 +180,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
};
|
||||
}, [open, setActiveConversationKey, setConversations]);
|
||||
|
||||
useEffect(() => {
|
||||
discardPendingAttachments();
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}, [activeConversationKey, discardPendingAttachments, isMobile]);
|
||||
const switchConversation = useCallback(
|
||||
(key: string) => {
|
||||
discardPendingAttachments();
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
setActiveConversationKey(key);
|
||||
},
|
||||
[discardPendingAttachments, isMobile, setActiveConversationKey],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -338,17 +200,22 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
|
||||
/** 新建对话(Codex 风格):先进入草稿态,发送第一条消息时才创建 session */
|
||||
const startNewConversation = useCallback(() => {
|
||||
setActiveConversationKey('');
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}, [isMobile, setActiveConversationKey]);
|
||||
switchConversation('');
|
||||
}, [switchConversation]);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
(conversation: ConversationData) => {
|
||||
let title = conversation.title;
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: '重命名会话',
|
||||
icon: <EditOutlined />,
|
||||
content: <Input defaultValue={title} maxLength={100} onChange={(event) => (title = event.target.value)} />,
|
||||
content: (
|
||||
<Input
|
||||
defaultValue={title}
|
||||
maxLength={100}
|
||||
onChange={(event) => (title = event.target.value)}
|
||||
/>
|
||||
),
|
||||
okText: '保存',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
@@ -383,7 +250,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
(conversation: ConversationData) => {
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: '删除会话',
|
||||
content: '该会话及全部历史消息将被永久删除。',
|
||||
okText: '删除',
|
||||
@@ -395,9 +262,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
removeConversation(conversation.key);
|
||||
const remaining = conversations.filter((item) => item.key !== conversation.key);
|
||||
if (!remaining.length) {
|
||||
setActiveConversationKey('');
|
||||
switchConversation('');
|
||||
} else if (conversation.id === activeId) {
|
||||
setActiveConversationKey(remaining[0].key);
|
||||
switchConversation(remaining[0].key);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -405,9 +272,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
[
|
||||
activeId,
|
||||
conversations,
|
||||
switchConversation,
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
setActiveConversationKey,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -443,7 +310,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
selectedKeys.includes(item.key),
|
||||
) as ConversationData[];
|
||||
if (!selected.length) return;
|
||||
Modal.confirm({
|
||||
modal.confirm({
|
||||
title: `删除选中的 ${selected.length} 个会话`,
|
||||
content: '选中的会话及全部历史消息将被永久删除,此操作不可恢复。',
|
||||
okText: '删除',
|
||||
@@ -457,7 +324,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
setConversationStatus({});
|
||||
await aiChatApi.deleteAllConversations();
|
||||
setConversations([]);
|
||||
setActiveConversationKey('');
|
||||
switchConversation('');
|
||||
} else {
|
||||
for (const item of selected) removeConversationEntry(item);
|
||||
const deletedKeys: string[] = [];
|
||||
@@ -477,9 +344,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
const remaining = conversations.filter((item) => !deleted.has(item.key));
|
||||
setConversations(remaining);
|
||||
if (!remaining.length) {
|
||||
setActiveConversationKey('');
|
||||
switchConversation('');
|
||||
} else if (activeId != null && !remaining.some((item) => item.id === activeId)) {
|
||||
setActiveConversationKey(remaining[0].key);
|
||||
switchConversation(remaining[0].key);
|
||||
}
|
||||
if (failedTitles.length) message.error(`删除失败:${failedTitles.join('、')}`);
|
||||
}
|
||||
@@ -493,7 +360,7 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
removeConversation,
|
||||
removeConversationEntry,
|
||||
selectedKeys,
|
||||
setActiveConversationKey,
|
||||
switchConversation,
|
||||
setConversations,
|
||||
]);
|
||||
|
||||
@@ -505,7 +372,9 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
],
|
||||
onClick: ({ key, domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
const conversation = conversations.find((entry) => entry.key === item.key) as ConversationData;
|
||||
const conversation = conversations.find(
|
||||
(entry) => entry.key === item.key,
|
||||
) as ConversationData;
|
||||
if (key === 'rename') renameConversation(conversation);
|
||||
if (key === 'delete') deleteConversation(conversation);
|
||||
},
|
||||
@@ -521,252 +390,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
await aiChatApi.updateConversation(activeConversation.id, { lockedSkillKey: skillKey }),
|
||||
);
|
||||
setConversation(activeConversation.key, updated);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('切换技能失败', error);
|
||||
message.error('切换技能失败');
|
||||
}
|
||||
},
|
||||
[activeConversation, setConversation],
|
||||
);
|
||||
|
||||
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]);
|
||||
|
||||
const reloadMessage = useCallback(
|
||||
(messageInfo: MessageInfo<AiChatMessage>) => {
|
||||
reloadWithStatus(messageInfo);
|
||||
},
|
||||
[reloadWithStatus],
|
||||
);
|
||||
|
||||
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 updateFeedback = useCallback(
|
||||
async (messageInfo: MessageInfo<AiChatMessage>, feedback: 'like' | 'dislike' | null) => {
|
||||
if (typeof messageInfo.message.id !== 'number') return;
|
||||
try {
|
||||
await aiChatApi.setFeedback(messageInfo.message.id, feedback);
|
||||
setMessage(messageInfo.id, {
|
||||
message: { ...messageInfo.message, feedback },
|
||||
});
|
||||
} catch {
|
||||
message.error('提交反馈失败');
|
||||
}
|
||||
},
|
||||
[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,
|
||||
contentRender: (content: AiChatMessage) => (
|
||||
<AiMessageContent
|
||||
message={content}
|
||||
status={info.status as AiChatMessageStatus}
|
||||
onReload={content.role === 'assistant' && info.status !== 'loading' ? () => reloadMessage(info) : undefined}
|
||||
onFeedback={content.role === 'assistant' ? (feedback) => void updateFeedback(info, feedback) : undefined}
|
||||
onSubmitForm={submitForm}
|
||||
onSubmitReview={submitReview}
|
||||
onConfirmReviewStep={confirmReviewStep}
|
||||
onConfirmReviewGroup={confirmReviewGroup}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
[confirmReviewGroup, confirmReviewStep, messages, reloadMessage, submitForm, submitReview, updateFeedback],
|
||||
);
|
||||
|
||||
const conversationItems = useMemo<ConversationItemType[]>(
|
||||
() =>
|
||||
conversations.map((item) => {
|
||||
@@ -822,83 +453,61 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={<span className="ai-chat-title"><RobotOutlined />恭学 AI 助手</span>}
|
||||
title={
|
||||
<span className="ai-chat-title">
|
||||
<RobotOutlined />
|
||||
恭学 AI 助手
|
||||
</span>
|
||||
}
|
||||
open={open}
|
||||
closeIcon={<ArrowRightOutlined title="收起到后台继续运行" />}
|
||||
onClose={onClose}
|
||||
width={isMobile ? '100%' : 'min(1040px, 92vw)'}
|
||||
size={isMobile ? '100%' : 'min(1040px, 92vw)'}
|
||||
destroyOnHidden={false}
|
||||
className="ai-chat-drawer"
|
||||
styles={{ body: { padding: 0, height: '100%' } }}
|
||||
>
|
||||
<div className="ai-chat-layout">
|
||||
<aside className={`ai-chat-sidebar${sidebarOpen ? ' is-open' : ''}`}>
|
||||
<Conversations
|
||||
items={conversationItems}
|
||||
activeKey={activeConversationKey}
|
||||
onActiveChange={(key) => {
|
||||
if (selectionMode) toggleConversationSelection(key);
|
||||
else setActiveConversationKey(key);
|
||||
}}
|
||||
menu={selectionMode ? undefined : conversationMenu}
|
||||
creation={
|
||||
selectionMode
|
||||
? undefined
|
||||
: { label: '新对话', icon: <PlusOutlined />, onClick: startNewConversation }
|
||||
}
|
||||
/>
|
||||
{loadingList && <Spin className="ai-chat-sidebar__loading" />}
|
||||
<div className="ai-chat-sidebar__footer">
|
||||
{selectionMode ? (
|
||||
<>
|
||||
<span className="ai-chat-sidebar__selected-count">{selectedKeys.length} 已选</span>
|
||||
<Button size="small" type="text" onClick={selectAllConversations}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={invertConversationSelection}>
|
||||
反选
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
disabled={selectedKeys.length === 0}
|
||||
onClick={deleteSelectedConversations}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button size="small" type="text" onClick={exitSelectionMode}>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CheckSquareOutlined />}
|
||||
disabled={conversations.length === 0}
|
||||
onClick={enterSelectionMode}
|
||||
>
|
||||
管理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<AiChatSidebar
|
||||
className={`ai-chat-sidebar${effectiveSidebarOpen ? ' is-open' : ''}`}
|
||||
conversationItems={conversationItems}
|
||||
activeConversationKey={activeConversationKey}
|
||||
selectionMode={selectionMode}
|
||||
selectedKeys={selectedKeys}
|
||||
loadingList={loadingList}
|
||||
conversationCount={conversations.length}
|
||||
onActiveChange={(key) => {
|
||||
if (selectionMode) toggleConversationSelection(key);
|
||||
else switchConversation(key);
|
||||
}}
|
||||
menu={conversationMenu}
|
||||
onStartNewConversation={startNewConversation}
|
||||
onSelectAll={selectAllConversations}
|
||||
onInvertSelection={invertConversationSelection}
|
||||
onDeleteSelected={deleteSelectedConversations}
|
||||
onExitSelectionMode={exitSelectionMode}
|
||||
onEnterSelectionMode={enterSelectionMode}
|
||||
/>
|
||||
|
||||
<main className="ai-chat-main">
|
||||
<div className="ai-chat-toolbar">
|
||||
<Tooltip title={sidebarOpen ? '收起会话' : '展开会话'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={sidebarOpen ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
|
||||
onClick={() => setSidebarOpen((value) => !value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Typography.Text ellipsis>{activeConversation?.title || 'AI 助手'}</Typography.Text>
|
||||
<Dropdown menu={skillMenu} trigger={['click']}>
|
||||
<Button size="small">{lockedSkill?.name || '自动技能'}</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<AiChatComposer
|
||||
input={input}
|
||||
onChange={setInput}
|
||||
isRequesting={isRequesting}
|
||||
onSubmit={submit}
|
||||
onCancel={stopRequest}
|
||||
uploadItems={uploadItems}
|
||||
onCustomUpload={customUpload}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
deepThinking={deepThinking}
|
||||
onDeepThinkingChange={setDeepThinking}
|
||||
lockedSkill={lockedSkill}
|
||||
onClearSkill={() => void setLockedSkill(null)}
|
||||
onToggleSidebar={() => setSidebarOpen((value) => !value)}
|
||||
sidebarOpen={effectiveSidebarOpen}
|
||||
skillMenu={skillMenu}
|
||||
conversationTitle={activeConversation?.title || 'AI 助手'}
|
||||
/>
|
||||
|
||||
<div className="ai-chat-messages">
|
||||
{messages.length ? (
|
||||
@@ -909,7 +518,10 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
variant="borderless"
|
||||
icon={<RobotOutlined />}
|
||||
title="你好,我是恭学 AI 助手"
|
||||
description={lockedSkill?.description || '我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'}
|
||||
description={
|
||||
lockedSkill?.description ||
|
||||
'我会在你的权限范围内查询数据,也能通过表单帮你录入学生等业务信息。'
|
||||
}
|
||||
/>
|
||||
<Prompts
|
||||
title="你可以这样问"
|
||||
@@ -921,65 +533,14 @@ const AiChatDrawer: React.FC<AiChatDrawerProps> = ({ open, onClose, onRequesting
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ai-chat-composer">
|
||||
<Sender
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
loading={isRequesting}
|
||||
onSubmit={submit}
|
||||
onCancel={stopRequest}
|
||||
autoSize={{ minRows: 1, maxRows: 6 }}
|
||||
placeholder="询问学生、考勤、宿舍或账单数据"
|
||||
skill={
|
||||
lockedSkill
|
||||
? {
|
||||
title: lockedSkill.name,
|
||||
value: lockedSkill.key,
|
||||
closable: { onClose: () => void setLockedSkill(null) },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
header={
|
||||
uploadItems.length > 0 && (
|
||||
<div className="ai-chat-sender-header">
|
||||
<Attachments
|
||||
items={uploadItems}
|
||||
customRequest={customUpload}
|
||||
onRemove={removeAttachment}
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
footer={
|
||||
<div className="ai-chat-sender-footer">
|
||||
<Tooltip title="添加附件">
|
||||
<Attachments
|
||||
items={[]}
|
||||
customRequest={customUpload}
|
||||
onRemove={removeAttachment}
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx"
|
||||
multiple
|
||||
placeholder={{ title: '添加附件', description: '图片、PDF、Word、Excel,单个不超过 10MB' }}
|
||||
>
|
||||
<Button type="text" icon={<PaperClipOutlined />} aria-label="添加附件" />
|
||||
</Attachments>
|
||||
</Tooltip>
|
||||
<SenderSwitch
|
||||
checkedChildren="深度思考"
|
||||
unCheckedChildren="普通"
|
||||
value={deepThinking}
|
||||
onChange={setDeepThinking}
|
||||
disabled={isRequesting}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{importWizardRunId !== null && (
|
||||
<ImportWizardModal
|
||||
key={importWizardRunId}
|
||||
open
|
||||
runId={importWizardRunId}
|
||||
onClose={() => setImportWizardRunId(null)}
|
||||
/>
|
||||
<Typography.Text type="secondary" className="ai-chat-disclaimer">
|
||||
AI 操作均在权限范围内执行,写操作需通过表单确认,重要信息请以系统记录为准
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
DislikeFilled,
|
||||
DislikeOutlined,
|
||||
LikeFilled,
|
||||
LikeOutlined,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
TableOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Actions,
|
||||
CodeHighlighter,
|
||||
FileCard,
|
||||
Mermaid,
|
||||
Sources,
|
||||
Think,
|
||||
ThoughtChain,
|
||||
} from '@ant-design/x';
|
||||
import FileCard from '@ant-design/x/es/file-card';
|
||||
import Sources from '@ant-design/x/es/sources';
|
||||
import Think from '@ant-design/x/es/think';
|
||||
import ThoughtChain from '@ant-design/x/es/thought-chain';
|
||||
import type { ThoughtChainItemType } from '@ant-design/x';
|
||||
import XMarkdown from '@ant-design/x-markdown';
|
||||
import type { ComponentProps } from '@ant-design/x-markdown';
|
||||
import { Alert, Flex, Space, Typography } from 'antd';
|
||||
import XMarkdown, { type ComponentProps } from '@ant-design/x-markdown';
|
||||
import { Alert, Button, Flex, Input, Space, Typography } from 'antd';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import { DynamicChart } from './DynamicChart';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { DynamicReview } from './DynamicReview';
|
||||
import { LiteCodeHighlighter } from './LiteCodeHighlighter';
|
||||
import { LiteMermaid } from './LiteMermaid';
|
||||
import type {
|
||||
AiAttachment,
|
||||
AiChatMessage,
|
||||
AiChatMessageStatus,
|
||||
AiChartSchema,
|
||||
AiFormSchema,
|
||||
AiMessageFeedback,
|
||||
AiImportWizard,
|
||||
AiReviewSection,
|
||||
AiReviewSchema,
|
||||
AiReviewSectionType,
|
||||
@@ -52,6 +43,7 @@ const toolLabels: Record<string, string> = {
|
||||
render_form: '生成表单',
|
||||
render_review: '生成导入预览',
|
||||
render_chart: '生成图表',
|
||||
start_import_wizard: '生成导入向导',
|
||||
create_student: '创建学生',
|
||||
search_exams: '查询考试',
|
||||
search_schedules: '查询课表',
|
||||
@@ -66,8 +58,8 @@ const markdownComponents = {
|
||||
code: ({ children, lang, block }: ComponentProps) => {
|
||||
const content = String(children ?? '').replace(/\n$/, '');
|
||||
if (!block) return <code>{content}</code>;
|
||||
if (lang === 'mermaid') return <Mermaid>{content}</Mermaid>;
|
||||
return <CodeHighlighter lang={lang || 'text'}>{content}</CodeHighlighter>;
|
||||
if (lang === 'mermaid') return <LiteMermaid>{content}</LiteMermaid>;
|
||||
return <LiteCodeHighlighter lang={lang}>{content}</LiteCodeHighlighter>;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -118,7 +110,8 @@ function ToolChain({ tools }: { tools: AiToolRun[] }) {
|
||||
key: tool.toolCallId,
|
||||
title: toolLabels[tool.toolName] || tool.toolName,
|
||||
description: tool.durationMs ? `${tool.durationMs}ms` : undefined,
|
||||
content: tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'),
|
||||
content:
|
||||
tool.summary || (running ? '正在查询业务数据' : success ? '查询完成' : '查询失败'),
|
||||
status: running ? 'loading' : success ? 'success' : 'error',
|
||||
icon: running ? (
|
||||
<LoadingOutlined spin />
|
||||
@@ -135,11 +128,51 @@ function ToolChain({ tools }: { tools: AiToolRun[] }) {
|
||||
return <ThoughtChain items={items} line="solid" />;
|
||||
}
|
||||
|
||||
function EditUserContent({
|
||||
initial,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
initial: string;
|
||||
onConfirm: (value: string) => void;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(initial);
|
||||
return (
|
||||
<Space orientation="vertical" size={8} className="ai-chat-user-edit">
|
||||
<Input.TextArea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
autoSize={{ minRows: 2, maxRows: 8 }}
|
||||
onKeyDown={(event) => {
|
||||
// 中文输入法合成中的回车不应触发保存
|
||||
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onConfirm(draft);
|
||||
} else if (event.key === 'Escape') {
|
||||
onCancel?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Flex gap={8} justify="flex-end">
|
||||
<Button size="small" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={() => onConfirm(draft)}>
|
||||
保存
|
||||
</Button>
|
||||
</Flex>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AiMessageContentProps {
|
||||
message: AiChatMessage;
|
||||
status?: AiChatMessageStatus;
|
||||
onReload?: () => void;
|
||||
onFeedback?: (feedback: AiMessageFeedback) => void;
|
||||
editing?: boolean;
|
||||
onEditConfirm?: (value: string) => void;
|
||||
onEditCancel?: () => void;
|
||||
onSubmitForm?: (form: AiFormSchema, values: Record<string, unknown>) => void;
|
||||
onSubmitReview?: (reviewId: string, reviewTitle?: string) => void;
|
||||
onConfirmReviewStep?: (
|
||||
@@ -152,17 +185,20 @@ export interface AiMessageContentProps {
|
||||
reviewId: string,
|
||||
type: AiReviewSectionType,
|
||||
) => AiReviewSchema | Promise<AiReviewSchema> | void;
|
||||
onOpenImportWizard?: (runId: string) => void;
|
||||
}
|
||||
|
||||
export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
message,
|
||||
status,
|
||||
onReload,
|
||||
onFeedback,
|
||||
editing,
|
||||
onEditConfirm,
|
||||
onEditCancel,
|
||||
onSubmitForm,
|
||||
onSubmitReview,
|
||||
onConfirmReviewStep,
|
||||
onConfirmReviewGroup,
|
||||
onOpenImportWizard,
|
||||
}) => {
|
||||
const streaming = status === 'loading' || status === 'updating';
|
||||
const formSubmission = message.metadata?.a2uiSubmit;
|
||||
@@ -199,8 +235,8 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
? String((reviewSubmission as Record<string, unknown>).reviewTitle)
|
||||
: '批量导入';
|
||||
return (
|
||||
<Space direction="vertical" size={8} className="ai-chat-user-content">
|
||||
<Alert type="success" showIcon message={`已确认导入《${reviewTitle}》`} />
|
||||
<Space orientation="vertical" size={8} className="ai-chat-user-content">
|
||||
<Alert type="success" showIcon title={`已确认导入《${reviewTitle}》`} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -210,64 +246,55 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
? String((formSubmission as Record<string, unknown>).formTitle)
|
||||
: '表单';
|
||||
return (
|
||||
<Space direction="vertical" size={8} className="ai-chat-user-content">
|
||||
<Alert type="info" showIcon message={`已提交《${formTitle}》`} />
|
||||
<Space orientation="vertical" size={8} className="ai-chat-user-content">
|
||||
<Alert type="info" showIcon title={`已提交《${formTitle}》`} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Space direction="vertical" size={8} className="ai-chat-user-content">
|
||||
{attachmentCards.length > 0 && <Flex wrap gap={8}>{attachmentCards}</Flex>}
|
||||
<div className="ai-chat-user-text">{message.content}</div>
|
||||
<Space orientation="vertical" size={8} className="ai-chat-user-content">
|
||||
{attachmentCards.length > 0 && (
|
||||
<Flex wrap gap={8}>
|
||||
{attachmentCards}
|
||||
</Flex>
|
||||
)}
|
||||
{editing ? (
|
||||
<EditUserContent
|
||||
initial={message.content}
|
||||
onConfirm={(value) => onEditConfirm?.(value)}
|
||||
onCancel={onEditCancel}
|
||||
/>
|
||||
) : (
|
||||
<div className="ai-chat-user-text">{message.content}</div>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const actionItems = [
|
||||
{
|
||||
key: 'copy',
|
||||
label: '复制',
|
||||
icon: <CopyOutlined />,
|
||||
onItemClick: () => void navigator.clipboard.writeText(message.content),
|
||||
},
|
||||
...(onReload
|
||||
? [{ key: 'reload', label: '重新生成', icon: <ReloadOutlined />, onItemClick: onReload }]
|
||||
: []),
|
||||
...(onFeedback
|
||||
? [
|
||||
{
|
||||
key: 'like',
|
||||
label: '有帮助',
|
||||
icon: message.feedback === 'like' ? <LikeFilled /> : <LikeOutlined />,
|
||||
onItemClick: () => onFeedback(message.feedback === 'like' ? null : 'like'),
|
||||
},
|
||||
{
|
||||
key: 'dislike',
|
||||
label: '没帮助',
|
||||
icon: message.feedback === 'dislike' ? <DislikeFilled /> : <DislikeOutlined />,
|
||||
onItemClick: () => onFeedback(message.feedback === 'dislike' ? null : 'dislike'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={10} className="ai-chat-answer">
|
||||
{streaming && !message.content && !message.reasoningContent && message.toolRuns.length === 0 && (
|
||||
<div className="ai-chat-streaming-placeholder" role="status" aria-label="生成中">
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
<Space orientation="vertical" size={10} className="ai-chat-answer">
|
||||
{streaming &&
|
||||
!message.content &&
|
||||
!message.reasoningContent &&
|
||||
message.toolRuns.length === 0 && (
|
||||
<div className="ai-chat-streaming-placeholder" role="status" aria-label="生成中">
|
||||
<LoadingOutlined spin />
|
||||
</div>
|
||||
)}
|
||||
{message.retrying && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`AI 服务繁忙,正在自动重试(第 ${message.retrying.attempt} / ${message.retrying.maxRetries} 次)...`}
|
||||
title={`AI 服务繁忙,正在自动重试(第 ${message.retrying.attempt} / ${message.retrying.maxRetries} 次)...`}
|
||||
description={message.retrying.reason ? `原因:${message.retrying.reason}` : undefined}
|
||||
/>
|
||||
)}
|
||||
{message.reasoningContent && (
|
||||
<Think title={streaming ? '正在思考' : '思考过程'} loading={streaming} defaultExpanded={false}>
|
||||
<Think
|
||||
title={streaming ? '正在思考' : '思考过程'}
|
||||
loading={streaming}
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<XMarkdown
|
||||
content={message.reasoningContent}
|
||||
components={markdownComponents}
|
||||
@@ -279,7 +306,29 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
</Think>
|
||||
)}
|
||||
{message.toolRuns.length > 0 && <ToolChain tools={message.toolRuns} />}
|
||||
{attachmentCards.length > 0 && <Flex wrap gap={8}>{attachmentCards}</Flex>}
|
||||
{attachmentCards.length > 0 && (
|
||||
<Flex wrap gap={8}>
|
||||
{attachmentCards}
|
||||
</Flex>
|
||||
)}
|
||||
{(() => {
|
||||
const wizard = message.metadata?.a2uiImportWizard as AiImportWizard | undefined;
|
||||
if (!wizard || !onOpenImportWizard) return null;
|
||||
return (
|
||||
<Flex wrap gap={8} align="center">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<TableOutlined />}
|
||||
onClick={() => onOpenImportWizard(wizard.runId)}
|
||||
>
|
||||
打开导入向导
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{wizard.fileName}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
})()}
|
||||
{message.content && (
|
||||
<XMarkdown
|
||||
content={message.content}
|
||||
@@ -324,9 +373,8 @@ export const AiMessageContent: React.FC<AiMessageContentProps> = ({
|
||||
{(message.charts ?? []).map((chart: AiChartSchema) => (
|
||||
<DynamicChart key={chart.id} chart={chart} />
|
||||
))}
|
||||
{message.error && <Alert type="error" showIcon message={message.error} />}
|
||||
{message.error && <Alert type="error" showIcon title={message.error} />}
|
||||
{message.cancelled && <Typography.Text type="secondary">回答已停止</Typography.Text>}
|
||||
{!streaming && message.content && <Actions items={actionItems} fadeIn />}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { XCard, registerCatalog } from '@ant-design/x-card';
|
||||
import type { XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
import { Button, Tag, Tooltip, Typography } from 'antd';
|
||||
import React, { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { XCard, registerCatalog, type XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
import { Button, Spin, Tag, Tooltip, Typography } from 'antd';
|
||||
import { DownloadOutlined } from '@ant-design/icons';
|
||||
import type { EChartsType } from 'echarts/core';
|
||||
import ReactECharts, { type EChartsOption } from '../../components/ECharts';
|
||||
import type { EChartsOption } from '../../components/ECharts';
|
||||
import type { AiChartSchema } from './types';
|
||||
|
||||
// echarts 体积较大,仅在真正渲染图表时加载,避免打开 AI 抽屉就拉取
|
||||
const ReactECharts = lazy(() => import('../../components/ECharts'));
|
||||
|
||||
const CHART_CATALOG_ID = 'gongxue-chart-catalog';
|
||||
|
||||
registerCatalog({
|
||||
@@ -41,117 +43,129 @@ const CHART_TYPE_LABELS: Record<string, string> = {
|
||||
funnel: '漏斗图',
|
||||
};
|
||||
|
||||
function buildOption(chart: AiChartSchema): EChartsOption {
|
||||
function buildNameValueRows(chart: AiChartSchema): { name: string; value: number }[] {
|
||||
const nameField = chart.columns[0]?.key ?? '';
|
||||
const valueField = chart.columns[1]?.key ?? '';
|
||||
return chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: numberValue(row[valueField]),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildScatterOption(chart: AiChartSchema): EChartsOption {
|
||||
const columns = chart.columns;
|
||||
if (chart.chartType === 'scatter') {
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const xField = columns[1]?.key ?? '';
|
||||
const yField = columns[2]?.key ?? '';
|
||||
const data = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: [numberValue(row[xField]), numberValue(row[yField])],
|
||||
}));
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: unknown) => {
|
||||
const item = params as { name?: string; value?: number[] };
|
||||
const [x, y] = item.value ?? [];
|
||||
return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`;
|
||||
},
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const xField = columns[1]?.key ?? '';
|
||||
const yField = columns[2]?.key ?? '';
|
||||
const data = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: [numberValue(row[xField]), numberValue(row[yField])],
|
||||
}));
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: unknown) => {
|
||||
const item = params as { name?: string; value?: number[] };
|
||||
const [x, y] = item.value ?? [];
|
||||
return `${item.name ?? ''}: (${x ?? 0}, ${y ?? 0})`;
|
||||
},
|
||||
grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true },
|
||||
xAxis: { type: 'value', name: columns[1]?.title },
|
||||
yAxis: { type: 'value', name: columns[2]?.title },
|
||||
series: [{ type: 'scatter', symbolSize: 10, data }],
|
||||
};
|
||||
}
|
||||
if (chart.chartType === 'radar') {
|
||||
const seriesNameField = columns[0]?.key ?? '';
|
||||
const indicatorColumns = columns.slice(1);
|
||||
const indicators = indicatorColumns.map((column) => {
|
||||
const values = chart.rows.map((row) => numberValue(row[column.key]));
|
||||
const max = Math.max(1, ...values);
|
||||
return { name: column.title, max: Math.ceil(max * 1.1) };
|
||||
});
|
||||
const seriesData = chart.rows.map((row) => ({
|
||||
name: String(row[seriesNameField] ?? ''),
|
||||
value: indicatorColumns.map((column) => numberValue(row[column.key])),
|
||||
}));
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
radar: { indicator: indicators, radius: '65%' },
|
||||
series: [{ type: 'radar', data: seriesData }],
|
||||
};
|
||||
}
|
||||
if (chart.chartType === 'gauge') {
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const valueField = columns[1]?.key ?? '';
|
||||
const maxField = columns[2]?.key;
|
||||
const gauges = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: numberValue(row[valueField]),
|
||||
max: maxField ? Math.max(1, numberValue(row[maxField])) : 100,
|
||||
}));
|
||||
return {
|
||||
series: gauges.map((gauge, index) => ({
|
||||
type: 'gauge',
|
||||
center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'],
|
||||
radius: '75%',
|
||||
min: 0,
|
||||
max: gauge.max,
|
||||
title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 },
|
||||
detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] },
|
||||
data: [{ value: gauge.value, name: gauge.name }],
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (chart.chartType === 'funnel') {
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const valueField = columns[1]?.key ?? '';
|
||||
const data = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: numberValue(row[valueField]),
|
||||
}));
|
||||
return {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'funnel',
|
||||
left: '10%',
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
width: '80%',
|
||||
minSize: '20%',
|
||||
label: { formatter: '{b}: {c}' },
|
||||
data,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (chart.chartType === 'pie') {
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const valueField = columns[1]?.key ?? '';
|
||||
const data = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: numberValue(row[valueField]),
|
||||
}));
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['35%', '68%'],
|
||||
center: ['50%', '45%'],
|
||||
data,
|
||||
label: { formatter: '{b}: {c}' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
grid: { left: 8, right: 16, top: 32, bottom: 48, containLabel: true },
|
||||
xAxis: { type: 'value', name: columns[1]?.title },
|
||||
yAxis: { type: 'value', name: columns[2]?.title },
|
||||
series: [{ type: 'scatter', symbolSize: 10, data }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildRadarOption(chart: AiChartSchema): EChartsOption {
|
||||
const columns = chart.columns;
|
||||
const seriesNameField = columns[0]?.key ?? '';
|
||||
const indicatorColumns = columns.slice(1);
|
||||
const indicators = indicatorColumns.map((column) => {
|
||||
const values = chart.rows.map((row) => numberValue(row[column.key]));
|
||||
const max = Math.max(1, ...values);
|
||||
return { name: column.title, max: Math.ceil(max * 1.1) };
|
||||
});
|
||||
const seriesData = chart.rows.map((row) => ({
|
||||
name: String(row[seriesNameField] ?? ''),
|
||||
value: indicatorColumns.map((column) => numberValue(row[column.key])),
|
||||
}));
|
||||
return {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
radar: { indicator: indicators, radius: '65%' },
|
||||
series: [{ type: 'radar', data: seriesData }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildGaugeOption(chart: AiChartSchema): EChartsOption {
|
||||
const columns = chart.columns;
|
||||
const nameField = columns[0]?.key ?? '';
|
||||
const valueField = columns[1]?.key ?? '';
|
||||
const maxField = columns[2]?.key;
|
||||
const gauges = chart.rows.map((row) => ({
|
||||
name: String(row[nameField] ?? ''),
|
||||
value: numberValue(row[valueField]),
|
||||
max: maxField ? Math.max(1, numberValue(row[maxField])) : 100,
|
||||
}));
|
||||
return {
|
||||
series: gauges.map((gauge, index) => ({
|
||||
type: 'gauge',
|
||||
center: [`${((index + 0.5) * 100) / gauges.length}%`, '58%'],
|
||||
radius: '75%',
|
||||
min: 0,
|
||||
max: gauge.max,
|
||||
title: { show: true, offsetCenter: [0, '82%'], fontSize: 12 },
|
||||
detail: { formatter: '{value}', fontSize: 14, offsetCenter: [0, '20%'] },
|
||||
data: [{ value: gauge.value, name: gauge.name }],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNameValueOption(chart: AiChartSchema): EChartsOption {
|
||||
const data = buildNameValueRows(chart);
|
||||
return chart.chartType === 'funnel'
|
||||
? {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {c}' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'funnel',
|
||||
left: '10%',
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
width: '80%',
|
||||
minSize: '20%',
|
||||
label: { formatter: '{b}: {c}' },
|
||||
data,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, type: 'scroll' },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['35%', '68%'],
|
||||
center: ['50%', '45%'],
|
||||
data,
|
||||
label: { formatter: '{b}: {c}' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildOption(chart: AiChartSchema): EChartsOption {
|
||||
if (chart.chartType === 'scatter') return buildScatterOption(chart);
|
||||
if (chart.chartType === 'radar') return buildRadarOption(chart);
|
||||
if (chart.chartType === 'gauge') return buildGaugeOption(chart);
|
||||
if (chart.chartType === 'funnel' || chart.chartType === 'pie') return buildNameValueOption(chart);
|
||||
return buildCategoryOption(chart);
|
||||
}
|
||||
|
||||
function buildCategoryOption(chart: AiChartSchema): EChartsOption {
|
||||
const columns = chart.columns;
|
||||
const categoryField = columns[0]?.key ?? '';
|
||||
const categories = chart.rows.map((row) => String(row[categoryField] ?? ''));
|
||||
const series = columns.slice(1).map((column) => ({
|
||||
@@ -223,11 +237,9 @@ const ChartPreview: React.FC<ChartPreviewProps> = ({ chart }) => {
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ width: '100%', height: 260 }}
|
||||
onReady={setInstance}
|
||||
/>
|
||||
<Suspense fallback={<Spin size="small" />}>
|
||||
<ReactECharts option={option} style={{ width: '100%', height: 260 }} onReady={setInstance} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -291,5 +303,3 @@ export const DynamicChart: React.FC<DynamicChartProps> = ({ chart }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicChart;
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { XCard, registerCatalog } from '@ant-design/x-card';
|
||||
import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
import { Alert, Button, DatePicker, Flex, Form, Input, InputNumber, Select, Typography } from 'antd';
|
||||
import {
|
||||
XCard,
|
||||
registerCatalog,
|
||||
type ActionPayload,
|
||||
type XAgentCommand_v0_9,
|
||||
} from '@ant-design/x-card';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
DatePicker,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import type { AiFormField, AiFormSchema } from './types';
|
||||
|
||||
@@ -47,7 +61,11 @@ function normalizeValues(
|
||||
}
|
||||
|
||||
interface FormPreviewProps {
|
||||
form?: AiFormSchema;
|
||||
form?: AiFormSchema & {
|
||||
submitting?: boolean;
|
||||
submitted?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
disabled?: boolean;
|
||||
onAction?: (name: string, context: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -58,18 +76,14 @@ interface FormPreviewProps {
|
||||
* normalized values back through the `form:submit` action.
|
||||
*/
|
||||
const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) => {
|
||||
const runtime = form as unknown as {
|
||||
submitting?: boolean;
|
||||
submitted?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
const submitting = Boolean(runtime.submitting);
|
||||
const submitting = Boolean(form?.submitting);
|
||||
const initialValues = useMemo(
|
||||
() => Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])),
|
||||
() =>
|
||||
Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])),
|
||||
[form?.fields],
|
||||
);
|
||||
if (!form) return null;
|
||||
const finished = Boolean(runtime.submitted) || form.status === 'submitted';
|
||||
const finished = Boolean(form.submitted) || form.status === 'submitted';
|
||||
|
||||
const handleFinish = (values: Record<string, unknown>) => {
|
||||
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
|
||||
@@ -124,17 +138,20 @@ const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) =
|
||||
options={field.options}
|
||||
/>
|
||||
) : field.type === 'date' ? (
|
||||
<DatePicker className="ai-chat-dynamic-form__date" placeholder={field.placeholder} />
|
||||
<DatePicker
|
||||
className="ai-chat-dynamic-form__date"
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={field.placeholder} />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
{runtime.error && (
|
||||
{form.error && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={runtime.error}
|
||||
title={form.error}
|
||||
className="ai-chat-dynamic-form__error"
|
||||
/>
|
||||
)}
|
||||
@@ -235,5 +252,3 @@ export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubm
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicForm;
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { XCard, registerCatalog } from '@ant-design/x-card';
|
||||
import type { ActionPayload, XAgentCommand_v0_9 } from '@ant-design/x-card';
|
||||
import { Alert, Button, Flex, Popconfirm, Steps, Table, Tag, Typography } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import type {
|
||||
AiReviewRow,
|
||||
AiReviewSchema,
|
||||
AiReviewSection,
|
||||
AiReviewSectionStatus,
|
||||
AiReviewSectionType,
|
||||
} from './types';
|
||||
import {
|
||||
XCard,
|
||||
registerCatalog,
|
||||
type ActionPayload,
|
||||
type XAgentCommand_v0_9,
|
||||
} from '@ant-design/x-card';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Flex,
|
||||
Popconfirm,
|
||||
Steps,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableProps,
|
||||
} from 'antd';
|
||||
import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types';
|
||||
import {
|
||||
GROUP_STATUS_LABELS,
|
||||
SECTION_ORDER,
|
||||
SECTION_STATUS_LABELS,
|
||||
SECTION_TYPE_LABELS,
|
||||
dependencyHint,
|
||||
groupSections,
|
||||
groupStatus,
|
||||
sectionCount,
|
||||
sectionResultText,
|
||||
sectionStatus,
|
||||
sectionType,
|
||||
} from './reviewSection';
|
||||
|
||||
const REVIEW_CATALOG_ID = 'gongxue-review-catalog';
|
||||
|
||||
@@ -35,125 +55,6 @@ function surfaceId(reviewId: string): string {
|
||||
return `review-${reviewId}`;
|
||||
}
|
||||
|
||||
const SECTION_TYPE_LABELS: Record<AiReviewSectionType, string> = {
|
||||
students: '学生',
|
||||
rooms: '宿舍',
|
||||
transfers: '换宿',
|
||||
checkins: '入住记录',
|
||||
};
|
||||
|
||||
const SECTION_ORDER: AiReviewSectionType[] = [
|
||||
'students',
|
||||
'rooms',
|
||||
'transfers',
|
||||
'checkins',
|
||||
];
|
||||
|
||||
const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionType[]> = {
|
||||
students: [],
|
||||
rooms: [],
|
||||
transfers: ['students', 'rooms'],
|
||||
checkins: [],
|
||||
};
|
||||
|
||||
function sectionType(section: Pick<AiReviewSection, 'key' | 'type'>): AiReviewSectionType {
|
||||
if (
|
||||
section.type === 'students' ||
|
||||
section.type === 'rooms' ||
|
||||
section.type === 'transfers' ||
|
||||
section.type === 'checkins'
|
||||
) {
|
||||
return section.type;
|
||||
}
|
||||
const key = section.key as AiReviewSectionType;
|
||||
if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {
|
||||
return key;
|
||||
}
|
||||
const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));
|
||||
return prefix ?? 'students';
|
||||
}
|
||||
|
||||
function sectionCount(section: AiReviewSection): number {
|
||||
return section.rows.length;
|
||||
}
|
||||
|
||||
function sectionStatus(section: AiReviewSection): AiReviewSectionStatus {
|
||||
return section.status ?? 'pending';
|
||||
}
|
||||
|
||||
function sectionResultText(section: AiReviewSection): string {
|
||||
if (!section.resultSummary) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(section.resultSummary) as { message?: unknown };
|
||||
if (typeof parsed.message === 'string') return parsed.message;
|
||||
} catch {
|
||||
// Older data may store a plain text summary.
|
||||
}
|
||||
return section.resultSummary;
|
||||
}
|
||||
|
||||
const SECTION_STATUS_LABELS: Record<AiReviewSectionStatus, string> = {
|
||||
pending: '待确认',
|
||||
submitted: '已导入',
|
||||
failed: '失败',
|
||||
skipped: '已跳过',
|
||||
};
|
||||
|
||||
type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing';
|
||||
|
||||
const GROUP_STATUS_LABELS: Record<GroupStatus, string> = {
|
||||
pending: '待确认',
|
||||
partial: '部分完成',
|
||||
submitted: '已导入',
|
||||
failed: '失败',
|
||||
importing: '导入中',
|
||||
};
|
||||
|
||||
function groupSections(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
): AiReviewSection[] {
|
||||
return sections.filter((section) => sectionType(section) === type);
|
||||
}
|
||||
|
||||
function groupStatus(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
submittingKey: string | null,
|
||||
submittingGroup: boolean,
|
||||
activeType?: AiReviewSectionType,
|
||||
): GroupStatus {
|
||||
const items = groupSections(sections, type);
|
||||
if (items.length === 0) return 'pending';
|
||||
if (
|
||||
(submittingGroup && type === activeType) ||
|
||||
items.some((item) => submittingKey === item.key)
|
||||
) {
|
||||
return 'importing';
|
||||
}
|
||||
if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';
|
||||
if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function dependencyHint(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
): { step: number; title: string } | null {
|
||||
for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) {
|
||||
const matches = groupSections(sections, dependencyType);
|
||||
if (matches.length === 0) {
|
||||
return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] };
|
||||
}
|
||||
for (const section of matches) {
|
||||
if (sectionStatus(section) !== 'submitted') {
|
||||
return { step: sections.indexOf(section), title: section.title };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function errorMessage(reason: unknown): string {
|
||||
if (reason instanceof Error) return reason.message;
|
||||
if (reason && typeof reason === 'object' && 'message' in reason) {
|
||||
@@ -188,7 +89,14 @@ function SectionTable({ section }: { section: AiReviewSection }) {
|
||||
}
|
||||
|
||||
interface ReviewPreviewProps {
|
||||
review?: AiReviewSchema;
|
||||
review?: AiReviewSchema & {
|
||||
submitting?: boolean;
|
||||
activeKey?: string;
|
||||
activeType?: AiReviewSectionType;
|
||||
submittingKey?: string | null;
|
||||
submittingGroup?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
disabled?: boolean;
|
||||
onAction?: (name: string, context: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -197,27 +105,19 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
if (!review) return null;
|
||||
const submitted = review.status === 'submitted';
|
||||
const expired = review.status === 'expired';
|
||||
const runtime = review as unknown as {
|
||||
submitting?: boolean;
|
||||
activeKey?: string;
|
||||
activeType?: string;
|
||||
submittingKey?: string | null;
|
||||
submittingGroup?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
const submitting = Boolean(runtime.submitting);
|
||||
const submittingKey = runtime.submittingKey ?? null;
|
||||
const submittingGroup = Boolean(runtime.submittingGroup);
|
||||
const submitting = Boolean(review.submitting);
|
||||
const submittingKey = review.submittingKey ?? null;
|
||||
const submittingGroup = Boolean(review.submittingGroup);
|
||||
const sections = review.sections;
|
||||
const presentTypes = SECTION_ORDER.filter((type) =>
|
||||
sections.some((section) => sectionType(section) === type),
|
||||
);
|
||||
const activeType = presentTypes.includes(runtime.activeType as AiReviewSectionType)
|
||||
? (runtime.activeType as AiReviewSectionType)
|
||||
const activeType = presentTypes.includes(review.activeType as AiReviewSectionType)
|
||||
? (review.activeType as AiReviewSectionType)
|
||||
: presentTypes[0];
|
||||
if (!activeType) return null;
|
||||
const activeSection =
|
||||
sections.find((section) => section.key === runtime.activeKey) ??
|
||||
sections.find((section) => section.key === review.activeKey) ??
|
||||
groupSections(sections, activeType)[0];
|
||||
const activeStatus = activeSection ? sectionStatus(activeSection) : 'pending';
|
||||
const dependency =
|
||||
@@ -289,7 +189,10 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
)}
|
||||
<Steps
|
||||
size="small"
|
||||
current={Math.max(0, typeItems.findIndex((item) => item.key === activeType))}
|
||||
current={Math.max(
|
||||
0,
|
||||
typeItems.findIndex((item) => item.key === activeType),
|
||||
)}
|
||||
items={typeItems.map((item) => ({
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
@@ -309,16 +212,18 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
{SECTION_TYPE_LABELS[activeType]} · 共 {group.length} 张表 / {typeTotal} 行
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{GROUP_STATUS_LABELS[
|
||||
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
|
||||
]}
|
||||
{
|
||||
GROUP_STATUS_LABELS[
|
||||
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
|
||||
]
|
||||
}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
{groupDep && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
title={
|
||||
groupDep.step === -1
|
||||
? `「${groupDep.title}」分表尚未生成或导入,请先确认前置步骤`
|
||||
: `请先确认第 ${groupDep.step + 1} 步「${groupDep.title}」`
|
||||
@@ -339,11 +244,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submittingGroup}
|
||||
disabled={!groupReady}
|
||||
>
|
||||
<Button type="primary" loading={submittingGroup} disabled={!groupReady}>
|
||||
{groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) ===
|
||||
'submitted'
|
||||
? '已导入'
|
||||
@@ -372,9 +273,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
wrap
|
||||
gap={8}
|
||||
className="ai-chat-review-card__sheet"
|
||||
onClick={() =>
|
||||
onAction?.('review:selectStep', { sectionKey: section.key })
|
||||
}
|
||||
onClick={() => onAction?.('review:selectStep', { sectionKey: section.key })}
|
||||
>
|
||||
<Flex vertical gap={2} style={{ minWidth: 160 }}>
|
||||
<Typography.Text>
|
||||
@@ -419,7 +318,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`${activeSection.title}:${activeSection.issues.length} 条待处理`}
|
||||
title={`${activeSection.title}:${activeSection.issues.length} 条待处理`}
|
||||
description={
|
||||
<ul className="ai-chat-review__issues">
|
||||
{activeSection.issues.slice(0, 20).map((issue, issueIndex) => (
|
||||
@@ -434,7 +333,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
title={
|
||||
dependency.step === -1
|
||||
? `「${dependency.title}」分表尚未生成或导入,请先确认前置步骤`
|
||||
: `请先确认第 ${dependency.step + 1} 步「${dependency.title}」`
|
||||
@@ -453,7 +352,13 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
<Flex justify="space-between" align="center" wrap gap={8} className="ai-chat-review-card__footer">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap
|
||||
gap={8}
|
||||
className="ai-chat-review-card__footer"
|
||||
>
|
||||
<Typography.Text type="secondary">
|
||||
共 {allRows} 行,含 {allIssues.length} 条提示
|
||||
</Typography.Text>
|
||||
@@ -466,22 +371,18 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
disabled={submitting || anyRunning || disabled}
|
||||
onConfirm={() => onAction?.('review:submit', { reviewId: review.id })}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
disabled={disabled || anyRunning}
|
||||
>
|
||||
<Button type="primary" loading={submitting} disabled={disabled || anyRunning}>
|
||||
全部确认并入库
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Flex>
|
||||
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />}
|
||||
{runtime.error && (
|
||||
{review.error && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={runtime.error}
|
||||
title={review.error}
|
||||
className="ai-chat-review-card__step-error"
|
||||
/>
|
||||
)}
|
||||
@@ -525,6 +426,8 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
const [submittingGroup, setSubmittingGroup] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | undefined>(undefined);
|
||||
const [activeType, setActiveType] = useState<AiReviewSectionType | undefined>(undefined);
|
||||
const activeTypeRef = useRef<AiReviewSectionType | undefined>(activeType);
|
||||
activeTypeRef.current = activeType;
|
||||
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
@@ -537,7 +440,9 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
review.sections.some((section) => sectionType(section) === type),
|
||||
);
|
||||
const preferredType =
|
||||
activeType && types.includes(activeType) ? activeType : types[0];
|
||||
activeTypeRef.current && types.includes(activeTypeRef.current)
|
||||
? activeTypeRef.current
|
||||
: types[0];
|
||||
setActiveType(preferredType);
|
||||
setActiveKey((current) =>
|
||||
current &&
|
||||
@@ -547,7 +452,7 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
? current
|
||||
: review.sections.find((section) => sectionType(section) === preferredType)?.key,
|
||||
);
|
||||
}, [activeType, review]);
|
||||
}, [review]);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = surfaceId(localReview.id);
|
||||
@@ -593,7 +498,16 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
},
|
||||
});
|
||||
setCommands([...cmds]);
|
||||
}, [activeKey, activeType, disabled, error, localReview, submitting, submittingGroup, submittingKey]);
|
||||
}, [
|
||||
activeKey,
|
||||
activeType,
|
||||
disabled,
|
||||
error,
|
||||
localReview,
|
||||
submitting,
|
||||
submittingGroup,
|
||||
submittingKey,
|
||||
]);
|
||||
|
||||
const handleSubmit = async (reviewId: string) => {
|
||||
if (submitting) return;
|
||||
@@ -639,8 +553,7 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
const handleAction = (payload: ActionPayload) => {
|
||||
const context = payload.context ?? {};
|
||||
if (payload.name === 'review:submit') {
|
||||
const reviewId =
|
||||
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
void handleSubmit(reviewId);
|
||||
return;
|
||||
}
|
||||
@@ -648,33 +561,27 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
const type = context.type as AiReviewSectionType | undefined;
|
||||
if (type && SECTION_ORDER.includes(type)) {
|
||||
setActiveType(type);
|
||||
setActiveKey(
|
||||
localReview.sections.find((section) => sectionType(section) === type)?.key,
|
||||
);
|
||||
setActiveKey(localReview.sections.find((section) => sectionType(section) === type)?.key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.name === 'review:selectStep') {
|
||||
if (typeof context.sectionKey === 'string') {
|
||||
const section = localReview.sections.find(
|
||||
(item) => item.key === context.sectionKey,
|
||||
);
|
||||
const section = localReview.sections.find((item) => item.key === context.sectionKey);
|
||||
setActiveKey(context.sectionKey);
|
||||
if (section) setActiveType(sectionType(section));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.name === 'review:confirmStep') {
|
||||
const reviewId =
|
||||
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
if (typeof context.sectionKey === 'string') {
|
||||
void handleConfirmStep(reviewId, context.sectionKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (payload.name === 'review:confirmGroup') {
|
||||
const reviewId =
|
||||
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
const type = context.type as AiReviewSectionType | undefined;
|
||||
if (type && SECTION_ORDER.includes(type)) {
|
||||
void handleConfirmGroup(reviewId, type);
|
||||
@@ -684,16 +591,10 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
|
||||
return (
|
||||
<div className="ai-chat-review">
|
||||
<XCard.Box
|
||||
components={{ ReviewPreview }}
|
||||
commands={commands}
|
||||
onAction={handleAction}
|
||||
>
|
||||
<XCard.Box components={{ ReviewPreview }} commands={commands} onAction={handleAction}>
|
||||
<XCard.Card id={surfaceId(localReview.id)} />
|
||||
</XCard.Box>
|
||||
{error && <Alert type="error" showIcon message={error} className="ai-chat-review__error" />}
|
||||
{error && <Alert type="error" showIcon title={error} className="ai-chat-review__error" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicReview;
|
||||
|
||||
49
apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx
Normal file
49
apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/prism-light';
|
||||
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
|
||||
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
|
||||
import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
|
||||
import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
|
||||
import css from 'react-syntax-highlighter/dist/esm/languages/prism/css';
|
||||
|
||||
// 只注册 AI 对话里常用的语言,避免 @ant-design/x 的 CodeHighlighter
|
||||
// 把所有 prism 语言都打进主包
|
||||
SyntaxHighlighter.registerLanguage('tsx', tsx);
|
||||
SyntaxHighlighter.registerLanguage('typescript', typescript);
|
||||
SyntaxHighlighter.registerLanguage('javascript', javascript);
|
||||
SyntaxHighlighter.registerLanguage('json', json);
|
||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||
SyntaxHighlighter.registerLanguage('shell', bash);
|
||||
SyntaxHighlighter.registerLanguage('sql', sql);
|
||||
SyntaxHighlighter.registerLanguage('css', css);
|
||||
|
||||
const SUPPORTED_LANGUAGES = new Set([
|
||||
'tsx',
|
||||
'typescript',
|
||||
'javascript',
|
||||
'json',
|
||||
'bash',
|
||||
'shell',
|
||||
'sql',
|
||||
'css',
|
||||
]);
|
||||
|
||||
interface LiteCodeHighlighterProps {
|
||||
lang?: string;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) {
|
||||
const language = lang && SUPPORTED_LANGUAGES.has(lang) ? lang : undefined;
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneLight}
|
||||
customStyle={{ margin: '12px 0', borderRadius: 8, fontSize: 13 }}
|
||||
>
|
||||
{children}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
48
apps/admin/src/components/AiChat/LiteMermaid.tsx
Normal file
48
apps/admin/src/components/AiChat/LiteMermaid.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface LiteMermaidProps {
|
||||
children: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量 Mermaid 渲染:动态 import mermaid,只有出现 mermaid 代码块时才加载
|
||||
* mermaid 及其解析器/图布局依赖,避免随 AI 抽屉主包一起加载。
|
||||
*/
|
||||
export function LiteMermaid({ children }: LiteMermaidProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const mermaid = (await import('mermaid')).default;
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
|
||||
const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);
|
||||
if (!cancelled) {
|
||||
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
||||
container.replaceChildren(doc.documentElement);
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : '图表渲染失败');
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [children]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<pre style={{ whiteSpace: 'pre-wrap', color: '#cf1322', fontSize: 12 }}>{children}</pre>
|
||||
);
|
||||
}
|
||||
return <div ref={containerRef} className="ai-chat-mermaid" />;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
AiApiResponse,
|
||||
AiAttachment,
|
||||
AiConversation,
|
||||
AiMessageFeedback,
|
||||
AiMessagePage,
|
||||
AiReviewSchema,
|
||||
AiReviewSection,
|
||||
@@ -15,8 +14,7 @@ const basePath = '/ai/chat/conversations';
|
||||
|
||||
export const aiChatApi = {
|
||||
listSkills: async () => (await api.get<AiApiResponse<AiSkill[]>>('/ai/chat/skills')).data,
|
||||
listConversations: async () =>
|
||||
(await api.get<AiApiResponse<AiConversation[]>>(basePath)).data,
|
||||
listConversations: async () => (await api.get<AiApiResponse<AiConversation[]>>(basePath)).data,
|
||||
createConversation: async (input?: { title?: string; lockedSkillKey?: string | null }) =>
|
||||
(await api.post<AiApiResponse<AiConversation>>(basePath, input ?? {})).data,
|
||||
updateConversation: async (
|
||||
@@ -26,6 +24,12 @@ export const aiChatApi = {
|
||||
deleteConversation: (id: number) => api.delete<void>(`${basePath}/${id}`),
|
||||
deleteAllConversations: async () =>
|
||||
(await api.delete<{ success: boolean; data: { deleted: number } }>(basePath)).data,
|
||||
deleteMessage: async (conversationId: number, messageId: number) =>
|
||||
(
|
||||
await api.delete<AiApiResponse<{ deletedIds: number[] }>>(
|
||||
`${basePath}/${conversationId}/messages/${messageId}`,
|
||||
)
|
||||
).data,
|
||||
uploadAttachment: async (file: File): Promise<AiAttachment> => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
@@ -37,17 +41,6 @@ export const aiChatApi = {
|
||||
).data;
|
||||
},
|
||||
deleteAttachment: (id: number) => api.delete<void>(`/ai/chat/attachments/${id}`),
|
||||
setFeedback: async (
|
||||
messageId: number,
|
||||
feedback: AiMessageFeedback,
|
||||
reason?: string,
|
||||
) =>
|
||||
(
|
||||
await api.patch<AiApiResponse<{ id: number; feedback: AiMessageFeedback }>>(
|
||||
`/ai/chat/messages/${messageId}/feedback`,
|
||||
{ feedback, reason },
|
||||
)
|
||||
).data,
|
||||
confirmReviewStep: async (
|
||||
reviewId: string,
|
||||
sectionKey: AiReviewSection['key'],
|
||||
@@ -90,7 +83,3 @@ export const aiChatApi = {
|
||||
export function conversationStreamUrl(id: number): string {
|
||||
return `/api${basePath}/${id}/stream`;
|
||||
}
|
||||
|
||||
export function regenerateStreamUrl(conversationId: number, messageId: number): string {
|
||||
return `/api${basePath}/${conversationId}/messages/${messageId}/regenerate/stream`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ describe('AI chat history mapper', () => {
|
||||
status: 'completed',
|
||||
errorCode: null,
|
||||
createdAt: '2026-07-23T00:00:00.000Z',
|
||||
feedback: 'like',
|
||||
attachments: [
|
||||
{
|
||||
id: 8,
|
||||
@@ -37,7 +36,6 @@ describe('AI chat history mapper', () => {
|
||||
expect(mapped.message.reasoningContent).toBe('思考');
|
||||
expect(mapped.message.toolRuns[0].summary).toBe('共 4 间');
|
||||
expect(mapped.message.attachments).toHaveLength(1);
|
||||
expect(mapped.message.feedback).toBe('like');
|
||||
});
|
||||
|
||||
it('maps failed and cancelled history to X SDK statuses', () => {
|
||||
|
||||
@@ -65,8 +65,6 @@ export function mapHistoryMessage(record: AiMessageRecord): MessageInfo<AiChatMe
|
||||
reviews: historyReviews(record),
|
||||
charts: historyCharts(record),
|
||||
replyToMessageId: record.replyToMessageId,
|
||||
feedback: record.feedback,
|
||||
feedbackReason: record.feedbackReason,
|
||||
metadata: record.metadata,
|
||||
error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined,
|
||||
cancelled: record.status === 'cancelled',
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('AI chat SSE message reducer', () => {
|
||||
expect(message.toolRuns[0]).toMatchObject({ status: 'success', summary: '找到 1 条记录' });
|
||||
});
|
||||
|
||||
it('tracks processed attachments and final feedback state', () => {
|
||||
it('tracks processed attachments and final message state', () => {
|
||||
let message = reduceAiSseMessage(undefined, {
|
||||
event: 'attachment.processed',
|
||||
data: JSON.stringify({
|
||||
@@ -66,13 +66,11 @@ describe('AI chat SSE message reducer', () => {
|
||||
id: 12,
|
||||
content: '完成',
|
||||
reasoningContent: null,
|
||||
feedback: 'like',
|
||||
attachments: message.attachments,
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(message.attachments).toHaveLength(1);
|
||||
expect(message.feedback).toBe('like');
|
||||
});
|
||||
|
||||
it('uses final content and records cancellation and errors', () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ interface AiSsePayload {
|
||||
form?: AiFormSchema;
|
||||
review?: AiReviewSchema;
|
||||
chart?: AiChartSchema;
|
||||
wizard?: unknown;
|
||||
retry?: AiModelRetryInfo;
|
||||
message?:
|
||||
| string
|
||||
@@ -46,8 +47,6 @@ interface AiSsePayload {
|
||||
toolRuns?: AiToolRun[];
|
||||
attachments?: AiAttachment[];
|
||||
replyToMessageId?: number | null;
|
||||
feedback?: 'like' | 'dislike' | null;
|
||||
feedbackReason?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
error?: string;
|
||||
@@ -79,29 +78,10 @@ function mergeForms(
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeReviews(
|
||||
current: AiReviewSchema[] | undefined,
|
||||
incoming: AiReviewSchema | AiReviewSchema[] | undefined,
|
||||
): AiReviewSchema[] {
|
||||
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
|
||||
if (!items.length) return current ?? [];
|
||||
const next = [...(current ?? [])];
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const index = next.findIndex((existing) => existing.id === item.id);
|
||||
if (index === -1) {
|
||||
next.push(item);
|
||||
} else {
|
||||
next[index] = item;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeCharts(
|
||||
current: AiChartSchema[] | undefined,
|
||||
incoming: AiChartSchema | AiChartSchema[] | undefined,
|
||||
): AiChartSchema[] {
|
||||
function mergeById<T extends { id: string }>(
|
||||
current: T[] | undefined,
|
||||
incoming: T | T[] | undefined,
|
||||
): T[] {
|
||||
const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];
|
||||
if (!items.length) return current ?? [];
|
||||
const next = [...(current ?? [])];
|
||||
@@ -165,6 +145,28 @@ function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRu
|
||||
}));
|
||||
}
|
||||
|
||||
function applyMessagePayload(
|
||||
message: AiChatMessage,
|
||||
nested: AiSsePayload['message'],
|
||||
payload: AiSsePayload,
|
||||
): void {
|
||||
if (typeof nested !== 'object' || nested === null) return;
|
||||
message.forms = mergeForms(
|
||||
message.forms,
|
||||
(nested.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
|
||||
);
|
||||
message.reviews = mergeById<AiReviewSchema>(
|
||||
message.reviews,
|
||||
(nested.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
|
||||
);
|
||||
message.charts = mergeById<AiChartSchema>(
|
||||
message.charts,
|
||||
(nested.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
|
||||
);
|
||||
message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;
|
||||
message.metadata = nested.metadata ?? message.metadata;
|
||||
}
|
||||
|
||||
export function reduceAiSseMessage(
|
||||
originMessage: AiChatMessage | undefined,
|
||||
chunk?: AiSseChunk,
|
||||
@@ -179,22 +181,7 @@ export function reduceAiSseMessage(
|
||||
message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent;
|
||||
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
||||
message.attachments = nested?.attachments ?? message.attachments;
|
||||
message.forms = mergeForms(
|
||||
message.forms,
|
||||
(nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
|
||||
);
|
||||
message.reviews = mergeReviews(
|
||||
message.reviews,
|
||||
(nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
|
||||
);
|
||||
message.charts = mergeCharts(
|
||||
message.charts,
|
||||
(nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
|
||||
);
|
||||
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
|
||||
message.feedback = nested?.feedback ?? message.feedback;
|
||||
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
|
||||
message.metadata = nested?.metadata ?? message.metadata;
|
||||
applyMessagePayload(message, nested, payload);
|
||||
} else if (event === 'reasoning.delta') {
|
||||
message.retrying = null;
|
||||
message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';
|
||||
@@ -206,9 +193,11 @@ export function reduceAiSseMessage(
|
||||
} else if (event === 'ui.form' && payload.form) {
|
||||
message.forms = mergeForms(message.forms, payload.form);
|
||||
} else if (event === 'ui.review' && payload.review) {
|
||||
message.reviews = mergeReviews(message.reviews, payload.review);
|
||||
message.reviews = mergeById<AiReviewSchema>(message.reviews, payload.review);
|
||||
} else if (event === 'ui.chart' && payload.chart) {
|
||||
message.charts = mergeCharts(message.charts, payload.chart);
|
||||
message.charts = mergeById<AiChartSchema>(message.charts, payload.chart);
|
||||
} else if (event === 'ui.import_wizard' && payload.wizard) {
|
||||
message.metadata = { ...message.metadata, a2uiImportWizard: payload.wizard };
|
||||
} else if (event === 'tool.started') {
|
||||
message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running');
|
||||
} else if (event === 'tool.completed') {
|
||||
@@ -227,22 +216,7 @@ export function reduceAiSseMessage(
|
||||
nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent;
|
||||
message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns);
|
||||
message.attachments = nested?.attachments ?? message.attachments;
|
||||
message.forms = mergeForms(
|
||||
message.forms,
|
||||
(nested?.metadata?.a2uiForm as AiFormSchema | undefined) ?? payload.form,
|
||||
);
|
||||
message.reviews = mergeReviews(
|
||||
message.reviews,
|
||||
(nested?.metadata?.a2uiReview as AiReviewSchema | undefined) ?? payload.review,
|
||||
);
|
||||
message.charts = mergeCharts(
|
||||
message.charts,
|
||||
(nested?.metadata?.a2uiChart as AiChartSchema | AiChartSchema[] | undefined) ?? payload.chart,
|
||||
);
|
||||
message.replyToMessageId = nested?.replyToMessageId ?? message.replyToMessageId;
|
||||
message.feedback = nested?.feedback ?? message.feedback;
|
||||
message.feedbackReason = nested?.feedbackReason ?? message.feedbackReason;
|
||||
message.metadata = nested?.metadata ?? message.metadata;
|
||||
applyMessagePayload(message, nested, payload);
|
||||
message.retrying = null;
|
||||
} else if (event === 'message.cancelled') {
|
||||
message.id = payload.messageId ?? message.id;
|
||||
@@ -280,6 +254,16 @@ export async function authenticatedFetch(
|
||||
reasoningEffort: body.reasoningEffort,
|
||||
}),
|
||||
};
|
||||
} else if (body.editMessageId) {
|
||||
requestInput = `${String(input).replace(/\/stream$/, '')}/messages/${body.editMessageId}/edit/stream`;
|
||||
requestInit = {
|
||||
...init,
|
||||
body: JSON.stringify({
|
||||
content: body.message,
|
||||
clientRequestId: body.clientRequestId,
|
||||
reasoningEffort: body.reasoningEffort,
|
||||
}),
|
||||
};
|
||||
} else if (body.formSubmission) {
|
||||
requestInput = `${String(input).replace(/\/conversations\/\d+\/stream$/, '')}/forms/${body.formSubmission.formId}/submit/stream`;
|
||||
requestInit = {
|
||||
@@ -304,6 +288,7 @@ export async function authenticatedFetch(
|
||||
localAttachments: _localAttachments,
|
||||
reloadMessage: _reloadMessage,
|
||||
regenerateMessageId: _regenerateMessageId,
|
||||
editMessageId: _editMessageId,
|
||||
formSubmission: _formSubmission,
|
||||
reviewSubmission: _reviewSubmission,
|
||||
...payload
|
||||
@@ -331,10 +316,7 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
|
||||
/** Routes events that target another (already streamed) message. */
|
||||
onExternalReview?: (messageId: number, review: AiReviewSchema) => void;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void,
|
||||
) {
|
||||
constructor(url: string, onSettled?: (result?: { ok: boolean; aborted?: boolean }) => void) {
|
||||
super({
|
||||
request: XRequest<AiChatInput, AiSseChunk, AiChatMessage>(url, {
|
||||
manual: true,
|
||||
@@ -369,11 +351,16 @@ export class GongxueAiChatProvider extends AbstractChatProvider<
|
||||
formSubmission: requestParams.formSubmission,
|
||||
reviewSubmission: requestParams.reviewSubmission,
|
||||
regenerateMessageId: requestParams.regenerateMessageId,
|
||||
editMessageId: requestParams.editMessageId,
|
||||
reloadMessage: requestParams.reloadMessage,
|
||||
};
|
||||
}
|
||||
|
||||
transformLocalMessage(requestParams: Partial<AiChatInput>): AiChatMessage {
|
||||
transformLocalMessage(requestParams: Partial<AiChatInput>): AiChatMessage | AiChatMessage[] {
|
||||
if (requestParams.editMessageId) {
|
||||
// 编辑消息不需要新增用户气泡,store 里已原位更新原消息。
|
||||
return [];
|
||||
}
|
||||
if (requestParams.formSubmission) {
|
||||
return {
|
||||
role: 'user',
|
||||
|
||||
115
apps/admin/src/components/AiChat/reviewSection.ts
Normal file
115
apps/admin/src/components/AiChat/reviewSection.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { AiReviewSection, AiReviewSectionStatus, AiReviewSectionType } from './types';
|
||||
|
||||
export const SECTION_TYPE_LABELS: Record<AiReviewSectionType, string> = {
|
||||
students: '学生',
|
||||
rooms: '宿舍',
|
||||
transfers: '换宿',
|
||||
checkins: '入住记录',
|
||||
};
|
||||
|
||||
export const SECTION_ORDER: AiReviewSectionType[] = ['students', 'rooms', 'transfers', 'checkins'];
|
||||
|
||||
const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionType[]> = {
|
||||
students: [],
|
||||
rooms: [],
|
||||
transfers: ['students', 'rooms'],
|
||||
checkins: [],
|
||||
};
|
||||
|
||||
export function sectionType(section: Pick<AiReviewSection, 'key' | 'type'>): AiReviewSectionType {
|
||||
if (
|
||||
section.type === 'students' ||
|
||||
section.type === 'rooms' ||
|
||||
section.type === 'transfers' ||
|
||||
section.type === 'checkins'
|
||||
) {
|
||||
return section.type;
|
||||
}
|
||||
const key = section.key as AiReviewSectionType;
|
||||
if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {
|
||||
return key;
|
||||
}
|
||||
const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));
|
||||
return prefix ?? 'students';
|
||||
}
|
||||
|
||||
export function sectionCount(section: AiReviewSection): number {
|
||||
return section.rows.length;
|
||||
}
|
||||
|
||||
export function sectionStatus(section: AiReviewSection): AiReviewSectionStatus {
|
||||
return section.status ?? 'pending';
|
||||
}
|
||||
|
||||
export function sectionResultText(section: AiReviewSection): string {
|
||||
if (!section.resultSummary) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(section.resultSummary) as { message?: unknown };
|
||||
if (typeof parsed.message === 'string') return parsed.message;
|
||||
} catch {
|
||||
// Older data may store a plain text summary.
|
||||
}
|
||||
return section.resultSummary;
|
||||
}
|
||||
|
||||
export const SECTION_STATUS_LABELS: Record<AiReviewSectionStatus, string> = {
|
||||
pending: '待确认',
|
||||
submitted: '已导入',
|
||||
failed: '失败',
|
||||
skipped: '已跳过',
|
||||
};
|
||||
|
||||
export type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing';
|
||||
|
||||
export const GROUP_STATUS_LABELS: Record<GroupStatus, string> = {
|
||||
pending: '待确认',
|
||||
partial: '部分完成',
|
||||
submitted: '已导入',
|
||||
failed: '失败',
|
||||
importing: '导入中',
|
||||
};
|
||||
|
||||
export function groupSections(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
): AiReviewSection[] {
|
||||
return sections.filter((section) => sectionType(section) === type);
|
||||
}
|
||||
|
||||
export function groupStatus(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
submittingKey: string | null,
|
||||
submittingGroup: boolean,
|
||||
activeType?: AiReviewSectionType,
|
||||
): GroupStatus {
|
||||
const items = groupSections(sections, type);
|
||||
if (items.length === 0) return 'pending';
|
||||
if (
|
||||
(submittingGroup && type === activeType) ||
|
||||
items.some((item) => submittingKey === item.key)
|
||||
) {
|
||||
return 'importing';
|
||||
}
|
||||
if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';
|
||||
if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
export function dependencyHint(
|
||||
sections: AiReviewSection[],
|
||||
type: AiReviewSectionType,
|
||||
): { step: number; title: string } | null {
|
||||
for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) {
|
||||
const matches = groupSections(sections, dependencyType);
|
||||
if (matches.length === 0) {
|
||||
return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] };
|
||||
}
|
||||
for (const section of matches) {
|
||||
if (sectionStatus(section) !== 'submitted') {
|
||||
return { step: sections.indexOf(section), title: section.title };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -207,10 +207,68 @@
|
||||
padding: 20px clamp(16px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.ai-chat-messages .ant-bubble {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-chat-messages .ant-bubble-content {
|
||||
max-width: min(100%, 680px);
|
||||
}
|
||||
|
||||
.ai-chat-messages .ant-bubble-extra {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.ai-chat-hover-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid #eceef2;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ai-chat-messages .ant-bubble:hover .ai-chat-hover-actions,
|
||||
.ai-chat-hover-actions:focus-within {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ai-chat-hover-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
color: #5f6672;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ai-chat-hover-action:hover {
|
||||
background: #f0f2f5;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.ai-chat-hover-action.is-danger:hover {
|
||||
background: #fff1f0;
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.ai-chat-user-text {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -221,6 +279,10 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ai-chat-user-edit {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.ai-chat-answer {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
@@ -98,6 +98,23 @@ export interface AiChartSchema {
|
||||
rows: AiReviewRow[];
|
||||
}
|
||||
|
||||
export interface AiImportWizard {
|
||||
runId: string;
|
||||
fileName: string;
|
||||
sheets: Array<{
|
||||
name: string;
|
||||
suggestedStepKey?: AiReviewSectionType | null;
|
||||
headers: string[];
|
||||
rowCount: number;
|
||||
}>;
|
||||
steps: Array<{
|
||||
stepKey: AiReviewSectionType;
|
||||
label: string;
|
||||
sheets: string[];
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AiToolRunStatus =
|
||||
| 'running'
|
||||
| 'success'
|
||||
@@ -126,7 +143,6 @@ export interface AiModelRetryInfo {
|
||||
}
|
||||
|
||||
export type AiMessageRole = 'user' | 'assistant';
|
||||
export type AiMessageFeedback = 'like' | 'dislike' | null;
|
||||
|
||||
export interface AiChatMessage {
|
||||
id?: number | string;
|
||||
@@ -139,8 +155,6 @@ export interface AiChatMessage {
|
||||
reviews?: AiReviewSchema[];
|
||||
charts?: AiChartSchema[];
|
||||
replyToMessageId?: number | null;
|
||||
feedback?: AiMessageFeedback;
|
||||
feedbackReason?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
retrying?: AiModelRetryInfo | null;
|
||||
error?: string;
|
||||
@@ -155,8 +169,6 @@ export interface AiMessageRecord {
|
||||
status: 'pending' | 'completed' | 'failed' | 'cancelled';
|
||||
errorCode: string | null;
|
||||
replyToMessageId?: number | null;
|
||||
feedback?: AiMessageFeedback;
|
||||
feedbackReason?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
attachments?: AiAttachment[];
|
||||
createdAt: string;
|
||||
@@ -176,6 +188,7 @@ export interface AiChatInput {
|
||||
skillKey: string | null;
|
||||
clientRequestId: string;
|
||||
reasoningEffort?: string | null;
|
||||
editMessageId?: number;
|
||||
localAttachments?: AiAttachment[];
|
||||
formSubmission?: {
|
||||
formId: string;
|
||||
|
||||
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