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:
AI Clinical Dev Team
2025-10-10 20:52:30 +08:00
parent 8bd2b4fc54
commit 84bf1c86ab
11 changed files with 2939 additions and 119 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -12,12 +12,16 @@
"dependencies": {
"@ant-design/icons": "^5.5.2",
"@types/js-yaml": "^4.0.9",
"@types/react-syntax-highlighter": "^15.5.13",
"antd": "^5.22.5",
"axios": "^1.12.2",
"js-yaml": "^4.1.0",
"react": "^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": {
"@types/react": "^18.3.18",

View 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,
};

View 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;
}
}

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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 };

View File

@@ -1,24 +1,36 @@
import { useParams } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { Card, Typography, Input, Button, Space, Select, Upload, Tag, Alert, Divider, Spin } from 'antd'
import {
SendOutlined,
PaperClipOutlined,
RobotOutlined,
FolderOpenOutlined,
SyncOutlined,
} from '@ant-design/icons'
import { Card, Typography, Space, Alert, Spin, message } from 'antd'
import { RobotOutlined } from '@ant-design/icons'
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 { TextArea } = Input
const AgentChatPage = () => {
const { agentId } = useParams()
const { currentProject } = useProjectStore()
// 智能体相关状态
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 [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(() => {
@@ -26,7 +38,7 @@ const AgentChatPage = () => {
if (!agentId) return
try {
setLoading(true)
setAgentLoading(true)
const response = await agentApi.getById(agentId)
if (response.success && response.data) {
@@ -39,14 +51,103 @@ const AgentChatPage = () => {
setError('加载智能体配置失败')
message.error('加载智能体配置失败')
} finally {
setLoading(false)
setAgentLoading(false)
}
}
fetchAgent()
}, [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 (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" tip="加载智能体配置中..." />
@@ -54,6 +155,17 @@ const AgentChatPage = () => {
)
}
if (!currentProject) {
return (
<Alert
message="请先选择项目"
description="请在侧边栏选择一个项目后再开始对话"
type="info"
showIcon
/>
)
}
if (error || !agent) {
return (
<Alert
@@ -80,19 +192,28 @@ const AgentChatPage = () => {
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 标题栏 */}
<Card style={{ marginBottom: 16 }}>
<Space align="center">
<span style={{ fontSize: 32 }}>{agent.icon}</span>
<div>
<Title level={3} style={{ marginBottom: 4 }}>
{agent.name}
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{agent.description}
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }}>
DeepSeek-V3 | {agent.category}
</Paragraph>
</div>
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<Space align="center">
<span style={{ fontSize: 32 }}>{agent.icon}</span>
<div>
<Title level={3} style={{ marginBottom: 4 }}>
{agent.name}
</Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{agent.description}
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }}>
{agent.category} | {currentProject?.name}
</Paragraph>
</div>
</Space>
{/* 模型选择器 */}
<ModelSelector
value={selectedModel}
onChange={setSelectedModel}
disabled={sending}
/>
</Space>
</Card>
@@ -111,94 +232,32 @@ const AgentChatPage = () => {
padding: 0,
}}
>
{/* 消息列表区域(占位符) */}
<div
style={{
flex: 1,
padding: 24,
overflowY: 'auto',
background: '#fafafa',
}}
>
<div style={{ textAlign: 'center', padding: '60px 0', color: '#999' }}>
<RobotOutlined style={{ fontSize: 64, marginBottom: 16 }} />
<div></div>
</div>
{/* 消息示例(占位符) */}
<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>
{/* 消息列表 */}
{messages.length === 0 && !sending ? (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fafafa' }}>
<div style={{ textAlign: 'center', color: '#999' }}>
<RobotOutlined style={{ fontSize: 64, marginBottom: 16 }} />
<div style={{ fontSize: 16 }}></div>
<div style={{ fontSize: 14, marginTop: 8 }}>
使@知识库功能引用文献
</div>
</div>
</div>
</div>
) : (
<MessageList
messages={messages}
loading={sending}
streamingContent={streamingContent}
/>
)}
<Divider style={{ margin: 0 }} />
{/* 输入区域 */}
<div style={{ padding: 16, background: '#fff' }}>
{/* 工具栏 */}
<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 />}>
...
</Tag>
</Space>
{/* 输入框 */}
<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>
{/* 消息输入 */}
<MessageInput
onSend={handleSendMessage}
loading={sending}
knowledgeBases={knowledgeBases}
placeholder={`${agent.name}提问...Shift+Enter换行Enter发送`}
/>
</Card>
</div>
)

11
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
// 可以在这里添加更多环境变量
}
interface ImportMeta {
readonly env: ImportMetaEnv
}