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:
chengkai3
2026-06-20 23:20:17 +08:00
parent b1df2d0b40
commit 21f9839dd6
10 changed files with 851 additions and 1 deletions
+228
View File
@@ -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"]