Stage 2 & 3: Remove Graphy, integrate ZVec vectorization, and add knowledge base chat
## Changes ### Stage 2: ZVec Integration - Created `zvec_service.py` for MD file vectorization with Aliyun ZVec - Added `document_vector.py` model for storing embeddings - Integrated ZVec into file operations (create/update/delete MD files) - Added `create_document_vector_table.sql` migration - Background vectorization on file save, removal on file delete ### Stage 3: Knowledge Base Chat - Created `chat_session.py` model for chat sessions and messages - Implemented `rag_service.py` for RAG (vector retrieval + LLM generation) - Added `chat.py` API endpoints (create sessions, send messages, list history) - Created `Chat.jsx` frontend component with project/model selection - Added `chat.js` API client - Chat route integrated into App.jsx ### Cleanup - Removed all graphy-related files (graphy/, graphy.py, graphy_task.py, etc.) - Removed BUILD_GRAPH from enums (no longer needed) ### Database - Created migration script for chat tables (ChatSession, ChatMessage) - Created migration script for document vectors Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>main
parent
0b0713fc4e
commit
e6c92551ea
|
|
@ -0,0 +1,17 @@
|
|||
.git
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
storage
|
||||
backup
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/venv
|
||||
backend/__pycache__
|
||||
backend/logs
|
||||
graphy/.venv
|
||||
graphy/build
|
||||
graphy/dist
|
||||
graphy/*.egg-info
|
||||
**/__pycache__
|
||||
**/.pytest_cache
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# 三阶段实现计划:远端合并 + ZVec集成 + 知识库对话
|
||||
|
||||
## Stage 1: 远端更新合并 & 模型配置集成
|
||||
**Goal**: 合并远端的新增功能(项目管理、文件管理、分享等),保留本地的模型配置功能
|
||||
**Success Criteria**:
|
||||
- 无冲突合并远端代码
|
||||
- 本地LLMModelConfig功能完全保留
|
||||
- 所有数据库迁移脚本就位
|
||||
- 前后端依赖关系正确
|
||||
**Tests**:
|
||||
- 数据库初始化成功
|
||||
- 项目CRUD操作正常
|
||||
- 模型配置API正常工作
|
||||
**Status**: Complete
|
||||
|
||||
## Stage 2: 删除Graphy + 集成ZVec向量化
|
||||
**Goal**: 移除graphy组件,实现ZVec自动向量化(MD文件操作时)
|
||||
**Success Criteria**:
|
||||
- ✅ 删除所有graphy相关文件和导入
|
||||
- ✅ 创建ZVec集成服务
|
||||
- ✅ 在文件操作(创建/修改/删除)时自动调用ZVec(仅限MD)
|
||||
- ✅ 向量化结果存储到数据库
|
||||
**Tests**:
|
||||
- 创建MD文件时触发向量化
|
||||
- 修改MD文件时重新向量化
|
||||
- 删除MD文件时清理向量数据
|
||||
- PDF文件不触发向量化
|
||||
**Status**: In Progress (核心服务完成,待数据库创建)
|
||||
|
||||
## Stage 3: 知识库对话功能
|
||||
**Goal**: 基于ZVec向量化的文档,实现大模型知识库对话
|
||||
**Success Criteria**:
|
||||
- ✅ 创建知识库对话页面/组件
|
||||
- ✅ 支持项目选择
|
||||
- ✅ 支持LLM模型选择(使用Stage 1的模型配置)
|
||||
- ✅ RAG检索+大模型生成对话
|
||||
- ✅ 对话历史记录
|
||||
**Tests**:
|
||||
- 能成功创建对话会话
|
||||
- 向量检索返回相关文档
|
||||
- 大模型生成回复正常
|
||||
- 对话历史保存正确
|
||||
**Status**: Complete
|
||||
|
||||
---
|
||||
|
||||
## 关键技术决策
|
||||
1. **ZVec集成方式**:通过项目文件监听或API触发(取决于项目架构)
|
||||
2. **向量存储**:在DocumentMeta模型中新增embedding字段或创建单独的向量表
|
||||
3. **RAG实现**:使用向量相似度检索 + LLMProviderService进行生成
|
||||
4. **前端交互**:Chat页面,侧边栏项目/模型选择,消息列表
|
||||
|
||||
## 已有基础
|
||||
- ✅ LLMProviderService(多协议支持)
|
||||
- ✅ LLMModelConfig模型和API
|
||||
- ✅ 项目管理系统
|
||||
- ✅ 文件管理系统
|
||||
- ✅ 前端框架(React+Ant Design)
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
API v1 路由汇总
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, shares, llm_model_configs
|
||||
from app.api.v1 import auth, projects, files, menu, dashboard, preview, role_permissions, users, roles, search, logs, git_repos, notifications, shares, llm_model_configs, chat
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
|
@ -22,3 +22,4 @@ api_router.include_router(roles.router, prefix="/roles", tags=["角色管理"])
|
|||
api_router.include_router(search.router, prefix="/search", tags=["文档搜索"])
|
||||
api_router.include_router(logs.router, prefix="/logs", tags=["系统日志"])
|
||||
api_router.include_router(llm_model_configs.router, prefix="/llm-model-configs", tags=["LLM 模型配置"])
|
||||
api_router.include_router(chat.router, prefix="/chat", tags=["知识库对话"])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
"""
|
||||
知识库对话相关 API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
from typing import List
|
||||
import json
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.chat_session import ChatSession, ChatMessage
|
||||
from app.models.project import Project
|
||||
from app.models.document_vector import DocumentVector
|
||||
from app.schemas.response import success_response
|
||||
from app.services.project_service import get_project_or_404, require_project_read_access
|
||||
from app.services.rag_service import rag_service
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ChatCreateRequest(BaseModel):
|
||||
"""创建对话会话请求"""
|
||||
project_id: int
|
||||
llm_config_id: int
|
||||
title: str = "新对话"
|
||||
|
||||
|
||||
class ChatMessageRequest(BaseModel):
|
||||
"""发送聊天消息请求"""
|
||||
session_id: int
|
||||
message: str
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
"""对话响应"""
|
||||
session_id: int
|
||||
user_message: str
|
||||
assistant_message: str
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=dict)
|
||||
async def create_chat_session(
|
||||
req: ChatCreateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建新的对话会话"""
|
||||
project = await get_project_or_404(db, req.project_id)
|
||||
_, user_role = await require_project_read_access(db, req.project_id, current_user)
|
||||
|
||||
session = ChatSession(
|
||||
user_id=current_user.id,
|
||||
project_id=req.project_id,
|
||||
llm_config_id=req.llm_config_id,
|
||||
title=req.title,
|
||||
)
|
||||
db.add(session)
|
||||
await db.commit()
|
||||
await db.refresh(session)
|
||||
|
||||
return success_response(data={
|
||||
"session_id": session.id,
|
||||
"title": session.title,
|
||||
"created_at": session.created_at.isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=dict)
|
||||
async def list_chat_sessions(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""列出项目的所有对话会话"""
|
||||
await require_project_read_access(db, project_id, current_user)
|
||||
|
||||
stmt = select(ChatSession).where(
|
||||
ChatSession.project_id == project_id,
|
||||
ChatSession.user_id == current_user.id,
|
||||
).order_by(ChatSession.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
sessions = result.scalars().all()
|
||||
|
||||
return success_response(data=[{
|
||||
"id": s.id,
|
||||
"title": s.title,
|
||||
"created_at": s.created_at.isoformat(),
|
||||
"updated_at": s.updated_at.isoformat(),
|
||||
} for s in sessions])
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/messages", response_model=dict)
|
||||
async def get_session_messages(
|
||||
session_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取对话会话的所有消息"""
|
||||
stmt = select(ChatSession).where(ChatSession.id == session_id)
|
||||
result = await db.execute(stmt)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="对话会话不存在")
|
||||
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该对话")
|
||||
|
||||
await require_project_read_access(db, session.project_id, current_user)
|
||||
|
||||
msg_stmt = select(ChatMessage).where(
|
||||
ChatMessage.session_id == session_id
|
||||
).order_by(ChatMessage.created_at.asc())
|
||||
msg_result = await db.execute(msg_stmt)
|
||||
messages = msg_result.scalars().all()
|
||||
|
||||
return success_response(data=[{
|
||||
"id": m.id,
|
||||
"role": m.role,
|
||||
"content": m.content,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
} for m in messages])
|
||||
|
||||
|
||||
@router.post("/send", response_model=dict)
|
||||
async def send_chat_message(
|
||||
req: ChatMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""发送对话消息并获取回复"""
|
||||
stmt = select(ChatSession).where(ChatSession.id == req.session_id)
|
||||
result = await db.execute(stmt)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="对话会话不存在")
|
||||
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权访问该对话")
|
||||
|
||||
await require_project_read_access(db, session.project_id, current_user)
|
||||
|
||||
query_message = ChatMessage(
|
||||
session_id=req.session_id,
|
||||
role="user",
|
||||
content=req.message,
|
||||
)
|
||||
db.add(query_message)
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
query_vector = await _get_query_vector(req.message)
|
||||
|
||||
retrieved_docs = await rag_service.retrieve_documents(
|
||||
db,
|
||||
session.project_id,
|
||||
query_vector,
|
||||
top_k=5,
|
||||
)
|
||||
|
||||
msg_stmt = select(ChatMessage).where(
|
||||
ChatMessage.session_id == req.session_id,
|
||||
).order_by(ChatMessage.created_at.asc())
|
||||
msg_result = await db.execute(msg_stmt)
|
||||
prev_messages = msg_result.scalars().all()
|
||||
|
||||
conversation_history = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in prev_messages
|
||||
if m.id != query_message.id
|
||||
]
|
||||
|
||||
assistant_response = await rag_service.generate_response(
|
||||
db,
|
||||
req.message,
|
||||
query_vector,
|
||||
session.project_id,
|
||||
session.llm_config_id,
|
||||
retrieved_docs,
|
||||
conversation_history,
|
||||
)
|
||||
|
||||
assistant_message = ChatMessage(
|
||||
session_id=req.session_id,
|
||||
role="assistant",
|
||||
content=assistant_response,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={
|
||||
"session_id": req.session_id,
|
||||
"user_message": req.message,
|
||||
"assistant_message": assistant_response,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"生成对话回复失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", response_model=dict)
|
||||
async def delete_chat_session(
|
||||
session_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除对话会话"""
|
||||
stmt = select(ChatSession).where(ChatSession.id == session_id)
|
||||
result = await db.execute(stmt)
|
||||
session = result.scalar_one_or_none()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="对话会话不存在")
|
||||
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="无权删除该对话")
|
||||
|
||||
await db.delete(session)
|
||||
msg_stmt = delete(ChatMessage).where(ChatMessage.session_id == session_id)
|
||||
await db.execute(msg_stmt)
|
||||
await db.commit()
|
||||
|
||||
return success_response(message="对话会话已删除")
|
||||
|
||||
|
||||
async def _get_query_vector(query: str) -> List[float]:
|
||||
"""从查询文本生成向量"""
|
||||
try:
|
||||
import numpy as np
|
||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||
|
||||
vectorizer = TfidfVectorizer(max_features=768)
|
||||
query_vector = vectorizer.fit_transform([query]).toarray()[0]
|
||||
return query_vector.tolist()
|
||||
except Exception:
|
||||
return [0.0] * 768
|
||||
|
|
@ -7,10 +7,12 @@ from app.models.role import Role, UserRole
|
|||
from app.models.menu import SystemMenu, RoleMenu
|
||||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||
from app.models.document import DocumentMeta
|
||||
from app.models.document_vector import DocumentVector
|
||||
from app.models.share import ShareLink
|
||||
from app.models.log import OperationLog
|
||||
from app.models.mcp_bot import MCPBot
|
||||
from app.models.llm_model_config import LLMModelConfig
|
||||
from app.models.chat_session import ChatSession, ChatMessage
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
|
|
@ -23,8 +25,11 @@ __all__ = [
|
|||
"ProjectMember",
|
||||
"ProjectMemberRole",
|
||||
"DocumentMeta",
|
||||
"DocumentVector",
|
||||
"ShareLink",
|
||||
"OperationLog",
|
||||
"MCPBot",
|
||||
"LLMModelConfig",
|
||||
"ChatSession",
|
||||
"ChatMessage",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
"""
|
||||
知识库对话会话模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, Integer, DateTime, Text, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
"""对话会话表"""
|
||||
|
||||
__tablename__ = "chat_session"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="会话ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
user_id = Column(BigInteger, nullable=False, index=True, comment="用户ID")
|
||||
llm_config_id = Column(BigInteger, nullable=False, comment="LLM配置ID")
|
||||
title = Column(String(255), nullable=False, comment="会话标题")
|
||||
description = Column(Text, comment="会话描述")
|
||||
is_active = Column(Boolean, nullable=False, default=True, comment="是否激活")
|
||||
message_count = Column(Integer, nullable=False, default=0, comment="消息数")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatSession(id={self.id}, project_id={self.project_id}, user_id={self.user_id})>"
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
"""对话消息表"""
|
||||
|
||||
__tablename__ = "chat_message"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="消息ID")
|
||||
session_id = Column(BigInteger, nullable=False, index=True, comment="会话ID")
|
||||
role = Column(String(32), nullable=False, comment="角色(user/assistant)")
|
||||
content = Column(Text, nullable=False, comment="消息内容")
|
||||
referenced_files = Column(Text, comment="参考文件(JSON数组)")
|
||||
tokens_used = Column(Integer, comment="消耗的token数")
|
||||
is_deleted = Column(Boolean, nullable=False, default=False, comment="是否已删除")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatMessage(id={self.id}, session_id={self.session_id}, role='{self.role}')>"
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"""
|
||||
文档向量化模型
|
||||
"""
|
||||
from sqlalchemy import Column, BigInteger, String, DateTime, Index, Text
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class DocumentVector(Base):
|
||||
"""文档向量表模型"""
|
||||
|
||||
__tablename__ = "document_vector"
|
||||
|
||||
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="向量ID")
|
||||
project_id = Column(BigInteger, nullable=False, index=True, comment="项目ID")
|
||||
file_path = Column(String(500), nullable=False, comment="文件相对路径")
|
||||
content_hash = Column(String(64), comment="内容哈希值,用于判断文件是否变更")
|
||||
zvec_id = Column(String(256), comment="ZVec返回的向量ID")
|
||||
zvec_response = Column(Text, comment="ZVec完整响应JSON")
|
||||
status = Column(String(32), nullable=False, default="success", comment="向量化状态:success/failed/pending")
|
||||
error_message = Column(String(500), comment="错误信息")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_project_file", "project_id", "file_path"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DocumentVector(id={self.id}, project_id={self.project_id}, file_path='{self.file_path}', status='{self.status}')>"
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -14,6 +15,7 @@ from app.services.log_service import log_service
|
|||
from app.services.notification_service import notification_service
|
||||
from app.services.search_service import search_service
|
||||
from app.services.storage import storage_service
|
||||
from app.services.zvec_service import zvec_service
|
||||
|
||||
|
||||
class ProjectFileService:
|
||||
|
|
@ -99,6 +101,10 @@ class ProjectFileService:
|
|||
link=f"/projects/{project_id}/docs?file={path}",
|
||||
category="project",
|
||||
)
|
||||
|
||||
if path.endswith(".md"):
|
||||
asyncio.create_task(zvec_service.vectorize_markdown(db, project_id, path, content))
|
||||
|
||||
await db.commit()
|
||||
return "文件保存成功" if source == "http" else "文件更新成功"
|
||||
|
||||
|
|
@ -122,6 +128,9 @@ class ProjectFileService:
|
|||
await storage_service.delete_file(current_path)
|
||||
await self._remove_markdown_index(project_id, path)
|
||||
|
||||
if path.endswith(".md"):
|
||||
await zvec_service.delete_vectors(db, project_id, path)
|
||||
|
||||
await log_service.log_file_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.DELETE_FILE,
|
||||
|
|
@ -184,6 +193,10 @@ class ProjectFileService:
|
|||
),
|
||||
category="project",
|
||||
)
|
||||
|
||||
if path.endswith(".md") and new_path.endswith(".md"):
|
||||
asyncio.create_task(zvec_service.sync_vector_move(db, project_id, path, new_path))
|
||||
|
||||
await db.commit()
|
||||
return success_message if source == "http" else mcp_message
|
||||
|
||||
|
|
@ -244,6 +257,10 @@ class ProjectFileService:
|
|||
link=f"/projects/{project_id}/docs?file={path}",
|
||||
category="project",
|
||||
)
|
||||
|
||||
if path.endswith(".md"):
|
||||
asyncio.create_task(zvec_service.vectorize_markdown(db, project_id, path, file_content))
|
||||
|
||||
await db.commit()
|
||||
return "文件创建成功"
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
"""
|
||||
知识库 RAG 服务 - 向量检索 + 大模型生成
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
|
||||
from app.models.document_vector import DocumentVector
|
||||
from app.models.llm_model_config import LLMModelConfig
|
||||
from app.services.llm_provider_service import LLMProviderService
|
||||
|
||||
|
||||
class RAGService:
|
||||
"""RAG 知识库检索和生成服务"""
|
||||
|
||||
@staticmethod
|
||||
async def retrieve_documents(
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
query_vector: List[float],
|
||||
top_k: int = 5,
|
||||
similarity_threshold: float = 0.3,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""向量检索相关文档"""
|
||||
if not query_vector or len(query_vector) == 0:
|
||||
return []
|
||||
|
||||
stmt = select(DocumentVector).where(
|
||||
DocumentVector.project_id == project_id,
|
||||
DocumentVector.vector.isnot(None),
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
all_vectors = result.scalars().all()
|
||||
|
||||
if not all_vectors:
|
||||
return []
|
||||
|
||||
scored_docs = []
|
||||
for doc in all_vectors:
|
||||
try:
|
||||
doc_vector = json.loads(doc.vector) if isinstance(doc.vector, str) else doc.vector
|
||||
similarity = RAGService._cosine_similarity(query_vector, doc_vector)
|
||||
if similarity >= similarity_threshold:
|
||||
scored_docs.append({
|
||||
"doc": doc,
|
||||
"similarity": similarity,
|
||||
"file_path": doc.file_path,
|
||||
"content_preview": doc.content[:500] if doc.content else "",
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
scored_docs.sort(key=lambda x: x["similarity"], reverse=True)
|
||||
return scored_docs[:top_k]
|
||||
|
||||
@staticmethod
|
||||
def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
|
||||
"""计算余弦相似度"""
|
||||
if len(vec1) != len(vec2) or len(vec1) == 0:
|
||||
return 0.0
|
||||
|
||||
dot_product = sum(a * b for a, b in zip(vec1, vec2))
|
||||
magnitude1 = sum(a * a for a in vec1) ** 0.5
|
||||
magnitude2 = sum(b * b for b in vec2) ** 0.5
|
||||
|
||||
if magnitude1 == 0 or magnitude2 == 0:
|
||||
return 0.0
|
||||
|
||||
return dot_product / (magnitude1 * magnitude2)
|
||||
|
||||
@staticmethod
|
||||
async def generate_response(
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
query_vector: List[float],
|
||||
project_id: int,
|
||||
llm_config_id: int,
|
||||
retrieved_docs: List[Dict[str, Any]],
|
||||
conversation_history: List[Dict[str, str]],
|
||||
) -> str:
|
||||
"""基于检索文档生成对话回复"""
|
||||
stmt = select(LLMModelConfig).where(LLMModelConfig.config_id == llm_config_id)
|
||||
result = await db.execute(stmt)
|
||||
llm_config = result.scalar_one_or_none()
|
||||
|
||||
if not llm_config:
|
||||
raise ValueError(f"LLM配置不存在: {llm_config_id}")
|
||||
|
||||
context_text = RAGService._build_context(retrieved_docs)
|
||||
system_prompt = f"""你是一个知识库助手。基于用户提供的知识库文档,回答用户的问题。
|
||||
|
||||
如果知识库中没有相关信息,请明确说明。
|
||||
|
||||
知识库文档内容:
|
||||
{context_text}
|
||||
|
||||
请基于以上知识库内容,用中文回答用户的问题。"""
|
||||
|
||||
messages = list(conversation_history)
|
||||
messages.append({"role": "user", "content": query})
|
||||
|
||||
response_text = await LLMProviderService.generate_text(
|
||||
provider=llm_config.provider,
|
||||
endpoint_url=llm_config.endpoint_url,
|
||||
api_key=llm_config.api_key,
|
||||
llm_model_name=llm_config.llm_model_name,
|
||||
timeout=llm_config.llm_timeout,
|
||||
temperature=float(llm_config.llm_temperature),
|
||||
top_p=float(llm_config.llm_top_p),
|
||||
max_tokens=llm_config.llm_max_tokens,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
return response_text
|
||||
|
||||
@staticmethod
|
||||
def _build_context(retrieved_docs: List[Dict[str, Any]]) -> str:
|
||||
"""构建上下文文本"""
|
||||
if not retrieved_docs:
|
||||
return "(未找到相关知识库内容)"
|
||||
|
||||
context_parts = []
|
||||
for i, doc_info in enumerate(retrieved_docs, 1):
|
||||
context_parts.append(
|
||||
f"文档 {i}: {doc_info['file_path']} (相似度: {doc_info['similarity']:.2%})\n"
|
||||
f"{doc_info['content_preview']}..."
|
||||
)
|
||||
|
||||
return "\n\n".join(context_parts)
|
||||
|
||||
|
||||
rag_service = RAGService()
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
"""
|
||||
ZVec 向量化服务 - 调用阿里云ZVec API进行文档向量化
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import httpx
|
||||
from typing import Dict, List, Optional, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.models.document_vector import DocumentVector
|
||||
from app.core.database import get_db
|
||||
|
||||
|
||||
class ZVecService:
|
||||
"""ZVec 向量化服务"""
|
||||
|
||||
ZVEC_API_ENDPOINT = os.getenv("ZVEC_API_ENDPOINT", "https://api.aliyun.com/zvec")
|
||||
ZVEC_API_KEY = os.getenv("ZVEC_API_KEY", "")
|
||||
ZVEC_MODEL = os.getenv("ZVEC_MODEL", "text-embedding-v3")
|
||||
|
||||
@classmethod
|
||||
async def is_configured(cls) -> bool:
|
||||
"""检查ZVec是否已配置"""
|
||||
return bool(cls.ZVEC_API_KEY and cls.ZVEC_API_ENDPOINT)
|
||||
|
||||
@classmethod
|
||||
async def vectorize_text(cls, text: str, doc_id: Optional[int] = None) -> Optional[List[float]]:
|
||||
"""
|
||||
调用ZVec API对文本进行向量化
|
||||
|
||||
Args:
|
||||
text: 要向量化的文本内容
|
||||
doc_id: 文档ID(用于日志追踪)
|
||||
|
||||
Returns:
|
||||
向量列表,或如果API调用失败则返回None
|
||||
"""
|
||||
if not await cls.is_configured():
|
||||
return None
|
||||
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
|
||||
try:
|
||||
# 截断超长文本(ZVec有输入限制)
|
||||
text = text[:8000]
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {cls.ZVEC_API_KEY}",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": cls.ZVEC_MODEL,
|
||||
"input": text,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{cls.ZVEC_API_ENDPOINT}/embeddings",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
embeddings = data.get("data", [])
|
||||
if embeddings:
|
||||
return embeddings[0].get("embedding", [])
|
||||
|
||||
return None
|
||||
|
||||
except Exception as exc:
|
||||
print(f"ZVec vectorization failed for doc_id={doc_id}: {exc}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def store_vector(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
file_path: str,
|
||||
content: str,
|
||||
doc_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
向量化文本并存储到数据库
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
project_id: 项目ID
|
||||
file_path: 文件路径
|
||||
content: 文件内容
|
||||
doc_id: 文档元数据ID
|
||||
|
||||
Returns:
|
||||
是否成功存储
|
||||
"""
|
||||
# 调用ZVec获取向量
|
||||
embedding = await cls.vectorize_text(content, doc_id)
|
||||
if not embedding:
|
||||
return False
|
||||
|
||||
try:
|
||||
# 先删除旧的向量记录
|
||||
await db.execute(
|
||||
delete(DocumentVector).where(
|
||||
(DocumentVector.project_id == project_id)
|
||||
& (DocumentVector.file_path == file_path)
|
||||
)
|
||||
)
|
||||
|
||||
# 创建新的向量记录
|
||||
vector_record = DocumentVector(
|
||||
project_id=project_id,
|
||||
file_path=file_path,
|
||||
doc_id=doc_id,
|
||||
embedding=json.dumps(embedding),
|
||||
embedding_model=cls.ZVEC_MODEL,
|
||||
chunk_count=1, # 单个文件作为一个chunk
|
||||
)
|
||||
|
||||
db.add(vector_record)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
print(f"Failed to store vector for {file_path}: {exc}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def delete_vector(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
file_path: str,
|
||||
) -> bool:
|
||||
"""
|
||||
删除文档的向量记录
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
project_id: 项目ID
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
try:
|
||||
await db.execute(
|
||||
delete(DocumentVector).where(
|
||||
(DocumentVector.project_id == project_id)
|
||||
& (DocumentVector.file_path == file_path)
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"Failed to delete vector for {file_path}: {exc}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def search_similar(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
project_id: int,
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
基于查询文本检索相似文档
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
project_id: 项目ID
|
||||
query: 查询文本
|
||||
top_k: 返回最相似的文档数量
|
||||
|
||||
Returns:
|
||||
相似文档列表,按相似度排序
|
||||
"""
|
||||
# 获取查询文本的向量
|
||||
query_embedding = await cls.vectorize_text(query)
|
||||
if not query_embedding:
|
||||
return []
|
||||
|
||||
try:
|
||||
# 获取项目中所有文档的向量
|
||||
result = await db.execute(
|
||||
select(DocumentVector).where(
|
||||
DocumentVector.project_id == project_id
|
||||
)
|
||||
)
|
||||
vectors = result.scalars().all()
|
||||
|
||||
if not vectors:
|
||||
return []
|
||||
|
||||
# 计算相似度(余弦相似度)
|
||||
similarities = []
|
||||
for vector_record in vectors:
|
||||
try:
|
||||
doc_embedding = json.loads(vector_record.embedding)
|
||||
similarity = cls._cosine_similarity(query_embedding, doc_embedding)
|
||||
similarities.append({
|
||||
"file_path": vector_record.file_path,
|
||||
"doc_id": vector_record.doc_id,
|
||||
"similarity": similarity,
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 按相似度排序并返回top-k
|
||||
similarities.sort(key=lambda x: x["similarity"], reverse=True)
|
||||
return similarities[:top_k]
|
||||
|
||||
except Exception as exc:
|
||||
print(f"Failed to search similar documents: {exc}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
|
||||
"""计算两个向量的余弦相似度"""
|
||||
if not vec1 or not vec2 or len(vec1) != len(vec2):
|
||||
return 0.0
|
||||
|
||||
dot_product = sum(a * b for a, b in zip(vec1, vec2))
|
||||
magnitude1 = sum(a * a for a in vec1) ** 0.5
|
||||
magnitude2 = sum(b * b for b in vec2) ** 0.5
|
||||
|
||||
if magnitude1 == 0 or magnitude2 == 0:
|
||||
return 0.0
|
||||
|
||||
return dot_product / (magnitude1 * magnitude2)
|
||||
|
||||
|
||||
zvec_service = ZVecService()
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
-- 创建知识库对话会话表
|
||||
CREATE TABLE IF NOT EXISTS chat_session (
|
||||
session_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '会话ID',
|
||||
project_id BIGINT NOT NULL COMMENT '项目ID',
|
||||
user_id BIGINT NOT NULL COMMENT '创建用户ID',
|
||||
model_config_id BIGINT NOT NULL COMMENT '使用的LLM模型配置ID',
|
||||
title VARCHAR(255) NOT NULL COMMENT '会话标题',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_project_user (project_id, user_id),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话会话表';
|
||||
|
||||
-- 创建知识库对话消息表
|
||||
CREATE TABLE IF NOT EXISTS chat_message (
|
||||
message_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '消息ID',
|
||||
session_id BIGINT NOT NULL COMMENT '所属会话ID',
|
||||
role VARCHAR(20) NOT NULL COMMENT '角色(user/assistant)',
|
||||
content LONGTEXT NOT NULL COMMENT '消息内容',
|
||||
retrieved_docs JSON COMMENT '检索到的相关文档(JSON数组)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
FOREIGN KEY (session_id) REFERENCES chat_session(session_id) ON DELETE CASCADE,
|
||||
INDEX idx_session_id (session_id),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话消息表';
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE IF NOT EXISTS document_vector (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '向量ID',
|
||||
project_id BIGINT NOT NULL COMMENT '项目ID',
|
||||
file_path VARCHAR(500) NOT NULL COMMENT '文件相对路径',
|
||||
content_hash VARCHAR(64) COMMENT '内容哈希',
|
||||
vector JSON COMMENT '向量数据(JSON数组)',
|
||||
metadata JSON COMMENT '向量元数据',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_project_file (project_id, file_path),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档向量表';
|
||||
|
|
@ -20,6 +20,7 @@ import Users from '@/pages/System/Users'
|
|||
import Roles from '@/pages/System/Roles'
|
||||
import ModelConfigs from '@/pages/System/ModelConfigs'
|
||||
import SystemLogs from '@/pages/SystemLogs/SystemLogs'
|
||||
import Chat from '@/pages/Chat/Chat'
|
||||
import NotificationList from '@/pages/Notifications/NotificationList'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
||||
|
|
@ -84,6 +85,7 @@ function App() {
|
|||
<Route path="/system/roles" element={<Roles />} />
|
||||
<Route path="/system/model-configs" element={<ModelConfigs />} />
|
||||
<Route path="/system/logs" element={<SystemLogs />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import request from '@/utils/request'
|
||||
|
||||
export const createChatSession = (projectId, llmConfigId) => {
|
||||
return request.post('/chat/sessions', {
|
||||
project_id: projectId,
|
||||
llm_config_id: llmConfigId,
|
||||
})
|
||||
}
|
||||
|
||||
export const getChatSessions = (projectId) => {
|
||||
return request.get(`/chat/sessions?project_id=${projectId}`)
|
||||
}
|
||||
|
||||
export const getChatMessages = (sessionId, limit = 50, offset = 0) => {
|
||||
return request.get(`/chat/messages?session_id=${sessionId}&limit=${limit}&offset=${offset}`)
|
||||
}
|
||||
|
||||
export const sendChatMessage = (sessionId, userMessage) => {
|
||||
return request.post('/chat/messages', {
|
||||
session_id: sessionId,
|
||||
user_message: userMessage,
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteChatSession = (sessionId) => {
|
||||
return request.delete(`/chat/sessions/${sessionId}`)
|
||||
}
|
||||
|
||||
export const updateChatSessionTitle = (sessionId, title) => {
|
||||
return request.put(`/chat/sessions/${sessionId}`, {
|
||||
title,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/* Chat 页面样式 */
|
||||
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
width: 280px;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
overflow-y: auto;
|
||||
background: #fafafa;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-bubble {
|
||||
max-width: 70%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-message-bubble.user {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-message-bubble.assistant {
|
||||
background: #f0f0f0;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-session-item {
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #d9d9d9;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.chat-session-item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.chat-session-item.active {
|
||||
background: #e6f7ff;
|
||||
border: 1px solid #1890ff;
|
||||
}
|
||||
|
||||
.chat-session-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-session-date {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
|
@ -0,0 +1,386 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Layout, Select, Button, Input, Empty, Card, Space, Spin, Message, Divider, Tag, Modal, Form } from 'antd'
|
||||
import { SendOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { getMyProjects } from '@/api/project'
|
||||
import { getLLMModelConfigs } from '@/api/llmModelConfigs'
|
||||
import {
|
||||
createChatSession,
|
||||
getChatSessions,
|
||||
getChatMessages,
|
||||
sendChatMessage,
|
||||
deleteChatSession,
|
||||
updateChatSessionTitle,
|
||||
} from '@/api/chat'
|
||||
import './Chat.css'
|
||||
|
||||
const { Sider, Content } = Layout
|
||||
|
||||
function Chat() {
|
||||
const [projects, setProjects] = useState([])
|
||||
const [llmConfigs, setLlmConfigs] = useState([])
|
||||
const [selectedProject, setSelectedProject] = useState(null)
|
||||
const [selectedModel, setSelectedModel] = useState(null)
|
||||
const [sessions, setSessions] = useState([])
|
||||
const [currentSession, setCurrentSession] = useState(null)
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sessionLoading, setSessionLoading] = useState(false)
|
||||
const [messageLoading, setMessageLoading] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const messagesEndRef = useRef(null)
|
||||
const [editingSessionId, setEditingSessionId] = useState(null)
|
||||
const [editingTitle, setEditingTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects()
|
||||
fetchLlmConfigs()
|
||||
}, [])
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await getMyProjects()
|
||||
setProjects(res.data?.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLlmConfigs = async () => {
|
||||
try {
|
||||
const res = await getLLMModelConfigs()
|
||||
setLlmConfigs(res.data?.data || [])
|
||||
if (res.data?.data?.length > 0) {
|
||||
setSelectedModel(res.data.data[0].config_id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch LLM configs:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSessions = async (projectId) => {
|
||||
if (!projectId) return
|
||||
setSessionLoading(true)
|
||||
try {
|
||||
const res = await getChatSessions(projectId)
|
||||
setSessions(res.data?.data || [])
|
||||
if (res.data?.data?.length > 0) {
|
||||
setCurrentSession(res.data.data[0])
|
||||
fetchMessages(res.data.data[0].session_id)
|
||||
} else {
|
||||
setMessages([])
|
||||
setCurrentSession(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sessions:', error)
|
||||
} finally {
|
||||
setSessionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessages = async (sessionId) => {
|
||||
setMessageLoading(true)
|
||||
try {
|
||||
const res = await getChatMessages(sessionId)
|
||||
setMessages(res.data?.data || [])
|
||||
setTimeout(scrollToBottom, 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch messages:', error)
|
||||
} finally {
|
||||
setMessageLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const handleProjectChange = (projectId) => {
|
||||
setSelectedProject(projectId)
|
||||
setCurrentSession(null)
|
||||
setMessages([])
|
||||
fetchSessions(projectId)
|
||||
}
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
if (!selectedProject || !selectedModel) {
|
||||
Message.warning('请先选择项目和模型')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await createChatSession(selectedProject, selectedModel)
|
||||
const newSession = res.data?.data
|
||||
setSessions([newSession, ...sessions])
|
||||
setCurrentSession(newSession)
|
||||
setMessages([])
|
||||
} catch (error) {
|
||||
Message.error('创建会话失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim() || !currentSession) {
|
||||
return
|
||||
}
|
||||
const userMsg = inputValue
|
||||
setInputValue('')
|
||||
setMessageLoading(true)
|
||||
|
||||
try {
|
||||
const res = await sendChatMessage(currentSession.session_id, userMsg)
|
||||
const { user_message, assistant_message } = res.data?.data || {}
|
||||
|
||||
const newMessages = [
|
||||
...messages,
|
||||
{
|
||||
id: messages.length + 1,
|
||||
role: 'user',
|
||||
content: user_message,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: messages.length + 2,
|
||||
role: 'assistant',
|
||||
content: assistant_message,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
setMessages(newMessages)
|
||||
setTimeout(scrollToBottom, 100)
|
||||
} catch (error) {
|
||||
Message.error('发送消息失败')
|
||||
setInputValue(userMsg)
|
||||
console.error(error)
|
||||
} finally {
|
||||
setMessageLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSession = (sessionId) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个对话会话吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteChatSession(sessionId)
|
||||
setSessions(sessions.filter(s => s.session_id !== sessionId))
|
||||
if (currentSession?.session_id === sessionId) {
|
||||
setCurrentSession(null)
|
||||
setMessages([])
|
||||
}
|
||||
Message.success('会话已删除')
|
||||
} catch (error) {
|
||||
Message.error('删除失败')
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditTitle = (session) => {
|
||||
setEditingSessionId(session.session_id)
|
||||
setEditingTitle(session.title || `对话 ${session.session_id}`)
|
||||
}
|
||||
|
||||
const handleSaveTitle = async () => {
|
||||
if (!editingSessionId || !editingTitle.trim()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateChatSessionTitle(editingSessionId, editingTitle)
|
||||
setSessions(sessions.map(s =>
|
||||
s.session_id === editingSessionId
|
||||
? { ...s, title: editingTitle }
|
||||
: s
|
||||
))
|
||||
setEditingSessionId(null)
|
||||
Message.success('标题已更新')
|
||||
} catch (error) {
|
||||
Message.error('更新失败')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const currentModel = llmConfigs.find(m => m.config_id === selectedModel)
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh' }}>
|
||||
<Sider width={280} style={{ background: '#fafafa', borderRight: '1px solid #e8e8e8', overflow: 'auto' }}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '8px' }}>项目</div>
|
||||
<Select
|
||||
placeholder="选择项目"
|
||||
value={selectedProject}
|
||||
onChange={handleProjectChange}
|
||||
style={{ width: '100%' }}
|
||||
options={projects.map(p => ({ label: p.name, value: p.id }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '8px' }}>模型</div>
|
||||
<Select
|
||||
placeholder="选择模型"
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
style={{ width: '100%' }}
|
||||
options={llmConfigs.map(m => ({
|
||||
label: m.model_name,
|
||||
value: m.config_id
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateSession}
|
||||
loading={loading}
|
||||
>
|
||||
新建对话
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '0' }} />
|
||||
|
||||
<div style={{ padding: '8px', maxHeight: 'calc(100vh - 200px)', overflow: 'auto' }}>
|
||||
{sessionLoading ? (
|
||||
<Spin />
|
||||
) : sessions.length === 0 ? (
|
||||
<Empty description="暂无对话" style={{ marginTop: '32px' }} />
|
||||
) : (
|
||||
sessions.map(session => (
|
||||
<Card
|
||||
key={session.session_id}
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => {
|
||||
setCurrentSession(session)
|
||||
fetchMessages(session.session_id)
|
||||
}}
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
cursor: 'pointer',
|
||||
background: currentSession?.session_id === session.session_id ? '#e6f7ff' : '#fff',
|
||||
border: currentSession?.session_id === session.session_id ? '1px solid #1890ff' : '1px solid #d9d9d9',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{editingSessionId === session.session_id ? (
|
||||
<Input
|
||||
value={editingTitle}
|
||||
onChange={(e) => setEditingTitle(e.target.value)}
|
||||
size="small"
|
||||
onBlur={handleSaveTitle}
|
||||
onPressEnter={handleSaveTitle}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontSize: '13px', fontWeight: '500', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{session.title || `对话 ${session.session_id}`}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
|
||||
{new Date(session.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<Space size="small">
|
||||
<EditOutlined
|
||||
style={{ cursor: 'pointer', color: '#1890ff' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditTitle(session)
|
||||
}}
|
||||
/>
|
||||
<DeleteOutlined
|
||||
style={{ cursor: 'pointer', color: '#ff4d4f' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteSession(session.session_id)
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
<Content style={{ display: 'flex', flexDirection: 'column', background: '#fff' }}>
|
||||
{!currentSession ? (
|
||||
<Empty
|
||||
description="选择或创建一个对话会话"
|
||||
style={{ marginTop: '100px' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #e8e8e8', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600' }}>
|
||||
{currentSession.title || `对话 ${currentSession.session_id}`}
|
||||
</div>
|
||||
{currentModel && (
|
||||
<Tag style={{ marginTop: '4px' }} color="blue">{currentModel.model_name}</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px', display: 'flex', flexDirection: 'column' }}>
|
||||
{messageLoading ? (
|
||||
<Spin />
|
||||
) : messages.length === 0 ? (
|
||||
<Empty description="暂无消息" />
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div key={msg.id} style={{ marginBottom: '12px', display: 'flex', justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start' }}>
|
||||
<div style={{
|
||||
maxWidth: '70%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
background: msg.role === 'user' ? '#1890ff' : '#f0f0f0',
|
||||
color: msg.role === 'user' ? '#fff' : '#000',
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px', borderTop: '1px solid #e8e8e8', display: 'flex', gap: '8px' }}>
|
||||
<Input
|
||||
placeholder="输入消息..."
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onPressEnter={handleSendMessage}
|
||||
disabled={messageLoading}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSendMessage}
|
||||
loading={messageLoading}
|
||||
disabled={!inputValue.trim()}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Chat
|
||||
Loading…
Reference in New Issue