From 21f9839dd620ed41e77be2ec1a3cf76331339c7d Mon Sep 17 00:00:00 2001 From: chengkai3 Date: Sat, 20 Jun 2026 23:20:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20[FL-166]=20=E5=AE=9E=E7=8E=B0AI?= =?UTF-8?q?=E9=97=AE=E7=AD=94=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端实现: - 添加 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 Co-authored-by: multica-agent --- api/app/api/router.py | 2 + api/app/api/v1/ai_chat.py | 99 +++++++ api/app/models/__init__.py | 3 +- api/app/models/ai_chat.py | 59 ++++ api/app/schemas/ai_chat.py | 57 ++++ api/app/services/ai_chat_service.py | 228 +++++++++++++++ api/migrations/002_add_ai_chat.sql | 32 +++ api/migrations/003_add_ai_chat_params.sql | 10 + web/src/app/admin/ai-chat/page.tsx | 335 ++++++++++++++++++++++ web/src/types/ai-chat.ts | 27 ++ 10 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 api/app/api/v1/ai_chat.py create mode 100644 api/app/models/ai_chat.py create mode 100644 api/app/schemas/ai_chat.py create mode 100644 api/app/services/ai_chat_service.py create mode 100644 api/migrations/002_add_ai_chat.sql create mode 100644 api/migrations/003_add_ai_chat_params.sql create mode 100644 web/src/app/admin/ai-chat/page.tsx create mode 100644 web/src/types/ai-chat.ts diff --git a/api/app/api/router.py b/api/app/api/router.py index f3ca111..0c107bb 100644 --- a/api/app/api/router.py +++ b/api/app/api/router.py @@ -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) diff --git a/api/app/api/v1/ai_chat.py b/api/app/api/v1/ai_chat.py new file mode 100644 index 0000000..7367d57 --- /dev/null +++ b/api/app/api/v1/ai_chat.py @@ -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) diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index f1d8db4..52842f0 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -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", diff --git a/api/app/models/ai_chat.py b/api/app/models/ai_chat.py new file mode 100644 index 0000000..879a4ed --- /dev/null +++ b/api/app/models/ai_chat.py @@ -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", + ) diff --git a/api/app/schemas/ai_chat.py b/api/app/schemas/ai_chat.py new file mode 100644 index 0000000..89717c0 --- /dev/null +++ b/api/app/schemas/ai_chat.py @@ -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 diff --git a/api/app/services/ai_chat_service.py b/api/app/services/ai_chat_service.py new file mode 100644 index 0000000..134a8b8 --- /dev/null +++ b/api/app/services/ai_chat_service.py @@ -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"] diff --git a/api/migrations/002_add_ai_chat.sql b/api/migrations/002_add_ai_chat.sql new file mode 100644 index 0000000..7e59841 --- /dev/null +++ b/api/migrations/002_add_ai_chat.sql @@ -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 '消息内容'; diff --git a/api/migrations/003_add_ai_chat_params.sql b/api/migrations/003_add_ai_chat_params.sql new file mode 100644 index 0000000..ad6d2b3 --- /dev/null +++ b/api/migrations/003_add_ai_chat_params.sql @@ -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 '系统参数配置表'; diff --git a/web/src/app/admin/ai-chat/page.tsx b/web/src/app/admin/ai-chat/page.tsx new file mode 100644 index 0000000..452943b --- /dev/null +++ b/web/src/app/admin/ai-chat/page.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Button, + Card, + Empty, + Input, + List, + Modal, + Space, + Spin, + Typography, + message, +} from "antd"; +import { + PlusOutlined, + SendOutlined, + DeleteOutlined, + EditOutlined, + RobotOutlined, +} from "@ant-design/icons"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useAuth } from "@/components/auth-provider"; +import { readApiError, API_BASE_URL } from "@/lib/api"; +import type { + AiChatConversation, + AiChatConversationListResponse, + AiChatMessage, + AiChatMessageResponse, +} from "@/types/ai-chat"; + +const { TextArea } = Input; +const { Title, Text } = Typography; + +export default function AiChatPage() { + const { fetchWithAuth } = useAuth(); + const queryClient = useQueryClient(); + + const [selectedConvId, setSelectedConvId] = useState(null); + const [messageInput, setMessageInput] = useState(""); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [newConvTitle, setNewConvTitle] = useState("新对话"); + const messagesEndRef = useRef(null); + + const { data: conversations, isLoading: convLoading } = useQuery({ + queryKey: ["ai-chat-conversations"], + queryFn: async () => { + const response = await fetchWithAuth( + `${API_BASE_URL}/api/v1/ai-chat/conversations?limit=100` + ); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as AiChatConversationListResponse; + }, + }); + + const { data: currentConv, isLoading: convDetailLoading } = useQuery({ + queryKey: ["ai-chat-conversation", selectedConvId], + queryFn: async () => { + if (!selectedConvId) return null; + const response = await fetchWithAuth( + `${API_BASE_URL}/api/v1/ai-chat/conversations/${selectedConvId}` + ); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as AiChatConversation; + }, + enabled: !!selectedConvId, + }); + + const createConvMutation = useMutation({ + mutationFn: async (title: string) => { + const response = await fetchWithAuth( + `${API_BASE_URL}/api/v1/ai-chat/conversations`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + } + ); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as AiChatConversation; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ["ai-chat-conversations"] }); + setSelectedConvId(data.id); + setCreateModalOpen(false); + setNewConvTitle("新对话"); + message.success("创建对话成功"); + }, + onError: (error: Error) => { + message.error(`创建对话失败: ${error.message}`); + }, + }); + + const deleteConvMutation = useMutation({ + mutationFn: async (id: number) => { + const response = await fetchWithAuth( + `${API_BASE_URL}/api/v1/ai-chat/conversations/${id}`, + { method: "DELETE" } + ); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["ai-chat-conversations"] }); + setSelectedConvId(null); + message.success("删除对话成功"); + }, + onError: (error: Error) => { + message.error(`删除对话失败: ${error.message}`); + }, + }); + + const sendMessageMutation = useMutation({ + mutationFn: async ({ + convId, + content, + }: { + convId: number; + content: string; + }) => { + const response = await fetchWithAuth( + `${API_BASE_URL}/api/v1/ai-chat/conversations/${convId}/messages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + } + ); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as AiChatMessageResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["ai-chat-conversation", selectedConvId], + }); + setMessageInput(""); + }, + onError: (error: Error) => { + message.error(`发送消息失败: ${error.message}`); + }, + }); + + const handleSendMessage = useCallback(() => { + if (!selectedConvId || !messageInput.trim()) return; + sendMessageMutation.mutate({ + convId: selectedConvId, + content: messageInput.trim(), + }); + }, [selectedConvId, messageInput, sendMessageMutation]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [currentConv?.messages]); + + return ( +
+
+ + <RobotOutlined /> AI 问答 + +
+ +
+
+
+ +
+ +
+ {convLoading ? ( + + ) : ( + ( + setSelectedConvId(conv.id)} + style={{ + cursor: "pointer", + background: + selectedConvId === conv.id ? "#e6f7ff" : "transparent", + padding: "8px", + borderRadius: "4px", + marginBottom: "8px", + }} + > + +
+
+ +
+ {!selectedConvId ? ( + + ) : ( + <> +
+ {convDetailLoading ? ( + + ) : ( + + {currentConv?.messages?.map((msg) => ( + + + {msg.role === "user" ? "我" : "AI"} + +
+ {msg.content} +
+
+ ))} +
+ + )} +
+ +
+ +