diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index 80dc8ef..80f752d 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -3,7 +3,16 @@ import { ElMessage } from "element-plus"; import { onMounted, reactive, ref } from "vue"; import { api, clearToken, getToken, saveToken } from "./services/api"; -import type { AdminProfile, AdminUser, DashboardStats, KnowledgeItem, ModelItem } from "./types/api"; +import type { + AdminProfile, + AdminUser, + AiLogRecord, + ChatDetail, + ChatRecord, + DashboardStats, + KnowledgeItem, + ModelItem, +} from "./types/api"; const admin = ref(null); const activeMenu = ref("dashboard"); @@ -16,10 +25,13 @@ const userKeyword = ref(""); const knowledge = ref([]); const models = ref([]); const configs = ref[]>([]); -const chats = ref[]>([]); -const aiLogs = ref[]>([]); +const chats = ref([]); +const aiLogs = ref([]); const operationLogs = ref[]>([]); const promptContent = ref(""); +const chatDetail = ref(null); +const chatDetailOpen = ref(false); +const recordTab = ref("chats"); const knowledgeForm = reactive({ name: "", @@ -45,6 +57,13 @@ const configForm = reactive({ description: "默认每日聊天次数", }); +const chatFilters = reactive({ + keyword: "", + userId: undefined as number | undefined, + status: "", + dateRange: [] as string[], +}); + onMounted(async () => { if (!getToken()) return; try { @@ -89,7 +108,7 @@ async function loadCurrentMenu() { if (activeMenu.value === "models") models.value = await api.models(); if (activeMenu.value === "configs") configs.value = await api.configs(); if (activeMenu.value === "records") { - chats.value = await api.chats(); + chats.value = await api.chats(buildChatQuery()); aiLogs.value = await api.aiLogs(); operationLogs.value = await api.operationLogs(); } @@ -149,6 +168,34 @@ async function saveConfig() { ElMessage.success("配置已保存"); await loadCurrentMenu(); } + +async function searchChats() { + chats.value = await api.chats(buildChatQuery()); +} + +async function resetChatFilters() { + Object.assign(chatFilters, { keyword: "", userId: undefined, status: "", dateRange: [] }); + await searchChats(); +} + +async function openChatDetail(row: ChatRecord) { + chatDetail.value = await api.chatDetail(row.id); + chatDetailOpen.value = true; +} + +async function exportChats() { + await api.exportChats(buildChatQuery()); +} + +function buildChatQuery() { + return { + keyword: chatFilters.keyword, + userId: chatFilters.userId, + status: chatFilters.status, + dateFrom: chatFilters.dateRange?.[0], + dateTo: chatFilters.dateRange?.[1], + }; +} diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index cfba85d..7ac7cfe 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -1,4 +1,15 @@ -import type { AdminProfile, ApiResponse, DashboardStats, KnowledgeItem, ModelItem, AdminUser } from "../types/api"; +import type { + AdminProfile, + AdminUser, + AiLogRecord, + ApiResponse, + ChatDetail, + ChatRecord, + ChatRecordQuery, + DashboardStats, + KnowledgeItem, + ModelItem, +} from "../types/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "http://127.0.0.1:8100/api"; const TOKEN_KEY = "ai-kb-admin-token"; @@ -28,6 +39,30 @@ async function request(path: string, init: RequestInit = {}): Promise { return body.data; } +async function download(path: string, filename: string) { + const headers = new Headers(); + const token = getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + const response = await fetch(`${API_BASE}${path}`, { headers }); + if (!response.ok) throw new Error("导出失败"); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.URL.revokeObjectURL(url); +} + +function queryString(query: object) { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") params.set(key, String(value)); + }); + const text = params.toString(); + return text ? `?${text}` : ""; +} + export const api = { login: (username: string, password: string) => request<{ token: string; admin: AdminProfile }>("/admin/login", { @@ -56,7 +91,10 @@ export const api = { configs: () => request[]>("/admin/config"), saveConfig: (payload: Record) => request>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }), - chats: () => request[]>("/admin/chat/list"), - aiLogs: () => request[]>("/admin/ai-log/list"), + chats: (query: ChatRecordQuery = {}) => request(`/admin/chat/list${queryString(query)}`), + chatDetail: (sessionId: number) => request(`/admin/chat/${sessionId}`), + exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"), + aiLogs: (query: { sessionId?: number; userId?: number; status?: string } = {}) => + request(`/admin/ai-log/list${queryString(query)}`), operationLogs: () => request[]>("/admin/log/list"), }; diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css index 82b5551..9fc5a4b 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -188,3 +188,102 @@ textarea { .actions { margin-top: 12px; } + +.record-filter { + display: grid; + grid-template-columns: minmax(260px, 1.4fr) 140px 150px 360px 72px 72px 72px; + gap: 10px; + align-items: center; + margin-bottom: 14px; +} + +.chat-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 18px; +} + +.chat-summary div { + padding: 12px; + border: 1px solid #dfe8e5; + border-radius: 8px; + background: #f8fbfa; +} + +.chat-summary span { + display: block; + color: #6b7d77; + font-size: 12px; +} + +.chat-summary strong { + display: block; + margin-top: 6px; + overflow: hidden; + color: #1f2d2a; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detail-title { + margin: 18px 0 10px; + font-size: 16px; +} + +.conversation-list { + display: grid; + gap: 12px; +} + +.conversation-message { + padding: 12px; + border: 1px solid #dfe8e5; + border-radius: 8px; + background: #ffffff; +} + +.conversation-message.user { + border-color: #cde7df; + background: #f2faf7; +} + +.message-meta, +.message-extra { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + color: #6b7d77; + font-size: 12px; +} + +.message-meta strong { + color: #0f735d; +} + +.conversation-message pre, +.prompt-preview { + margin: 10px 0; + padding: 10px; + overflow-x: auto; + border-radius: 6px; + background: #f4f7f6; + color: #223631; + white-space: pre-wrap; + word-break: break-word; + font-family: "SFMono-Regular", Consolas, "PingFang SC", monospace; + line-height: 1.65; +} + +.ai-log-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 14px; + color: #40524b; + font-size: 13px; +} + +.error-text { + color: #b42318; +} diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index 55c3c94..cb2d58e 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -50,3 +50,60 @@ export interface ModelItem { timeoutSecond: number; enabled: number; } + +export interface ChatRecord { + id: number; + userId: number; + userPhone: string; + userName: string; + title: string; + messageCount: number; + lastMessageAt?: string | null; + updatedAt: string; +} + +export interface ChatMessageRecord { + id: number; + sessionId: number; + userId: number; + role: "user" | "assistant"; + content: string; + messageStatus: string; + tokenInput?: number | null; + tokenOutput?: number | null; + responseTimeMs?: number | null; + modelId?: number | null; + createdAt: string; +} + +export interface AiLogRecord { + id: number; + sessionId?: number | null; + messageId?: number | null; + userId?: number | null; + modelName?: string | null; + knowledgeIds?: string | null; + retrieveCount: number; + inputToken?: number | null; + outputToken?: number | null; + totalToken?: number | null; + costMs?: number | null; + status: string; + errorMessage?: string | null; + prompt?: string | null; + createdAt: string; +} + +export interface ChatDetail { + session: ChatRecord; + messages: ChatMessageRecord[]; + aiLogs: AiLogRecord[]; +} + +export interface ChatRecordQuery { + keyword?: string; + userId?: number | null; + status?: string; + dateFrom?: string; + dateTo?: string; +} diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py index 88551df..944ebfb 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py @@ -1,7 +1,12 @@ from __future__ import annotations +import csv +from datetime import datetime +from io import StringIO + from fastapi import APIRouter, Depends, Query -from sqlalchemy import select +from fastapi.responses import Response +from sqlalchemy import exists, or_, select from sqlalchemy.orm import Session from app.core.database import get_db @@ -10,33 +15,65 @@ from app.core.responses import api_success from app.models.admin import Admin from app.models.chat import ChatMessage, ChatSession from app.models.logs import AiRequestLog, OperationLog +from app.models.user import User router = APIRouter() @router.get("/chat/list") def chat_list( + keyword: str = Query(default=""), + userId: int | None = Query(default=None), + status: str = Query(default=""), + dateFrom: datetime | None = Query(default=None), + dateTo: datetime | None = Query(default=None), db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin), ) -> dict: - sessions = db.scalars( - select(ChatSession) - .where(ChatSession.is_deleted == 0) + rows = db.execute( + _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo) .order_by(ChatSession.updated_at.desc()) .limit(200) ).all() - return api_success( - [ - { - "id": item.id, - "userId": item.user_id, - "title": item.title, - "messageCount": item.message_count, - "lastMessageAt": item.last_message_at, - "updatedAt": item.updated_at, - } - for item in sessions - ] + return api_success([_chat_row_dict(session, user) for session, user in rows]) + + +@router.get("/chat/export") +def export_chats( + keyword: str = Query(default=""), + userId: int | None = Query(default=None), + status: str = Query(default=""), + dateFrom: datetime | None = Query(default=None), + dateTo: datetime | None = Query(default=None), + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> Response: + rows = db.execute( + _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo) + .order_by(ChatSession.updated_at.desc()) + .limit(1000) + ).all() + output = StringIO() + writer = csv.writer(output) + writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "标题", "消息数", "最后消息时间", "更新时间"]) + for session, user in rows: + writer.writerow( + [ + session.id, + session.user_id, + user.phone if user else "", + user.name if user else "", + session.title, + session.message_count, + session.last_message_at, + session.updated_at, + ] + ) + content = "\ufeff" + output.getvalue() + return Response( + content=content, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="chat_records.csv"'}, ) @@ -46,25 +83,33 @@ def chat_detail( db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin), ) -> dict: + session_row = db.execute( + select(ChatSession, User) + .join(User, User.id == ChatSession.user_id, isouter=True) + .where(ChatSession.id == session_id) + ).first() + if session_row is None: + from fastapi import HTTPException, status + + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在") + + session, user = session_row messages = db.scalars( select(ChatMessage) .where(ChatMessage.session_id == session_id) .order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc()) ).all() + ai_logs = db.scalars( + select(AiRequestLog) + .where(AiRequestLog.session_id == session_id) + .order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc()) + ).all() return api_success( - [ - { - "id": item.id, - "role": item.role, - "content": item.content, - "messageStatus": item.message_status, - "tokenInput": item.token_input, - "tokenOutput": item.token_output, - "responseTimeMs": item.response_time_ms, - "createdAt": item.created_at, - } - for item in messages - ] + { + "session": _chat_row_dict(session, user), + "messages": [_message_dict(item) for item in messages], + "aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs], + } ) @@ -95,24 +140,110 @@ def operation_logs( @router.get("/ai-log/list") -def ai_logs(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict: - logs = db.scalars(select(AiRequestLog).order_by(AiRequestLog.created_at.desc()).limit(200)).all() - return api_success( - [ - { - "id": item.id, - "sessionId": item.session_id, - "messageId": item.message_id, - "userId": item.user_id, - "modelName": item.model_name, - "knowledgeIds": item.knowledge_ids, - "retrieveCount": item.retrieve_count, - "totalToken": item.total_token, - "costMs": item.cost_ms, - "status": item.status, - "errorMessage": item.error_message, - "createdAt": item.created_at, - } - for item in logs - ] +def ai_logs( + sessionId: int | None = Query(default=None), + userId: int | None = Query(default=None), + status: str = Query(default=""), + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + query = select(AiRequestLog).order_by(AiRequestLog.created_at.desc()).limit(200) + if sessionId is not None: + query = query.where(AiRequestLog.session_id == sessionId) + if userId is not None: + query = query.where(AiRequestLog.user_id == userId) + if status: + query = query.where(AiRequestLog.status == status) + logs = db.scalars(query).all() + return api_success([_ai_log_dict(item) for item in logs]) + + +def _chat_query( + *, + keyword: str, + user_id: int | None, + status: str, + date_from: datetime | None, + date_to: datetime | None, +): + query = ( + select(ChatSession, User) + .join(User, User.id == ChatSession.user_id, isouter=True) + .where(ChatSession.is_deleted == 0) ) + if user_id is not None: + query = query.where(ChatSession.user_id == user_id) + if date_from is not None: + query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None)) + if date_to is not None: + query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None)) + if status: + query = query.where( + exists() + .where(ChatMessage.session_id == ChatSession.id) + .where(ChatMessage.message_status == status) + ) + if keyword: + like = f"%{keyword}%" + query = query.where( + or_( + ChatSession.title.like(like), + User.phone.like(like), + User.name.like(like), + exists() + .where(ChatMessage.session_id == ChatSession.id) + .where(ChatMessage.content.like(like)), + ) + ) + return query + + +def _chat_row_dict(session: ChatSession, user: User | None) -> dict: + return { + "id": session.id, + "userId": session.user_id, + "userPhone": user.phone if user else "", + "userName": user.name if user else "", + "title": session.title, + "messageCount": session.message_count, + "lastMessageAt": session.last_message_at, + "updatedAt": session.updated_at, + } + + +def _message_dict(message: ChatMessage) -> dict: + return { + "id": message.id, + "sessionId": message.session_id, + "userId": message.user_id, + "role": message.role, + "content": message.content, + "messageStatus": message.message_status, + "tokenInput": message.token_input, + "tokenOutput": message.token_output, + "responseTimeMs": message.response_time_ms, + "modelId": message.model_id, + "createdAt": message.created_at, + } + + +def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False) -> dict: + data = { + "id": log.id, + "sessionId": log.session_id, + "messageId": log.message_id, + "userId": log.user_id, + "modelName": log.model_name, + "knowledgeIds": log.knowledge_ids, + "retrieveCount": log.retrieve_count, + "inputToken": log.input_token, + "outputToken": log.output_token, + "totalToken": log.total_token, + "costMs": log.cost_ms, + "status": log.status, + "errorMessage": log.error_message, + "createdAt": log.created_at, + } + if include_prompt: + data["prompt"] = log.prompt + return data diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py index d33300f..329f10c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/config.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py @@ -17,6 +17,8 @@ class Settings(BaseSettings): default_factory=lambda: [ "http://localhost:5173", "http://127.0.0.1:5173", + "http://localhost:5174", + "http://127.0.0.1:5174", "http://localhost:3000", "http://127.0.0.1:3000", ] diff --git a/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py b/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py index b9c4385..754b516 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/admin_service.py @@ -31,7 +31,16 @@ class AdminAuthService: db.refresh(admin) token, expired_at = create_access_token(str(admin.id), "admin") - return {"token": token, "expiredAt": expired_at, "admin": admin} + return { + "token": token, + "expiredAt": expired_at, + "admin": { + "id": admin.id, + "username": admin.username, + "name": admin.name, + "status": admin.status, + }, + } @staticmethod def ensure_bootstrap_admin(db: Session) -> None: diff --git a/ai_knowledge_base_v2/development_records/2026-07-07-admin-chat-records-completion.md b/ai_knowledge_base_v2/development_records/2026-07-07-admin-chat-records-completion.md new file mode 100644 index 0000000..fc071f2 --- /dev/null +++ b/ai_knowledge_base_v2/development_records/2026-07-07-admin-chat-records-completion.md @@ -0,0 +1,61 @@ +# 管理后台聊天记录补齐记录 + +日期:2026-07-07 + +## 本次目标 + +把管理后台“记录审计”里的聊天记录做完整,让管理员可以按会话查询、搜索聊天内容、查看完整对话,并追溯关联的 AI 请求日志和 Prompt。 + +## 已完成后端能力 + +- 增强 `GET /api/admin/chat/list`: + - 支持按关键词搜索。 + - 关键词覆盖会话标题、用户手机号、用户姓名和聊天消息内容。 + - 支持按用户 ID 过滤。 + - 支持按消息状态过滤。 + - 支持按更新时间范围过滤。 + - 返回用户手机号、用户姓名、会话标题、消息数、最后消息时间等列表字段。 +- 增强 `GET /api/admin/chat/{session_id}`: + - 返回会话信息。 + - 返回完整聊天消息。 + - 返回关联 AI 请求日志。 + - AI 请求日志详情包含 Prompt,方便排查模型回答依据。 +- 新增 `GET /api/admin/chat/export`: + - 按当前筛选条件导出聊天会话 CSV。 + - CSV 带 UTF-8 BOM,便于 Excel 打开中文。 +- 增强 `GET /api/admin/ai-log/list`: + - 支持按会话 ID、用户 ID 和状态过滤。 + +## 已完成前端能力 + +- 记录审计新增聊天记录筛选区: + - 关键词 + - 用户 ID + - 消息状态 + - 时间范围 + - 搜索、重置、导出 +- 聊天记录列表展示: + - 会话 ID + - 用户手机号 + - 用户姓名 + - 会话标题 + - 消息数 + - 最后消息时间 +- 新增聊天详情抽屉: + - 会话摘要 + - 完整用户/AI 对话 + - 消息状态、Token、耗时 + - 关联 AI 请求日志 + - Prompt 预览 + - 错误信息展示 + +## 顺手修正 + +- 后端 CORS 增加管理后台本地地址 `5174`。 +- 管理员登录返回值改为明确字典,避免直接返回 ORM 对象导致前端解析不稳定。 + +## 当前边界 + +- 导出当前只导出会话列表,不导出每条消息全文;如果后续需要“完整对话导出”,建议单独增加明细 CSV 或 Excel 导出。 +- 聊天详情里的 AI 请求日志展示 Prompt,但不会展示模型 API Key 等敏感信息。 +- 当前列表分页仍使用最多 200 条的简单限制,后续数据量上来后应改成分页参数。 diff --git a/ai_knowledge_base_v2/development_records/README.md b/ai_knowledge_base_v2/development_records/README.md index 6566ba0..d7c7245 100644 --- a/ai_knowledge_base_v2/development_records/README.md +++ b/ai_knowledge_base_v2/development_records/README.md @@ -32,4 +32,5 @@ | `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 | | `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 | | `2026-07-06-phase5-admin-console.md` | 阶段五管理后台记录。 | +| `2026-07-07-admin-chat-records-completion.md` | 管理后台聊天记录补齐记录。 | | `../test_records/2026-07-06-phase6-verification.md` | 阶段六验证记录。 |