From f3b59935d69017883851f79edda0621b7e1dbf40 Mon Sep 17 00:00:00 2001 From: xiong Date: Thu, 23 Jul 2026 14:24:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20AI=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E4=B8=8E=E5=8F=AA=E8=AF=BB=E6=9F=A5=E8=AF=A2=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/package.json | 4 + .../src/components/AiChat/AiChatDrawer.tsx | 381 ++++ .../components/AiChat/AiMessageContent.tsx | 112 ++ .../components/AiChat/api.integration.test.ts | 39 + apps/admin/src/components/AiChat/api.ts | 36 + .../AiChat/bubble.integration.test.tsx | 41 + .../message-mappers.integration.test.ts | 41 + .../src/components/AiChat/message-mappers.ts | 33 + .../AiChat/provider.integration.test.ts | 98 + apps/admin/src/components/AiChat/provider.ts | 189 ++ apps/admin/src/components/AiChat/style.css | 185 ++ apps/admin/src/components/AiChat/types.ts | 74 + apps/admin/src/layouts/MainLayout.tsx | 21 +- apps/admin/src/pages/Permissions/index.tsx | 1 + apps/admin/src/pages/Roles/index.tsx | 1 + .../agent-business-scope.factory.ts | 39 + .../src/agent-tools/agent-tools.module.ts | 33 +- .../agent-tools/tools/business-tools.spec.ts | 79 + .../tools/get-attendance-summary.tool.ts | 30 + .../tools/get-dashboard-stats.tool.ts | 19 + .../tools/get-room-occupancy-summary.tool.ts | 21 + .../agent-tools/tools/search-bills.tool.ts | 24 + .../agent-tools/tools/search-classes.tool.ts | 29 + .../agent-tools/tools/search-rooms.tool.ts | 22 + .../src/agent-tools/tools/tool-input.ts | 55 + apps/server/src/ai-chat/ai-chat.controller.ts | 139 ++ .../src/ai-chat/ai-chat.migration.spec.ts | 46 + apps/server/src/ai-chat/ai-chat.module.ts | 20 + .../src/ai-chat/ai-chat.service.spec.ts | 154 ++ apps/server/src/ai-chat/ai-chat.service.ts | 424 +++++ apps/server/src/ai-chat/ai-chat.types.ts | 37 + .../ai-chat/ai-model-stream.service.spec.ts | 68 + .../src/ai-chat/ai-model-stream.service.ts | 252 +++ apps/server/src/ai-chat/dto/ai-chat.dto.ts | 38 + .../entities/ai-conversation.entity.ts | 42 + .../src/ai-chat/entities/ai-message.entity.ts | 56 + .../ai-chat/entities/ai-tool-run.entity.ts | 47 + apps/server/src/ai-chat/entities/index.ts | 3 + apps/server/src/ai-chat/index.ts | 2 + apps/server/src/app.module.ts | 10 + .../src/attendance/attendance.service.ts | 42 + apps/server/src/bills/bills.service.ts | 47 + apps/server/src/classes/classes.service.ts | 43 + apps/server/src/dashboard/dashboard.module.ts | 1 + .../server/src/dashboard/dashboard.service.ts | 31 + apps/server/src/entities/index.ts | 1 + apps/server/src/migration-runner.ts | 2 + .../src/migrations/1784780000000-AddAiChat.ts | 83 + apps/server/src/rbac/rbac.seed.spec.ts | 55 + apps/server/src/rbac/rbac.service.ts | 1 + apps/server/src/rooms/rooms.service.ts | 72 + package-lock.json | 1623 ++++++++++++++++- 52 files changed, 4942 insertions(+), 4 deletions(-) create mode 100644 apps/admin/src/components/AiChat/AiChatDrawer.tsx create mode 100644 apps/admin/src/components/AiChat/AiMessageContent.tsx create mode 100644 apps/admin/src/components/AiChat/api.integration.test.ts create mode 100644 apps/admin/src/components/AiChat/api.ts create mode 100644 apps/admin/src/components/AiChat/bubble.integration.test.tsx create mode 100644 apps/admin/src/components/AiChat/message-mappers.integration.test.ts create mode 100644 apps/admin/src/components/AiChat/message-mappers.ts create mode 100644 apps/admin/src/components/AiChat/provider.integration.test.ts create mode 100644 apps/admin/src/components/AiChat/provider.ts create mode 100644 apps/admin/src/components/AiChat/style.css create mode 100644 apps/admin/src/components/AiChat/types.ts create mode 100644 apps/server/src/agent-tools/agent-business-scope.factory.ts create mode 100644 apps/server/src/agent-tools/tools/business-tools.spec.ts create mode 100644 apps/server/src/agent-tools/tools/get-attendance-summary.tool.ts create mode 100644 apps/server/src/agent-tools/tools/get-dashboard-stats.tool.ts create mode 100644 apps/server/src/agent-tools/tools/get-room-occupancy-summary.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-bills.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-classes.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-rooms.tool.ts create mode 100644 apps/server/src/agent-tools/tools/tool-input.ts create mode 100644 apps/server/src/ai-chat/ai-chat.controller.ts create mode 100644 apps/server/src/ai-chat/ai-chat.migration.spec.ts create mode 100644 apps/server/src/ai-chat/ai-chat.module.ts create mode 100644 apps/server/src/ai-chat/ai-chat.service.spec.ts create mode 100644 apps/server/src/ai-chat/ai-chat.service.ts create mode 100644 apps/server/src/ai-chat/ai-chat.types.ts create mode 100644 apps/server/src/ai-chat/ai-model-stream.service.spec.ts create mode 100644 apps/server/src/ai-chat/ai-model-stream.service.ts create mode 100644 apps/server/src/ai-chat/dto/ai-chat.dto.ts create mode 100644 apps/server/src/ai-chat/entities/ai-conversation.entity.ts create mode 100644 apps/server/src/ai-chat/entities/ai-message.entity.ts create mode 100644 apps/server/src/ai-chat/entities/ai-tool-run.entity.ts create mode 100644 apps/server/src/ai-chat/entities/index.ts create mode 100644 apps/server/src/ai-chat/index.ts create mode 100644 apps/server/src/migrations/1784780000000-AddAiChat.ts diff --git a/apps/admin/package.json b/apps/admin/package.json index d7d7673..e5bbac1 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -14,6 +14,9 @@ }, "dependencies": { "@ant-design/icons": "^6.1.1", + "@ant-design/x": "2.8.0", + "@ant-design/x-markdown": "2.8.0", + "@ant-design/x-sdk": "2.8.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -22,6 +25,7 @@ "dayjs": "^1.11.20", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", + "lucide-react": "^0.468.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.14.1", diff --git a/apps/admin/src/components/AiChat/AiChatDrawer.tsx b/apps/admin/src/components/AiChat/AiChatDrawer.tsx new file mode 100644 index 0000000..4f5e1ba --- /dev/null +++ b/apps/admin/src/components/AiChat/AiChatDrawer.tsx @@ -0,0 +1,381 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + DeleteOutlined, + EditOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + PlusOutlined, + ReloadOutlined, + RobotOutlined, +} from '@ant-design/icons'; +import { Bubble, Conversations, Sender } from '@ant-design/x'; +import type { BubbleItemType, BubbleListProps, ConversationItemType } from '@ant-design/x'; +import { useXChat, type MessageInfo } from '@ant-design/x-sdk'; +import { Button, Drawer, Empty, Grid, Input, Modal, Spin, Tooltip, Typography } from 'antd'; +import type { MenuProps } from 'antd'; +import { message } from '../../ui/app-message'; +import { aiChatApi, conversationStreamUrl } from './api'; +import { AiMessageContent } from './AiMessageContent'; +import { mapHistoryMessage } from './message-mappers'; +import { GongxueAiChatProvider } from './provider'; +import type { AiChatInput, AiChatMessage, AiConversation, AiSseChunk } from './types'; +import './style.css'; + +interface AiChatDrawerProps { + open: boolean; + onClose: () => void; +} + +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 const aiBubbleRoles: BubbleListProps['role'] = { + user: { + placement: 'end', + variant: 'filled', + contentRender: (content: AiChatMessage) => , + }, + assistant: { + placement: 'start', + variant: 'borderless', + contentRender: (content: AiChatMessage, info) => ( + + ), + }, +}; + +const AiChatDrawer: React.FC = ({ open, onClose }) => { + const screens = Grid.useBreakpoint(); + const isMobile = !screens.sm; + const [conversations, setConversations] = useState([]); + const [activeId, setActiveId] = useState(null); + const [loadingList, setLoadingList] = useState(false); + const [loadingMessages, setLoadingMessages] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(!isMobile); + const [input, setInput] = useState(''); + const requestingRef = React.useRef(false); + + useEffect(() => setSidebarOpen(!isMobile), [isMobile]); + + const refreshConversations = useCallback(async () => { + const data = sortConversations(await aiChatApi.listConversations()); + setConversations(data); + setActiveId((current) => + current && data.some((item) => item.id === current) ? current : (data[0]?.id ?? null), + ); + }, []); + + const provider = useMemo( + () => + activeId + ? new GongxueAiChatProvider(conversationStreamUrl(activeId), () => { + void refreshConversations(); + }) + : undefined, + [activeId, refreshConversations], + ); + + const { messages, onRequest, onReload, isRequesting, abort, setMessages } = useXChat< + AiChatMessage, + AiChatMessage, + AiChatInput, + AiSseChunk + >({ + provider, + conversationKey: activeId ? String(activeId) : 'no-conversation', + requestPlaceholder: { + role: 'assistant', + content: '', + reasoningContent: '', + toolRuns: [], + }, + requestFallback: ( + _params: Partial, + { + error, + messageInfo, + }: { + error: Error; + messageInfo: MessageInfo; + messages: AiChatMessage[]; + errorInfo?: unknown; + }, + ) => ({ + ...(messageInfo?.message || { + role: 'assistant' as const, + content: '', + reasoningContent: '', + toolRuns: [], + }), + error: error.name === 'AbortError' ? undefined : '连接中断,请稍后重试', + cancelled: error.name === 'AbortError', + }), + }); + requestingRef.current = isRequesting; + const abortRef = React.useRef(abort); + abortRef.current = abort; + + const stopRequest = useCallback(() => { + if (requestingRef.current) abortRef.current(); + }, []); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoadingList(true); + aiChatApi + .listConversations() + .then(async (items) => { + if (cancelled) return; + let next = sortConversations(items); + if (next.length === 0) next = [await aiChatApi.createConversation()]; + if (cancelled) return; + setConversations(next); + setActiveId((current) => current ?? next[0].id); + }) + .catch(() => message.error('加载 AI 会话失败')) + .finally(() => !cancelled && setLoadingList(false)); + return () => { + cancelled = true; + }; + }, [open]); + + useEffect(() => { + if (!open || !activeId) { + setMessages([]); + return; + } + let cancelled = false; + stopRequest(); + setLoadingMessages(true); + aiChatApi + .listMessages(activeId) + .then((page) => { + if (!cancelled) setMessages(page.items.map(mapHistoryMessage)); + }) + .catch(() => !cancelled && message.error('加载会话记录失败')) + .finally(() => !cancelled && setLoadingMessages(false)); + return () => { + cancelled = true; + }; + }, [activeId, open, setMessages, stopRequest]); + + const createConversation = async () => { + try { + stopRequest(); + const created = await aiChatApi.createConversation(); + setConversations((items) => [created, ...items]); + setActiveId(created.id); + if (isMobile) setSidebarOpen(false); + } catch { + message.error('新建会话失败'); + } + }; + + const renameConversation = (conversation: AiConversation) => { + let title = conversation.title; + Modal.confirm({ + title: '重命名会话', + icon: , + content: ( + (title = event.target.value)} + /> + ), + okText: '保存', + cancelText: '取消', + onOk: async () => { + const normalized = title.trim(); + if (!normalized) throw new Error('请输入会话名称'); + const updated = await aiChatApi.renameConversation(conversation.id, normalized); + setConversations((items) => items.map((item) => (item.id === updated.id ? updated : item))); + }, + }); + }; + + const deleteConversation = (conversation: AiConversation) => { + Modal.confirm({ + title: '删除会话', + content: '该会话及全部历史消息将被永久删除。', + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + if (conversation.id === activeId) stopRequest(); + await aiChatApi.deleteConversation(conversation.id); + const remaining = conversations.filter((item) => item.id !== conversation.id); + if (remaining.length > 0) { + setConversations(remaining); + if (conversation.id === activeId) setActiveId(remaining[0].id); + } else { + const created = await aiChatApi.createConversation(); + setConversations([created]); + setActiveId(created.id); + } + } catch { + message.error('删除会话失败'); + throw new Error('删除会话失败'); + } + }, + }); + }; + + const submit = (value: string) => { + const normalized = value.trim(); + if (!normalized || !activeId || isRequesting) return; + onRequest({ message: normalized }); + setInput(''); + }; + + const retryMessage = (assistantIndex: number, assistantId: string | number) => { + const previous = [...messages.slice(0, assistantIndex)] + .reverse() + .find((item) => item.message.role === 'user'); + if (!previous?.message.content) return; + onReload(assistantId, { message: previous.message.content }); + }; + + const conversationItems = conversations.map((item) => ({ + key: String(item.id), + label: item.title, + })); + const bubbleItems: BubbleItemType[] = messages.map( + (item: MessageInfo, index: number) => ({ + key: item.id, + role: item.message.role, + status: item.status, + content: item.message, + streaming: item.status === 'loading' || item.status === 'updating', + loading: + item.message.role === 'assistant' && + item.status === 'loading' && + !item.message.content && + !item.message.reasoningContent, + footer: + item.message.role === 'assistant' && (item.status === 'error' || item.message.error) + ? () => ( + + ) + : undefined, + }), + ); + + return ( + + + AI 助理 + + } + placement="right" + width={isMobile ? '100%' : 920} + open={open} + onClose={() => { + stopRequest(); + onClose(); + }} + destroyOnHidden + className="ai-chat-drawer" + styles={{ body: { padding: 0 } }} + > +
+ + +
+
+ +
+
+ {loadingMessages ? ( + + ) : bubbleItems.length === 0 ? ( + + ) : ( + + )} +
+
+ + + AI 仅能读取您有权访问的数据,请核对重要结果。 + +
+
+
+
+ ); +}; + +export default AiChatDrawer; diff --git a/apps/admin/src/components/AiChat/AiMessageContent.tsx b/apps/admin/src/components/AiChat/AiMessageContent.tsx new file mode 100644 index 0000000..d810659 --- /dev/null +++ b/apps/admin/src/components/AiChat/AiMessageContent.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons'; +import { CodeHighlighter, Think } from '@ant-design/x'; +import XMarkdown from '@ant-design/x-markdown'; +import type { ComponentProps } from '@ant-design/x-markdown'; +import { Alert, Space, Tag, Typography } from 'antd'; +import type { AiChatMessage, AiChatMessageStatus, AiToolRun } from './types'; + +const toolLabels: Record = { + search_students: '查询学生', + get_student_basic: '读取学生信息', + search_classes: '查询班级', + get_attendance_summary: '统计考勤', + search_rooms: '查询房间', + get_room_occupancy_summary: '统计入住', + search_bills: '查询账单', + get_dashboard_stats: '读取经营概览', +}; + +const markdownComponents = { + code: ({ children, lang, block }: ComponentProps) => { + const content = String(children ?? '').replace(/\n$/, ''); + if (!block) return {content}; + return {content}; + }, +}; + +const markdownSanitizerConfig = { + ALLOW_UNKNOWN_PROTOCOLS: false, + FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'], + FORBID_ATTR: ['style'], +}; + +function ToolStatus({ tool }: { tool: AiToolRun }) { + const isRunning = tool.status === 'running'; + const isSuccess = tool.status === 'success'; + const icon = isRunning ? ( + + ) : isSuccess ? ( + + ) : ( + + ); + const color = isRunning ? 'processing' : isSuccess ? 'success' : 'error'; + const statusText = isRunning ? '查询中' : isSuccess ? '查询完成' : tool.summary || '查询失败'; + return ( +
+ + {toolLabels[tool.toolName] || tool.toolName} + + + {statusText} + +
+ ); +} + +export const AiMessageContent: React.FC<{ + message: AiChatMessage; + status?: AiChatMessageStatus; +}> = ({ message, status }) => { + if (message.role === 'user') return
{message.content}
; + const streaming = status === 'loading' || status === 'updating'; + return ( + + {message.reasoningContent && ( + + + + )} + {message.toolRuns.length > 0 && ( +
+ {message.toolRuns.map((tool) => ( + + ))} +
+ )} + {message.content && ( + + )} + {message.error && } + {message.cancelled && 回答已停止} +
+ ); +}; diff --git a/apps/admin/src/components/AiChat/api.integration.test.ts b/apps/admin/src/components/AiChat/api.integration.test.ts new file mode 100644 index 0000000..4080c24 --- /dev/null +++ b/apps/admin/src/components/AiChat/api.integration.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import api from '../../api'; +import { aiChatApi } from './api'; + +describe('AI chat API adapter', () => { + afterEach(() => vi.restoreAllMocks()); + + it('unwraps the backend success/data response', async () => { + vi.spyOn(api, 'get').mockResolvedValue({ + success: true, + data: [ + { + id: 1, + title: '会话', + createdAt: '2026-07-23T00:00:00.000Z', + updatedAt: '2026-07-23T00:00:00.000Z', + lastMessageAt: null, + }, + ], + }); + + await expect(aiChatApi.listConversations()).resolves.toMatchObject([{ id: 1, title: '会话' }]); + }); + + it('loads every history page in chronological page order', async () => { + vi.spyOn(api, 'get') + .mockResolvedValueOnce({ + success: true, + data: { items: [{ id: 1 }], total: 101, page: 1, limit: 100 }, + }) + .mockResolvedValueOnce({ + success: true, + data: { items: [{ id: 101 }], total: 101, page: 2, limit: 100 }, + }); + + const page = await aiChatApi.listMessages(3); + expect(page.items.map((item) => item.id)).toEqual([1, 101]); + }); +}); diff --git a/apps/admin/src/components/AiChat/api.ts b/apps/admin/src/components/AiChat/api.ts new file mode 100644 index 0000000..b238665 --- /dev/null +++ b/apps/admin/src/components/AiChat/api.ts @@ -0,0 +1,36 @@ +import api from '../../api'; +import type { AiApiResponse, AiConversation, AiMessagePage } from './types'; + +const basePath = '/ai/chat/conversations'; + +export const aiChatApi = { + listConversations: async () => (await api.get>(basePath)).data, + createConversation: async (title?: string) => + (await api.post>(basePath, title ? { title } : {})).data, + renameConversation: async (id: number, title: string) => + (await api.patch>(`${basePath}/${id}`, { title })).data, + deleteConversation: (id: number) => api.delete(`${basePath}/${id}`), + listMessages: async (id: number): Promise => { + const first = ( + await api.get>(`${basePath}/${id}/messages`, { + params: { page: 1, limit: 100 }, + }) + ).data; + const pageCount = Math.ceil(first.total / first.limit); + if (pageCount <= 1) return first; + const rest = await Promise.all( + Array.from({ length: pageCount - 1 }, (_, index) => + api + .get>(`${basePath}/${id}/messages`, { + params: { page: index + 2, limit: first.limit }, + }) + .then((response) => response.data), + ), + ); + return { ...first, items: [first, ...rest].flatMap((page) => page.items) }; + }, +}; + +export function conversationStreamUrl(id: number): string { + return `/api${basePath}/${id}/stream`; +} diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx new file mode 100644 index 0000000..7d8918f --- /dev/null +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -0,0 +1,41 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Bubble } from '@ant-design/x'; +import { afterEach, describe, expect, it } from 'vitest'; +import { aiBubbleRoles } from './AiChatDrawer'; +import type { AiChatMessage } from './types'; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +describe('AI chat bubble rendering', () => { + it('renders a structured user message instead of passing the object to React', async () => { + const message: AiChatMessage = { + role: 'user', + content: '查询今天的系统概览', + reasoningContent: '', + toolRuns: [], + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain('查询今天的系统概览'); + }); +}); diff --git a/apps/admin/src/components/AiChat/message-mappers.integration.test.ts b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts new file mode 100644 index 0000000..a5c8e31 --- /dev/null +++ b/apps/admin/src/components/AiChat/message-mappers.integration.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { mapHistoryMessage } from './message-mappers'; + +describe('AI chat history mapper', () => { + it('restores reasoning, tool summaries and completed status', () => { + const mapped = mapHistoryMessage({ + id: 3, + role: 'assistant', + content: '回答', + reasoningContent: '思考', + status: 'completed', + errorCode: null, + createdAt: '2026-07-23T00:00:00.000Z', + toolRuns: [ + { + toolCallId: 'tool-1', + toolName: 'search_rooms', + status: 'success', + resultSummary: '共 4 间', + }, + ], + }); + + expect(mapped.status).toBe('success'); + expect(mapped.message.reasoningContent).toBe('思考'); + expect(mapped.message.toolRuns[0].summary).toBe('共 4 间'); + }); + + it('maps failed and cancelled history to X SDK statuses', () => { + const base = { + id: 4, + role: 'assistant' as const, + content: '', + reasoningContent: null, + errorCode: 'UPSTREAM_ERROR', + createdAt: '2026-07-23T00:00:00.000Z', + }; + expect(mapHistoryMessage({ ...base, status: 'failed' }).status).toBe('error'); + expect(mapHistoryMessage({ ...base, status: 'cancelled' }).status).toBe('abort'); + }); +}); diff --git a/apps/admin/src/components/AiChat/message-mappers.ts b/apps/admin/src/components/AiChat/message-mappers.ts new file mode 100644 index 0000000..a508cb3 --- /dev/null +++ b/apps/admin/src/components/AiChat/message-mappers.ts @@ -0,0 +1,33 @@ +import type { MessageInfo } from '@ant-design/x-sdk'; +import type { AiChatMessage, AiChatMessageStatus, AiMessageRecord, AiToolRun } from './types'; + +function mapStatus(record: AiMessageRecord): AiChatMessageStatus { + if (record.status === 'pending') return 'loading'; + if (record.status === 'failed') return 'error'; + if (record.status === 'cancelled') return 'abort'; + return record.role === 'user' ? 'local' : 'success'; +} + +function normalizeToolRun(tool: AiToolRun): AiToolRun { + return { + ...tool, + status: tool.status === 'error' ? 'failed' : tool.status, + summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary, + }; +} + +export function mapHistoryMessage(record: AiMessageRecord): MessageInfo { + return { + id: record.id, + status: mapStatus(record), + message: { + id: record.id, + role: record.role, + content: record.content || '', + reasoningContent: record.reasoningContent || '', + toolRuns: (record.toolRuns || []).map(normalizeToolRun), + error: record.status === 'failed' ? record.errorCode || 'AI 回答生成失败' : undefined, + cancelled: record.status === 'cancelled', + }, + }; +} diff --git a/apps/admin/src/components/AiChat/provider.integration.test.ts b/apps/admin/src/components/AiChat/provider.integration.test.ts new file mode 100644 index 0000000..6680171 --- /dev/null +++ b/apps/admin/src/components/AiChat/provider.integration.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { parseSsePayload, reduceAiSseMessage } from './provider'; + +describe('AI chat SSE message reducer', () => { + it('separates reasoning and answer deltas', () => { + let message = reduceAiSseMessage(undefined, { + event: 'reasoning.delta', + data: JSON.stringify({ messageId: 8, delta: '分析' }), + }); + message = reduceAiSseMessage(message, { + event: 'content.delta', + data: JSON.stringify({ delta: '**答案**' }), + }); + + expect(message.reasoningContent).toBe('分析'); + expect(message.content).toBe('**答案**'); + }); + + it('tracks tool lifecycle without exposing raw payloads', () => { + let message = reduceAiSseMessage(undefined, { + event: 'tool.started', + data: JSON.stringify({ + toolCallId: 'call-1', + toolName: 'search_students', + summary: '姓名条件', + }), + }); + message = reduceAiSseMessage(message, { + event: 'tool.completed', + data: JSON.stringify({ + toolCallId: 'call-1', + toolName: 'search_students', + status: 'success', + summary: '找到 1 条记录', + }), + }); + + expect(message.toolRuns).toHaveLength(1); + expect(message.toolRuns[0]).toMatchObject({ status: 'success', summary: '找到 1 条记录' }); + }); + + it('uses final content and records cancellation and errors', () => { + let message = reduceAiSseMessage(undefined, { + event: 'message.completed', + data: JSON.stringify({ + message: { + id: 12, + content: '最终回答', + reasoningContent: '完成', + status: 'completed', + toolRuns: [ + { + toolCallId: 'nested-tool', + toolName: 'search_rooms', + status: 'success', + resultSummary: '共 4 间', + }, + ], + }, + }), + }); + message = reduceAiSseMessage(message, { + event: 'error', + data: JSON.stringify({ message: '上游服务不可用' }), + }); + message = reduceAiSseMessage(message, { + event: 'message.cancelled', + data: JSON.stringify({ messageId: 12 }), + }); + + expect(message).toMatchObject({ + id: 12, + content: '最终回答', + reasoningContent: '完成', + error: '上游服务不可用', + cancelled: true, + }); + expect(message.toolRuns[0]).toMatchObject({ toolCallId: 'nested-tool', status: 'success' }); + }); + + it('reads nested assistant message from message.created', () => { + const message = reduceAiSseMessage(undefined, { + event: 'message.created', + data: JSON.stringify({ + message: { id: 9, content: '', reasoningContent: null, status: 'pending' }, + }), + }); + + expect(message.id).toBe(9); + }); + + it('tolerates non-JSON event data', () => { + expect(parseSsePayload({ event: 'content.delta', data: 'plain text' })).toEqual({ + event: 'content.delta', + payload: { delta: 'plain text' }, + }); + }); +}); diff --git a/apps/admin/src/components/AiChat/provider.ts b/apps/admin/src/components/AiChat/provider.ts new file mode 100644 index 0000000..797bf60 --- /dev/null +++ b/apps/admin/src/components/AiChat/provider.ts @@ -0,0 +1,189 @@ +import { + AbstractChatProvider, + XRequest, + type TransformMessage, + type XRequestOptions, +} from '@ant-design/x-sdk'; +import type { AiChatInput, AiChatMessage, AiSseChunk, AiToolRun } from './types'; + +interface AiSsePayload { + messageId?: number; + userMessageId?: number; + assistantMessageId?: number; + delta?: string; + content?: string; + reasoningContent?: string | null; + toolCallId?: string; + toolName?: string; + status?: string; + summary?: string | null; + durationMs?: number | null; + message?: + | string + | { + id?: number; + content?: string; + reasoningContent?: string | null; + status?: string; + toolRuns?: AiToolRun[]; + }; + error?: string; +} + +function emptyAssistant(): AiChatMessage { + return { + role: 'assistant', + content: '', + reasoningContent: '', + toolRuns: [], + }; +} + +export function parseSsePayload(chunk?: AiSseChunk): { + event: string; + payload: AiSsePayload; +} { + if (!chunk) return { event: '', payload: {} }; + const event = chunk.event?.trim() || 'message'; + if (!chunk.data || chunk.data === '[DONE]') return { event, payload: {} }; + try { + const parsed: unknown = JSON.parse(chunk.data); + return { + event, + payload: parsed && typeof parsed === 'object' ? (parsed as AiSsePayload) : {}, + }; + } catch { + return { event, payload: { delta: chunk.data } }; + } +} + +function upsertToolRun( + toolRuns: AiToolRun[], + payload: AiSsePayload, + fallbackStatus: AiToolRun['status'], +): AiToolRun[] { + const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`; + const next: AiToolRun = { + toolCallId, + toolName: payload.toolName || '查询工具', + status: (payload.status as AiToolRun['status']) || fallbackStatus, + summary: payload.summary, + resultSummary: fallbackStatus === 'running' ? undefined : payload.summary, + argumentsSummary: fallbackStatus === 'running' ? payload.summary : undefined, + durationMs: payload.durationMs, + }; + const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId); + if (index === -1) return [...toolRuns, next]; + return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item)); +} + +function normalizeToolRuns(toolRuns: AiToolRun[] | undefined, fallback: AiToolRun[]): AiToolRun[] { + if (!toolRuns) return fallback; + return toolRuns.map((tool) => ({ + ...tool, + status: tool.status === 'error' ? 'failed' : tool.status, + summary: tool.resultSummary ?? tool.argumentsSummary ?? tool.summary, + })); +} + +export function reduceAiSseMessage( + originMessage: AiChatMessage | undefined, + chunk?: AiSseChunk, +): AiChatMessage { + const message = originMessage ? { ...originMessage } : emptyAssistant(); + const { event, payload } = parseSsePayload(chunk); + + if (event === 'message.created') { + const nested = typeof payload.message === 'object' ? payload.message : undefined; + message.id = nested?.id ?? payload.assistantMessageId ?? payload.messageId ?? message.id; + message.content = nested?.content ?? message.content; + message.reasoningContent = nested?.reasoningContent ?? message.reasoningContent; + message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); + } else if (event === 'reasoning.delta') { + message.reasoningContent += payload.delta ?? payload.reasoningContent ?? ''; + } else if (event === 'content.delta') { + message.content += payload.delta ?? payload.content ?? ''; + } else if (event === 'tool.started') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'running'); + } else if (event === 'tool.completed') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'success'); + } else if (event === 'tool.failed') { + message.toolRuns = upsertToolRun(message.toolRuns, payload, 'failed'); + } else if (event === 'message.completed') { + const nested = typeof payload.message === 'object' ? payload.message : undefined; + message.id = nested?.id ?? payload.messageId ?? message.id; + message.content = nested?.content ?? payload.content ?? message.content; + message.reasoningContent = + nested?.reasoningContent ?? payload.reasoningContent ?? message.reasoningContent; + message.toolRuns = normalizeToolRuns(nested?.toolRuns, message.toolRuns); + } else if (event === 'message.cancelled') { + message.id = payload.messageId ?? message.id; + message.cancelled = true; + } else if (event === 'error') { + message.error = + (typeof payload.message === 'string' ? payload.message : undefined) || + payload.error || + 'AI 回答生成失败'; + } + return message; +} + +async function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + const token = localStorage.getItem('token'); + if (token) headers.set('Authorization', `Bearer ${token}`); + headers.set('Accept', 'text/event-stream'); + const response = await fetch(input, { ...init, headers }); + if (response.status === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + localStorage.removeItem('permissions'); + window.location.href = '/login'; + } + return response; +} + +export class GongxueAiChatProvider extends AbstractChatProvider< + AiChatMessage, + AiChatInput, + AiSseChunk +> { + constructor(url: string, onSettled?: () => void) { + super({ + request: XRequest(url, { + manual: true, + fetch: authenticatedFetch, + timeout: 15_000, + streamTimeout: 120_000, + callbacks: { + onUpdate: () => undefined, + onSuccess: () => onSettled?.(), + onError: () => onSettled?.(), + }, + }), + }); + } + + transformParams( + requestParams: Partial, + options: XRequestOptions, + ): AiChatInput { + return { + ...options.params, + message: requestParams.message?.trim() || '', + }; + } + + transformLocalMessage(requestParams: Partial): AiChatMessage { + return { + role: 'user', + content: requestParams.message?.trim() || '', + reasoningContent: '', + toolRuns: [], + }; + } + + transformMessage(info: TransformMessage): AiChatMessage { + return reduceAiSseMessage(info.originMessage, info.chunk); + } +} diff --git a/apps/admin/src/components/AiChat/style.css b/apps/admin/src/components/AiChat/style.css new file mode 100644 index 0000000..e700382 --- /dev/null +++ b/apps/admin/src/components/AiChat/style.css @@ -0,0 +1,185 @@ +.ai-chat-drawer .ant-drawer-body { + overflow: hidden; +} + +.ai-chat-title { + display: flex; + align-items: center; + gap: 8px; +} + +.ai-chat-layout { + display: flex; + height: 100%; + min-height: 0; + background: #fff; +} + +.ai-chat-sidebar { + position: relative; + flex: 0 0 0; + width: 0; + min-width: 0; + overflow: hidden; + background: #f7f7f8; + border-right: 1px solid #e5e5e7; + transition: flex-basis 180ms ease; +} + +.ai-chat-sidebar.is-open { + flex-basis: 248px; + width: 248px; + padding: 12px 8px; +} + +.ai-chat-sidebar .ant-conversations { + width: 232px; + height: 100%; + overflow-y: auto; +} + +.ai-chat-sidebar__loading { + position: absolute; + inset: 68px 0 auto; +} + +.ai-chat-main { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.ai-chat-toolbar { + display: flex; + flex: 0 0 48px; + align-items: center; + gap: 8px; + min-width: 0; + padding: 0 12px; + border-bottom: 1px solid #ededf0; +} + +.ai-chat-toolbar .ant-typography { + min-width: 0; + font-weight: 600; +} + +.ai-chat-messages { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + min-height: 0; + overflow: hidden; +} + +.ai-chat-messages > .ant-bubble-list { + width: 100%; + height: 100%; + padding: 20px clamp(16px, 4vw, 48px); +} + +.ai-chat-messages .ant-bubble-content { + max-width: min(100%, 680px); +} + +.ai-chat-user-text { + max-width: 100%; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.ai-chat-answer { + width: 100%; + min-width: 0; +} + +.ai-chat-answer .ant-space-item, +.ai-chat-answer .ant-x-markdown { + min-width: 0; + max-width: 100%; +} + +.ai-chat-answer pre, +.ai-chat-answer table { + max-width: 100%; + overflow-x: auto; +} + +.ai-chat-tools { + display: grid; + gap: 6px; + padding: 8px 10px; + background: #f7f7f8; + border: 1px solid #ededf0; + border-radius: 8px; +} + +.ai-chat-tool { + display: flex; + align-items: flex-start; + gap: 6px; + min-width: 0; +} + +.ai-chat-tool .ant-tag { + flex: 0 0 auto; + margin: 0; +} + +.ai-chat-tool__summary { + min-width: 0; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-chat-composer { + flex: 0 0 auto; + padding: 12px clamp(12px, 3vw, 32px) 14px; + background: #fff; + border-top: 1px solid #ededf0; +} + +.ai-chat-composer .ant-sender-input:focus, +.ai-chat-composer .ant-sender-input:focus-visible, +.ai-chat-composer .ant-sender-input:focus-within { + outline: none; + box-shadow: none; +} + +.ai-chat-disclaimer { + display: block; + margin-top: 6px; + font-size: 11px; + text-align: center; +} + +@media (max-width: 575px) { + .ai-chat-sidebar { + position: absolute; + z-index: 2; + inset: 0 auto 0 0; + box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08); + } + + .ai-chat-sidebar.is-open { + width: min(82vw, 300px); + flex-basis: min(82vw, 300px); + } + + .ai-chat-sidebar .ant-conversations { + width: calc(min(82vw, 300px) - 16px); + } + + .ai-chat-messages > .ant-bubble-list { + padding: 14px 12px; + } + + .ai-chat-messages .ant-bubble-content { + max-width: 92%; + } +} diff --git a/apps/admin/src/components/AiChat/types.ts b/apps/admin/src/components/AiChat/types.ts new file mode 100644 index 0000000..6315456 --- /dev/null +++ b/apps/admin/src/components/AiChat/types.ts @@ -0,0 +1,74 @@ +export interface AiConversation { + id: number; + title: string; + createdAt: string; + updatedAt: string; + lastMessageAt: string | null; +} + +export type AiToolRunStatus = + | 'running' + | 'success' + | 'error' + | 'failed' + | 'denied' + | 'not_found'; + +export interface AiToolRun { + id?: number; + toolCallId: string; + toolName: string; + status: AiToolRunStatus; + summary?: string | null; + argumentsSummary?: string | null; + resultSummary?: string | null; + durationMs?: number | null; +} + +export type AiMessageRole = 'user' | 'assistant'; + +export interface AiChatMessage { + id?: number | string; + role: AiMessageRole; + content: string; + reasoningContent: string; + toolRuns: AiToolRun[]; + error?: string; + cancelled?: boolean; +} + +export interface AiMessageRecord { + id: number; + role: AiMessageRole; + content: string; + reasoningContent: string | null; + status: 'pending' | 'completed' | 'failed' | 'cancelled'; + errorCode: string | null; + createdAt: string; + toolRuns?: AiToolRun[]; +} + +export interface AiMessagePage { + items: AiMessageRecord[]; + total: number; + page: number; + limit: number; +} + +export interface AiChatInput { + message: string; +} + +export type AiChatMessageStatus = 'local' | 'loading' | 'updating' | 'success' | 'error' | 'abort'; + +export interface AiApiResponse { + success: boolean; + data: T; + message?: string; +} + +export interface AiSseChunk { + event?: string; + data?: string; + id?: string; +} diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 495e6cb..e201ea1 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid } from 'antd'; +import { Sparkles } from 'lucide-react'; +import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid, Tooltip } from 'antd'; import { DashboardOutlined, TeamOutlined, @@ -36,6 +37,8 @@ import NotificationBell from '../components/NotificationBell'; import RouteDock from '../components/RouteDock'; import { buildMenu, type AppMenuItem } from '../auth/menu-policy'; +const AiChatDrawer = React.lazy(() => import('../components/AiChat/AiChatDrawer')); + const { Header, Sider, Content } = Layout; const iconMap: Record = { @@ -71,6 +74,7 @@ const iconMap: Record = { const MainLayout: React.FC = () => { const [collapsed, setCollapsed] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); + const [aiChatOpen, setAiChatOpen] = useState(false); const [openKeys, setOpenKeys] = useState([]); const prevPathname = useRef(''); const navigate = useNavigate(); @@ -265,6 +269,16 @@ const MainLayout: React.FC = () => { onClick={() => (usesDrawer ? setDrawerOpen(true) : setCollapsed(!collapsed))} />
+ {hasPermission('ai:chat:use') && ( + +