first commit

This commit is contained in:
liu
2026-07-13 15:23:29 +08:00
commit 50885a98c8
473 changed files with 33772 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
import {
App,
Button,
Empty,
Space,
Switch,
Tag,
Typography,
theme, Spin, Avatar, Card, Tooltip,
} from 'antd';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { getAgentList, updateAgent } from '@/api/ai/agent.ts';
import type { IAgent } from '@/domain/iAgents.ts';
const { Title, Text, Paragraph } = Typography;
export default function AgentPage() {
const { t } = useTranslation();
const { token } = theme.useToken();
const { message } = App.useApp();
const navigate = useNavigate();
const [agents, setAgents] = useState<IAgent[]>([]);
const [loading, setLoading] = useState(false);
const loadAgents = useCallback(async () => {
setLoading(true);
try {
const res = await getAgentList();
setAgents(res.data.data ?? []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAgents();
}, [loadAgents]);
const handleToggleEnabled = async (id: number, enabled: boolean) => {
try {
await updateAgent(id, { enabled });
setAgents((prev) =>
prev.map((a) => (a.id === id ? { ...a, enabled } : a)),
);
message.success(t('ai.agent.update.success'));
} catch {
message.error(t('ai.agent.update.failed'));
}
};
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: token.marginLG }}>
<div>
<Title level={3} style={{ marginBottom: token.marginXS }}>
{t('ai.agent.page.title')}
</Title>
<Text type="secondary">{t('ai.agent.page.description')}</Text>
</div>
</div>
<Spin spinning={loading}>
{agents.length > 0 ? (
<div className={'flex flex-wrap gap-6'}>
{agents.map((agent) => (
<Tooltip title={agent.description}>
<Card hoverable variant={'borderless'} styles={{ body: { width: 300, padding: 20, overflow: "hidden" }}}>
<div className={'flex justify-between items-center mb-2.5'}>
<Space align={'center'}>
<Avatar src={agent.icon} size={32} />
<span style={{fontWeight: 700, fontSize: 18}}>{agent.name}</span>
</Space>
<Switch
checked={agent.enabled}
size="small"
onChange={(checked) =>
handleToggleEnabled(agent.id!, checked)
}
/>
</div>
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ marginBottom: token.marginSM }}
>
{agent.description}
</Paragraph>
<Space size={[4, 4]} wrap>
{agent.tags?.map((tag) => (
<Tag key={tag} color="blue">
{tag}
</Tag>
))}
</Space>
<div style={{ marginTop: token.marginSM }}>
<Button
type="primary"
size="small"
block
onClick={() => navigate(`/ai/chat?agent_id=${agent.id}`)}
>
{t('ai.agent.goChat')}
</Button>
</div>
</Card>
</Tooltip>
))}
</div>
) : (
<Empty description={t('ai.agent.empty')} />
)}
</Spin>
</>
);
}
+372
View File
@@ -0,0 +1,372 @@
import React, {useEffect, useRef, useState} from 'react';
import {
Bubble, type BubbleListProps,
Conversations,
Sender,
Welcome,
} from '@ant-design/x';
import type { BubbleItemType } from '@ant-design/x';
import {useXConversations, XRequest} from '@ant-design/x-sdk';
import {App, Avatar, Button, Card, Space, Typography, theme, type UploadFile} from 'antd';
import {
DeleteOutlined,
EditOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router';
import { XMarkdown } from '@ant-design/x-markdown';
import '@ant-design/x-markdown/themes/light.css';
import { getConversations, getMessages, deleteConversation } from '@/api/ai/chat.ts';
import { getAgent } from '@/api/ai/agent.ts';
import type { IAgent } from '@/domain/iAgents.ts';
import useGlobalStore from "@/stores/global";
const { Text } = Typography;
/** 自定义气泡扩展字段(通过 extraInfo 传递) */
interface ChatExtraInfo {
thinking?: string;
isStreaming?: boolean;
files?: UploadFile[];
}
const ChatPage: React.FC = () => {
const { t } = useTranslation();
const { message: appMessage, modal } = App.useApp();
const { token } = theme.useToken();
const themeConfig = useGlobalStore(state => state.themeConfig)
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const agentIdParam = searchParams.get('agent_id');
const [agent, setAgent] = useState<IAgent | null>(null);
const bubbleListRef = useRef<any>(null);
const {
conversations,
setConversations,
activeConversationKey,
setActiveConversationKey,
} = useXConversations({});
// 消息列表
const [messages, setMessages] = useState<BubbleItemType[]>([]);
// 加载状态
const [loading, setLoading] = useState(false);
// 输入框内容
const [senderValue, setSenderValue] = useState<string>("");
// 输入框只读
const [senderDisabled, setSenderDisabled] = useState<boolean>(false);
// 加载会话列表
const loadConversations = async () => {
const {data} = await getConversations();
if (data.success && data.data) {
setConversations(data.data);
}
}
// 加载消息列表
const loadMessages = async (conversationId: string) => {
const { data } = await getMessages(conversationId);
if (data.success && data.data) {
setMessages(data.data);
setTimeout(() => {
bubbleListRef.current?.scrollTo({ top: 'bottom' });
}, 100);
}
}
useEffect(() => { loadConversations(); }, []);
useEffect(() => {
if (agentIdParam) {
getAgent(Number(agentIdParam)).then(({ data }) => {
if (data.success && data.data) {
setAgent(data.data);
}
});
}
}, [agentIdParam]);
// 会话操作
const handleActiveChange = (key: string) => {
setActiveConversationKey(key);
loadMessages(key);
}
// 新建会话
const handleNewChat = () => {
setActiveConversationKey('');
setMessages([]);
};
// 删除会话
const handleDelete = (convId: string) => {
modal.confirm({
title: t('ai.chat.delete.confirm'),
okButtonProps: { danger: true },
onOk: async () => {
await deleteConversation(convId);
appMessage.success(t('ai.chat.delete.success'));
if (activeConversationKey === convId) {
setActiveConversationKey('');
setMessages([]);
}
await loadConversations();
},
});
}
// 发送消息
const handleSend = async (text: string) => {
if (!text.trim() || loading) return;
// 用户消息
const userMessage: BubbleItemType = {
key: `user-${Date.now()}`,
role: 'user',
content: text,
status: 'success',
};
// AI 占位消息
const aiMessage: BubbleItemType = {
key: `assistant-${Date.now()}`,
role: 'assistant',
content: '',
loading: true,
extraInfo: { isStreaming: true } as ChatExtraInfo,
};
setMessages((prev) => [...prev, userMessage, aiMessage]);
setLoading(true);
setSenderDisabled(true);
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_BASE_URL || '';
let aiContent = '';
XRequest(`${baseUrl}/ai/chat/send`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'Accept': 'text/event-stream',
},
method: "post",
params: {
conversation_id: activeConversationKey,
message: text,
agent_id: agentIdParam ? Number(agentIdParam) : undefined,
},
callbacks: {
onSuccess: async () => {
setLoading(false);
setSenderValue("");
setSenderDisabled(false);
if (!activeConversationKey) {
const {data} = await getConversations();
if (data.success && data.data && data.data.length > 0) {
setConversations(data.data);
const latest = data.data[0].key;
setActiveConversationKey(latest as string);
}
}
},
onError: () => {
setLoading(false);
setSenderDisabled(false);
},
onUpdate: (msg) => {
console.log("onUpdate", msg);
const payload = msg.data;
if (payload === '[DONE]') return;
try {
const event = JSON.parse(payload);
// 正文内容增量
if (event.type === 'text_delta' && event.delta) {
aiContent += event.delta;
setMessages((prev) => prev.map(m => (
m.key === aiMessage.key ? {
...m,
content: aiContent,
loading: false
} : m
)));
}
// 内容结束
if (event.type === 'text_end') {
setMessages((prev) => prev.map(m => (
m.key === aiMessage.key ? {
...m,
key: event.message_id,
loading: false
} : m
)));
}
} catch (err) {
/* empty */
}
},
}
})
}
// ==================== 角色配置 ====================
const roleConfig: BubbleListProps['role'] = {
assistant: {
placement: 'start',
variant: 'filled',
avatar: <Avatar src={agent?.icon || 'https://file.xinadmin.cn/file/favicons.ico'} />,
// AI 消息内容:Markdown 渲染
contentRender: (content: string, info: any) => {
if (!content) return null;
const isStreaming = info?.extraInfo?.isStreaming === true;
return (
<XMarkdown
content={content}
streaming={{
hasNextChunk: isStreaming,
enableAnimation: true,
tail: isStreaming,
}}
openLinksInNewTab
escapeRawHtml
/>
);
},
},
user: {
placement: 'end',
variant: 'filled',
avatar: (
<Avatar
icon={<UserOutlined />}
style={{ background: token.colorFillSecondary, color: token.colorTextSecondary }}
size={36}
/>
),
},
};
const hasMessages = messages.length > 0;
return (
<Card
styles={{ body: { padding: 0, overflow: 'hidden', display: 'flex', height: 'calc(100vh - 180px)' } }}
title={
<Space align="center">
{agent ? (
<>
<Avatar src={agent.icon} size={36} />
<div>
<span style={{ fontSize: 18, fontWeight: 600, marginRight: 10 }}>{agent.name}</span>
{agent.description && (
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1 }}>{agent.description}</Text>
)}
</div>
</>
) : (
<span>{t('ai.chat.defaultTitle')}</span>
)}
</Space>
}
extra={
<Button onClick={() => navigate('/ai/agent')}>
{t('ai.chat.switchAgent')}
</Button>
}
>
{/* 左侧会话列表 */}
<div
style={{
width: 280,
borderRight: `1px solid ${token.colorBorderSecondary}`
}}
>
<Conversations
items={conversations}
activeKey={activeConversationKey}
onActiveChange={handleActiveChange}
menu={(item) => ({
items: [
{
label: 'Rename',
key: 'Rename',
icon: <EditOutlined />,
},
{
type: 'divider',
},
{
label: 'Delete Chat',
key: 'delete',
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDelete(item.key as string),
},
],
})}
creation={{
onClick: handleNewChat,
}}
style={{ flex: 1, overflow: 'auto' }}
/>
</div>
{/* 右侧对话区域 */}
<div className={"flex-1 flex flex-col min-w-0 px-1"}>
{/* 空状态 - 欢迎页 */}
{!hasMessages && (
<div className={"h-full flex items-center justify-center"}>
<Welcome
icon={agent?.icon || "https://file.xinadmin.cn/file/favicons.ico"}
style={{
backgroundImage: themeConfig.themeScheme === 'dark' ?
'linear-gradient(97deg, rgba(90,196,255,0.12) 0%, rgba(174,136,255,0.12) 100%)'
: 'linear-gradient(97deg, #f2f9fe 0%, #f7f3ff 100%)',
borderRadius: 20,
width: '80%',
padding: '20px'
}}
title={t('ai.chat.welcome.title')}
description={t('ai.chat.welcome.description')}
variant="borderless"
/>
</div>
)}
{/* 消息列表 */}
{hasMessages && (
<div style={{ flex: 1, overflow: 'hidden' }}>
<Bubble.List
ref={bubbleListRef}
items={messages}
autoScroll
style={{ height: '100%', padding: 16 }}
role={roleConfig}
/>
</div>
)}
{/* 发送区域 */}
<div style={{ padding: '0 16px 16px' }}>
<Sender
loading={loading}
disabled={senderDisabled}
value={senderValue}
onChange={setSenderValue}
placeholder={t('ai.chat.placeholder')}
onSubmit={handleSend}
onCancel={() => setLoading(false)}
autoSize={{minRows: 1, maxRows: 6}}
/>
</div>
</div>
</Card>
);
};
export default ChatPage;
+192
View File
@@ -0,0 +1,192 @@
import XinTable from '@/components/XinTable';
import {Button, Drawer, Table, Tag, Tooltip, Typography} from 'antd';
import {EyeOutlined} from '@ant-design/icons';
import type {XinTableColumn} from '@/components/XinTable/typings.ts';
import type {IAgentConversation, IAgentMessage} from '@/domain/iAgents.ts';
import {getMessages} from '@/api/ai/conversation.ts';
import {useTranslation} from 'react-i18next';
import dayjs from 'dayjs';
import {useState} from 'react';
const {Title, Text} = Typography;
export default function AgentConversationPage() {
const {t} = useTranslation();
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerTitle, setDrawerTitle] = useState('');
const [messages, setMessages] = useState<IAgentMessage[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const [messagesTotal, setMessagesTotal] = useState(0);
const [messagesPage, setMessagesPage] = useState(1);
const [currentConversationId, setCurrentConversationId] = useState('');
const fetchMessages = async (conversationId: string, page: number) => {
setMessagesLoading(true);
try {
const res = await getMessages(conversationId, {page, pageSize: 20});
const listData = res.data.data!;
setMessages(listData.data);
setMessagesTotal(listData.total);
} finally {
setMessagesLoading(false);
}
};
const handleViewMessages = async (record: IAgentConversation) => {
setCurrentConversationId(record.id!);
setDrawerTitle(record.title || '');
setMessagesPage(1);
setDrawerOpen(true);
await fetchMessages(record.id!, 1);
};
const handleMessagesPageChange = (page: number) => {
setMessagesPage(page);
fetchMessages(currentConversationId, page);
};
const columns: XinTableColumn<IAgentConversation>[] = [
{
title: t('ai.conversation.id'),
dataIndex: 'id',
hideInForm: true,
width: 260,
ellipsis: true,
align: 'center',
},
{
title: t('ai.conversation.username'),
dataIndex: 'username',
hideInForm: true,
align: 'center',
width: 120,
render: (value: string) => value || t('ai.conversation.noUser'),
},
{
title: t('ai.conversation.title'),
dataIndex: 'title',
valueType: 'text',
ellipsis: true,
},
{
title: t('ai.conversation.messageCount'),
dataIndex: 'message_count',
hideInForm: true,
hideInSearch: true,
align: 'center',
width: 100,
},
{
title: t('ai.conversation.createdAt'),
dataIndex: 'created_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
width: 180,
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-',
},
{
title: t('ai.conversation.updatedAt'),
dataIndex: 'updated_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
width: 180,
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-',
},
];
const messageColumns = [
{
title: t('ai.conversation.message.role'),
dataIndex: 'role',
width: 100,
render: (role: string) => {
const colorMap: Record<string, string> = {
user: 'blue',
assistant: 'green',
system: 'orange',
};
return <Tag color={colorMap[role] || 'default'}>{t(`ai.conversation.message.role.${role}`, role)}</Tag>;
},
},
{
title: t('ai.conversation.message.agent'),
dataIndex: 'agent',
width: 150,
ellipsis: true,
},
{
title: t('ai.conversation.message.content'),
dataIndex: 'content',
ellipsis: true,
},
{
title: t('ai.conversation.message.createdAt'),
dataIndex: 'created_at',
width: 180,
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-',
},
];
return (
<>
<div className={'mb-5'}>
<Title level={3}>{t('ai.conversation.page.title')}</Title>
<Text type="secondary">{t('ai.conversation.page.description')}</Text>
</div>
<XinTable<IAgentConversation>
api={'/ai/conversation'}
columns={columns}
rowKey={'id'}
accessName={'ai.conversation'}
addShow={false}
editShow={false}
formProps={false}
operateProps={{
fixed: 'right',
width: 120,
}}
operateRender={(record, dom) => [
<Tooltip title={t('ai.conversation.viewMessages')} key="view">
<Button
type="primary"
icon={<EyeOutlined/>}
size="small"
onClick={() => handleViewMessages(record)}
/>
</Tooltip>,
dom.del,
]}
scroll={{x: 1100}}
cardProps={{
variant: 'borderless'
}}
/>
<Drawer
title={`${t('ai.conversation.messageTitle')} - ${drawerTitle}`}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={900}
>
<Table<IAgentMessage>
dataSource={messages}
columns={messageColumns}
rowKey="id"
loading={messagesLoading}
pagination={{
current: messagesPage,
total: messagesTotal,
pageSize: 20,
onChange: handleMessagesPageChange,
showSizeChanger: false,
}}
scroll={{x: 700}}
size="small"
/>
</Drawer>
</>
);
}