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

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

View File

@@ -0,0 +1,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>
</>
);
};