lanny xu commited on
Commit
1af767b
·
1 Parent(s): 152b32b
Files changed (2) hide show
  1. run_server.py +82 -0
  2. server.py +537 -0
run_server.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Kaggle/Colab 启动脚本
3
+ 用于启动 FastAPI 服务器并配置 ngrok 穿透
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import subprocess
9
+ import time
10
+ import threading
11
+
12
+ def install_ngrok():
13
+ """安装 pyngrok"""
14
+ print("🔧 正在安装 pyngrok...")
15
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "pyngrok"])
16
+
17
+ def run_server():
18
+ """在后台运行服务器"""
19
+ print("🚀 启动 FastAPI 服务器...")
20
+ subprocess.Popen([sys.executable, "server.py"])
21
+
22
+ def start_ngrok():
23
+ """启动 ngrok 穿透"""
24
+ try:
25
+ from pyngrok import ngrok
26
+
27
+ # 尝试读取 token
28
+ token = os.environ.get("NGROK_AUTHTOKEN")
29
+ if not token:
30
+ print("\n⚠️ 警告: 未设置 NGROK_AUTHTOKEN 环境变量")
31
+ print(" 虽然可以运行,但连接时间会受限。建议在 Secrets 中设置。")
32
+ # 尝试从输入读取(仅在交互模式下)
33
+ # token = input("请输入 ngrok authtoken (可选): ")
34
+
35
+ if token:
36
+ ngrok.set_auth_token(token)
37
+
38
+ # 建立隧道
39
+ public_url = ngrok.connect(8000).public_url
40
+
41
+ print("\n" + "="*60)
42
+ print(f"✅ 成功穿透! 公网访问地址:")
43
+ print(f"👉 {public_url}")
44
+ print("="*60 + "\n")
45
+
46
+ # 保持运行
47
+ try:
48
+ while True:
49
+ time.sleep(1)
50
+ except KeyboardInterrupt:
51
+ print("正在关闭...")
52
+ ngrok.kill()
53
+
54
+ except ImportError:
55
+ print("❌ pyngrok 导入失败,请确保已安装")
56
+ except Exception as e:
57
+ print(f"❌ ngrok 启动失败: {e}")
58
+
59
+ if __name__ == "__main__":
60
+ # 1. 安装依赖
61
+ try:
62
+ import uvicorn
63
+ import fastapi
64
+ except ImportError:
65
+ print("🔧 安装 FastAPI 依赖...")
66
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "fastapi", "uvicorn", "python-multipart"])
67
+
68
+ try:
69
+ import pyngrok
70
+ except ImportError:
71
+ install_ngrok()
72
+
73
+ # 2. 启动 FastAPI
74
+ server_thread = threading.Thread(target=run_server)
75
+ server_thread.daemon = True
76
+ server_thread.start()
77
+
78
+ # 等待服务器启动
79
+ time.sleep(3)
80
+
81
+ # 3. 启动 ngrok
82
+ start_ngrok()
server.py ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI + React 18 单文件全栈应用
3
+ 专为 Kaggle/Colab 环境设计,展示企业级前后端分离架构
4
+
5
+ 功能特点:
6
+ 1. 后端:FastAPI (异步、高性能、自动文档)
7
+ 2. 前端:React 18 + Tailwind CSS (现代化UI、组件化)
8
+ 3. 部署:单文件运行,自动处理静态资源,支持 ngrok 穿透
9
+
10
+ 使用方法:
11
+ python server.py
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ import uvicorn
17
+ from fastapi import FastAPI, UploadFile, File, HTTPException
18
+ from fastapi.responses import HTMLResponse
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+ from pydantic import BaseModel
21
+ from typing import List, Optional
22
+ import shutil
23
+
24
+ # 导入项目核心模块
25
+ # 确保项目根目录在 sys.path 中
26
+ sys.path.append(os.getcwd())
27
+
28
+ try:
29
+ from main import AdaptiveRAGSystem
30
+ from config import ENABLE_MULTIMODAL
31
+ except ImportError:
32
+ # 模拟导入,防止在没有依赖的环境下报错
33
+ class AdaptiveRAGSystem:
34
+ def __init__(self, *args, **kwargs): pass
35
+ def query(self, _): return {"answer": "系统未正确初始化", "sources": []}
36
+ ENABLE_MULTIMODAL = False
37
+
38
+ # ============================================================
39
+ # 1. FastAPI 后端定义
40
+ # ============================================================
41
+
42
+ app = FastAPI(
43
+ title="Adaptive RAG Enterprise API",
44
+ description="基于 FastAPI 和 React 构建的企业级 RAG 系统演示",
45
+ version="1.0.0"
46
+ )
47
+
48
+ # 允许跨域 (虽然单体部署不需要,但为了开发规范加上)
49
+ app.add_middleware(
50
+ CORSMiddleware,
51
+ allow_origins=["*"],
52
+ allow_credentials=True,
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+
57
+ # 全局 RAG 系统实例
58
+ rag_system = None
59
+
60
+ def get_rag_system():
61
+ global rag_system
62
+ if rag_system is None:
63
+ try:
64
+ print("🔄 初始化 RAG 系统...")
65
+ rag_system = AdaptiveRAGSystem()
66
+ print("✅ RAG 系统初始化完成")
67
+ except Exception as e:
68
+ print(f"❌ RAG 系统初始化失败: {e}")
69
+ raise HTTPException(status_code=500, detail=str(e))
70
+ return rag_system
71
+
72
+ # --- 数据模型 ---
73
+
74
+ class ChatRequest(BaseModel):
75
+ message: str
76
+ history: List[dict] = []
77
+
78
+ class ChatResponse(BaseModel):
79
+ answer: str
80
+ sources: List[str] = []
81
+ metrics: Optional[dict] = None
82
+ images: List[str] = []
83
+
84
+ # --- API 路由 ---
85
+
86
+ @app.get("/api/health")
87
+ async def health_check():
88
+ """健康检查接口"""
89
+ return {"status": "ok", "service": "Adaptive RAG", "multimodal": ENABLE_MULTIMODAL}
90
+
91
+ @app.post("/api/chat", response_model=ChatResponse)
92
+ async def chat_endpoint(request: ChatRequest):
93
+ """聊天接口"""
94
+ system = get_rag_system()
95
+
96
+ try:
97
+ # 调用 RAG 系统的主查询方法
98
+ # 注意:这里假设 main.py 中的 AdaptiveRAGSystem 有 query 方法
99
+ # 如果是 main_graphrag.py,可能需要调整调用逻辑
100
+ result = system.query(request.message)
101
+
102
+ # 解析结果
103
+ answer = result.get("answer", "无法生成回答")
104
+ sources = [doc.page_content[:200] + "..." for doc in result.get("source_documents", [])]
105
+ metrics = result.get("retrieval_metrics", {})
106
+
107
+ # 处理多模态图片结果 (如果有)
108
+ images = []
109
+ if ENABLE_MULTIMODAL and "images" in result:
110
+ # 这里简化处理,实际可能需要返回图片URL或Base64
111
+ images = result["images"]
112
+
113
+ return ChatResponse(
114
+ answer=answer,
115
+ sources=sources,
116
+ metrics=metrics,
117
+ images=images
118
+ )
119
+
120
+ except Exception as e:
121
+ import traceback
122
+ traceback.print_exc()
123
+ raise HTTPException(status_code=500, detail=f"处理请求时出错: {str(e)}")
124
+
125
+ @app.post("/api/upload")
126
+ async def upload_file(file: UploadFile = File(...)):
127
+ """文件上传接口"""
128
+ try:
129
+ # 确保上传目录存在
130
+ upload_dir = "./data/uploads"
131
+ os.makedirs(upload_dir, exist_ok=True)
132
+
133
+ file_path = os.path.join(upload_dir, file.filename)
134
+
135
+ with open(file_path, "wb") as buffer:
136
+ shutil.copyfileobj(file.file, buffer)
137
+
138
+ return {"filename": file.filename, "status": "success", "message": "文件上传成功,将在下次索引重建时生效"}
139
+ except Exception as e:
140
+ raise HTTPException(status_code=500, detail=f"文件上传失败: {str(e)}")
141
+
142
+ # ============================================================
143
+ # 2. 前端 React 应用 (嵌入在 Python 字符串中)
144
+ # ============================================================
145
+
146
+ HTML_CONTENT = """
147
+ <!DOCTYPE html>
148
+ <html lang="zh-CN">
149
+ <head>
150
+ <meta charset="UTF-8">
151
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
152
+ <title>Enterprise RAG System (React)</title>
153
+
154
+ <!-- 引入 React 和 ReactDOM -->
155
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
156
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
157
+
158
+ <!-- 引入 Babel 用于解析 JSX -->
159
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
160
+
161
+ <!-- 引入 Tailwind CSS -->
162
+ <script src="https://cdn.tailwindcss.com"></script>
163
+
164
+ <!-- 引入 Markdown 渲染库 -->
165
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
166
+
167
+ <!-- 引入 FontAwesome 图标 -->
168
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
169
+
170
+ <style>
171
+ .markdown-body p { margin-bottom: 0.5rem; }
172
+ .markdown-body ul { list-style-type: disc; margin-left: 1.5rem; }
173
+ .markdown-body ol { list-style-type: decimal; margin-left: 1.5rem; }
174
+ .markdown-body pre { background-color: #f3f4f6; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
175
+ .markdown-body code { background-color: #f3f4f6; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: monospace; }
176
+
177
+ /* 自定义滚动条 */
178
+ ::-webkit-scrollbar { width: 8px; }
179
+ ::-webkit-scrollbar-track { background: #f1f1f1; }
180
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
181
+ ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
182
+
183
+ .typing-indicator span {
184
+ display: inline-block;
185
+ width: 6px;
186
+ height: 6px;
187
+ background-color: #94a3b8;
188
+ border-radius: 50%;
189
+ animation: typing 1.4s infinite ease-in-out both;
190
+ margin: 0 2px;
191
+ }
192
+ .typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
193
+ .typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
194
+ @keyframes typing {
195
+ 0%, 80%, 100% { transform: scale(0); }
196
+ 40% { transform: scale(1); }
197
+ }
198
+ </style>
199
+ </head>
200
+ <body class="bg-slate-50 h-screen flex flex-col font-sans text-slate-800">
201
+ <div id="root" class="h-full w-full"></div>
202
+
203
+ <script type="text/babel">
204
+ const { useState, useEffect, useRef } = React;
205
+
206
+ function App() {
207
+ const [messages, setMessages] = useState([]);
208
+ const [inputMessage, setInputMessage] = useState('');
209
+ const [loading, setLoading] = useState(false);
210
+ const [sidebarOpen, setSidebarOpen] = useState(true);
211
+ const [showSources, setShowSources] = useState(true);
212
+ const [showMetrics, setShowMetrics] = useState(false);
213
+ const [multimodalEnabled, setMultimodalEnabled] = useState(false);
214
+ const [uploadStatus, setUploadStatus] = useState(null);
215
+
216
+ const chatContainerRef = useRef(null);
217
+ const fileInputRef = useRef(null);
218
+
219
+ // 初始化检查健康状态
220
+ useEffect(() => {
221
+ fetch('/api/health')
222
+ .then(res => res.json())
223
+ .then(data => setMultimodalEnabled(data.multimodal))
224
+ .catch(err => console.error("无法连接到后端", err));
225
+ }, []);
226
+
227
+ // 自动滚动到底部
228
+ useEffect(() => {
229
+ if (chatContainerRef.current) {
230
+ chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
231
+ }
232
+ }, [messages, loading]);
233
+
234
+ const sendMessage = async () => {
235
+ if (!inputMessage.trim() || loading) return;
236
+
237
+ const userMsg = inputMessage.trim();
238
+ setMessages(prev => [...prev, { role: 'user', content: userMsg }]);
239
+ setInputMessage('');
240
+ setLoading(true);
241
+
242
+ try {
243
+ const response = await fetch('/api/chat', {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: JSON.stringify({ message: userMsg })
247
+ });
248
+
249
+ if (!response.ok) throw new Error('API request failed');
250
+
251
+ const data = await response.json();
252
+
253
+ setMessages(prev => [...prev, {
254
+ role: 'assistant',
255
+ content: data.answer,
256
+ sources: data.sources,
257
+ metrics: data.metrics,
258
+ images: data.images
259
+ }]);
260
+
261
+ } catch (error) {
262
+ setMessages(prev => [...prev, {
263
+ role: 'assistant',
264
+ content: '⚠️ 系统遇到错误,请稍后重试。'
265
+ }]);
266
+ console.error(error);
267
+ } finally {
268
+ setLoading(false);
269
+ }
270
+ };
271
+
272
+ const handleFileUpload = async (event) => {
273
+ const file = event.target.files[0];
274
+ if (!file) return;
275
+
276
+ const formData = new FormData();
277
+ formData.append('file', file);
278
+
279
+ setUploadStatus({ type: 'info', message: '正在上传...' });
280
+
281
+ try {
282
+ const response = await fetch('/api/upload', {
283
+ method: 'POST',
284
+ body: formData
285
+ });
286
+
287
+ if (response.ok) {
288
+ setUploadStatus({ type: 'success', message: '上传成功!' });
289
+ } else {
290
+ throw new Error('Upload failed');
291
+ }
292
+ } catch (error) {
293
+ setUploadStatus({ type: 'error', message: '上传失败' });
294
+ }
295
+
296
+ setTimeout(() => setUploadStatus(null), 3000);
297
+ };
298
+
299
+ const handleKeyDown = (e) => {
300
+ if (e.key === 'Enter' && !e.shiftKey) {
301
+ e.preventDefault();
302
+ sendMessage();
303
+ }
304
+ };
305
+
306
+ return (
307
+ <div className="flex h-full max-w-7xl mx-auto w-full shadow-2xl bg-white overflow-hidden">
308
+
309
+ {/* 左侧侧边栏 */}
310
+ <div className={`bg-slate-900 text-white flex flex-col flex-shrink-0 transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-0'}`}>
311
+ <div className="p-4 border-b border-slate-700 flex items-center justify-between">
312
+ <div className="flex items-center space-x-2 font-bold text-xl overflow-hidden whitespace-nowrap">
313
+ <i className="fa-solid fa-brain text-blue-400"></i>
314
+ <span>Adaptive RAG</span>
315
+ </div>
316
+ </div>
317
+
318
+ <div className="flex-1 overflow-y-auto p-4 space-y-6">
319
+ {/* 系统状态 */}
320
+ <div>
321
+ <h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">系统状态</h3>
322
+ <div className="flex items-center space-x-2 text-sm">
323
+ <span className="w-2 h-2 rounded-full bg-green-500"></span>
324
+ <span>API 服务正常</span>
325
+ </div>
326
+ <div className="flex items-center space-x-2 text-sm mt-1">
327
+ <span className={`w-2 h-2 rounded-full ${multimodalEnabled ? 'bg-green-500' : 'bg-gray-500'}`}></span>
328
+ <span>多模态支持: {multimodalEnabled ? '开启' : '关闭'}</span>
329
+ </div>
330
+ </div>
331
+
332
+ {/* 知识库管理 */}
333
+ <div>
334
+ <h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">知识库管理</h3>
335
+ <div
336
+ className="bg-slate-800 rounded-lg p-3 text-center border border-dashed border-slate-600 hover:border-blue-400 transition-colors cursor-pointer"
337
+ onClick={() => fileInputRef.current.click()}
338
+ >
339
+ <input
340
+ type="file"
341
+ ref={fileInputRef}
342
+ className="hidden"
343
+ onChange={handleFileUpload}
344
+ />
345
+ <i className="fa-solid fa-cloud-arrow-up text-2xl text-slate-400 mb-2"></i>
346
+ <p className="text-xs text-slate-300">点击上传 PDF/图片</p>
347
+ </div>
348
+ {uploadStatus && (
349
+ <p className={`text-xs mt-2 text-center ${uploadStatus.type === 'success' ? 'text-green-400' : 'text-red-400'}`}>
350
+ {uploadStatus.message}
351
+ </p>
352
+ )}
353
+ </div>
354
+
355
+ {/* 设置 */}
356
+ <div>
357
+ <h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">设置</h3>
358
+ <div className="flex items-center justify-between text-sm py-1">
359
+ <span>显示检索源</span>
360
+ <button
361
+ className={`w-8 h-4 rounded-full relative transition-colors ${showSources ? 'bg-blue-600' : 'bg-slate-700'}`}
362
+ onClick={() => setShowSources(!showSources)}
363
+ >
364
+ <span className={`absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform ${showSources ? 'translate-x-4' : ''}`}></span>
365
+ </button>
366
+ </div>
367
+ <div className="flex items-center justify-between text-sm py-1">
368
+ <span>显示指标</span>
369
+ <button
370
+ className={`w-8 h-4 rounded-full relative transition-colors ${showMetrics ? 'bg-blue-600' : 'bg-slate-700'}`}
371
+ onClick={() => setShowMetrics(!showMetrics)}
372
+ >
373
+ <span className={`absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform ${showMetrics ? 'translate-x-4' : ''}`}></span>
374
+ </button>
375
+ </div>
376
+ </div>
377
+ </div>
378
+
379
+ <div className="p-4 border-t border-slate-700 text-xs text-slate-500 text-center">
380
+ Enterprise Edition v1.0
381
+ </div>
382
+ </div>
383
+
384
+ {/* 主聊天区域 */}
385
+ <div className="flex-1 flex flex-col h-full bg-slate-50 relative">
386
+
387
+ {/* 顶部导航栏 */}
388
+ <div className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6 shadow-sm z-10">
389
+ <div className="flex items-center">
390
+ <button onClick={() => setSidebarOpen(!sidebarOpen)} className="text-slate-500 hover:text-slate-700 focus:outline-none mr-4">
391
+ <i className="fa-solid fa-bars text-xl"></i>
392
+ </button>
393
+ <h1 className="text-lg font-semibold text-slate-700">智能知识库助手 (React版)</h1>
394
+ </div>
395
+ <div className="flex items-center space-x-4">
396
+ <a href="/docs" target="_blank" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
397
+ <i className="fa-solid fa-book-open mr-1"></i> API 文档
398
+ </a>
399
+ </div>
400
+ </div>
401
+
402
+ {/* 聊天记录 */}
403
+ <div className="flex-1 overflow-y-auto p-6 space-y-6 scroll-smooth" ref={chatContainerRef}>
404
+
405
+ {messages.length === 0 && (
406
+ <div className="flex flex-col items-center justify-center h-full text-center space-y-4 opacity-50">
407
+ <div className="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center text-blue-500 text-4xl mb-4">
408
+ <i className="fa-solid fa-robot"></i>
409
+ </div>
410
+ <h2 className="text-2xl font-bold text-slate-700">有什么可以帮您?</h2>
411
+ <p className="text-slate-500 max-w-md">我可以回答关于知识库的问题,支持多模态检索和图谱分析。</p>
412
+ <div className="grid grid-cols-2 gap-4 mt-8 w-full max-w-lg">
413
+ <button onClick={() => setInputMessage('GraphRAG 的核心原理是什么?')} className="p-4 bg-white border border-slate-200 rounded-xl hover:border-blue-400 hover:shadow-md transition-all text-left text-sm">
414
+ GraphRAG 的核心原理是什么?
415
+ </button>
416
+ <button onClick={() => setInputMessage('分析这些文档的主要主题')} className="p-4 bg-white border border-slate-200 rounded-xl hover:border-blue-400 hover:shadow-md transition-all text-left text-sm">
417
+ 分析这些文档的主要主题
418
+ </button>
419
+ </div>
420
+ </div>
421
+ )}
422
+
423
+ {messages.map((msg, index) => (
424
+ <div key={index} className={`flex flex-col space-y-2 ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
425
+ {msg.role === 'user' ? (
426
+ <div className="flex items-end space-x-2 max-w-[80%]">
427
+ <div className="bg-blue-600 text-white px-5 py-3 rounded-2xl rounded-tr-none shadow-md">
428
+ {msg.content}
429
+ </div>
430
+ <div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 flex-shrink-0">
431
+ <i className="fa-solid fa-user"></i>
432
+ </div>
433
+ </div>
434
+ ) : (
435
+ <div className="flex items-start space-x-3 max-w-[90%] w-full">
436
+ <div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center text-green-600 flex-shrink-0 mt-1">
437
+ <i className="fa-solid fa-robot"></i>
438
+ </div>
439
+ <div className="flex flex-col space-y-2 w-full">
440
+ <div
441
+ className="bg-white border border-slate-200 px-6 py-4 rounded-2xl rounded-tl-none shadow-sm w-full markdown-body"
442
+ dangerouslySetInnerHTML={{ __html: marked.parse(msg.content) }}
443
+ ></div>
444
+
445
+ {showSources && msg.sources && msg.sources.length > 0 && (
446
+ <div className="bg-slate-50 border border-slate-200 rounded-xl p-3 text-xs text-slate-600 mt-2">
447
+ <div className="font-semibold mb-2 flex items-center text-slate-400">
448
+ <i className="fa-solid fa-quote-left mr-2"></i> 参考来源
449
+ </div>
450
+ {msg.sources.map((source, idx) => (
451
+ <div key={idx} className="mb-2 last:mb-0 pl-3 border-l-2 border-blue-300">
452
+ {source}
453
+ </div>
454
+ ))}
455
+ </div>
456
+ )}
457
+
458
+ {showMetrics && msg.metrics && (
459
+ <div className="flex flex-wrap gap-2 mt-1">
460
+ <span className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-500 border border-slate-200">
461
+ <i className="fa-solid fa-clock mr-1"></i> {msg.metrics.latency ? msg.metrics.latency.toFixed(3) : 0}s
462
+ </span>
463
+ <span className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-500 border border-slate-200">
464
+ <i className="fa-solid fa-file-lines mr-1"></i> Docs: {msg.metrics.retrieved_docs_count || 0}
465
+ </span>
466
+ </div>
467
+ )}
468
+ </div>
469
+ </div>
470
+ )}
471
+ </div>
472
+ ))}
473
+
474
+ {loading && (
475
+ <div className="flex items-start space-x-3">
476
+ <div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center text-green-600 flex-shrink-0 mt-1">
477
+ <i className="fa-solid fa-robot"></i>
478
+ </div>
479
+ <div className="bg-white border border-slate-200 px-5 py-3 rounded-2xl rounded-tl-none shadow-sm">
480
+ <div className="typing-indicator">
481
+ <span></span><span></span><span></span>
482
+ </div>
483
+ </div>
484
+ </div>
485
+ )}
486
+ </div>
487
+
488
+ {/* 输入区域 */}
489
+ <div className="bg-white p-4 border-t border-slate-200">
490
+ <div className="max-w-4xl mx-auto relative">
491
+ <textarea
492
+ value={inputMessage}
493
+ onChange={(e) => setInputMessage(e.target.value)}
494
+ onKeyDown={handleKeyDown}
495
+ placeholder="输入您的问题... (Shift + Enter 换行)"
496
+ className="w-full pl-4 pr-12 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none shadow-inner text-sm"
497
+ rows="2"
498
+ ></textarea>
499
+ <button
500
+ onClick={sendMessage}
501
+ disabled={loading || !inputMessage.trim()}
502
+ className="absolute right-2 bottom-2.5 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-md"
503
+ >
504
+ <i className="fa-solid fa-paper-plane"></i>
505
+ </button>
506
+ </div>
507
+ <div className="text-center mt-2 text-xs text-slate-400">
508
+ Powered by Adaptive RAG & FastAPI & React
509
+ </div>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ );
514
+ }
515
+
516
+ const root = ReactDOM.createRoot(document.getElementById('root'));
517
+ root.render(<App />);
518
+ </script>
519
+ </body>
520
+ </html>
521
+ """
522
+
523
+ @app.get("/", response_class=HTMLResponse)
524
+ async def read_root():
525
+ """返回单页应用前端"""
526
+ return HTMLResponse(content=HTML_CONTENT)
527
+
528
+ if __name__ == "__main__":
529
+ print("="*60)
530
+ print("🚀 企业级 RAG 服务器启动中...")
531
+ print(" 后端: FastAPI")
532
+ print(" 前端: Vue 3 + Tailwind")
533
+ print(" 地址: http://0.0.0.0:8000")
534
+ print(" 文档: http://0.0.0.0:8000/docs")
535
+ print("="*60)
536
+
537
+ uvicorn.run(app, host="0.0.0.0", port=8000)