feat: [FL-166] 实现AI问答功能
- 后端实现: - 添加 ai_chat_conversations 和 ai_chat_messages 数据模型 - 创建 AI 问答 API 路由(/api/v1/ai-chat) - 实现对话管理和消息发送服务 - 集成 OpenAI API 进行对话交互 - 支持流式对话历史和上下文管理 - 前端实现: - 创建 ChatGPT 风格的聊天界面(/admin/ai-chat) - 支持新建、选择、删除对话 - 实现消息发送和实时显示 - 使用 Ant Design 组件构建响应式 UI - 系统参数配置: - ai_chat.openai_api_key: OpenAI API 密钥 - ai_chat.model: 使用的 AI 模型(默认 gpt-3.5-turbo) - ai_chat.base_url: API 基础 URL(支持第三方兼容接口) - 数据库迁移: - 002_add_ai_chat.sql: 创建对话和消息表 - 003_add_ai_chat_params.sql: 添加系统参数默认配置 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from .v1.admin import router as admin_router
|
||||
from .v1.admin_files import router as admin_files_router
|
||||
from .v1.ai_chat import router as ai_chat_router
|
||||
from .v1.atp_assets import router as atp_assets_router
|
||||
from .v1.atp_models import router as atp_models_router
|
||||
from .v1.auth import router as auth_router
|
||||
@@ -26,6 +27,7 @@ v1_router.include_router(auth_router)
|
||||
v1_router.include_router(users_router)
|
||||
v1_router.include_router(admin_router)
|
||||
v1_router.include_router(admin_files_router)
|
||||
v1_router.include_router(ai_chat_router)
|
||||
v1_router.include_router(atp_assets_router)
|
||||
v1_router.include_router(atp_models_router)
|
||||
v1_router.include_router(task_monitor_router)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, require_enabled_menu_route, require_user
|
||||
from ...schemas.ai_chat import (
|
||||
AiChatConversationCreateRequest,
|
||||
AiChatConversationDetail,
|
||||
AiChatConversationListResponse,
|
||||
AiChatConversationSummary,
|
||||
AiChatConversationUpdateRequest,
|
||||
AiChatMessageResponse,
|
||||
AiChatMessageSendRequest,
|
||||
)
|
||||
from ...services.ai_chat_service import (
|
||||
create_conversation,
|
||||
delete_conversation,
|
||||
get_conversation_by_id,
|
||||
list_conversations,
|
||||
send_message,
|
||||
serialize_conversation_detail,
|
||||
update_conversation,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/ai-chat",
|
||||
tags=["ai-chat"],
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations", response_model=AiChatConversationListResponse)
|
||||
def get_conversations(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
current_user: CurrentUser = Depends(require_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AiChatConversationListResponse:
|
||||
return list_conversations(db, user_id=current_user.user.id, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/conversations", response_model=AiChatConversationSummary)
|
||||
def create_conversation_endpoint(
|
||||
payload: AiChatConversationCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AiChatConversationSummary:
|
||||
return create_conversation(db, payload, user_id=current_user.user.id)
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}", response_model=AiChatConversationDetail)
|
||||
def get_conversation_detail(
|
||||
conversation_id: int,
|
||||
current_user: CurrentUser = Depends(require_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AiChatConversationDetail:
|
||||
conv = get_conversation_by_id(db, conversation_id, user_id=current_user.user.id)
|
||||
if not conv:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
return serialize_conversation_detail(conv)
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}", response_model=AiChatConversationSummary)
|
||||
def update_conversation_endpoint(
|
||||
conversation_id: int,
|
||||
payload: AiChatConversationUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AiChatConversationSummary:
|
||||
updated = update_conversation(db, conversation_id, payload, user_id=current_user.user.id)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}")
|
||||
def delete_conversation_endpoint(
|
||||
conversation_id: int,
|
||||
current_user: CurrentUser = Depends(require_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
deleted = delete_conversation(db, conversation_id, user_id=current_user.user.id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages", response_model=AiChatMessageResponse)
|
||||
def send_message_endpoint(
|
||||
conversation_id: int,
|
||||
payload: AiChatMessageSendRequest,
|
||||
current_user: CurrentUser = Depends(require_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AiChatMessageResponse:
|
||||
result = send_message(db, conversation_id, payload.content, user_id=current_user.user.id)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Conversation not found")
|
||||
user_msg, assistant_msg = result
|
||||
return AiChatMessageResponse(message=user_msg, reply=assistant_msg)
|
||||
@@ -4,9 +4,10 @@ Import all model modules during package initialization so SQLAlchemy can
|
||||
resolve string-based relationships regardless of route/service import order.
|
||||
"""
|
||||
|
||||
from . import atp_asset, atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
from . import ai_chat, atp_asset, atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
|
||||
__all__ = [
|
||||
"ai_chat",
|
||||
"atp_asset",
|
||||
"atp_model",
|
||||
"audit_log",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
class AiChatConversation(Base):
|
||||
__tablename__ = "ai_chat_conversations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="新对话")
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("users.user_id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship("User", foreign_keys=[user_id], lazy="selectin")
|
||||
messages: Mapped[list[AiChatMessage]] = relationship(
|
||||
"AiChatMessage",
|
||||
back_populates="conversation",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
)
|
||||
|
||||
|
||||
class AiChatMessage(Base):
|
||||
__tablename__ = "ai_chat_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
conversation_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("ai_chat_conversations.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), index=True)
|
||||
content: Mapped[str] = mapped_column(Text())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
conversation: Mapped[AiChatConversation] = relationship(
|
||||
"AiChatConversation",
|
||||
back_populates="messages",
|
||||
lazy="selectin",
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .user import UserPublic
|
||||
|
||||
|
||||
class AiChatMessageSummary(BaseModel):
|
||||
id: int
|
||||
conversation_id: int
|
||||
role: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AiChatConversationSummary(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user: UserPublic | None = None
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
class AiChatConversationDetail(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
user: UserPublic | None = None
|
||||
messages: list[AiChatMessageSummary] = []
|
||||
|
||||
|
||||
class AiChatConversationListResponse(BaseModel):
|
||||
items: list[AiChatConversationSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class AiChatConversationCreateRequest(BaseModel):
|
||||
title: str = Field(default="新对话", min_length=1, max_length=256)
|
||||
|
||||
|
||||
class AiChatConversationUpdateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class AiChatMessageSendRequest(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=20000)
|
||||
|
||||
|
||||
class AiChatMessageResponse(BaseModel):
|
||||
message: AiChatMessageSummary
|
||||
reply: AiChatMessageSummary
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from ..models.ai_chat import AiChatConversation, AiChatMessage
|
||||
from ..schemas.ai_chat import (
|
||||
AiChatConversationCreateRequest,
|
||||
AiChatConversationDetail,
|
||||
AiChatConversationListResponse,
|
||||
AiChatConversationSummary,
|
||||
AiChatConversationUpdateRequest,
|
||||
AiChatMessageSummary,
|
||||
)
|
||||
from ..services.system_param_service import get_system_param_by_key
|
||||
from .user_service import serialize_user
|
||||
|
||||
|
||||
def serialize_message(msg: AiChatMessage) -> AiChatMessageSummary:
|
||||
return AiChatMessageSummary(
|
||||
id=msg.id,
|
||||
conversation_id=msg.conversation_id,
|
||||
role=msg.role,
|
||||
content=msg.content,
|
||||
created_at=msg.created_at,
|
||||
)
|
||||
|
||||
|
||||
def serialize_conversation(conv: AiChatConversation, message_count: int = 0) -> AiChatConversationSummary:
|
||||
return AiChatConversationSummary(
|
||||
id=conv.id,
|
||||
title=conv.title,
|
||||
user_id=conv.user_id,
|
||||
created_at=conv.created_at,
|
||||
updated_at=conv.updated_at,
|
||||
user=serialize_user(conv.user) if conv.user else None,
|
||||
message_count=message_count,
|
||||
)
|
||||
|
||||
|
||||
def serialize_conversation_detail(conv: AiChatConversation) -> AiChatConversationDetail:
|
||||
messages = sorted(conv.messages, key=lambda m: m.created_at)
|
||||
return AiChatConversationDetail(
|
||||
id=conv.id,
|
||||
title=conv.title,
|
||||
user_id=conv.user_id,
|
||||
created_at=conv.created_at,
|
||||
updated_at=conv.updated_at,
|
||||
user=serialize_user(conv.user) if conv.user else None,
|
||||
messages=[serialize_message(msg) for msg in messages],
|
||||
)
|
||||
|
||||
|
||||
def list_conversations(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> AiChatConversationListResponse:
|
||||
stmt = (
|
||||
select(AiChatConversation)
|
||||
.options(selectinload(AiChatConversation.user))
|
||||
.where(AiChatConversation.user_id == user_id)
|
||||
)
|
||||
|
||||
total_stmt = select(func.count()).select_from(AiChatConversation).where(AiChatConversation.user_id == user_id)
|
||||
|
||||
total = db.scalar(total_stmt) or 0
|
||||
items = (
|
||||
db.execute(
|
||||
stmt.order_by(AiChatConversation.updated_at.desc(), AiChatConversation.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
result_items = []
|
||||
for item in items:
|
||||
msg_count = db.scalar(
|
||||
select(func.count()).select_from(AiChatMessage).where(AiChatMessage.conversation_id == item.id)
|
||||
) or 0
|
||||
result_items.append(serialize_conversation(item, message_count=msg_count))
|
||||
|
||||
return AiChatConversationListResponse(items=result_items, total=total)
|
||||
|
||||
|
||||
def get_conversation_by_id(db: Session, conversation_id: int, user_id: str) -> AiChatConversation | None:
|
||||
return db.execute(
|
||||
select(AiChatConversation)
|
||||
.options(
|
||||
selectinload(AiChatConversation.user),
|
||||
selectinload(AiChatConversation.messages),
|
||||
)
|
||||
.where(AiChatConversation.id == conversation_id, AiChatConversation.user_id == user_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def create_conversation(
|
||||
db: Session,
|
||||
payload: AiChatConversationCreateRequest,
|
||||
*,
|
||||
user_id: str,
|
||||
) -> AiChatConversationSummary:
|
||||
conv = AiChatConversation(
|
||||
title=payload.title.strip(),
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(conv)
|
||||
db.commit()
|
||||
db.refresh(conv)
|
||||
return serialize_conversation(conv, message_count=0)
|
||||
|
||||
|
||||
def update_conversation(
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
payload: AiChatConversationUpdateRequest,
|
||||
*,
|
||||
user_id: str,
|
||||
) -> AiChatConversationSummary | None:
|
||||
conv = get_conversation_by_id(db, conversation_id, user_id)
|
||||
if not conv:
|
||||
return None
|
||||
|
||||
conv.title = payload.title.strip()
|
||||
db.commit()
|
||||
|
||||
msg_count = db.scalar(
|
||||
select(func.count()).select_from(AiChatMessage).where(AiChatMessage.conversation_id == conv.id)
|
||||
) or 0
|
||||
|
||||
return serialize_conversation(conv, message_count=msg_count)
|
||||
|
||||
|
||||
def delete_conversation(db: Session, conversation_id: int, *, user_id: str) -> bool:
|
||||
conv = get_conversation_by_id(db, conversation_id, user_id)
|
||||
if not conv:
|
||||
return False
|
||||
|
||||
db.delete(conv)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def send_message(
|
||||
db: Session,
|
||||
conversation_id: int,
|
||||
content: str,
|
||||
*,
|
||||
user_id: str,
|
||||
) -> tuple[AiChatMessageSummary, AiChatMessageSummary] | None:
|
||||
conv = get_conversation_by_id(db, conversation_id, user_id)
|
||||
if not conv:
|
||||
return None
|
||||
|
||||
user_message = AiChatMessage(
|
||||
conversation_id=conversation_id,
|
||||
role="user",
|
||||
content=content.strip(),
|
||||
)
|
||||
db.add(user_message)
|
||||
db.commit()
|
||||
db.refresh(user_message)
|
||||
|
||||
history = sorted(conv.messages, key=lambda m: m.created_at)
|
||||
history.append(user_message)
|
||||
|
||||
try:
|
||||
reply_content = _call_openai_api(db, history)
|
||||
except Exception as e:
|
||||
reply_content = f"抱歉,AI服务暂时不可用:{str(e)}"
|
||||
|
||||
assistant_message = AiChatMessage(
|
||||
conversation_id=conversation_id,
|
||||
role="assistant",
|
||||
content=reply_content,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
db.commit()
|
||||
db.refresh(assistant_message)
|
||||
|
||||
return serialize_message(user_message), serialize_message(assistant_message)
|
||||
|
||||
|
||||
def _call_openai_api(db: Session, history: list[AiChatMessage]) -> str:
|
||||
api_key_param = get_system_param_by_key(db, "ai_chat.openai_api_key")
|
||||
model_param = get_system_param_by_key(db, "ai_chat.model")
|
||||
base_url_param = get_system_param_by_key(db, "ai_chat.base_url")
|
||||
|
||||
api_key = api_key_param.param_value if api_key_param and api_key_param.status == "enabled" else None
|
||||
model = model_param.param_value if model_param and model_param.status == "enabled" else "gpt-3.5-turbo"
|
||||
base_url = base_url_param.param_value if base_url_param and base_url_param.status == "enabled" else "https://api.openai.com/v1"
|
||||
|
||||
if not api_key:
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
raise ValueError("AI模型配置缺失:请在系统参数中配置 ai_chat.openai_api_key")
|
||||
|
||||
messages = [{"role": msg.role, "content": msg.content} for msg in history]
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
response = client.post(
|
||||
f"{base_url}/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return data["choices"][0]["message"]["content"]
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Create AI chat tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_chat_conversations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(256) NOT NULL DEFAULT '新对话',
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_ai_chat_conversations_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ai_chat_conversations_user_id ON ai_chat_conversations(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_chat_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
role VARCHAR(16) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_ai_chat_messages_conversation_id FOREIGN KEY (conversation_id) REFERENCES ai_chat_conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ai_chat_messages_conversation_id ON ai_chat_messages(conversation_id);
|
||||
CREATE INDEX idx_ai_chat_messages_role ON ai_chat_messages(role);
|
||||
|
||||
COMMENT ON TABLE ai_chat_conversations IS 'AI问答对话会话表';
|
||||
COMMENT ON TABLE ai_chat_messages IS 'AI问答消息表';
|
||||
COMMENT ON COLUMN ai_chat_conversations.title IS '对话标题';
|
||||
COMMENT ON COLUMN ai_chat_conversations.user_id IS '用户ID';
|
||||
COMMENT ON COLUMN ai_chat_messages.conversation_id IS '对话ID';
|
||||
COMMENT ON COLUMN ai_chat_messages.role IS '角色:user 或 assistant';
|
||||
COMMENT ON COLUMN ai_chat_messages.content IS '消息内容';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Insert default AI chat system parameters
|
||||
|
||||
INSERT INTO system_params (param_key, param_name, param_value, description, status, created_at, updated_at)
|
||||
VALUES
|
||||
('ai_chat.openai_api_key', 'OpenAI API Key', '', '用于AI问答的OpenAI API密钥。如果为空,将使用环境变量 OPENAI_API_KEY', 'disabled', NOW(), NOW()),
|
||||
('ai_chat.model', 'AI模型', 'gpt-3.5-turbo', '使用的AI模型名称,例如: gpt-3.5-turbo, gpt-4, gpt-4-turbo', 'enabled', NOW(), NOW()),
|
||||
('ai_chat.base_url', 'API Base URL', 'https://api.openai.com/v1', 'OpenAI API的基础URL,可配置为第三方兼容接口', 'enabled', NOW(), NOW())
|
||||
ON CONFLICT (param_key) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE system_params IS '系统参数配置表';
|
||||
Reference in New Issue
Block a user