mirror of
https://gitee.com/JavaLionLi/plus-ui.git
synced 2026-09-15 16:28:33 +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 type { R } from '@/api/types';
|
||||||
import request, { globalHeaders } from '@/api/request';
|
import request from '@/api/request';
|
||||||
import { useAppStore } from '@/stores/appStore';
|
import type { SnailOpenApiUser } from './types';
|
||||||
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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerCurrentSnailUser() {
|
export function registerCurrentSnailUser() {
|
||||||
return request<R<SnailOpenApiUser>>({
|
return request<R<SnailOpenApiUser>>({
|
||||||
@ -64,87 +8,3 @@ export function registerCurrentSnailUser() {
|
|||||||
method: 'post'
|
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 {
|
export interface SnailOpenApiUser {
|
||||||
openId: string;
|
openId: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
.ai-chat-page {
|
.ai-chat-page {
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-rows: 38px minmax(0, 1fr);
|
|
||||||
height: calc(100dvh - 150px);
|
height: calc(100dvh - 150px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -17,202 +16,28 @@
|
|||||||
padding-block-end: 0 !important;
|
padding-block-end: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chat-header {
|
.ai-chat-frame {
|
||||||
display: flex;
|
display: block;
|
||||||
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;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 34px;
|
height: 100%;
|
||||||
padding: 0 10px;
|
|
||||||
color: var(--app-text-react);
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
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);
|
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 {
|
.ai-chat-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
height: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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);
|
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 { PageContainer } from '@ant-design/pro-components';
|
||||||
import { Bubble, Conversations, Prompts, Sender, type BubbleItemType } from '@ant-design/x';
|
import { Button, Empty, Spin } from 'antd';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Avatar, Button, Empty, message, Space, Spin, Typography } from 'antd';
|
import { registerCurrentSnailUser } from '@/api/ai/agent';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { getToken } from '@/utils/auth';
|
||||||
import type {
|
import { appEnv } from '@/utils/env';
|
||||||
AgentChatSyncResponse,
|
|
||||||
AgentItem,
|
|
||||||
ConversationMessage,
|
|
||||||
ConversationSummaryItem
|
|
||||||
} from '@/api/ai/agent/types';
|
|
||||||
import {
|
|
||||||
createConversation,
|
|
||||||
deleteConversation,
|
|
||||||
fetchAgentChat,
|
|
||||||
fetchAgentChatSync,
|
|
||||||
fetchAgentConversations,
|
|
||||||
fetchChatMode,
|
|
||||||
fetchConversationMessages,
|
|
||||||
fetchMyAgents,
|
|
||||||
registerCurrentSnailUser
|
|
||||||
} from '@/api/ai/agent';
|
|
||||||
|
|
||||||
interface ChatMessage {
|
function buildChatUrl(openId: string, trustedCredential: string) {
|
||||||
role: 'user' | 'assistant';
|
const params = new URLSearchParams({ openId, trustedCredential });
|
||||||
content: string;
|
return `${appEnv.baseApi}/snail-chat/?${params.toString()}`;
|
||||||
}
|
|
||||||
|
|
||||||
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 '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AiChatPage() {
|
export default function AiChatPage() {
|
||||||
const queryClient = useQueryClient();
|
const [chatUrl, setChatUrl] = useState('');
|
||||||
const [currentAgentId, setCurrentAgentId] = useState<number>();
|
const [loadError, setLoadError] = useState('');
|
||||||
const [currentConversationId, setCurrentConversationId] = useState('');
|
const [loading, setLoading] = useState(false);
|
||||||
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 bootstrapQuery = useQuery({
|
const loadChat = useCallback(async () => {
|
||||||
queryKey: aiChatKeys.bootstrap,
|
setLoading(true);
|
||||||
queryFn: async () => {
|
setLoadError('');
|
||||||
const [userRes, agentsRes, modeRes] = await Promise.all([
|
try {
|
||||||
registerCurrentSnailUser(),
|
const token = getToken();
|
||||||
fetchMyAgents(),
|
if (!token) {
|
||||||
fetchChatMode()
|
setChatUrl('');
|
||||||
]);
|
setLoadError('登录凭证不存在,请重新登录后再试');
|
||||||
return {
|
return;
|
||||||
nickname: userRes.data?.nickname || '',
|
}
|
||||||
agents: normalizeAgentList(agentsRes.data),
|
|
||||||
sendMode: modeRes.data?.mode === 'sync' ? ('sync' as const) : ('stream' as const)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const agents = bootstrapQuery.data?.agents || [];
|
const res = await registerCurrentSnailUser();
|
||||||
const currentAgent = useMemo(
|
if (!res.data?.openId) {
|
||||||
() => agents.find(agent => agent.id === currentAgentId) || agents[0] || null,
|
setChatUrl('');
|
||||||
[agents, currentAgentId]
|
setLoadError('获取 AI 用户身份失败');
|
||||||
);
|
return;
|
||||||
const sendMode = bootstrapQuery.data?.sendMode || 'stream';
|
}
|
||||||
const nickname = bootstrapQuery.data?.nickname || '';
|
|
||||||
|
|
||||||
const conversationsQuery = useQuery({
|
setChatUrl(buildChatUrl(res.data.openId, token));
|
||||||
queryKey: aiChatKeys.conversations(currentAgent?.id),
|
} catch {
|
||||||
enabled: !!currentAgent?.id,
|
setChatUrl('');
|
||||||
queryFn: async () => {
|
setLoadError('加载 AI 聊天失败,请稍后重试');
|
||||||
if (!currentAgent?.id) return [];
|
} finally {
|
||||||
const res = await fetchAgentConversations(currentAgent.id, { page: 1, size: 50 });
|
setLoading(false);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const finishSending = useCallback(() => {
|
|
||||||
setSending(false);
|
|
||||||
clearTimer();
|
|
||||||
abortRef.current = null;
|
|
||||||
}, [clearTimer]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentAgentId && agents[0]) {
|
loadChat();
|
||||||
setCurrentAgentId(agents[0].id);
|
}, [loadChat]);
|
||||||
}
|
|
||||||
}, [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]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer className="ai-chat-container" title={false}>
|
<PageContainer className="ai-chat-container" title={false}>
|
||||||
<div className="ai-chat-page">
|
<div className="ai-chat-page">
|
||||||
<header className="ai-chat-header">
|
{loading ? (
|
||||||
<div className="ai-chat-brand">
|
<div className="ai-chat-loading">
|
||||||
<span className="ai-chat-brand-dot" />
|
<Spin />
|
||||||
Snail AI
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
) : null}
|
||||||
<div className="ai-chat-body">
|
{chatUrl ? (
|
||||||
<aside className="ai-chat-sidebar">
|
<iframe className="ai-chat-frame" src={chatUrl} title="Snail AI" allow="clipboard-read; clipboard-write" />
|
||||||
<div className="ai-chat-side-block">
|
) : (
|
||||||
<Button
|
<Empty className="ai-chat-empty" description={loadError || '正在加载 Snail AI'}>
|
||||||
type="primary"
|
{loadError ? (
|
||||||
block
|
<Button type="primary" icon={<ReloadOutlined />} onClick={loadChat}>
|
||||||
icon={<PlusOutlined />}
|
重新加载
|
||||||
disabled={!agents.length}
|
|
||||||
onClick={startNewConversation}
|
|
||||||
>
|
|
||||||
新对话
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
<Spin spinning={bootstrapQuery.isLoading || conversationsQuery.isLoading}>
|
</Empty>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user