feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user