mirror of
https://gitee.com/JavaLionLi/plus-ui.git
synced 2026-09-13 07:43:43 +08:00
update 优化将自行实现的ai聊天室页面改为内嵌 snail-ai 自带的聊天室页面
This commit is contained in:
parent
497a317e90
commit
830eacbf02
@ -1,62 +1,6 @@
|
||||
import type { PageResult, R } from '@/api/types';
|
||||
import request, { globalHeaders } from '@/api/request';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { appEnv } from '@/utils/env';
|
||||
import type {
|
||||
AgentChatRequest,
|
||||
AgentChatSyncResponse,
|
||||
AgentItem,
|
||||
ConversationMessage,
|
||||
ConversationSummaryItem,
|
||||
SnailOpenApiUser
|
||||
} from './types';
|
||||
|
||||
export function fetchMyAgents() {
|
||||
return request<R<AgentItem[] | PageResult<AgentItem>>>({
|
||||
url: '/snail-ai/agents',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchAgentDetail(id: number) {
|
||||
return request<R<AgentItem>>({
|
||||
url: `/snail-ai/agent/${id}`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchAgentConversations(
|
||||
id: number,
|
||||
params: { page?: number; size?: number; start?: string; end?: string }
|
||||
) {
|
||||
return request<R<PageResult<ConversationSummaryItem> | ConversationSummaryItem[]>>({
|
||||
url: `/snail-ai/agent/${id}/conversations`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchConversationMessages(agentId: number, conversationId: string) {
|
||||
return request<R<ConversationMessage[] | PageResult<ConversationMessage>>>({
|
||||
url: `/snail-ai/agent/${agentId}/conversation/${conversationId}/messages`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export function createConversation(agentId: number, data: { title?: string }) {
|
||||
return request<R<ConversationSummaryItem>>({
|
||||
url: `/snail-ai/agent/${agentId}/conversation`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteConversation(agentId: number, conversationId: string) {
|
||||
return request<R>({
|
||||
url: `/snail-ai/agent/${agentId}/conversation/${conversationId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
import type { R } from '@/api/types';
|
||||
import request from '@/api/request';
|
||||
import type { SnailOpenApiUser } from './types';
|
||||
|
||||
export function registerCurrentSnailUser() {
|
||||
return request<R<SnailOpenApiUser>>({
|
||||
@ -64,87 +8,3 @@ export function registerCurrentSnailUser() {
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchChatMode() {
|
||||
return request<R<{ mode?: 'stream' | 'sync' }>>({
|
||||
url: '/snail-ai/chat/mode',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAgentChat(
|
||||
agentId: number,
|
||||
data: AgentChatRequest,
|
||||
options: {
|
||||
onMessage: (chunk: string) => void;
|
||||
onThinking?: (chunk: string) => void;
|
||||
onDone: () => void;
|
||||
onError: (error: Error) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
) {
|
||||
const baseURL = appEnv.baseApi;
|
||||
const language = useAppStore.getState().appLocale;
|
||||
try {
|
||||
const response = await fetch(`${baseURL}/snail-ai/agent/${agentId}/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...globalHeaders(),
|
||||
'Content-Language': language,
|
||||
Accept: 'text/event-stream',
|
||||
'Content-Type': 'application/json;charset=utf-8'
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
signal: options.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('ReadableStream not supported');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const eventBlocks = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = eventBlocks.pop() || '';
|
||||
for (const block of eventBlocks) {
|
||||
if (!block.trim()) continue;
|
||||
let eventName = 'message';
|
||||
let payload = '';
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||||
if (line.startsWith('data:')) payload += line.slice(5).trim();
|
||||
}
|
||||
if (!payload && eventName !== 'done') continue;
|
||||
if (eventName === 'thinking') {
|
||||
options.onThinking?.(payload);
|
||||
} else if (eventName === 'done') {
|
||||
options.onDone();
|
||||
return;
|
||||
} else if (eventName === 'error') {
|
||||
throw new Error(payload || 'SSE stream error');
|
||||
} else {
|
||||
options.onMessage(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
options.onDone();
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') options.onError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchAgentChatSync(agentId: number, data: AgentChatRequest) {
|
||||
return request<R<AgentChatSyncResponse>>({
|
||||
url: `/snail-ai/agent/${agentId}/chat/sync`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,58 +1,3 @@
|
||||
export interface AgentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar?: string;
|
||||
greeting?: string;
|
||||
status?: number;
|
||||
presetQuestions?: string[];
|
||||
}
|
||||
|
||||
export interface ConversationSummaryItem {
|
||||
conversationId: string;
|
||||
agentId?: number;
|
||||
title: string;
|
||||
lastMessageDt?: string;
|
||||
createDt?: string;
|
||||
updateDt?: string;
|
||||
}
|
||||
|
||||
export interface ConversationSummaryList {
|
||||
total: number;
|
||||
rows: ConversationSummaryItem[];
|
||||
}
|
||||
|
||||
export interface ConversationMessage {
|
||||
role?: string;
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
message?: string;
|
||||
text?: string;
|
||||
messageType?: string;
|
||||
senderType?: string;
|
||||
}
|
||||
|
||||
export interface AgentChatRequest {
|
||||
conversationId?: string;
|
||||
content: string;
|
||||
disabledMcpServerIds?: number[];
|
||||
disabledSkillIds?: number[];
|
||||
}
|
||||
|
||||
export interface AgentChatSyncResponse {
|
||||
conversationId?: string;
|
||||
content?: string;
|
||||
traceId?: string;
|
||||
durationMs?: number;
|
||||
text?: string;
|
||||
message?: string;
|
||||
answer?: string;
|
||||
reply?: string;
|
||||
outputText?: string;
|
||||
result?: string;
|
||||
messages?: ConversationMessage[];
|
||||
}
|
||||
|
||||
export interface SnailOpenApiUser {
|
||||
openId: string;
|
||||
nickname?: string;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
.ai-chat-page {
|
||||
display: grid;
|
||||
grid-template-rows: 38px minmax(0, 1fr);
|
||||
.ai-chat-page {
|
||||
position: relative;
|
||||
height: calc(100dvh - 150px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@ -17,202 +16,28 @@
|
||||
padding-block-end: 0 !important;
|
||||
}
|
||||
|
||||
.ai-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
background: var(--app-surface-react);
|
||||
border-bottom: 1px solid var(--app-border-subtle-react);
|
||||
}
|
||||
|
||||
.ai-chat-brand {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-chat-brand-dot,
|
||||
.ai-chat-agent-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--app-link-react);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ai-chat-body {
|
||||
display: grid;
|
||||
grid-template-columns: 292px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-chat-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
background: var(--app-surface-muted-react);
|
||||
border-right: 1px solid var(--app-border-subtle-react);
|
||||
}
|
||||
|
||||
.ai-chat-sidebar > .ant-spin-nested-loading {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-chat-sidebar > .ant-spin-nested-loading > .ant-spin-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-chat-side-scroll {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ai-chat-side-block {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.ai-chat-block-title {
|
||||
margin-bottom: 8px;
|
||||
color: var(--app-text-muted-react);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-chat-side-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
.ai-chat-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
color: var(--app-text-react);
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-chat-side-item:hover,
|
||||
.ai-chat-side-item.active {
|
||||
color: var(--app-link-react);
|
||||
background: var(--app-link-soft-react);
|
||||
}
|
||||
|
||||
.ai-chat-side-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ai-chat-delete {
|
||||
color: var(--app-danger-react);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ai-chat-side-item:hover .ai-chat-delete,
|
||||
.ai-chat-side-item.active .ai-chat-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ai-chat-user {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--app-surface-react);
|
||||
border-top: 1px solid var(--app-border-subtle-react);
|
||||
}
|
||||
|
||||
.ai-chat-user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-chat-user-status {
|
||||
color: var(--app-text-muted-react);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ai-chat-main {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-chat-empty {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--app-text-muted-react);
|
||||
}
|
||||
|
||||
.ai-chat-scroll {
|
||||
min-height: 0;
|
||||
padding: 12px 14px 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ai-chat-content {
|
||||
width: 100%;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.ai-chat-welcome {
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--app-surface-react);
|
||||
border: 1px solid var(--app-border-subtle-react);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-message-row {
|
||||
display: flex !important;
|
||||
justify-content: flex-start;
|
||||
padding: 4px 0 !important;
|
||||
border-block-end: 0 !important;
|
||||
}
|
||||
|
||||
.ai-chat-message-row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ai-chat-message {
|
||||
max-width: 80%;
|
||||
padding: 8px 10px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
background: var(--app-surface-react);
|
||||
border: 1px solid var(--app-border-subtle-react);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-message-row.user .ai-chat-message {
|
||||
color: var(--app-link-strong-react);
|
||||
background: var(--app-link-soft-react);
|
||||
border-color: var(--app-link-border-react);
|
||||
}
|
||||
|
||||
.ai-chat-message.pending {
|
||||
color: var(--app-text-muted-react);
|
||||
}
|
||||
|
||||
.ai-chat-input-wrap {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 14px 10px;
|
||||
background: var(--app-bg-react);
|
||||
border-top: 1px solid var(--app-border-subtle-react);
|
||||
}
|
||||
|
||||
.ai-chat-sender {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
background: var(--app-surface-react);
|
||||
}
|
||||
|
||||
.ai-chat-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--app-surface-react) 76%, transparent);
|
||||
}
|
||||
|
||||
@ -1,532 +1,71 @@
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { Bubble, Conversations, Prompts, Sender, type BubbleItemType } from '@ant-design/x';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Avatar, Button, Empty, message, Space, Spin, Typography } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
AgentChatSyncResponse,
|
||||
AgentItem,
|
||||
ConversationMessage,
|
||||
ConversationSummaryItem
|
||||
} from '@/api/ai/agent/types';
|
||||
import {
|
||||
createConversation,
|
||||
deleteConversation,
|
||||
fetchAgentChat,
|
||||
fetchAgentChatSync,
|
||||
fetchAgentConversations,
|
||||
fetchChatMode,
|
||||
fetchConversationMessages,
|
||||
fetchMyAgents,
|
||||
registerCurrentSnailUser
|
||||
} from '@/api/ai/agent';
|
||||
import { Button, Empty, Spin } from 'antd';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { registerCurrentSnailUser } from '@/api/ai/agent';
|
||||
import { getToken } from '@/utils/auth';
|
||||
import { appEnv } from '@/utils/env';
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
const aiChatKeys = {
|
||||
bootstrap: ['ai-chat', 'bootstrap'] as const,
|
||||
conversations: (agentId?: number) => ['ai-chat', 'conversations', agentId] as const,
|
||||
messages: (agentId?: number, conversationId?: string) => ['ai-chat', 'messages', agentId, conversationId] as const
|
||||
};
|
||||
|
||||
function getList<T>(payload: unknown): T[] {
|
||||
const container = (payload as { data?: unknown })?.data ?? payload;
|
||||
if (Array.isArray(container)) return container as T[];
|
||||
const record = container as { rows?: T[]; list?: T[]; records?: T[] };
|
||||
if (Array.isArray(record?.rows)) return record.rows;
|
||||
if (Array.isArray(record?.list)) return record.list;
|
||||
if (Array.isArray(record?.records)) return record.records;
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeAgentList(payload: unknown): AgentItem[] {
|
||||
return getList<Record<string, unknown>>(payload)
|
||||
.map(item => ({
|
||||
id: Number(item.id ?? item.agentId),
|
||||
name: String(item.name ?? item.title ?? ''),
|
||||
description: item.description as string | undefined,
|
||||
avatar: item.avatar as string | undefined,
|
||||
greeting: item.greeting as string | undefined,
|
||||
status: item.status as number | undefined,
|
||||
presetQuestions: Array.isArray(item.presetQuestions) ? item.presetQuestions.map(String) : []
|
||||
}))
|
||||
.filter(item => Number.isFinite(item.id) && !!item.name);
|
||||
}
|
||||
|
||||
function normalizeConversationList(payload: unknown): ConversationSummaryItem[] {
|
||||
return getList<Record<string, unknown>>(payload)
|
||||
.map(item => ({
|
||||
conversationId: String(item.conversationId ?? item.id ?? ''),
|
||||
title: String(item.title ?? item.name ?? ''),
|
||||
lastMessageDt: item.lastMessageDt as string | undefined,
|
||||
createDt: (item.createDt ?? item.createTime) as string | undefined,
|
||||
updateDt: (item.updateDt ?? item.updateTime) as string | undefined
|
||||
}))
|
||||
.filter(item => !!item.conversationId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastMessageDt || b.createDt || 0).getTime() - new Date(a.lastMessageDt || a.createDt || 0).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMessageList(payload: unknown): ChatMessage[] {
|
||||
return getList<ConversationMessage>(payload)
|
||||
.map(item => {
|
||||
const role = String(item.role || item.messageType || item.senderType || '').toLowerCase();
|
||||
return {
|
||||
role: role === 'user' ? 'user' : 'assistant',
|
||||
content: String(item.content ?? item.message ?? item.text ?? '')
|
||||
} as ChatMessage;
|
||||
})
|
||||
.filter(item => !!item.content);
|
||||
}
|
||||
|
||||
function normalizeStreamChunk(chunk: string) {
|
||||
const text = String(chunk || '');
|
||||
if (!text.trim()) return '';
|
||||
const tryParse = (raw: string) => {
|
||||
try {
|
||||
const obj = JSON.parse(raw) as { content?: string };
|
||||
return typeof obj.content === 'string' ? obj.content : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const single = tryParse(text);
|
||||
if (single !== null) return single;
|
||||
const lines = text
|
||||
.replace(/}\s*{/g, '}\n{')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
if (!lines.length) return text;
|
||||
let merged = '';
|
||||
for (const line of lines) {
|
||||
const parsed = tryParse(line);
|
||||
if (parsed === null) return text;
|
||||
merged += parsed;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function extractSyncReply(payload: AgentChatSyncResponse) {
|
||||
const candidates = [
|
||||
payload.content,
|
||||
payload.text,
|
||||
payload.message,
|
||||
payload.answer,
|
||||
payload.reply,
|
||||
payload.outputText,
|
||||
payload.result
|
||||
];
|
||||
const hit = candidates.find(item => typeof item === 'string' && item.trim());
|
||||
if (hit) return normalizeStreamChunk(String(hit)) || String(hit);
|
||||
if (Array.isArray(payload.messages)) {
|
||||
return (
|
||||
normalizeMessageList(payload.messages)
|
||||
.filter(item => item.role === 'assistant')
|
||||
.at(-1)?.content || ''
|
||||
);
|
||||
}
|
||||
return '';
|
||||
function buildChatUrl(openId: string, trustedCredential: string) {
|
||||
const params = new URLSearchParams({ openId, trustedCredential });
|
||||
return `${appEnv.baseApi}/snail-chat/?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function AiChatPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [currentAgentId, setCurrentAgentId] = useState<number>();
|
||||
const [currentConversationId, setCurrentConversationId] = useState('');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [chatUrl, setChatUrl] = useState('');
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: aiChatKeys.bootstrap,
|
||||
queryFn: async () => {
|
||||
const [userRes, agentsRes, modeRes] = await Promise.all([
|
||||
registerCurrentSnailUser(),
|
||||
fetchMyAgents(),
|
||||
fetchChatMode()
|
||||
]);
|
||||
return {
|
||||
nickname: userRes.data?.nickname || '',
|
||||
agents: normalizeAgentList(agentsRes.data),
|
||||
sendMode: modeRes.data?.mode === 'sync' ? ('sync' as const) : ('stream' as const)
|
||||
};
|
||||
}
|
||||
});
|
||||
const loadChat = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setChatUrl('');
|
||||
setLoadError('登录凭证不存在,请重新登录后再试');
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = bootstrapQuery.data?.agents || [];
|
||||
const currentAgent = useMemo(
|
||||
() => agents.find(agent => agent.id === currentAgentId) || agents[0] || null,
|
||||
[agents, currentAgentId]
|
||||
);
|
||||
const sendMode = bootstrapQuery.data?.sendMode || 'stream';
|
||||
const nickname = bootstrapQuery.data?.nickname || '';
|
||||
const res = await registerCurrentSnailUser();
|
||||
if (!res.data?.openId) {
|
||||
setChatUrl('');
|
||||
setLoadError('获取 AI 用户身份失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationsQuery = useQuery({
|
||||
queryKey: aiChatKeys.conversations(currentAgent?.id),
|
||||
enabled: !!currentAgent?.id,
|
||||
queryFn: async () => {
|
||||
if (!currentAgent?.id) return [];
|
||||
const res = await fetchAgentConversations(currentAgent.id, { page: 1, size: 50 });
|
||||
return normalizeConversationList(res.data);
|
||||
}
|
||||
});
|
||||
|
||||
const conversationMessagesQuery = useQuery({
|
||||
queryKey: aiChatKeys.messages(currentAgent?.id, currentConversationId),
|
||||
enabled: !!currentAgent?.id && !!currentConversationId,
|
||||
queryFn: async () => {
|
||||
if (!currentAgent?.id) return [];
|
||||
const res = await fetchConversationMessages(currentAgent.id, currentConversationId);
|
||||
return normalizeMessageList(res.data);
|
||||
}
|
||||
});
|
||||
|
||||
const presetQuestions = useMemo(() => (currentAgent?.presetQuestions || []).filter(Boolean), [currentAgent]);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
setChatUrl(buildChatUrl(res.data.openId, token));
|
||||
} catch {
|
||||
setChatUrl('');
|
||||
setLoadError('加载 AI 聊天失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finishSending = useCallback(() => {
|
||||
setSending(false);
|
||||
clearTimer();
|
||||
abortRef.current = null;
|
||||
}, [clearTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentAgentId && agents[0]) {
|
||||
setCurrentAgentId(agents[0].id);
|
||||
}
|
||||
}, [agents, currentAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentConversationId && conversationMessagesQuery.data && !sending) {
|
||||
setMessages(conversationMessagesQuery.data);
|
||||
}
|
||||
}, [conversationMessagesQuery.data, currentConversationId, sending]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
clearTimer();
|
||||
};
|
||||
}, [clearTimer]);
|
||||
|
||||
const refreshConversationData = useCallback(
|
||||
async (agentId: number, conversationId?: string) => {
|
||||
await queryClient.invalidateQueries({ queryKey: aiChatKeys.conversations(agentId) });
|
||||
if (conversationId) {
|
||||
await queryClient.invalidateQueries({ queryKey: aiChatKeys.messages(agentId, conversationId) });
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const selectAgent = (agentId: string | number) => {
|
||||
const nextAgentId = Number(agentId);
|
||||
if (currentAgentId === nextAgentId) return;
|
||||
abortRef.current?.abort();
|
||||
finishSending();
|
||||
setCurrentAgentId(nextAgentId);
|
||||
setCurrentConversationId('');
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const startNewConversation = () => {
|
||||
abortRef.current?.abort();
|
||||
finishSending();
|
||||
setCurrentConversationId('');
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const selectConversation = (conversationId: string | number) => {
|
||||
const nextConversationId = String(conversationId);
|
||||
if (currentConversationId === nextConversationId) return;
|
||||
abortRef.current?.abort();
|
||||
finishSending();
|
||||
setCurrentConversationId(nextConversationId);
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const removeConversation = async (conversationId: string) => {
|
||||
if (!currentAgent) return;
|
||||
await deleteConversation(currentAgent.id, conversationId);
|
||||
message.success('删除成功');
|
||||
if (currentConversationId === conversationId) {
|
||||
abortRef.current?.abort();
|
||||
finishSending();
|
||||
setCurrentConversationId('');
|
||||
setMessages([]);
|
||||
}
|
||||
await refreshConversationData(currentAgent.id);
|
||||
};
|
||||
|
||||
const sendMessage = async (value?: string) => {
|
||||
const text = (value ?? content).trim();
|
||||
if (!currentAgent || !text || sending) return;
|
||||
setContent('');
|
||||
setSending(true);
|
||||
clearTimer();
|
||||
timerRef.current = setTimeout(() => {
|
||||
abortRef.current?.abort();
|
||||
finishSending();
|
||||
message.warning('响应超时,已恢复发送按钮,请重试');
|
||||
}, 300000);
|
||||
|
||||
let targetConversationId = currentConversationId;
|
||||
if (!targetConversationId) {
|
||||
try {
|
||||
const res = await createConversation(currentAgent.id, { title: text.slice(0, 20) });
|
||||
if (!res.data?.conversationId) {
|
||||
finishSending();
|
||||
message.error('创建会话失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
targetConversationId = res.data.conversationId;
|
||||
setCurrentConversationId(targetConversationId);
|
||||
await refreshConversationData(currentAgent.id);
|
||||
} catch (error) {
|
||||
finishSending();
|
||||
message.error((error as Error).message || '创建会话失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(items => [...items, { role: 'user', content: text }]);
|
||||
if (sendMode === 'sync') {
|
||||
try {
|
||||
const res = await fetchAgentChatSync(currentAgent.id, { conversationId: targetConversationId, content: text });
|
||||
setMessages(items => [
|
||||
...items,
|
||||
{ role: 'assistant', content: extractSyncReply(res.data) || '(后端已返回空消息)' }
|
||||
]);
|
||||
await refreshConversationData(currentAgent.id, targetConversationId);
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || '对话失败,请稍后重试');
|
||||
} finally {
|
||||
finishSending();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages(items => [...items, { role: 'assistant', content: '' }]);
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = new AbortController();
|
||||
await fetchAgentChat(
|
||||
currentAgent.id,
|
||||
{ conversationId: targetConversationId, content: text },
|
||||
{
|
||||
signal: abortRef.current.signal,
|
||||
onMessage(chunk) {
|
||||
setMessages(items => {
|
||||
const next = [...items];
|
||||
const index = next.length - 1;
|
||||
next[index] = { ...next[index], content: `${next[index]?.content || ''}${normalizeStreamChunk(chunk)}` };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
async onDone() {
|
||||
setMessages(items => {
|
||||
const next = [...items];
|
||||
const index = next.length - 1;
|
||||
if (next[index]?.role === 'assistant' && !next[index].content.trim()) {
|
||||
next[index] = { ...next[index], content: '(后端已返回空消息)' };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
finishSending();
|
||||
await refreshConversationData(currentAgent.id, targetConversationId);
|
||||
},
|
||||
onError(error) {
|
||||
setMessages(items => items.filter((_, index) => index !== items.length - 1));
|
||||
finishSending();
|
||||
message.error(error.message || '对话失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const agentItems = useMemo(
|
||||
() =>
|
||||
agents.map(agent => ({
|
||||
key: String(agent.id),
|
||||
label: agent.name,
|
||||
icon: (
|
||||
<Avatar size={20} src={agent.avatar}>
|
||||
{agent.name.slice(0, 1)}
|
||||
</Avatar>
|
||||
)
|
||||
})),
|
||||
[agents]
|
||||
);
|
||||
|
||||
const conversationItems = useMemo(
|
||||
() =>
|
||||
(conversationsQuery.data || []).map(conv => ({
|
||||
key: conv.conversationId,
|
||||
label: conv.title || '未命名会话'
|
||||
})),
|
||||
[conversationsQuery.data]
|
||||
);
|
||||
|
||||
const bubbleItems = useMemo<BubbleItemType[]>(
|
||||
() =>
|
||||
messages.map((item, index) => ({
|
||||
key: `${item.role}-${index}`,
|
||||
role: item.role === 'user' ? 'user' : 'ai',
|
||||
content: item.content || '正在生成...',
|
||||
loading: item.role === 'assistant' && !item.content
|
||||
})),
|
||||
[messages]
|
||||
);
|
||||
loadChat();
|
||||
}, [loadChat]);
|
||||
|
||||
return (
|
||||
<PageContainer className="ai-chat-container" title={false}>
|
||||
<div className="ai-chat-page">
|
||||
<header className="ai-chat-header">
|
||||
<div className="ai-chat-brand">
|
||||
<span className="ai-chat-brand-dot" />
|
||||
Snail AI
|
||||
{loading ? (
|
||||
<div className="ai-chat-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</header>
|
||||
<div className="ai-chat-body">
|
||||
<aside className="ai-chat-sidebar">
|
||||
<div className="ai-chat-side-block">
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
disabled={!agents.length}
|
||||
onClick={startNewConversation}
|
||||
>
|
||||
新对话
|
||||
) : null}
|
||||
{chatUrl ? (
|
||||
<iframe className="ai-chat-frame" src={chatUrl} title="Snail AI" allow="clipboard-read; clipboard-write" />
|
||||
) : (
|
||||
<Empty className="ai-chat-empty" description={loadError || '正在加载 Snail AI'}>
|
||||
{loadError ? (
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={loadChat}>
|
||||
重新加载
|
||||
</Button>
|
||||
</div>
|
||||
<Spin spinning={bootstrapQuery.isLoading || conversationsQuery.isLoading}>
|
||||
<div className="ai-chat-side-scroll">
|
||||
<div className="ai-chat-side-block">
|
||||
<div className="ai-chat-block-title">我的智能体</div>
|
||||
{!agents.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无智能体" /> : null}
|
||||
<Conversations
|
||||
className="ai-chat-conversations"
|
||||
items={agentItems}
|
||||
activeKey={currentAgent ? String(currentAgent.id) : undefined}
|
||||
onActiveChange={selectAgent}
|
||||
/>
|
||||
</div>
|
||||
<div className="ai-chat-side-block">
|
||||
<div className="ai-chat-block-title">对话记录</div>
|
||||
{!conversationItems.length ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无会话" />
|
||||
) : null}
|
||||
<Conversations
|
||||
className="ai-chat-conversations"
|
||||
items={conversationItems}
|
||||
activeKey={currentConversationId}
|
||||
onActiveChange={selectConversation}
|
||||
menu={item => ({
|
||||
items: [{ key: 'delete', label: '删除', icon: <DeleteOutlined />, danger: true }],
|
||||
onClick: ({ domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
removeConversation(item.key);
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
<div className="ai-chat-user">
|
||||
<Avatar>{(nickname || 'U').slice(0, 1)}</Avatar>
|
||||
<div>
|
||||
<div className="ai-chat-user-name">{nickname || '已登录用户'}</div>
|
||||
<div className="ai-chat-user-status">已登录</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="ai-chat-main">
|
||||
{!currentAgent ? (
|
||||
<div className="ai-chat-empty">请先选择一个智能体开始对话</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ai-chat-scroll">
|
||||
<div className="ai-chat-content">
|
||||
{!currentConversationId && !messages.length && (
|
||||
<div className="ai-chat-welcome">
|
||||
<Space align="start">
|
||||
<Avatar size={40} src={currentAgent.avatar}>
|
||||
{currentAgent.name.slice(0, 1)}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{currentAgent.name}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">{currentAgent.description || '暂无描述'}</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
<p>{currentAgent.greeting || '你好,我是你的智能助手。'}</p>
|
||||
{presetQuestions.length > 0 && (
|
||||
<Prompts
|
||||
wrap
|
||||
items={presetQuestions.map(question => ({
|
||||
key: question,
|
||||
label: question,
|
||||
disabled: sending
|
||||
}))}
|
||||
onItemClick={({ data }) => sendMessage(String(data.key))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Spin
|
||||
spinning={conversationMessagesQuery.isFetching && !!currentConversationId && !messages.length}
|
||||
>
|
||||
<Bubble.List
|
||||
className="ai-chat-bubble-list"
|
||||
autoScroll
|
||||
items={bubbleItems}
|
||||
role={{
|
||||
user: { placement: 'end', variant: 'filled' },
|
||||
ai: {
|
||||
placement: 'start',
|
||||
variant: 'outlined',
|
||||
avatar: <Avatar src={currentAgent.avatar}>{currentAgent.name.slice(0, 1)}</Avatar>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ai-chat-input-wrap">
|
||||
<Sender
|
||||
className="ai-chat-sender"
|
||||
value={content}
|
||||
loading={sending}
|
||||
disabled={sending}
|
||||
placeholder="给智能体发消息"
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
onChange={setContent}
|
||||
onSubmit={sendMessage}
|
||||
onCancel={() => {
|
||||
abortRef.current?.abort();
|
||||
finishSending();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
) : null}
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user