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
+2
View File
@@ -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)
+99
View File
@@ -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)
+2 -1
View File
@@ -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",
+59
View File
@@ -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",
)
+57
View File
@@ -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
+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"]
+32
View File
@@ -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 '消息内容';
+10
View File
@@ -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 '系统参数配置表';
+335
View File
@@ -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<number | null>(null);
const [messageInput, setMessageInput] = useState("");
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newConvTitle, setNewConvTitle] = useState("新对话");
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<div style={{ padding: "16px", borderBottom: "1px solid #f0f0f0" }}>
<Title level={3} style={{ margin: 0 }}>
<RobotOutlined /> AI
</Title>
</div>
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
<div
style={{
width: "280px",
borderRight: "1px solid #f0f0f0",
display: "flex",
flexDirection: "column",
}}
>
<div style={{ padding: "16px" }}>
<Button
type="primary"
icon={<PlusOutlined />}
block
onClick={() => setCreateModalOpen(true)}
>
</Button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0 16px" }}>
{convLoading ? (
<Spin />
) : (
<List
dataSource={conversations?.items || []}
renderItem={(conv) => (
<List.Item
key={conv.id}
onClick={() => setSelectedConvId(conv.id)}
style={{
cursor: "pointer",
background:
selectedConvId === conv.id ? "#e6f7ff" : "transparent",
padding: "8px",
borderRadius: "4px",
marginBottom: "8px",
}}
>
<List.Item.Meta
title={conv.title}
description={`${conv.message_count || 0} 条消息`}
/>
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
deleteConvMutation.mutate(conv.id);
}}
/>
</List.Item>
)}
/>
)}
</div>
</div>
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
{!selectedConvId ? (
<Empty
description="请选择或创建一个对话"
style={{ marginTop: "100px" }}
/>
) : (
<>
<div
style={{
flex: 1,
overflow: "auto",
padding: "24px",
background: "#fafafa",
}}
>
{convDetailLoading ? (
<Spin />
) : (
<Space direction="vertical" style={{ width: "100%" }} size="large">
{currentConv?.messages?.map((msg) => (
<Card
key={msg.id}
style={{
maxWidth: "80%",
marginLeft: msg.role === "user" ? "auto" : 0,
background: msg.role === "user" ? "#1890ff" : "#fff",
color: msg.role === "user" ? "#fff" : "#000",
}}
>
<Text
strong
style={{
color: msg.role === "user" ? "#fff" : "#000",
}}
>
{msg.role === "user" ? "我" : "AI"}
</Text>
<div style={{ marginTop: "8px", whiteSpace: "pre-wrap" }}>
{msg.content}
</div>
</Card>
))}
<div ref={messagesEndRef} />
</Space>
)}
</div>
<div
style={{
padding: "16px",
borderTop: "1px solid #f0f0f0",
background: "#fff",
}}
>
<Space.Compact style={{ width: "100%" }}>
<TextArea
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
placeholder="输入消息..."
autoSize={{ minRows: 1, maxRows: 4 }}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<Button
type="primary"
icon={<SendOutlined />}
loading={sendMessageMutation.isPending}
onClick={handleSendMessage}
disabled={!messageInput.trim()}
>
</Button>
</Space.Compact>
</div>
</>
)}
</div>
</div>
<Modal
title="新建对话"
open={createModalOpen}
onOk={() => createConvMutation.mutate(newConvTitle)}
onCancel={() => {
setCreateModalOpen(false);
setNewConvTitle("新对话");
}}
confirmLoading={createConvMutation.isPending}
>
<Input
placeholder="对话标题"
value={newConvTitle}
onChange={(e) => setNewConvTitle(e.target.value)}
/>
</Modal>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
export interface AiChatMessage {
id: number;
conversation_id: number;
role: "user" | "assistant";
content: string;
created_at: string;
}
export interface AiChatConversation {
id: number;
title: string;
user_id: string;
created_at: string;
updated_at: string;
message_count?: number;
messages?: AiChatMessage[];
}
export interface AiChatConversationListResponse {
items: AiChatConversation[];
total: number;
}
export interface AiChatMessageResponse {
message: AiChatMessage;
reply: AiChatMessage;
}