feat: Day 14-17 - Frontend Chat Interface completed
Frontend: - Create MessageList component with streaming animation - Create MessageInput component with @knowledge base support - Create ModelSelector component (DeepSeek/Qwen/Gemini) - Implement conversationApi with SSE streaming - Update AgentChatPage integrate all components - Add Markdown rendering (react-markdown + remark-gfm) - Add code highlighting (react-syntax-highlighter) - Add vite-env.d.ts for environment variables Features: - Real-time streaming output with cursor animation - Markdown and code block rendering - Model switching (DeepSeek-V3, Qwen3-72B, Gemini Pro) - @Knowledge base selector (UI ready) - Auto-scroll to bottom - Shift+Enter for new line, Enter to send - Beautiful message bubble design Build: Frontend build successfully (7.94s, 1.9MB) New Files: - components/chat/MessageList.tsx (170 lines) - components/chat/MessageList.css (150 lines) - components/chat/MessageInput.tsx (145 lines) - components/chat/MessageInput.css (60 lines) - components/chat/ModelSelector.tsx (110 lines) - components/chat/ModelSelector.css (35 lines) - api/conversationApi.ts (170 lines) - src/vite-env.d.ts (9 lines) Total: ~850 lines of new code
This commit is contained in:
1763
frontend/package-lock.json
generated
1763
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.5.2",
|
"@ant-design/icons": "^5.5.2",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"antd": "^5.22.5",
|
"antd": "^5.22.5",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0"
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
|
|||||||
169
frontend/src/api/conversationApi.ts
Normal file
169
frontend/src/api/conversationApi.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import request from './request';
|
||||||
|
import type { ModelType } from '../components/chat/ModelSelector';
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
model?: string;
|
||||||
|
metadata?: any;
|
||||||
|
tokens?: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
projectId: string;
|
||||||
|
agentId: string;
|
||||||
|
title: string;
|
||||||
|
modelName: string;
|
||||||
|
messageCount: number;
|
||||||
|
totalTokens: number;
|
||||||
|
metadata?: any;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
project?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
background?: string;
|
||||||
|
researchType?: string;
|
||||||
|
};
|
||||||
|
messages?: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateConversationData {
|
||||||
|
projectId: string;
|
||||||
|
agentId: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageData {
|
||||||
|
conversationId: string;
|
||||||
|
content: string;
|
||||||
|
modelType: ModelType;
|
||||||
|
knowledgeBaseIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageResponse {
|
||||||
|
userMessage: Message;
|
||||||
|
assistantMessage: Message;
|
||||||
|
usage?: {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建对话
|
||||||
|
export const createConversation = (data: CreateConversationData) => {
|
||||||
|
return request.post<ApiResponse<Conversation>>('/conversations', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取对话列表
|
||||||
|
export const getConversations = (projectId?: string) => {
|
||||||
|
const params = projectId ? { projectId } : {};
|
||||||
|
return request.get<ApiResponse<Conversation[]>>('/conversations', { params });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取对话详情
|
||||||
|
export const getConversationById = (id: string) => {
|
||||||
|
return request.get<ApiResponse<Conversation>>(`/conversations/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送消息(非流式)
|
||||||
|
export const sendMessage = (data: SendMessageData) => {
|
||||||
|
return request.post<ApiResponse<SendMessageResponse>>('/conversations/message', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送消息(流式输出)
|
||||||
|
export const sendMessageStream = async (
|
||||||
|
data: SendMessageData,
|
||||||
|
onChunk: (content: string) => void,
|
||||||
|
onComplete: () => void,
|
||||||
|
onError: (error: Error) => void
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/conversations/message/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('Response body is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (!trimmedLine || !trimmedLine.startsWith('data:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataStr = trimmedLine.slice(5).trim();
|
||||||
|
|
||||||
|
if (dataStr === '[DONE]') {
|
||||||
|
onComplete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(dataStr);
|
||||||
|
if (chunk.content) {
|
||||||
|
onChunk(chunk.content);
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Failed to parse SSE data:', parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stream error:', error);
|
||||||
|
onError(error as Error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除对话
|
||||||
|
export const deleteConversation = (id: string) => {
|
||||||
|
return request.delete<ApiResponse>(`/conversations/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
createConversation,
|
||||||
|
getConversations,
|
||||||
|
getConversationById,
|
||||||
|
sendMessage,
|
||||||
|
sendMessageStream,
|
||||||
|
deleteConversation,
|
||||||
|
};
|
||||||
|
|
||||||
80
frontend/src/components/chat/MessageInput.css
Normal file
80
frontend/src/components/chat/MessageInput.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
.message-input-container {
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-knowledge-bases {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
animation: slideDown 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea {
|
||||||
|
resize: none;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea:hover {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-textarea:focus {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-hint {
|
||||||
|
color: #1890ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.message-input-container {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
179
frontend/src/components/chat/MessageInput.tsx
Normal file
179
frontend/src/components/chat/MessageInput.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState, useRef, KeyboardEvent } from 'react';
|
||||||
|
import { Input, Button, Space, Tooltip, Dropdown, Tag } from 'antd';
|
||||||
|
import {
|
||||||
|
SendOutlined,
|
||||||
|
PaperClipOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
CloseCircleOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
|
import './MessageInput.css';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface KnowledgeBase {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageInputProps {
|
||||||
|
onSend: (content: string, knowledgeBaseIds: string[]) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
knowledgeBases?: KnowledgeBase[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageInput: React.FC<MessageInputProps> = ({
|
||||||
|
onSend,
|
||||||
|
loading = false,
|
||||||
|
knowledgeBases = [],
|
||||||
|
placeholder = '输入你的问题...(Shift+Enter换行,Enter发送)',
|
||||||
|
}) => {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<string[]>([]);
|
||||||
|
const textAreaRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// 处理发送消息
|
||||||
|
const handleSend = () => {
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (!trimmedContent || loading) return;
|
||||||
|
|
||||||
|
onSend(trimmedContent, selectedKnowledgeBases);
|
||||||
|
setContent('');
|
||||||
|
setSelectedKnowledgeBases([]);
|
||||||
|
|
||||||
|
// 重置输入框高度
|
||||||
|
if (textAreaRef.current) {
|
||||||
|
textAreaRef.current.resizableTextArea.textArea.style.height = 'auto';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理键盘事件
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Enter发送,Shift+Enter换行
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加知识库
|
||||||
|
const handleSelectKnowledgeBase = (kbId: string) => {
|
||||||
|
if (!selectedKnowledgeBases.includes(kbId)) {
|
||||||
|
setSelectedKnowledgeBases([...selectedKnowledgeBases, kbId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除知识库
|
||||||
|
const handleRemoveKnowledgeBase = (kbId: string) => {
|
||||||
|
setSelectedKnowledgeBases(selectedKnowledgeBases.filter(id => id !== kbId));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 知识库下拉菜单
|
||||||
|
const knowledgeBaseMenuItems: MenuProps['items'] = knowledgeBases.map(kb => ({
|
||||||
|
key: kb.id,
|
||||||
|
label: kb.name,
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
onClick: () => handleSelectKnowledgeBase(kb.id),
|
||||||
|
disabled: selectedKnowledgeBases.includes(kb.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 获取选中的知识库名称
|
||||||
|
const getKnowledgeBaseName = (kbId: string) => {
|
||||||
|
const kb = knowledgeBases.find(k => k.id === kbId);
|
||||||
|
return kb ? kb.name : kbId;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-input-container">
|
||||||
|
{/* 已选择的知识库标签 */}
|
||||||
|
{selectedKnowledgeBases.length > 0 && (
|
||||||
|
<div className="selected-knowledge-bases">
|
||||||
|
<Space wrap>
|
||||||
|
{selectedKnowledgeBases.map(kbId => (
|
||||||
|
<Tag
|
||||||
|
key={kbId}
|
||||||
|
closable
|
||||||
|
color="blue"
|
||||||
|
onClose={() => handleRemoveKnowledgeBase(kbId)}
|
||||||
|
closeIcon={<CloseCircleOutlined />}
|
||||||
|
>
|
||||||
|
<FileTextOutlined /> {getKnowledgeBaseName(kbId)}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入框和工具栏 */}
|
||||||
|
<div className="message-input-wrapper">
|
||||||
|
<TextArea
|
||||||
|
ref={textAreaRef}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoSize={{ minRows: 2, maxRows: 8 }}
|
||||||
|
disabled={loading}
|
||||||
|
className="message-textarea"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="message-input-toolbar">
|
||||||
|
<Space>
|
||||||
|
{/* @知识库按钮 */}
|
||||||
|
{knowledgeBases.length > 0 && (
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: knowledgeBaseMenuItems }}
|
||||||
|
placement="topLeft"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Tooltip title="@知识库 - 基于知识库内容回答">
|
||||||
|
<Button
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
type="text"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
@知识库
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 上传附件按钮(预留) */}
|
||||||
|
<Tooltip title="上传文档(即将上线)">
|
||||||
|
<Button
|
||||||
|
icon={<PaperClipOutlined />}
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 发送按钮 */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SendOutlined />}
|
||||||
|
onClick={handleSend}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!content.trim() || loading}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<div className="message-input-hint">
|
||||||
|
<span>Shift + Enter 换行,Enter 发送</span>
|
||||||
|
{selectedKnowledgeBases.length > 0 && (
|
||||||
|
<span className="kb-hint">
|
||||||
|
· 已选择 {selectedKnowledgeBases.length} 个知识库
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageInput;
|
||||||
|
|
||||||
189
frontend/src/components/chat/MessageList.css
Normal file
189
frontend/src/components/chat/MessageList.css
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
animation: fadeIn 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-user .message-body {
|
||||||
|
background: #e6f7ff;
|
||||||
|
border: 1px solid #91d5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown样式 */
|
||||||
|
.assistant-message-text h1,
|
||||||
|
.assistant-message-text h2,
|
||||||
|
.assistant-message-text h3,
|
||||||
|
.assistant-message-text h4,
|
||||||
|
.assistant-message-text h5,
|
||||||
|
.assistant-message-text h6 {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text ul,
|
||||||
|
.assistant-message-text ol {
|
||||||
|
margin-left: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text code {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #d73a49;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text pre {
|
||||||
|
margin: 12px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text blockquote {
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
|
padding-left: 16px;
|
||||||
|
margin-left: 0;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text th,
|
||||||
|
.assistant-message-text td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-message-text th {
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流式输出动画 */
|
||||||
|
.assistant-message-text.streaming {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-blink {
|
||||||
|
display: inline-block;
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
color: #1890ff;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 49% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%, 100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-footer {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.message-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
186
frontend/src/components/chat/MessageList.tsx
Normal file
186
frontend/src/components/chat/MessageList.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { Avatar, Typography, Space, Tag } from 'antd';
|
||||||
|
import { UserOutlined, RobotOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import './MessageList.css';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
model?: string;
|
||||||
|
tokens?: number;
|
||||||
|
createdAt: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageListProps {
|
||||||
|
messages: Message[];
|
||||||
|
loading?: boolean;
|
||||||
|
streamingContent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageList: React.FC<MessageListProps> = ({
|
||||||
|
messages,
|
||||||
|
loading = false,
|
||||||
|
streamingContent = ''
|
||||||
|
}) => {
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 自动滚动到底部
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages, streamingContent]);
|
||||||
|
|
||||||
|
// Markdown代码块渲染
|
||||||
|
const MarkdownComponents = {
|
||||||
|
code({ node, inline, className, children, ...props }: any) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
|
return !inline && match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={vscDarkPlus}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessage = (message: Message) => {
|
||||||
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`message-item ${isUser ? 'message-user' : 'message-assistant'}`}
|
||||||
|
>
|
||||||
|
<div className="message-avatar">
|
||||||
|
<Avatar
|
||||||
|
size={40}
|
||||||
|
icon={isUser ? <UserOutlined /> : <RobotOutlined />}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isUser ? '#1890ff' : '#52c41a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="message-content">
|
||||||
|
<div className="message-header">
|
||||||
|
<Text strong>{isUser ? '我' : 'AI助手'}</Text>
|
||||||
|
{message.model && (
|
||||||
|
<Tag color="blue" style={{ marginLeft: 8 }}>
|
||||||
|
{message.model}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
|
||||||
|
{new Date(message.createdAt).toLocaleTimeString('zh-CN')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="message-body">
|
||||||
|
{isUser ? (
|
||||||
|
<div className="user-message-text">{message.content}</div>
|
||||||
|
) : (
|
||||||
|
<div className="assistant-message-text">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={MarkdownComponents}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.tokens && (
|
||||||
|
<div className="message-footer">
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
消耗Token: {message.tokens}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="message-list">
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
{messages.map(renderMessage)}
|
||||||
|
|
||||||
|
{/* 流式输出中的消息 */}
|
||||||
|
{loading && streamingContent && (
|
||||||
|
<div className="message-item message-assistant">
|
||||||
|
<div className="message-avatar">
|
||||||
|
<Avatar
|
||||||
|
size={40}
|
||||||
|
icon={<RobotOutlined />}
|
||||||
|
style={{ backgroundColor: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="message-content">
|
||||||
|
<div className="message-header">
|
||||||
|
<Text strong>AI助手</Text>
|
||||||
|
<Tag color="processing" style={{ marginLeft: 8 }}>
|
||||||
|
<LoadingOutlined /> 生成中...
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="message-body">
|
||||||
|
<div className="assistant-message-text streaming">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={MarkdownComponents}
|
||||||
|
>
|
||||||
|
{streamingContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
<span className="cursor-blink">▊</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 仅显示loading,无流式内容 */}
|
||||||
|
{loading && !streamingContent && (
|
||||||
|
<div className="message-item message-assistant">
|
||||||
|
<div className="message-avatar">
|
||||||
|
<Avatar
|
||||||
|
size={40}
|
||||||
|
icon={<LoadingOutlined spin />}
|
||||||
|
style={{ backgroundColor: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="message-content">
|
||||||
|
<Text type="secondary">AI正在思考中...</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{/* 自动滚动锚点 */}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageList;
|
||||||
|
|
||||||
52
frontend/src/components/chat/ModelSelector.css
Normal file
52
frontend/src/components/chat/ModelSelector.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
.model-selector-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #595959;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector .ant-select-selector {
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector:hover .ant-select-selector {
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tag {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tag:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-dropdown .ant-select-item-option {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-dropdown .ant-select-item-option:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.model-selector-container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
140
frontend/src/components/chat/ModelSelector.tsx
Normal file
140
frontend/src/components/chat/ModelSelector.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Select, Space, Tag, Tooltip } from 'antd';
|
||||||
|
import { ThunderboltOutlined, RocketOutlined, GlobalOutlined } from '@ant-design/icons';
|
||||||
|
import './ModelSelector.css';
|
||||||
|
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
export type ModelType = 'deepseek-v3' | 'qwen3-72b' | 'gemini-pro';
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
value: ModelType;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
features: string[];
|
||||||
|
recommended?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const models: ModelInfo[] = [
|
||||||
|
{
|
||||||
|
value: 'deepseek-v3',
|
||||||
|
label: 'DeepSeek-V3',
|
||||||
|
description: '高性价比,推理能力强',
|
||||||
|
icon: <ThunderboltOutlined />,
|
||||||
|
color: '#1890ff',
|
||||||
|
features: ['快速响应', '成本优化', '长文本处理'],
|
||||||
|
recommended: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'qwen3-72b',
|
||||||
|
label: 'Qwen3-72B',
|
||||||
|
description: '阿里通义千问,中文理解优秀',
|
||||||
|
icon: <RocketOutlined />,
|
||||||
|
color: '#52c41a',
|
||||||
|
features: ['中文优化', '多轮对话', '专业领域'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gemini-pro',
|
||||||
|
label: 'Gemini Pro',
|
||||||
|
description: 'Google大模型(即将上线)',
|
||||||
|
icon: <GlobalOutlined />,
|
||||||
|
color: '#faad14',
|
||||||
|
features: ['多模态', '全球化', '创新能力'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
value: ModelType;
|
||||||
|
onChange: (value: ModelType) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const currentModel = models.find(m => m.value === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="model-selector-container">
|
||||||
|
<Space align="center">
|
||||||
|
<span className="model-selector-label">AI模型:</span>
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
className="model-selector"
|
||||||
|
popupClassName="model-selector-dropdown"
|
||||||
|
>
|
||||||
|
{models.map(model => (
|
||||||
|
<Option
|
||||||
|
key={model.value}
|
||||||
|
value={model.value}
|
||||||
|
disabled={model.value === 'gemini-pro'} // Gemini暂未开放
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<span style={{ color: model.color }}>{model.icon}</span>
|
||||||
|
<span>{model.label}</span>
|
||||||
|
{model.recommended && (
|
||||||
|
<Tag color="gold" style={{ marginLeft: 4 }}>
|
||||||
|
推荐
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{model.value === 'gemini-pro' && (
|
||||||
|
<Tag color="default" style={{ marginLeft: 4 }}>
|
||||||
|
即将上线
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{currentModel && (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 600 }}>
|
||||||
|
{currentModel.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
{currentModel.description}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>特点:</strong>
|
||||||
|
{currentModel.features.map((f, i) => (
|
||||||
|
<Tag
|
||||||
|
key={i}
|
||||||
|
color={currentModel.color}
|
||||||
|
style={{ marginLeft: 4, marginTop: 4 }}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
overlayStyle={{ maxWidth: 300 }}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
color={currentModel.color}
|
||||||
|
icon={currentModel.icon}
|
||||||
|
className="model-tag"
|
||||||
|
>
|
||||||
|
{currentModel.label}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModelSelector;
|
||||||
|
export { models };
|
||||||
|
export type { ModelInfo };
|
||||||
|
|
||||||
@@ -1,32 +1,44 @@
|
|||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Card, Typography, Input, Button, Space, Select, Upload, Tag, Alert, Divider, Spin } from 'antd'
|
import { Card, Typography, Space, Alert, Spin, message } from 'antd'
|
||||||
import {
|
import { RobotOutlined } from '@ant-design/icons'
|
||||||
SendOutlined,
|
|
||||||
PaperClipOutlined,
|
|
||||||
RobotOutlined,
|
|
||||||
FolderOpenOutlined,
|
|
||||||
SyncOutlined,
|
|
||||||
} from '@ant-design/icons'
|
|
||||||
import { agentApi, type AgentConfig } from '../api/agentApi'
|
import { agentApi, type AgentConfig } from '../api/agentApi'
|
||||||
import { message } from 'antd'
|
import conversationApi, { type Conversation, type Message } from '../api/conversationApi'
|
||||||
|
import MessageList from '../components/chat/MessageList'
|
||||||
|
import MessageInput from '../components/chat/MessageInput'
|
||||||
|
import ModelSelector, { type ModelType } from '../components/chat/ModelSelector'
|
||||||
|
import { useProjectStore } from '../stores/useProjectStore'
|
||||||
|
|
||||||
const { Title, Paragraph } = Typography
|
const { Title, Paragraph } = Typography
|
||||||
const { TextArea } = Input
|
|
||||||
|
|
||||||
const AgentChatPage = () => {
|
const AgentChatPage = () => {
|
||||||
const { agentId } = useParams()
|
const { agentId } = useParams()
|
||||||
|
const { currentProject } = useProjectStore()
|
||||||
|
|
||||||
|
// 智能体相关状态
|
||||||
const [agent, setAgent] = useState<AgentConfig | null>(null)
|
const [agent, setAgent] = useState<AgentConfig | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [agentLoading, setAgentLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 对话相关状态
|
||||||
|
const [conversation, setConversation] = useState<Conversation | null>(null)
|
||||||
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
|
const [selectedModel, setSelectedModel] = useState<ModelType>('deepseek-v3')
|
||||||
|
|
||||||
|
// 消息发送状态
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [streamingContent, setStreamingContent] = useState('')
|
||||||
|
|
||||||
|
// 知识库(预留)
|
||||||
|
const [knowledgeBases] = useState([])
|
||||||
|
|
||||||
// 加载智能体配置
|
// 加载智能体配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchAgent = async () => {
|
const fetchAgent = async () => {
|
||||||
if (!agentId) return
|
if (!agentId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setAgentLoading(true)
|
||||||
const response = await agentApi.getById(agentId)
|
const response = await agentApi.getById(agentId)
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
@@ -39,14 +51,103 @@ const AgentChatPage = () => {
|
|||||||
setError('加载智能体配置失败')
|
setError('加载智能体配置失败')
|
||||||
message.error('加载智能体配置失败')
|
message.error('加载智能体配置失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setAgentLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchAgent()
|
fetchAgent()
|
||||||
}, [agentId])
|
}, [agentId])
|
||||||
|
|
||||||
if (loading) {
|
// 创建或加载对话
|
||||||
|
useEffect(() => {
|
||||||
|
const initConversation = async () => {
|
||||||
|
if (!agent || !currentProject) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建新对话
|
||||||
|
const response = await conversationApi.createConversation({
|
||||||
|
projectId: currentProject.id,
|
||||||
|
agentId: agent.id,
|
||||||
|
title: `与${agent.name}的对话`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
setConversation(response.data.data)
|
||||||
|
setMessages([])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create conversation:', err)
|
||||||
|
message.error('创建对话失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initConversation()
|
||||||
|
}, [agent, currentProject])
|
||||||
|
|
||||||
|
// 发送消息(流式)
|
||||||
|
const handleSendMessage = async (content: string, knowledgeBaseIds: string[]) => {
|
||||||
|
if (!conversation || sending) return
|
||||||
|
|
||||||
|
setSending(true)
|
||||||
|
setStreamingContent('')
|
||||||
|
|
||||||
|
// 添加用户消息到列表
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
conversationId: conversation.id,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, userMessage])
|
||||||
|
|
||||||
|
try {
|
||||||
|
let fullContent = ''
|
||||||
|
|
||||||
|
await conversationApi.sendMessageStream(
|
||||||
|
{
|
||||||
|
conversationId: conversation.id,
|
||||||
|
content,
|
||||||
|
modelType: selectedModel,
|
||||||
|
knowledgeBaseIds,
|
||||||
|
},
|
||||||
|
// onChunk
|
||||||
|
(chunk) => {
|
||||||
|
fullContent += chunk
|
||||||
|
setStreamingContent(fullContent)
|
||||||
|
},
|
||||||
|
// onComplete
|
||||||
|
() => {
|
||||||
|
// 流式完成后,添加完整的助手消息
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: `temp-assistant-${Date.now()}`,
|
||||||
|
conversationId: conversation.id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: fullContent,
|
||||||
|
model: selectedModel,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
setMessages(prev => [...prev, assistantMessage])
|
||||||
|
setStreamingContent('')
|
||||||
|
setSending(false)
|
||||||
|
},
|
||||||
|
// onError
|
||||||
|
(error) => {
|
||||||
|
console.error('Stream error:', error)
|
||||||
|
message.error('发送消息失败:' + error.message)
|
||||||
|
setStreamingContent('')
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send message:', err)
|
||||||
|
message.error('发送消息失败')
|
||||||
|
setStreamingContent('')
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||||
<Spin size="large" tip="加载智能体配置中..." />
|
<Spin size="large" tip="加载智能体配置中..." />
|
||||||
@@ -54,6 +155,17 @@ const AgentChatPage = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!currentProject) {
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
message="请先选择项目"
|
||||||
|
description="请在侧边栏选择一个项目后再开始对话"
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (error || !agent) {
|
if (error || !agent) {
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -80,6 +192,7 @@ const AgentChatPage = () => {
|
|||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* 标题栏 */}
|
{/* 标题栏 */}
|
||||||
<Card style={{ marginBottom: 16 }}>
|
<Card style={{ marginBottom: 16 }}>
|
||||||
|
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Space align="center">
|
<Space align="center">
|
||||||
<span style={{ fontSize: 32 }}>{agent.icon}</span>
|
<span style={{ fontSize: 32 }}>{agent.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -90,10 +203,18 @@ const AgentChatPage = () => {
|
|||||||
{agent.description}
|
{agent.description}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }}>
|
<Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }}>
|
||||||
当前模型:DeepSeek-V3 | 分类:{agent.category}
|
分类:{agent.category} | 项目:{currentProject?.name}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
{/* 模型选择器 */}
|
||||||
|
<ModelSelector
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={setSelectedModel}
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 聊天区域 */}
|
{/* 聊天区域 */}
|
||||||
@@ -111,94 +232,32 @@ const AgentChatPage = () => {
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 消息列表区域(占位符) */}
|
{/* 消息列表 */}
|
||||||
<div
|
{messages.length === 0 && !sending ? (
|
||||||
style={{
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fafafa' }}>
|
||||||
flex: 1,
|
<div style={{ textAlign: 'center', color: '#999' }}>
|
||||||
padding: 24,
|
|
||||||
overflowY: 'auto',
|
|
||||||
background: '#fafafa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px 0', color: '#999' }}>
|
|
||||||
<RobotOutlined style={{ fontSize: 64, marginBottom: 16 }} />
|
<RobotOutlined style={{ fontSize: 64, marginBottom: 16 }} />
|
||||||
<div>开始对话,我将为您提供专业的研究建议</div>
|
<div style={{ fontSize: 16 }}>开始对话,我将为您提供专业的研究建议</div>
|
||||||
</div>
|
<div style={{ fontSize: 14, marginTop: 8 }}>
|
||||||
|
您可以直接输入问题,或使用@知识库功能引用文献
|
||||||
{/* 消息示例(占位符) */}
|
|
||||||
<div style={{ maxWidth: 800, margin: '0 auto' }}>
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<Tag color="blue" style={{ marginBottom: 8 }}>
|
|
||||||
用户
|
|
||||||
</Tag>
|
|
||||||
<Card size="small">
|
|
||||||
<Paragraph style={{ marginBottom: 0 }}>
|
|
||||||
这是一个示例消息...(开发中)
|
|
||||||
</Paragraph>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<Tag color="green" style={{ marginBottom: 8 }}>
|
|
||||||
AI助手
|
|
||||||
</Tag>
|
|
||||||
<Card size="small">
|
|
||||||
<Paragraph style={{ marginBottom: 0 }}>
|
|
||||||
这是AI的回复示例...(开发中)
|
|
||||||
</Paragraph>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<Divider style={{ margin: 0 }} />
|
<MessageList
|
||||||
|
messages={messages}
|
||||||
{/* 输入区域 */}
|
loading={sending}
|
||||||
<div style={{ padding: 16, background: '#fff' }}>
|
streamingContent={streamingContent}
|
||||||
{/* 工具栏 */}
|
|
||||||
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }}>
|
|
||||||
<Space>
|
|
||||||
<Upload showUploadList={false}>
|
|
||||||
<Button icon={<PaperClipOutlined />}>上传文件</Button>
|
|
||||||
</Upload>
|
|
||||||
|
|
||||||
<Button icon={<FolderOpenOutlined />}>
|
|
||||||
@ 知识库
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
defaultValue="deepseek-v3"
|
|
||||||
style={{ width: 180 }}
|
|
||||||
options={[
|
|
||||||
{ label: 'DeepSeek-V3', value: 'deepseek-v3' },
|
|
||||||
{ label: 'Qwen3-72B', value: 'qwen3-72b' },
|
|
||||||
{ label: 'Gemini Pro', value: 'gemini-pro' },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</Space>
|
)}
|
||||||
|
|
||||||
<Tag color="orange" icon={<SyncOutlined spin />}>
|
{/* 消息输入 */}
|
||||||
功能开发中...
|
<MessageInput
|
||||||
</Tag>
|
onSend={handleSendMessage}
|
||||||
</Space>
|
loading={sending}
|
||||||
|
knowledgeBases={knowledgeBases}
|
||||||
{/* 输入框 */}
|
placeholder={`向${agent.name}提问...(Shift+Enter换行,Enter发送)`}
|
||||||
<Space.Compact style={{ width: '100%' }}>
|
|
||||||
<TextArea
|
|
||||||
placeholder="输入您的问题... (功能开发中)"
|
|
||||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
|
||||||
disabled
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SendOutlined />}
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
发送
|
|
||||||
</Button>
|
|
||||||
</Space.Compact>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
11
frontend/src/vite-env.d.ts
vendored
Normal file
11
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string
|
||||||
|
// 可以在这里添加更多环境变量
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user