Compare commits
165 Commits
main
...
codex/h5-q
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ba8fb6e0c | |||
| 921b56cfb9 | |||
| 1b5fcc4d52 | |||
| 6c02662d3b | |||
| 245b6b3eca | |||
| cbbdb41b80 | |||
| c3f24d0ccd | |||
| 56c9f2f359 | |||
| ba1840233c | |||
| e4a391aa08 | |||
| 128d444a76 | |||
| 653317ca7d | |||
| 971440ebff | |||
| bbd19cf9ed | |||
| dda2e040ca | |||
| a8bb8b068c | |||
| 44ab895149 | |||
| cb3b91b101 | |||
| 750a57a390 | |||
| 00d53c2569 | |||
| 0864f9305b | |||
| 2453816364 | |||
| 120fe185f9 | |||
| 82e0c02bac | |||
| b5047ca7e4 | |||
| 390f0a0cbf | |||
| 64a5052351 | |||
| e337897b7a | |||
| 8266d845c0 | |||
| c3639fe30d | |||
| 16fa9d06c2 | |||
| 872233adc6 | |||
| 6cadca8c8e | |||
| d2d43de6d3 | |||
| 73fdfed0e5 | |||
| 0199dd8242 | |||
| 9bce095583 | |||
| 70be6ab657 | |||
| 85208972b7 | |||
| 3393d1a809 | |||
| a81f16f298 | |||
| da795b10c3 | |||
| 10c829e62d | |||
| 603fb70715 | |||
| 4f75e11a8d | |||
| d0d25d9e17 | |||
| b3cb4a1ca9 | |||
| 5844741f24 | |||
| d9a8f42522 | |||
| dc4b1bfce9 | |||
| fc8a593393 | |||
| 2e274dea51 | |||
| 808ba74fc5 | |||
| 1ab834733e | |||
| 851c2a532a | |||
| b82a5a464f | |||
| 5ead280df0 | |||
| 83f67936c8 | |||
| 085deef9d3 | |||
| 75c54466d4 | |||
| 367127504f | |||
| 6d164a77fe | |||
| 0bc767933e | |||
| d7d9514348 | |||
| ebaf3defa2 | |||
| b759409580 | |||
| 076fcb9f02 | |||
| 7f835ec699 | |||
| fb39c9c543 | |||
| 9fb3dad5bd | |||
| aedecb3aab | |||
| 5aca872129 | |||
| 5819830e33 | |||
| f07335dab4 | |||
| ef24868105 | |||
| 55333445f4 | |||
| 53febbbf91 | |||
| 7587da6d18 | |||
| 664d1acbce | |||
| e661e53d0b | |||
| 5bd737b064 | |||
| 769bd2b7b4 | |||
| 0b0ff6b9dc | |||
| 34e740bbda | |||
| d9c7f9a309 | |||
| 0ea75dea1d | |||
| facb3a6a56 | |||
| 6c53566a4e | |||
| 532d51370e | |||
| cde7da572b | |||
| c56a4b49e1 | |||
| 34d6dd01c7 | |||
| 0e2706a4f4 | |||
| 2dcb43b717 | |||
| c86b517063 | |||
| d78d0ea75f | |||
| e1a130177c | |||
| 42569a1057 | |||
| f39e51a4d4 | |||
| d6b7ec1e52 | |||
| 1c73e8dee6 | |||
| 67048a14e8 | |||
| 76da6dd65a | |||
| 3598e34819 | |||
| f4196612d4 | |||
| 5dfff52b04 | |||
| 61db0aa9b8 | |||
| efd03ff9df | |||
| 6464c61396 | |||
| c68af6f806 | |||
| 70466a6a31 | |||
| 23d5f42972 | |||
| db1d83631b | |||
| 812ae80536 | |||
| 7a6068590e | |||
| 72bd2e1e02 | |||
| 1aab53cb37 | |||
| 211cb6207e | |||
| 465a481e7a | |||
| d476c54580 | |||
| 6f3bbd3803 | |||
| 10b6de0622 | |||
| 925ef0238d | |||
| 7811e3ed1f | |||
| 13185ab42b | |||
| 35011d2679 | |||
| fb0bbc346b | |||
| 7be5dde1db | |||
| aed6d8ffbf | |||
| 9c76be70fe | |||
| eda2c5af76 | |||
| ac72b6a94a | |||
| ca50fa437c | |||
| 6ecdc8011d | |||
| 4b3ea7baa4 | |||
| cd01e45fbd | |||
| d18d9fe9d8 | |||
| 29f25dd767 | |||
| 4f11d8632c | |||
| 84da551cbe | |||
| 03b1810a90 | |||
| 8d5d36c5eb | |||
| 7be9847d65 | |||
| da262f4596 | |||
| dada4aafcd | |||
| bcdf91c85d | |||
| 55b7c5b0ea | |||
| 08c301c136 | |||
| 08dd371480 | |||
| 62eeb3186c | |||
| 301661e7b4 | |||
| 86cbed2437 | |||
| 207340973c | |||
| 96742de07b | |||
| e9f3db9ab2 | |||
| 17cba7cf61 | |||
| 44af745828 | |||
| b7dfa13872 | |||
| dbd59a84df | |||
| 4eac7351a9 | |||
| 708e0a424e | |||
| fa2d82333c | |||
| a4b59add07 | |||
| cd73ed3001 | |||
| 34aeb22760 |
38
ai_knowledge_base_v2/.env.example
Normal file
38
ai_knowledge_base_v2/.env.example
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# ============================================
|
||||||
|
# 飞书知识库配置
|
||||||
|
# ============================================
|
||||||
|
FEISHU_APP_ID=
|
||||||
|
FEISHU_APP_SECRET=
|
||||||
|
FEISHU_SEARCH_URL=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 管理后台首个管理员初始化
|
||||||
|
# 生产环境必须替换为安全账号密码,不能使用 admin123456
|
||||||
|
# ============================================
|
||||||
|
APP_ENV=production
|
||||||
|
MYSQL_ROOT_PASSWORD=replace-with-strong-root-password
|
||||||
|
MYSQL_PASSWORD=replace-with-strong-database-password
|
||||||
|
REDIS_PASSWORD=replace-with-strong-redis-password
|
||||||
|
DATABASE_URL=mysql+pymysql://ai_kb:URL编码后的数据库密码@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4
|
||||||
|
REDIS_URL=redis://:URL编码后的Redis密码@redis:6379/0
|
||||||
|
JWT_SECRET_KEY=replace-with-at-least-32-random-characters
|
||||||
|
# 使用命令生成:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
CONFIG_ENCRYPTION_KEY=replace-with-generated-fernet-key
|
||||||
|
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=replace-with-strong-password
|
||||||
|
BOOTSTRAP_ADMIN_NAME=系统管理员
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 内网穿透(frpc sidecar)
|
||||||
|
# 启动 docker compose 前,拷贝本文件为 .env 并填写
|
||||||
|
# 或直接在 shell 中 export 这些变量
|
||||||
|
# ============================================
|
||||||
|
FRP_SERVER_ADDR=your-server-ip
|
||||||
|
FRP_SERVER_PORT=7000
|
||||||
|
FRP_AUTH_TOKEN=your-auth-token
|
||||||
|
FRP_PROXY_NAME=qa-web
|
||||||
|
# 默认穿透统一入口 gateway:80,由 gateway 按路径转发到用户端、后台和 API
|
||||||
|
FRP_LOCAL_IP=gateway
|
||||||
|
FRP_LOCAL_PORT=80
|
||||||
|
# 自定义域名,逗号分隔,如:qa.huiyushuyuan.cn
|
||||||
|
FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn
|
||||||
1
ai_knowledge_base_v2/.gitignore
vendored
Normal file
1
ai_knowledge_base_v2/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
51
ai_knowledge_base_v2/README.md
Normal file
51
ai_knowledge_base_v2/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# AI 知识库系统 V2 工作区
|
||||||
|
|
||||||
|
这个目录用于承载新需求版本:**AI 企业知识库问答系统**。
|
||||||
|
|
||||||
|
它和旧的 `hy_qa_asset_backend/docs` 目录刻意分开。旧项目的目标是“答疑资产后台系统”,核心是把大本营答疑内容清洗、审核、沉淀成可调用问答;新需求的目标是“企业飞书知识库 AI 问答系统”,核心是登录、权限、知识库、聊天会话、实时飞书检索和 RAG 问答。
|
||||||
|
|
||||||
|
## 目录说明
|
||||||
|
|
||||||
|
- `source_documents/`:保存原始需求资料,包括 PRD、SRS、API、数据库、RAG、后台原型、实施计划、测试、验收、部署、字段字典、权限矩阵。
|
||||||
|
- `extracted/`:从原始 Word/Excel 中抽取出的 Markdown,主要用于开发检索和上下文阅读。
|
||||||
|
- `development_records/`:保存新项目开发过程记录、范围决策、架构决策、实施计划和阶段性说明。
|
||||||
|
- `reports/`:保存评估报告、方案报告和后续技术调研结论。
|
||||||
|
|
||||||
|
## 当前目标
|
||||||
|
|
||||||
|
按照已上传的 AI 知识库系统需求包,重新规划和开发一版新系统:
|
||||||
|
|
||||||
|
- 用户端:手机号验证码登录、学员名单准入、AI 聊天、多会话、历史聊天、流式回答、停止生成、Markdown 渲染、开放知识库过滤。
|
||||||
|
- 管理后台:管理员登录、用户管理、学员手动添加、学员批量导入、管理员管理、知识库管理、Prompt 管理、模型管理、聊天记录、数据统计、系统配置、操作日志。
|
||||||
|
- AI/RAG:一期以飞书知识库作为唯一知识来源;每次提问实时检索;按知识库开放状态过滤;已开放知识库默认所有用户可见;组装 Prompt;调用大模型;通过 SSE 流式输出;记录 AI 请求日志。
|
||||||
|
- 一期不做:文件上传、图片理解、OCR、联网搜索、向量数据库同步、支付系统、课程系统、自动授权、多租户、多 Prompt、重新生成、编辑消息。
|
||||||
|
|
||||||
|
## 工作原则
|
||||||
|
|
||||||
|
- V2 先作为独立工作区推进,避免和旧项目文档混在一起。
|
||||||
|
- 上传的原始文档是 V2 的需求依据。
|
||||||
|
- 给用户、团队、交接和评审看的文档统一使用中文。
|
||||||
|
- `extracted/` 里的机器抽取文档可以保留原始语言或工程化表达,优先服务开发检索。
|
||||||
|
- 旧代码只在领域边界匹配时复用,不把旧的“答疑资产审核模型”硬套到新的“开放知识库问答模型”上。
|
||||||
|
|
||||||
|
## 本地开发启动
|
||||||
|
|
||||||
|
阶段二已经提供本地开发编排:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai_knowledge_base_v2
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问地址:
|
||||||
|
|
||||||
|
- 统一入口:`http://127.0.0.1:8080/`
|
||||||
|
- 用户端 H5:`http://127.0.0.1:8080/`
|
||||||
|
- 管理后台:`http://127.0.0.1:8080/login`
|
||||||
|
- 后端 API:`http://127.0.0.1:8080/api/health`
|
||||||
|
- 用户端直连调试:`http://127.0.0.1:5173/`
|
||||||
|
- 管理后台直连调试:`http://127.0.0.1:5174/login/`
|
||||||
|
- 后端直连调试:`http://127.0.0.1:8100/api/health`
|
||||||
|
- MySQL:`127.0.0.1:3307`
|
||||||
|
|
||||||
|
开发阶段 mock 短信验证码为 `123456`。
|
||||||
5
ai_knowledge_base_v2/apps/admin-web/.dockerignore
Normal file
5
ai_knowledge_base_v2/apps/admin-web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.tsbuildinfo
|
||||||
1
ai_knowledge_base_v2/apps/admin-web/.env.example
Normal file
1
ai_knowledge_base_v2/apps/admin-web/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=/api
|
||||||
5
ai_knowledge_base_v2/apps/admin-web/.gitignore
vendored
Normal file
5
ai_knowledge_base_v2/apps/admin-web/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile
Normal file
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5174
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"]
|
||||||
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile.prod
Normal file
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile.prod
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.prod.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
33
ai_knowledge_base_v2/apps/admin-web/README.md
Normal file
33
ai_knowledge_base_v2/apps/admin-web/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# 大本营答疑助手管理后台
|
||||||
|
|
||||||
|
这是大本营答疑助手的管理后台,使用 Vue 3、Vite、TypeScript 和 Element Plus。
|
||||||
|
|
||||||
|
## 本地启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev -- --port 5174
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8080/login
|
||||||
|
```
|
||||||
|
|
||||||
|
默认后端地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 管理员初始化
|
||||||
|
|
||||||
|
开发阶段后端会在首次管理员登录时自动创建默认管理员:
|
||||||
|
|
||||||
|
```text
|
||||||
|
账号:admin
|
||||||
|
密码:admin123456
|
||||||
|
```
|
||||||
|
|
||||||
|
管理后台登录页不会预填账号密码。生产环境必须通过环境变量配置安全的首个管理员账号密码,不能使用开发默认密码。
|
||||||
12
ai_knowledge_base_v2/apps/admin-web/index.html
Normal file
12
ai_knowledge_base_v2/apps/admin-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>大本营千问千答</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
15
ai_knowledge_base_v2/apps/admin-web/nginx.prod.conf
Normal file
15
ai_knowledge_base_v2/apps/admin-web/nginx.prod.conf
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
1572
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
Normal file
1572
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
ai_knowledge_base_v2/apps/admin-web/package.json
Normal file
21
ai_knowledge_base_v2/apps/admin-web/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-kb-admin-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"element-plus": "^2.11.0",
|
||||||
|
"vite": "^7.1.0",
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vue-tsc": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1316
ai_knowledge_base_v2/apps/admin-web/src/App.vue
Normal file
1316
ai_knowledge_base_v2/apps/admin-web/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive } from "vue";
|
||||||
|
|
||||||
|
defineProps<{ loading: boolean }>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [credentials: { username: string; password: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = reactive({ username: "", password: "" });
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
emit("submit", { username: form.username.trim(), password: form.password });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="login-page">
|
||||||
|
<section class="login-hero" aria-label="大本营千问千答后台登录">
|
||||||
|
<section class="login-hero-main" aria-label="产品介绍">
|
||||||
|
<header class="login-brand">
|
||||||
|
<span class="login-brand-mark" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 64 64" role="presentation">
|
||||||
|
<circle cx="32" cy="32" r="28" />
|
||||||
|
<path d="M32 9c4 7 8 10 15 11-1 7 1 12 7 17-6 4-9 9-9 16-7-1-12 1-17 6-4-6-9-9-16-9 1-7-1-12-6-17 6-4 9-9 9-16 7 1 12-1 17-6 4 6 9 9 16 9Z" />
|
||||||
|
<path d="M32 19c2.5 5 5.5 7.5 11 8-4 3.5-5.5 7.5-4 13-4.7-2.5-8.7-2.5-13 0 1.5-5.5 0-9.5-4-13 5.5-.5 8.5-3 10-8Z" />
|
||||||
|
<circle cx="32" cy="32" r="3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="login-brand-copy">
|
||||||
|
<strong>大本营千问千答</strong>
|
||||||
|
<small>智能知识管理与问答系统</small>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="login-orbit" aria-hidden="true"><i></i></div>
|
||||||
|
<div class="login-dot-matrix" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="login-brand-content">
|
||||||
|
<h1>
|
||||||
|
把大本营答疑、知识库和学员<br />
|
||||||
|
运营放在<span>同一个控制台</span>里
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
统一维护学员名单、开放知识库、模型接入、Agent 调试、问答记录和系统运行参数,让日常运营更清晰,也让排查与交接更从容。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="login-highlights" aria-label="核心能力">
|
||||||
|
<div class="login-highlight-item">
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24"><rect x="5" y="3.5" width="13" height="17" rx="2" /><path d="M8.5 7.5h5M8.5 11.5h6M8.5 15.5h3.5M18 7h2v11a2 2 0 0 1-2 2" /></svg>
|
||||||
|
</span>
|
||||||
|
<strong>大本营知识库</strong>
|
||||||
|
</div>
|
||||||
|
<div class="login-highlight-item">
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24"><rect x="4" y="7" width="16" height="12" rx="4" /><path d="M9 12h.01M15 12h.01M8 16h8M12 7V4M10.5 3h3" /></svg>
|
||||||
|
</span>
|
||||||
|
<strong>答疑 Agent 调试</strong>
|
||||||
|
</div>
|
||||||
|
<div class="login-highlight-item">
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M5 5.5h14v11H9l-4 3v-14Z" /><path d="M8.5 9.5h7M8.5 13h4" /></svg>
|
||||||
|
</span>
|
||||||
|
<strong>问答记录审计</strong>
|
||||||
|
</div>
|
||||||
|
<div class="login-highlight-item">
|
||||||
|
<span aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 3.5 15 5l3.3-.2.2 3.3 1.7 2.9-2 2.7-.5 3.3-3.3.5-2.4 2.3-2.4-2.3-3.3-.5-.5-3.3-2-2.7 1.7-2.9.2-3.3L9 5l3-1.5Z" /><circle cx="12" cy="11.7" r="3" /></svg>
|
||||||
|
</span>
|
||||||
|
<strong>系统运行管理</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg class="login-wave" viewBox="0 0 920 250" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<path d="M-20 45C105 25 110 150 250 151S410 85 530 118s185 97 410 18" />
|
||||||
|
<path d="M-20 62C95 40 127 169 264 167s160-64 277-28 180 82 399 18" />
|
||||||
|
<path d="M-20 80C90 57 140 188 278 184s159-60 273-23 176 69 389 17" />
|
||||||
|
<path d="M-20 105C88 78 142 209 295 202s168-54 278-16 165 56 367 13" />
|
||||||
|
<path d="M-20 133C95 102 157 224 315 220s164-46 282-11 158 43 343 12" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<svg class="login-lotus" viewBox="0 0 360 300" aria-hidden="true">
|
||||||
|
<g>
|
||||||
|
<path d="M180 258C126 219 119 159 180 72c61 87 54 147 0 186Z" />
|
||||||
|
<path d="M177 260C112 244 78 196 99 99c75 58 98 108 78 161Z" />
|
||||||
|
<path d="M183 260c65-16 99-64 78-161-75 58-98 108-78 161Z" />
|
||||||
|
<path d="M162 264C92 266 47 232 34 143c78 30 119 70 128 121Z" />
|
||||||
|
<path d="M198 264c70 2 115-32 128-121-78 30-119 70-128 121Z" />
|
||||||
|
<path d="M119 275c-45 5-80-8-111-41 56 3 97 15 111 41ZM241 275c45 5 80-8 111-41-56 3-97 15-111 41Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="login-card" aria-labelledby="login-title">
|
||||||
|
<div class="login-card-inner">
|
||||||
|
<div class="login-card-head">
|
||||||
|
<span class="login-card-eyebrow">ADMIN CONSOLE</span>
|
||||||
|
<h2 id="login-title">欢迎登录</h2>
|
||||||
|
<p>登录大本营答疑助手后台管理系统</p>
|
||||||
|
</div>
|
||||||
|
<el-form label-position="top" autocomplete="off" @submit.prevent="submit">
|
||||||
|
<el-form-item label="管理员账号">
|
||||||
|
<el-input v-model="form.username" size="large" placeholder="请输入管理员账号" autocomplete="off">
|
||||||
|
<template #prefix>
|
||||||
|
<svg class="login-input-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5" /><path d="M5.5 19c.6-4 2.8-6 6.5-6s5.9 2 6.5 6" /></svg>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
size="large"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autocomplete="new-password"
|
||||||
|
show-password
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<svg class="login-input-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v2.5" /></svg>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" native-type="submit" :loading="loading" class="full-btn login-submit">
|
||||||
|
进入答疑后台
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
<p class="login-security-note"><span aria-hidden="true"></span>仅限已授权的后台管理员访问</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<p class="login-copyright">© 2026 深圳市慧愈文化科技有限公司</p>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ page: number; pageSize: number; total: number }>();
|
||||||
|
const emit = defineEmits<{ change: [page: number, pageSize: number] }>();
|
||||||
|
|
||||||
|
function changePage(page: number, pageSize: number) { emit("change", page, pageSize); }
|
||||||
|
function changeSize(pageSize: number) { emit("change", 1, pageSize); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-pagination">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:total="total"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
@current-change="changePage($event, pageSize)"
|
||||||
|
@size-change="changeSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
temperature: number | null;
|
||||||
|
topP: number | null;
|
||||||
|
topK: number | null;
|
||||||
|
presencePenalty: number | null;
|
||||||
|
frequencyPenalty: number | null;
|
||||||
|
maxToken: number | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
"update:temperature": [value: number | null];
|
||||||
|
"update:topP": [value: number | null];
|
||||||
|
"update:topK": [value: number | null];
|
||||||
|
"update:presencePenalty": [value: number | null];
|
||||||
|
"update:frequencyPenalty": [value: number | null];
|
||||||
|
"update:maxToken": [value: number | null];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="agent-config-grid">
|
||||||
|
<el-form-item>
|
||||||
|
<template #label><span class="agent-parameter-label">回答随机性<el-tooltip placement="top" :show-after="200" content="取值 0–2。数值越小,回答越稳定、一致;数值越大,表达越多样,但也更容易发散。答疑场景建议使用较低数值。"><button type="button" class="agent-parameter-help" aria-label="回答随机性说明">?</button></el-tooltip></span></template>
|
||||||
|
<el-input-number :model-value="temperature" :disabled="disabled" :min="0" :max="2" :step="0.1" @update:model-value="$emit('update:temperature', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<template #label><span class="agent-parameter-label">核采样概率<el-tooltip placement="top" :show-after="200" content="取值 0–1。模型只从累计概率达到该数值的候选词中选择。数值越小越专注,越大越多样。留空表示使用模型供应商默认值。"><button type="button" class="agent-parameter-help" aria-label="核采样概率说明">?</button></el-tooltip></span></template>
|
||||||
|
<el-input-number :model-value="topP" :disabled="disabled" :min="0" :max="1" :step="0.05" @update:model-value="$emit('update:topP', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<template #label><span class="agent-parameter-label">候选词数量<el-tooltip placement="top" :show-after="200" content="取值 1–1000。每次生成只从概率最高的前 K 个候选词中选择。数值越小越保守,越大选择越多。留空表示使用供应商默认值,部分模型可能不支持。"><button type="button" class="agent-parameter-help" aria-label="候选词数量说明">?</button></el-tooltip></span></template>
|
||||||
|
<el-input-number :model-value="topK" :disabled="disabled" :min="1" :max="1000" @update:model-value="$emit('update:topK', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<template #label><span class="agent-parameter-label">最大输出长度<el-tooltip placement="top" :show-after="200" content="限制模型本次最多生成的 Token 数,不等于字数。建议正式答疑使用 4096–8192;数值越大允许回答越长,也会增加耗时和费用,最终仍受模型自身上限约束。"><button type="button" class="agent-parameter-help" aria-label="最大输出长度说明">?</button></el-tooltip></span></template>
|
||||||
|
<el-input-number :model-value="maxToken" :disabled="disabled" :min="256" :max="100000" :step="256" @update:model-value="$emit('update:maxToken', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<template #label><span class="agent-parameter-label">主题重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -2–2,0 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><button type="button" class="agent-parameter-help" aria-label="主题重复惩罚说明">?</button></el-tooltip></span></template>
|
||||||
|
<el-input-number :model-value="presencePenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:presencePenalty', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<template #label><span class="agent-parameter-label">高频重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -2–2,0 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><button type="button" class="agent-parameter-help" aria-label="高频重复惩罚说明">?</button></el-tooltip></span></template>
|
||||||
|
<el-input-number :model-value="frequencyPenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:frequencyPenalty', $event)" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import type { AgentDebugResult, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem } from "../types/api";
|
||||||
|
import AgentGenerationParameters from "./AgentGenerationParameters.vue";
|
||||||
|
import AdminPagination from "./AdminPagination.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ previewKnowledgeId?: number | null }>();
|
||||||
|
const emit = defineEmits<{ consumedPreview: [] }>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const promptSaving = ref(false);
|
||||||
|
const runtimeSaving = ref(false);
|
||||||
|
const historyLoading = ref(false);
|
||||||
|
const agentDebugging = ref(false);
|
||||||
|
const activeConfigTab = ref("prompt");
|
||||||
|
const prompt = ref<PromptDetail | null>(null);
|
||||||
|
const promptContent = ref("");
|
||||||
|
const savedPromptContent = ref("");
|
||||||
|
const models = ref<ModelItem[]>([]);
|
||||||
|
const knowledge = ref<KnowledgeItem[]>([]);
|
||||||
|
const history = ref<PromptHistoryItem[]>([]);
|
||||||
|
const historyPager = reactive({ page: 1, pageSize: 10, total: 0 });
|
||||||
|
const historyDetailOpen = ref(false);
|
||||||
|
const selectedHistory = ref<PromptDetail | null>(null);
|
||||||
|
const historyDetailLoading = ref(false);
|
||||||
|
const agentDebugTrace = ref<Record<string, any>[]>([]);
|
||||||
|
const agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
|
||||||
|
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const agentForm = reactive({
|
||||||
|
modelId: undefined as number | undefined,
|
||||||
|
knowledgeIds: [] as number[],
|
||||||
|
temperature: 0.2 as number | null,
|
||||||
|
topP: null as number | null,
|
||||||
|
topK: null as number | null,
|
||||||
|
presencePenalty: null as number | null,
|
||||||
|
frequencyPenalty: null as number | null,
|
||||||
|
maxToken: 8192 as number | null,
|
||||||
|
question: "",
|
||||||
|
});
|
||||||
|
const runtimeConfig = ref<AgentRuntimeConfig | null>(null);
|
||||||
|
const runtimeForm = reactive({
|
||||||
|
temperature: null as number | null,
|
||||||
|
topP: null as number | null,
|
||||||
|
topK: null as number | null,
|
||||||
|
presencePenalty: null as number | null,
|
||||||
|
frequencyPenalty: null as number | null,
|
||||||
|
maxToken: 8192 as number | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
|
||||||
|
const selectedModelName = computed(() => {
|
||||||
|
const model = models.value.find((item) => item.id === agentForm.modelId);
|
||||||
|
return model?.displayName || model?.modelName || "未选择模型";
|
||||||
|
});
|
||||||
|
const selectedKnowledgeSummary = computed(() => {
|
||||||
|
if (!agentForm.knowledgeIds.length) return "未选择知识库";
|
||||||
|
if (agentForm.knowledgeIds.length === 1) {
|
||||||
|
return knowledge.value.find((item) => item.id === agentForm.knowledgeIds[0])?.name || "1 个知识库";
|
||||||
|
}
|
||||||
|
return `${agentForm.knowledgeIds.length} 个知识库`;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
watch(() => props.previewKnowledgeId, (knowledgeId) => {
|
||||||
|
if (knowledgeId && knowledge.value.length) applyPreviewKnowledge(knowledgeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [promptResult, modelRows, knowledgeRows, formalConfig] = await Promise.all([
|
||||||
|
api.prompt(), api.models(), api.knowledgeOptions(), api.agentRuntimeConfig(),
|
||||||
|
]);
|
||||||
|
applyPrompt(promptResult);
|
||||||
|
models.value = modelRows;
|
||||||
|
knowledge.value = knowledgeRows;
|
||||||
|
agentForm.modelId = modelRows.find((item) => item.enabled === 1)?.id ?? modelRows[0]?.id;
|
||||||
|
applyRuntimeConfig(formalConfig);
|
||||||
|
applyDebugModelDefaults(agentForm.modelId);
|
||||||
|
agentForm.knowledgeIds = knowledgeRows
|
||||||
|
.filter((item) => item.status === 1 && item.lifecycleStatus === "active")
|
||||||
|
.map((item) => item.id);
|
||||||
|
if (props.previewKnowledgeId) applyPreviewKnowledge(props.previewKnowledgeId);
|
||||||
|
await loadHistory();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "Agent 配置加载失败"));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRuntimeConfig(value: AgentRuntimeConfig) {
|
||||||
|
runtimeConfig.value = value;
|
||||||
|
Object.assign(runtimeForm, {
|
||||||
|
temperature: value.temperature,
|
||||||
|
topP: value.topP,
|
||||||
|
topK: value.topK,
|
||||||
|
presencePenalty: value.presencePenalty,
|
||||||
|
frequencyPenalty: value.frequencyPenalty,
|
||||||
|
maxToken: value.maxToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDebugModelDefaults(modelId?: number) {
|
||||||
|
const model = models.value.find((item) => item.id === modelId);
|
||||||
|
if (!model) return;
|
||||||
|
Object.assign(agentForm, {
|
||||||
|
temperature: model.temperature ?? null,
|
||||||
|
topP: model.topP ?? null,
|
||||||
|
topK: model.topK ?? null,
|
||||||
|
presencePenalty: model.presencePenalty ?? null,
|
||||||
|
frequencyPenalty: model.frequencyPenalty ?? null,
|
||||||
|
maxToken: model.maxToken ?? (model.enabled === 1 ? runtimeConfig.value?.maxToken : null) ?? 8192,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRuntimeConfig() {
|
||||||
|
if (!runtimeConfig.value?.modelId) return ElMessage.error("请先在模型管理中启用一个正式模型");
|
||||||
|
if (!runtimeForm.maxToken) return ElMessage.warning("最大输出长度不能为空");
|
||||||
|
runtimeSaving.value = true;
|
||||||
|
try {
|
||||||
|
const saved = await api.saveAgentRuntimeConfig({
|
||||||
|
temperature: runtimeForm.temperature,
|
||||||
|
topP: runtimeForm.topP,
|
||||||
|
topK: runtimeForm.topK,
|
||||||
|
presencePenalty: runtimeForm.presencePenalty,
|
||||||
|
frequencyPenalty: runtimeForm.frequencyPenalty,
|
||||||
|
maxToken: runtimeForm.maxToken,
|
||||||
|
});
|
||||||
|
applyRuntimeConfig(saved);
|
||||||
|
const model = models.value.find((item) => item.id === saved.modelId);
|
||||||
|
if (model) Object.assign(model, runtimeForm);
|
||||||
|
if (agentForm.modelId === saved.modelId) applyDebugModelDefaults(saved.modelId ?? undefined);
|
||||||
|
ElMessage.success("正式运行参数已保存,将从用户端下一条消息开始生效");
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "正式运行参数保存失败"));
|
||||||
|
} finally {
|
||||||
|
runtimeSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreviewKnowledge(knowledgeId: number) {
|
||||||
|
if (knowledge.value.some((item) => item.id === knowledgeId)) {
|
||||||
|
agentForm.knowledgeIds = [knowledgeId];
|
||||||
|
activeConfigTab.value = "debug";
|
||||||
|
}
|
||||||
|
emit("consumedPreview");
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPrompt(value: PromptDetail) {
|
||||||
|
prompt.value = value;
|
||||||
|
promptContent.value = value.promptContent;
|
||||||
|
savedPromptContent.value = value.promptContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrompt() {
|
||||||
|
if (!promptContent.value.trim()) return ElMessage.warning("主提示词不能为空");
|
||||||
|
promptSaving.value = true;
|
||||||
|
try {
|
||||||
|
applyPrompt(await api.savePrompt(promptContent.value.trim()));
|
||||||
|
ElMessage.success("主提示词已保存为新版本");
|
||||||
|
await loadHistory(1);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "主提示词保存失败"));
|
||||||
|
} finally {
|
||||||
|
promptSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPrompt() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
"恢复默认提示词会新增一个版本,现有历史不会被删除。确认继续?",
|
||||||
|
"恢复默认提示词",
|
||||||
|
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promptSaving.value = true;
|
||||||
|
try {
|
||||||
|
applyPrompt(await api.resetPrompt());
|
||||||
|
ElMessage.success("已恢复默认提示词并保存为新版本");
|
||||||
|
await loadHistory(1);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "恢复默认提示词失败"));
|
||||||
|
} finally {
|
||||||
|
promptSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory(page = historyPager.page, pageSize = historyPager.pageSize) {
|
||||||
|
historyLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.promptHistory({ page, pageSize });
|
||||||
|
history.value = result.items;
|
||||||
|
Object.assign(historyPager, { page: result.page, pageSize: result.pageSize, total: result.total });
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "提示词历史加载失败"));
|
||||||
|
} finally {
|
||||||
|
historyLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openHistory(item: PromptHistoryItem) {
|
||||||
|
historyDetailOpen.value = true;
|
||||||
|
historyDetailLoading.value = true;
|
||||||
|
selectedHistory.value = null;
|
||||||
|
try {
|
||||||
|
selectedHistory.value = await api.promptHistoryDetail(item.id);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "提示词版本加载失败"));
|
||||||
|
historyDetailOpen.value = false;
|
||||||
|
} finally {
|
||||||
|
historyDetailLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreHistory(id: number) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确认把版本 #${id} 恢复为当前主提示词?系统会新增一个恢复版本,不会覆盖历史。`,
|
||||||
|
"恢复历史版本",
|
||||||
|
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
promptSaving.value = true;
|
||||||
|
try {
|
||||||
|
applyPrompt(await api.restorePrompt(id));
|
||||||
|
historyDetailOpen.value = false;
|
||||||
|
activeConfigTab.value = "prompt";
|
||||||
|
ElMessage.success(`版本 #${id} 已恢复为当前主提示词`);
|
||||||
|
await loadHistory(1);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "历史版本恢复失败"));
|
||||||
|
} finally {
|
||||||
|
promptSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSelectedHistory() {
|
||||||
|
if (selectedHistory.value?.id) await restoreHistory(selectedHistory.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function debugAgent() {
|
||||||
|
if (!agentForm.modelId) return ElMessage.error("请选择调试模型");
|
||||||
|
if (!agentForm.question.trim()) return ElMessage.error("请输入调试问题");
|
||||||
|
if (!promptContent.value.trim()) return ElMessage.error("主提示词不能为空");
|
||||||
|
const question = agentForm.question.trim();
|
||||||
|
agentPreviewMessages.value.push({ role: "user", content: question });
|
||||||
|
agentForm.question = "";
|
||||||
|
agentDebugging.value = true;
|
||||||
|
try {
|
||||||
|
const result: AgentDebugResult = await api.debugAgent({
|
||||||
|
promptContent: promptContent.value,
|
||||||
|
modelId: agentForm.modelId,
|
||||||
|
knowledgeIds: agentForm.knowledgeIds,
|
||||||
|
question,
|
||||||
|
temperature: agentForm.temperature,
|
||||||
|
topP: agentForm.topP,
|
||||||
|
topK: agentForm.topK,
|
||||||
|
presencePenalty: agentForm.presencePenalty,
|
||||||
|
frequencyPenalty: agentForm.frequencyPenalty,
|
||||||
|
maxToken: agentForm.maxToken,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
agentPreviewMessages.value.push({ role: "assistant", content: result.message });
|
||||||
|
return ElMessage.error(result.message);
|
||||||
|
}
|
||||||
|
agentPreviewMessages.value.push({ role: "assistant", content: result.answer });
|
||||||
|
agentDebugTrace.value = result.retrievalTrace || [];
|
||||||
|
ElMessage.success(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error, "Agent 调试失败");
|
||||||
|
agentPreviewMessages.value.push({ role: "assistant", content: message });
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
agentDebugging.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAgentPreview() {
|
||||||
|
agentPreviewMessages.value = [{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" }];
|
||||||
|
agentDebugTrace.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentMessageParts(content: string) {
|
||||||
|
const matched = content.match(/<think>([\s\S]*?)<\/think>/i);
|
||||||
|
if (!matched) return { reasoning: "", answer: content };
|
||||||
|
return {
|
||||||
|
reasoning: matched[1].trim(),
|
||||||
|
answer: content.replace(matched[0], "").trim() || "模型未返回正式回答。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTypeLabel(value?: string) {
|
||||||
|
return ({ save: "手动保存", reset: "恢复默认", restore: "历史恢复", default: "系统默认" } as Record<string, string>)[value || ""] || "保存";
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTypeTag(value?: string) {
|
||||||
|
if (value === "reset") return "warning";
|
||||||
|
if (value === "restore") return "success";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) return "尚未保存";
|
||||||
|
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-head agent-page-head">
|
||||||
|
<div>
|
||||||
|
<h2>Agent 管理</h2>
|
||||||
|
<p>维护主提示词、用户端正式生成参数和历史版本,并验证真实检索与回答效果。</p>
|
||||||
|
</div>
|
||||||
|
<div class="agent-current-summary">
|
||||||
|
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
|
||||||
|
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDate(prompt?.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-loading="loading" class="agent-workbench agent-workbench-v2">
|
||||||
|
<div class="agent-config-panel agent-control-panel">
|
||||||
|
<el-tabs v-model="activeConfigTab" class="agent-config-tabs">
|
||||||
|
<el-tab-pane label="主提示词" name="prompt">
|
||||||
|
<div class="agent-section-intro">
|
||||||
|
<div><h3>当前主提示词</h3><p>保存、恢复默认或恢复历史版本时都会新增版本,历史记录不会被覆盖。</p></div>
|
||||||
|
<el-tag v-if="promptDirty" type="warning">有未保存修改</el-tag>
|
||||||
|
</div>
|
||||||
|
<el-input v-model="promptContent" class="agent-prompt-editor" type="textarea" :rows="18" resize="vertical" placeholder="请输入 Agent 主提示词" />
|
||||||
|
<div class="agent-editor-meta"><span>{{ promptContent.length }} 字符</span><span>调试会立即使用编辑框中的内容</span></div>
|
||||||
|
<div class="actions agent-primary-actions">
|
||||||
|
<el-button type="primary" :loading="promptSaving" :disabled="!promptDirty" @click="savePrompt">保存为新版本</el-button>
|
||||||
|
<el-button :loading="promptSaving" @click="resetPrompt">恢复默认提示词</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="正式运行配置" name="runtime">
|
||||||
|
<div class="agent-section-intro">
|
||||||
|
<div><h3>用户端正式运行参数</h3><p>保存后从用户端下一条消息开始生效。当前正式模型:{{ runtimeConfig?.modelName || '未启用模型' }}</p></div>
|
||||||
|
<el-button type="primary" :loading="runtimeSaving" :disabled="!runtimeConfig?.modelId" @click="saveRuntimeConfig">保存正式参数</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="!runtimeConfig?.modelId" title="当前没有已启用模型,请先到模型管理中启用一个正式模型。" type="warning" :closable="false" show-icon />
|
||||||
|
<el-alert v-else title="最大输出长度默认提升为 8192 Token;设置过低会导致长回答在完成前被模型截断。实际可用上限仍受模型供应商限制。" type="info" :closable="false" show-icon />
|
||||||
|
<el-form label-position="top" class="agent-runtime-form">
|
||||||
|
<section class="agent-advanced-settings">
|
||||||
|
<div class="agent-subsection-title"><h4>正式生成参数</h4><span>用户端正式对话使用</span></div>
|
||||||
|
<AgentGenerationParameters
|
||||||
|
v-model:temperature="runtimeForm.temperature"
|
||||||
|
v-model:top-p="runtimeForm.topP"
|
||||||
|
v-model:top-k="runtimeForm.topK"
|
||||||
|
v-model:presence-penalty="runtimeForm.presencePenalty"
|
||||||
|
v-model:frequency-penalty="runtimeForm.frequencyPenalty"
|
||||||
|
v-model:max-token="runtimeForm.maxToken"
|
||||||
|
:disabled="!runtimeConfig?.modelId"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="调试配置" name="debug">
|
||||||
|
<div class="agent-section-intro"><div><h3>本次调试配置</h3><p>配置只作用于后台预览,不会修改模型和知识库的正式状态。</p></div></div>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="调试模型">
|
||||||
|
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型" @change="applyDebugModelDefaults">
|
||||||
|
<el-option v-for="model in models" :key="model.id" :label="model.displayName || model.modelName" :value="model.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="调试知识库">
|
||||||
|
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
|
||||||
|
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<section class="agent-advanced-settings">
|
||||||
|
<div class="agent-subsection-title"><h4>高级生成参数</h4><span>仅影响本次后台调试</span></div>
|
||||||
|
<AgentGenerationParameters
|
||||||
|
v-model:temperature="agentForm.temperature"
|
||||||
|
v-model:top-p="agentForm.topP"
|
||||||
|
v-model:top-k="agentForm.topK"
|
||||||
|
v-model:presence-penalty="agentForm.presencePenalty"
|
||||||
|
v-model:frequency-penalty="agentForm.frequencyPenalty"
|
||||||
|
v-model:max-token="agentForm.maxToken"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="历史版本" name="history">
|
||||||
|
<div class="agent-section-intro"><div><h3>主提示词历史</h3><p>查看完整内容或恢复任意版本。恢复操作会作为一个新版本记录。</p></div></div>
|
||||||
|
<div v-loading="historyLoading" class="prompt-history-list">
|
||||||
|
<el-empty v-if="history.length === 0" description="还没有已保存的提示词版本" :image-size="64" />
|
||||||
|
<article v-for="item in history" :key="item.id" class="prompt-history-item" :class="{ current: item.isCurrent }">
|
||||||
|
<div class="prompt-history-head">
|
||||||
|
<strong>版本 #{{ item.id }}</strong>
|
||||||
|
<el-tag v-if="item.isCurrent" type="success" size="small">当前</el-tag>
|
||||||
|
<el-tag :type="changeTypeTag(item.changeType)" size="small">{{ changeTypeLabel(item.changeType) }}</el-tag>
|
||||||
|
<span>{{ item.updatedByName }} · {{ formatDate(item.updatedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ item.preview || "空内容" }}</p>
|
||||||
|
<div class="prompt-history-foot">
|
||||||
|
<span>{{ item.charCount }} 字符<span v-if="item.sourcePromptId"> · 来源 #{{ item.sourcePromptId }}</span></span>
|
||||||
|
<div><el-button link type="primary" @click="openHistory(item)">查看完整内容</el-button><el-button v-if="!item.isCurrent" link type="primary" @click="restoreHistory(item.id)">恢复此版本</el-button></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<AdminPagination :page="historyPager.page" :page-size="historyPager.pageSize" :total="historyPager.total" @change="loadHistory" />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="agent-preview-panel">
|
||||||
|
<div class="agent-preview-head">
|
||||||
|
<div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }}</small></div>
|
||||||
|
<div class="agent-preview-head-actions"><span>{{ selectedModelName }}</span><el-button link @click="clearAgentPreview">清空</el-button></div>
|
||||||
|
</div>
|
||||||
|
<div class="agent-preview-chat">
|
||||||
|
<article v-for="(message, index) in agentPreviewMessages" :key="`${message.role}-${index}`" class="agent-preview-message-row" :class="message.role">
|
||||||
|
<div class="agent-preview-avatar">{{ message.role === "user" ? "你" : "AI" }}</div>
|
||||||
|
<div class="agent-preview-bubble">
|
||||||
|
<strong>{{ message.role === "user" ? "你" : "Agent" }}</strong>
|
||||||
|
<template v-if="message.role === 'assistant'">
|
||||||
|
<details v-if="agentMessageParts(message.content).reasoning" class="agent-preview-thinking"><summary>思考过程</summary><pre>{{ agentMessageParts(message.content).reasoning }}</pre></details>
|
||||||
|
<pre>{{ agentMessageParts(message.content).answer }}</pre>
|
||||||
|
</template>
|
||||||
|
<pre v-else>{{ message.content }}</pre>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article v-if="agentDebugging" class="agent-preview-message-row assistant thinking"><div class="agent-preview-avatar">AI</div><div class="agent-preview-bubble"><strong>Agent</strong><pre>正在思考中...</pre></div></article>
|
||||||
|
</div>
|
||||||
|
<div class="agent-preview-composer">
|
||||||
|
<el-input v-model="agentForm.question" type="textarea" :rows="2" resize="none" placeholder="向 Agent 发送测试问题;Enter 发送,Shift+Enter 换行" @keydown.enter.exact.prevent="debugAgent" />
|
||||||
|
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<aside class="agent-trace-panel agent-trace-wide">
|
||||||
|
<div class="agent-preview-head"><div><h3>检索追踪</h3><small>按调用顺序查看请求、响应、耗时与错误</small></div><span>本轮 {{ agentDebugTrace.length }} 次 Tool 调用</span></div>
|
||||||
|
<div class="agent-trace-list">
|
||||||
|
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
|
||||||
|
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
|
||||||
|
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
|
||||||
|
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
|
||||||
|
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
|
||||||
|
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="historyDetailOpen" width="min(820px, 92vw)" title="主提示词历史版本" destroy-on-close>
|
||||||
|
<div v-loading="historyDetailLoading" class="prompt-history-detail">
|
||||||
|
<template v-if="selectedHistory">
|
||||||
|
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDate(selectedHistory.updatedAt) }} · {{ selectedHistory.charCount }} 字符</span></div>
|
||||||
|
<pre>{{ selectedHistory.promptContent }}</pre>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<template #footer><el-button @click="historyDetailOpen = false">关闭</el-button><el-button v-if="selectedHistory?.id !== prompt?.id" type="primary" :loading="promptSaving" @click="restoreSelectedHistory">恢复为当前主提示词</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import type { KnowledgeVersion, KnowledgeVersionContent } from "../types/api";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
versions: KnowledgeVersion[];
|
||||||
|
versionId: number | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ "update:versionId": [versionId: number] }>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const content = ref<KnowledgeVersionContent | null>(null);
|
||||||
|
const activeSectionId = ref<number | null>(null);
|
||||||
|
let requestSequence = 0;
|
||||||
|
|
||||||
|
const activeSection = computed(() => (
|
||||||
|
content.value?.sections.find((section) => section.id === activeSectionId.value)
|
||||||
|
?? content.value?.sections[0]
|
||||||
|
?? null
|
||||||
|
));
|
||||||
|
|
||||||
|
const characterCount = computed(() => (
|
||||||
|
content.value?.sections.reduce((total, section) => total + section.content.length, 0) ?? 0
|
||||||
|
));
|
||||||
|
|
||||||
|
watch(() => props.versionId, loadVersion, { immediate: true });
|
||||||
|
|
||||||
|
async function loadVersion(versionId: number | null) {
|
||||||
|
const sequence = ++requestSequence;
|
||||||
|
content.value = null;
|
||||||
|
activeSectionId.value = null;
|
||||||
|
error.value = "";
|
||||||
|
if (!versionId) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.knowledgeVersionContent(versionId);
|
||||||
|
if (sequence !== requestSequence) return;
|
||||||
|
content.value = result;
|
||||||
|
activeSectionId.value = result.sections[0]?.id ?? null;
|
||||||
|
} catch (loadError) {
|
||||||
|
if (sequence !== requestSequence) return;
|
||||||
|
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
|
||||||
|
} finally {
|
||||||
|
if (sequence === requestSequence) loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectVersion(value: number) {
|
||||||
|
emit("update:versionId", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionStatus(version: KnowledgeVersion) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
published: "正式版本",
|
||||||
|
processing: "同步处理中",
|
||||||
|
pending_review: "未生效版本",
|
||||||
|
approved: "未生效版本",
|
||||||
|
rejected: "未生效版本",
|
||||||
|
discarded: "已废弃版本",
|
||||||
|
superseded: "历史版本",
|
||||||
|
};
|
||||||
|
return labels[version.status] ?? version.status;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="content-preview" v-loading="loading">
|
||||||
|
<header class="content-preview-toolbar">
|
||||||
|
<div>
|
||||||
|
<h4>知识库内容预览</h4>
|
||||||
|
<p>按同步后的章节结构查看实际进入检索链路的内容。</p>
|
||||||
|
</div>
|
||||||
|
<el-select
|
||||||
|
:model-value="versionId"
|
||||||
|
class="content-version-select"
|
||||||
|
placeholder="选择预览版本"
|
||||||
|
:disabled="versions.length === 0"
|
||||||
|
@update:model-value="selectVersion"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="version in versions"
|
||||||
|
:key="version.id"
|
||||||
|
:label="`V${version.versionNo} · ${versionStatus(version)}`"
|
||||||
|
:value="version.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
|
||||||
|
<template #default><el-button link type="primary" @click="loadVersion(versionId)">重新加载</el-button></template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-empty v-else-if="!loading && !versionId" description="该知识库暂无可预览版本" />
|
||||||
|
<el-empty v-else-if="!loading && content && content.sections.length === 0" description="当前版本没有可预览的章节内容" />
|
||||||
|
|
||||||
|
<template v-else-if="content && activeSection">
|
||||||
|
<div class="content-preview-summary">
|
||||||
|
<span>V{{ content.version.versionNo }}</span>
|
||||||
|
<span>{{ content.sections.length }} 个章节</span>
|
||||||
|
<span>{{ content.cards.length }} 张知识卡</span>
|
||||||
|
<span>约 {{ characterCount.toLocaleString() }} 字</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-preview-layout">
|
||||||
|
<aside class="content-outline" aria-label="章节目录">
|
||||||
|
<strong>章节目录</strong>
|
||||||
|
<button
|
||||||
|
v-for="section in content.sections"
|
||||||
|
:key="section.id"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: section.id === activeSection.id }"
|
||||||
|
:title="section.title"
|
||||||
|
@click="activeSectionId = section.id"
|
||||||
|
>
|
||||||
|
<span>{{ section.sectionKey }}</span>
|
||||||
|
<em>{{ section.title }}</em>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<article class="content-article">
|
||||||
|
<div class="content-article-head">
|
||||||
|
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
|
||||||
|
<span>源内容位置 {{ activeSection.sourceStart }}–{{ activeSection.sourceEnd }}</span>
|
||||||
|
</div>
|
||||||
|
<pre>{{ activeSection.content }}</pre>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-collapse class="content-card-panel">
|
||||||
|
<el-collapse-item :title="`查看知识卡(${content.cards.length})`" name="cards">
|
||||||
|
<el-empty v-if="content.cards.length === 0" description="当前版本尚未生成知识卡" :image-size="72" />
|
||||||
|
<el-table v-else :data="content.cards" max-height="360">
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="riskLevel" label="风险" width="100" />
|
||||||
|
</el-table>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content-preview { min-height: 360px; }
|
||||||
|
.content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
||||||
|
.content-preview-toolbar h4 { margin: 0; font-size: 17px; color: #203b32; }
|
||||||
|
.content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; }
|
||||||
|
.content-version-select { width: 220px; flex: 0 0 220px; }
|
||||||
|
.content-preview-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.content-preview-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; }
|
||||||
|
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
|
||||||
|
.content-outline { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 14px 10px; border-right: 1px solid #e5ece9; background: #f7faf9; }
|
||||||
|
.content-outline > strong { display: block; padding: 0 10px 10px; color: #455c53; font-size: 13px; }
|
||||||
|
.content-outline button { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; width: 100%; gap: 8px; padding: 10px; border: 0; border-radius: 8px; background: transparent; color: #53645e; text-align: left; cursor: pointer; }
|
||||||
|
.content-outline button:hover { background: #eaf3ef; }
|
||||||
|
.content-outline button.active { background: #dcefe7; color: #176746; }
|
||||||
|
.content-outline button span { font-size: 11px; color: #7c8d87; }
|
||||||
|
.content-outline button em { overflow: hidden; font-style: normal; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.content-article { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 24px 28px; }
|
||||||
|
.content-article-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #edf1ef; }
|
||||||
|
.content-article-head small { color: #26815f; }
|
||||||
|
.content-article-head h3 { margin: 5px 0 0; color: #203b32; font-size: 20px; }
|
||||||
|
.content-article-head > span { color: #88958f; font-size: 12px; white-space: nowrap; }
|
||||||
|
.content-article pre { margin: 20px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #354840; font: inherit; font-size: 14px; line-height: 1.85; }
|
||||||
|
.content-card-panel { margin-top: 16px; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
|
||||||
|
.content-version-select { width: 100%; flex-basis: auto; }
|
||||||
|
.content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
|
||||||
|
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
|
||||||
|
.content-outline > strong { display: none; }
|
||||||
|
.content-outline button { min-width: 180px; width: 180px; }
|
||||||
|
.content-article { height: auto; max-height: 58vh; padding: 20px; }
|
||||||
|
.content-article-head { flex-direction: column; gap: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
|
import { api } from "../services/api";
|
||||||
|
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
|
||||||
|
import TableRowActions from "./TableRowActions.vue";
|
||||||
|
import AdminPagination from "./AdminPagination.vue";
|
||||||
|
import type {
|
||||||
|
KnowledgeDetail,
|
||||||
|
KnowledgeItem,
|
||||||
|
KnowledgeSyncJob,
|
||||||
|
KnowledgeVersion,
|
||||||
|
MigrationSummary,
|
||||||
|
} from "../types/api";
|
||||||
|
|
||||||
|
const emit = defineEmits<{ preview: [knowledgeId: number] }>();
|
||||||
|
|
||||||
|
const rows = ref<KnowledgeItem[]>([]);
|
||||||
|
const migration = ref<MigrationSummary | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const operatingId = ref<number | null>(null);
|
||||||
|
const detailOpen = ref(false);
|
||||||
|
const detail = ref<KnowledgeDetail | null>(null);
|
||||||
|
const versions = ref<KnowledgeVersion[]>([]);
|
||||||
|
const jobs = ref<KnowledgeSyncJob[]>([]);
|
||||||
|
const previewVersionId = ref<number | null>(null);
|
||||||
|
const activeTab = ref("overview");
|
||||||
|
const selectedRows = ref<KnowledgeItem[]>([]);
|
||||||
|
const editOpen = ref(false);
|
||||||
|
const editSaving = ref(false);
|
||||||
|
const batchOpen = ref(false);
|
||||||
|
const batchSaving = ref(false);
|
||||||
|
const resolvingNode = ref(false);
|
||||||
|
const nodeResolved = ref(false);
|
||||||
|
const sourceTitle = ref("");
|
||||||
|
const filter = reactive({ keyword: "", type: "", open: "", source: "" });
|
||||||
|
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||||
|
const form = reactive({
|
||||||
|
name: "",
|
||||||
|
feishuSpaceId: "",
|
||||||
|
feishuNodeId: "",
|
||||||
|
knowledgeType: "general",
|
||||||
|
status: 0,
|
||||||
|
remark: "",
|
||||||
|
});
|
||||||
|
const editForm = reactive({ id: 0, name: "", knowledgeType: "general" });
|
||||||
|
const batchForm = reactive({ knowledgeType: "" });
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
async function load(page = pager.page, pageSize = pager.pageSize) {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.knowledge({
|
||||||
|
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
|
||||||
|
sourceStatus: filter.source,
|
||||||
|
page, pageSize,
|
||||||
|
});
|
||||||
|
rows.value = result.items;
|
||||||
|
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
|
||||||
|
if (!migration.value) migration.value = await api.migrationSummary();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识库列表加载失败"));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
Object.assign(editForm, {
|
||||||
|
id: detail.value.knowledge.id,
|
||||||
|
name: detail.value.knowledge.name,
|
||||||
|
knowledgeType: detail.value.knowledge.knowledgeType,
|
||||||
|
});
|
||||||
|
editOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMetadata() {
|
||||||
|
if (!editForm.name.trim()) {
|
||||||
|
ElMessage.warning("请输入后台知识库名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editSaving.value = true;
|
||||||
|
try {
|
||||||
|
await api.updateKnowledgeMetadata(editForm.id, {
|
||||||
|
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType,
|
||||||
|
});
|
||||||
|
ElMessage.success("知识库基本信息已更新");
|
||||||
|
editOpen.value = false;
|
||||||
|
await Promise.all([load(), refreshDetail(editForm.id)]);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "基本信息保存失败"));
|
||||||
|
} finally {
|
||||||
|
editSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchEdit() {
|
||||||
|
if (!selectedRows.value.length) {
|
||||||
|
ElMessage.warning("请先勾选需要修改的知识库");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.assign(batchForm, { knowledgeType: "" });
|
||||||
|
batchOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBatchMetadata() {
|
||||||
|
if (!batchForm.knowledgeType) {
|
||||||
|
ElMessage.warning("请选择知识库类型");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await confirmAction(
|
||||||
|
`将修改已选择的 ${selectedRows.value.length} 个知识库。该操作不影响正式版本和开放状态。`,
|
||||||
|
"确认批量修改",
|
||||||
|
{ confirmButtonText: "确认修改", cancelButtonText: "取消", type: "warning" },
|
||||||
|
)) return;
|
||||||
|
batchSaving.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.batchUpdateKnowledgeMetadata({
|
||||||
|
knowledgeIds: selectedRows.value.map((item) => item.id),
|
||||||
|
knowledgeType: batchForm.knowledgeType || undefined,
|
||||||
|
});
|
||||||
|
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed} 个`);
|
||||||
|
batchOpen.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "批量修改失败"));
|
||||||
|
} finally {
|
||||||
|
batchSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMore(command: string, item: KnowledgeItem) {
|
||||||
|
try {
|
||||||
|
if (command === "detail") return await openDetail(item);
|
||||||
|
if (command === "preview") return emit("preview", item.id);
|
||||||
|
if (command === "versions") {
|
||||||
|
await openDetail(item);
|
||||||
|
if (detailOpen.value) activeTab.value = "versions";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command === "archive") return await archive(item);
|
||||||
|
if (command === "restore") return await restore(item);
|
||||||
|
if (command === "delete") return await remove(item);
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel" && error !== "close") ElMessage.error(errorMessage(error, "操作失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore(item: KnowledgeItem) {
|
||||||
|
if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", {
|
||||||
|
confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning",
|
||||||
|
})) return;
|
||||||
|
await api.setKnowledgeLifecycle(item.id, "active");
|
||||||
|
ElMessage.success("知识库已恢复,当前仍为关闭状态");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createKnowledge() {
|
||||||
|
if (!nodeResolved.value || !form.name || !form.feishuSpaceId || !form.feishuNodeId) {
|
||||||
|
ElMessage.warning("请先通过 Node ID 获取并确认知识库信息");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.createKnowledge({ ...form });
|
||||||
|
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", status: 0, remark: "" });
|
||||||
|
nodeResolved.value = false; sourceTitle.value = "";
|
||||||
|
ElMessage.success("知识库已新增,首次同步成功后将自动生效");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识库新增失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveNode() {
|
||||||
|
const nodeId = form.feishuNodeId.trim();
|
||||||
|
if (!nodeId) return ElMessage.warning("请输入 Node ID");
|
||||||
|
resolvingNode.value = true;
|
||||||
|
try {
|
||||||
|
const info = await api.resolveKnowledgeNode(nodeId);
|
||||||
|
Object.assign(form, { feishuNodeId: info.nodeId, feishuSpaceId: info.spaceId, name: info.name, remark: info.remark || "" });
|
||||||
|
sourceTitle.value = info.sourceTitle; nodeResolved.value = true;
|
||||||
|
ElMessage.success("已获取飞书知识库信息");
|
||||||
|
} catch (error) {
|
||||||
|
nodeResolved.value = false; sourceTitle.value = "";
|
||||||
|
ElMessage.error(errorMessage(error, "Node ID 信息获取失败"));
|
||||||
|
} finally { resolvingNode.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(item: KnowledgeItem) {
|
||||||
|
detailOpen.value = true;
|
||||||
|
detail.value = null;
|
||||||
|
activeTab.value = "overview";
|
||||||
|
previewVersionId.value = null;
|
||||||
|
try {
|
||||||
|
const refreshedDetail = await refreshDetail(item.id);
|
||||||
|
previewVersionId.value = refreshedDetail.currentVersion?.id ?? versions.value[0]?.id ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
detailOpen.value = false;
|
||||||
|
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDetail(id: number) {
|
||||||
|
const [nextDetail, nextVersions, nextJobs] = await Promise.all([
|
||||||
|
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
|
||||||
|
]);
|
||||||
|
detail.value = nextDetail;
|
||||||
|
versions.value = nextVersions;
|
||||||
|
jobs.value = nextJobs;
|
||||||
|
return nextDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sync(item: KnowledgeItem) {
|
||||||
|
operatingId.value = item.id;
|
||||||
|
try {
|
||||||
|
const result = await api.syncKnowledge(item.id);
|
||||||
|
const message = result.status === "unchanged"
|
||||||
|
? "飞书内容未变化,未生成新版本"
|
||||||
|
: result.status === "published"
|
||||||
|
? "同步完成,最新版本已自动生效"
|
||||||
|
: "同步完成";
|
||||||
|
ElMessage.success(message);
|
||||||
|
await load();
|
||||||
|
if (detailOpen.value) await refreshDetail(item.id);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识库同步失败"));
|
||||||
|
} finally {
|
||||||
|
operatingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMigration() {
|
||||||
|
if (!await confirmAction(
|
||||||
|
"将依次重新读取全部飞书文章。每个知识库同步成功后会立即替换为最新版本,原版本仍可回滚。",
|
||||||
|
"开始知识迁移",
|
||||||
|
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
|
||||||
|
)) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await api.runKnowledgeMigration();
|
||||||
|
ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`);
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "知识迁移失败"));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollback(version: KnowledgeVersion) {
|
||||||
|
if (!detail.value) return;
|
||||||
|
if (!await confirmAction(
|
||||||
|
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
|
||||||
|
"回滚知识版本",
|
||||||
|
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
|
||||||
|
)) return;
|
||||||
|
try {
|
||||||
|
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
|
||||||
|
ElMessage.success("知识版本已回滚");
|
||||||
|
await load();
|
||||||
|
await refreshDetail(detail.value.knowledge.id);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, "版本回滚失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
|
||||||
|
const next = item.status !== 1;
|
||||||
|
if (!await confirmAction(
|
||||||
|
next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。",
|
||||||
|
next ? "开放知识库" : "关闭知识库",
|
||||||
|
{ confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" },
|
||||||
|
)) return false;
|
||||||
|
operatingId.value = item.id;
|
||||||
|
try {
|
||||||
|
await api.setKnowledgeOpen(item.id, next);
|
||||||
|
ElMessage.success(next ? "知识库已开放" : "知识库已关闭");
|
||||||
|
if (detailOpen.value && detail.value?.knowledge.id === item.id) await refreshDetail(item.id);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(errorMessage(error, next ? "知识库开放失败" : "知识库关闭失败"));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
operatingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archive(item: KnowledgeItem) {
|
||||||
|
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录将保留。", "归档知识库", {
|
||||||
|
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
|
||||||
|
})) return;
|
||||||
|
await api.setKnowledgeLifecycle(item.id, "archived");
|
||||||
|
ElMessage.success("知识库已归档");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(item: KnowledgeItem) {
|
||||||
|
if (!await confirmAction(
|
||||||
|
`将物理删除“${item.name}”及关联数据。该操作不可恢复,并可能影响历史检索记录定位。`,
|
||||||
|
"物理删除知识库",
|
||||||
|
{ confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" },
|
||||||
|
)) return;
|
||||||
|
await api.deleteKnowledge(item.id);
|
||||||
|
ElMessage.success("知识库已删除");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showVersion(version: KnowledgeVersion) {
|
||||||
|
previewVersionId.value = version.id;
|
||||||
|
activeTab.value = "content";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusType(status: string) {
|
||||||
|
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
|
||||||
|
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
|
||||||
|
if (["pending_review", "processing", "pending"].includes(status)) return "warning";
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(value: string) {
|
||||||
|
return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAction(
|
||||||
|
message: string,
|
||||||
|
title: string,
|
||||||
|
options: Parameters<typeof ElMessageBox.confirm>[2],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(message, title, options);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-head inline">
|
||||||
|
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库;同步成功后最新版本自动生效,历史版本可随时回滚。</p></div>
|
||||||
|
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="migration-overview">
|
||||||
|
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
|
||||||
|
<div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div>
|
||||||
|
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
|
||||||
|
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="knowledge-work-panel">
|
||||||
|
<h3>通过 Node ID 快速新增</h3>
|
||||||
|
<div class="node-resolve-row">
|
||||||
|
<el-input v-model="form.feishuNodeId" placeholder="请输入飞书 Node ID" :disabled="resolvingNode" @input="nodeResolved = false" />
|
||||||
|
<el-button type="primary" :loading="resolvingNode" @click="resolveNode">获取知识库信息</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="nodeResolved" :title="`已获取:${sourceTitle}`" type="success" :closable="false" show-icon />
|
||||||
|
<div v-if="nodeResolved" class="knowledge-create-grid">
|
||||||
|
<el-input v-model="form.feishuSpaceId" placeholder="Space ID" disabled />
|
||||||
|
<el-input :model-value="sourceTitle" placeholder="飞书原标题" disabled />
|
||||||
|
<el-input v-model="form.name" placeholder="后台显示名称" />
|
||||||
|
<el-select v-model="form.knowledgeType">
|
||||||
|
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
|
||||||
|
</el-select>
|
||||||
|
<el-input v-model="form.remark" placeholder="备注(可修改)" />
|
||||||
|
<el-button type="primary" @click="createKnowledge">保存知识库</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="knowledge-filters">
|
||||||
|
<el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable @change="load(1)" />
|
||||||
|
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select>
|
||||||
|
<el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select>
|
||||||
|
<el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="knowledge-batch-bar">
|
||||||
|
<span>已选择 {{ selectedRows.length }} 个知识库</span>
|
||||||
|
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
|
||||||
|
<el-table-column type="selection" width="48" />
|
||||||
|
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
|
||||||
|
<el-table-column label="类型" width="140"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}</template></el-table-column>
|
||||||
|
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
|
||||||
|
<el-table-column prop="lastSyncedAt" label="最近同步" width="180" />
|
||||||
|
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row)">同步最新版本</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item command="versions">查看版本</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
|
||||||
|
|
||||||
|
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
|
||||||
|
<template v-if="detail">
|
||||||
|
<div class="knowledge-detail-head">
|
||||||
|
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · 同步后自动生效</p></div>
|
||||||
|
<div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
|
||||||
|
</div>
|
||||||
|
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="基本信息" name="overview">
|
||||||
|
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
|
||||||
|
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>版本更新</dt><dd>同步成功后自动替换</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>当前版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd></dl>
|
||||||
|
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途:</strong>{{ detail.manifest.purpose }}</p><p><strong>适用:</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用:</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题:</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界:</strong>{{ detail.manifest.boundaries }}</p></section>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="内容预览" name="content" lazy>
|
||||||
|
<KnowledgeContentPreview v-model:version-id="previewVersionId" :versions="versions" />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></el-tab-pane>
|
||||||
|
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="editOpen" title="编辑知识库基本信息" width="min(520px, 92vw)" append-to-body destroy-on-close>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
|
||||||
|
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
|
||||||
|
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="batchOpen" title="批量修改知识库" width="min(520px, 92vw)" append-to-body destroy-on-close>
|
||||||
|
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
|
||||||
|
<el-form label-position="top" class="batch-edit-form">
|
||||||
|
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.knowledge-batch-bar,
|
||||||
|
.knowledge-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-batch-bar {
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 8px 0;
|
||||||
|
color: #66756f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-section-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-status-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-detail-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-edit-form {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div class="table-row-actions"><slot /></div>
|
||||||
|
</template>
|
||||||
277
ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts
Normal file
277
ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
export type SystemSettingValue = string | number | boolean;
|
||||||
|
type SystemSettingType = "number" | "switch" | "text" | "password";
|
||||||
|
|
||||||
|
export interface SystemSettingDefinition {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: SystemSettingType;
|
||||||
|
defaultValue: SystemSettingValue;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemSettingSection {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
settings: SystemSettingDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const systemSettingSections: SystemSettingSection[] = [
|
||||||
|
{
|
||||||
|
title: "用户与登录",
|
||||||
|
description: "控制学员准入、默认资料、验证码和登录态有效时间。",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: "daily_chat_limit",
|
||||||
|
label: "默认每日聊天额度",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 100,
|
||||||
|
min: 0,
|
||||||
|
max: 100000,
|
||||||
|
description: "新建或导入学员未单独填写额度时使用。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "access_token_expire_minutes",
|
||||||
|
label: "登录 Token 有效期(分钟)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 43200,
|
||||||
|
min: 5,
|
||||||
|
max: 43200,
|
||||||
|
description: "用户端和管理端登录后的 Token 有效期。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sms_code_expire_minutes",
|
||||||
|
label: "验证码有效期(分钟)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 5,
|
||||||
|
min: 1,
|
||||||
|
max: 60,
|
||||||
|
description: "短信验证码生成后的可使用时间。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mock_sms_enabled",
|
||||||
|
label: "使用 mock 短信",
|
||||||
|
type: "switch",
|
||||||
|
defaultValue: true,
|
||||||
|
description: "开发和演示环境可开启,生产环境应关闭并接入真实短信。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mock_sms_code",
|
||||||
|
label: "mock 短信验证码",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "123456",
|
||||||
|
placeholder: "例如 123456",
|
||||||
|
description: "mock 短信开启时使用的固定验证码。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aliyun_sms_access_key_id",
|
||||||
|
label: "阿里云 AccessKey ID",
|
||||||
|
type: "password",
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "请输入 RAM 用户 AccessKey ID",
|
||||||
|
description: "关闭 mock 短信后用于调用阿里云短信服务。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aliyun_sms_access_key_secret",
|
||||||
|
label: "阿里云 AccessKey Secret",
|
||||||
|
type: "password",
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "请输入 RAM 用户 AccessKey Secret",
|
||||||
|
description: "敏感配置,建议使用最小权限 RAM 用户。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aliyun_sms_sign_name",
|
||||||
|
label: "阿里云短信签名",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "例如 某某课堂",
|
||||||
|
description: "需与阿里云短信控制台审核通过的签名一致。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aliyun_sms_template_code",
|
||||||
|
label: "阿里云模板 Code",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "例如 SMS_123456789",
|
||||||
|
description: "验证码短信模板 Code。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aliyun_sms_template_param_key",
|
||||||
|
label: "验证码变量名",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "code",
|
||||||
|
placeholder: "默认 code",
|
||||||
|
description: "模板中验证码变量名,例如模板变量为 ${code} 时填写 code。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aliyun_sms_endpoint",
|
||||||
|
label: "阿里云短信 Endpoint",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "dysmsapi.aliyuncs.com",
|
||||||
|
placeholder: "dysmsapi.aliyuncs.com",
|
||||||
|
description: "一般保持默认值即可。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "default_user_name_prefix",
|
||||||
|
label: "默认用户名前缀",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "用户",
|
||||||
|
placeholder: "例如 用户",
|
||||||
|
description: "系统需要生成默认名称时使用的前缀。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AI 问答",
|
||||||
|
description: "控制 Agent 调用、上下文和用户端回答展示策略。",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: "ai_timeout_seconds",
|
||||||
|
label: "AI 请求超时(秒)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 60,
|
||||||
|
min: 5,
|
||||||
|
max: 300,
|
||||||
|
description: "真实模型请求超过该时间应判定为超时。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "mock_model_enabled",
|
||||||
|
label: "使用 mock 大模型",
|
||||||
|
type: "switch",
|
||||||
|
defaultValue: true,
|
||||||
|
description: "开发和演示环境可开启,真实模型验收前应关闭。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chat_context_message_count",
|
||||||
|
label: "上下文消息数",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 10,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
description: "当前会话带入模型的最近历史消息条数;更早内容会滚动摘要,设为 0 将关闭会话记忆。修改后下一次提问立即生效。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "show_reference_sources",
|
||||||
|
label: "用户端展示引用来源",
|
||||||
|
type: "switch",
|
||||||
|
defaultValue: true,
|
||||||
|
description: "后台始终记录引用;此项控制用户端是否展示。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chat_max_active_requests",
|
||||||
|
label: "问答最大并发数",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 2,
|
||||||
|
min: 1,
|
||||||
|
max: 1000,
|
||||||
|
description: "同一后端进程内同时进入飞书和模型生成链路的最大请求数。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chat_max_queue_size",
|
||||||
|
label: "问答最大排队数",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 20,
|
||||||
|
min: 0,
|
||||||
|
max: 10000,
|
||||||
|
description: "超过最大并发后允许等待的请求数量,超过后直接提示稍后再试。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chat_queue_timeout_seconds",
|
||||||
|
label: "问答排队超时(秒)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 60,
|
||||||
|
min: 1,
|
||||||
|
max: 3600,
|
||||||
|
description: "请求在排队中超过该时间后自动结束并提示用户稍后再试。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chat_active_lease_seconds",
|
||||||
|
label: "问答执行租约(秒)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 900,
|
||||||
|
min: 60,
|
||||||
|
max: 86400,
|
||||||
|
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "外部服务",
|
||||||
|
description: "控制飞书搜索接口、飞书直读超时和重试策略。",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: "feishu_search_url",
|
||||||
|
label: "飞书检索服务地址",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "例如 https://example.com/search",
|
||||||
|
description: "如使用独立飞书检索适配服务,在这里填写调用地址。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "feishu_app_id",
|
||||||
|
label: "飞书应用 AppID",
|
||||||
|
type: "text",
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "填写飞书开放平台应用 AppID",
|
||||||
|
description: "用于后端直读飞书知识库;系统设置优先于环境变量。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "feishu_app_secret",
|
||||||
|
label: "飞书应用 AppSecret",
|
||||||
|
type: "password",
|
||||||
|
defaultValue: "",
|
||||||
|
placeholder: "填写飞书开放平台应用 AppSecret",
|
||||||
|
description: "用于后端获取飞书 tenant_access_token,保存后立即生效。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "feishu_timeout_seconds",
|
||||||
|
label: "飞书请求超时(秒)",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 20,
|
||||||
|
min: 1,
|
||||||
|
max: 120,
|
||||||
|
description: "飞书相关接口调用超过该时间应判定为超时。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "feishu_retry_count",
|
||||||
|
label: "飞书检索重试次数",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 2,
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
description: "飞书检索失败时的最大重试次数。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "系统开关",
|
||||||
|
description: "控制系统级展示和运行策略,不包含密钥类配置。",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: "show_operation_log_detail",
|
||||||
|
label: "展示操作日志详情",
|
||||||
|
type: "switch",
|
||||||
|
defaultValue: true,
|
||||||
|
description: "开启后后台审计页可展示更完整的操作上下文。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "chat_export_enabled",
|
||||||
|
label: "允许导出聊天记录",
|
||||||
|
type: "switch",
|
||||||
|
defaultValue: true,
|
||||||
|
description: "关闭后后台不应提供聊天记录导出能力。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "maintenance_mode",
|
||||||
|
label: "维护模式",
|
||||||
|
type: "switch",
|
||||||
|
defaultValue: false,
|
||||||
|
description: "预留给后续停机维护提示和访问控制使用。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const systemSettingDefinitions = systemSettingSections.flatMap((section) => section.settings);
|
||||||
101
ai_knowledge_base_v2/apps/admin-web/src/main.ts
Normal file
101
ai_knowledge_base_v2/apps/admin-web/src/main.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
ElAlert,
|
||||||
|
ElButton,
|
||||||
|
ElCollapse,
|
||||||
|
ElCollapseItem,
|
||||||
|
ElDatePicker,
|
||||||
|
ElDivider,
|
||||||
|
ElDialog,
|
||||||
|
ElDrawer,
|
||||||
|
ElDropdown,
|
||||||
|
ElDropdownItem,
|
||||||
|
ElDropdownMenu,
|
||||||
|
ElEmpty,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElInputNumber,
|
||||||
|
ElOption,
|
||||||
|
ElPagination,
|
||||||
|
ElProgress,
|
||||||
|
ElSelect,
|
||||||
|
ElSwitch,
|
||||||
|
ElTabPane,
|
||||||
|
ElTable,
|
||||||
|
ElTableColumn,
|
||||||
|
ElTabs,
|
||||||
|
ElTag,
|
||||||
|
ElTooltip,
|
||||||
|
vLoading,
|
||||||
|
} from "element-plus";
|
||||||
|
import "element-plus/theme-chalk/base.css";
|
||||||
|
import "element-plus/theme-chalk/el-alert.css";
|
||||||
|
import "element-plus/theme-chalk/el-button.css";
|
||||||
|
import "element-plus/theme-chalk/el-collapse.css";
|
||||||
|
import "element-plus/theme-chalk/el-date-picker.css";
|
||||||
|
import "element-plus/theme-chalk/el-divider.css";
|
||||||
|
import "element-plus/theme-chalk/el-dialog.css";
|
||||||
|
import "element-plus/theme-chalk/el-drawer.css";
|
||||||
|
import "element-plus/theme-chalk/el-dropdown.css";
|
||||||
|
import "element-plus/theme-chalk/el-empty.css";
|
||||||
|
import "element-plus/theme-chalk/el-form.css";
|
||||||
|
import "element-plus/theme-chalk/el-input.css";
|
||||||
|
import "element-plus/theme-chalk/el-input-number.css";
|
||||||
|
import "element-plus/theme-chalk/el-loading.css";
|
||||||
|
import "element-plus/theme-chalk/el-message.css";
|
||||||
|
import "element-plus/theme-chalk/el-message-box.css";
|
||||||
|
import "element-plus/theme-chalk/el-option.css";
|
||||||
|
import "element-plus/theme-chalk/el-overlay.css";
|
||||||
|
import "element-plus/theme-chalk/el-pagination.css";
|
||||||
|
import "element-plus/theme-chalk/el-progress.css";
|
||||||
|
import "element-plus/theme-chalk/el-popper.css";
|
||||||
|
import "element-plus/theme-chalk/el-scrollbar.css";
|
||||||
|
import "element-plus/theme-chalk/el-select.css";
|
||||||
|
import "element-plus/theme-chalk/el-switch.css";
|
||||||
|
import "element-plus/theme-chalk/el-table.css";
|
||||||
|
import "element-plus/theme-chalk/el-tabs.css";
|
||||||
|
import "element-plus/theme-chalk/el-tag.css";
|
||||||
|
import "element-plus/theme-chalk/el-tooltip.css";
|
||||||
|
import { createApp } from "vue";
|
||||||
|
import zhCn from "element-plus/es/locale/lang/zh-cn";
|
||||||
|
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.config.globalProperties.$ELEMENT = { locale: zhCn };
|
||||||
|
|
||||||
|
[
|
||||||
|
ElAlert,
|
||||||
|
ElButton,
|
||||||
|
ElCollapse,
|
||||||
|
ElCollapseItem,
|
||||||
|
ElDatePicker,
|
||||||
|
ElDivider,
|
||||||
|
ElDialog,
|
||||||
|
ElDrawer,
|
||||||
|
ElDropdown,
|
||||||
|
ElDropdownItem,
|
||||||
|
ElDropdownMenu,
|
||||||
|
ElEmpty,
|
||||||
|
ElForm,
|
||||||
|
ElFormItem,
|
||||||
|
ElInput,
|
||||||
|
ElInputNumber,
|
||||||
|
ElOption,
|
||||||
|
ElPagination,
|
||||||
|
ElProgress,
|
||||||
|
ElSelect,
|
||||||
|
ElSwitch,
|
||||||
|
ElTabPane,
|
||||||
|
ElTable,
|
||||||
|
ElTableColumn,
|
||||||
|
ElTabs,
|
||||||
|
ElTag,
|
||||||
|
ElTooltip,
|
||||||
|
].forEach((component) => {
|
||||||
|
app.use(component);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.directive("loading", vLoading);
|
||||||
|
app.mount("#app");
|
||||||
217
ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
Normal file
217
ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import type {
|
||||||
|
AdminProfile,
|
||||||
|
AdminUser,
|
||||||
|
AgentDebugResult,
|
||||||
|
AgentGenerationConfig,
|
||||||
|
AgentRuntimeConfig,
|
||||||
|
AiLogRecord,
|
||||||
|
ApiResponse,
|
||||||
|
ChatDetail,
|
||||||
|
ChatRecord,
|
||||||
|
ChatRecordQuery,
|
||||||
|
DashboardStats,
|
||||||
|
KnowledgeItem,
|
||||||
|
KnowledgeDetail,
|
||||||
|
KnowledgeSyncJob,
|
||||||
|
KnowledgeVersion,
|
||||||
|
KnowledgeVersionContent,
|
||||||
|
MigrationSummary,
|
||||||
|
KnowledgeBatchUpdateResult,
|
||||||
|
RetrievalLogItem,
|
||||||
|
AttentionItem,
|
||||||
|
ModelItem,
|
||||||
|
SystemConfigItem,
|
||||||
|
UserImportResult,
|
||||||
|
PageResult,
|
||||||
|
PromptDetail,
|
||||||
|
PromptHistoryItem,
|
||||||
|
} from "../types/api";
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||||
|
const TOKEN_KEY = "ai-kb-admin-token";
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return window.localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveToken(token: string) {
|
||||||
|
window.localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearToken() {
|
||||||
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||||
|
const body = await readApiResponse<T>(response);
|
||||||
|
if (!response.ok || body.code !== 0) {
|
||||||
|
throw new Error(body.detail || body.message || `${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readApiResponse<T>(response: Response): Promise<ApiResponse<T> & { detail?: string }> {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error(response.ok ? "接口返回为空" : `服务暂时不可用(${response.status})`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as ApiResponse<T> & { detail?: string };
|
||||||
|
} catch {
|
||||||
|
throw new Error(response.ok ? "接口返回格式不正确" : `服务暂时不可用(${response.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload<T>(path: string, formData: FormData): Promise<T> {
|
||||||
|
const headers = new Headers();
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers });
|
||||||
|
const body = await readApiResponse<T>(response);
|
||||||
|
if (!response.ok || body.code !== 0) {
|
||||||
|
throw new Error(body.message || body.detail || `${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return body.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
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", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
}),
|
||||||
|
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
|
||||||
|
profile: () => request<AdminProfile>("/admin/profile"),
|
||||||
|
dashboard: (start?: string, end?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (start) params.set("start", start);
|
||||||
|
if (end) params.set("end", end);
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
|
||||||
|
},
|
||||||
|
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
|
||||||
|
createUser: (payload: Record<string, unknown>) =>
|
||||||
|
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
importUsers: (students: Record<string, unknown>[]) =>
|
||||||
|
request<UserImportResult>("/admin/user/import", { method: "POST", body: JSON.stringify({ students }) }),
|
||||||
|
downloadStudentTemplate: () => download("/admin/user/import/template", "学员导入模板.xlsx"),
|
||||||
|
importUsersExcel: (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return upload<UserImportResult>("/admin/user/import/excel", formData);
|
||||||
|
},
|
||||||
|
updateUser: (id: number, payload: Record<string, unknown>) =>
|
||||||
|
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
|
||||||
|
knowledge: (query: Record<string, unknown> = {}) => request<PageResult<KnowledgeItem>>(`/admin/knowledge/list${queryString(query)}`),
|
||||||
|
knowledgeOptions: () => request<KnowledgeItem[]>("/admin/knowledge/options"),
|
||||||
|
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }),
|
||||||
|
createKnowledge: (payload: Record<string, unknown>) =>
|
||||||
|
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
|
||||||
|
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string }) =>
|
||||||
|
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
|
||||||
|
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string }) =>
|
||||||
|
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
|
||||||
|
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
|
||||||
|
importKnowledgeFromSpace: (body: { node_token: string }) =>
|
||||||
|
request<{ imported: number; skipped: number; id: number; name: string }>(
|
||||||
|
"/admin/knowledge/import",
|
||||||
|
{ method: "POST", body: JSON.stringify(body) },
|
||||||
|
),
|
||||||
|
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
|
||||||
|
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
|
||||||
|
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
|
||||||
|
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
|
||||||
|
syncKnowledge: (id: number) =>
|
||||||
|
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
|
||||||
|
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
|
||||||
|
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
|
||||||
|
setKnowledgeOpen: (id: number, open: boolean) =>
|
||||||
|
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
|
||||||
|
setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>
|
||||||
|
request<KnowledgeItem>(`/admin/knowledge/${id}/lifecycle`, { method: "PUT", body: JSON.stringify({ status }) }),
|
||||||
|
migrationSummary: () => request<MigrationSummary>("/admin/knowledge-migration/summary"),
|
||||||
|
runKnowledgeMigration: (knowledgeId?: number) =>
|
||||||
|
request<{ total: number; success: number; results: Record<string, unknown>[] }>(`/admin/knowledge-migration/run${knowledgeId ? `?knowledgeId=${knowledgeId}` : ""}`, { method: "POST", body: "{}" }),
|
||||||
|
prompt: () => request<PromptDetail>("/admin/prompt"),
|
||||||
|
savePrompt: (promptContent: string) =>
|
||||||
|
request<PromptDetail>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
|
||||||
|
resetPrompt: () => request<PromptDetail>("/admin/prompt/reset", { method: "POST", body: "{}" }),
|
||||||
|
promptHistory: (query: { page?: number; pageSize?: number } = {}) =>
|
||||||
|
request<PageResult<PromptHistoryItem>>(`/admin/prompt/history${queryString(query)}`),
|
||||||
|
promptHistoryDetail: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}`),
|
||||||
|
restorePrompt: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}/restore`, { method: "POST", body: "{}" }),
|
||||||
|
debugAgent: (payload: Record<string, unknown>) =>
|
||||||
|
request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),
|
||||||
|
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
|
||||||
|
request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
models: () => request<ModelItem[]>("/admin/model/list"),
|
||||||
|
createModel: (payload: Record<string, unknown>) =>
|
||||||
|
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
updateModel: (id: number, payload: Record<string, unknown>) =>
|
||||||
|
request<ModelItem>(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
enableModel: (modelId: number) =>
|
||||||
|
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }),
|
||||||
|
deleteModel: (modelId: number) =>
|
||||||
|
request<null>(`/admin/model/${modelId}`, { method: "DELETE" }),
|
||||||
|
testModel: (modelId: number) =>
|
||||||
|
request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
body: "{}",
|
||||||
|
}),
|
||||||
|
configs: () => request<SystemConfigItem[]>("/admin/config"),
|
||||||
|
saveConfig: (payload: Record<string, unknown>) =>
|
||||||
|
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
|
||||||
|
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
|
||||||
|
chatDetail: (sessionId: number) => request<ChatDetail>(`/admin/chat/${sessionId}`),
|
||||||
|
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
|
||||||
|
aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
|
request<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`),
|
||||||
|
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
|
||||||
|
operationLogs: (query: { module?: string; page?: number; pageSize?: number } = {}) => request<PageResult<Record<string, unknown>>>(`/admin/log/list${queryString(query)}`),
|
||||||
|
retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<RetrievalLogItem>>(`/admin/retrieval-log/list${queryString(query)}`),
|
||||||
|
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
|
||||||
|
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),
|
||||||
|
retrievalRetention: () => request<{ enabled: boolean; retentionDays: number | null; lastRunAt: string | null; lastResult: string | null }>("/admin/retrieval-log/retention"),
|
||||||
|
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
|
||||||
|
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
|
||||||
|
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
|
||||||
|
attentionList: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
|
||||||
|
updateAttention: (id: number, status: string, note?: string) =>
|
||||||
|
request<AttentionItem>(`/admin/attention/${id}`, { method: "PUT", body: JSON.stringify({ status, note }) }),
|
||||||
|
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
|
||||||
|
clearFeishuCache: () =>
|
||||||
|
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
|
||||||
|
};
|
||||||
1855
ai_knowledge_base_v2/apps/admin-web/src/styles.css
Normal file
1855
ai_knowledge_base_v2/apps/admin-web/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
320
ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
Normal file
320
ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
export interface ApiResponse<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageResult<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProfile {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
userCount: number;
|
||||||
|
sessionCount: number;
|
||||||
|
messageCount: number;
|
||||||
|
aiRequestCount: number;
|
||||||
|
knowledgeCount: number;
|
||||||
|
inputToken: number;
|
||||||
|
outputToken: number;
|
||||||
|
totalToken: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptDetail {
|
||||||
|
id: number | null;
|
||||||
|
promptContent: string;
|
||||||
|
changeType: "default" | "save" | "reset" | "restore";
|
||||||
|
sourcePromptId?: number | null;
|
||||||
|
updatedByName?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
charCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptHistoryItem {
|
||||||
|
id: number;
|
||||||
|
preview: string;
|
||||||
|
charCount: number;
|
||||||
|
changeType: "save" | "reset" | "restore";
|
||||||
|
sourcePromptId?: number | null;
|
||||||
|
updatedByName: string;
|
||||||
|
updatedAt: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUser {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
name: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
status: number;
|
||||||
|
dailyChatLimit: number;
|
||||||
|
dailyChatUsed: number;
|
||||||
|
expiredAt?: string | null;
|
||||||
|
lastLoginAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserImportFailure {
|
||||||
|
row: number;
|
||||||
|
phone: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserImportResult {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
failed: number;
|
||||||
|
failures: UserImportFailure[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemConfigItem {
|
||||||
|
id: number;
|
||||||
|
configKey: string;
|
||||||
|
configValue: string;
|
||||||
|
description?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
feishuSpaceId: string;
|
||||||
|
feishuNodeId: string;
|
||||||
|
status: number;
|
||||||
|
remark?: string | null;
|
||||||
|
sourceTitle?: string | null;
|
||||||
|
knowledgeType: "course" | "qa" | "general";
|
||||||
|
lifecycleStatus: "active" | "archived";
|
||||||
|
sourceStatus: "normal" | "error";
|
||||||
|
sourceError?: string | null;
|
||||||
|
currentVersionId?: number | null;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
lastPublishedAt?: string | null;
|
||||||
|
migrationStatus: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelItem {
|
||||||
|
id: number;
|
||||||
|
provider: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
apiType: string;
|
||||||
|
modelName: string;
|
||||||
|
baseUrl?: string | null;
|
||||||
|
apiUrl: string;
|
||||||
|
apiKeyMasked: string;
|
||||||
|
authType: string;
|
||||||
|
apiVersion?: string | null;
|
||||||
|
temperature?: number | null;
|
||||||
|
topP?: number | null;
|
||||||
|
topK?: number | null;
|
||||||
|
presencePenalty?: number | null;
|
||||||
|
frequencyPenalty?: number | null;
|
||||||
|
maxToken?: number | null;
|
||||||
|
contextWindow?: number | null;
|
||||||
|
streamEnabled: number;
|
||||||
|
responseFormat?: string | null;
|
||||||
|
extraParams?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
timeoutSecond: number;
|
||||||
|
enabled: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentGenerationConfig {
|
||||||
|
temperature: number | null;
|
||||||
|
topP: number | null;
|
||||||
|
topK: number | null;
|
||||||
|
presencePenalty: number | null;
|
||||||
|
frequencyPenalty: number | null;
|
||||||
|
maxToken: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRuntimeConfig extends AgentGenerationConfig {
|
||||||
|
modelId: number | null;
|
||||||
|
modelName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentDebugResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
answer: string;
|
||||||
|
prompt: string;
|
||||||
|
modelName?: string;
|
||||||
|
retrieveCount?: number;
|
||||||
|
knowledgeIds?: string;
|
||||||
|
retrievalTrace?: Record<string, unknown>[];
|
||||||
|
retrievalLogId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeVersion {
|
||||||
|
id: number;
|
||||||
|
knowledgeId: number;
|
||||||
|
snapshotId: number;
|
||||||
|
versionNo: number;
|
||||||
|
status: string;
|
||||||
|
qualityStatus: string;
|
||||||
|
changeSummary?: Record<string, unknown> | null;
|
||||||
|
qualityReport?: Record<string, unknown> | null;
|
||||||
|
processingRuleVersion: string;
|
||||||
|
generationModel?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
publishedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeManifest {
|
||||||
|
id: number;
|
||||||
|
versionId: number;
|
||||||
|
purpose: string;
|
||||||
|
applicableQuestions: string;
|
||||||
|
inapplicableQuestions: string;
|
||||||
|
coreTopics: string;
|
||||||
|
entities?: string | null;
|
||||||
|
boundaries?: string | null;
|
||||||
|
changeLevel: string;
|
||||||
|
confirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeDetail {
|
||||||
|
knowledge: KnowledgeItem;
|
||||||
|
currentVersion?: KnowledgeVersion | null;
|
||||||
|
manifest?: KnowledgeManifest | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeVersionContent {
|
||||||
|
version: KnowledgeVersion;
|
||||||
|
manifest?: KnowledgeManifest | null;
|
||||||
|
sections: Array<{ id: number; sectionKey: string; title: string; content: string; sourceStart: number; sourceEnd: number; contentHash: string }>;
|
||||||
|
cards: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeSyncJob {
|
||||||
|
id: number;
|
||||||
|
knowledgeId: number;
|
||||||
|
mode: string;
|
||||||
|
status: string;
|
||||||
|
stage: string;
|
||||||
|
progress: number;
|
||||||
|
sourceError?: string | null;
|
||||||
|
resultSummary?: Record<string, unknown> | null;
|
||||||
|
createdAt: string;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationSummary {
|
||||||
|
total: number;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeBatchUpdateResult {
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
results: Array<{ knowledgeId: number; ok: boolean; error?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetrievalLogItem {
|
||||||
|
id: number;
|
||||||
|
sessionId?: number | null;
|
||||||
|
question: string;
|
||||||
|
knowledgeCalled: boolean;
|
||||||
|
selectedKnowledgeIds?: string | null;
|
||||||
|
queryTerms?: string[] | null;
|
||||||
|
status: string;
|
||||||
|
toolCostMs?: number | null;
|
||||||
|
totalCostMs?: number | null;
|
||||||
|
attentionCreated: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttentionItem {
|
||||||
|
id: number;
|
||||||
|
sessionId: number;
|
||||||
|
messageId: number;
|
||||||
|
userId: number;
|
||||||
|
triggerMessage: string;
|
||||||
|
problemSummary: string;
|
||||||
|
triggerReason: string;
|
||||||
|
priority: string;
|
||||||
|
status: string;
|
||||||
|
handlerNote?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
retrievedChunks: RetrievedKnowledgeChunk[];
|
||||||
|
inputToken?: number | null;
|
||||||
|
outputToken?: number | null;
|
||||||
|
totalToken?: number | null;
|
||||||
|
costMs?: number | null;
|
||||||
|
status: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
prompt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetrievedKnowledgeChunk {
|
||||||
|
index: number;
|
||||||
|
knowledgeId?: number | null;
|
||||||
|
knowledgeName: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
sourceUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatDetail {
|
||||||
|
session: ChatRecord;
|
||||||
|
messages: ChatMessageRecord[];
|
||||||
|
aiLogs: AiLogRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRecordQuery {
|
||||||
|
keyword?: string;
|
||||||
|
userId?: number | null;
|
||||||
|
status?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
1
ai_knowledge_base_v2/apps/admin-web/src/vite-env.d.ts
vendored
Normal file
1
ai_knowledge_base_v2/apps/admin-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
17
ai_knowledge_base_v2/apps/admin-web/tsconfig.json
Normal file
17
ai_knowledge_base_v2/apps/admin-web/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
40
ai_knowledge_base_v2/apps/admin-web/vite.config.ts
Normal file
40
ai_knowledge_base_v2/apps/admin-web/vite.config.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "/login/",
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes("node_modules")) return undefined;
|
||||||
|
if (id.includes("element-plus/es/components/table")) return "el-table";
|
||||||
|
if (
|
||||||
|
id.includes("/vue/") ||
|
||||||
|
id.includes("@vue/") ||
|
||||||
|
id.includes("@vueuse") ||
|
||||||
|
id.includes("async-validator") ||
|
||||||
|
id.includes("@popperjs") ||
|
||||||
|
id.includes("@floating-ui") ||
|
||||||
|
id.includes("dayjs")
|
||||||
|
) {
|
||||||
|
return "vendor";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
allowedHosts: ["qa.huiyushuyuan.cn"],
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://backend:8100",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
7
ai_knowledge_base_v2/apps/backend/.dockerignore
Normal file
7
ai_knowledge_base_v2/apps/backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
47
ai_knowledge_base_v2/apps/backend/.env.example
Normal file
47
ai_knowledge_base_v2/apps/backend/.env.example
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
APP_NAME=AI Knowledge Base V2 Backend
|
||||||
|
APP_ENV=local
|
||||||
|
DEBUG=true
|
||||||
|
API_PREFIX=/api
|
||||||
|
|
||||||
|
DATABASE_URL=mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
|
||||||
|
REDIS_URL=
|
||||||
|
DB_POOL_SIZE=10
|
||||||
|
DB_MAX_OVERFLOW=20
|
||||||
|
DB_POOL_TIMEOUT=30
|
||||||
|
DB_POOL_RECYCLE=1800
|
||||||
|
AUTO_CREATE_TABLES=false
|
||||||
|
|
||||||
|
JWT_SECRET_KEY=change-me-in-production
|
||||||
|
CONFIG_ENCRYPTION_KEY=
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=43200
|
||||||
|
|
||||||
|
MOCK_SMS_ENABLED=true
|
||||||
|
MOCK_SMS_CODE=123456
|
||||||
|
SMS_CODE_EXPIRE_MINUTES=5
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_ID=
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||||
|
ALIYUN_SMS_SIGN_NAME=
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE=
|
||||||
|
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
|
||||||
|
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
|
||||||
|
MOCK_RAG_ENABLED=false
|
||||||
|
MOCK_MODEL_ENABLED=true
|
||||||
|
FEISHU_MOCK_ENABLED=false
|
||||||
|
FEISHU_SEARCH_URL=
|
||||||
|
FEISHU_APP_ID=
|
||||||
|
FEISHU_APP_SECRET=
|
||||||
|
FEISHU_TIMEOUT_SECONDS=20
|
||||||
|
FEISHU_RETRY_COUNT=2
|
||||||
|
|
||||||
|
DEFAULT_DAILY_CHAT_LIMIT=100
|
||||||
|
DEFAULT_USER_NAME_PREFIX=用户
|
||||||
|
CHAT_MAX_ACTIVE_REQUESTS=2
|
||||||
|
CHAT_MAX_QUEUE_SIZE=20
|
||||||
|
CHAT_QUEUE_TIMEOUT_SECONDS=60
|
||||||
|
CHAT_ACTIVE_LEASE_SECONDS=900
|
||||||
|
|
||||||
|
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
|
||||||
|
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD=admin123456
|
||||||
|
BOOTSTRAP_ADMIN_NAME=系统管理员
|
||||||
7
ai_knowledge_base_v2/apps/backend/.gitignore
vendored
Normal file
7
ai_knowledge_base_v2/apps/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
19
ai_knowledge_base_v2/apps/backend/Dockerfile
Normal file
19
ai_knowledge_base_v2/apps/backend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1} --no-access-log"]
|
||||||
52
ai_knowledge_base_v2/apps/backend/README.md
Normal file
52
ai_knowledge_base_v2/apps/backend/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 大本营答疑助手后端
|
||||||
|
|
||||||
|
这是大本营答疑助手的后端服务目录,当前处于阶段二第一批建设:后端基础工程、核心数据模型、mock 登录能力。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- FastAPI
|
||||||
|
- SQLAlchemy
|
||||||
|
- Alembic
|
||||||
|
- MySQL 8.x
|
||||||
|
- Pydantic Settings
|
||||||
|
- PyJWT
|
||||||
|
|
||||||
|
## 当前已包含
|
||||||
|
|
||||||
|
- 应用入口和健康检查
|
||||||
|
- 统一 JSON 响应结构
|
||||||
|
- 配置读取和 `.env` 示例
|
||||||
|
- SQLAlchemy 数据库连接
|
||||||
|
- Alembic 迁移骨架
|
||||||
|
- 核心 `sys_*` 数据模型
|
||||||
|
- mock 短信验证码
|
||||||
|
- 用户手机号验证码登录
|
||||||
|
- 用户 Token 鉴权
|
||||||
|
- 当前用户信息接口
|
||||||
|
|
||||||
|
## 本地启动
|
||||||
|
|
||||||
|
方式一:使用 V2 统一编排。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai_knowledge_base_v2
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
方式二:本地 Python 直接启动。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai_knowledge_base_v2/apps/backend
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
uvicorn app.main:app --reload --host 127.0.0.1 --port 8100
|
||||||
|
```
|
||||||
|
|
||||||
|
默认短信验证码为 `.env` 中的 `MOCK_SMS_CODE`,默认值是 `123456`。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 补管理员登录接口。
|
||||||
|
- 接入 Redis 替换内存短信验证码和 Token 黑名单。
|
||||||
37
ai_knowledge_base_v2/apps/backend/alembic.ini
Normal file
37
ai_knowledge_base_v2/apps/backend/alembic.ini
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
51
ai_knowledge_base_v2/apps/backend/alembic/env.py
Normal file
51
ai_knowledge_base_v2/apps/backend/alembic/env.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=settings.database_url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0001_initial_schema"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"sys_user",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("phone", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("nickname", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("status", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("daily_chat_limit", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("daily_chat_used", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("effective_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("expired_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("is_deleted", sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_user_phone", "sys_user", ["phone"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_role",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_role_code", "sys_role", ["code"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_admin",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("password", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("role_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("status", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["role_id"], ["sys_role.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_admin_username", "sys_admin", ["username"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("feishu_space_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("feishu_node_id", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("status", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("remark", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_user_kb",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("effective_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("expired_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_user_kb_user_id", "sys_user_kb", ["user_id"])
|
||||||
|
op.create_index("ix_sys_user_kb_knowledge_id", "sys_user_kb", ["knowledge_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_model",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("provider", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("model_name", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("api_url", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("api_key", sa.Text(), nullable=False),
|
||||||
|
sa.Column("temperature", sa.Numeric(4, 2), nullable=True),
|
||||||
|
sa.Column("max_token", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("timeout_second", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("enabled", sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_prompt",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("prompt_content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("updated_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_system_config",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("config_key", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("config_value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("updated_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_system_config_config_key", "sys_system_config", ["config_key"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_chat_session",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("message_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("last_message_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("is_deleted", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_chat_session_user_id", "sys_chat_session", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_chat_message",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("session_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("message_status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("token_input", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("token_output", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("response_time_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("model_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["model_id"], ["sys_model.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["session_id"], ["sys_chat_session.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_chat_message_session_id", "sys_chat_message", ["session_id"])
|
||||||
|
op.create_index("ix_sys_chat_message_user_id", "sys_chat_message", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_ai_request_log",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("session_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("message_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("model_name", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("prompt", sa.Text(), nullable=True),
|
||||||
|
sa.Column("knowledge_ids", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("retrieve_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("input_token", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("output_token", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("total_token", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("cost_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_ai_request_log_session_id", "sys_ai_request_log", ["session_id"])
|
||||||
|
op.create_index("ix_sys_ai_request_log_message_id", "sys_ai_request_log", ["message_id"])
|
||||||
|
op.create_index("ix_sys_ai_request_log_user_id", "sys_ai_request_log", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_operation_log",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("admin_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("module", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("action", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("target_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("before_json", sa.Text(), nullable=True),
|
||||||
|
sa.Column("after_json", sa.Text(), nullable=True),
|
||||||
|
sa.Column("ip", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("result", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sys_operation_log_admin_id", "sys_operation_log", ["admin_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_sys_operation_log_admin_id", table_name="sys_operation_log")
|
||||||
|
op.drop_table("sys_operation_log")
|
||||||
|
op.drop_index("ix_sys_ai_request_log_user_id", table_name="sys_ai_request_log")
|
||||||
|
op.drop_index("ix_sys_ai_request_log_message_id", table_name="sys_ai_request_log")
|
||||||
|
op.drop_index("ix_sys_ai_request_log_session_id", table_name="sys_ai_request_log")
|
||||||
|
op.drop_table("sys_ai_request_log")
|
||||||
|
op.drop_index("ix_sys_chat_message_user_id", table_name="sys_chat_message")
|
||||||
|
op.drop_index("ix_sys_chat_message_session_id", table_name="sys_chat_message")
|
||||||
|
op.drop_table("sys_chat_message")
|
||||||
|
op.drop_index("ix_sys_chat_session_user_id", table_name="sys_chat_session")
|
||||||
|
op.drop_table("sys_chat_session")
|
||||||
|
op.drop_index("ix_sys_system_config_config_key", table_name="sys_system_config")
|
||||||
|
op.drop_table("sys_system_config")
|
||||||
|
op.drop_table("sys_prompt")
|
||||||
|
op.drop_table("sys_model")
|
||||||
|
op.drop_index("ix_sys_user_kb_knowledge_id", table_name="sys_user_kb")
|
||||||
|
op.drop_index("ix_sys_user_kb_user_id", table_name="sys_user_kb")
|
||||||
|
op.drop_table("sys_user_kb")
|
||||||
|
op.drop_table("sys_knowledge")
|
||||||
|
op.drop_index("ix_sys_admin_username", table_name="sys_admin")
|
||||||
|
op.drop_table("sys_admin")
|
||||||
|
op.drop_index("ix_sys_role_code", table_name="sys_role")
|
||||||
|
op.drop_table("sys_role")
|
||||||
|
op.drop_index("ix_sys_user_phone", table_name="sys_user")
|
||||||
|
op.drop_table("sys_user")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0002_expand_model_config"
|
||||||
|
down_revision = "0001_initial_schema"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("sys_model", sa.Column("display_name", sa.String(length=100), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"sys_model",
|
||||||
|
sa.Column("api_type", sa.String(length=50), nullable=False, server_default="openai_compatible"),
|
||||||
|
)
|
||||||
|
op.add_column("sys_model", sa.Column("base_url", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"sys_model",
|
||||||
|
sa.Column("auth_type", sa.String(length=30), nullable=False, server_default="bearer"),
|
||||||
|
)
|
||||||
|
op.add_column("sys_model", sa.Column("api_version", sa.String(length=50), nullable=True))
|
||||||
|
op.add_column("sys_model", sa.Column("top_p", sa.Numeric(4, 2), nullable=True))
|
||||||
|
op.add_column("sys_model", sa.Column("top_k", sa.Integer(), nullable=True))
|
||||||
|
op.add_column("sys_model", sa.Column("presence_penalty", sa.Numeric(4, 2), nullable=True))
|
||||||
|
op.add_column("sys_model", sa.Column("frequency_penalty", sa.Numeric(4, 2), nullable=True))
|
||||||
|
op.add_column("sys_model", sa.Column("context_window", sa.Integer(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"sys_model",
|
||||||
|
sa.Column("stream_enabled", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
)
|
||||||
|
op.add_column("sys_model", sa.Column("response_format", sa.String(length=50), nullable=True))
|
||||||
|
op.add_column("sys_model", sa.Column("extra_params", sa.Text(), nullable=True))
|
||||||
|
op.add_column("sys_model", sa.Column("remark", sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
op.alter_column("sys_model", "api_type", server_default=None)
|
||||||
|
op.alter_column("sys_model", "auth_type", server_default=None)
|
||||||
|
op.alter_column("sys_model", "stream_enabled", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("sys_model", "remark")
|
||||||
|
op.drop_column("sys_model", "extra_params")
|
||||||
|
op.drop_column("sys_model", "response_format")
|
||||||
|
op.drop_column("sys_model", "stream_enabled")
|
||||||
|
op.drop_column("sys_model", "context_window")
|
||||||
|
op.drop_column("sys_model", "frequency_penalty")
|
||||||
|
op.drop_column("sys_model", "presence_penalty")
|
||||||
|
op.drop_column("sys_model", "top_k")
|
||||||
|
op.drop_column("sys_model", "top_p")
|
||||||
|
op.drop_column("sys_model", "api_version")
|
||||||
|
op.drop_column("sys_model", "auth_type")
|
||||||
|
op.drop_column("sys_model", "base_url")
|
||||||
|
op.drop_column("sys_model", "api_type")
|
||||||
|
op.drop_column("sys_model", "display_name")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0003_ai_log_retrieved_chunks"
|
||||||
|
down_revision = "0002_expand_model_config"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("sys_ai_request_log", sa.Column("retrieved_chunks", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("sys_ai_request_log", "retrieved_chunks")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0004_daily_chat_reset_date"
|
||||||
|
down_revision = "0003_ai_log_retrieved_chunks"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("sys_user", sa.Column("daily_chat_reset_date", sa.Date(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("sys_user", "daily_chat_reset_date")
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0005_chat_session_summary"
|
||||||
|
down_revision = "0004_daily_chat_reset_date"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("sys_chat_session", sa.Column("summary", sa.Text(), nullable=True))
|
||||||
|
op.add_column("sys_chat_session", sa.Column("summary_up_to_message_id", sa.BigInteger(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("sys_chat_session", "summary_up_to_message_id")
|
||||||
|
op.drop_column("sys_chat_session", "summary")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
revision = "0006_expand_ai_request_log_text"
|
||||||
|
down_revision = "0005_chat_session_summary"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"sys_ai_request_log",
|
||||||
|
"prompt",
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=mysql.MEDIUMTEXT(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"sys_ai_request_log",
|
||||||
|
"retrieved_chunks",
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
type_=mysql.MEDIUMTEXT(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"sys_ai_request_log",
|
||||||
|
"retrieved_chunks",
|
||||||
|
existing_type=mysql.MEDIUMTEXT(),
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"sys_ai_request_log",
|
||||||
|
"prompt",
|
||||||
|
existing_type=mysql.MEDIUMTEXT(),
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import mysql
|
||||||
|
|
||||||
|
revision = "0007_knowledge_agent_rebuild"
|
||||||
|
down_revision = "0006_expand_ai_request_log_text"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for column in [
|
||||||
|
sa.Column("source_title", sa.String(255), nullable=True),
|
||||||
|
sa.Column("knowledge_type", sa.String(30), nullable=False, server_default="general"),
|
||||||
|
sa.Column("review_mode", sa.String(30), nullable=False, server_default="manual"),
|
||||||
|
sa.Column("lifecycle_status", sa.String(30), nullable=False, server_default="active"),
|
||||||
|
sa.Column("source_status", sa.String(30), nullable=False, server_default="normal"),
|
||||||
|
sa.Column("source_error", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("manifest_confirmed", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("current_version_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("pending_version_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("last_synced_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("last_published_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("migration_status", sa.String(30), nullable=False, server_default="pending"),
|
||||||
|
]:
|
||||||
|
op.add_column("sys_knowledge", column)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_source_snapshot",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("sync_job_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("source_title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("source_content", mysql.LONGTEXT(), nullable=False),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("source_identifier", sa.String(255), nullable=False),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_source_snapshot", "knowledge_id", "sync_job_id", "content_hash")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_version",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("snapshot_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("version_no", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False),
|
||||||
|
sa.Column("quality_status", sa.String(30), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("change_summary", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("quality_report", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("processing_rule_version", sa.String(50), nullable=False),
|
||||||
|
sa.Column("generation_model", sa.String(100), nullable=True),
|
||||||
|
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("published_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("published_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["snapshot_id"], ["sys_knowledge_source_snapshot.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_version", "knowledge_id", "status")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_manifest",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("purpose", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("applicable_questions", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("inapplicable_questions", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("core_topics", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("entities", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("boundaries", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("change_level", sa.String(20), nullable=False, server_default="major"),
|
||||||
|
sa.Column("confirmed", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("confirmed_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("confirmed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_manifest", "knowledge_id", "version_id")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_section",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("section_key", sa.String(64), nullable=False),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("content", mysql.LONGTEXT(), nullable=False),
|
||||||
|
sa.Column("source_start", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("source_end", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_section", "knowledge_id", "version_id")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_chunk",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("section_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("content", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("normalized_text", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("keywords", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("synonyms", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("source_start", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("source_end", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("sort_order", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["section_id"], ["sys_knowledge_section.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_chunk", "knowledge_id", "version_id", "section_id")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_card",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("section_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("summary", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("core_conclusion", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("applicable_questions", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("inapplicable_questions", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("keywords", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("synonyms", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("entities", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("risk_level", sa.String(20), nullable=False, server_default="normal"),
|
||||||
|
sa.Column("source_range", sa.String(100), nullable=False),
|
||||||
|
sa.Column("content_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("generation_model", sa.String(100), nullable=True),
|
||||||
|
sa.Column("generation_rule_version", sa.String(50), nullable=False),
|
||||||
|
sa.Column("review_status", sa.String(20), nullable=False, server_default="pending"),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["section_id"], ["sys_knowledge_section.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_card", "knowledge_id", "version_id", "section_id")
|
||||||
|
|
||||||
|
_create_job_and_log_tables()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_job_and_log_tables() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_sync_job",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("mode", sa.String(30), nullable=False),
|
||||||
|
sa.Column("status", sa.String(30), nullable=False),
|
||||||
|
sa.Column("stage", sa.String(50), nullable=False),
|
||||||
|
sa.Column("progress", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("source_error", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("result_summary", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("requested_by", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_sync_job", "knowledge_id", "status")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_review_record",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("card_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("action", sa.String(30), nullable=False),
|
||||||
|
sa.Column("reason", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("reviewed_by", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["card_id"], ["sys_knowledge_card.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_review_record", "knowledge_id", "version_id")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_publish_log",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("from_version_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("to_version_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("action", sa.String(30), nullable=False),
|
||||||
|
sa.Column("keep_open", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("detail_json", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("operated_by", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_publish_log", "knowledge_id")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_retrieval_log",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("session_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("message_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("question", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("catalog_snapshot", mysql.LONGTEXT(), nullable=False),
|
||||||
|
sa.Column("catalog_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("safety_rule_version", sa.String(50), nullable=False),
|
||||||
|
sa.Column("knowledge_called", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("selected_knowledge_ids", sa.String(500), nullable=True),
|
||||||
|
sa.Column("query_terms", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("tool_trace", mysql.LONGTEXT(), nullable=True),
|
||||||
|
sa.Column("final_section_ids", sa.String(500), nullable=True),
|
||||||
|
sa.Column("discarded_conflicts", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("final_answer", mysql.LONGTEXT(), nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False),
|
||||||
|
sa.Column("tool_cost_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("total_cost_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("error_message", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("attention_created", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_retrieval_log", "session_id", "message_id", "user_id")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_knowledge_retrieval_candidate",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("retrieval_log_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("version_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("chunk_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("section_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("lexical_score", sa.String(30), nullable=False),
|
||||||
|
sa.Column("rerank_score", sa.String(30), nullable=True),
|
||||||
|
sa.Column("selected", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("discard_reason", sa.String(255), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["retrieval_log_id"], ["sys_knowledge_retrieval_log.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_knowledge_retrieval_candidate", "retrieval_log_id", "knowledge_id", "version_id")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_human_attention_record",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("session_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("message_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("trigger_message", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("problem_summary", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("trigger_reason", mysql.MEDIUMTEXT(), nullable=False),
|
||||||
|
sa.Column("priority", sa.String(20), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("handler_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("handler_note", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_human_attention_record", "session_id", "message_id", "user_id", "priority", "status")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sys_human_attention_history",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("attention_id", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("from_status", sa.String(20), nullable=True),
|
||||||
|
sa.Column("to_status", sa.String(20), nullable=False),
|
||||||
|
sa.Column("note", mysql.MEDIUMTEXT(), nullable=True),
|
||||||
|
sa.Column("operated_by", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["attention_id"], ["sys_human_attention_record.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
_indexes("sys_human_attention_history", "attention_id")
|
||||||
|
|
||||||
|
|
||||||
|
def _indexes(table: str, *columns: str) -> None:
|
||||||
|
for column in columns:
|
||||||
|
op.create_index(f"ix_{table}_{column}", table, [column])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in [
|
||||||
|
"sys_human_attention_history",
|
||||||
|
"sys_human_attention_record",
|
||||||
|
"sys_knowledge_retrieval_candidate",
|
||||||
|
"sys_knowledge_retrieval_log",
|
||||||
|
"sys_knowledge_publish_log",
|
||||||
|
"sys_knowledge_review_record",
|
||||||
|
"sys_knowledge_sync_job",
|
||||||
|
"sys_knowledge_card",
|
||||||
|
"sys_knowledge_chunk",
|
||||||
|
"sys_knowledge_section",
|
||||||
|
"sys_knowledge_manifest",
|
||||||
|
"sys_knowledge_version",
|
||||||
|
"sys_knowledge_source_snapshot",
|
||||||
|
]:
|
||||||
|
op.drop_table(table)
|
||||||
|
for column in [
|
||||||
|
"migration_status", "last_published_at", "last_synced_at", "pending_version_id",
|
||||||
|
"current_version_id", "manifest_confirmed", "source_error", "source_status",
|
||||||
|
"lifecycle_status", "review_mode", "knowledge_type", "source_title",
|
||||||
|
]:
|
||||||
|
op.drop_column("sys_knowledge", column)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0008_unique_kb_node"
|
||||||
|
down_revision = "0007_knowledge_agent_rebuild"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")}
|
||||||
|
if "uq_knowledge_feishu_node" not in constraints:
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_knowledge_feishu_node",
|
||||||
|
"sys_knowledge",
|
||||||
|
["feishu_space_id", "feishu_node_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")}
|
||||||
|
if "uq_knowledge_feishu_node" in constraints:
|
||||||
|
op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""log retention, storage snapshots and audit indexes
|
||||||
|
|
||||||
|
Revision ID: 0009_logs_storage
|
||||||
|
Revises: 0008_unique_kb_node
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0009_logs_storage"
|
||||||
|
down_revision = "0008_unique_kb_node"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "sys_log_retention_policy" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"sys_log_retention_policy",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("retention_days", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("enabled", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_run_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("last_result", sa.Text(), nullable=True),
|
||||||
|
sa.Column("updated_by", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
if "sys_storage_snapshot" not in tables:
|
||||||
|
op.create_table(
|
||||||
|
"sys_storage_snapshot",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("scope", sa.String(50), nullable=False),
|
||||||
|
sa.Column("total_bytes", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("detail_json", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_status_created", ["status", "created_at"])
|
||||||
|
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_created_id", ["created_at", "id"])
|
||||||
|
_create_index_if_missing("sys_ai_request_log", "ix_ai_log_status_created", ["status", "created_at"])
|
||||||
|
_create_index_if_missing("sys_operation_log", "ix_operation_module_created", ["module", "created_at"])
|
||||||
|
_create_index_if_missing("sys_chat_session", "ix_chat_session_deleted_updated", ["is_deleted", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
|
||||||
|
if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}:
|
||||||
|
op.create_index(name, table, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_chat_session_deleted_updated", table_name="sys_chat_session")
|
||||||
|
op.drop_index("ix_operation_module_created", table_name="sys_operation_log")
|
||||||
|
op.drop_index("ix_ai_log_status_created", table_name="sys_ai_request_log")
|
||||||
|
op.drop_index("ix_retrieval_created_id", table_name="sys_knowledge_retrieval_log")
|
||||||
|
op.drop_index("ix_retrieval_status_created", table_name="sys_knowledge_retrieval_log")
|
||||||
|
op.drop_table("sys_storage_snapshot")
|
||||||
|
op.drop_table("sys_log_retention_policy")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add prompt history metadata and ordering index
|
||||||
|
|
||||||
|
Revision ID: 0010_prompt_history_management
|
||||||
|
Revises: 0009_logs_storage
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0010_prompt_history_management"
|
||||||
|
down_revision = "0009_logs_storage"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"sys_prompt",
|
||||||
|
sa.Column("change_type", sa.String(length=20), nullable=False, server_default="save"),
|
||||||
|
)
|
||||||
|
op.add_column("sys_prompt", sa.Column("source_prompt_id", sa.BigInteger(), nullable=True))
|
||||||
|
op.create_index("ix_sys_prompt_updated_at_id", "sys_prompt", ["updated_at", "id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_sys_prompt_updated_at_id", table_name="sys_prompt")
|
||||||
|
op.drop_column("sys_prompt", "source_prompt_id")
|
||||||
|
op.drop_column("sys_prompt", "change_type")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""make knowledge synchronization automatically publish
|
||||||
|
|
||||||
|
Revision ID: 0011_knowledge_auto_publish
|
||||||
|
Revises: 0010_prompt_history_management
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0011_knowledge_auto_publish"
|
||||||
|
down_revision = "0010_prompt_history_management"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Keep the legacy column for backward compatibility while removing manual
|
||||||
|
# review from the active workflow and API.
|
||||||
|
op.execute("UPDATE sys_knowledge SET review_mode = 'automatic'")
|
||||||
|
op.alter_column(
|
||||||
|
"sys_knowledge",
|
||||||
|
"review_mode",
|
||||||
|
existing_type=sa.String(length=30),
|
||||||
|
existing_nullable=False,
|
||||||
|
server_default="automatic",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"sys_knowledge",
|
||||||
|
"review_mode",
|
||||||
|
existing_type=sa.String(length=30),
|
||||||
|
existing_nullable=False,
|
||||||
|
server_default="manual",
|
||||||
|
)
|
||||||
1
ai_knowledge_base_v2/apps/backend/app/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
ai_knowledge_base_v2/apps/backend/app/api/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
260
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
Normal file
260
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_records.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.knowledge import (
|
||||||
|
HumanAttentionHistory,
|
||||||
|
HumanAttentionRecord,
|
||||||
|
KnowledgeRetrievalCandidate,
|
||||||
|
KnowledgeRetrievalLog,
|
||||||
|
)
|
||||||
|
from app.schemas.knowledge import AttentionUpdateRequest
|
||||||
|
from app.services.admin_service import OperationLogService
|
||||||
|
from app.models.logs import LogRetentionPolicy
|
||||||
|
from app.services.maintenance_service import MaintenanceService
|
||||||
|
from app.api.pagination import page_result
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/retrieval-log/list")
|
||||||
|
def retrieval_logs(
|
||||||
|
sessionId: int | None = Query(default=None),
|
||||||
|
knowledgeId: int | None = Query(default=None),
|
||||||
|
knowledgeCalled: bool | None = Query(default=None),
|
||||||
|
statusValue: str = Query(default="", alias="status"),
|
||||||
|
attentionCreated: bool | None = Query(default=None),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=50, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
query = select(KnowledgeRetrievalLog).order_by(KnowledgeRetrievalLog.id.desc())
|
||||||
|
if sessionId is not None:
|
||||||
|
query = query.where(KnowledgeRetrievalLog.session_id == sessionId)
|
||||||
|
if knowledgeId is not None:
|
||||||
|
query = query.where(KnowledgeRetrievalLog.selected_knowledge_ids.like(f"%{knowledgeId}%"))
|
||||||
|
if knowledgeCalled is not None:
|
||||||
|
query = query.where(KnowledgeRetrievalLog.knowledge_called == (1 if knowledgeCalled else 0))
|
||||||
|
if statusValue:
|
||||||
|
query = query.where(KnowledgeRetrievalLog.status == statusValue)
|
||||||
|
if attentionCreated is not None:
|
||||||
|
query = query.where(KnowledgeRetrievalLog.attention_created == (1 if attentionCreated else 0))
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||||
|
rows = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||||
|
return api_success(page_result([_retrieval_dict(item, detail=False) for item in rows], total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/retrieval-log/cleanup/estimate")
|
||||||
|
def estimate_cleanup(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
before = _cleanup_before(payload)
|
||||||
|
count = db.scalar(select(func.count()).select_from(KnowledgeRetrievalLog).where(KnowledgeRetrievalLog.created_at < before)) or 0
|
||||||
|
return api_success({"before": before, "estimatedCount": count})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/retrieval-log/cleanup")
|
||||||
|
def cleanup_logs(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
before = _cleanup_before(payload)
|
||||||
|
total = MaintenanceService.delete_retrieval_logs(db, before)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action=f"cleanup:{total}", target_id=None)
|
||||||
|
db.commit()
|
||||||
|
return api_success({"deleted": total, "before": before})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/retrieval-log/retention")
|
||||||
|
def get_retention(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||||
|
return api_success({"enabled": bool(policy and policy.enabled), "retentionDays": policy.retention_days if policy else None, "lastRunAt": policy.last_run_at if policy else None, "lastResult": policy.last_result if policy else None})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/retrieval-log/retention")
|
||||||
|
def set_retention(payload: dict, db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
days = payload.get("retentionDays")
|
||||||
|
if days is not None and (not isinstance(days, int) or days < 1):
|
||||||
|
raise HTTPException(status_code=400, detail="保留天数必须为正整数,永久保留请传 null")
|
||||||
|
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1)) or LogRetentionPolicy()
|
||||||
|
policy.retention_days, policy.enabled, policy.updated_by = days, 0 if days is None else 1, current_admin.id
|
||||||
|
db.add(policy)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="retrieval_log", action="retention_update", target_id=None)
|
||||||
|
db.commit()
|
||||||
|
return api_success({"enabled": bool(policy.enabled), "retentionDays": policy.retention_days})
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_before(payload: dict) -> datetime:
|
||||||
|
raw = str(payload.get("before") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="必须指定清理日期")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/retrieval-log/{log_id}")
|
||||||
|
def retrieval_detail(
|
||||||
|
log_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
log = db.get(KnowledgeRetrievalLog, log_id)
|
||||||
|
if log is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="检索日志不存在")
|
||||||
|
candidates = db.scalars(
|
||||||
|
select(KnowledgeRetrievalCandidate)
|
||||||
|
.where(KnowledgeRetrievalCandidate.retrieval_log_id == log.id)
|
||||||
|
.order_by(KnowledgeRetrievalCandidate.selected.desc(), KnowledgeRetrievalCandidate.id)
|
||||||
|
).all()
|
||||||
|
data = _retrieval_dict(log, detail=True)
|
||||||
|
data["candidates"] = [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"knowledgeId": item.knowledge_id,
|
||||||
|
"versionId": item.version_id,
|
||||||
|
"chunkId": item.chunk_id,
|
||||||
|
"sectionId": item.section_id,
|
||||||
|
"lexicalScore": item.lexical_score,
|
||||||
|
"rerankScore": item.rerank_score,
|
||||||
|
"selected": item.selected == 1,
|
||||||
|
"discardReason": item.discard_reason,
|
||||||
|
}
|
||||||
|
for item in candidates
|
||||||
|
]
|
||||||
|
return api_success(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attention/list")
|
||||||
|
def attention_list(
|
||||||
|
priority: str = Query(default=""),
|
||||||
|
statusValue: str = Query(default="", alias="status"),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=20, ge=10, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
query = select(HumanAttentionRecord)
|
||||||
|
if priority:
|
||||||
|
query = query.where(HumanAttentionRecord.priority == priority)
|
||||||
|
if statusValue:
|
||||||
|
query = query.where(HumanAttentionRecord.status == statusValue)
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||||
|
rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||||
|
return api_success(page_result([_attention_dict(item) for item in rows], total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/attention/{attention_id}")
|
||||||
|
def update_attention(
|
||||||
|
attention_id: int,
|
||||||
|
payload: AttentionUpdateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
item = db.get(HumanAttentionRecord, attention_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人工关注记录不存在")
|
||||||
|
previous = item.status
|
||||||
|
item.status = payload.status
|
||||||
|
item.handler_id = current_admin.id
|
||||||
|
item.handler_note = payload.note
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
item,
|
||||||
|
HumanAttentionHistory(
|
||||||
|
attention_id=item.id,
|
||||||
|
from_status=previous,
|
||||||
|
to_status=payload.status,
|
||||||
|
note=payload.note,
|
||||||
|
operated_by=current_admin.id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="update", target_id=item.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success(_attention_dict(item))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/attention/{attention_id}")
|
||||||
|
def delete_attention(
|
||||||
|
attention_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
item = db.get(HumanAttentionRecord, attention_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人工关注记录不存在")
|
||||||
|
histories = db.scalars(
|
||||||
|
select(HumanAttentionHistory).where(HumanAttentionHistory.attention_id == item.id)
|
||||||
|
).all()
|
||||||
|
for history in histories:
|
||||||
|
db.delete(history)
|
||||||
|
db.delete(item)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="delete", target_id=attention_id)
|
||||||
|
db.commit()
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
|
||||||
|
data = {
|
||||||
|
"id": item.id,
|
||||||
|
"sessionId": item.session_id,
|
||||||
|
"messageId": item.message_id,
|
||||||
|
"userId": item.user_id,
|
||||||
|
"question": item.question,
|
||||||
|
"knowledgeCalled": item.knowledge_called == 1,
|
||||||
|
"selectedKnowledgeIds": item.selected_knowledge_ids,
|
||||||
|
"queryTerms": _json(item.query_terms),
|
||||||
|
"finalSectionIds": item.final_section_ids,
|
||||||
|
"status": item.status,
|
||||||
|
"toolCostMs": item.tool_cost_ms,
|
||||||
|
"totalCostMs": item.total_cost_ms,
|
||||||
|
"errorMessage": item.error_message,
|
||||||
|
"attentionCreated": item.attention_created == 1,
|
||||||
|
"createdAt": item.created_at,
|
||||||
|
}
|
||||||
|
if detail:
|
||||||
|
data.update(
|
||||||
|
{
|
||||||
|
"catalogSnapshot": _json(item.catalog_snapshot),
|
||||||
|
"catalogHash": item.catalog_hash,
|
||||||
|
"safetyRuleVersion": item.safety_rule_version,
|
||||||
|
"toolTrace": _json(item.tool_trace),
|
||||||
|
"discardedConflicts": _json(item.discarded_conflicts),
|
||||||
|
"finalAnswer": item.final_answer,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _attention_dict(item: HumanAttentionRecord) -> dict:
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"sessionId": item.session_id,
|
||||||
|
"messageId": item.message_id,
|
||||||
|
"userId": item.user_id,
|
||||||
|
"triggerMessage": item.trigger_message,
|
||||||
|
"problemSummary": item.problem_summary,
|
||||||
|
"triggerReason": item.trigger_reason,
|
||||||
|
"priority": item.priority,
|
||||||
|
"status": item.status,
|
||||||
|
"handlerId": item.handler_id,
|
||||||
|
"handlerNote": item.handler_note,
|
||||||
|
"createdAt": item.created_at,
|
||||||
|
"updatedAt": item.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _json(raw: str | None):
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return raw
|
||||||
33
ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py
Normal file
33
ai_knowledge_base_v2/apps/backend/app/api/admin_auth.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead
|
||||||
|
from app.services.admin_service import AdminAuthService
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.services.security_state_service import client_ip
|
||||||
|
from app.core.dependencies import get_current_token_payload
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||||
|
result = AdminAuthService.login(db, payload.username, payload.password, client_ip(request))
|
||||||
|
return api_success(AdminLoginResponse.model_validate(result).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile")
|
||||||
|
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
return api_success(AdminRead.model_validate(current_admin).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
|
||||||
|
AuthService.logout(token_payload)
|
||||||
|
return api_success()
|
||||||
87
ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
Normal file
87
ai_knowledge_base_v2/apps/backend/app/api/admin_dashboard.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.schemas.admin import DashboardStats
|
||||||
|
from app.services.admin_service import AdminDashboardService
|
||||||
|
from app.models.logs import StorageSnapshot
|
||||||
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def dashboard(
|
||||||
|
start: str = Query(default="", description="开始日期 YYYY-MM-DD"),
|
||||||
|
end: str = Query(default="", description="结束日期 YYYY-MM-DD"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
|
||||||
|
if end:
|
||||||
|
end_dt = datetime.strptime(end, "%Y-%m-%d")
|
||||||
|
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||||||
|
else:
|
||||||
|
end_dt = None
|
||||||
|
stats = AdminDashboardService.stats(db, start_dt, end_dt)
|
||||||
|
return api_success(DashboardStats.model_validate(stats).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/storage")
|
||||||
|
def storage_stats(
|
||||||
|
refresh: bool = Query(default=False),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
latest = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == "project").order_by(StorageSnapshot.id.desc()).limit(1))
|
||||||
|
if latest is not None and not refresh:
|
||||||
|
return api_success(_snapshot_dict(latest, db))
|
||||||
|
detail: dict = {"database": {"status": "unavailable", "bytes": None, "tables": []}, "redis": {"status": "unavailable", "bytes": None}, "files": {"status": "unavailable", "bytes": None}}
|
||||||
|
errors: list[str] = []
|
||||||
|
try:
|
||||||
|
if db.bind and db.bind.dialect.name == "mysql":
|
||||||
|
rows = db.execute(text("SELECT table_name, data_length + index_length AS bytes FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY bytes DESC")).all()
|
||||||
|
tables = [{"name": row[0], "bytes": int(row[1] or 0)} for row in rows]
|
||||||
|
detail["database"] = {"status": "ok", "bytes": sum(x["bytes"] for x in tables), "tables": tables}
|
||||||
|
else:
|
||||||
|
detail["database"] = {"status": "unavailable", "bytes": None, "tables": [], "message": "当前数据库不支持精确表占用统计"}
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"database: {exc}")
|
||||||
|
try:
|
||||||
|
redis = get_sync_redis_client()
|
||||||
|
if redis is not None:
|
||||||
|
info = redis.info("memory")
|
||||||
|
detail["redis"] = {"status": "ok", "bytes": int(info.get("used_memory", 0))}
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"redis: {exc}")
|
||||||
|
try:
|
||||||
|
root = Path(".uploads")
|
||||||
|
if root.exists():
|
||||||
|
detail["files"] = {"status": "ok", "bytes": sum(p.stat().st_size for p in root.rglob("*") if p.is_file())}
|
||||||
|
else:
|
||||||
|
detail["files"] = {"status": "unavailable", "bytes": None, "message": "项目未配置本地附件目录"}
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"files: {exc}")
|
||||||
|
known = [x.get("bytes") for x in detail.values() if x.get("status") == "ok"]
|
||||||
|
snapshot = StorageSnapshot(scope="project", total_bytes=sum(known) if known else None, detail_json=json.dumps(detail, ensure_ascii=False), status="partial" if errors or any(x.get("status") != "ok" for x in detail.values()) else "ok", error_message="; ".join(errors) or None)
|
||||||
|
db.add(snapshot); db.commit(); db.refresh(snapshot)
|
||||||
|
return api_success(_snapshot_dict(snapshot, db))
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_dict(item: StorageSnapshot, db: Session) -> dict:
|
||||||
|
with_growth = {"totalBytes": item.total_bytes, "detail": json.loads(item.detail_json), "status": item.status, "errorMessage": item.error_message, "createdAt": item.created_at}
|
||||||
|
# Growth is intentionally unknown until enough snapshots exist; never report a misleading zero.
|
||||||
|
for days, key in ((7, "growth7DaysBytes"), (30, "growth30DaysBytes")):
|
||||||
|
previous = db.scalar(select(StorageSnapshot).where(StorageSnapshot.scope == item.scope, StorageSnapshot.created_at <= item.created_at - timedelta(days=days)).order_by(StorageSnapshot.created_at.desc()).limit(1))
|
||||||
|
with_growth[key] = item.total_bytes - previous.total_bytes if previous and item.total_bytes is not None and previous.total_bytes is not None else None
|
||||||
|
return with_growth
|
||||||
252
ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
Normal file
252
ai_knowledge_base_v2/apps/backend/app/api/admin_knowledge.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.knowledge import (
|
||||||
|
Knowledge,
|
||||||
|
KnowledgeCard,
|
||||||
|
KnowledgeChunk,
|
||||||
|
KnowledgeManifest,
|
||||||
|
KnowledgePublishLog,
|
||||||
|
KnowledgeReviewRecord,
|
||||||
|
KnowledgeSection,
|
||||||
|
KnowledgeSourceSnapshot,
|
||||||
|
KnowledgeSyncJob,
|
||||||
|
KnowledgeVersion,
|
||||||
|
UserKnowledgePermission,
|
||||||
|
)
|
||||||
|
from app.schemas.admin import KnowledgeSaveRequest
|
||||||
|
from app.services.admin_service import OperationLogService
|
||||||
|
from app.services.feishu_service import FeishuKnowledgeService
|
||||||
|
from app.api.pagination import page_result
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge/list")
|
||||||
|
def list_knowledge(
|
||||||
|
keyword: str = Query(default=""),
|
||||||
|
knowledgeType: str = Query(default=""),
|
||||||
|
openStatus: int | None = Query(default=None),
|
||||||
|
sourceStatus: str = Query(default=""),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=20, ge=10, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
query = select(Knowledge)
|
||||||
|
if keyword:
|
||||||
|
query = query.where((Knowledge.name.like(f"%{keyword}%")) | (Knowledge.source_title.like(f"%{keyword}%")))
|
||||||
|
if knowledgeType:
|
||||||
|
query = query.where(Knowledge.knowledge_type == knowledgeType)
|
||||||
|
if openStatus is not None:
|
||||||
|
query = query.where(Knowledge.status == openStatus)
|
||||||
|
if sourceStatus:
|
||||||
|
query = query.where(Knowledge.source_status == sourceStatus)
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||||
|
items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||||
|
return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge/options")
|
||||||
|
def knowledge_options(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
items = db.scalars(select(Knowledge).where(Knowledge.lifecycle_status == "active").order_by(Knowledge.id.desc())).all()
|
||||||
|
return api_success([_knowledge_dict(item) for item in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge/resolve-node")
|
||||||
|
def resolve_knowledge_node(
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
node_id = str(payload.get("nodeId") or "").strip()
|
||||||
|
if not node_id or len(node_id) > 100 or not node_id.replace("_", "").replace("-", "").isalnum():
|
||||||
|
raise HTTPException(status_code=400, detail="Node ID 格式错误")
|
||||||
|
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_id))
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=409, detail=f"该 Node ID 已添加(知识库:{existing.name})")
|
||||||
|
try:
|
||||||
|
info = FeishuKnowledgeService.get_node_info(node_id, db)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"飞书节点信息获取失败:{exc}") from exc
|
||||||
|
space_id, title = str(info.get("space_id") or "").strip(), str(info.get("title") or "").strip()
|
||||||
|
if not space_id:
|
||||||
|
raise HTTPException(status_code=422, detail="飞书接口未返回 Space ID,请检查节点是否移动或当前应用权限")
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(status_code=422, detail="飞书接口未返回文章标题,请检查文章是否已删除")
|
||||||
|
return api_success({"nodeId": node_id, "spaceId": space_id, "sourceTitle": title, "name": title, "remark": str(info.get("description") or ""), "objType": info.get("obj_type")})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge/import")
|
||||||
|
def import_knowledge_from_space(
|
||||||
|
payload: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
node_token = payload.get("node_token", "").strip()
|
||||||
|
if not node_token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="NodeToken 不能为空")
|
||||||
|
|
||||||
|
info = FeishuKnowledgeService.get_node_info(node_token, db)
|
||||||
|
space_id = info.get("space_id", "")
|
||||||
|
if not space_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"无法获取节点 {node_token} 的 SpaceID",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == node_token))
|
||||||
|
if existing is not None:
|
||||||
|
return api_success({"imported": 0, "skipped": 1, "id": existing.id, "name": existing.name})
|
||||||
|
|
||||||
|
knowledge = Knowledge(
|
||||||
|
name=info.get("title", node_token),
|
||||||
|
feishu_space_id=space_id,
|
||||||
|
feishu_node_id=node_token,
|
||||||
|
status=0,
|
||||||
|
knowledge_type="general",
|
||||||
|
review_mode="automatic",
|
||||||
|
remark=f"obj_type={info.get('obj_type', '')}",
|
||||||
|
)
|
||||||
|
db.add(knowledge)
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(
|
||||||
|
db, admin_id=current_admin.id, module="knowledge", action="import", target_id=knowledge.id
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
|
||||||
|
db.refresh(knowledge)
|
||||||
|
return api_success({"imported": 1, "skipped": 0, "id": knowledge.id, "name": knowledge.name})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge")
|
||||||
|
def create_knowledge(
|
||||||
|
payload: KnowledgeSaveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
existing = db.scalar(select(Knowledge).where(Knowledge.feishu_node_id == payload.feishuNodeId))
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库")
|
||||||
|
knowledge = Knowledge(
|
||||||
|
name=payload.name,
|
||||||
|
feishu_space_id=payload.feishuSpaceId,
|
||||||
|
feishu_node_id=payload.feishuNodeId,
|
||||||
|
status=0,
|
||||||
|
remark=payload.remark,
|
||||||
|
knowledge_type=payload.knowledgeType,
|
||||||
|
review_mode="automatic",
|
||||||
|
)
|
||||||
|
db.add(knowledge)
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="create", target_id=knowledge.id)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该飞书文章已创建知识库") from exc
|
||||||
|
db.refresh(knowledge)
|
||||||
|
return api_success(_knowledge_dict(knowledge))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/knowledge/{knowledge_id}")
|
||||||
|
def update_knowledge(
|
||||||
|
knowledge_id: int,
|
||||||
|
payload: KnowledgeSaveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge = _get_knowledge(db, knowledge_id)
|
||||||
|
knowledge.name = payload.name
|
||||||
|
knowledge.remark = payload.remark
|
||||||
|
knowledge.knowledge_type = payload.knowledgeType
|
||||||
|
knowledge.review_mode = "automatic"
|
||||||
|
db.add(knowledge)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(knowledge)
|
||||||
|
return api_success(_knowledge_dict(knowledge))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/knowledge/{knowledge_id}")
|
||||||
|
def delete_knowledge(
|
||||||
|
knowledge_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge = _get_knowledge(db, knowledge_id)
|
||||||
|
if knowledge.lifecycle_status != "archived" or knowledge.status != 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="物理删除前必须先关闭并归档知识库",
|
||||||
|
)
|
||||||
|
# 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
|
||||||
|
permissions = db.scalars(
|
||||||
|
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
|
||||||
|
).all()
|
||||||
|
for permission in permissions:
|
||||||
|
db.delete(permission)
|
||||||
|
for model in (KnowledgeReviewRecord, KnowledgeCard, KnowledgeChunk, KnowledgeSection, KnowledgeManifest, KnowledgePublishLog, KnowledgeSyncJob):
|
||||||
|
for row in db.scalars(select(model).where(model.knowledge_id == knowledge.id)).all():
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
for row in db.scalars(select(KnowledgeVersion).where(KnowledgeVersion.knowledge_id == knowledge.id)).all():
|
||||||
|
db.delete(row)
|
||||||
|
db.flush()
|
||||||
|
for row in db.scalars(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == knowledge.id)).all():
|
||||||
|
db.delete(row)
|
||||||
|
db.delete(knowledge)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_knowledge(db: Session, knowledge_id: int) -> Knowledge:
|
||||||
|
knowledge = db.get(Knowledge, knowledge_id)
|
||||||
|
if knowledge is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
|
||||||
|
return knowledge
|
||||||
|
|
||||||
|
|
||||||
|
def _require_super_admin(admin: Admin) -> None:
|
||||||
|
if admin.role is not None and admin.role.code != "super_admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以管理知识库")
|
||||||
|
|
||||||
|
|
||||||
|
def _knowledge_dict(knowledge: Knowledge) -> dict:
|
||||||
|
return {
|
||||||
|
"id": knowledge.id,
|
||||||
|
"name": knowledge.name,
|
||||||
|
"feishuSpaceId": knowledge.feishu_space_id,
|
||||||
|
"feishuNodeId": knowledge.feishu_node_id,
|
||||||
|
"status": knowledge.status,
|
||||||
|
"remark": knowledge.remark,
|
||||||
|
"sourceTitle": knowledge.source_title,
|
||||||
|
"knowledgeType": knowledge.knowledge_type,
|
||||||
|
"lifecycleStatus": knowledge.lifecycle_status,
|
||||||
|
"sourceStatus": knowledge.source_status,
|
||||||
|
"sourceError": knowledge.source_error,
|
||||||
|
"currentVersionId": knowledge.current_version_id,
|
||||||
|
"lastSyncedAt": knowledge.last_synced_at,
|
||||||
|
"lastPublishedAt": knowledge.last_published_at,
|
||||||
|
"migrationStatus": knowledge.migration_status,
|
||||||
|
"createdAt": knowledge.created_at,
|
||||||
|
"updatedAt": knowledge.updated_at,
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.knowledge import (
|
||||||
|
Knowledge,
|
||||||
|
KnowledgeCard,
|
||||||
|
KnowledgeChunk,
|
||||||
|
KnowledgeManifest,
|
||||||
|
KnowledgeSection,
|
||||||
|
KnowledgeSyncJob,
|
||||||
|
KnowledgeVersion,
|
||||||
|
)
|
||||||
|
from app.schemas.knowledge import (
|
||||||
|
KnowledgeLifecycleRequest,
|
||||||
|
KnowledgeBatchMetadataUpdateRequest,
|
||||||
|
KnowledgeMetadataUpdateRequest,
|
||||||
|
KnowledgeRollbackRequest,
|
||||||
|
KnowledgeStatusRequest,
|
||||||
|
)
|
||||||
|
from app.services.admin_service import OperationLogService
|
||||||
|
from app.services.knowledge_pipeline_service import KnowledgePipelineService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge/batch/metadata")
|
||||||
|
def batch_update_metadata(
|
||||||
|
payload: KnowledgeBatchMetadataUpdateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
if payload.knowledgeType is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择待修改的知识库类型")
|
||||||
|
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
|
||||||
|
rows = {
|
||||||
|
item.id: item
|
||||||
|
for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all()
|
||||||
|
}
|
||||||
|
results: list[dict] = []
|
||||||
|
for knowledge_id in knowledge_ids:
|
||||||
|
item = rows.get(knowledge_id)
|
||||||
|
if item is None:
|
||||||
|
results.append({"knowledgeId": knowledge_id, "ok": False, "error": "知识库不存在"})
|
||||||
|
continue
|
||||||
|
if payload.knowledgeType is not None:
|
||||||
|
item.knowledge_type = payload.knowledgeType
|
||||||
|
db.add(item)
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="knowledge",
|
||||||
|
action="batch_update_metadata",
|
||||||
|
target_id=item.id,
|
||||||
|
)
|
||||||
|
results.append({"knowledgeId": knowledge_id, "ok": True})
|
||||||
|
db.commit()
|
||||||
|
success = sum(1 for item in results if item["ok"])
|
||||||
|
return api_success(
|
||||||
|
{"total": len(results), "success": success, "failed": len(results) - success, "results": results}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/knowledge/{knowledge_id}/metadata")
|
||||||
|
def update_metadata(
|
||||||
|
knowledge_id: int,
|
||||||
|
payload: KnowledgeMetadataUpdateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
|
knowledge.name = payload.name.strip()
|
||||||
|
knowledge.knowledge_type = payload.knowledgeType
|
||||||
|
knowledge.review_mode = "automatic"
|
||||||
|
db.add(knowledge)
|
||||||
|
OperationLogService.write(
|
||||||
|
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(knowledge)
|
||||||
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge/{knowledge_id}/detail")
|
||||||
|
def detail(
|
||||||
|
knowledge_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
|
current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None
|
||||||
|
manifest = None
|
||||||
|
manifest_version_id = knowledge.current_version_id
|
||||||
|
if manifest_version_id:
|
||||||
|
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
|
||||||
|
return api_success(
|
||||||
|
{
|
||||||
|
"knowledge": _knowledge_detail(knowledge),
|
||||||
|
"currentVersion": _version_dict(current),
|
||||||
|
"manifest": _manifest_dict(manifest),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge/{knowledge_id}/versions")
|
||||||
|
def versions(
|
||||||
|
knowledge_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
_knowledge(db, knowledge_id)
|
||||||
|
rows = db.scalars(
|
||||||
|
select(KnowledgeVersion)
|
||||||
|
.where(KnowledgeVersion.knowledge_id == knowledge_id)
|
||||||
|
.order_by(KnowledgeVersion.version_no.desc())
|
||||||
|
).all()
|
||||||
|
return api_success([_version_dict(item) for item in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge/version/{version_id}/content")
|
||||||
|
def version_content(
|
||||||
|
version_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
version = _version(db, version_id)
|
||||||
|
sections = db.scalars(
|
||||||
|
select(KnowledgeSection).where(KnowledgeSection.version_id == version.id).order_by(KnowledgeSection.sort_order)
|
||||||
|
).all()
|
||||||
|
cards = db.scalars(
|
||||||
|
select(KnowledgeCard).where(KnowledgeCard.version_id == version.id).order_by(KnowledgeCard.id)
|
||||||
|
).all()
|
||||||
|
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == version.id))
|
||||||
|
return api_success(
|
||||||
|
{
|
||||||
|
"version": _version_dict(version),
|
||||||
|
"manifest": _manifest_dict(manifest),
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"sectionKey": item.section_key,
|
||||||
|
"title": item.title,
|
||||||
|
"content": item.content,
|
||||||
|
"sourceStart": item.source_start,
|
||||||
|
"sourceEnd": item.source_end,
|
||||||
|
"contentHash": item.content_hash,
|
||||||
|
}
|
||||||
|
for item in sections
|
||||||
|
],
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"sectionId": item.section_id,
|
||||||
|
"title": item.title,
|
||||||
|
"summary": item.summary,
|
||||||
|
"coreConclusion": item.core_conclusion,
|
||||||
|
"applicableQuestions": item.applicable_questions,
|
||||||
|
"inapplicableQuestions": item.inapplicable_questions,
|
||||||
|
"keywords": _json_value(item.keywords),
|
||||||
|
"synonyms": _json_value(item.synonyms),
|
||||||
|
"riskLevel": item.risk_level,
|
||||||
|
"sourceRange": item.source_range,
|
||||||
|
"reviewStatus": item.review_status,
|
||||||
|
}
|
||||||
|
for item in cards
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge/{knowledge_id}/sync-jobs")
|
||||||
|
def sync_jobs(
|
||||||
|
knowledge_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
rows = db.scalars(
|
||||||
|
select(KnowledgeSyncJob)
|
||||||
|
.where(KnowledgeSyncJob.knowledge_id == knowledge_id)
|
||||||
|
.order_by(KnowledgeSyncJob.id.desc())
|
||||||
|
.limit(50)
|
||||||
|
).all()
|
||||||
|
return api_success([_job_dict(item) for item in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge/{knowledge_id}/sync")
|
||||||
|
async def synchronize(
|
||||||
|
knowledge_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
|
if knowledge.lifecycle_status == "archived":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
|
||||||
|
try:
|
||||||
|
job = await KnowledgePipelineService.synchronize(
|
||||||
|
db, knowledge, admin_id=current_admin.id
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="sync", target_id=knowledge.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success(_job_dict(job))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
|
||||||
|
def rollback(
|
||||||
|
knowledge_id: int,
|
||||||
|
version_id: int,
|
||||||
|
payload: KnowledgeRollbackRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
|
||||||
|
if knowledge.current_version_id == version.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本")
|
||||||
|
if payload.keepOpen and knowledge.lifecycle_status != "active":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放")
|
||||||
|
try:
|
||||||
|
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/knowledge/{knowledge_id}/open-status")
|
||||||
|
def open_status(
|
||||||
|
knowledge_id: int,
|
||||||
|
payload: KnowledgeStatusRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
|
if payload.open:
|
||||||
|
if not knowledge.current_version_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本")
|
||||||
|
if knowledge.source_status != "normal":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
|
||||||
|
if knowledge.lifecycle_status != "active":
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能开放")
|
||||||
|
knowledge.status = 1 if payload.open else 0
|
||||||
|
db.add(knowledge)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="open" if payload.open else "close", target_id=knowledge.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/knowledge/{knowledge_id}/lifecycle")
|
||||||
|
def lifecycle(
|
||||||
|
knowledge_id: int,
|
||||||
|
payload: KnowledgeLifecycleRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
|
if knowledge.lifecycle_status == payload.status:
|
||||||
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
knowledge.lifecycle_status = payload.status
|
||||||
|
if payload.status == "archived":
|
||||||
|
knowledge.status = 0
|
||||||
|
db.add(knowledge)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=payload.status, target_id=knowledge.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success(_knowledge_detail(knowledge))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/knowledge-migration/summary")
|
||||||
|
def migration_summary(
|
||||||
|
db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
rows = db.execute(
|
||||||
|
select(Knowledge.migration_status, func.count(Knowledge.id)).group_by(Knowledge.migration_status)
|
||||||
|
).all()
|
||||||
|
counts = {migration_status: count for migration_status, count in rows}
|
||||||
|
return api_success({"total": sum(counts.values()), "counts": counts})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/knowledge-migration/run")
|
||||||
|
async def run_migration(
|
||||||
|
knowledgeId: int | None = Query(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
_require_super_admin(current_admin)
|
||||||
|
query = select(Knowledge).where(Knowledge.lifecycle_status == "active")
|
||||||
|
if knowledgeId is not None:
|
||||||
|
query = query.where(Knowledge.id == knowledgeId)
|
||||||
|
rows = db.scalars(query.order_by(Knowledge.id)).all()
|
||||||
|
results = []
|
||||||
|
for knowledge in rows:
|
||||||
|
try:
|
||||||
|
job = await KnowledgePipelineService.synchronize(
|
||||||
|
db, knowledge, admin_id=current_admin.id
|
||||||
|
)
|
||||||
|
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
|
||||||
|
except Exception as exc:
|
||||||
|
results.append({"knowledgeId": knowledge.id, "ok": False, "error": str(exc)})
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge_migration", action="run", target_id=knowledgeId)
|
||||||
|
db.commit()
|
||||||
|
return api_success({"total": len(results), "success": sum(1 for item in results if item["ok"]), "results": results})
|
||||||
|
|
||||||
|
|
||||||
|
def _require_super_admin(admin: Admin) -> None:
|
||||||
|
if admin.role is not None and admin.role.code != "super_admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可以执行此操作")
|
||||||
|
|
||||||
|
|
||||||
|
def _knowledge(db: Session, knowledge_id: int) -> Knowledge:
|
||||||
|
item = db.get(Knowledge, knowledge_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识库不存在")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _version(db: Session, version_id: int) -> KnowledgeVersion:
|
||||||
|
item = db.get(KnowledgeVersion, version_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="知识版本不存在")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _knowledge_version(db: Session, knowledge_id: int, version_id: int) -> tuple[Knowledge, KnowledgeVersion]:
|
||||||
|
knowledge = _knowledge(db, knowledge_id)
|
||||||
|
version = _version(db, version_id)
|
||||||
|
if version.knowledge_id != knowledge.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本不属于该知识库")
|
||||||
|
return knowledge, version
|
||||||
|
|
||||||
|
|
||||||
|
def _knowledge_detail(item: Knowledge) -> dict:
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.name,
|
||||||
|
"status": item.status,
|
||||||
|
"remark": item.remark,
|
||||||
|
"sourceTitle": item.source_title,
|
||||||
|
"feishuSpaceId": item.feishu_space_id,
|
||||||
|
"feishuNodeId": item.feishu_node_id,
|
||||||
|
"knowledgeType": item.knowledge_type,
|
||||||
|
"open": item.status == 1,
|
||||||
|
"lifecycleStatus": item.lifecycle_status,
|
||||||
|
"sourceStatus": item.source_status,
|
||||||
|
"sourceError": item.source_error,
|
||||||
|
"currentVersionId": item.current_version_id,
|
||||||
|
"lastSyncedAt": item.last_synced_at,
|
||||||
|
"lastPublishedAt": item.last_published_at,
|
||||||
|
"migrationStatus": item.migration_status,
|
||||||
|
"createdAt": item.created_at,
|
||||||
|
"updatedAt": item.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _version_dict(item: KnowledgeVersion | None) -> dict | None:
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"knowledgeId": item.knowledge_id,
|
||||||
|
"snapshotId": item.snapshot_id,
|
||||||
|
"versionNo": item.version_no,
|
||||||
|
"status": item.status,
|
||||||
|
"qualityStatus": item.quality_status,
|
||||||
|
"changeSummary": _json_value(item.change_summary),
|
||||||
|
"qualityReport": _json_value(item.quality_report),
|
||||||
|
"processingRuleVersion": item.processing_rule_version,
|
||||||
|
"generationModel": item.generation_model,
|
||||||
|
"createdAt": item.created_at,
|
||||||
|
"publishedAt": item.published_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_dict(item: KnowledgeManifest | None) -> dict | None:
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"versionId": item.version_id,
|
||||||
|
"purpose": item.purpose,
|
||||||
|
"applicableQuestions": item.applicable_questions,
|
||||||
|
"inapplicableQuestions": item.inapplicable_questions,
|
||||||
|
"coreTopics": item.core_topics,
|
||||||
|
"entities": item.entities,
|
||||||
|
"boundaries": item.boundaries,
|
||||||
|
"changeLevel": item.change_level,
|
||||||
|
"confirmed": item.confirmed == 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_dict(item: KnowledgeSyncJob) -> dict:
|
||||||
|
return {
|
||||||
|
"id": item.id,
|
||||||
|
"knowledgeId": item.knowledge_id,
|
||||||
|
"mode": item.mode,
|
||||||
|
"status": item.status,
|
||||||
|
"stage": item.stage,
|
||||||
|
"progress": item.progress,
|
||||||
|
"sourceError": item.source_error,
|
||||||
|
"resultSummary": _json_value(item.result_summary),
|
||||||
|
"createdAt": item.created_at,
|
||||||
|
"startedAt": item.started_at,
|
||||||
|
"finishedAt": item.finished_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value(raw: str | None):
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return raw
|
||||||
307
ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
Normal file
307
ai_knowledge_base_v2/apps/backend/app/api/admin_records.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from io import StringIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import exists, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, load_only
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
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
|
||||||
|
from app.api.pagination import page_result
|
||||||
|
|
||||||
|
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),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=20, ge=10, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
query = _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||||
|
rows = db.execute(query.order_by(ChatSession.updated_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||||
|
return api_success(page_result([_chat_row_dict(session, user) for session, user in rows], total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
|
@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"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chat/{session_id}")
|
||||||
|
def chat_detail(
|
||||||
|
session_id: int,
|
||||||
|
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(
|
||||||
|
{
|
||||||
|
"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],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/log/list")
|
||||||
|
def operation_logs(
|
||||||
|
module: str = Query(default=""),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=20, ge=10, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
query = select(OperationLog)
|
||||||
|
if module:
|
||||||
|
query = query.where(OperationLog.module == module)
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||||
|
logs = db.scalars(
|
||||||
|
query.options(load_only(OperationLog.id, OperationLog.admin_id, OperationLog.module, OperationLog.action, OperationLog.target_id, OperationLog.result, OperationLog.created_at))
|
||||||
|
.order_by(OperationLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
|
||||||
|
).all()
|
||||||
|
return api_success(page_result(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": item.id,
|
||||||
|
"adminId": item.admin_id,
|
||||||
|
"module": item.module,
|
||||||
|
"action": item.action,
|
||||||
|
"targetId": item.target_id,
|
||||||
|
"result": item.result,
|
||||||
|
"createdAt": item.created_at,
|
||||||
|
}
|
||||||
|
for item in logs
|
||||||
|
], total=total, page=page, page_size=pageSize
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ai-log/list")
|
||||||
|
def ai_logs(
|
||||||
|
sessionId: int | None = Query(default=None),
|
||||||
|
userId: int | None = Query(default=None),
|
||||||
|
status: str = Query(default=""),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=20, ge=10, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
query = select(AiRequestLog)
|
||||||
|
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)
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||||
|
logs = db.scalars(
|
||||||
|
query.options(load_only(
|
||||||
|
AiRequestLog.id, AiRequestLog.session_id, AiRequestLog.message_id, AiRequestLog.user_id,
|
||||||
|
AiRequestLog.model_name, AiRequestLog.knowledge_ids, AiRequestLog.retrieve_count,
|
||||||
|
AiRequestLog.input_token, AiRequestLog.output_token, AiRequestLog.total_token,
|
||||||
|
AiRequestLog.cost_ms, AiRequestLog.status, AiRequestLog.error_message, AiRequestLog.created_at,
|
||||||
|
)).order_by(AiRequestLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
|
||||||
|
).all()
|
||||||
|
return api_success(page_result([_ai_log_dict(item, include_chunks=False) for item in logs], total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ai-log/{log_id}")
|
||||||
|
def ai_log_detail(
|
||||||
|
log_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
log = db.get(AiRequestLog, log_id)
|
||||||
|
if log is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AI 请求日志不存在")
|
||||||
|
return api_success(_ai_log_dict(log, include_prompt=True))
|
||||||
|
|
||||||
|
|
||||||
|
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, include_chunks: bool = True) -> 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,
|
||||||
|
"retrievedChunks": _parse_retrieved_chunks(log.retrieved_chunks) if include_chunks else [],
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_retrieved_chunks(raw: str | None) -> list[dict[str, Any]]:
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
chunks = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return []
|
||||||
|
if not isinstance(chunks, list):
|
||||||
|
return []
|
||||||
|
normalized: list[dict[str, Any]] = []
|
||||||
|
for index, item in enumerate(chunks, start=1):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"index": item.get("index") or index,
|
||||||
|
"knowledgeId": item.get("knowledgeId"),
|
||||||
|
"knowledgeName": item.get("knowledgeName") or "",
|
||||||
|
"title": item.get("title") or "",
|
||||||
|
"content": item.get("content") or "",
|
||||||
|
"sourceUrl": item.get("sourceUrl") or None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
522
ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py
Normal file
522
ai_knowledge_base_v2/apps/backend/app/api/admin_settings.py
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.pagination import page_result
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
|
||||||
|
from app.models.knowledge import Knowledge
|
||||||
|
from app.schemas.admin import (
|
||||||
|
AgentDebugRequest,
|
||||||
|
AgentRuntimeConfigSaveRequest,
|
||||||
|
EnableModelRequest,
|
||||||
|
ModelSaveRequest,
|
||||||
|
PromptSaveRequest,
|
||||||
|
SystemConfigSaveRequest,
|
||||||
|
)
|
||||||
|
from app.services.admin_service import OperationLogService
|
||||||
|
from app.services.feishu_service import FeishuKnowledgeService
|
||||||
|
from app.services.knowledge_agent_service import KnowledgeAgentService
|
||||||
|
from app.services.knowledge_service import KnowledgeScope
|
||||||
|
from app.services.model_service import ModelClientService
|
||||||
|
from app.services.rag_service import PromptService, RagResult
|
||||||
|
from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
DEFAULT_PROMPT = "你是大本营答疑助手。你只能基于提供的知识片段回答,不能编造。"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompt")
|
||||||
|
def get_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
row = db.execute(
|
||||||
|
select(Prompt, Admin)
|
||||||
|
.outerjoin(Admin, Admin.id == Prompt.updated_by)
|
||||||
|
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return api_success(_prompt_detail(row[0], row[1]) if row else _default_prompt_detail())
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/prompt")
|
||||||
|
def save_prompt(
|
||||||
|
payload: PromptSaveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
prompt = Prompt(prompt_content=payload.promptContent, change_type="save", updated_by=current_admin.id)
|
||||||
|
db.add(prompt)
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="save", target_id=prompt.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prompt)
|
||||||
|
return api_success(_prompt_detail(prompt, current_admin))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prompt/reset")
|
||||||
|
def reset_prompt(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
prompt = Prompt(prompt_content=DEFAULT_PROMPT, change_type="reset", updated_by=current_admin.id)
|
||||||
|
db.add(prompt)
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="prompt", action="reset", target_id=prompt.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prompt)
|
||||||
|
return api_success(_prompt_detail(prompt, current_admin))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompt/history")
|
||||||
|
def prompt_history(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=10, ge=10, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
total = db.scalar(select(func.count(Prompt.id))) or 0
|
||||||
|
rows = db.execute(
|
||||||
|
select(Prompt, Admin)
|
||||||
|
.outerjoin(Admin, Admin.id == Prompt.updated_by)
|
||||||
|
.order_by(Prompt.updated_at.desc(), Prompt.id.desc())
|
||||||
|
.offset((page - 1) * pageSize)
|
||||||
|
.limit(pageSize)
|
||||||
|
).all()
|
||||||
|
items = [
|
||||||
|
_prompt_summary(prompt, admin, is_current=index == 0 and page == 1)
|
||||||
|
for index, (prompt, admin) in enumerate(rows)
|
||||||
|
]
|
||||||
|
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompt/history/{prompt_id}")
|
||||||
|
def prompt_history_detail(
|
||||||
|
prompt_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
row = db.execute(
|
||||||
|
select(Prompt, Admin)
|
||||||
|
.outerjoin(Admin, Admin.id == Prompt.updated_by)
|
||||||
|
.where(Prompt.id == prompt_id)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
|
||||||
|
return api_success(_prompt_detail(row[0], row[1]))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prompt/history/{prompt_id}/restore")
|
||||||
|
def restore_prompt(
|
||||||
|
prompt_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
source = db.get(Prompt, prompt_id)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提示词版本不存在")
|
||||||
|
restored = Prompt(
|
||||||
|
prompt_content=source.prompt_content,
|
||||||
|
change_type="restore",
|
||||||
|
source_prompt_id=source.id,
|
||||||
|
updated_by=current_admin.id,
|
||||||
|
)
|
||||||
|
db.add(restored)
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="prompt",
|
||||||
|
action="restore",
|
||||||
|
target_id=restored.id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(restored)
|
||||||
|
return api_success(_prompt_detail(restored, current_admin))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agent/debug")
|
||||||
|
async def debug_agent(
|
||||||
|
payload: AgentDebugRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
model = db.get(ModelConfig, payload.modelId)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||||
|
|
||||||
|
rag_result = await KnowledgeAgentService.build_result(
|
||||||
|
db,
|
||||||
|
question=payload.question,
|
||||||
|
version_overrides=payload.knowledgeVersions,
|
||||||
|
preview_knowledge_ids=payload.knowledgeIds,
|
||||||
|
)
|
||||||
|
debug_messages = [
|
||||||
|
{"role": "system", "content": payload.promptContent.strip()},
|
||||||
|
*(rag_result.messages or []),
|
||||||
|
]
|
||||||
|
rag_result = RagResult(
|
||||||
|
question=rag_result.question,
|
||||||
|
knowledge_scopes=rag_result.knowledge_scopes,
|
||||||
|
chunks=rag_result.chunks,
|
||||||
|
prompt=PromptService.render_messages(debug_messages),
|
||||||
|
allow_general_knowledge=rag_result.allow_general_knowledge,
|
||||||
|
retrieval_log_id=rag_result.retrieval_log_id,
|
||||||
|
tool_trace=rag_result.tool_trace,
|
||||||
|
messages=debug_messages,
|
||||||
|
)
|
||||||
|
result = await ModelClientService.debug_model_async(
|
||||||
|
model,
|
||||||
|
rag_result,
|
||||||
|
{
|
||||||
|
"temperature": payload.temperature,
|
||||||
|
"top_p": payload.topP,
|
||||||
|
"top_k": payload.topK,
|
||||||
|
"presence_penalty": payload.presencePenalty,
|
||||||
|
"frequency_penalty": payload.frequencyPenalty,
|
||||||
|
"max_token": payload.maxToken,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result["retrievalTrace"] = rag_result.tool_trace or []
|
||||||
|
result["retrievalLogId"] = rag_result.retrieval_log_id
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="debug", target_id=model.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agent/runtime-config")
|
||||||
|
def get_agent_runtime_config(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
model = _enabled_model(db)
|
||||||
|
return api_success(_agent_runtime_config_dict(model))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/agent/runtime-config")
|
||||||
|
def save_agent_runtime_config(
|
||||||
|
payload: AgentRuntimeConfigSaveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
model = _enabled_model(db)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="当前没有已启用模型,请先在模型管理中启用一个模型",
|
||||||
|
)
|
||||||
|
model.temperature = payload.temperature
|
||||||
|
model.top_p = payload.topP
|
||||||
|
model.top_k = payload.topK
|
||||||
|
model.presence_penalty = payload.presencePenalty
|
||||||
|
model.frequency_penalty = payload.frequencyPenalty
|
||||||
|
model.max_token = payload.maxToken
|
||||||
|
db.add(model)
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="agent",
|
||||||
|
action="save_runtime_config",
|
||||||
|
target_id=model.id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(model)
|
||||||
|
return api_success(_agent_runtime_config_dict(model))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model/list")
|
||||||
|
def list_models(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
models = db.scalars(select(ModelConfig).order_by(ModelConfig.id.desc())).all()
|
||||||
|
return api_success([_model_dict(model) for model in models])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/model")
|
||||||
|
def create_model(
|
||||||
|
payload: ModelSaveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
if not payload.apiKey:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API Key 不能为空")
|
||||||
|
model = ModelConfig(
|
||||||
|
provider=payload.provider,
|
||||||
|
display_name=payload.displayName,
|
||||||
|
api_type=payload.apiType,
|
||||||
|
model_name=payload.modelName,
|
||||||
|
base_url=payload.baseUrl,
|
||||||
|
api_url=payload.apiUrl,
|
||||||
|
api_key=SecretService.encrypt(payload.apiKey),
|
||||||
|
auth_type=payload.authType,
|
||||||
|
api_version=payload.apiVersion,
|
||||||
|
temperature=payload.temperature,
|
||||||
|
top_p=payload.topP,
|
||||||
|
top_k=payload.topK,
|
||||||
|
presence_penalty=payload.presencePenalty,
|
||||||
|
frequency_penalty=payload.frequencyPenalty,
|
||||||
|
max_token=payload.maxToken,
|
||||||
|
context_window=payload.contextWindow,
|
||||||
|
stream_enabled=payload.streamEnabled,
|
||||||
|
response_format=payload.responseFormat,
|
||||||
|
extra_params=payload.extraParams,
|
||||||
|
remark=payload.remark,
|
||||||
|
timeout_second=payload.timeoutSecond,
|
||||||
|
enabled=0,
|
||||||
|
)
|
||||||
|
db.add(model)
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="create", target_id=model.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(model)
|
||||||
|
return api_success(_model_dict(model))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/model/{model_id}")
|
||||||
|
def update_model(
|
||||||
|
model_id: int,
|
||||||
|
payload: ModelSaveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
model = db.get(ModelConfig, model_id)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||||
|
|
||||||
|
model.provider = payload.provider
|
||||||
|
model.display_name = payload.displayName
|
||||||
|
model.api_type = payload.apiType
|
||||||
|
model.model_name = payload.modelName
|
||||||
|
model.base_url = payload.baseUrl
|
||||||
|
model.api_url = payload.apiUrl
|
||||||
|
if payload.apiKey and payload.apiKey != MASKED_SECRET:
|
||||||
|
model.api_key = SecretService.encrypt(payload.apiKey)
|
||||||
|
model.auth_type = payload.authType
|
||||||
|
model.api_version = payload.apiVersion
|
||||||
|
model.temperature = payload.temperature
|
||||||
|
model.top_p = payload.topP
|
||||||
|
model.top_k = payload.topK
|
||||||
|
model.presence_penalty = payload.presencePenalty
|
||||||
|
model.frequency_penalty = payload.frequencyPenalty
|
||||||
|
model.max_token = payload.maxToken
|
||||||
|
model.context_window = payload.contextWindow
|
||||||
|
model.stream_enabled = payload.streamEnabled
|
||||||
|
model.response_format = payload.responseFormat
|
||||||
|
model.extra_params = payload.extraParams
|
||||||
|
model.remark = payload.remark
|
||||||
|
model.timeout_second = payload.timeoutSecond
|
||||||
|
db.add(model)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(model)
|
||||||
|
return api_success(_model_dict(model))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/model/enable")
|
||||||
|
def enable_model(
|
||||||
|
payload: EnableModelRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
target = db.get(ModelConfig, payload.modelId)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||||
|
for model in db.scalars(select(ModelConfig)).all():
|
||||||
|
model.enabled = 1 if model.id == target.id else 0
|
||||||
|
db.add(model)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="enable", target_id=target.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/model/{model_id}")
|
||||||
|
def delete_model(
|
||||||
|
model_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
model = db.get(ModelConfig, model_id)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||||
|
db.delete(model)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="delete", target_id=model.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/model/{model_id}/test")
|
||||||
|
def test_model(
|
||||||
|
model_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
model = db.get(ModelConfig, model_id)
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
|
||||||
|
result = ModelClientService.test_model(model)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="test", target_id=model.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
def list_config(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
configs = db.scalars(select(SystemConfig).order_by(SystemConfig.config_key.asc())).all()
|
||||||
|
return api_success([_config_dict(config) for config in configs])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
def save_config(
|
||||||
|
payload: SystemConfigSaveRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == payload.configKey))
|
||||||
|
if config is None:
|
||||||
|
config = SystemConfig(config_key=payload.configKey, config_value=payload.configValue)
|
||||||
|
if payload.configKey in SENSITIVE_CONFIG_KEYS:
|
||||||
|
if payload.configValue == MASKED_SECRET:
|
||||||
|
return api_success(_config_dict(config))
|
||||||
|
config.config_value = SecretService.encrypt(payload.configValue)
|
||||||
|
else:
|
||||||
|
config.config_value = payload.configValue
|
||||||
|
config.description = payload.description
|
||||||
|
config.updated_by = current_admin.id
|
||||||
|
db.add(config)
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="config", action="save", target_id=config.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config)
|
||||||
|
return api_success(_config_dict(config))
|
||||||
|
|
||||||
|
|
||||||
|
def _model_dict(model: ModelConfig) -> dict:
|
||||||
|
return {
|
||||||
|
"id": model.id,
|
||||||
|
"provider": model.provider,
|
||||||
|
"displayName": model.display_name,
|
||||||
|
"apiType": model.api_type,
|
||||||
|
"modelName": model.model_name,
|
||||||
|
"baseUrl": model.base_url,
|
||||||
|
"apiUrl": model.api_url,
|
||||||
|
"apiKeyMasked": SecretService.masked(model.api_key),
|
||||||
|
"authType": model.auth_type,
|
||||||
|
"apiVersion": model.api_version,
|
||||||
|
"temperature": float(model.temperature) if model.temperature is not None else None,
|
||||||
|
"topP": float(model.top_p) if model.top_p is not None else None,
|
||||||
|
"topK": model.top_k,
|
||||||
|
"presencePenalty": float(model.presence_penalty) if model.presence_penalty is not None else None,
|
||||||
|
"frequencyPenalty": float(model.frequency_penalty) if model.frequency_penalty is not None else None,
|
||||||
|
"maxToken": model.max_token,
|
||||||
|
"contextWindow": model.context_window,
|
||||||
|
"streamEnabled": model.stream_enabled,
|
||||||
|
"responseFormat": model.response_format,
|
||||||
|
"extraParams": model.extra_params,
|
||||||
|
"remark": model.remark,
|
||||||
|
"timeoutSecond": model.timeout_second,
|
||||||
|
"enabled": model.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_model(db: Session) -> ModelConfig | None:
|
||||||
|
return db.scalar(
|
||||||
|
select(ModelConfig)
|
||||||
|
.where(ModelConfig.enabled == 1)
|
||||||
|
.order_by(ModelConfig.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_runtime_config_dict(model: ModelConfig | None) -> dict:
|
||||||
|
return {
|
||||||
|
"modelId": model.id if model is not None else None,
|
||||||
|
"modelName": (model.display_name or model.model_name) if model is not None else None,
|
||||||
|
"temperature": float(model.temperature) if model is not None and model.temperature is not None else None,
|
||||||
|
"topP": float(model.top_p) if model is not None and model.top_p is not None else None,
|
||||||
|
"topK": model.top_k if model is not None else None,
|
||||||
|
"presencePenalty": float(model.presence_penalty) if model is not None and model.presence_penalty is not None else None,
|
||||||
|
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
|
||||||
|
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_prompt_detail() -> dict:
|
||||||
|
return {
|
||||||
|
"id": None,
|
||||||
|
"promptContent": DEFAULT_PROMPT,
|
||||||
|
"changeType": "default",
|
||||||
|
"sourcePromptId": None,
|
||||||
|
"updatedByName": "系统默认",
|
||||||
|
"updatedAt": None,
|
||||||
|
"charCount": len(DEFAULT_PROMPT),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_summary(prompt: Prompt, admin: Admin | None, *, is_current: bool) -> dict:
|
||||||
|
compact = " ".join(prompt.prompt_content.split())
|
||||||
|
return {
|
||||||
|
"id": prompt.id,
|
||||||
|
"preview": compact[:140],
|
||||||
|
"charCount": len(prompt.prompt_content),
|
||||||
|
"changeType": prompt.change_type,
|
||||||
|
"sourcePromptId": prompt.source_prompt_id,
|
||||||
|
"updatedByName": admin.name if admin else "未知管理员",
|
||||||
|
"updatedAt": prompt.updated_at,
|
||||||
|
"isCurrent": is_current,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_detail(prompt: Prompt, admin: Admin | None = None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": prompt.id,
|
||||||
|
"promptContent": prompt.prompt_content,
|
||||||
|
"changeType": prompt.change_type,
|
||||||
|
"sourcePromptId": prompt.source_prompt_id,
|
||||||
|
"updatedByName": admin.name if admin else None,
|
||||||
|
"updatedAt": prompt.updated_at,
|
||||||
|
"charCount": len(prompt.prompt_content),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_knowledge_scopes(db: Session, knowledge_ids: list[int]) -> list[KnowledgeScope]:
|
||||||
|
query = select(Knowledge).where(Knowledge.status == 1)
|
||||||
|
if knowledge_ids:
|
||||||
|
query = query.where(Knowledge.id.in_(knowledge_ids))
|
||||||
|
rows = db.scalars(query.order_by(Knowledge.id.asc())).all()
|
||||||
|
return [
|
||||||
|
KnowledgeScope(
|
||||||
|
id=item.id,
|
||||||
|
name=item.name,
|
||||||
|
feishu_space_id=item.feishu_space_id,
|
||||||
|
feishu_node_id=item.feishu_node_id,
|
||||||
|
)
|
||||||
|
for item in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_agent_debug_prompt(prompt_content: str, question: str, chunks: list) -> str:
|
||||||
|
context = "\n\n".join(
|
||||||
|
f"[知识片段 {index}] 来源:{chunk.knowledge_name} / {chunk.title}\n{chunk.content}"
|
||||||
|
for index, chunk in enumerate(chunks, start=1)
|
||||||
|
)
|
||||||
|
if not context:
|
||||||
|
context = "未检索到相关知识片段。"
|
||||||
|
return f"{prompt_content.strip()}\n\n{context}\n\n用户问题:{question.strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/feishu/cache/clear")
|
||||||
|
def clear_feishu_cache(current_admin: Admin = Depends(get_current_admin)) -> dict:
|
||||||
|
count = FeishuKnowledgeService.clear_cache()
|
||||||
|
return api_success({"cleared": count, "message": f"已清空 {count} 条飞书缓存"})
|
||||||
|
|
||||||
|
|
||||||
|
def _config_dict(config: SystemConfig) -> dict:
|
||||||
|
value = config.config_value
|
||||||
|
if config.config_key in SENSITIVE_CONFIG_KEYS:
|
||||||
|
value = SecretService.masked(value)
|
||||||
|
return {
|
||||||
|
"id": config.id,
|
||||||
|
"configKey": config.config_key,
|
||||||
|
"configValue": value,
|
||||||
|
"description": config.description,
|
||||||
|
"updatedAt": config.updated_at,
|
||||||
|
}
|
||||||
386
ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
Normal file
386
ai_knowledge_base_v2/apps/backend/app/api/admin_users.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from openpyxl import Workbook, load_workbook
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_admin
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.ai_config import SystemConfig
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
|
||||||
|
from app.services.admin_service import OperationLogService
|
||||||
|
from app.api.pagination import page_result
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期"]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/list")
|
||||||
|
def list_users(
|
||||||
|
keyword: str = Query(default=""),
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
pageSize: int = Query(default=20, ge=10, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
query = select(User).where(User.is_deleted == 0).order_by(User.id.desc())
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
query = query.where((User.phone.like(like)) | (User.name.like(like)))
|
||||||
|
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||||
|
users = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
|
||||||
|
return api_success(page_result([_user_dict(user) for user in users], total=total, page=page, page_size=pageSize))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user")
|
||||||
|
def create_user(
|
||||||
|
payload: AdminUserCreateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
phone = _normalize_phone(payload.phone)
|
||||||
|
_validate_phone(phone)
|
||||||
|
existing = db.scalar(select(User).where(User.phone == phone))
|
||||||
|
if existing is not None and not existing.is_deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="手机号已存在")
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
user = existing
|
||||||
|
user.is_deleted = 0
|
||||||
|
user.daily_chat_used = 0
|
||||||
|
else:
|
||||||
|
user = User(phone=phone, daily_chat_used=0)
|
||||||
|
|
||||||
|
_apply_user_payload(user, payload, _default_daily_chat_limit(db))
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return api_success(_user_dict(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/import")
|
||||||
|
def import_users(
|
||||||
|
payload: AdminUserImportRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
if not payload.students:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请提供学员数据")
|
||||||
|
|
||||||
|
return api_success(_import_user_items(list(enumerate(payload.students, start=1)), db, current_admin))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/import/template")
|
||||||
|
def download_user_import_template(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "学员导入模板"
|
||||||
|
sheet.append(STUDENT_TEMPLATE_HEADERS)
|
||||||
|
sheet.append(["13800000001", "张三", "三三", _default_daily_chat_limit(db), "启用", "2026-12-31 23:59:59"])
|
||||||
|
sheet.append(["13800000002", "李四", "", "", "启用", ""])
|
||||||
|
|
||||||
|
widths = [18, 14, 14, 16, 12, 22]
|
||||||
|
for index, width in enumerate(widths, start=1):
|
||||||
|
sheet.column_dimensions[chr(64 + index)].width = width
|
||||||
|
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
stream.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="student_import_template.xlsx"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/import/excel")
|
||||||
|
def import_users_excel(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
filename = (file.filename or "").lower()
|
||||||
|
if not filename.endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 .xlsx 格式的 Excel 文件")
|
||||||
|
|
||||||
|
content = file.file.read()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件为空")
|
||||||
|
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(BytesIO(content), data_only=True)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 文件解析失败") from exc
|
||||||
|
|
||||||
|
sheet = workbook.active
|
||||||
|
header_map = _excel_header_map(next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), ()))
|
||||||
|
for required in ("手机号", "姓名"):
|
||||||
|
if required not in header_map:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"模板缺少必填列:{required}")
|
||||||
|
|
||||||
|
students: list[tuple[int, AdminUserImportItem]] = []
|
||||||
|
parse_failures: list[dict] = []
|
||||||
|
for row_number, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
|
||||||
|
if not any(_cell_text(cell) for cell in row):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item = AdminUserImportItem(
|
||||||
|
phone=_cell_text(_excel_value(row, header_map, "手机号")),
|
||||||
|
name=_cell_text(_excel_value(row, header_map, "姓名")),
|
||||||
|
nickname=_cell_text(_excel_value(row, header_map, "昵称")) or None,
|
||||||
|
dailyChatLimit=_parse_optional_int(_excel_value(row, header_map, "每日聊天额度")),
|
||||||
|
status=_parse_status(_excel_value(row, header_map, "状态")),
|
||||||
|
expiredAt=_parse_optional_datetime(_excel_value(row, header_map, "有效期")),
|
||||||
|
)
|
||||||
|
students.append((row_number, item))
|
||||||
|
except (ValueError, ValidationError) as exc:
|
||||||
|
parse_failures.append(
|
||||||
|
{
|
||||||
|
"row": row_number,
|
||||||
|
"phone": _cell_text(_excel_value(row, header_map, "手机号")),
|
||||||
|
"reason": _row_error_message(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not students and not parse_failures:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Excel 中没有可导入的学员数据")
|
||||||
|
|
||||||
|
result = _import_user_items(students, db, current_admin, parse_failures)
|
||||||
|
return api_success(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _import_user_items(
|
||||||
|
students: list[tuple[int, AdminUserImportItem]],
|
||||||
|
db: Session,
|
||||||
|
current_admin: Admin,
|
||||||
|
initial_failures: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
failed: list[dict] = list(initial_failures or [])
|
||||||
|
seen: set[str] = set()
|
||||||
|
default_daily_limit = _default_daily_chat_limit(db)
|
||||||
|
|
||||||
|
for row_number, item in students:
|
||||||
|
phone = _normalize_phone(item.phone)
|
||||||
|
name = item.name.strip()
|
||||||
|
try:
|
||||||
|
_validate_phone(phone)
|
||||||
|
if not name:
|
||||||
|
raise ValueError("姓名不能为空")
|
||||||
|
if phone in seen:
|
||||||
|
raise ValueError("手机号在本次导入中重复")
|
||||||
|
seen.add(phone)
|
||||||
|
|
||||||
|
user = db.scalar(select(User).where(User.phone == phone))
|
||||||
|
if user is None:
|
||||||
|
user = User(phone=phone, daily_chat_used=0)
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
user.is_deleted = 0
|
||||||
|
updated += 1
|
||||||
|
_apply_user_payload(user, item, default_daily_limit)
|
||||||
|
db.add(user)
|
||||||
|
except ValueError as exc:
|
||||||
|
failed.append({"row": row_number, "phone": item.phone, "reason": str(exc)})
|
||||||
|
|
||||||
|
if created or updated:
|
||||||
|
db.flush()
|
||||||
|
OperationLogService.write(
|
||||||
|
db,
|
||||||
|
admin_id=current_admin.id,
|
||||||
|
module="user",
|
||||||
|
action="import",
|
||||||
|
target_id=None,
|
||||||
|
result="SUCCESS",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"created": created, "updated": updated, "failed": len(failed), "failures": failed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user/{user_id}")
|
||||||
|
def user_detail(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
user = _get_user(db, user_id)
|
||||||
|
return api_success(_user_dict(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/user/{user_id}")
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
payload: AdminUserUpdateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
user = _get_user(db, user_id)
|
||||||
|
if payload.status is not None:
|
||||||
|
user.status = payload.status
|
||||||
|
if payload.dailyChatLimit is not None:
|
||||||
|
user.daily_chat_limit = payload.dailyChatLimit
|
||||||
|
if payload.expiredAt is not None:
|
||||||
|
user.expired_at = payload.expiredAt.replace(tzinfo=None)
|
||||||
|
db.add(user)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return api_success(_user_dict(user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/user/{user_id}")
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_admin: Admin = Depends(get_current_admin),
|
||||||
|
) -> dict:
|
||||||
|
user = _get_user(db, user_id)
|
||||||
|
user.is_deleted = 1
|
||||||
|
db.add(user)
|
||||||
|
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="delete", target_id=user.id)
|
||||||
|
db.commit()
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user(db: Session, user_id: int) -> User:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None or user.is_deleted:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _user_dict(user: User) -> dict:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"phone": user.phone,
|
||||||
|
"name": user.name,
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"status": user.status,
|
||||||
|
"dailyChatLimit": user.daily_chat_limit,
|
||||||
|
"dailyChatUsed": user.daily_chat_used,
|
||||||
|
"expiredAt": user.expired_at,
|
||||||
|
"lastLoginAt": user.last_login_at,
|
||||||
|
"createdAt": user.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_user_payload(
|
||||||
|
user: User,
|
||||||
|
payload: AdminUserCreateRequest | AdminUserImportItem,
|
||||||
|
default_daily_limit: int,
|
||||||
|
) -> None:
|
||||||
|
user.name = payload.name.strip()
|
||||||
|
user.nickname = payload.nickname.strip() if payload.nickname else None
|
||||||
|
user.status = payload.status
|
||||||
|
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit
|
||||||
|
user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _default_daily_chat_limit(db: Session) -> int:
|
||||||
|
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == "daily_chat_limit"))
|
||||||
|
if config is not None and config.config_value.strip():
|
||||||
|
try:
|
||||||
|
return max(0, min(100000, int(float(config.config_value.strip()))))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
return get_settings().default_daily_chat_limit
|
||||||
|
except Exception:
|
||||||
|
return 100
|
||||||
|
|
||||||
|
|
||||||
|
def _excel_header_map(headers: tuple) -> dict[str, int]:
|
||||||
|
return {_cell_text(header): index for index, header in enumerate(headers) if _cell_text(header)}
|
||||||
|
|
||||||
|
|
||||||
|
def _excel_value(row: tuple, header_map: dict[str, int], header: str) -> object | None:
|
||||||
|
index = header_map.get(header)
|
||||||
|
if index is None or index >= len(row):
|
||||||
|
return None
|
||||||
|
return row[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_text(value: object | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, float) and value.is_integer():
|
||||||
|
return str(int(value))
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_int(value: object | None) -> int | None:
|
||||||
|
text = _cell_text(value)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(float(text))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("每日聊天额度必须是数字") from exc
|
||||||
|
if parsed < 0 or parsed > 100000:
|
||||||
|
raise ValueError("每日聊天额度必须在 0 到 100000 之间")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status(value: object | None) -> int:
|
||||||
|
text = _cell_text(value).lower()
|
||||||
|
if not text:
|
||||||
|
return 1
|
||||||
|
if text in {"1", "启用", "正常", "是", "true", "enabled"}:
|
||||||
|
return 1
|
||||||
|
if text in {"0", "禁用", "停用", "否", "false", "disabled"}:
|
||||||
|
return 0
|
||||||
|
raise ValueError("状态只能填写启用/禁用或 1/0")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_datetime(value: object | None) -> datetime | None:
|
||||||
|
if value is None or _cell_text(value) == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.replace(tzinfo=None)
|
||||||
|
if isinstance(value, date):
|
||||||
|
return datetime.combine(value, datetime.min.time())
|
||||||
|
|
||||||
|
text = _cell_text(value)
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")
|
||||||
|
|
||||||
|
|
||||||
|
def _row_error_message(exc: ValueError | ValidationError) -> str:
|
||||||
|
if isinstance(exc, ValidationError):
|
||||||
|
return "字段格式不正确"
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_phone(phone: str) -> str:
|
||||||
|
return re.sub(r"\D", "", phone or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_phone(phone: str) -> None:
|
||||||
|
if not re.fullmatch(r"1[3-9]\d{9}", phone):
|
||||||
|
raise ValueError("手机号格式不正确")
|
||||||
37
ai_knowledge_base_v2/apps/backend/app/api/auth.py
Normal file
37
ai_knowledge_base_v2/apps/backend/app/api/auth.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_token_payload
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
from app.services.captcha_service import CaptchaService
|
||||||
|
from app.services.security_state_service import client_ip
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/captcha")
|
||||||
|
def captcha() -> dict:
|
||||||
|
return api_success(CaptchaResponse.model_validate(CaptchaService.create()).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sms/send")
|
||||||
|
def send_sms(payload: SendSmsRequest, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||||
|
AuthService.send_sms_code(db, payload.phone, payload.captchaId, payload.captchaCode, client_ip(request))
|
||||||
|
return api_success(message="发送成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
|
result = AuthService.login_with_sms(db, payload.phone, payload.code)
|
||||||
|
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(token_payload: dict = Depends(get_current_token_payload)) -> dict:
|
||||||
|
AuthService.logout(token_payload)
|
||||||
|
return api_success()
|
||||||
175
ai_knowledge_base_v2/apps/backend/app/api/chat.py
Normal file
175
ai_knowledge_base_v2/apps/backend/app/api/chat.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.chat import (
|
||||||
|
ChatCompletionRequest,
|
||||||
|
ChatMessageRead,
|
||||||
|
ChatSessionRead,
|
||||||
|
CreateSessionResponse,
|
||||||
|
StopChatRequest,
|
||||||
|
UpdateSessionTitleRequest,
|
||||||
|
)
|
||||||
|
from app.services.chat_service import ChatService
|
||||||
|
from app.services.chat_queue_runtime import (
|
||||||
|
cancel_chat_waiter,
|
||||||
|
release_chat_slot,
|
||||||
|
request_chat_slot,
|
||||||
|
wait_for_chat_slot,
|
||||||
|
)
|
||||||
|
from app.services.chat_queue_service import load_chat_queue_config
|
||||||
|
from app.services.chat_stream_service import ChatStreamService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/session")
|
||||||
|
def create_session(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
|
||||||
|
session = ChatService.create_session(db, current_user)
|
||||||
|
return api_success(CreateSessionResponse(sessionId=session.id).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session/list")
|
||||||
|
def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
|
||||||
|
sessions = ChatService.list_sessions(db, current_user)
|
||||||
|
return api_success([ChatSessionRead.model_validate(session).model_dump() for session in sessions])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
def history(
|
||||||
|
sessionId: int = Query(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
messages = ChatService.get_history(db, current_user, sessionId)
|
||||||
|
return api_success([ChatMessageRead.model_validate(message).model_dump(mode="json") for message in messages])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/session/title")
|
||||||
|
def update_title(
|
||||||
|
payload: UpdateSessionTitleRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
session = ChatService.update_title(db, current_user, payload.sessionId, payload.title)
|
||||||
|
return api_success(ChatSessionRead.model_validate(session).model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/session/{session_id}")
|
||||||
|
def delete_session(
|
||||||
|
session_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
ChatService.delete_session(db, current_user, session_id)
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/completions")
|
||||||
|
def completions(
|
||||||
|
payload: ChatCompletionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> StreamingResponse:
|
||||||
|
return StreamingResponse(_chat_stream(payload, db, current_user), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stop")
|
||||||
|
def stop(
|
||||||
|
payload: StopChatRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
ChatService.stop_generation(db, current_user, payload.sessionId)
|
||||||
|
return api_success()
|
||||||
|
|
||||||
|
|
||||||
|
async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User) -> AsyncIterator[str]:
|
||||||
|
config = load_chat_queue_config(db)
|
||||||
|
queue_lease = await request_chat_slot(config)
|
||||||
|
queue_request = queue_lease.request
|
||||||
|
acquired = queue_request.status == "acquired"
|
||||||
|
should_cancel_waiter = queue_request.status == "queued"
|
||||||
|
|
||||||
|
if queue_request.status == "rejected":
|
||||||
|
yield _sse_event(
|
||||||
|
"error",
|
||||||
|
message="当前请求过多,请稍后再试。",
|
||||||
|
activeCount=queue_request.active_count,
|
||||||
|
waitingCount=queue_request.waiting_count,
|
||||||
|
)
|
||||||
|
yield _sse_done()
|
||||||
|
return
|
||||||
|
|
||||||
|
if queue_request.status == "queued" and queue_request.token is not None:
|
||||||
|
yield _sse_event(
|
||||||
|
"queued",
|
||||||
|
message=f"当前请求较多,正在排队中,前方约 {max(queue_request.position - 1, 0)} 个请求。",
|
||||||
|
position=queue_request.position,
|
||||||
|
activeCount=queue_request.active_count,
|
||||||
|
waitingCount=queue_request.waiting_count,
|
||||||
|
)
|
||||||
|
queue_lease = await wait_for_chat_slot(queue_lease, config)
|
||||||
|
queue_request = queue_lease.request
|
||||||
|
acquired = queue_request.status == "acquired"
|
||||||
|
should_cancel_waiter = queue_request.status == "queued"
|
||||||
|
if queue_request.status == "rejected":
|
||||||
|
yield _sse_event(
|
||||||
|
"error",
|
||||||
|
message="当前请求过多,请稍后再试。",
|
||||||
|
activeCount=queue_request.active_count,
|
||||||
|
waitingCount=queue_request.waiting_count,
|
||||||
|
)
|
||||||
|
yield _sse_done()
|
||||||
|
return
|
||||||
|
if queue_request.status == "timeout":
|
||||||
|
yield _sse_event("error", message="排队等待超时,请稍后再试。")
|
||||||
|
yield _sse_done()
|
||||||
|
return
|
||||||
|
if queue_request.status == "canceled":
|
||||||
|
yield _sse_event("error", message="排队请求已取消,请稍后再试。")
|
||||||
|
yield _sse_done()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield _sse_event(
|
||||||
|
"generating",
|
||||||
|
message="已进入生成队列,正在生成回答。",
|
||||||
|
activeCount=queue_request.active_count,
|
||||||
|
waitingCount=queue_request.waiting_count,
|
||||||
|
)
|
||||||
|
async for chunk in ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message):
|
||||||
|
yield _sse_event("content", content=chunk)
|
||||||
|
except HTTPException as exc:
|
||||||
|
yield _sse_event("error", message=str(exc.detail))
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Chat stream failed")
|
||||||
|
yield _sse_event("error", message="AI 回复生成失败,请稍后再试。")
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
await release_chat_slot(queue_lease)
|
||||||
|
elif should_cancel_waiter:
|
||||||
|
await cancel_chat_waiter(queue_lease)
|
||||||
|
|
||||||
|
yield _sse_done()
|
||||||
|
|
||||||
|
def _sse_event(event_type: str, **payload: object) -> str:
|
||||||
|
return f"data: {json.dumps({'type': event_type, **payload}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_done() -> str:
|
||||||
|
return "data: [DONE]\n\n"
|
||||||
45
ai_knowledge_base_v2/apps/backend/app/api/health.py
Normal file
45
ai_knowledge_base_v2/apps/backend/app/api/health.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import SessionLocal
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return api_success({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ready")
|
||||||
|
def ready():
|
||||||
|
checks = {"database": _database_ready(), "redis": _redis_ready()}
|
||||||
|
redis_required = get_settings().readiness_requires_redis
|
||||||
|
ready_state = checks["database"] and (checks["redis"] or not redis_required)
|
||||||
|
payload = api_success({"status": "ready" if ready_state else "not_ready", "checks": checks})
|
||||||
|
return payload if ready_state else JSONResponse(status_code=503, content=payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _database_ready() -> bool:
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("SELECT 1"))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _redis_ready() -> bool:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(client.ping())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
7
ai_knowledge_base_v2/apps/backend/app/api/pagination.py
Normal file
7
ai_knowledge_base_v2/apps/backend/app/api/pagination.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def page_result(items: list[Any], *, total: int, page: int, page_size: int) -> dict[str, Any]:
|
||||||
|
return {"items": items, "total": total, "page": page, "pageSize": page_size}
|
||||||
32
ai_knowledge_base_v2/apps/backend/app/api/router.py
Normal file
32
ai_knowledge_base_v2/apps/backend/app/api/router.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api import (
|
||||||
|
admin_auth,
|
||||||
|
admin_agent_records,
|
||||||
|
admin_dashboard,
|
||||||
|
admin_knowledge,
|
||||||
|
admin_knowledge_lifecycle,
|
||||||
|
admin_records,
|
||||||
|
admin_settings,
|
||||||
|
admin_users,
|
||||||
|
auth,
|
||||||
|
chat,
|
||||||
|
health,
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(health.router, tags=["health"])
|
||||||
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
|
api_router.include_router(user.router, prefix="/user", tags=["user"])
|
||||||
|
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
|
||||||
|
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
|
||||||
|
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
|
||||||
|
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
|
||||||
|
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
|
||||||
|
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
|
||||||
|
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
|
||||||
|
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
|
||||||
|
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
|
||||||
15
ai_knowledge_base_v2/apps/backend/app/api/user.py
Normal file
15
ai_knowledge_base_v2/apps/backend/app/api/user.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user
|
||||||
|
from app.core.responses import api_success
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserProfile
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile")
|
||||||
|
def profile(current_user: User = Depends(get_current_user)) -> dict:
|
||||||
|
return api_success(UserProfile.model_validate(current_user).model_dump(mode="json"))
|
||||||
1
ai_knowledge_base_v2/apps/backend/app/core/__init__.py
Normal file
1
ai_knowledge_base_v2/apps/backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
73
ai_knowledge_base_v2/apps/backend/app/core/config.py
Normal file
73
ai_knowledge_base_v2/apps/backend/app/core/config.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
app_name: str = "AI Knowledge Base V2 Backend"
|
||||||
|
app_env: str = "local"
|
||||||
|
debug: bool = True
|
||||||
|
log_level: str = "INFO"
|
||||||
|
api_prefix: str = "/api"
|
||||||
|
cors_origins: list[str] = Field(
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4"
|
||||||
|
redis_url: str = ""
|
||||||
|
readiness_requires_redis: bool = True
|
||||||
|
db_pool_size: int = 10
|
||||||
|
db_max_overflow: int = 20
|
||||||
|
db_pool_timeout: int = 30
|
||||||
|
db_pool_recycle: int = 1800
|
||||||
|
auto_create_tables: bool = False
|
||||||
|
|
||||||
|
jwt_secret_key: str = "change-me-in-production"
|
||||||
|
config_encryption_key: str = ""
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
access_token_expire_minutes: int = 60 * 24 * 30
|
||||||
|
|
||||||
|
mock_sms_enabled: bool = True
|
||||||
|
mock_sms_code: str = "123456"
|
||||||
|
sms_code_expire_minutes: int = 5
|
||||||
|
aliyun_sms_access_key_id: str = ""
|
||||||
|
aliyun_sms_access_key_secret: str = ""
|
||||||
|
aliyun_sms_sign_name: str = ""
|
||||||
|
aliyun_sms_template_code: str = ""
|
||||||
|
aliyun_sms_template_param_key: str = "code"
|
||||||
|
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
|
||||||
|
mock_rag_enabled: bool = False
|
||||||
|
mock_model_enabled: bool = True
|
||||||
|
feishu_mock_enabled: bool = False
|
||||||
|
feishu_search_url: str = ""
|
||||||
|
feishu_app_id: str = ""
|
||||||
|
feishu_app_secret: str = ""
|
||||||
|
feishu_timeout_seconds: int = 20
|
||||||
|
feishu_retry_count: int = 2
|
||||||
|
|
||||||
|
default_daily_chat_limit: int = 100
|
||||||
|
default_user_name_prefix: str = "用户"
|
||||||
|
chat_max_active_requests: int = 2
|
||||||
|
chat_max_queue_size: int = 20
|
||||||
|
chat_queue_timeout_seconds: int = 60
|
||||||
|
chat_active_lease_seconds: int = 900
|
||||||
|
bootstrap_admin_username: str = ""
|
||||||
|
bootstrap_admin_password: str = ""
|
||||||
|
bootstrap_admin_name: str = "系统管理员"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
36
ai_knowledge_base_v2/apps/backend/app/core/database.py
Normal file
36
ai_knowledge_base_v2/apps/backend/app/core/database.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models import Base
|
||||||
|
from app.services.knowledge_catalog_cache_service import register_knowledge_cache_invalidation_events
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine_options = {"pool_pre_ping": True, "future": True}
|
||||||
|
if not settings.database_url.startswith("sqlite"):
|
||||||
|
engine_options.update(
|
||||||
|
pool_size=settings.db_pool_size,
|
||||||
|
max_overflow=settings.db_max_overflow,
|
||||||
|
pool_timeout=settings.db_pool_timeout,
|
||||||
|
pool_recycle=settings.db_pool_recycle,
|
||||||
|
)
|
||||||
|
engine = create_engine(settings.database_url, **engine_options)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
register_knowledge_cache_invalidation_events()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_tables() -> None:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
59
ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
Normal file
59
ai_knowledge_base_v2/apps/backend/app/core/dependencies.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.auth_service import AuthService
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_token_payload(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict:
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
if AuthService.is_token_revoked(payload):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已退出")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
payload: dict = Depends(get_current_token_payload),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
if payload.get("type") != "user":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是用户登录态")
|
||||||
|
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
user = db.get(User, int(user_id)) if user_id else None
|
||||||
|
if user is None or user.is_deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||||
|
if user.status != 1:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||||
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
if user.effective_at and user.effective_at > now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
|
||||||
|
if user.expired_at and user.expired_at < now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_admin(
|
||||||
|
payload: dict = Depends(get_current_token_payload),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Admin:
|
||||||
|
if payload.get("type") != "admin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前凭证不是管理员登录态")
|
||||||
|
|
||||||
|
admin_id = payload.get("sub")
|
||||||
|
admin = db.get(Admin, int(admin_id)) if admin_id else None
|
||||||
|
if admin is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员不存在")
|
||||||
|
if admin.status != 1:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
|
||||||
|
return admin
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.core.responses import api_error
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=exc.status_code, content=api_error(_code_from_status(exc.status_code), str(exc.detail)))
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=422, content=api_error(10000, "请求参数不正确", exc.errors()))
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=500, content=api_error(50000, "系统内部错误,请稍后重试"))
|
||||||
|
|
||||||
|
|
||||||
|
def _code_from_status(status_code: int) -> int:
|
||||||
|
if status_code in {401, 403}:
|
||||||
|
return 20000 if status_code == 401 else 40000
|
||||||
|
if status_code == 400:
|
||||||
|
return 10000
|
||||||
|
return 50000
|
||||||
99
ai_knowledge_base_v2/apps/backend/app/core/observability.py
Normal file
99
ai_knowledge_base_v2/apps/backend/app/core/observability.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from logging.config import dictConfig
|
||||||
|
from time import perf_counter
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
request_id_context: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
"request_id": getattr(record, "request_id", None) or request_id_context.get(),
|
||||||
|
}
|
||||||
|
for key in ("method", "path", "status_code", "duration_ms", "client_ip"):
|
||||||
|
value = getattr(record, key, None)
|
||||||
|
if value is not None:
|
||||||
|
payload[key] = value
|
||||||
|
if record.exc_info:
|
||||||
|
payload["exception"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "INFO") -> None:
|
||||||
|
dictConfig(
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {"json": {"()": JsonFormatter}},
|
||||||
|
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}},
|
||||||
|
"loggers": {
|
||||||
|
"app": {"handlers": ["console"], "level": level, "propagate": False},
|
||||||
|
"app.access": {"handlers": ["console"], "level": level, "propagate": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestObservabilityMiddleware:
|
||||||
|
def __init__(self, app) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.logger = logging.getLogger("app.access")
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
||||||
|
incoming_id = headers.get(b"x-request-id", b"").decode("ascii", errors="ignore").strip()
|
||||||
|
request_id = incoming_id[:64] if incoming_id else uuid4().hex
|
||||||
|
token = request_id_context.set(request_id)
|
||||||
|
started_at = perf_counter()
|
||||||
|
status_code = 500
|
||||||
|
response_finished = False
|
||||||
|
|
||||||
|
async def send_with_request_id(message) -> None:
|
||||||
|
nonlocal status_code, response_finished
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
status_code = int(message["status"])
|
||||||
|
message.setdefault("headers", []).append((b"x-request-id", request_id.encode("ascii")))
|
||||||
|
elif message["type"] == "http.response.body" and not message.get("more_body", False):
|
||||||
|
response_finished = True
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, receive, send_with_request_id)
|
||||||
|
except Exception:
|
||||||
|
self._log(scope, request_id, 500, started_at, logging.ERROR, "request_failed", exc_info=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if response_finished:
|
||||||
|
self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed")
|
||||||
|
request_id_context.reset(token)
|
||||||
|
|
||||||
|
def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None:
|
||||||
|
client = scope.get("client")
|
||||||
|
self.logger.log(
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
extra={
|
||||||
|
"request_id": request_id,
|
||||||
|
"method": scope.get("method"),
|
||||||
|
"path": scope.get("path"),
|
||||||
|
"status_code": status_code,
|
||||||
|
"duration_ms": round((perf_counter() - started_at) * 1000, 2),
|
||||||
|
"client_ip": client[0] if client else "unknown",
|
||||||
|
},
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
11
ai_knowledge_base_v2/apps/backend/app/core/responses.py
Normal file
11
ai_knowledge_base_v2/apps/backend/app/core/responses.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def api_success(data: Any = None, message: str = "success") -> dict[str, Any]:
|
||||||
|
return {"code": 0, "message": message, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
def api_error(code: int, message: str, data: Any = None) -> dict[str, Any]:
|
||||||
|
return {"code": code, "message": message, "data": data}
|
||||||
53
ai_knowledge_base_v2/apps/backend/app/core/security.py
Normal file
53
ai_knowledge_base_v2/apps/backend/app/core/security.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: str, token_type: str = "user") -> tuple[str, datetime]:
|
||||||
|
settings = get_settings()
|
||||||
|
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
payload = {
|
||||||
|
"sub": subject,
|
||||||
|
"type": token_type,
|
||||||
|
"jti": uuid4().hex,
|
||||||
|
"exp": expires_at,
|
||||||
|
"iat": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
return token, expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict:
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc
|
||||||
|
except jwt.InvalidTokenError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效登录凭证") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = os.urandom(16).hex()
|
||||||
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
|
||||||
|
return f"pbkdf2_sha256${salt}${digest.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
algorithm, salt, expected = password_hash.split("$", 2)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if algorithm != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000)
|
||||||
|
return hmac.compare_digest(digest.hex(), expected)
|
||||||
55
ai_knowledge_base_v2/apps/backend/app/main.py
Normal file
55
ai_knowledge_base_v2/apps/backend/app/main.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.router import api_router
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import create_tables
|
||||||
|
from app.core.exception_handlers import register_exception_handlers
|
||||||
|
from app.core.observability import RequestObservabilityMiddleware, configure_logging
|
||||||
|
from app.services.secret_service import SecretService
|
||||||
|
from app.services.maintenance_service import MaintenanceService
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
settings = get_settings()
|
||||||
|
SecretService.validate_production_key()
|
||||||
|
if settings.auto_create_tables:
|
||||||
|
create_tables()
|
||||||
|
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
maintenance_task.cancel()
|
||||||
|
try:
|
||||||
|
await maintenance_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(settings.log_level)
|
||||||
|
|
||||||
|
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
|
||||||
|
register_exception_handlers(app)
|
||||||
|
app.add_middleware(RequestObservabilityMiddleware)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api_router, prefix=settings.api_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root() -> dict[str, str]:
|
||||||
|
return {"name": settings.app_name, "status": "ok"}
|
||||||
54
ai_knowledge_base_v2/apps/backend/app/models/__init__.py
Normal file
54
ai_knowledge_base_v2/apps/backend/app/models/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from app.models.admin import Admin, Role
|
||||||
|
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.chat import ChatMessage, ChatSession
|
||||||
|
from app.models.knowledge import (
|
||||||
|
HumanAttentionHistory,
|
||||||
|
HumanAttentionRecord,
|
||||||
|
Knowledge,
|
||||||
|
KnowledgeCard,
|
||||||
|
KnowledgeChunk,
|
||||||
|
KnowledgeManifest,
|
||||||
|
KnowledgePublishLog,
|
||||||
|
KnowledgeRetrievalCandidate,
|
||||||
|
KnowledgeRetrievalLog,
|
||||||
|
KnowledgeReviewRecord,
|
||||||
|
KnowledgeSection,
|
||||||
|
KnowledgeSourceSnapshot,
|
||||||
|
KnowledgeSyncJob,
|
||||||
|
KnowledgeVersion,
|
||||||
|
UserKnowledgePermission,
|
||||||
|
)
|
||||||
|
from app.models.logs import AiRequestLog, LogRetentionPolicy, OperationLog, StorageSnapshot
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Admin",
|
||||||
|
"AiRequestLog",
|
||||||
|
"Base",
|
||||||
|
"ChatMessage",
|
||||||
|
"ChatSession",
|
||||||
|
"Knowledge",
|
||||||
|
"KnowledgeCard",
|
||||||
|
"KnowledgeChunk",
|
||||||
|
"KnowledgeManifest",
|
||||||
|
"KnowledgePublishLog",
|
||||||
|
"KnowledgeRetrievalCandidate",
|
||||||
|
"KnowledgeRetrievalLog",
|
||||||
|
"KnowledgeReviewRecord",
|
||||||
|
"KnowledgeSection",
|
||||||
|
"KnowledgeSourceSnapshot",
|
||||||
|
"KnowledgeSyncJob",
|
||||||
|
"KnowledgeVersion",
|
||||||
|
"HumanAttentionHistory",
|
||||||
|
"HumanAttentionRecord",
|
||||||
|
"ModelConfig",
|
||||||
|
"OperationLog",
|
||||||
|
"LogRetentionPolicy",
|
||||||
|
"StorageSnapshot",
|
||||||
|
"Prompt",
|
||||||
|
"Role",
|
||||||
|
"SystemConfig",
|
||||||
|
"User",
|
||||||
|
"UserKnowledgePermission",
|
||||||
|
]
|
||||||
33
ai_knowledge_base_v2/apps/backend/app/models/admin.py
Normal file
33
ai_knowledge_base_v2/apps/backend/app/models/admin.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Role(Base, TimestampMixin):
|
||||||
|
__tablename__ = "sys_role"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
admins: Mapped[list["Admin"]] = relationship("Admin", back_populates="role")
|
||||||
|
|
||||||
|
|
||||||
|
class Admin(Base, TimestampMixin):
|
||||||
|
__tablename__ = "sys_admin"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
role_id: Mapped[int | None] = mapped_column(ForeignKey("sys_role.id"), nullable=True)
|
||||||
|
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
role: Mapped[Role | None] = relationship("Role", back_populates="admins")
|
||||||
62
ai_knowledge_base_v2/apps/backend/app/models/ai_config.py
Normal file
62
ai_knowledge_base_v2/apps/backend/app/models/ai_config.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, Index, Integer, Numeric, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Prompt(Base):
|
||||||
|
__tablename__ = "sys_prompt"
|
||||||
|
__table_args__ = (Index("ix_sys_prompt_updated_at_id", "updated_at", "id"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True
|
||||||
|
)
|
||||||
|
prompt_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
change_type: Mapped[str] = mapped_column(String(20), default="save", nullable=False)
|
||||||
|
source_prompt_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelConfig(Base):
|
||||||
|
__tablename__ = "sys_model"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
api_type: Mapped[str] = mapped_column(String(50), default="openai_compatible", nullable=False)
|
||||||
|
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
base_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
api_url: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
api_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
auth_type: Mapped[str] = mapped_column(String(30), default="bearer", nullable=False)
|
||||||
|
api_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
temperature: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
|
||||||
|
top_p: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
|
||||||
|
top_k: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
presence_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
|
||||||
|
frequency_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
|
||||||
|
max_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
context_window: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
stream_enabled: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||||
|
response_format: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
extra_params: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
|
||||||
|
enabled: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(Base):
|
||||||
|
__tablename__ = "sys_system_config"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
config_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||||
|
config_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
19
ai_knowledge_base_v2/apps/backend/app/models/base.py
Normal file
19
ai_knowledge_base_v2/apps/backend/app/models/base.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoftDeleteMixin:
|
||||||
|
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||||
42
ai_knowledge_base_v2/apps/backend/app/models/chat.py
Normal file
42
ai_knowledge_base_v2/apps/backend/app/models/chat.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSession(Base, TimestampMixin):
|
||||||
|
__tablename__ = "sys_chat_session"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
summary_up_to_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
is_deleted: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="chat_sessions")
|
||||||
|
messages: Mapped[list["ChatMessage"]] = relationship("ChatMessage", back_populates="session")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(Base):
|
||||||
|
__tablename__ = "sys_chat_message"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
message_status: Mapped[str] = mapped_column(String(20), default="FINISHED", nullable=False)
|
||||||
|
token_input: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
token_output: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
model_id: Mapped[int | None] = mapped_column(ForeignKey("sys_model.id"), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages")
|
||||||
276
ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
Normal file
276
ai_knowledge_base_v2/apps/backend/app/models/knowledge.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||||
|
|
||||||
|
|
||||||
|
class Knowledge(Base, TimestampMixin):
|
||||||
|
__tablename__ = "sys_knowledge"
|
||||||
|
__table_args__ = (UniqueConstraint("feishu_space_id", "feishu_node_id", name="uq_knowledge_feishu_node"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
feishu_space_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
feishu_node_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||||
|
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
source_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
knowledge_type: Mapped[str] = mapped_column(String(30), default="general", nullable=False)
|
||||||
|
review_mode: Mapped[str] = mapped_column(String(30), default="automatic", nullable=False)
|
||||||
|
lifecycle_status: Mapped[str] = mapped_column(String(30), default="active", nullable=False)
|
||||||
|
source_status: Mapped[str] = mapped_column(String(30), default="normal", nullable=False)
|
||||||
|
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
manifest_confirmed: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
current_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
pending_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
last_published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
migration_status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False)
|
||||||
|
|
||||||
|
user_permissions: Mapped[list["UserKnowledgePermission"]] = relationship(
|
||||||
|
"UserKnowledgePermission", back_populates="knowledge"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserKnowledgePermission(Base, TimestampMixin):
|
||||||
|
__tablename__ = "sys_user_kb"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="knowledge_permissions")
|
||||||
|
knowledge: Mapped[Knowledge] = relationship("Knowledge", back_populates="user_permissions")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeSourceSnapshot(Base):
|
||||||
|
__tablename__ = "sys_knowledge_source_snapshot"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
sync_job_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
|
source_title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
source_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
|
||||||
|
source_identifier: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeVersion(Base):
|
||||||
|
__tablename__ = "sys_knowledge_version"
|
||||||
|
__table_args__ = (UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
snapshot_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_source_snapshot.id"), nullable=False)
|
||||||
|
version_no: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
|
||||||
|
quality_status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False)
|
||||||
|
change_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
quality_report: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
processing_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
generation_model: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
published_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeManifest(Base):
|
||||||
|
__tablename__ = "sys_knowledge_manifest"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||||
|
purpose: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
applicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
inapplicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
core_topics: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
entities: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
boundaries: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
change_level: Mapped[str] = mapped_column(String(20), default="major", nullable=False)
|
||||||
|
confirmed: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
confirmed_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeSection(Base):
|
||||||
|
__tablename__ = "sys_knowledge_section"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||||
|
section_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
source_start: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
source_end: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeChunk(Base):
|
||||||
|
__tablename__ = "sys_knowledge_chunk"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||||
|
section_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_section.id"), index=True, nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
normalized_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
keywords: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
synonyms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
source_start: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
source_end: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeCard(Base):
|
||||||
|
__tablename__ = "sys_knowledge_card"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||||
|
section_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_section.id"), index=True, nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
core_conclusion: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
applicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
inapplicable_questions: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
keywords: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
synonyms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
entities: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
risk_level: Mapped[str] = mapped_column(String(20), default="normal", nullable=False)
|
||||||
|
source_range: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
generation_model: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
generation_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
review_status: Mapped[str] = mapped_column(String(20), default="pending", nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeSyncJob(Base):
|
||||||
|
__tablename__ = "sys_knowledge_sync_job"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
mode: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
|
||||||
|
stage: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
source_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
result_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
requested_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeReviewRecord(Base):
|
||||||
|
__tablename__ = "sys_knowledge_review_record"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
version_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_version.id"), index=True, nullable=False)
|
||||||
|
card_id: Mapped[int | None] = mapped_column(ForeignKey("sys_knowledge_card.id"), nullable=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
reviewed_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgePublishLog(Base):
|
||||||
|
__tablename__ = "sys_knowledge_publish_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge.id"), index=True, nullable=False)
|
||||||
|
from_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
to_version_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
keep_open: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
operated_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeRetrievalLog(Base):
|
||||||
|
__tablename__ = "sys_knowledge_retrieval_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
|
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
|
question: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
catalog_snapshot: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
catalog_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
safety_rule_version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
knowledge_called: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
selected_knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
query_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
tool_trace: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
final_section_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
discarded_conflicts: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
final_answer: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
tool_cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
total_cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
attention_created: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeRetrievalCandidate(Base):
|
||||||
|
__tablename__ = "sys_knowledge_retrieval_candidate"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
retrieval_log_id: Mapped[int] = mapped_column(ForeignKey("sys_knowledge_retrieval_log.id"), index=True, nullable=False)
|
||||||
|
knowledge_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||||
|
version_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||||
|
chunk_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
section_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
lexical_score: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
rerank_score: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
selected: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
discard_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class HumanAttentionRecord(Base):
|
||||||
|
__tablename__ = "sys_human_attention_record"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||||
|
message_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||||
|
user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||||
|
trigger_message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
problem_summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
trigger_reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
priority: Mapped[str] = mapped_column(String(20), index=True, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), index=True, default="pending", nullable=False)
|
||||||
|
handler_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
handler_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class HumanAttentionHistory(Base):
|
||||||
|
__tablename__ = "sys_human_attention_history"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
attention_id: Mapped[int] = mapped_column(ForeignKey("sys_human_attention_record.id"), index=True, nullable=False)
|
||||||
|
from_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
to_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
operated_by: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
68
ai_knowledge_base_v2/apps/backend/app/models/logs.py
Normal file
68
ai_knowledge_base_v2/apps/backend/app/models/logs.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, Integer, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
|
||||||
|
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||||
|
|
||||||
|
|
||||||
|
class AiRequestLog(Base):
|
||||||
|
__tablename__ = "sys_ai_request_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
|
message_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
|
user_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
|
model_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
prompt: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
knowledge_ids: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
retrieve_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
retrieved_chunks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
input_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
output_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
total_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
cost_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class OperationLog(Base):
|
||||||
|
__tablename__ = "sys_operation_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
admin_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||||
|
module: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
target_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
before_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
after_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
result: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class LogRetentionPolicy(Base):
|
||||||
|
__tablename__ = "sys_log_retention_policy"
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
enabled: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
last_result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageSnapshot(Base):
|
||||||
|
__tablename__ = "sys_storage_snapshot"
|
||||||
|
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||||
|
scope: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
total_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
detail_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||||
29
ai_knowledge_base_v2/apps/backend/app/models/user.py
Normal file
29
ai_knowledge_base_v2/apps/backend/app/models/user.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Date, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
|
__tablename__ = "sys_user"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
nickname: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
status: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||||
|
daily_chat_limit: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
|
||||||
|
daily_chat_used: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
daily_chat_reset_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
knowledge_permissions: Mapped[list["UserKnowledgePermission"]] = relationship(
|
||||||
|
"UserKnowledgePermission", back_populates="user"
|
||||||
|
)
|
||||||
|
chat_sessions: Mapped[list["ChatSession"]] = relationship("ChatSession", back_populates="user")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
134
ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
Normal file
134
ai_knowledge_base_v2/apps/backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.common import ORMModel
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLoginRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=1, max_length=50)
|
||||||
|
password: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
expiredAt: datetime
|
||||||
|
admin: dict
|
||||||
|
|
||||||
|
|
||||||
|
class AdminRead(ORMModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
name: str
|
||||||
|
status: int
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
userCount: int
|
||||||
|
sessionCount: int
|
||||||
|
messageCount: int
|
||||||
|
aiRequestCount: int
|
||||||
|
knowledgeCount: int
|
||||||
|
inputToken: int
|
||||||
|
outputToken: int
|
||||||
|
totalToken: int
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserUpdateRequest(BaseModel):
|
||||||
|
status: int | None = Field(default=None, ge=0, le=1)
|
||||||
|
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||||
|
expiredAt: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserCreateRequest(BaseModel):
|
||||||
|
phone: str = Field(min_length=11, max_length=20)
|
||||||
|
name: str = Field(min_length=1, max_length=50)
|
||||||
|
nickname: str | None = Field(default=None, max_length=50)
|
||||||
|
status: int = Field(default=1, ge=0, le=1)
|
||||||
|
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||||
|
expiredAt: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserImportItem(BaseModel):
|
||||||
|
phone: str = Field(default="", max_length=20)
|
||||||
|
name: str = Field(default="", max_length=50)
|
||||||
|
nickname: str | None = Field(default=None, max_length=50)
|
||||||
|
status: int = Field(default=1, ge=0, le=1)
|
||||||
|
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||||
|
expiredAt: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserImportRequest(BaseModel):
|
||||||
|
students: list[AdminUserImportItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeSaveRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
feishuSpaceId: str = Field(min_length=1, max_length=100)
|
||||||
|
feishuNodeId: str = Field(min_length=1, max_length=100)
|
||||||
|
status: int = Field(default=1, ge=0, le=1)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
knowledgeType: str = Field(default="general", pattern="^(course|qa|general)$")
|
||||||
|
|
||||||
|
|
||||||
|
class PromptSaveRequest(BaseModel):
|
||||||
|
promptContent: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentDebugRequest(BaseModel):
|
||||||
|
promptContent: str = Field(min_length=1)
|
||||||
|
modelId: int = Field(gt=0)
|
||||||
|
knowledgeIds: list[int] = Field(default_factory=list)
|
||||||
|
knowledgeVersions: dict[int, int] = Field(default_factory=dict)
|
||||||
|
question: str = Field(min_length=1, max_length=2000)
|
||||||
|
temperature: float | None = Field(default=None, ge=0, le=2)
|
||||||
|
topP: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
topK: int | None = Field(default=None, ge=1, le=1000)
|
||||||
|
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||||
|
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||||
|
maxToken: int | None = Field(default=8192, ge=1, le=100000)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRuntimeConfigSaveRequest(BaseModel):
|
||||||
|
temperature: float | None = Field(default=None, ge=0, le=2)
|
||||||
|
topP: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
topK: int | None = Field(default=None, ge=1, le=1000)
|
||||||
|
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||||
|
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||||
|
maxToken: int = Field(default=8192, ge=256, le=100000)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSaveRequest(BaseModel):
|
||||||
|
provider: str = Field(min_length=1, max_length=50)
|
||||||
|
displayName: str | None = Field(default=None, max_length=100)
|
||||||
|
apiType: str = Field(default="openai_compatible", max_length=50)
|
||||||
|
modelName: str = Field(min_length=1, max_length=100)
|
||||||
|
baseUrl: str | None = Field(default=None, max_length=255)
|
||||||
|
apiUrl: str = Field(default="", max_length=255)
|
||||||
|
apiKey: str = ""
|
||||||
|
authType: str = Field(default="bearer", max_length=30)
|
||||||
|
apiVersion: str | None = Field(default=None, max_length=50)
|
||||||
|
temperature: float | None = Field(default=0.2, ge=0, le=2)
|
||||||
|
topP: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
topK: int | None = Field(default=None, ge=1, le=1000)
|
||||||
|
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||||
|
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
|
||||||
|
maxToken: int | None = Field(default=1024, ge=1, le=100000)
|
||||||
|
contextWindow: int | None = Field(default=None, ge=1, le=2000000)
|
||||||
|
streamEnabled: int = Field(default=1, ge=0, le=1)
|
||||||
|
responseFormat: str | None = Field(default=None, max_length=50)
|
||||||
|
extraParams: str | None = None
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
timeoutSecond: int = Field(default=30, ge=1, le=300)
|
||||||
|
|
||||||
|
|
||||||
|
class EnableModelRequest(BaseModel):
|
||||||
|
modelId: int
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfigSaveRequest(BaseModel):
|
||||||
|
configKey: str = Field(min_length=1, max_length=100)
|
||||||
|
configValue: str
|
||||||
|
description: str | None = Field(default=None, max_length=255)
|
||||||
30
ai_knowledge_base_v2/apps/backend/app/schemas/auth.py
Normal file
30
ai_knowledge_base_v2/apps/backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.user import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class SendSmsRequest(BaseModel):
|
||||||
|
phone: str = Field(min_length=11, max_length=20)
|
||||||
|
captchaId: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
captchaCode: str | None = Field(default=None, min_length=4, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
phone: str = Field(min_length=11, max_length=20)
|
||||||
|
code: str = Field(min_length=4, max_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
expiredAt: datetime
|
||||||
|
user: UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
class CaptchaResponse(BaseModel):
|
||||||
|
captchaId: str
|
||||||
|
imageBase64: str
|
||||||
|
expiresInSeconds: int
|
||||||
42
ai_knowledge_base_v2/apps/backend/app/schemas/chat.py
Normal file
42
ai_knowledge_base_v2/apps/backend/app/schemas/chat.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.schemas.common import ORMModel
|
||||||
|
|
||||||
|
|
||||||
|
class CreateSessionResponse(BaseModel):
|
||||||
|
sessionId: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionRead(ORMModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
messageCount: int = Field(alias="message_count")
|
||||||
|
updatedAt: datetime = Field(alias="updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageRead(ORMModel):
|
||||||
|
id: int
|
||||||
|
role: str
|
||||||
|
content: str
|
||||||
|
message_status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSessionTitleRequest(BaseModel):
|
||||||
|
sessionId: int
|
||||||
|
title: str = Field(min_length=1, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionRequest(BaseModel):
|
||||||
|
sessionId: int
|
||||||
|
message: str = Field(min_length=1, max_length=4000)
|
||||||
|
|
||||||
|
|
||||||
|
class StopChatRequest(BaseModel):
|
||||||
|
sessionId: int
|
||||||
7
ai_knowledge_base_v2/apps/backend/app/schemas/common.py
Normal file
7
ai_knowledge_base_v2/apps/backend/app/schemas/common.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ORMModel(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
30
ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py
Normal file
30
ai_knowledge_base_v2/apps/backend/app/schemas/knowledge.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeRollbackRequest(BaseModel):
|
||||||
|
keepOpen: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeStatusRequest(BaseModel):
|
||||||
|
open: bool
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeLifecycleRequest(BaseModel):
|
||||||
|
status: str = Field(pattern="^(active|archived)$")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeMetadataUpdateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
knowledgeType: str = Field(pattern="^(course|qa|general)$")
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeBatchMetadataUpdateRequest(BaseModel):
|
||||||
|
knowledgeIds: list[int] = Field(min_length=1, max_length=500)
|
||||||
|
knowledgeType: str | None = Field(default=None, pattern="^(course|qa|general)$")
|
||||||
|
|
||||||
|
|
||||||
|
class AttentionUpdateRequest(BaseModel):
|
||||||
|
status: str = Field(pattern="^(pending|processing|resolved|ignored)$")
|
||||||
|
note: str | None = Field(default=None, max_length=2000)
|
||||||
20
ai_knowledge_base_v2/apps/backend/app/schemas/user.py
Normal file
20
ai_knowledge_base_v2/apps/backend/app/schemas/user.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from app.schemas.common import ORMModel
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(ORMModel):
|
||||||
|
id: int
|
||||||
|
phone: str
|
||||||
|
name: str
|
||||||
|
nickname: str | None = None
|
||||||
|
dailyLimit: int = Field(alias="daily_chat_limit")
|
||||||
|
todayUsed: int = Field(alias="daily_chat_used")
|
||||||
|
status: int
|
||||||
|
effective_at: datetime | None = None
|
||||||
|
expired_at: datetime | None = None
|
||||||
|
last_login_at: datetime | None = None
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
167
ai_knowledge_base_v2/apps/backend/app/services/admin_service.py
Normal file
167
ai_knowledge_base_v2/apps/backend/app/services/admin_service.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.security import create_access_token, hash_password, verify_password
|
||||||
|
from app.models.admin import Admin
|
||||||
|
from app.models.chat import ChatMessage, ChatSession
|
||||||
|
from app.models.knowledge import Knowledge
|
||||||
|
from app.models.logs import AiRequestLog, OperationLog
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.security_state_service import SecurityStateService
|
||||||
|
|
||||||
|
|
||||||
|
DEVELOPMENT_ENVS = {"local", "dev", "development", "docker", "test", "testing"}
|
||||||
|
UNSAFE_BOOTSTRAP_PASSWORDS = {
|
||||||
|
"123456",
|
||||||
|
"admin",
|
||||||
|
"admin123456",
|
||||||
|
"change-me-in-production",
|
||||||
|
"password",
|
||||||
|
"replace-with-strong-password",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_production_env(app_env: str) -> bool:
|
||||||
|
return app_env.strip().lower() not in DEVELOPMENT_ENVS
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bootstrap_admin_config() -> tuple[str, str, str]:
|
||||||
|
settings = get_settings()
|
||||||
|
username = settings.bootstrap_admin_username.strip()
|
||||||
|
password = settings.bootstrap_admin_password
|
||||||
|
name = settings.bootstrap_admin_name.strip() or username
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="未配置首个管理员初始化账号,请配置 BOOTSTRAP_ADMIN_USERNAME/BOOTSTRAP_ADMIN_PASSWORD",
|
||||||
|
)
|
||||||
|
|
||||||
|
if _is_production_env(settings.app_env):
|
||||||
|
normalized_password = password.strip().lower()
|
||||||
|
if normalized_password in UNSAFE_BOOTSTRAP_PASSWORDS or len(password) < 12:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="生产环境首个管理员初始化密码不安全,请重新配置 BOOTSTRAP_ADMIN_PASSWORD",
|
||||||
|
)
|
||||||
|
|
||||||
|
return username, password, name
|
||||||
|
|
||||||
|
|
||||||
|
class AdminAuthService:
|
||||||
|
@classmethod
|
||||||
|
def login(cls, db: Session, username: str, password: str, ip: str = "unknown") -> dict:
|
||||||
|
failure_key = f"auth:admin:fail:{username.lower()}:{ip}"
|
||||||
|
SecurityStateService.enforce_limit(
|
||||||
|
f"rate:admin_login:ip:{ip}", limit=30, window_seconds=600, message="登录请求过于频繁,请稍后再试"
|
||||||
|
)
|
||||||
|
SecurityStateService.ensure_not_locked(
|
||||||
|
failure_key, limit=5, message="账号或当前网络连续登录失败次数过多,请十五分钟后再试"
|
||||||
|
)
|
||||||
|
cls.ensure_bootstrap_admin(db)
|
||||||
|
admin = db.scalar(select(Admin).where(Admin.username == username))
|
||||||
|
if admin is None or not verify_password(password, admin.password):
|
||||||
|
SecurityStateService.record_failure(
|
||||||
|
failure_key,
|
||||||
|
limit=5,
|
||||||
|
lock_seconds=900,
|
||||||
|
message="账号或当前网络连续登录失败次数过多,请十五分钟后再试",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="管理员账号或密码错误")
|
||||||
|
if admin.status != 1:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="管理员已禁用")
|
||||||
|
SecurityStateService.clear(failure_key)
|
||||||
|
|
||||||
|
admin.last_login_at = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin)
|
||||||
|
|
||||||
|
token, expired_at = create_access_token(str(admin.id), "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:
|
||||||
|
has_admin = db.scalar(select(func.count(Admin.id))) or 0
|
||||||
|
if has_admin:
|
||||||
|
return
|
||||||
|
username, password, name = _get_bootstrap_admin_config()
|
||||||
|
admin = Admin(
|
||||||
|
username=username,
|
||||||
|
password=hash_password(password),
|
||||||
|
name=name,
|
||||||
|
status=1,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDashboardService:
|
||||||
|
@staticmethod
|
||||||
|
def stats(db: Session, start: datetime | None = None, end: datetime | None = None) -> dict:
|
||||||
|
from sqlalchemy import and_
|
||||||
|
|
||||||
|
# 基础表查询条件(时间范围)
|
||||||
|
user_filter = [User.is_deleted == 0]
|
||||||
|
session_filter = [ChatSession.is_deleted == 0]
|
||||||
|
msg_filter = []
|
||||||
|
ai_filter = []
|
||||||
|
|
||||||
|
if start:
|
||||||
|
user_filter.append(User.created_at >= start)
|
||||||
|
session_filter.append(ChatSession.created_at >= start)
|
||||||
|
msg_filter.append(ChatMessage.created_at >= start)
|
||||||
|
ai_filter.append(AiRequestLog.created_at >= start)
|
||||||
|
if end:
|
||||||
|
user_filter.append(User.created_at <= end)
|
||||||
|
session_filter.append(ChatSession.created_at <= end)
|
||||||
|
msg_filter.append(ChatMessage.created_at <= end)
|
||||||
|
ai_filter.append(AiRequestLog.created_at <= end)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"userCount": db.scalar(select(func.count(User.id)).where(and_(*user_filter))) or 0,
|
||||||
|
"sessionCount": db.scalar(select(func.count(ChatSession.id)).where(and_(*session_filter))) or 0,
|
||||||
|
"messageCount": db.scalar(select(func.count(ChatMessage.id)).where(and_(*msg_filter))) or 0,
|
||||||
|
"aiRequestCount": db.scalar(select(func.count(AiRequestLog.id)).where(and_(*ai_filter))) or 0,
|
||||||
|
"knowledgeCount": db.scalar(select(func.count(Knowledge.id))) or 0,
|
||||||
|
"inputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.input_token), 0)).where(and_(*ai_filter))) or 0,
|
||||||
|
"outputToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.output_token), 0)).where(and_(*ai_filter))) or 0,
|
||||||
|
"totalToken": db.scalar(select(func.coalesce(func.sum(AiRequestLog.total_token), 0)).where(and_(*ai_filter))) or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OperationLogService:
|
||||||
|
@staticmethod
|
||||||
|
def write(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
admin_id: int | None,
|
||||||
|
module: str,
|
||||||
|
action: str,
|
||||||
|
target_id: int | None = None,
|
||||||
|
result: str = "SUCCESS",
|
||||||
|
) -> None:
|
||||||
|
db.add(
|
||||||
|
OperationLog(
|
||||||
|
admin_id=admin_id,
|
||||||
|
module=module,
|
||||||
|
action=action,
|
||||||
|
target_id=target_id,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.logs import AiRequestLog
|
||||||
|
from app.services.rag_service import RetrievedChunk
|
||||||
|
|
||||||
|
|
||||||
|
class AiRequestLogService:
|
||||||
|
@staticmethod
|
||||||
|
def write_success(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
session_id: int,
|
||||||
|
message_id: int | None,
|
||||||
|
user_id: int,
|
||||||
|
model_name: str,
|
||||||
|
prompt: str,
|
||||||
|
knowledge_ids: str,
|
||||||
|
retrieve_count: int,
|
||||||
|
input_token: int,
|
||||||
|
output_token: int,
|
||||||
|
cost_ms: int,
|
||||||
|
retrieved_chunks: Sequence[RetrievedChunk] | None = None,
|
||||||
|
) -> None:
|
||||||
|
db.add(
|
||||||
|
AiRequestLog(
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=message_id,
|
||||||
|
user_id=user_id,
|
||||||
|
model_name=model_name,
|
||||||
|
prompt=prompt,
|
||||||
|
knowledge_ids=knowledge_ids,
|
||||||
|
retrieve_count=retrieve_count,
|
||||||
|
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
|
||||||
|
input_token=input_token,
|
||||||
|
output_token=output_token,
|
||||||
|
total_token=input_token + output_token,
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
status="SUCCESS",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_failed(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
session_id: int,
|
||||||
|
message_id: int | None,
|
||||||
|
user_id: int,
|
||||||
|
model_name: str | None,
|
||||||
|
prompt: str | None,
|
||||||
|
knowledge_ids: str | None,
|
||||||
|
retrieve_count: int,
|
||||||
|
cost_ms: int,
|
||||||
|
error_message: str,
|
||||||
|
retrieved_chunks: Sequence[RetrievedChunk] | None = None,
|
||||||
|
) -> None:
|
||||||
|
db.add(
|
||||||
|
AiRequestLog(
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=message_id,
|
||||||
|
user_id=user_id,
|
||||||
|
model_name=model_name,
|
||||||
|
prompt=prompt,
|
||||||
|
knowledge_ids=knowledge_ids,
|
||||||
|
retrieve_count=retrieve_count,
|
||||||
|
retrieved_chunks=_dump_retrieved_chunks(retrieved_chunks),
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
status="FAILED",
|
||||||
|
error_message=error_message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dump_retrieved_chunks(chunks: Sequence[RetrievedChunk] | None) -> str | None:
|
||||||
|
if not chunks:
|
||||||
|
return None
|
||||||
|
payload = [
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"knowledgeId": chunk.knowledge_id,
|
||||||
|
"knowledgeName": chunk.knowledge_name,
|
||||||
|
"title": chunk.title,
|
||||||
|
"content": chunk.content,
|
||||||
|
"sourceUrl": chunk.source_url,
|
||||||
|
}
|
||||||
|
for index, chunk in enumerate(chunks, start=1)
|
||||||
|
]
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
110
ai_knowledge_base_v2/apps/backend/app/services/auth_service.py
Normal file
110
ai_knowledge_base_v2/apps/backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.security import create_access_token
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.captcha_service import CaptchaService
|
||||||
|
from app.services.sms_code_service import SmsCodeService
|
||||||
|
from app.services.security_state_service import SecurityStateService
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
CAPTCHA_REQUIRED_SECONDS = 60 * 60
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def send_sms_code(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
phone: str,
|
||||||
|
captcha_id: str | None,
|
||||||
|
captcha_code: str | None,
|
||||||
|
ip: str,
|
||||||
|
) -> None:
|
||||||
|
user = cls._get_existing_user(db, phone)
|
||||||
|
cls._ensure_user_can_login(user)
|
||||||
|
captcha_key = cls._captcha_required_key(phone)
|
||||||
|
has_captcha = bool(captcha_id or captcha_code)
|
||||||
|
if has_captcha:
|
||||||
|
if not captcha_id or not captcha_code:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请完整填写图形验证码")
|
||||||
|
CaptchaService.verify(captcha_id, captcha_code)
|
||||||
|
SecurityStateService.clear(captcha_key)
|
||||||
|
elif SecurityStateService.has_flag(captcha_key):
|
||||||
|
raise HTTPException(status_code=428, detail="需要完成图形验证码后再发送短信")
|
||||||
|
|
||||||
|
SecurityStateService.enforce_limit(
|
||||||
|
f"rate:sms:ip:{ip}", limit=20, window_seconds=3600, message="当前网络发送验证码过于频繁,请稍后再试"
|
||||||
|
)
|
||||||
|
SecurityStateService.enforce_limit(
|
||||||
|
f"rate:sms:phone:{phone}", limit=1, window_seconds=60, message="验证码发送过于频繁,请一分钟后再试"
|
||||||
|
)
|
||||||
|
SmsCodeService.send_code(db, phone)
|
||||||
|
SecurityStateService.mark_flag(captcha_key, cls.CAPTCHA_REQUIRED_SECONDS)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def login_with_sms(cls, db: Session, phone: str, code: str) -> dict:
|
||||||
|
failure_key = f"auth:sms:fail:{phone}"
|
||||||
|
SecurityStateService.ensure_not_locked(
|
||||||
|
failure_key, limit=5, message="验证码连续错误次数过多,请十五分钟后重新获取"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
SmsCodeService.verify_code(db, phone, code)
|
||||||
|
except HTTPException:
|
||||||
|
SecurityStateService.mark_flag(cls._captcha_required_key(phone), cls.CAPTCHA_REQUIRED_SECONDS)
|
||||||
|
SecurityStateService.record_failure(
|
||||||
|
failure_key,
|
||||||
|
limit=5,
|
||||||
|
lock_seconds=900,
|
||||||
|
message="验证码连续错误次数过多,请十五分钟后重新获取",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
SecurityStateService.clear(failure_key)
|
||||||
|
user = cls._get_existing_user(db, phone)
|
||||||
|
cls._ensure_user_can_login(user)
|
||||||
|
|
||||||
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
user.last_login_at = now
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
token, expired_at = create_access_token(str(user.id), "user")
|
||||||
|
return {"token": token, "expiredAt": expired_at, "user": user}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def logout(cls, token_payload: dict) -> None:
|
||||||
|
jti = token_payload.get("jti")
|
||||||
|
if jti:
|
||||||
|
SecurityStateService.revoke_token(str(jti), token_payload.get("exp"))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_token_revoked(cls, token_payload: dict) -> bool:
|
||||||
|
jti = token_payload.get("jti")
|
||||||
|
return bool(jti and SecurityStateService.is_token_revoked(str(jti)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_existing_user(cls, db: Session, phone: str) -> User:
|
||||||
|
user = db.scalar(select(User).where(User.phone == phone, User.is_deleted == 0))
|
||||||
|
if user is not None:
|
||||||
|
return user
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在学员名单中,请联系管理员")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_user_can_login(user: User) -> None:
|
||||||
|
now = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
if user.status != 1:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||||
|
if user.effective_at and user.effective_at > now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号尚未生效")
|
||||||
|
if user.expired_at and user.expired_at < now:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已过期")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _captcha_required_key(phone: str) -> str:
|
||||||
|
return f"auth:sms:captcha_required:{phone}"
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from io import BytesIO
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||||
|
|
||||||
|
from app.services.redis_client import get_sync_redis_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CAPTCHA_EXPIRES_SECONDS = 300
|
||||||
|
CAPTCHA_WIDTH = 168
|
||||||
|
CAPTCHA_HEIGHT = 64
|
||||||
|
CAPTCHA_LENGTH = 4
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CaptchaRecord:
|
||||||
|
code: str
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CaptchaService:
|
||||||
|
_records: dict[str, CaptchaRecord] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls) -> dict:
|
||||||
|
cls._cleanup_expired()
|
||||||
|
captcha_id = str(uuid4())
|
||||||
|
code = _random_code()
|
||||||
|
if _set_redis_captcha(captcha_id, code):
|
||||||
|
cls._records.pop(captcha_id, None)
|
||||||
|
else:
|
||||||
|
cls._records[captcha_id] = CaptchaRecord(
|
||||||
|
code=code,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(seconds=CAPTCHA_EXPIRES_SECONDS),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"captchaId": captcha_id,
|
||||||
|
"imageBase64": _render_captcha_image(code),
|
||||||
|
"expiresInSeconds": CAPTCHA_EXPIRES_SECONDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def verify(cls, captcha_id: str, captcha_code: str) -> None:
|
||||||
|
redis_available, redis_code = _get_redis_captcha(captcha_id)
|
||||||
|
if redis_available and redis_code is not None:
|
||||||
|
if redis_code.lower() != captcha_code.strip().lower():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误")
|
||||||
|
_delete_redis_captcha(captcha_id)
|
||||||
|
cls._records.pop(captcha_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
record = cls._records.get(captcha_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码不存在或已过期")
|
||||||
|
if record.expires_at < datetime.now(UTC):
|
||||||
|
cls._records.pop(captcha_id, None)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码已过期")
|
||||||
|
if record.code.lower() != captcha_code.strip().lower():
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图形验证码错误")
|
||||||
|
cls._records.pop(captcha_id, None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cleanup_expired(cls) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
expired_ids = [captcha_id for captcha_id, record in cls._records.items() if record.expires_at < now]
|
||||||
|
for captcha_id in expired_ids:
|
||||||
|
cls._records.pop(captcha_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _random_code() -> str:
|
||||||
|
alphabet = "".join(ch for ch in string.ascii_uppercase + string.digits if ch not in "0O1IL")
|
||||||
|
return "".join(random.SystemRandom().choice(alphabet) for _ in range(CAPTCHA_LENGTH))
|
||||||
|
|
||||||
|
|
||||||
|
def _captcha_key(captcha_id: str) -> str:
|
||||||
|
return f"auth:captcha:{captcha_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_redis_captcha(captcha_id: str, code: str) -> bool:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(client.set(_captcha_key(captcha_id), code, ex=CAPTCHA_EXPIRES_SECONDS))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis captcha write failed for %s", captcha_id, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_redis_captcha(captcha_id: str) -> tuple[bool, str | None]:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return False, None
|
||||||
|
try:
|
||||||
|
value = client.get(_captcha_key(captcha_id))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis captcha read failed for %s", captcha_id, exc_info=True)
|
||||||
|
return False, None
|
||||||
|
return True, value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_redis_captcha(captcha_id: str) -> None:
|
||||||
|
client = get_sync_redis_client()
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.delete(_captcha_key(captcha_id))
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis captcha delete failed for %s", captcha_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_captcha_image(code: str) -> str:
|
||||||
|
image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f7fbfa")
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
rng = random.SystemRandom()
|
||||||
|
|
||||||
|
for _ in range(70):
|
||||||
|
x = rng.randint(0, CAPTCHA_WIDTH - 1)
|
||||||
|
y = rng.randint(0, CAPTCHA_HEIGHT - 1)
|
||||||
|
color = rng.choice(["#d7e5df", "#c7ddd5", "#e4ece9"])
|
||||||
|
draw.point((x, y), fill=color)
|
||||||
|
|
||||||
|
for _ in range(4):
|
||||||
|
start = (rng.randint(0, CAPTCHA_WIDTH // 2), rng.randint(0, CAPTCHA_HEIGHT))
|
||||||
|
end = (rng.randint(CAPTCHA_WIDTH // 2, CAPTCHA_WIDTH), rng.randint(0, CAPTCHA_HEIGHT))
|
||||||
|
draw.line([start, end], fill=rng.choice(["#b7cec5", "#cbdad5"]), width=1)
|
||||||
|
|
||||||
|
font = _load_font()
|
||||||
|
char_width = CAPTCHA_WIDTH // CAPTCHA_LENGTH
|
||||||
|
for index, char in enumerate(code):
|
||||||
|
layer = Image.new("RGBA", (char_width, CAPTCHA_HEIGHT), (255, 255, 255, 0))
|
||||||
|
layer_draw = ImageDraw.Draw(layer)
|
||||||
|
bbox = layer_draw.textbbox((0, 0), char, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
x = (char_width - text_width) // 2
|
||||||
|
y = (CAPTCHA_HEIGHT - text_height) // 2 - 3
|
||||||
|
layer_draw.text((x, y), char, font=font, fill=rng.choice(["#0f735d", "#173f35", "#31564d"]))
|
||||||
|
layer = layer.rotate(rng.randint(-14, 14), resample=Image.Resampling.BICUBIC, expand=False)
|
||||||
|
image.paste(layer, (index * char_width, 0), layer)
|
||||||
|
|
||||||
|
image = image.filter(ImageFilter.SMOOTH)
|
||||||
|
output = BytesIO()
|
||||||
|
image.save(output, format="PNG")
|
||||||
|
encoded = base64.b64encode(output.getvalue()).decode("ascii")
|
||||||
|
return f"data:image/png;base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_font():
|
||||||
|
for path in (
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/local/share/fonts/DejaVuSans-Bold.ttf",
|
||||||
|
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||||
|
"/Library/Fonts/Arial Bold.ttf",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(path, 38)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
logger.warning("Captcha font fallback to Pillow default font")
|
||||||
|
return ImageFont.load_default()
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.ai_config import SystemConfig
|
||||||
|
from app.models.chat import ChatMessage, ChatSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_CONTEXT_MESSAGE_COUNT = 10
|
||||||
|
MAX_CONTEXT_MESSAGE_COUNT = 100
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SummaryUpdateResult:
|
||||||
|
trace: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _SummaryWork:
|
||||||
|
messages: list[ChatMessage]
|
||||||
|
messages_text: str
|
||||||
|
request: dict
|
||||||
|
|
||||||
|
|
||||||
|
class ChatContextService:
|
||||||
|
"""Owns the runtime policy for a session's conversational memory."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def message_limit(db: Session) -> int:
|
||||||
|
config = db.scalar(
|
||||||
|
select(SystemConfig).where(SystemConfig.config_key == "chat_context_message_count")
|
||||||
|
)
|
||||||
|
if config is None or not config.config_value.strip():
|
||||||
|
return DEFAULT_CONTEXT_MESSAGE_COUNT
|
||||||
|
try:
|
||||||
|
value = int(float(config.config_value.strip()))
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Invalid chat_context_message_count=%r; falling back to %s",
|
||||||
|
config.config_value,
|
||||||
|
DEFAULT_CONTEXT_MESSAGE_COUNT,
|
||||||
|
)
|
||||||
|
return DEFAULT_CONTEXT_MESSAGE_COUNT
|
||||||
|
return max(0, min(MAX_CONTEXT_MESSAGE_COUNT, value))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def select_memory(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
history: list[ChatMessage] | None,
|
||||||
|
summary: str | None,
|
||||||
|
summary_up_to_message_id: int | None = None,
|
||||||
|
) -> tuple[list[ChatMessage], str | None]:
|
||||||
|
limit = cls.message_limit(db)
|
||||||
|
if limit == 0:
|
||||||
|
return [], None
|
||||||
|
recent = list((history or [])[-limit:])
|
||||||
|
if summary and summary_up_to_message_id is not None:
|
||||||
|
recent = [message for message in recent if message.id > summary_up_to_message_id]
|
||||||
|
return recent, summary
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_summary(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
session: ChatSession,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
) -> SummaryUpdateResult:
|
||||||
|
"""Roll older messages into one bounded summary as soon as they leave the raw window."""
|
||||||
|
work = cls._prepare_summary(db, session, history)
|
||||||
|
if work is None:
|
||||||
|
return SummaryUpdateResult()
|
||||||
|
started = perf_counter()
|
||||||
|
try:
|
||||||
|
# Local import avoids a rag_service -> context_service -> model_service cycle.
|
||||||
|
from app.services.model_service import ModelClientService
|
||||||
|
|
||||||
|
new_summary = ModelClientService.summarize_or_raise(
|
||||||
|
db,
|
||||||
|
work.messages_text,
|
||||||
|
previous_summary=session.summary,
|
||||||
|
)
|
||||||
|
return cls._apply_summary(session, work, new_summary, started)
|
||||||
|
except Exception as exc:
|
||||||
|
return cls._summary_failure(session, work, exc, started)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def update_summary_async(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
session: ChatSession,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
) -> SummaryUpdateResult:
|
||||||
|
work = cls._prepare_summary(db, session, history)
|
||||||
|
if work is None:
|
||||||
|
return SummaryUpdateResult()
|
||||||
|
started = perf_counter()
|
||||||
|
try:
|
||||||
|
from app.services.model_service import ModelClientService
|
||||||
|
|
||||||
|
new_summary = await ModelClientService.summarize_or_raise_async(
|
||||||
|
db,
|
||||||
|
work.messages_text,
|
||||||
|
previous_summary=session.summary,
|
||||||
|
)
|
||||||
|
return cls._apply_summary(session, work, new_summary, started)
|
||||||
|
except Exception as exc:
|
||||||
|
return cls._summary_failure(session, work, exc, started)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _prepare_summary(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
session: ChatSession,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
) -> _SummaryWork | None:
|
||||||
|
limit = cls.message_limit(db)
|
||||||
|
if limit == 0 or len(history) <= limit:
|
||||||
|
return None
|
||||||
|
cutoff = len(history) - limit
|
||||||
|
start_idx = cls._covered_history_index(session, history)
|
||||||
|
if cutoff <= start_idx:
|
||||||
|
return None
|
||||||
|
messages = history[start_idx:cutoff]
|
||||||
|
messages_text = cls.messages_text(messages)
|
||||||
|
if not messages_text:
|
||||||
|
return None
|
||||||
|
return _SummaryWork(
|
||||||
|
messages=messages,
|
||||||
|
messages_text=messages_text,
|
||||||
|
request={
|
||||||
|
"previousSummary": session.summary or "",
|
||||||
|
"messages": messages_text,
|
||||||
|
"messageCount": len(messages),
|
||||||
|
"contextMessageLimit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _apply_summary(
|
||||||
|
cls,
|
||||||
|
session: ChatSession,
|
||||||
|
work: _SummaryWork,
|
||||||
|
summary: str,
|
||||||
|
started: float,
|
||||||
|
) -> SummaryUpdateResult:
|
||||||
|
summary = summary.strip()
|
||||||
|
if not summary:
|
||||||
|
raise ValueError("模型返回了空的历史摘要")
|
||||||
|
session.summary = summary
|
||||||
|
session.summary_up_to_message_id = work.messages[-1].id
|
||||||
|
duration_ms = int((perf_counter() - started) * 1000)
|
||||||
|
return SummaryUpdateResult(
|
||||||
|
trace=cls._trace(
|
||||||
|
work.request,
|
||||||
|
{"summary": summary, "count": len(work.messages)},
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _summary_failure(
|
||||||
|
cls,
|
||||||
|
session: ChatSession,
|
||||||
|
work: _SummaryWork,
|
||||||
|
exc: Exception,
|
||||||
|
started: float,
|
||||||
|
) -> SummaryUpdateResult:
|
||||||
|
duration_ms = int((perf_counter() - started) * 1000)
|
||||||
|
logger.exception(
|
||||||
|
"Chat history summary failed for session_id=%s message_count=%s",
|
||||||
|
session.id,
|
||||||
|
len(work.messages),
|
||||||
|
exc_info=exc,
|
||||||
|
)
|
||||||
|
return SummaryUpdateResult(
|
||||||
|
trace=cls._trace(
|
||||||
|
work.request,
|
||||||
|
{"count": 0, "fallback": "recent_messages_only"},
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
status="fallback",
|
||||||
|
error=str(exc)[:500],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _covered_history_index(session: ChatSession, history: list[ChatMessage]) -> int:
|
||||||
|
if session.summary_up_to_message_id is None:
|
||||||
|
return 0
|
||||||
|
for index, message in enumerate(history):
|
||||||
|
if message.id > session.summary_up_to_message_id:
|
||||||
|
return index
|
||||||
|
return len(history)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def messages_text(messages: list[ChatMessage]) -> str:
|
||||||
|
role_labels = {"user": "用户", "assistant": "大本营答疑助手"}
|
||||||
|
parts: list[str] = []
|
||||||
|
for message in messages:
|
||||||
|
content = re.sub(
|
||||||
|
r"<think[\s\S]*?</think\s*>",
|
||||||
|
"",
|
||||||
|
message.content,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
).strip()
|
||||||
|
if content:
|
||||||
|
parts.append(f"{role_labels.get(message.role, message.role)}:{content}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _trace(
|
||||||
|
request: dict,
|
||||||
|
response: dict,
|
||||||
|
*,
|
||||||
|
duration_ms: int,
|
||||||
|
status: str = "success",
|
||||||
|
error: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"tool": "chat_memory_summary",
|
||||||
|
"order": 0,
|
||||||
|
"request": request,
|
||||||
|
"response": response,
|
||||||
|
**response,
|
||||||
|
"status": status,
|
||||||
|
"startedAtMs": 0,
|
||||||
|
"endedAtMs": duration_ms,
|
||||||
|
"durationMs": duration_ms,
|
||||||
|
"responseCount": response.get("count"),
|
||||||
|
"error": error,
|
||||||
|
"retries": 0,
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
from app.services.chat_queue_service import ChatQueueConfig, QueueRequest
|
||||||
|
from app.services.redis_client import get_redis_client
|
||||||
|
|
||||||
|
|
||||||
|
class RedisChatQueueManager:
|
||||||
|
_active_key = "ai_kb:chat_queue:active"
|
||||||
|
_waiting_key = "ai_kb:chat_queue:waiting"
|
||||||
|
_request_script = """
|
||||||
|
local active_key = KEYS[1]
|
||||||
|
local waiting_key = KEYS[2]
|
||||||
|
local now = tonumber(ARGV[1])
|
||||||
|
local token = ARGV[2]
|
||||||
|
local max_active = tonumber(ARGV[3])
|
||||||
|
local max_waiting = tonumber(ARGV[4])
|
||||||
|
local active_expire_at = tonumber(ARGV[5])
|
||||||
|
local key_ttl = tonumber(ARGV[6])
|
||||||
|
|
||||||
|
redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
|
||||||
|
local active_count = redis.call('ZCARD', active_key)
|
||||||
|
local waiting_count = redis.call('LLEN', waiting_key)
|
||||||
|
|
||||||
|
if active_count < max_active and waiting_count == 0 then
|
||||||
|
redis.call('ZADD', active_key, active_expire_at, token)
|
||||||
|
redis.call('EXPIRE', active_key, key_ttl)
|
||||||
|
return {'acquired', token, 0, active_count + 1, 0}
|
||||||
|
end
|
||||||
|
|
||||||
|
if waiting_count >= max_waiting then
|
||||||
|
redis.call('EXPIRE', active_key, key_ttl)
|
||||||
|
redis.call('EXPIRE', waiting_key, key_ttl)
|
||||||
|
return {'rejected', token, 0, active_count, waiting_count}
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call('RPUSH', waiting_key, token)
|
||||||
|
redis.call('EXPIRE', waiting_key, key_ttl)
|
||||||
|
redis.call('EXPIRE', active_key, key_ttl)
|
||||||
|
return {'queued', token, waiting_count + 1, active_count, waiting_count + 1}
|
||||||
|
"""
|
||||||
|
_promote_script = """
|
||||||
|
local active_key = KEYS[1]
|
||||||
|
local waiting_key = KEYS[2]
|
||||||
|
local now = tonumber(ARGV[1])
|
||||||
|
local token = ARGV[2]
|
||||||
|
local max_active = tonumber(ARGV[3])
|
||||||
|
local active_expire_at = tonumber(ARGV[4])
|
||||||
|
local key_ttl = tonumber(ARGV[5])
|
||||||
|
|
||||||
|
redis.call('ZREMRANGEBYSCORE', active_key, '-inf', now)
|
||||||
|
local active_count = redis.call('ZCARD', active_key)
|
||||||
|
local waiting_count = redis.call('LLEN', waiting_key)
|
||||||
|
local head = redis.call('LINDEX', waiting_key, 0)
|
||||||
|
|
||||||
|
if head == token and active_count < max_active then
|
||||||
|
redis.call('LPOP', waiting_key)
|
||||||
|
redis.call('ZADD', active_key, active_expire_at, token)
|
||||||
|
redis.call('EXPIRE', active_key, key_ttl)
|
||||||
|
redis.call('EXPIRE', waiting_key, key_ttl)
|
||||||
|
return {'acquired', token, 0, active_count + 1, waiting_count - 1}
|
||||||
|
end
|
||||||
|
|
||||||
|
local position = redis.call('LPOS', waiting_key, token)
|
||||||
|
if not position then
|
||||||
|
return {'canceled', token, 0, active_count, waiting_count}
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call('EXPIRE', active_key, key_ttl)
|
||||||
|
redis.call('EXPIRE', waiting_key, key_ttl)
|
||||||
|
return {'queued', token, position + 1, active_count, waiting_count}
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def request_slot(self, config: ChatQueueConfig) -> QueueRequest:
|
||||||
|
client = _require_client()
|
||||||
|
now = time.time()
|
||||||
|
token = uuid4().hex
|
||||||
|
result = await client.eval(
|
||||||
|
self._request_script,
|
||||||
|
2,
|
||||||
|
self._active_key,
|
||||||
|
self._waiting_key,
|
||||||
|
now,
|
||||||
|
token,
|
||||||
|
config.max_active_requests,
|
||||||
|
config.max_queue_size,
|
||||||
|
now + config.active_lease_seconds,
|
||||||
|
_key_ttl(config),
|
||||||
|
)
|
||||||
|
return _queue_request(result)
|
||||||
|
|
||||||
|
async def wait_for_slot(self, token: str, config: ChatQueueConfig) -> QueueRequest:
|
||||||
|
client = _require_client()
|
||||||
|
deadline = time.monotonic() + config.queue_timeout_seconds
|
||||||
|
while True:
|
||||||
|
now = time.time()
|
||||||
|
result = await client.eval(
|
||||||
|
self._promote_script,
|
||||||
|
2,
|
||||||
|
self._active_key,
|
||||||
|
self._waiting_key,
|
||||||
|
now,
|
||||||
|
token,
|
||||||
|
config.max_active_requests,
|
||||||
|
now + config.active_lease_seconds,
|
||||||
|
_key_ttl(config),
|
||||||
|
)
|
||||||
|
request = _queue_request(result)
|
||||||
|
if request.status in {"acquired", "canceled"}:
|
||||||
|
return request
|
||||||
|
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
await self.cancel_waiter(token)
|
||||||
|
return QueueRequest(status="timeout", token=token)
|
||||||
|
await asyncio.sleep(min(0.25, remaining))
|
||||||
|
|
||||||
|
async def cancel_waiter(self, token: str) -> None:
|
||||||
|
client = _require_client()
|
||||||
|
await client.lrem(self._waiting_key, 0, token)
|
||||||
|
|
||||||
|
async def release_slot(self, token: str) -> None:
|
||||||
|
client = _require_client()
|
||||||
|
await client.zrem(self._active_key, token)
|
||||||
|
|
||||||
|
|
||||||
|
redis_chat_queue_manager = RedisChatQueueManager()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_client() -> Redis:
|
||||||
|
client = get_redis_client()
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError("Redis is not configured")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_request(result: list) -> QueueRequest:
|
||||||
|
status = str(result[0])
|
||||||
|
token = str(result[1]) if len(result) > 1 and result[1] else None
|
||||||
|
return QueueRequest(
|
||||||
|
status=status,
|
||||||
|
token=token,
|
||||||
|
position=int(result[2]) if len(result) > 2 else 0,
|
||||||
|
active_count=int(result[3]) if len(result) > 3 else 0,
|
||||||
|
waiting_count=int(result[4]) if len(result) > 4 else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _key_ttl(config: ChatQueueConfig) -> int:
|
||||||
|
return max(config.active_lease_seconds, config.queue_timeout_seconds) + 300
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.services.chat_queue_redis_service import redis_chat_queue_manager
|
||||||
|
from app.services.chat_queue_service import ChatQueueConfig, QueueRequest, chat_queue_manager
|
||||||
|
from app.services.redis_client import ping_redis
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatQueueLease:
|
||||||
|
backend: str
|
||||||
|
request: QueueRequest
|
||||||
|
|
||||||
|
|
||||||
|
async def request_chat_slot(config: ChatQueueConfig) -> ChatQueueLease:
|
||||||
|
if await ping_redis():
|
||||||
|
try:
|
||||||
|
return ChatQueueLease("redis", await redis_chat_queue_manager.request_slot(config))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
request = await asyncio.to_thread(chat_queue_manager.request_slot, config)
|
||||||
|
return ChatQueueLease("memory", request)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_chat_slot(lease: ChatQueueLease, config: ChatQueueConfig) -> ChatQueueLease:
|
||||||
|
token = lease.request.token
|
||||||
|
if lease.backend == "redis" and isinstance(token, str):
|
||||||
|
try:
|
||||||
|
lease.request = await redis_chat_queue_manager.wait_for_slot(token, config)
|
||||||
|
return lease
|
||||||
|
except Exception:
|
||||||
|
await cancel_chat_waiter(lease)
|
||||||
|
lease.backend = "memory"
|
||||||
|
lease.request = await asyncio.to_thread(chat_queue_manager.request_slot, config)
|
||||||
|
if lease.request.status == "queued":
|
||||||
|
lease.request = await asyncio.to_thread(chat_queue_manager.wait_for_slot, lease.request.token, config)
|
||||||
|
return lease
|
||||||
|
|
||||||
|
lease.request = await asyncio.to_thread(chat_queue_manager.wait_for_slot, token, config)
|
||||||
|
return lease
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_chat_waiter(lease: ChatQueueLease) -> None:
|
||||||
|
token = lease.request.token
|
||||||
|
if lease.backend == "redis" and isinstance(token, str):
|
||||||
|
try:
|
||||||
|
await redis_chat_queue_manager.cancel_waiter(token)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if token is not None:
|
||||||
|
await asyncio.to_thread(chat_queue_manager.cancel_waiter, token)
|
||||||
|
|
||||||
|
|
||||||
|
async def release_chat_slot(lease: ChatQueueLease) -> None:
|
||||||
|
token = lease.request.token
|
||||||
|
if lease.backend == "redis" and isinstance(token, str):
|
||||||
|
try:
|
||||||
|
await redis_chat_queue_manager.release_slot(token)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
await asyncio.to_thread(chat_queue_manager.release_slot)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from threading import Condition
|
||||||
|
import time
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.ai_config import SystemConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChatQueueConfig:
|
||||||
|
max_active_requests: int
|
||||||
|
max_queue_size: int
|
||||||
|
queue_timeout_seconds: int
|
||||||
|
active_lease_seconds: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class QueueRequest:
|
||||||
|
status: str
|
||||||
|
token: object | None = None
|
||||||
|
position: int = 0
|
||||||
|
active_count: int = 0
|
||||||
|
waiting_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ChatQueueManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._condition = Condition()
|
||||||
|
self._active_count = 0
|
||||||
|
self._waiters: deque[object] = deque()
|
||||||
|
|
||||||
|
def request_slot(self, config: ChatQueueConfig) -> QueueRequest:
|
||||||
|
with self._condition:
|
||||||
|
if self._active_count < config.max_active_requests and not self._waiters:
|
||||||
|
self._active_count += 1
|
||||||
|
return QueueRequest(
|
||||||
|
status="acquired",
|
||||||
|
active_count=self._active_count,
|
||||||
|
waiting_count=len(self._waiters),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(self._waiters) >= config.max_queue_size:
|
||||||
|
return QueueRequest(
|
||||||
|
status="rejected",
|
||||||
|
active_count=self._active_count,
|
||||||
|
waiting_count=len(self._waiters),
|
||||||
|
)
|
||||||
|
|
||||||
|
token = object()
|
||||||
|
self._waiters.append(token)
|
||||||
|
return QueueRequest(
|
||||||
|
status="queued",
|
||||||
|
token=token,
|
||||||
|
position=len(self._waiters),
|
||||||
|
active_count=self._active_count,
|
||||||
|
waiting_count=len(self._waiters),
|
||||||
|
)
|
||||||
|
|
||||||
|
def wait_for_slot(self, token: object, config: ChatQueueConfig) -> QueueRequest:
|
||||||
|
deadline = time.monotonic() + config.queue_timeout_seconds
|
||||||
|
with self._condition:
|
||||||
|
while True:
|
||||||
|
if self._waiters and self._waiters[0] is token and self._active_count < config.max_active_requests:
|
||||||
|
self._waiters.popleft()
|
||||||
|
self._active_count += 1
|
||||||
|
self._condition.notify_all()
|
||||||
|
return QueueRequest(
|
||||||
|
status="acquired",
|
||||||
|
active_count=self._active_count,
|
||||||
|
waiting_count=len(self._waiters),
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
try:
|
||||||
|
self._waiters.remove(token)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
self._condition.notify_all()
|
||||||
|
return QueueRequest(
|
||||||
|
status="timeout",
|
||||||
|
active_count=self._active_count,
|
||||||
|
waiting_count=len(self._waiters),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._condition.wait(timeout=remaining)
|
||||||
|
|
||||||
|
def cancel_waiter(self, token: object) -> None:
|
||||||
|
with self._condition:
|
||||||
|
try:
|
||||||
|
self._waiters.remove(token)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
|
def release_slot(self) -> None:
|
||||||
|
with self._condition:
|
||||||
|
if self._active_count > 0:
|
||||||
|
self._active_count -= 1
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
chat_queue_manager = ChatQueueManager()
|
||||||
|
|
||||||
|
|
||||||
|
def load_chat_queue_config(db: Session) -> ChatQueueConfig:
|
||||||
|
settings = get_settings()
|
||||||
|
return ChatQueueConfig(
|
||||||
|
max_active_requests=_config_int(
|
||||||
|
db,
|
||||||
|
"chat_max_active_requests",
|
||||||
|
settings.chat_max_active_requests,
|
||||||
|
minimum=1,
|
||||||
|
maximum=1000,
|
||||||
|
),
|
||||||
|
max_queue_size=_config_int(
|
||||||
|
db,
|
||||||
|
"chat_max_queue_size",
|
||||||
|
settings.chat_max_queue_size,
|
||||||
|
minimum=0,
|
||||||
|
maximum=10000,
|
||||||
|
),
|
||||||
|
queue_timeout_seconds=_config_int(
|
||||||
|
db,
|
||||||
|
"chat_queue_timeout_seconds",
|
||||||
|
settings.chat_queue_timeout_seconds,
|
||||||
|
minimum=1,
|
||||||
|
maximum=3600,
|
||||||
|
),
|
||||||
|
active_lease_seconds=_config_int(
|
||||||
|
db,
|
||||||
|
"chat_active_lease_seconds",
|
||||||
|
settings.chat_active_lease_seconds,
|
||||||
|
minimum=60,
|
||||||
|
maximum=86400,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_int(db: Session, key: str, default: int, *, minimum: int, maximum: int) -> int:
|
||||||
|
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == key))
|
||||||
|
if config is None or not config.config_value.strip():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = int(float(config.config_value.strip()))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return max(minimum, min(maximum, value))
|
||||||
222
ai_knowledge_base_v2/apps/backend/app/services/chat_service.py
Normal file
222
ai_knowledge_base_v2/apps/backend/app/services/chat_service.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from time import perf_counter
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.chat import ChatMessage, ChatSession
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.ai_request_log_service import AiRequestLogService
|
||||||
|
from app.services.external_errors import ExternalServiceError
|
||||||
|
from app.services.chat_context_service import ChatContextService
|
||||||
|
from app.services.model_service import ModelClientService
|
||||||
|
from app.services.rag_service import RagService
|
||||||
|
|
||||||
|
|
||||||
|
class ChatService:
|
||||||
|
@staticmethod
|
||||||
|
def create_session(db: Session, user: User) -> ChatSession:
|
||||||
|
now = _now()
|
||||||
|
session = ChatSession(
|
||||||
|
user_id=user.id,
|
||||||
|
title="新聊天",
|
||||||
|
message_count=0,
|
||||||
|
last_message_at=now,
|
||||||
|
is_deleted=0,
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_sessions(db: Session, user: User) -> list[ChatSession]:
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(ChatSession)
|
||||||
|
.where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0)
|
||||||
|
.order_by(ChatSession.updated_at.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_history(db: Session, user: User, session_id: int) -> list[ChatMessage]:
|
||||||
|
session = ChatService._get_user_session(db, user, session_id)
|
||||||
|
return list(
|
||||||
|
db.scalars(
|
||||||
|
select(ChatMessage)
|
||||||
|
.where(ChatMessage.session_id == session.id, ChatMessage.user_id == user.id)
|
||||||
|
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_title(db: Session, user: User, session_id: int, title: str) -> ChatSession:
|
||||||
|
session = ChatService._get_user_session(db, user, session_id)
|
||||||
|
session.title = title.strip()
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_session(db: Session, user: User, session_id: int) -> None:
|
||||||
|
session = ChatService._get_user_session(db, user, session_id)
|
||||||
|
session.is_deleted = 1
|
||||||
|
db.add(session)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_answer(db: Session, user: User, session_id: int, question: str) -> str:
|
||||||
|
user = ChatService.prepare_daily_quota(db, user)
|
||||||
|
session = ChatService._get_user_session(db, user, session_id)
|
||||||
|
ChatService._ensure_quota(user)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
normalized_question = question.strip()
|
||||||
|
user_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="user",
|
||||||
|
content=normalized_question,
|
||||||
|
message_status="FINISHED",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(user_message)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
started_at = perf_counter()
|
||||||
|
rag_result = None
|
||||||
|
try:
|
||||||
|
# 获取历史消息(不含刚插入的 user_message,它还没 flush id)
|
||||||
|
history = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ChatMessage)
|
||||||
|
.where(
|
||||||
|
ChatMessage.session_id == session.id,
|
||||||
|
ChatMessage.user_id == user.id,
|
||||||
|
ChatMessage.id < user_message.id,
|
||||||
|
)
|
||||||
|
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_result = ChatContextService.update_summary(db, session, history)
|
||||||
|
context_trace = [summary_result.trace] if summary_result.trace else None
|
||||||
|
|
||||||
|
# 构建 prompt(传入历史 + 摘要)
|
||||||
|
rag_result = RagService.build_result(
|
||||||
|
db, user, normalized_question,
|
||||||
|
history=history,
|
||||||
|
session_summary=session.summary,
|
||||||
|
summary_up_to_message_id=session.summary_up_to_message_id,
|
||||||
|
context_trace=context_trace,
|
||||||
|
)
|
||||||
|
completion = ModelClientService.complete(db, rag_result)
|
||||||
|
except ExternalServiceError as exc:
|
||||||
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
AiRequestLogService.write_failed(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=user_message.id,
|
||||||
|
user_id=user.id,
|
||||||
|
model_name=None,
|
||||||
|
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||||
|
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||||
|
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
error_message=str(exc),
|
||||||
|
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
|
||||||
|
assistant_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="assistant",
|
||||||
|
content=completion.answer,
|
||||||
|
message_status="FINISHED",
|
||||||
|
token_input=completion.input_token,
|
||||||
|
token_output=completion.output_token,
|
||||||
|
response_time_ms=cost_ms,
|
||||||
|
model_id=completion.model_id,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(assistant_message)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
session.message_count += 2
|
||||||
|
session.last_message_at = now
|
||||||
|
if session.title == "新聊天":
|
||||||
|
session.title = _title_from_question(normalized_question)
|
||||||
|
|
||||||
|
user.daily_chat_used += 1
|
||||||
|
db.add_all([session, user])
|
||||||
|
AiRequestLogService.write_success(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=assistant_message.id,
|
||||||
|
user_id=user.id,
|
||||||
|
model_name=completion.model_name,
|
||||||
|
prompt=rag_result.prompt,
|
||||||
|
knowledge_ids=rag_result.knowledge_ids,
|
||||||
|
retrieve_count=len(rag_result.chunks),
|
||||||
|
input_token=completion.input_token,
|
||||||
|
output_token=completion.output_token,
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
retrieved_chunks=rag_result.chunks,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return completion.answer
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def stop_generation(db: Session, user: User, session_id: int) -> None:
|
||||||
|
ChatService._get_user_session(db, user, session_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_user_session(db: Session, user: User, session_id: int) -> ChatSession:
|
||||||
|
session = db.get(ChatSession, session_id)
|
||||||
|
if session is None or session.user_id != user.id or session.is_deleted:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
|
||||||
|
return session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _maybe_update_summary(db: Session, session: ChatSession, history: list[ChatMessage]) -> None:
|
||||||
|
ChatContextService.update_summary(db, session, history)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_quota(user: User) -> None:
|
||||||
|
if user.daily_chat_used >= user.daily_chat_limit:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="今日提问次数已用完")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def prepare_daily_quota(db: Session, user: User) -> User:
|
||||||
|
locked_user = db.scalar(select(User).where(User.id == user.id).with_for_update())
|
||||||
|
if locked_user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||||
|
|
||||||
|
today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||||
|
if locked_user.daily_chat_reset_date != today:
|
||||||
|
locked_user.daily_chat_used = 0
|
||||||
|
locked_user.daily_chat_reset_date = today
|
||||||
|
db.add(locked_user)
|
||||||
|
db.flush()
|
||||||
|
return locked_user
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _title_from_question(question: str) -> str:
|
||||||
|
title = question.strip().replace("\n", " ")
|
||||||
|
return title[:20] if title else "新聊天"
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import AsyncIterator, Iterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from inspect import signature
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.chat import ChatMessage
|
||||||
|
from app.models.knowledge import KnowledgeRetrievalLog
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.ai_request_log_service import AiRequestLogService
|
||||||
|
from app.services.chat_service import ChatService, _title_from_question
|
||||||
|
from app.services.chat_context_service import ChatContextService
|
||||||
|
from app.services.external_errors import ExternalServiceError
|
||||||
|
from app.services.human_attention_service import HumanAttentionService
|
||||||
|
from app.services.model_stream_service import ModelStreamService
|
||||||
|
from app.services.rag_async_service import AsyncRagService
|
||||||
|
from app.services.rag_service import RagService
|
||||||
|
|
||||||
|
|
||||||
|
class ChatStreamService:
|
||||||
|
@staticmethod
|
||||||
|
def stream_answer(db: Session, user: User, session_id: int, question: str) -> Iterator[str]:
|
||||||
|
user = ChatService.prepare_daily_quota(db, user)
|
||||||
|
session = ChatService._get_user_session(db, user, session_id)
|
||||||
|
ChatService._ensure_quota(user)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
normalized_question = question.strip()
|
||||||
|
user_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="user",
|
||||||
|
content=normalized_question,
|
||||||
|
message_status="FINISHED",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(user_message)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
history = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ChatMessage)
|
||||||
|
.where(
|
||||||
|
ChatMessage.session_id == session.id,
|
||||||
|
ChatMessage.user_id == user.id,
|
||||||
|
ChatMessage.id < user_message.id,
|
||||||
|
)
|
||||||
|
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
summary_result = ChatContextService.update_summary(db, session, history)
|
||||||
|
context_trace = [summary_result.trace] if summary_result.trace else None
|
||||||
|
|
||||||
|
started_at = perf_counter()
|
||||||
|
rag_result = None
|
||||||
|
model_response = None
|
||||||
|
answer_parts: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
rag_result = _build_rag_result(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
normalized_question,
|
||||||
|
history,
|
||||||
|
getattr(session, "summary", None),
|
||||||
|
getattr(session, "summary_up_to_message_id", None),
|
||||||
|
context_trace,
|
||||||
|
)
|
||||||
|
model_response = ModelStreamService.stream(db, rag_result)
|
||||||
|
for chunk in model_response.chunks:
|
||||||
|
if chunk:
|
||||||
|
answer_parts.append(chunk)
|
||||||
|
yield chunk
|
||||||
|
except GeneratorExit:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
except ExternalServiceError as exc:
|
||||||
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
AiRequestLogService.write_failed(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=user_message.id,
|
||||||
|
user_id=user.id,
|
||||||
|
model_name=model_response.model_name if model_response is not None else None,
|
||||||
|
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||||
|
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||||
|
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
error_message=str(exc),
|
||||||
|
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||||
|
)
|
||||||
|
_mark_retrieval_failed(db, rag_result, str(exc), cost_ms)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
answer = "".join(answer_parts)
|
||||||
|
if not answer.strip():
|
||||||
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
AiRequestLogService.write_failed(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=user_message.id,
|
||||||
|
user_id=user.id,
|
||||||
|
model_name=model_response.model_name if model_response is not None else None,
|
||||||
|
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||||
|
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||||
|
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
error_message="模型未返回有效内容",
|
||||||
|
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||||
|
)
|
||||||
|
_mark_retrieval_failed(db, rag_result, "模型未返回有效内容", cost_ms)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
|
||||||
|
|
||||||
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
assistant_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="assistant",
|
||||||
|
content=answer,
|
||||||
|
message_status="FINISHED",
|
||||||
|
token_input=model_response.input_token if model_response is not None else None,
|
||||||
|
token_output=_rough_token_count(answer),
|
||||||
|
response_time_ms=cost_ms,
|
||||||
|
model_id=model_response.model_id if model_response is not None else None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(assistant_message)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
session.message_count += 2
|
||||||
|
session.last_message_at = now
|
||||||
|
if session.title == "新聊天":
|
||||||
|
session.title = _title_from_question(normalized_question)
|
||||||
|
|
||||||
|
user.daily_chat_used += 1
|
||||||
|
db.add_all([session, user])
|
||||||
|
AiRequestLogService.write_success(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=assistant_message.id,
|
||||||
|
user_id=user.id,
|
||||||
|
model_name=model_response.model_name if model_response is not None else "unknown",
|
||||||
|
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||||
|
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else "",
|
||||||
|
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||||
|
input_token=model_response.input_token if model_response is not None else 0,
|
||||||
|
output_token=_rough_token_count(answer),
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def stream_answer_async(db: Session, user: User, session_id: int, question: str) -> AsyncIterator[str]:
|
||||||
|
user = ChatService.prepare_daily_quota(db, user)
|
||||||
|
session = ChatService._get_user_session(db, user, session_id)
|
||||||
|
ChatService._ensure_quota(user)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
normalized_question = question.strip()
|
||||||
|
user_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="user",
|
||||||
|
content=normalized_question,
|
||||||
|
message_status="FINISHED",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(user_message)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
history = list(
|
||||||
|
db.scalars(
|
||||||
|
select(ChatMessage)
|
||||||
|
.where(
|
||||||
|
ChatMessage.session_id == session.id,
|
||||||
|
ChatMessage.user_id == user.id,
|
||||||
|
ChatMessage.id < user_message.id,
|
||||||
|
)
|
||||||
|
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
summary_result = await ChatContextService.update_summary_async(db, session, history)
|
||||||
|
context_trace = [summary_result.trace] if summary_result.trace else None
|
||||||
|
|
||||||
|
started_at = perf_counter()
|
||||||
|
rag_result = None
|
||||||
|
model_response = None
|
||||||
|
answer_parts: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
rag_result = await AsyncRagService.build_result(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
normalized_question,
|
||||||
|
history=history,
|
||||||
|
session_summary=getattr(session, "summary", None),
|
||||||
|
summary_up_to_message_id=getattr(session, "summary_up_to_message_id", None),
|
||||||
|
session_id=session.id,
|
||||||
|
context_trace=context_trace,
|
||||||
|
)
|
||||||
|
model_response = ModelStreamService.stream_async(db, rag_result)
|
||||||
|
async for chunk in model_response.chunks:
|
||||||
|
if chunk:
|
||||||
|
answer_parts.append(chunk)
|
||||||
|
yield chunk
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
except ExternalServiceError as exc:
|
||||||
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
AiRequestLogService.write_failed(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=user_message.id,
|
||||||
|
user_id=user.id,
|
||||||
|
model_name=model_response.model_name if model_response is not None else None,
|
||||||
|
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||||
|
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||||
|
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
error_message=str(exc),
|
||||||
|
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
answer = "".join(answer_parts)
|
||||||
|
if not answer.strip():
|
||||||
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
AiRequestLogService.write_failed(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=user_message.id,
|
||||||
|
user_id=user.id,
|
||||||
|
model_name=model_response.model_name if model_response is not None else None,
|
||||||
|
prompt=rag_result.prompt if rag_result is not None else normalized_question,
|
||||||
|
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else None,
|
||||||
|
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
error_message="模型未返回有效内容",
|
||||||
|
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="模型未返回有效内容")
|
||||||
|
|
||||||
|
_write_success(
|
||||||
|
db,
|
||||||
|
user=user,
|
||||||
|
session=session,
|
||||||
|
question=normalized_question,
|
||||||
|
answer=answer,
|
||||||
|
rag_result=rag_result,
|
||||||
|
model_response=model_response,
|
||||||
|
started_at=started_at,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _rough_token_count(text: str) -> int:
|
||||||
|
return max(1, len(text.strip()) // 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_success(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
session,
|
||||||
|
question: str,
|
||||||
|
answer: str,
|
||||||
|
rag_result,
|
||||||
|
model_response,
|
||||||
|
started_at: float,
|
||||||
|
now: datetime,
|
||||||
|
) -> None:
|
||||||
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
assistant_message = ChatMessage(
|
||||||
|
session_id=session.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role="assistant",
|
||||||
|
content=answer,
|
||||||
|
message_status="FINISHED",
|
||||||
|
token_input=model_response.input_token if model_response is not None else None,
|
||||||
|
token_output=_rough_token_count(answer),
|
||||||
|
response_time_ms=cost_ms,
|
||||||
|
model_id=model_response.model_id if model_response is not None else None,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(assistant_message)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
session.message_count += 2
|
||||||
|
session.last_message_at = now
|
||||||
|
if session.title == "新聊天":
|
||||||
|
session.title = _title_from_question(question)
|
||||||
|
|
||||||
|
user.daily_chat_used += 1
|
||||||
|
db.add_all([session, user])
|
||||||
|
AiRequestLogService.write_success(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=assistant_message.id,
|
||||||
|
user_id=user.id,
|
||||||
|
model_name=model_response.model_name if model_response is not None else "unknown",
|
||||||
|
prompt=rag_result.prompt if rag_result is not None else question,
|
||||||
|
knowledge_ids=rag_result.knowledge_ids if rag_result is not None else "",
|
||||||
|
retrieve_count=len(rag_result.chunks) if rag_result is not None else 0,
|
||||||
|
input_token=model_response.input_token if model_response is not None else 0,
|
||||||
|
output_token=_rough_token_count(answer),
|
||||||
|
cost_ms=cost_ms,
|
||||||
|
retrieved_chunks=rag_result.chunks if rag_result is not None else None,
|
||||||
|
)
|
||||||
|
if rag_result is not None and rag_result.retrieval_log_id:
|
||||||
|
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
|
||||||
|
if retrieval_log:
|
||||||
|
attention = HumanAttentionService.create_if_needed(
|
||||||
|
db,
|
||||||
|
session_id=session.id,
|
||||||
|
message_id=user_message_id_for_attention(db, session.id, assistant_message.id),
|
||||||
|
user_id=user.id,
|
||||||
|
question=question,
|
||||||
|
answer=answer,
|
||||||
|
knowledge_missing=not rag_result.allow_general_knowledge and not rag_result.is_hit,
|
||||||
|
)
|
||||||
|
retrieval_log.message_id = assistant_message.id
|
||||||
|
retrieval_log.final_answer = answer
|
||||||
|
retrieval_log.status = "success"
|
||||||
|
retrieval_log.total_cost_ms = cost_ms
|
||||||
|
retrieval_log.attention_created = 1 if attention else 0
|
||||||
|
db.add(retrieval_log)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def user_message_id_for_attention(db: Session, session_id: int, assistant_message_id: int) -> int:
|
||||||
|
message_id = db.scalar(
|
||||||
|
select(ChatMessage.id)
|
||||||
|
.where(
|
||||||
|
ChatMessage.session_id == session_id,
|
||||||
|
ChatMessage.role == "user",
|
||||||
|
ChatMessage.id < assistant_message_id,
|
||||||
|
)
|
||||||
|
.order_by(ChatMessage.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return int(message_id or assistant_message_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_retrieval_failed(db: Session, rag_result, error_message: str, cost_ms: int) -> None:
|
||||||
|
if rag_result is None or not rag_result.retrieval_log_id:
|
||||||
|
return
|
||||||
|
retrieval_log = db.get(KnowledgeRetrievalLog, rag_result.retrieval_log_id)
|
||||||
|
if retrieval_log:
|
||||||
|
retrieval_log.status = "failed"
|
||||||
|
retrieval_log.error_message = error_message
|
||||||
|
retrieval_log.total_cost_ms = cost_ms
|
||||||
|
db.add(retrieval_log)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rag_result(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
question: str,
|
||||||
|
history: list[ChatMessage],
|
||||||
|
summary: str | None,
|
||||||
|
summary_up_to_message_id: int | None,
|
||||||
|
context_trace: list[dict] | None = None,
|
||||||
|
):
|
||||||
|
parameters = signature(RagService.build_result).parameters
|
||||||
|
if "history" in parameters:
|
||||||
|
return RagService.build_result(
|
||||||
|
db,
|
||||||
|
user,
|
||||||
|
question,
|
||||||
|
history=history,
|
||||||
|
session_summary=summary,
|
||||||
|
summary_up_to_message_id=summary_up_to_message_id,
|
||||||
|
context_trace=context_trace,
|
||||||
|
)
|
||||||
|
return RagService.build_result(db, user, question)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalServiceError(RuntimeError):
|
||||||
|
def __init__(self, message: str, *, provider: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.provider = provider
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user