加入会话管理和助手管理
This commit is contained in:
parent
194282e029
commit
7df10e82be
@ -1,15 +1,12 @@
|
|||||||
# File: backend/app/api/v1/api.py
|
# File: backend/app/api/v1/api.py (更新)
|
||||||
# Description: 聚合 v1 版本的所有 API 路由
|
# Description: 聚合 v1 版本的所有 API 路由
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1.endpoints import chat # 导入聊天路由
|
from app.api.v1.endpoints import chat, assistants, sessions # 导入新路由
|
||||||
|
|
||||||
# 创建 v1 版本的总路由
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
# 将聊天路由包含到 v1 总路由中,并添加前缀
|
api_router.include_router(chat.router, prefix="/chat", tags=["Chat"])
|
||||||
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
api_router.include_router(assistants.router, prefix="/assistants", tags=["Assistants"]) # 添加助手路由
|
||||||
|
api_router.include_router(sessions.router, prefix="/sessions", tags=["Sessions"]) # 添加会话路由
|
||||||
|
|
||||||
# --- 如果有其他路由,也在这里 include ---
|
|
||||||
# from app.api.v1.endpoints import workflow
|
|
||||||
# api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])
|
|
||||||
|
|||||||
66
backend/app/api/v1/endpoints/assistants.py
Normal file
66
backend/app/api/v1/endpoints/assistants.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# File: backend/app/api/v1/endpoints/assistants.py (新建)
|
||||||
|
# Description: 助手的 API 路由
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, status
|
||||||
|
from typing import List
|
||||||
|
from app.models.pydantic_models import AssistantRead, AssistantCreate, AssistantUpdate
|
||||||
|
from app.services.assistant_service import assistant_service_instance, AssistantService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# --- 依赖注入 AssistantService ---
|
||||||
|
def get_assistant_service() -> AssistantService:
|
||||||
|
return assistant_service_instance
|
||||||
|
|
||||||
|
@router.post("/", response_model=AssistantRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_new_assistant(
|
||||||
|
assistant_data: AssistantCreate,
|
||||||
|
service: AssistantService = Depends(get_assistant_service)
|
||||||
|
):
|
||||||
|
"""创建新助手"""
|
||||||
|
return service.create_assistant(assistant_data)
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[AssistantRead])
|
||||||
|
async def read_all_assistants(
|
||||||
|
service: AssistantService = Depends(get_assistant_service)
|
||||||
|
):
|
||||||
|
"""获取所有助手列表"""
|
||||||
|
return service.get_assistants()
|
||||||
|
|
||||||
|
@router.get("/{assistant_id}", response_model=AssistantRead)
|
||||||
|
async def read_assistant_by_id(
|
||||||
|
assistant_id: str,
|
||||||
|
service: AssistantService = Depends(get_assistant_service)
|
||||||
|
):
|
||||||
|
"""根据 ID 获取特定助手"""
|
||||||
|
assistant = service.get_assistant(assistant_id)
|
||||||
|
if not assistant:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="找不到指定的助手")
|
||||||
|
return assistant
|
||||||
|
|
||||||
|
@router.put("/{assistant_id}", response_model=AssistantRead)
|
||||||
|
async def update_existing_assistant(
|
||||||
|
assistant_id: str,
|
||||||
|
assistant_data: AssistantUpdate,
|
||||||
|
service: AssistantService = Depends(get_assistant_service)
|
||||||
|
):
|
||||||
|
"""更新指定 ID 的助手"""
|
||||||
|
updated_assistant = service.update_assistant(assistant_id, assistant_data)
|
||||||
|
if not updated_assistant:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="找不到指定的助手")
|
||||||
|
return updated_assistant
|
||||||
|
|
||||||
|
@router.delete("/{assistant_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_existing_assistant(
|
||||||
|
assistant_id: str,
|
||||||
|
service: AssistantService = Depends(get_assistant_service)
|
||||||
|
):
|
||||||
|
"""删除指定 ID 的助手"""
|
||||||
|
deleted = service.delete_assistant(assistant_id)
|
||||||
|
if not deleted:
|
||||||
|
# 根据服务层逻辑判断是找不到还是不允许删除
|
||||||
|
assistant = service.get_assistant(assistant_id)
|
||||||
|
if assistant and assistant_id == 'asst-default':
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不允许删除默认助手")
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="找不到指定的助手")
|
||||||
|
# 成功删除,不返回内容
|
||||||
@ -1,41 +1,68 @@
|
|||||||
# File: backend/app/api/v1/endpoints/chat.py (更新)
|
# File: backend/app/api/v1/endpoints/chat.py (更新)
|
||||||
# Description: 聊天功能的 API 路由 (使用 ChatService)
|
# Description: 聊天功能的 API 路由 (使用更新后的 ChatService)
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends, status
|
||||||
from app.models.pydantic_models import ChatRequest, ChatResponse
|
from app.models.pydantic_models import ChatRequest, ChatResponse, SessionCreateRequest
|
||||||
# 导入 ChatService 实例
|
|
||||||
from app.services.chat_service import chat_service_instance, ChatService
|
from app.services.chat_service import chat_service_instance, ChatService
|
||||||
|
from app.services.session_service import session_service_instance, SessionService # 导入 SessionService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# --- (可选) 使用 FastAPI 的依赖注入来获取 ChatService 实例 ---
|
# --- 依赖注入 ---
|
||||||
# 这样更符合 FastAPI 的风格,方便测试和替换实现
|
def get_chat_service() -> ChatService:
|
||||||
# async def get_chat_service() -> ChatService:
|
return chat_service_instance
|
||||||
# return chat_service_instance
|
|
||||||
|
def get_session_service() -> SessionService:
|
||||||
|
return session_service_instance
|
||||||
|
|
||||||
@router.post("/", response_model=ChatResponse)
|
@router.post("/", response_model=ChatResponse)
|
||||||
async def handle_chat_message(
|
async def handle_chat_message(
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
# chat_service: ChatService = Depends(get_chat_service) # 使用依赖注入
|
chat_service: ChatService = Depends(get_chat_service),
|
||||||
|
session_service: SessionService = Depends(get_session_service) # 注入 SessionService
|
||||||
):
|
):
|
||||||
"""
|
"""处理用户发送的聊天消息 (包含 assistantId 和 sessionId)"""
|
||||||
处理用户发送的聊天消息,并使用 LangChain 获取 AI 回复
|
user_message = request.message
|
||||||
"""
|
session_id = request.session_id
|
||||||
user_message = request.message
|
assistant_id = request.assistant_id
|
||||||
# session_id = request.session_id # 如果 ChatRequest 中包含 session_id
|
|
||||||
print(f"接收到用户消息: {user_message}")
|
|
||||||
|
|
||||||
try:
|
print(f"接收到消息: User='{user_message}', Session='{session_id}', Assistant='{assistant_id}'")
|
||||||
# --- 调用 ChatService 获取 AI 回复 ---
|
|
||||||
# 使用全局实例 (简单方式)
|
|
||||||
ai_reply = await chat_service_instance.get_ai_reply(user_message)
|
|
||||||
# 或者使用依赖注入获取的实例
|
|
||||||
# ai_reply = await chat_service.get_ai_reply(user_message, session_id)
|
|
||||||
|
|
||||||
print(f"发送 AI 回复: {ai_reply}")
|
response_session_id = None
|
||||||
return ChatResponse(reply=ai_reply)
|
response_session_title = None
|
||||||
|
|
||||||
except Exception as e:
|
# --- 处理临时新会话 ---
|
||||||
# 如果 ChatService 抛出异常,捕获并返回 HTTP 500 错误
|
if session_id == 'temp-new-chat':
|
||||||
print(f"处理聊天消息时发生错误: {e}")
|
print("检测到临时新会话,正在创建...")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
try:
|
||||||
|
# 调用 SessionService 创建会话
|
||||||
|
create_req = SessionCreateRequest(assistant_id=assistant_id, first_message=user_message)
|
||||||
|
created_session = await session_service.create_session(create_req)
|
||||||
|
session_id = created_session.id # 使用新创建的会话 ID
|
||||||
|
response_session_id = created_session.id # 准备在响应中返回新 ID
|
||||||
|
response_session_title = created_session.title # 准备在响应中返回新标题
|
||||||
|
print(f"新会话已创建: ID={session_id}, Title='{created_session.title}'")
|
||||||
|
except ValueError as e: # 助手不存在等错误
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
except Exception as e: # LLM 调用或其他错误
|
||||||
|
print(f"创建会话时出错: {e}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建会话失败")
|
||||||
|
|
||||||
|
# --- 调用聊天服务获取回复 ---
|
||||||
|
try:
|
||||||
|
ai_reply = await chat_service.get_ai_reply(
|
||||||
|
user_message=user_message,
|
||||||
|
session_id=session_id, # 使用真实的或新创建的 session_id
|
||||||
|
assistant_id=assistant_id
|
||||||
|
)
|
||||||
|
print(f"发送 AI 回复: '{ai_reply}'")
|
||||||
|
return ChatResponse(
|
||||||
|
reply=ai_reply,
|
||||||
|
session_id=response_session_id, # 返回新 ID (如果创建了)
|
||||||
|
session_title=response_session_title # 返回新标题 (如果创建了)
|
||||||
|
)
|
||||||
|
except ValueError as e: # 助手不存在等错误
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
except Exception as e: # LLM 调用或其他错误
|
||||||
|
print(f"处理聊天消息时发生错误: {e}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|||||||
58
backend/app/api/v1/endpoints/sessions.py
Normal file
58
backend/app/api/v1/endpoints/sessions.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# File: backend/app/api/v1/endpoints/sessions.py (新建)
|
||||||
|
# Description: 会话管理的 API 路由
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, status
|
||||||
|
from typing import List
|
||||||
|
from app.models.pydantic_models import SessionRead, SessionCreateRequest, SessionCreateResponse
|
||||||
|
from app.services.session_service import session_service_instance, SessionService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_session_service() -> SessionService:
|
||||||
|
return session_service_instance
|
||||||
|
|
||||||
|
@router.post("/", response_model=SessionCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_new_session(
|
||||||
|
session_data: SessionCreateRequest,
|
||||||
|
service: SessionService = Depends(get_session_service)
|
||||||
|
):
|
||||||
|
"""创建新会话并自动生成标题"""
|
||||||
|
try:
|
||||||
|
return await service.create_session(session_data)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
# 处理可能的 LLM 调用错误
|
||||||
|
print(f"创建会话时出错: {e}")
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建会话失败")
|
||||||
|
|
||||||
|
@router.get("/assistant/{assistant_id}", response_model=List[SessionRead])
|
||||||
|
async def read_sessions_for_assistant(
|
||||||
|
assistant_id: str,
|
||||||
|
service: SessionService = Depends(get_session_service)
|
||||||
|
):
|
||||||
|
"""获取指定助手的所有会话列表"""
|
||||||
|
# TODO: 添加检查助手是否存在
|
||||||
|
return service.get_sessions_by_assistant(assistant_id)
|
||||||
|
|
||||||
|
@router.get("/{session_id}", response_model=SessionRead)
|
||||||
|
async def read_session_by_id(
|
||||||
|
session_id: str,
|
||||||
|
service: SessionService = Depends(get_session_service)
|
||||||
|
):
|
||||||
|
"""获取单个会话信息"""
|
||||||
|
session = service.get_session(session_id)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="找不到指定的会话")
|
||||||
|
return session
|
||||||
|
|
||||||
|
@router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_existing_session(
|
||||||
|
session_id: str,
|
||||||
|
service: SessionService = Depends(get_session_service)
|
||||||
|
):
|
||||||
|
"""删除指定 ID 的会话"""
|
||||||
|
deleted = service.delete_session(session_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="找不到指定的会话")
|
||||||
|
|
||||||
@ -1,16 +1,76 @@
|
|||||||
# File: backend/app/models/pydantic_models.py
|
# File: backend/app/models/pydantic_models.py (更新)
|
||||||
# Description: Pydantic 模型定义 API 数据结构
|
# Description: Pydantic 模型定义 API 数据结构
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
import uuid # 用于生成唯一 ID
|
||||||
|
|
||||||
|
# --- Assistant Models ---
|
||||||
|
|
||||||
|
class AssistantBase(BaseModel):
|
||||||
|
"""助手的基础模型,包含通用字段"""
|
||||||
|
name: str = Field(..., min_length=1, max_length=50, description="助手名称")
|
||||||
|
description: Optional[str] = Field(None, max_length=200, description="助手描述")
|
||||||
|
avatar: Optional[str] = Field(None, max_length=5, description="头像 Emoji 或字符")
|
||||||
|
system_prompt: str = Field(..., min_length=1, max_length=4000, description="系统提示")
|
||||||
|
model: str = Field(..., description="使用的 LLM 模型")
|
||||||
|
temperature: float = Field(0.7, ge=0.0, le=1.0, description="温度参数 (0.0-1.0)")
|
||||||
|
# 可以添加 top_p, max_tokens 等
|
||||||
|
|
||||||
|
class AssistantCreate(AssistantBase):
|
||||||
|
"""创建助手时使用的模型 (不需要 ID)"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class AssistantUpdate(BaseModel):
|
||||||
|
"""更新助手时使用的模型 (所有字段可选)"""
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||||
|
description: Optional[str] = Field(None, max_length=200)
|
||||||
|
avatar: Optional[str] = Field(None, max_length=5)
|
||||||
|
system_prompt: Optional[str] = Field(None, min_length=1, max_length=4000)
|
||||||
|
model: Optional[str] = None
|
||||||
|
temperature: Optional[float] = Field(None, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
class AssistantRead(AssistantBase):
|
||||||
|
"""读取助手信息时返回的模型 (包含 ID)"""
|
||||||
|
id: str = Field(..., description="助手唯一 ID")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True # Pydantic v2: orm_mode = True
|
||||||
|
|
||||||
|
# --- Chat Models (更新) ---
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
"""聊天请求模型"""
|
"""聊天请求模型 (添加 sessionId 和 assistantId)"""
|
||||||
message: str
|
message: str
|
||||||
# 可以添加更多字段,如 user_id, session_id 等
|
session_id: str = Field(..., description="当前会话 ID (可以是 'temp-new-chat')")
|
||||||
|
assistant_id: str = Field(..., description="当前使用的助手 ID")
|
||||||
|
|
||||||
class ChatResponse(BaseModel):
|
class ChatResponse(BaseModel):
|
||||||
"""聊天响应模型"""
|
"""聊天响应模型"""
|
||||||
reply: str
|
reply: str
|
||||||
# 可以添加更多字段,如 message_id, status 等
|
session_id: Optional[str] = None # (可选) 如果创建了新会话,返回新 ID
|
||||||
|
session_title: Optional[str] = None # (可选) 如果创建了新会话,返回新标题
|
||||||
|
|
||||||
# --- 你可以在这里添加其他功能的模型 ---
|
# --- Session Models ---
|
||||||
|
|
||||||
|
class SessionCreateRequest(BaseModel):
|
||||||
|
"""创建会话请求模型"""
|
||||||
|
assistant_id: str
|
||||||
|
first_message: str # 用户的第一条消息,用于生成标题
|
||||||
|
|
||||||
|
class SessionCreateResponse(BaseModel):
|
||||||
|
"""创建会话响应模型"""
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
assistant_id: str
|
||||||
|
created_at: str # 返回 ISO 格式时间字符串
|
||||||
|
|
||||||
|
class SessionRead(BaseModel):
|
||||||
|
"""读取会话信息模型"""
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
assistant_id: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
73
backend/app/services/assistant_service.py
Normal file
73
backend/app/services/assistant_service.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# File: backend/app/services/assistant_service.py (新建)
|
||||||
|
# Description: 管理助手数据的服务 (内存实现)
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from app.models.pydantic_models import AssistantRead, AssistantCreate, AssistantUpdate
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# 使用字典作为内存数据库存储助手
|
||||||
|
# key: assistant_id (str), value: AssistantRead object
|
||||||
|
assistants_db: Dict[str, AssistantRead] = {}
|
||||||
|
|
||||||
|
# 添加默认助手 (确保 ID 与前端 Mock 一致)
|
||||||
|
default_assistant = AssistantRead(
|
||||||
|
id='asst-default',
|
||||||
|
name='默认助手',
|
||||||
|
description='通用聊天助手',
|
||||||
|
avatar='🤖',
|
||||||
|
system_prompt='你是一个乐于助人的 AI 助手。',
|
||||||
|
model='gpt-3.5-turbo',
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
assistants_db[default_assistant.id] = default_assistant
|
||||||
|
|
||||||
|
class AssistantService:
|
||||||
|
"""助手数据的 CRUD 服务"""
|
||||||
|
|
||||||
|
def get_assistants(self) -> List[AssistantRead]:
|
||||||
|
"""获取所有助手"""
|
||||||
|
return list(assistants_db.values())
|
||||||
|
|
||||||
|
def get_assistant(self, assistant_id: str) -> Optional[AssistantRead]:
|
||||||
|
"""根据 ID 获取单个助手"""
|
||||||
|
return assistants_db.get(assistant_id)
|
||||||
|
|
||||||
|
def create_assistant(self, assistant_data: AssistantCreate) -> AssistantRead:
|
||||||
|
"""创建新助手"""
|
||||||
|
new_id = f"asst-{uuid.uuid4()}" # 生成唯一 ID
|
||||||
|
new_assistant = AssistantRead(id=new_id, **assistant_data.model_dump())
|
||||||
|
assistants_db[new_id] = new_assistant
|
||||||
|
print(f"助手已创建: {new_id} - {new_assistant.name}")
|
||||||
|
return new_assistant
|
||||||
|
|
||||||
|
def update_assistant(self, assistant_id: str, assistant_data: AssistantUpdate) -> Optional[AssistantRead]:
|
||||||
|
"""更新现有助手"""
|
||||||
|
existing_assistant = assistants_db.get(assistant_id)
|
||||||
|
if not existing_assistant:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 使用 Pydantic 的 model_copy 和 update 来更新字段
|
||||||
|
update_data = assistant_data.model_dump(exclude_unset=True) # 只获取设置了值的字段
|
||||||
|
if update_data:
|
||||||
|
updated_assistant = existing_assistant.model_copy(update=update_data)
|
||||||
|
assistants_db[assistant_id] = updated_assistant
|
||||||
|
print(f"助手已更新: {assistant_id}")
|
||||||
|
return updated_assistant
|
||||||
|
return existing_assistant # 如果没有更新任何字段,返回原始助手
|
||||||
|
|
||||||
|
def delete_assistant(self, assistant_id: str) -> bool:
|
||||||
|
"""删除助手"""
|
||||||
|
if assistant_id in assistants_db:
|
||||||
|
# 添加逻辑:不允许删除默认助手
|
||||||
|
if assistant_id == 'asst-default':
|
||||||
|
print("尝试删除默认助手 - 操作被阻止")
|
||||||
|
return False # 或者抛出特定异常
|
||||||
|
del assistants_db[assistant_id]
|
||||||
|
print(f"助手已删除: {assistant_id}")
|
||||||
|
# TODO: 在实际应用中,还需要删除关联的会话和消息
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 创建服务实例
|
||||||
|
assistant_service_instance = AssistantService()
|
||||||
|
|
||||||
@ -1,92 +1,127 @@
|
|||||||
# File: backend/app/services/chat_service.py (新建)
|
# File: backend/app/services/chat_service.py (更新)
|
||||||
# Description: 封装 LangChain 聊天逻辑
|
# Description: 封装 LangChain 聊天逻辑 (支持助手配置和会话历史)
|
||||||
|
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langchain_google_genai import ChatGoogleGenerativeAI # 如果使用 Google
|
|
||||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||||
from langchain_core.output_parsers import StrOutputParser
|
from langchain_core.output_parsers import StrOutputParser
|
||||||
from langchain_core.messages import HumanMessage, AIMessage
|
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from app.services.assistant_service import assistant_service_instance # 获取助手配置
|
||||||
|
from app.models.pydantic_models import AssistantRead # 引入助手模型
|
||||||
|
import app.core.config as Config
|
||||||
|
|
||||||
# --- 可选:添加内存管理 ---
|
# --- 更新内存管理 ---
|
||||||
# 简单的内存实现 (可以替换为更复杂的 LangChain Memory 类)
|
# 使用字典存储不同会话的内存
|
||||||
chat_history = {} # 使用字典存储不同会话的内存,需要 session_id
|
# key: session_id (str), value: List[BaseMessage]
|
||||||
|
chat_history_db: Dict[str, List[BaseMessage]] = {}
|
||||||
|
|
||||||
class ChatService:
|
class ChatService:
|
||||||
"""处理 AI 聊天交互的服务"""
|
"""处理 AI 聊天交互的服务 (支持助手配置)"""
|
||||||
|
|
||||||
def __init__(self, api_key: str):
|
def __init__(self, default_api_key: str):
|
||||||
|
"""初始化时可传入默认 API Key"""
|
||||||
|
self.default_api_key = default_api_key
|
||||||
|
# 不再在 init 中创建固定的 LLM 和 chain
|
||||||
|
|
||||||
|
def _get_llm(self, assistant: AssistantRead) -> ChatOpenAI:
|
||||||
|
"""根据助手配置动态创建 LLM 实例"""
|
||||||
|
# TODO: 支持不同模型提供商 (Gemini, Anthropic etc.)
|
||||||
|
if assistant.model.startswith("gpt"):
|
||||||
|
return ChatOpenAI(
|
||||||
|
model=assistant.model,
|
||||||
|
api_key=self.default_api_key, # 或从助手配置中读取特定 key
|
||||||
|
temperature=assistant.temperature
|
||||||
|
)
|
||||||
|
elif assistant.model.startswith("gemini"):
|
||||||
|
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||||
|
return ChatGoogleGenerativeAI(
|
||||||
|
model=assistant.model,
|
||||||
|
api_key=self.default_api_key, # 或从助手配置中读取特定 key
|
||||||
|
temperature=assistant.temperature
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 默认或抛出错误
|
||||||
|
print(f"警告: 模型 {assistant.model} 未明确支持,尝试使用 ChatOpenAI")
|
||||||
|
return ChatOpenAI(
|
||||||
|
model=assistant.model,
|
||||||
|
api_key=self.default_api_key,
|
||||||
|
temperature=assistant.temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_ai_reply(self, user_message: str, session_id: str, assistant_id: str) -> str:
|
||||||
"""
|
"""
|
||||||
初始化 ChatService
|
获取 AI 对用户消息的回复 (使用指定助手和会话历史)
|
||||||
Args:
|
|
||||||
api_key (str): 用于 LLM 的 API 密钥
|
|
||||||
"""
|
|
||||||
# --- 选择并初始化 LLM ---
|
|
||||||
# 使用 OpenAI GPT-3.5 Turbo (推荐) 或 GPT-4
|
|
||||||
# self.llm = ChatOpenAI(model="gpt-3.5-turbo", api_key=api_key, temperature=0.7)
|
|
||||||
# self.llm = ChatOpenAI(model="gpt-4", api_key=api_key, temperature=0.7)
|
|
||||||
|
|
||||||
# --- 如果使用 Google Gemini ---
|
|
||||||
self.llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash", google_api_key=api_key, convert_system_message_to_human=True)
|
|
||||||
|
|
||||||
# --- 定义 Prompt 模板 ---
|
|
||||||
# 包含系统消息、历史记录占位符和当前用户输入
|
|
||||||
self.prompt = ChatPromptTemplate.from_messages([
|
|
||||||
("system", "你是一个乐于助人的 AI 助手,请用简洁明了的语言回答问题。你的名字叫 CherryAI。"),
|
|
||||||
MessagesPlaceholder(variable_name="chat_history"), # 用于插入历史消息
|
|
||||||
("human", "{input}"), # 用户当前输入
|
|
||||||
])
|
|
||||||
|
|
||||||
# --- 定义输出解析器 ---
|
|
||||||
# 将 LLM 的输出解析为字符串
|
|
||||||
self.output_parser = StrOutputParser()
|
|
||||||
|
|
||||||
# --- 构建 LangChain Expression Language (LCEL) 链 ---
|
|
||||||
self.chain = self.prompt | self.llm | self.output_parser
|
|
||||||
|
|
||||||
async def get_ai_reply(self, user_message: str, session_id: str = "default_session") -> str:
|
|
||||||
"""
|
|
||||||
获取 AI 对用户消息的回复 (异步)
|
|
||||||
Args:
|
Args:
|
||||||
user_message (str): 用户发送的消息
|
user_message (str): 用户发送的消息
|
||||||
session_id (str): (可选) 用于区分不同对话的会话 ID,以支持内存
|
session_id (str): 会话 ID
|
||||||
|
assistant_id (str): 使用的助手 ID
|
||||||
Returns:
|
Returns:
|
||||||
str: AI 的回复文本
|
str: AI 的回复文本
|
||||||
Raises:
|
Raises:
|
||||||
|
ValueError: 如果找不到指定的助手
|
||||||
Exception: 如果调用 AI 服务时发生错误
|
Exception: 如果调用 AI 服务时发生错误
|
||||||
"""
|
"""
|
||||||
try:
|
# 1. 获取助手配置
|
||||||
# --- 获取当前会话的历史记录 (如果需要内存) ---
|
assistant = assistant_service_instance.get_assistant(assistant_id)
|
||||||
current_chat_history = chat_history.get(session_id, [])
|
if not assistant:
|
||||||
|
raise ValueError(f"找不到助手 ID: {assistant_id}")
|
||||||
|
|
||||||
# --- 使用 ainvoke 进行异步调用 ---
|
# 2. 获取或初始化当前会话的历史记录
|
||||||
ai_response = await self.chain.ainvoke({
|
current_chat_history = chat_history_db.get(session_id, [])
|
||||||
|
|
||||||
|
# 3. 构建 Prompt (包含动态系统提示)
|
||||||
|
prompt = ChatPromptTemplate.from_messages([
|
||||||
|
SystemMessage(content=assistant.system_prompt), # 使用助手的系统提示
|
||||||
|
MessagesPlaceholder(variable_name="chat_history"),
|
||||||
|
HumanMessage(content="{input}"),
|
||||||
|
])
|
||||||
|
|
||||||
|
# 4. 获取 LLM 实例
|
||||||
|
llm = self._get_llm(assistant)
|
||||||
|
|
||||||
|
# 5. 定义输出解析器
|
||||||
|
output_parser = StrOutputParser()
|
||||||
|
|
||||||
|
# 6. 构建 LCEL 链
|
||||||
|
chain = prompt | llm | output_parser
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 7. 调用链获取回复
|
||||||
|
ai_response = await chain.ainvoke({
|
||||||
"input": user_message,
|
"input": user_message,
|
||||||
"chat_history": current_chat_history, # 传入历史记录
|
"chat_history": current_chat_history,
|
||||||
})
|
})
|
||||||
|
|
||||||
# --- 更新会话历史记录 (如果需要内存) ---
|
# 8. 更新会话历史记录
|
||||||
# 只保留最近 N 轮对话,防止历史过长
|
|
||||||
max_history_length = 10 # 保留最近 5 轮对话 (10条消息)
|
|
||||||
current_chat_history.append(HumanMessage(content=user_message))
|
current_chat_history.append(HumanMessage(content=user_message))
|
||||||
current_chat_history.append(AIMessage(content=ai_response))
|
current_chat_history.append(AIMessage(content=ai_response))
|
||||||
# 如果历史记录超过长度,移除最早的消息
|
# 限制历史记录长度 (例如最近 10 条消息)
|
||||||
|
max_history_length = 10
|
||||||
if len(current_chat_history) > max_history_length:
|
if len(current_chat_history) > max_history_length:
|
||||||
chat_history[session_id] = current_chat_history[-max_history_length:]
|
chat_history_db[session_id] = current_chat_history[-max_history_length:]
|
||||||
else:
|
else:
|
||||||
chat_history[session_id] = current_chat_history
|
chat_history_db[session_id] = current_chat_history
|
||||||
|
|
||||||
return ai_response
|
return ai_response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"调用 LangChain 时出错: {e}")
|
print(f"调用 LangChain 时出错 (助手: {assistant_id}, 会话: {session_id}): {e}")
|
||||||
# 可以进行更细致的错误处理,例如区分 API 错误和内部错误
|
|
||||||
raise Exception(f"AI 服务暂时不可用: {e}")
|
raise Exception(f"AI 服务暂时不可用: {e}")
|
||||||
|
|
||||||
|
# (可选) 添加一个简单的文本生成方法用于生成标题
|
||||||
|
async def generate_text(self, prompt_text: str, model_name: str = "gpt-3.5-turbo", temperature: float = 0.5) -> str:
|
||||||
|
"""使用指定模型生成文本 (用于标题等)"""
|
||||||
|
try:
|
||||||
|
# 使用一个临时的、可能更便宜的模型
|
||||||
|
temp_llm = ChatOpenAI(model=model_name, api_key=self.default_api_key, temperature=temperature)
|
||||||
|
response = await temp_llm.ainvoke(prompt_text)
|
||||||
|
return response.content
|
||||||
|
except Exception as e:
|
||||||
|
print(f"生成文本时出错: {e}")
|
||||||
|
return "无法生成标题" # 返回默认值或抛出异常
|
||||||
|
|
||||||
# --- 在文件末尾,创建 ChatService 的实例 ---
|
|
||||||
# 从配置中获取 API Key
|
|
||||||
from app.core.config import GOOGLE_API_KEY
|
|
||||||
if not GOOGLE_API_KEY:
|
|
||||||
raise ValueError("请在 .env 文件中设置 GOOGLE_API_KEY")
|
|
||||||
|
|
||||||
chat_service_instance = ChatService(api_key=GOOGLE_API_KEY)
|
# --- 创建 ChatService 实例 ---
|
||||||
|
if not Config.GOOGLE_API_KEY:
|
||||||
|
raise ValueError("请在 .env 文件中设置 OPENAI_API_KEY")
|
||||||
|
chat_service_instance = ChatService(default_api_key=Config.GOOGLE_API_KEY)
|
||||||
|
|||||||
82
backend/app/services/session_service.py
Normal file
82
backend/app/services/session_service.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
# File: backend/app/services/session_service.py (新建)
|
||||||
|
# Description: 管理会话数据的服务 (内存实现)
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from app.models.pydantic_models import SessionRead, SessionCreateRequest, SessionCreateResponse, AssistantRead
|
||||||
|
from app.services.assistant_service import assistant_service_instance # 需要获取助手信息
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import uuid
|
||||||
|
# 导入 ChatService 以调用 LLM 生成标题 (避免循环导入,考虑重构)
|
||||||
|
# from app.services.chat_service import chat_service_instance
|
||||||
|
|
||||||
|
# 内存数据库存储会话
|
||||||
|
# key: session_id (str), value: SessionRead object
|
||||||
|
sessions_db: Dict[str, SessionRead] = {}
|
||||||
|
|
||||||
|
class SessionService:
|
||||||
|
"""会话数据的 CRUD 及标题生成服务"""
|
||||||
|
|
||||||
|
async def create_session(self, session_data: SessionCreateRequest) -> SessionCreateResponse:
|
||||||
|
"""创建新会话并生成标题"""
|
||||||
|
assistant = assistant_service_instance.get_assistant(session_data.assistant_id)
|
||||||
|
if not assistant:
|
||||||
|
raise ValueError("指定的助手不存在")
|
||||||
|
|
||||||
|
new_id = f"session-{uuid.uuid4()}"
|
||||||
|
created_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# --- TODO: 调用 LLM 生成标题 ---
|
||||||
|
# title_prompt = f"根据以下用户第一条消息,为此对话生成一个简洁的标题(不超过10个字):\n\n{session_data.first_message}"
|
||||||
|
# generated_title = await chat_service_instance.generate_text(title_prompt) # 需要一个简单的文本生成方法
|
||||||
|
|
||||||
|
# 模拟标题生成
|
||||||
|
generated_title = f"关于 \"{session_data.first_message[:15]}...\""
|
||||||
|
print(f"为新会话 {new_id} 生成标题: {generated_title}")
|
||||||
|
# --- 模拟结束 ---
|
||||||
|
|
||||||
|
new_session = SessionRead(
|
||||||
|
id=new_id,
|
||||||
|
title=generated_title,
|
||||||
|
assistant_id=session_data.assistant_id,
|
||||||
|
created_at=created_time.isoformat() # 存储 ISO 格式字符串
|
||||||
|
)
|
||||||
|
sessions_db[new_id] = new_session
|
||||||
|
print(f"会话已创建: {new_id}")
|
||||||
|
|
||||||
|
return SessionCreateResponse(
|
||||||
|
id=new_session.id,
|
||||||
|
title=new_session.title,
|
||||||
|
assistant_id=new_session.assistant_id,
|
||||||
|
created_at=new_session.created_at
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_sessions_by_assistant(self, assistant_id: str) -> List[SessionRead]:
|
||||||
|
"""获取指定助手的所有会话"""
|
||||||
|
return [s for s in sessions_db.values() if s.assistant_id == assistant_id]
|
||||||
|
|
||||||
|
def get_session(self, session_id: str) -> Optional[SessionRead]:
|
||||||
|
"""获取单个会话"""
|
||||||
|
return sessions_db.get(session_id)
|
||||||
|
|
||||||
|
def delete_session(self, session_id: str) -> bool:
|
||||||
|
"""删除会话"""
|
||||||
|
if session_id in sessions_db:
|
||||||
|
del sessions_db[session_id]
|
||||||
|
print(f"会话已删除: {session_id}")
|
||||||
|
# TODO: 删除关联的消息
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_sessions_by_assistant(self, assistant_id: str) -> int:
|
||||||
|
"""删除指定助手的所有会话"""
|
||||||
|
ids_to_delete = [s.id for s in sessions_db.values() if s.assistant_id == assistant_id]
|
||||||
|
count = 0
|
||||||
|
for session_id in ids_to_delete:
|
||||||
|
if self.delete_session(session_id):
|
||||||
|
count += 1
|
||||||
|
print(f"删除了助手 {assistant_id} 的 {count} 个会话")
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# 创建服务实例
|
||||||
|
session_service_instance = SessionService()
|
||||||
@ -1,11 +1,31 @@
|
|||||||
// File: frontend/app/chat/page.tsx (更新会话逻辑)
|
// File: frontend/app/chat/page.tsx (更新以使用 API)
|
||||||
// Description: AI 聊天界面,实现助手关联会话、临时新对话和发送时创建会话
|
// Description: 对接后端 API 实现助手和会话的加载与管理
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { SendHorizontal, Loader2, PanelRightOpen, PanelRightClose, UserPlus, Settings2 } from 'lucide-react';
|
import { SendHorizontal, Loader2, PanelRightOpen, PanelRightClose, UserPlus, Settings2, Trash2, Edit, RefreshCw } from 'lucide-react'; // 添加刷新图标
|
||||||
import { sendChatMessage } from '@/lib/api'; // 假设 sendChatMessage 只需 message
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
// Shadcn UI Components
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogClose } from "@/components/ui/dialog";
|
||||||
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Toaster, toast } from "sonner";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"; // 导入骨架屏
|
||||||
|
|
||||||
|
// API 函数和类型
|
||||||
|
import {
|
||||||
|
sendChatMessage, getAssistants, createAssistant, updateAssistant, deleteAssistant,
|
||||||
|
getSessionsByAssistant, deleteSession,
|
||||||
|
Assistant, Session, AssistantCreateData, AssistantUpdateData, ChatApiResponse
|
||||||
|
} from '@/lib/api'; // 确保路径正确
|
||||||
|
|
||||||
// --- 数据接口定义 ---
|
// --- 数据接口定义 ---
|
||||||
interface Message {
|
interface Message {
|
||||||
@ -16,192 +36,442 @@ interface Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ChatSession {
|
interface ChatSession {
|
||||||
id: string; // 唯一 ID,例如 'session-uuid-123' 或 'temp-new-chat'
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
assistantId: string; // 关联的助手 ID
|
assistantId: string;
|
||||||
isTemporary?: boolean; // 标记是否为临时会话
|
isTemporary?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Assistant {
|
// --- Zod Schema for Assistant Form Validation ---
|
||||||
id: string;
|
const assistantFormSchema = z.object({
|
||||||
name: string;
|
name: z.string().min(1, { message: "助手名称不能为空" }).max(50, { message: "名称过长" }),
|
||||||
description: string;
|
description: z.string().max(200, { message: "描述过长" }).optional(),
|
||||||
avatar?: string;
|
avatar: z.string().max(5, { message: "头像/Emoji 过长" }).optional(), // 简单限制长度
|
||||||
systemPrompt: string;
|
system_prompt: z.string().min(1, { message: "系统提示不能为空" }).max(4000, { message: "系统提示过长" }),
|
||||||
model: string;
|
model: z.string({ required_error: "请选择一个模型" }),
|
||||||
temperature: number;
|
temperature: z.number().min(0).max(1),
|
||||||
}
|
});
|
||||||
|
|
||||||
|
type AssistantFormData = z.infer<typeof assistantFormSchema>;
|
||||||
|
|
||||||
|
// 可选的模型列表
|
||||||
|
const availableModels = [
|
||||||
|
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
|
||||||
|
{ value: "gpt-4", label: "GPT-4" },
|
||||||
|
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
|
||||||
|
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
||||||
|
{ value: "deepseek-coder", label: "DeepSeek Coder" }, // 示例
|
||||||
|
// 添加更多模型...
|
||||||
|
];
|
||||||
|
|
||||||
// --- Helper Function ---
|
// --- Helper Function ---
|
||||||
// 查找助手的最新会话 (非临时)
|
|
||||||
const findLastSession = (sessions: ChatSession[], assistantId: string): ChatSession | undefined => {
|
const findLastSession = (sessions: ChatSession[], assistantId: string): ChatSession | undefined => {
|
||||||
return sessions
|
return sessions
|
||||||
.filter(s => s.assistantId === assistantId && !s.isTemporary)
|
.filter(s => s.assistantId === assistantId && !s.isTemporary)
|
||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Assistant Form Component ---
|
||||||
|
interface AssistantFormProps {
|
||||||
|
assistant?: Assistant | null; // 传入表示编辑,否则是创建
|
||||||
|
onSave: (data: AssistantFormData, id?: string) => void; // 保存回调
|
||||||
|
onClose: () => void; // 关闭 Dialog 的回调
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantForm({ assistant, onSave, onClose }: AssistantFormProps) {
|
||||||
|
const form = useForm<AssistantFormData>({
|
||||||
|
resolver: zodResolver(assistantFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: assistant?.name || "",
|
||||||
|
description: assistant?.description || "",
|
||||||
|
avatar: assistant?.avatar || "",
|
||||||
|
system_prompt: assistant?.system_prompt || "",
|
||||||
|
model: assistant?.model || availableModels[0].value, // 默认第一个模型
|
||||||
|
temperature: assistant?.temperature ?? 0.7, // 默认 0.7
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [isSaving, setIsSaving] = useState(false); // 添加保存状态
|
||||||
|
async function onSubmit(data: AssistantFormData) {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(data, assistant?.id); // 调用异步保存函数
|
||||||
|
onClose(); // 成功后关闭
|
||||||
|
} catch (error) {
|
||||||
|
// 错误已在 onSave 中处理并 toast
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>助手名称</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="例如:代码助手" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Description */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>描述 (可选)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="简单描述助手的功能" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Avatar */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="avatar"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>头像 (可选)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="输入 Emoji 或 URL" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>建议使用单个 Emoji。</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* System Prompt */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="system_prompt"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>系统提示 (System Prompt)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="定义助手的角色和行为..."
|
||||||
|
className="resize-y min-h-[100px]" // 允许垂直调整大小
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Model Selection */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="model"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>模型</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择一个 AI 模型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{availableModels.map(model => (
|
||||||
|
<SelectItem key={model.value} value={model.value}>
|
||||||
|
{model.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* Temperature */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="temperature"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>温度 (Temperature): {field.value.toFixed(1)}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
{/* Shadcn Slider expects an array for value */}
|
||||||
|
<Slider
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
defaultValue={[field.value]} // Use defaultValue for initial render
|
||||||
|
onValueChange={(value) => field.onChange(value[0])} // Update form state on change
|
||||||
|
className="py-2" // Add padding for better interaction
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
值越低越稳定,越高越有创造性。
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={isSaving}>取消</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button type="submit" disabled={isSaving}>
|
||||||
|
{isSaving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
{isSaving ? '保存中...' : '保存助手'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Main Chat Page Component ---
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
// --- State Variables ---
|
// --- State Variables ---
|
||||||
const [inputMessage, setInputMessage] = useState('');
|
const [inputMessage, setInputMessage] = useState('');
|
||||||
const [messages, setMessages] = useState<Message[]>([]); // 初始为空,由选中助手/会话决定
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [isLoading, setIsLoading] = useState(false); // AI 回复加载状态
|
||||||
|
const [error, setError] = useState<string | null>(null); // 通用错误显示 (可选)
|
||||||
|
|
||||||
const [isSessionPanelOpen, setIsSessionPanelOpen] = useState(true);
|
const [isSessionPanelOpen, setIsSessionPanelOpen] = useState(true);
|
||||||
|
const [isAssistantDialogOpen, setIsAssistantDialogOpen] = useState(false); // 控制助手表单 Dialog 显隐
|
||||||
|
const [editingAssistant, setEditingAssistant] = useState<Assistant | null>(null); // 当前正在编辑的助手
|
||||||
|
|
||||||
// --- Mock Data & State ---
|
// Data Loading States
|
||||||
const [assistants, setAssistants] = useState<Assistant[]>([
|
const [assistantsLoading, setAssistantsLoading] = useState(true);
|
||||||
{ id: 'asst-default', name: '默认助手', description: '通用聊天助手', avatar: '🤖', systemPrompt: '你是一个乐于助人的 AI 助手。', model: 'gpt-3.5-turbo', temperature: 0.7 },
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
{ id: 'asst-coder', name: '代码助手', description: '帮助编写和解释代码', avatar: '💻', systemPrompt: '你是一个专业的代码助手...', model: 'gpt-4', temperature: 0.5 },
|
|
||||||
{ id: 'asst-writer', name: '写作助手', description: '协助进行创意写作', avatar: '✍️', systemPrompt: '你是一位富有创意的写作伙伴...', model: 'gemini-pro', temperature: 0.9 },
|
|
||||||
]);
|
|
||||||
const [currentAssistantId, setCurrentAssistantId] = useState<string>('asst-default');
|
|
||||||
|
|
||||||
// 所有会话存储在一起,通过 assistantId 过滤
|
// Data State
|
||||||
const [allSessions, setAllSessions] = useState<ChatSession[]>([
|
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||||
{id: 'session-1', title: '默认助手的讨论', createdAt: new Date(Date.now() - 3600000), assistantId: 'asst-default'},
|
const [currentAssistantId, setCurrentAssistantId] = useState<string | null>(null); // 初始为 null
|
||||||
{id: 'session-2', title: '代码调试记录', createdAt: new Date(Date.now() - 7200000), assistantId: 'asst-coder'},
|
const [allSessions, setAllSessions] = useState<Session[]>([]);
|
||||||
{id: 'session-3', title: '默认助手的学习 LangChain', createdAt: new Date(), assistantId: 'asst-default'},
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null); // 初始为 null
|
||||||
]);
|
|
||||||
// 当前激活的会话 ID,可能是真实 ID 或 'temp-new-chat'
|
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// --- Refs ---
|
// --- Refs ---
|
||||||
const messagesEndRef = useRef<null | HTMLDivElement>(null);
|
const messagesEndRef = useRef<null | HTMLDivElement>(null);
|
||||||
|
|
||||||
// --- Effects ---
|
// --- Effects ---
|
||||||
// 自动滚动
|
// Initial data loading (Assistants)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
const loadAssistants = async () => {
|
||||||
}, [messages]);
|
setAssistantsLoading(true);
|
||||||
|
try {
|
||||||
|
const fetchedAssistants = await getAssistants();
|
||||||
|
setAssistants(fetchedAssistants);
|
||||||
|
// 设置默认选中的助手 (例如第一个或 ID 为 'asst-default' 的)
|
||||||
|
const defaultAssistant = fetchedAssistants.find(a => a.id === 'asst-default') || fetchedAssistants[0];
|
||||||
|
if (defaultAssistant) {
|
||||||
|
setCurrentAssistantId(defaultAssistant.id);
|
||||||
|
} else {
|
||||||
|
console.warn("No default or initial assistant found.");
|
||||||
|
// 可能需要提示用户创建助手
|
||||||
|
}
|
||||||
|
} catch (apiError: any) {
|
||||||
|
toast.error(`加载助手列表失败: ${apiError.message}`);
|
||||||
|
setError(`无法加载助手: ${apiError.message}`);
|
||||||
|
} finally {
|
||||||
|
setAssistantsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAssistants();
|
||||||
|
}, []); // 空依赖数组,只在挂载时运行一次
|
||||||
|
|
||||||
// 根据当前助手 ID 过滤出会话列表
|
// Load sessions when assistant changes
|
||||||
const currentAssistantSessions = React.useMemo(() => {
|
|
||||||
return allSessions.filter(s => s.assistantId === currentAssistantId);
|
|
||||||
}, [allSessions, currentAssistantId]);
|
|
||||||
|
|
||||||
// 当助手切换时,决定加载哪个会话
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lastSession = findLastSession(allSessions, currentAssistantId);
|
if (!currentAssistantId) return; // 如果没有选中助手,则不加载
|
||||||
if (lastSession) {
|
|
||||||
|
const loadSessions = async () => {
|
||||||
|
setSessionsLoading(true);
|
||||||
|
// 清空当前会话和消息列表
|
||||||
|
setCurrentSessionId(null);
|
||||||
|
setMessages([]);
|
||||||
|
try {
|
||||||
|
const fetchedSessions = await getSessionsByAssistant(currentAssistantId);
|
||||||
|
// 更新全局会话列表 (只保留其他助手的会话,加上当前助手的)
|
||||||
|
setAllSessions(prev => [
|
||||||
|
...prev.filter(s => s.assistant_id !== currentAssistantId),
|
||||||
|
...fetchedSessions
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 查找最新的会话并设为当前
|
||||||
|
const lastSession = fetchedSessions
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
|
||||||
|
|
||||||
|
if (lastSession) {
|
||||||
setCurrentSessionId(lastSession.id);
|
setCurrentSessionId(lastSession.id);
|
||||||
// TODO: 在实际应用中,这里需要调用 API 加载 lastSession.id 的历史消息
|
// TODO: 加载 lastSession.id 的历史消息
|
||||||
console.log(`加载助手 ${currentAssistantId} 的最后一个会话: ${lastSession.id}`);
|
console.log(`加载助手 ${currentAssistantId} 的最后一个会话: ${lastSession.id}`);
|
||||||
// 模拟加载消息
|
const currentAssistant = assistants.find(a => a.id === currentAssistantId);
|
||||||
const selectedAssistant = assistants.find(a => a.id === currentAssistantId);
|
setMessages([ { id: `init-${lastSession.id}-1`, text: `继续与 ${currentAssistant?.name || '助手'} 的对话: ${lastSession.title}`, sender: 'ai' } ]);
|
||||||
setMessages([
|
} else {
|
||||||
{ id: `init-${lastSession.id}-1`, text: `继续与 ${selectedAssistant?.name || '助手'} 的对话: ${lastSession.title}`, sender: 'ai' },
|
|
||||||
// ... 应该加载真实历史消息
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
// 没有历史会话,进入临时新对话状态
|
// 没有历史会话,进入临时新对话状态
|
||||||
setCurrentSessionId('temp-new-chat');
|
setCurrentSessionId('temp-new-chat');
|
||||||
console.log(`助手 ${currentAssistantId} 没有历史会话,创建临时新对话`);
|
console.log(`助手 ${currentAssistantId} 没有历史会话,创建临时新对话`);
|
||||||
const selectedAssistant = assistants.find(a => a.id === currentAssistantId);
|
const currentAssistant = assistants.find(a => a.id === currentAssistantId);
|
||||||
setMessages([
|
setMessages([ { id: `init-temp-${currentAssistantId}`, text: `开始与 ${currentAssistant?.name || '助手'} 的新对话吧!`, sender: 'ai' } ]);
|
||||||
{ id: `init-temp-${currentAssistantId}`, text: `开始与 ${selectedAssistant?.name || '助手'} 的新对话吧!`, sender: 'ai' },
|
}
|
||||||
]);
|
} catch (apiError: any) {
|
||||||
// 不将会话添加到 allSessions 列表
|
toast.error(`加载会话列表失败: ${apiError.message}`);
|
||||||
|
} finally {
|
||||||
|
setSessionsLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentAssistantId, allSessions, assistants]); // 依赖助手 ID 和所有会话
|
};
|
||||||
|
|
||||||
|
loadSessions();
|
||||||
|
}, [currentAssistantId, assistants]); // 依赖助手 ID 和助手列表 (以防助手信息更新)
|
||||||
|
|
||||||
|
// Auto scroll
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Filter sessions for the current assistant (UI display)
|
||||||
|
const currentAssistantSessions = React.useMemo(() => {
|
||||||
|
// 直接从 allSessions 过滤,因为加载时已经更新了
|
||||||
|
return allSessions
|
||||||
|
.filter(s => s.assistant_id === currentAssistantId)
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); // 按时间倒序
|
||||||
|
}, [allSessions, currentAssistantId]);
|
||||||
|
|
||||||
// --- Event Handlers ---
|
// --- Assistant CRUD Handlers (Updated with API calls) ---
|
||||||
|
const handleSaveAssistant = async (data: AssistantFormData, id?: string) => {
|
||||||
|
try {
|
||||||
|
let savedAssistant: Assistant;
|
||||||
|
if (id) {
|
||||||
|
// 编辑
|
||||||
|
savedAssistant = await updateAssistant(id, data);
|
||||||
|
setAssistants(prev => prev.map(a => (a.id === id ? savedAssistant : a)));
|
||||||
|
toast.success(`助手 "${savedAssistant.name}" 已更新`);
|
||||||
|
// 如果更新的是当前助手,可能需要重新加载会话或消息
|
||||||
|
if (id === currentAssistantId) {
|
||||||
|
// 简单处理:可以强制刷新会话列表(或提示用户)
|
||||||
|
setCurrentAssistantId(null); // 触发 useEffect 重新加载
|
||||||
|
setTimeout(() => setCurrentAssistantId(id), 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建
|
||||||
|
savedAssistant = await createAssistant(data);
|
||||||
|
setAssistants(prev => [...prev, savedAssistant]);
|
||||||
|
toast.success(`助手 "${savedAssistant.name}" 已创建`);
|
||||||
|
// 创建后自动选中
|
||||||
|
handleSelectAssistant(savedAssistant.id);
|
||||||
|
}
|
||||||
|
} catch (apiError: any) {
|
||||||
|
toast.error(`保存助手失败: ${apiError.message}`);
|
||||||
|
throw apiError; // 重新抛出错误,让表单的 finally 处理 isSaving
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAssistant = async (idToDelete: string) => {
|
||||||
|
if (idToDelete === 'asst-default' || assistants.length <= 1) {
|
||||||
|
toast.error("不能删除默认助手或最后一个助手");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (idToDelete === currentAssistantId) {
|
||||||
|
toast.error("请先切换到其他助手再删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantToDelete = assistants.find(a => a.id === idToDelete);
|
||||||
|
if (window.confirm(`确定要删除助手 "${assistantToDelete?.name}" 吗?相关会话也将被删除。`)) {
|
||||||
|
try {
|
||||||
|
await deleteAssistant(idToDelete);
|
||||||
|
// 后端应负责删除关联会话,前端只需更新助手列表
|
||||||
|
setAssistants(prev => prev.filter(a => a.id !== idToDelete));
|
||||||
|
// (可选) 如果需要立即清除前端的会话缓存
|
||||||
|
// setAllSessions(prev => prev.filter(s => s.assistant_id !== idToDelete));
|
||||||
|
toast.success(`助手 "${assistantToDelete?.name}" 已删除`);
|
||||||
|
} catch (apiError: any) {
|
||||||
|
toast.error(`删除助手失败: ${apiError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditAssistant = (assistant: Assistant) => {
|
||||||
|
setEditingAssistant(assistant);
|
||||||
|
setIsAssistantDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreateAssistantDialog = () => {
|
||||||
|
setEditingAssistant(null);
|
||||||
|
setIsAssistantDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Send Message Handler (Updated with API response handling) ---
|
||||||
const handleSendMessage = async (e?: React.FormEvent<HTMLFormElement>) => {
|
const handleSendMessage = async (e?: React.FormEvent<HTMLFormElement>) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const trimmedMessage = inputMessage.trim();
|
const trimmedMessage = inputMessage.trim();
|
||||||
if (!trimmedMessage || isLoading) return;
|
if (!trimmedMessage || isLoading || !currentSessionId || !currentAssistantId) return; // 增加检查
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true); // 先设置加载状态
|
setIsLoading(true);
|
||||||
|
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
text: trimmedMessage,
|
text: trimmedMessage,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
};
|
};
|
||||||
|
// 立即显示用户消息
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInputMessage(''); // 清空输入框
|
||||||
|
|
||||||
let targetSessionId = currentSessionId;
|
|
||||||
let newSessionCreated = false;
|
|
||||||
|
|
||||||
// 检查是否是临时新对话的第一条消息
|
|
||||||
if (currentSessionId === 'temp-new-chat') {
|
|
||||||
console.log("发送临时新对话的第一条消息,准备创建会话...");
|
|
||||||
try {
|
|
||||||
// --- 模拟后端创建会话并生成标题 ---
|
|
||||||
// TODO: 调用后端 API (e.g., POST /api/v1/sessions)
|
|
||||||
// const { newSessionId, newTitle } = await createSessionOnBackend(currentAssistantId, trimmedMessage);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500)); // 模拟网络延迟
|
|
||||||
const newSessionId = `session-${Date.now()}`; // 模拟生成的新 ID
|
|
||||||
const newTitle = `关于 "${trimmedMessage.substring(0, 15)}..." 的讨论`; // 模拟 AI 生成的标题
|
|
||||||
// --- 模拟结束 ---
|
|
||||||
|
|
||||||
const newSession: ChatSession = {
|
|
||||||
id: newSessionId,
|
|
||||||
title: newTitle,
|
|
||||||
createdAt: new Date(),
|
|
||||||
assistantId: currentAssistantId,
|
|
||||||
isTemporary: false, // 不再是临时的
|
|
||||||
};
|
|
||||||
|
|
||||||
// 将新创建的会话添加到全局列表,并设为当前会话
|
|
||||||
setAllSessions(prev => [...prev, newSession]);
|
|
||||||
setCurrentSessionId(newSessionId);
|
|
||||||
targetSessionId = newSessionId; // 更新目标会话 ID
|
|
||||||
newSessionCreated = true; // 标记已创建
|
|
||||||
console.log(`新会话创建成功: ID=${newSessionId}, Title=${newTitle}`);
|
|
||||||
|
|
||||||
// 更新消息列表(先添加用户消息)
|
|
||||||
// 注意:因为 setMessages 是异步的,这里先更新,后续 AI 回复再更新
|
|
||||||
setMessages(prev => [...prev, userMessage]);
|
|
||||||
|
|
||||||
|
|
||||||
} catch (creationError: any) {
|
|
||||||
console.error("创建新会话失败:", creationError);
|
|
||||||
setError(`无法创建新会话: ${creationError.message}`);
|
|
||||||
setIsLoading(false);
|
|
||||||
return; // 创建失败则停止后续操作
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 不是新会话,直接将用户消息添加到当前消息列表
|
|
||||||
setMessages(prev => [...prev, userMessage]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 清空输入框
|
|
||||||
setInputMessage('');
|
|
||||||
|
|
||||||
// 发送消息到后端 (无论是否新创建会话,都需要发送)
|
|
||||||
try {
|
try {
|
||||||
console.log(`发送消息到会话: ${targetSessionId}, 助手: ${currentAssistantId}`);
|
// 调用后端 API
|
||||||
// TODO: 调用后端 API 时需要传递 targetSessionId 和 currentAssistantId
|
const response: ChatApiResponse = await sendChatMessage(
|
||||||
// const aiReply = await sendChatMessage(trimmedMessage, targetSessionId, currentAssistantId);
|
trimmedMessage,
|
||||||
const aiReply = await sendChatMessage(trimmedMessage); // 暂时保持不变
|
currentSessionId, // 发送当前 session ID ('temp-new-chat' 或真实 ID)
|
||||||
|
currentAssistantId
|
||||||
|
);
|
||||||
|
|
||||||
const aiMessage: Message = {
|
// 处理 AI 回复
|
||||||
id: Date.now().toString() + '_ai',
|
const aiMessage: Message = {
|
||||||
text: aiReply,
|
id: Date.now().toString() + '_ai',
|
||||||
sender: 'ai',
|
text: response.reply,
|
||||||
};
|
sender: 'ai',
|
||||||
// 使用函数式更新,确保基于最新的消息列表添加回复
|
};
|
||||||
setMessages((prevMessages) => [...prevMessages, aiMessage]);
|
setMessages((prevMessages) => [...prevMessages, aiMessage]);
|
||||||
|
|
||||||
} catch (sendError: any) {
|
// 如果后端创建了新会话并返回了信息
|
||||||
console.error("发送消息失败:", sendError);
|
if (response.session_id && response.session_title && currentSessionId === 'temp-new-chat') {
|
||||||
const errorMessageText = sendError.message || 'An unknown error occurred.';
|
const newSession: Session = {
|
||||||
setError(errorMessageText); // 更新错误状态
|
id: response.session_id,
|
||||||
const errorMessage: Message = {
|
title: response.session_title,
|
||||||
id: Date.now().toString() + '_err',
|
assistant_id: currentAssistantId,
|
||||||
text: `错误: ${errorMessageText}`,
|
created_at: new Date().toISOString(), // 使用客户端时间或后端返回的时间
|
||||||
sender: 'ai',
|
|
||||||
isError: true,
|
|
||||||
};
|
};
|
||||||
setMessages((prevMessages) => [...prevMessages, errorMessage]);
|
// 更新全局会话列表和当前会话 ID
|
||||||
|
setAllSessions(prev => [...prev, newSession]);
|
||||||
|
setCurrentSessionId(newSession.id);
|
||||||
|
console.log(`前端已更新新会话信息: ID=${newSession.id}, Title=${newSession.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (apiError: any) {
|
||||||
|
console.error("发送消息失败:", apiError);
|
||||||
|
const errorMessageText = apiError.message || '发生未知错误';
|
||||||
|
setError(errorMessageText);
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: Date.now().toString() + '_err',
|
||||||
|
text: `错误: ${errorMessageText}`,
|
||||||
|
sender: 'ai',
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
setMessages((prevMessages) => [...prevMessages, errorMessage]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false); // 无论成功失败,结束加载
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
|
// --- Other Handlers (基本不变, 但需要检查 currentAssistantId/currentSessionId 是否存在) ---
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputMessage(e.target.value);
|
setInputMessage(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -216,23 +486,19 @@ export default function ChatPage() {
|
|||||||
setIsSessionPanelOpen(!isSessionPanelOpen);
|
setIsSessionPanelOpen(!isSessionPanelOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 点击助手列表项的处理函数
|
|
||||||
const handleSelectAssistant = (assistantId: string) => {
|
const handleSelectAssistant = (assistantId: string) => {
|
||||||
if (assistantId !== currentAssistantId) {
|
if (assistantId !== currentAssistantId) {
|
||||||
setCurrentAssistantId(assistantId);
|
setCurrentAssistantId(assistantId); // 触发 useEffect 加载会话
|
||||||
// 后续逻辑由 useEffect 处理
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击会话列表项的处理函数
|
|
||||||
const handleSelectSession = (sessionId: string) => {
|
const handleSelectSession = (sessionId: string) => {
|
||||||
if (sessionId !== currentSessionId) {
|
if (sessionId !== currentSessionId) {
|
||||||
setCurrentSessionId(sessionId);
|
setCurrentSessionId(sessionId);
|
||||||
// TODO: 调用 API 加载该会话的历史消息
|
// TODO: 调用 API 加载该会话的历史消息
|
||||||
console.log(`切换到会话: ${sessionId}`);
|
console.log(`切换到会话: ${sessionId}`);
|
||||||
// 模拟加载消息
|
|
||||||
const session = allSessions.find(s => s.id === sessionId);
|
const session = allSessions.find(s => s.id === sessionId);
|
||||||
const assistant = assistants.find(a => a.id === session?.assistantId);
|
const assistant = assistants.find(a => a.id === session?.assistant_id);
|
||||||
setMessages([
|
setMessages([
|
||||||
{ id: `init-${sessionId}-1`, text: `继续与 ${assistant?.name || '助手'} 的对话: ${session?.title || ''}`, sender: 'ai' },
|
{ id: `init-${sessionId}-1`, text: `继续与 ${assistant?.name || '助手'} 的对话: ${session?.title || ''}`, sender: 'ai' },
|
||||||
// ... 加载真实历史消息
|
// ... 加载真实历史消息
|
||||||
@ -240,13 +506,12 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击新建话题按钮
|
|
||||||
const handleNewTopic = () => {
|
const handleNewTopic = () => {
|
||||||
if (currentSessionId !== 'temp-new-chat') {
|
if (currentSessionId !== 'temp-new-chat' && currentAssistantId) { // 确保有助手被选中
|
||||||
setCurrentSessionId('temp-new-chat');
|
setCurrentSessionId('temp-new-chat');
|
||||||
const selectedAssistant = assistants.find(a => a.id === currentAssistantId);
|
const currentAssistant = assistants.find(a => a.id === currentAssistantId);
|
||||||
setMessages([
|
setMessages([
|
||||||
{ id: `init-temp-${currentAssistantId}`, text: `开始与 ${selectedAssistant?.name || '助手'} 的新对话吧!`, sender: 'ai' },
|
{ id: `init-temp-${currentAssistantId}`, text: `开始与 ${currentAssistant?.name || '助手'} 的新对话吧!`, sender: 'ai' },
|
||||||
]);
|
]);
|
||||||
console.log("手动创建临时新对话");
|
console.log("手动创建临时新对话");
|
||||||
}
|
}
|
||||||
@ -256,43 +521,109 @@ export default function ChatPage() {
|
|||||||
return (
|
return (
|
||||||
// 最外层 Flex 容器
|
// 最外层 Flex 容器
|
||||||
<div className="flex h-full gap-1"> {/* 使用 gap 添加间距 */}
|
<div className="flex h-full gap-1"> {/* 使用 gap 添加间距 */}
|
||||||
|
<Toaster position="top-center" richColors />
|
||||||
{/* 左侧助手面板 */}
|
{/* 左侧助手面板 */}
|
||||||
<aside className="w-64 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 flex flex-col">
|
<aside className="w-64 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 flex flex-col">
|
||||||
<h2 className="w-full text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200 flex items-center justify-between">
|
<h2 className="w-full text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200 flex items-center justify-between">
|
||||||
<span>助手列表</span>
|
<span>助手列表</span>
|
||||||
{/* TODO: 添加助手创建/编辑入口 */}
|
{/* 添加刷新按钮 */}
|
||||||
{/* <button className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700" title="管理助手">
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
|
||||||
<Settings2 size={18} />
|
// 重新加载助手列表
|
||||||
</button> */}
|
const loadAssistants = async () => { /* ... */ }; // 将加载逻辑提取出来
|
||||||
|
loadAssistants();
|
||||||
|
}} disabled={assistantsLoading}>
|
||||||
|
<RefreshCw size={16} className={assistantsLoading ? 'animate-spin' : ''}/>
|
||||||
|
</Button>
|
||||||
</h2>
|
</h2>
|
||||||
<button className="mb-4 w-full px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm flex items-center justify-center gap-2">
|
<Dialog open={isAssistantDialogOpen} onOpenChange={setIsAssistantDialogOpen}>
|
||||||
<UserPlus size={16} /> 添加助手
|
<DialogTrigger asChild>
|
||||||
</button>
|
<Button
|
||||||
|
variant="default" // 使用 shadcn Button
|
||||||
|
size="sm" // 调整大小
|
||||||
|
className="mb-4 w-full bg-red-500 hover:bg-red-600 text-white" // 样式调整
|
||||||
|
onClick={handleOpenCreateAssistantDialog} // 点击时重置编辑状态并打开
|
||||||
|
>
|
||||||
|
<UserPlus size={16} className="mr-2"/> 添加助手
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
{/* Dialog 内容 */}
|
||||||
|
<DialogContent className="sm:max-w-[600px]"> {/* 调整宽度 */}
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingAssistant ? '编辑助手' : '创建新助手'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingAssistant ? '修改助手的配置信息。' : '定义一个新助手的名称、行为和参数。'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* 渲染助手表单 */}
|
||||||
|
<AssistantForm
|
||||||
|
key={editingAssistant?.id || 'create'} // 添加 key 确保编辑时表单重置
|
||||||
|
assistant={editingAssistant}
|
||||||
|
onSave={handleSaveAssistant}
|
||||||
|
onClose={() => setIsAssistantDialogOpen(false)} // 传递关闭回调
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1"> {/* 添加右内边距防止滚动条遮挡 */}
|
<div className="flex-1 overflow-y-auto space-y-2 pr-1"> {/* 添加右内边距防止滚动条遮挡 */}
|
||||||
{/* 渲染助手列表 */}
|
{/* 渲染助手列表 */}
|
||||||
{assistants.map(assistant => (
|
{assistantsLoading ? (
|
||||||
<div
|
// 显示骨架屏
|
||||||
key={assistant.id}
|
Array.from({ length: 3 }).map((_, index) => (
|
||||||
onClick={() => handleSelectAssistant(assistant.id)}
|
<div key={index} className="p-3 rounded-lg flex items-center gap-3">
|
||||||
className={`p-3 rounded-lg cursor-pointer flex items-center gap-3 ${
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
currentAssistantId === assistant.id
|
<div className="flex-1 space-y-1">
|
||||||
? 'bg-red-100 dark:bg-red-900/50 ring-2 ring-red-300 dark:ring-red-700' // 添加选中高亮和边框
|
<Skeleton className="h-4 w-3/4" />
|
||||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
<Skeleton className="h-3 w-1/2" />
|
||||||
}`}
|
</div>
|
||||||
title={assistant.description}
|
|
||||||
>
|
|
||||||
<span className="text-xl">{assistant.avatar || '👤'}</span> {/* 显示头像 */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<p className={`text-sm font-medium truncate ${currentAssistantId === assistant.id ? 'text-red-800 dark:text-red-200' : 'text-gray-800 dark:text-gray-200'}`}>
|
|
||||||
{assistant.name}
|
|
||||||
</p>
|
|
||||||
<p className={`text-xs truncate ${currentAssistantId === assistant.id ? 'text-red-600 dark:text-red-400' : 'text-gray-500 dark:text-gray-400'}`}>
|
|
||||||
{assistant.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
))}
|
) : assistants.length === 0 ? (
|
||||||
|
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">没有找到助手。</p>
|
||||||
|
) : (
|
||||||
|
// 渲染助手列表
|
||||||
|
assistants.map(assistant => (
|
||||||
|
<div
|
||||||
|
key={assistant.id}
|
||||||
|
onClick={() => handleSelectAssistant(assistant.id)}
|
||||||
|
className={`group p-2 rounded-lg cursor-pointer flex items-center gap-3 relative ${
|
||||||
|
currentAssistantId === assistant.id
|
||||||
|
? 'bg-red-100 dark:bg-red-900/50 ring-1 ring-red-300 dark:ring-red-700'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
title={assistant.description || ''}
|
||||||
|
>
|
||||||
|
{/* ... 助手头像和名称 ... */}
|
||||||
|
<span className="text-lg flex-shrink-0 w-6 text-center">{assistant.avatar || '👤'}</span>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<p className={`text-sm font-medium truncate ${currentAssistantId === assistant.id ? 'text-red-800 dark:text-red-200' : 'text-gray-800 dark:text-gray-200'}`}>
|
||||||
|
{assistant.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 编辑和删除按钮 */}
|
||||||
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEditAssistant(assistant); }} // 阻止事件冒泡
|
||||||
|
title="编辑助手"
|
||||||
|
>
|
||||||
|
<Edit size={14} />
|
||||||
|
</Button>
|
||||||
|
{assistant.id !== 'asst-default' && ( // 不显示默认助手的删除按钮
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteAssistant(assistant.id); }}
|
||||||
|
title="删除助手"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -300,16 +631,19 @@ export default function ChatPage() {
|
|||||||
<div className="flex flex-col flex-1 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
<div className="flex flex-col flex-1 bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||||
{/* 聊天窗口标题 - 显示当前助手和切换会话按钮 */}
|
{/* 聊天窗口标题 - 显示当前助手和切换会话按钮 */}
|
||||||
<div className="flex justify-between items-center p-4 border-b dark:border-gray-700">
|
<div className="flex justify-between items-center p-4 border-b dark:border-gray-700">
|
||||||
<div className="flex items-center gap-2">
|
{currentAssistantId ? (
|
||||||
<span className="text-xl">{assistants.find(a => a.id === currentAssistantId)?.avatar || '👤'}</span>
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
<span className="text-xl">{assistants.find(a => a.id === currentAssistantId)?.avatar || '👤'}</span>
|
||||||
{assistants.find(a => a.id === currentAssistantId)?.name || '助手'}
|
<h1 className="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
</h1>
|
{assistants.find(a => a.id === currentAssistantId)?.name || '加载中...'}
|
||||||
{/* TODO: 显示模型、温度等信息 */}
|
<span className="text-sm font-normal text-gray-500 dark:text-gray-400 ml-2">
|
||||||
{/* <span className="text-xs bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">
|
({currentSessionId === 'temp-new-chat' ? '新话题' : allSessions.find(s => s.id === currentSessionId)?.title || (sessionsLoading ? '加载中...' : '选择话题')})
|
||||||
{assistants.find(a => a.id === currentAssistantId)?.model}
|
</span>
|
||||||
</span> */}
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-6 w-48" /> // 助手加载中显示骨架屏
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={toggleSessionPanel}
|
onClick={toggleSessionPanel}
|
||||||
className="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
className="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
@ -321,6 +655,11 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
{/* 消息显示区域 */}
|
{/* 消息显示区域 */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* 可以添加一个全局错误提示 */}
|
||||||
|
{error && <p className="text-center text-sm text-red-500 dark:text-red-400">{error}</p>}
|
||||||
|
{messages.length === 0 && !isLoading && !sessionsLoading && currentSessionId !== 'temp-new-chat' && (
|
||||||
|
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-8">选择一个话题开始聊天,或新建一个话题。</p>
|
||||||
|
)}
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
@ -359,18 +698,18 @@ export default function ChatPage() {
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={isLoading ? "AI 正在回复..." : "输入你的消息..."}
|
placeholder={isLoading ? "AI 正在回复..." : "输入你的消息..."}
|
||||||
disabled={isLoading}
|
disabled={isLoading || sessionsLoading || !currentAssistantId || !currentSessionId} // 添加禁用条件
|
||||||
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:focus:ring-red-600 disabled:opacity-70 transition-opacity"
|
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:focus:ring-red-600 disabled:opacity-70 transition-opacity"
|
||||||
aria-label="聊天输入框"
|
aria-label="聊天输入框"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!inputMessage.trim() || isLoading}
|
disabled={!inputMessage.trim() || isLoading || sessionsLoading || !currentAssistantId || !currentSessionId} // 添加禁用条件
|
||||||
className="p-2 rounded-lg bg-red-500 text-white hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center h-10 w-10"
|
className="p-2 rounded-lg bg-red-500 text-white hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center h-10 w-10"
|
||||||
aria-label={isLoading ? "正在发送" : "发送消息"}
|
aria-label={isLoading ? "正在发送" : "发送消息"}
|
||||||
>
|
>
|
||||||
{isLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : <SendHorizontal size={20} />}
|
{isLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : <SendHorizontal size={20} />}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -378,37 +717,47 @@ export default function ChatPage() {
|
|||||||
{/* 右侧会话管理面板 */}
|
{/* 右侧会话管理面板 */}
|
||||||
<aside className={`bg-white dark:bg-gray-800 rounded-lg shadow-md p-2 flex flex-col transition-all duration-300 ease-in-out ${isSessionPanelOpen ? 'w-64' : 'w-0 p-0 border-0 overflow-hidden opacity-0'}`}> {/* 调整关闭时的样式 */}
|
<aside className={`bg-white dark:bg-gray-800 rounded-lg shadow-md p-2 flex flex-col transition-all duration-300 ease-in-out ${isSessionPanelOpen ? 'w-64' : 'w-0 p-0 border-0 overflow-hidden opacity-0'}`}> {/* 调整关闭时的样式 */}
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200 whitespace-nowrap items-center justify-center">话题列表</h2> {/* 改为话题 */}
|
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-gray-200 whitespace-nowrap items-center justify-center">话题列表</h2> {/* 改为话题 */}
|
||||||
<button
|
<Button
|
||||||
onClick={handleNewTopic} // 绑定新建话题事件
|
onClick={handleNewTopic} // 绑定新建话题事件
|
||||||
className="mb-4 w-full px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm whitespace-nowrap flex items-center justify-center gap-2 disabled:opacity-50"
|
className="mb-4 w-full px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors text-sm whitespace-nowrap flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
disabled={currentSessionId === 'temp-new-chat'} // 如果已经是新话题则禁用
|
disabled={currentSessionId === 'temp-new-chat' || sessionsLoading || !currentAssistantId} // 添加禁用条件
|
||||||
>
|
>
|
||||||
+ 新建话题
|
+ 新建话题
|
||||||
</button>
|
</Button>
|
||||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
<div className="flex-1 overflow-y-auto space-y-4 pr-1">
|
||||||
{currentAssistantSessions
|
{sessionsLoading ? (
|
||||||
.filter(s => !s.isTemporary) // 不显示临时的
|
// 会话加载骨架屏
|
||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) // 按时间倒序
|
Array.from({ length: 5 }).map((_, index) => (
|
||||||
.map(session => (
|
<Skeleton key={index} className="h-8 w-full my-1.5 rounded-lg" />
|
||||||
<div
|
))
|
||||||
key={session.id}
|
) : currentAssistantSessions.length === 0 && currentSessionId !== 'temp-new-chat' ? (
|
||||||
onClick={() => handleSelectSession(session.id)} // 点击切换会话
|
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">没有历史话题。</p>
|
||||||
className={`p-2 rounded-lg cursor-pointer text-sm truncate whitespace-nowrap ${
|
) : (
|
||||||
currentSessionId === session.id
|
<>
|
||||||
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-400 font-medium' // 高亮当前会话
|
{/* 渲染会话列表 */}
|
||||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
{currentAssistantSessions.map(session => (
|
||||||
}`}
|
<div
|
||||||
title={session.title}
|
key={session.id}
|
||||||
>
|
onClick={() => handleSelectSession(session.id)}
|
||||||
{session.title}
|
className={`p-2 rounded-lg cursor-pointer text-sm truncate whitespace-nowrap ${
|
||||||
</div>
|
currentSessionId === session.id
|
||||||
))}
|
? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-400 font-medium'
|
||||||
{/* 如果当前是临时会话,显示一个占位符 */}
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
{currentSessionId === 'temp-new-chat' && (
|
}`}
|
||||||
<div className="p-2 rounded-lg text-sm truncate whitespace-nowrap bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-400 font-medium">
|
title={session.title}
|
||||||
新话题...
|
>
|
||||||
</div>
|
{session.title}
|
||||||
)}
|
{/* TODO: 添加删除会话按钮 */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* 新话题占位符 */}
|
||||||
|
{currentSessionId === 'temp-new-chat' && (
|
||||||
|
<div className="p-2 rounded-lg text-sm truncate whitespace-nowrap bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-400 font-medium">
|
||||||
|
新话题...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
13
frontend/components/ui/skeleton.tsx
Normal file
13
frontend/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@ -1,14 +1,51 @@
|
|||||||
// File: frontend/lib/api.ts (新建或修改)
|
// File: frontend/lib/api.ts (更新)
|
||||||
// Description: 用于调用后端 API 的工具函数
|
// Description: 添加调用助手和会话管理 API 的函数
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
// 从环境变量读取后端 API 地址,如果没有则使用默认值
|
// --- Types (从后端模型同步或手动定义) ---
|
||||||
// 确保在 .env.local 文件中定义 NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
// 这些类型应该与后端 pydantic_models.py 中的 Read 模型匹配
|
||||||
|
export interface Assistant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
system_prompt: string;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
assistant_id: string;
|
||||||
|
created_at: string; // ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建助手时发送的数据类型
|
||||||
|
export interface AssistantCreateData {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
system_prompt: string;
|
||||||
|
model: string;
|
||||||
|
temperature: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新助手时发送的数据类型 (所有字段可选)
|
||||||
|
export type AssistantUpdateData = Partial<AssistantCreateData>;
|
||||||
|
|
||||||
|
// 聊天响应类型
|
||||||
|
export interface ChatApiResponse {
|
||||||
|
reply: string;
|
||||||
|
session_id?: string | null; // 后端返回的新 session id
|
||||||
|
session_title?: string | null; // 后端返回的新 session title
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Client Setup ---
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
||||||
|
|
||||||
// 创建 axios 实例,可以设置一些全局配置
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
@ -16,36 +53,120 @@ const apiClient = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Helper for Error Handling ---
|
||||||
|
const handleApiError = (error: unknown, context: string): string => {
|
||||||
|
console.error(`API Error (${context}):`, error);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
// 尝试提取后端返回的详细错误信息
|
||||||
|
return (
|
||||||
|
error.response.data?.detail || `服务器错误 (${error.response.status})`
|
||||||
|
);
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return "发生未知网络错误";
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Chat API ---
|
||||||
/**
|
/**
|
||||||
* 发送聊天消息到后端
|
* 发送聊天消息到后端 (更新)
|
||||||
* @param message 用户输入的消息文本
|
* @param message 用户消息
|
||||||
* @returns AI 的回复文本
|
* @param sessionId 当前会话 ID (可以是 'temp-new-chat')
|
||||||
* @throws 如果 API 请求失败则抛出错误
|
* @param assistantId 当前助手 ID
|
||||||
|
* @returns 包含 AI 回复和可能的新会话信息的对象
|
||||||
*/
|
*/
|
||||||
export const sendChatMessage = async (message: string): Promise<string> => {
|
export const sendChatMessage = async (
|
||||||
|
message: string,
|
||||||
|
sessionId: string,
|
||||||
|
assistantId: string
|
||||||
|
): Promise<ChatApiResponse> => {
|
||||||
try {
|
try {
|
||||||
// 发送 POST 请求到后端的 /chat/ 端点
|
const response = await apiClient.post<ChatApiResponse>("/chat/", {
|
||||||
const response = await apiClient.post("/chat/", { message });
|
message,
|
||||||
// 检查响应数据和 reply 字段是否存在
|
session_id: sessionId,
|
||||||
if (response.data && response.data.reply) {
|
assistant_id: assistantId,
|
||||||
return response.data.reply; // 返回 AI 的回复
|
});
|
||||||
} else {
|
return response.data; // 返回整个响应体
|
||||||
// 如果响应格式不符合预期,抛出错误
|
|
||||||
throw new Error("Invalid response format from server");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error calling chat API:", error); // 在控制台打印详细错误
|
throw new Error(handleApiError(error, "sendChatMessage"));
|
||||||
// 检查是否是 Axios 错误并且有响应体
|
|
||||||
if (axios.isAxiosError(error) && error.response) {
|
|
||||||
// 尝试从响应体中获取错误详情,否则提供通用消息
|
|
||||||
throw new Error(
|
|
||||||
error.response.data?.detail || "Failed to communicate with server"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 如果不是 Axios 错误或没有响应体,抛出通用错误
|
|
||||||
throw new Error("Failed to send message. Please try again.");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- 你可以在这里添加调用其他后端 API 的函数 ---
|
// --- Assistant API ---
|
||||||
// export const getWorkflows = async () => { ... };
|
/** 获取所有助手列表 */
|
||||||
|
export const getAssistants = async (): Promise<Assistant[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<Assistant[]>("/assistants/");
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(handleApiError(error, "getAssistants"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 创建新助手 */
|
||||||
|
export const createAssistant = async (
|
||||||
|
data: AssistantCreateData
|
||||||
|
): Promise<Assistant> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<Assistant>("/assistants/", data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(handleApiError(error, "createAssistant"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 更新助手 */
|
||||||
|
export const updateAssistant = async (
|
||||||
|
id: string,
|
||||||
|
data: AssistantUpdateData
|
||||||
|
): Promise<Assistant> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put<Assistant>(`/assistants/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(handleApiError(error, "updateAssistant"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 删除助手 */
|
||||||
|
export const deleteAssistant = async (id: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/assistants/${id}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(handleApiError(error, "deleteAssistant"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Session API ---
|
||||||
|
/** 获取指定助手的所有会话 */
|
||||||
|
export const getSessionsByAssistant = async (
|
||||||
|
assistantId: string
|
||||||
|
): Promise<Session[]> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<Session[]>(
|
||||||
|
`/sessions/assistant/${assistantId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// 如果助手没有会话,后端可能返回 404 或空列表,这里统一处理为返回空列表
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw new Error(handleApiError(error, "getSessionsByAssistant"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 删除会话 */
|
||||||
|
export const deleteSession = async (sessionId: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/sessions/${sessionId}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(handleApiError(error, "deleteSession"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注意:创建会话的 API (POST /sessions/) 在后端被整合到了 POST /chat/ 逻辑中,
|
||||||
|
//当前端发送 sessionId 为 'temp-new-chat' 的消息时,后端会自动创建。
|
||||||
|
//如果需要单独创建会话(例如,不发送消息就创建),则需要单独实现前端调用 POST /sessions/。
|
||||||
|
|
||||||
|
// TODO: 添加获取会话消息的 API 函数 (GET /sessions/{session_id}/messages)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user