Initial commit: iConsulting 香港移民咨询智能客服系统
项目架构: - Monorepo (pnpm + Turborepo) - 后端: NestJS 微服务 + Claude Agent SDK - 前端: React + Vite + Ant Design 包含服务: - conversation-service: 对话服务 (Claude AI) - user-service: 用户认证服务 - payment-service: 支付服务 (支付宝/微信/Stripe) - knowledge-service: 知识库服务 (RAG + Neo4j) - evolution-service: 自我进化服务 - web-client: 用户前端 - admin-client: 管理后台 基础设施: - PostgreSQL + Redis + Neo4j - Kong API Gateway - Nginx 反向代理 - Docker Compose 部署配置 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
a7add8ff90
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pnpm install:*)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(pnpm add:*)",
|
||||||
|
"Bash(git init:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(git add:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
# ===========================================
|
||||||
|
# Docker 忽略文件
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# 依赖
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
.next
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# 环境配置
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
test
|
||||||
|
tests
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.spec.ts
|
||||||
|
|
||||||
|
# 文档
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
tmp
|
||||||
|
temp
|
||||||
|
.tmp
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
backups
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# 证书 (敏感)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
nginx/ssl/*
|
||||||
|
|
||||||
|
# 其他
|
||||||
|
.turbo
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
# ===========================================
|
||||||
|
# iConsulting - Environment Configuration
|
||||||
|
# ===========================================
|
||||||
|
# 使用方法: 复制此文件为 .env 并填入实际值
|
||||||
|
# cp .env.example .env
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Application
|
||||||
|
NODE_ENV=production
|
||||||
|
APP_NAME=iConsulting
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 服务器网络配置
|
||||||
|
# ===========================================
|
||||||
|
# 对外服务 IP (用户访问)
|
||||||
|
SERVER_PUBLIC_IP=14.215.128.96
|
||||||
|
# Claude API 出口 IP
|
||||||
|
CLAUDE_API_OUTBOUND_IP=154.84.135.121
|
||||||
|
# Claude API 服务器
|
||||||
|
CLAUDE_API_SERVER=67.223.119.33
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Anthropic Claude API
|
||||||
|
# ===========================================
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-api03-xxx
|
||||||
|
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# OpenAI API (用于 Embedding)
|
||||||
|
# ===========================================
|
||||||
|
OPENAI_API_KEY=sk-xxx
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# PostgreSQL Database
|
||||||
|
# ===========================================
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=your_secure_password
|
||||||
|
POSTGRES_DB=iconsulting
|
||||||
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Neo4j Graph Database
|
||||||
|
# ===========================================
|
||||||
|
NEO4J_URI=bolt://localhost:7687
|
||||||
|
NEO4J_USER=neo4j
|
||||||
|
NEO4J_PASSWORD=your_secure_password
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Redis Cache
|
||||||
|
# ===========================================
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=your_redis_password
|
||||||
|
REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Payment - Alipay
|
||||||
|
# ===========================================
|
||||||
|
ALIPAY_APP_ID=your_app_id
|
||||||
|
ALIPAY_PRIVATE_KEY=your_private_key
|
||||||
|
ALIPAY_PUBLIC_KEY=alipay_public_key
|
||||||
|
ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do
|
||||||
|
ALIPAY_NOTIFY_URL=https://your-domain.com/api/v1/payments/alipay/notify
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Payment - WeChat Pay
|
||||||
|
# ===========================================
|
||||||
|
WECHAT_APP_ID=your_app_id
|
||||||
|
WECHAT_MCH_ID=your_merchant_id
|
||||||
|
WECHAT_API_KEY=your_api_key
|
||||||
|
WECHAT_CERT_PATH=/path/to/wechat/cert
|
||||||
|
WECHAT_NOTIFY_URL=https://your-domain.com/api/v1/payments/wechat/notify
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Payment - Stripe (Credit Card)
|
||||||
|
# ===========================================
|
||||||
|
STRIPE_SECRET_KEY=sk_test_xxx
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Payment Callback URLs
|
||||||
|
# ===========================================
|
||||||
|
PAYMENT_CALLBACK_BASE_URL=https://your-domain.com
|
||||||
|
PAYMENT_SUCCESS_REDIRECT_URL=https://your-domain.com/payment/success
|
||||||
|
PAYMENT_CANCEL_REDIRECT_URL=https://your-domain.com/payment/cancel
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# JWT Authentication
|
||||||
|
# ===========================================
|
||||||
|
JWT_SECRET=your_super_secret_jwt_key_change_in_production
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SMS Service (for phone verification)
|
||||||
|
# ===========================================
|
||||||
|
SMS_PROVIDER=aliyun
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_ID=your_access_key_id
|
||||||
|
ALIYUN_SMS_ACCESS_KEY_SECRET=your_access_key_secret
|
||||||
|
ALIYUN_SMS_SIGN_NAME=iConsulting
|
||||||
|
ALIYUN_SMS_TEMPLATE_CODE=SMS_xxx
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Service Ports
|
||||||
|
# ===========================================
|
||||||
|
USER_SERVICE_PORT=3001
|
||||||
|
PAYMENT_SERVICE_PORT=3002
|
||||||
|
KNOWLEDGE_SERVICE_PORT=3003
|
||||||
|
CONVERSATION_SERVICE_PORT=3004
|
||||||
|
EVOLUTION_SERVICE_PORT=3005
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 服务间通信 URL
|
||||||
|
# ===========================================
|
||||||
|
USER_SERVICE_URL=http://localhost:3001
|
||||||
|
PAYMENT_SERVICE_URL=http://localhost:3002
|
||||||
|
KNOWLEDGE_SERVICE_URL=http://localhost:3003
|
||||||
|
CONVERSATION_SERVICE_URL=http://localhost:3004
|
||||||
|
EVOLUTION_SERVICE_URL=http://localhost:3005
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Kong API Gateway
|
||||||
|
# ===========================================
|
||||||
|
KONG_PROXY_PORT=8000
|
||||||
|
KONG_ADMIN_PORT=8001
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Frontend URLs
|
||||||
|
# ===========================================
|
||||||
|
WEB_CLIENT_URL=http://localhost
|
||||||
|
ADMIN_CLIENT_URL=http://localhost/admin
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# CORS
|
||||||
|
# ===========================================
|
||||||
|
CORS_ORIGINS=http://localhost,http://14.215.128.96
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Rate Limiting
|
||||||
|
# ===========================================
|
||||||
|
RATE_LIMIT_TTL=60
|
||||||
|
RATE_LIMIT_MAX=100
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Logging
|
||||||
|
# ===========================================
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_FORMAT=json
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
# ===========================================
|
||||||
|
# iConsulting Git 忽略配置
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# 依赖目录
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
dist/
|
||||||
|
**/dist/
|
||||||
|
build/
|
||||||
|
.next/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# 环境变量 (包含敏感信息)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 日志文件
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# 运行时数据
|
||||||
|
pids/
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# IDE 和编辑器
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# 测试覆盖率
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# 缓存
|
||||||
|
.cache/
|
||||||
|
.parcel-cache/
|
||||||
|
.turbo/
|
||||||
|
.eslintcache
|
||||||
|
.stylelintcache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
.tmp/
|
||||||
|
.temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# 备份文件
|
||||||
|
backups/
|
||||||
|
*.bak
|
||||||
|
*~
|
||||||
|
|
||||||
|
# SSL 证书 (敏感)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.p12
|
||||||
|
nginx/ssl/*
|
||||||
|
!nginx/ssl/.gitkeep
|
||||||
|
|
||||||
|
# 数据库文件
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# PM2
|
||||||
|
.pm2/
|
||||||
|
|
||||||
|
# 打包文件
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,176 @@
|
||||||
|
# iConsulting - 香港移民在线咨询系统
|
||||||
|
|
||||||
|
基于 Claude Agent SDK 的智能在线客服系统,专注于提供香港移民咨询服务。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **智能咨询**: 基于 Claude Agent SDK 的自然语言对话
|
||||||
|
- **付费评估**: 移民资格评估服务,支持支付宝/微信/信用卡
|
||||||
|
- **知识增强**: RAG + Neo4j 知识图谱
|
||||||
|
- **自我进化**: 从对话中学习,根据管理员指令调整
|
||||||
|
- **长期记忆**: 基于时间线的知识图谱记录
|
||||||
|
- **多端支持**: PC Web / H5 响应式设计
|
||||||
|
|
||||||
|
## 支持的移民类别
|
||||||
|
|
||||||
|
1. **优才计划 (QMAS)** - 行业翘楚、精英人士
|
||||||
|
2. **专才计划 (GEP)** - 专业人才
|
||||||
|
3. **留学IANG** - 非本地毕业生
|
||||||
|
4. **高才通 (TTPS)** - 高端人才
|
||||||
|
5. **投资移民 (CIES)** - 投资者
|
||||||
|
6. **科技人才 (TechTAS)** - 科技领域人才
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── 前端 (Clean Architecture)
|
||||||
|
│ ├── React 18 + TypeScript
|
||||||
|
│ ├── TailwindCSS + Radix UI
|
||||||
|
│ └── Zustand + React Query
|
||||||
|
│
|
||||||
|
├── 后端 (DDD + Hexagonal + 微服务)
|
||||||
|
│ ├── NestJS
|
||||||
|
│ ├── Claude Agent SDK
|
||||||
|
│ └── TypeORM
|
||||||
|
│
|
||||||
|
└── 基础设施
|
||||||
|
├── PostgreSQL + pgvector (RAG)
|
||||||
|
├── Neo4j (知识图谱)
|
||||||
|
├── Redis (缓存)
|
||||||
|
└── Kafka (消息队列)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
iconsulting/
|
||||||
|
├── packages/
|
||||||
|
│ ├── shared/ # 共享类型、常量、工具
|
||||||
|
│ ├── web-client/ # 用户端 Web 应用
|
||||||
|
│ ├── admin-client/ # 管理端 Web 应用
|
||||||
|
│ └── services/ # 后端微服务
|
||||||
|
│ ├── api-gateway/ # API 网关
|
||||||
|
│ ├── user-service/ # 用户服务
|
||||||
|
│ ├── conversation-service/ # 对话服务 (核心)
|
||||||
|
│ ├── knowledge-service/ # 知识服务
|
||||||
|
│ ├── payment-service/ # 支付服务
|
||||||
|
│ ├── admin-service/ # 管理服务
|
||||||
|
│ └── evolution-service/ # 进化服务
|
||||||
|
│
|
||||||
|
├── infrastructure/
|
||||||
|
│ └── docker/ # Docker 配置
|
||||||
|
│
|
||||||
|
├── DEVELOPMENT_GUIDE.md # 详细开发指导
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 pnpm (如果没有)
|
||||||
|
npm install -g pnpm
|
||||||
|
|
||||||
|
# 安装项目依赖
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动基础设施
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动 Docker 容器 (PostgreSQL, Neo4j, Redis, Kafka)
|
||||||
|
pnpm docker:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制环境变量示例文件
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 编辑 .env 文件,填入必要的配置
|
||||||
|
# 特别是 ANTHROPIC_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 运行数据库迁移
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动所有服务
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:
|
||||||
|
- 用户端: http://localhost:5173
|
||||||
|
- 管理端: http://localhost:5174
|
||||||
|
- API: http://localhost:3000
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
关键配置项:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Claude API
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-xxx
|
||||||
|
|
||||||
|
# 数据库
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=iconsulting
|
||||||
|
POSTGRES_PASSWORD=your_password
|
||||||
|
POSTGRES_DB=iconsulting
|
||||||
|
|
||||||
|
# Neo4j
|
||||||
|
NEO4J_URI=bolt://localhost:7687
|
||||||
|
NEO4J_USER=neo4j
|
||||||
|
NEO4J_PASSWORD=your_password
|
||||||
|
|
||||||
|
# 支付 (支付宝/微信)
|
||||||
|
ALIPAY_APP_ID=xxx
|
||||||
|
WECHAT_APP_ID=xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
完整配置请参考 `.env.example`
|
||||||
|
|
||||||
|
## 开发指南
|
||||||
|
|
||||||
|
详细的开发指导请参考 [DEVELOPMENT_GUIDE.md](./DEVELOPMENT_GUIDE.md)
|
||||||
|
|
||||||
|
## 开发进度
|
||||||
|
|
||||||
|
### 已完成
|
||||||
|
|
||||||
|
- [x] 项目架构设计
|
||||||
|
- [x] 开发指导文档
|
||||||
|
- [x] Monorepo 配置
|
||||||
|
- [x] 共享类型定义
|
||||||
|
- [x] Docker 基础设施配置
|
||||||
|
- [x] 数据库 Schema
|
||||||
|
- [x] 对话服务 (Claude Agent SDK 集成)
|
||||||
|
- [x] 用户端前端基础框架
|
||||||
|
|
||||||
|
### 进行中
|
||||||
|
|
||||||
|
- [ ] 用户服务
|
||||||
|
- [ ] 知识服务 (RAG + Neo4j)
|
||||||
|
- [ ] 支付服务
|
||||||
|
- [ ] 管理服务 (自我进化)
|
||||||
|
- [ ] 管理后台前端
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
1. Fork 本仓库
|
||||||
|
2. 创建特性分支 (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. 提交更改 (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||||
|
5. 创建 Pull Request
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
私有项目,保留所有权利。
|
||||||
|
|
@ -0,0 +1,903 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# iConsulting 部署管理脚本
|
||||||
|
#
|
||||||
|
# 用法: ./deploy.sh <command> [service] [options]
|
||||||
|
#
|
||||||
|
# 命令:
|
||||||
|
# build - 编译构建
|
||||||
|
# start - 启动服务
|
||||||
|
# stop - 停止服务
|
||||||
|
# restart - 重启服务
|
||||||
|
# status - 查看状态
|
||||||
|
# logs - 查看日志
|
||||||
|
# clean - 清理构建产物
|
||||||
|
# deploy - 完整部署(构建+启动)
|
||||||
|
# db - 数据库操作
|
||||||
|
# help - 显示帮助
|
||||||
|
#
|
||||||
|
# 服务:
|
||||||
|
# all - 所有服务
|
||||||
|
# web-client - 用户前端
|
||||||
|
# admin-client - 管理后台前端
|
||||||
|
# conversation - 对话服务
|
||||||
|
# user - 用户服务
|
||||||
|
# payment - 支付服务
|
||||||
|
# knowledge - 知识库服务
|
||||||
|
# evolution - 进化服务
|
||||||
|
# kong - API网关
|
||||||
|
# postgres - PostgreSQL数据库
|
||||||
|
# redis - Redis缓存
|
||||||
|
# neo4j - Neo4j图数据库
|
||||||
|
# nginx - Nginx静态服务
|
||||||
|
#
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
PURPLE='\033[0;35m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 项目根目录
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
ENV_FILE=".env"
|
||||||
|
|
||||||
|
# 服务端口配置
|
||||||
|
declare -A SERVICE_PORTS=(
|
||||||
|
["conversation"]=3004
|
||||||
|
["user"]=3001
|
||||||
|
["payment"]=3002
|
||||||
|
["knowledge"]=3003
|
||||||
|
["evolution"]=3005
|
||||||
|
["kong"]=8000
|
||||||
|
["postgres"]=5432
|
||||||
|
["redis"]=6379
|
||||||
|
["neo4j"]=7474
|
||||||
|
["nginx"]=80
|
||||||
|
)
|
||||||
|
|
||||||
|
# 服务目录映射
|
||||||
|
declare -A SERVICE_DIRS=(
|
||||||
|
["conversation"]="packages/services/conversation-service"
|
||||||
|
["user"]="packages/services/user-service"
|
||||||
|
["payment"]="packages/services/payment-service"
|
||||||
|
["knowledge"]="packages/services/knowledge-service"
|
||||||
|
["evolution"]="packages/services/evolution-service"
|
||||||
|
["web-client"]="packages/web-client"
|
||||||
|
["admin-client"]="packages/admin-client"
|
||||||
|
["shared"]="packages/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Docker服务名映射
|
||||||
|
declare -A DOCKER_SERVICES=(
|
||||||
|
["conversation"]="conversation-service"
|
||||||
|
["user"]="user-service"
|
||||||
|
["payment"]="payment-service"
|
||||||
|
["knowledge"]="knowledge-service"
|
||||||
|
["evolution"]="evolution-service"
|
||||||
|
["web-client"]="web-client"
|
||||||
|
["admin-client"]="admin-client"
|
||||||
|
["kong"]="kong"
|
||||||
|
["postgres"]="postgres"
|
||||||
|
["redis"]="redis"
|
||||||
|
["neo4j"]="neo4j"
|
||||||
|
["nginx"]="nginx"
|
||||||
|
)
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 工具函数
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_step() {
|
||||||
|
echo -e "${PURPLE}[STEP]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查命令是否存在
|
||||||
|
check_command() {
|
||||||
|
if ! command -v "$1" &> /dev/null; then
|
||||||
|
log_error "$1 未安装,请先安装"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查环境
|
||||||
|
check_environment() {
|
||||||
|
log_step "检查运行环境..."
|
||||||
|
|
||||||
|
check_command "node"
|
||||||
|
check_command "pnpm"
|
||||||
|
check_command "docker"
|
||||||
|
check_command "docker-compose"
|
||||||
|
|
||||||
|
# 检查 Node 版本
|
||||||
|
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
|
||||||
|
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||||
|
log_error "Node.js 版本需要 >= 18,当前版本: $(node -v)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "环境检查通过"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 加载环境变量
|
||||||
|
load_env() {
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
export $(grep -v '^#' "$ENV_FILE" | xargs)
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 等待服务就绪
|
||||||
|
wait_for_service() {
|
||||||
|
local host=$1
|
||||||
|
local port=$2
|
||||||
|
local service=$3
|
||||||
|
local max_attempts=${4:-30}
|
||||||
|
local attempt=1
|
||||||
|
|
||||||
|
log_info "等待 $service ($host:$port) 就绪..."
|
||||||
|
|
||||||
|
while [ $attempt -le $max_attempts ]; do
|
||||||
|
if nc -z "$host" "$port" 2>/dev/null; then
|
||||||
|
log_success "$service 已就绪"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 2
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_error "$service 启动超时"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 构建函数
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
install_deps() {
|
||||||
|
log_step "安装项目依赖..."
|
||||||
|
pnpm install
|
||||||
|
log_success "依赖安装完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建共享包
|
||||||
|
build_shared() {
|
||||||
|
log_step "构建 shared 包..."
|
||||||
|
cd "$PROJECT_ROOT/${SERVICE_DIRS[shared]}"
|
||||||
|
pnpm run build
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
log_success "shared 构建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建单个后端服务
|
||||||
|
build_backend_service() {
|
||||||
|
local service=$1
|
||||||
|
local dir="${SERVICE_DIRS[$service]}"
|
||||||
|
|
||||||
|
if [ -z "$dir" ]; then
|
||||||
|
log_error "未知服务: $service"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "构建 $service..."
|
||||||
|
cd "$PROJECT_ROOT/$dir"
|
||||||
|
|
||||||
|
# 清理旧构建
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
|
# TypeScript 编译
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
log_success "$service 构建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建单个前端
|
||||||
|
build_frontend() {
|
||||||
|
local service=$1
|
||||||
|
local dir="${SERVICE_DIRS[$service]}"
|
||||||
|
|
||||||
|
if [ -z "$dir" ]; then
|
||||||
|
log_error "未知服务: $service"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "构建 $service..."
|
||||||
|
cd "$PROJECT_ROOT/$dir"
|
||||||
|
|
||||||
|
# 清理旧构建
|
||||||
|
rm -rf dist
|
||||||
|
|
||||||
|
# Vite 构建
|
||||||
|
pnpm run build
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
log_success "$service 构建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建所有后端服务
|
||||||
|
build_all_backend() {
|
||||||
|
build_shared
|
||||||
|
|
||||||
|
for service in conversation user payment knowledge evolution; do
|
||||||
|
build_backend_service "$service"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建所有前端
|
||||||
|
build_all_frontend() {
|
||||||
|
for service in web-client admin-client; do
|
||||||
|
build_frontend "$service"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建所有
|
||||||
|
build_all() {
|
||||||
|
log_info "开始构建所有服务..."
|
||||||
|
install_deps
|
||||||
|
build_all_backend
|
||||||
|
build_all_frontend
|
||||||
|
log_success "所有服务构建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建入口
|
||||||
|
do_build() {
|
||||||
|
local target=${1:-all}
|
||||||
|
|
||||||
|
case $target in
|
||||||
|
all)
|
||||||
|
build_all
|
||||||
|
;;
|
||||||
|
shared)
|
||||||
|
build_shared
|
||||||
|
;;
|
||||||
|
backend)
|
||||||
|
build_all_backend
|
||||||
|
;;
|
||||||
|
frontend)
|
||||||
|
build_all_frontend
|
||||||
|
;;
|
||||||
|
web-client|admin-client)
|
||||||
|
build_frontend "$target"
|
||||||
|
;;
|
||||||
|
conversation|user|payment|knowledge|evolution)
|
||||||
|
build_backend_service "$target"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "未知构建目标: $target"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# Docker 操作函数
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
# 构建 Docker 镜像
|
||||||
|
build_docker_images() {
|
||||||
|
local service=${1:-}
|
||||||
|
|
||||||
|
log_step "构建 Docker 镜像..."
|
||||||
|
|
||||||
|
if [ -n "$service" ] && [ "$service" != "all" ]; then
|
||||||
|
local docker_service="${DOCKER_SERVICES[$service]}"
|
||||||
|
if [ -n "$docker_service" ]; then
|
||||||
|
docker-compose build "$docker_service"
|
||||||
|
else
|
||||||
|
log_error "未知服务: $service"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
docker-compose build
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Docker 镜像构建完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动基础设施
|
||||||
|
start_infrastructure() {
|
||||||
|
log_step "启动基础设施服务..."
|
||||||
|
|
||||||
|
docker-compose up -d postgres redis neo4j
|
||||||
|
|
||||||
|
# 等待数据库就绪
|
||||||
|
wait_for_service localhost 5432 "PostgreSQL"
|
||||||
|
wait_for_service localhost 6379 "Redis"
|
||||||
|
wait_for_service localhost 7474 "Neo4j"
|
||||||
|
|
||||||
|
log_success "基础设施启动完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动 Kong 网关
|
||||||
|
start_kong() {
|
||||||
|
log_step "启动 Kong API 网关..."
|
||||||
|
|
||||||
|
docker-compose up -d kong-database
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Kong 数据库迁移
|
||||||
|
docker-compose run --rm kong kong migrations bootstrap || true
|
||||||
|
|
||||||
|
docker-compose up -d kong
|
||||||
|
wait_for_service localhost 8000 "Kong"
|
||||||
|
|
||||||
|
log_success "Kong 启动完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动后端服务 (非 Docker 模式)
|
||||||
|
start_backend_service_local() {
|
||||||
|
local service=$1
|
||||||
|
local dir="${SERVICE_DIRS[$service]}"
|
||||||
|
local port="${SERVICE_PORTS[$service]}"
|
||||||
|
|
||||||
|
if [ -z "$dir" ]; then
|
||||||
|
log_error "未知服务: $service"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "启动 $service (端口: $port)..."
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT/$dir"
|
||||||
|
|
||||||
|
# 检查是否已构建
|
||||||
|
if [ ! -d "dist" ]; then
|
||||||
|
log_warning "$service 未构建,先进行构建..."
|
||||||
|
pnpm run build
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 使用 PM2 或直接启动
|
||||||
|
if command -v pm2 &> /dev/null; then
|
||||||
|
pm2 start dist/main.js --name "iconsulting-$service" --cwd "$PROJECT_ROOT/$dir"
|
||||||
|
else
|
||||||
|
# 后台启动
|
||||||
|
nohup node dist/main.js > "$PROJECT_ROOT/logs/$service.log" 2>&1 &
|
||||||
|
echo $! > "$PROJECT_ROOT/pids/$service.pid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
wait_for_service localhost "$port" "$service" 15
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动后端服务 (Docker 模式)
|
||||||
|
start_backend_service_docker() {
|
||||||
|
local service=$1
|
||||||
|
local docker_service="${DOCKER_SERVICES[$service]}"
|
||||||
|
|
||||||
|
log_step "启动 $service (Docker)..."
|
||||||
|
docker-compose up -d "$docker_service"
|
||||||
|
|
||||||
|
local port="${SERVICE_PORTS[$service]}"
|
||||||
|
wait_for_service localhost "$port" "$service"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动所有后端服务
|
||||||
|
start_all_backend() {
|
||||||
|
local mode=${1:-docker}
|
||||||
|
|
||||||
|
for service in user payment knowledge conversation evolution; do
|
||||||
|
if [ "$mode" = "docker" ]; then
|
||||||
|
start_backend_service_docker "$service"
|
||||||
|
else
|
||||||
|
start_backend_service_local "$service"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动 Nginx (静态文件服务)
|
||||||
|
start_nginx() {
|
||||||
|
log_step "启动 Nginx..."
|
||||||
|
docker-compose up -d nginx
|
||||||
|
wait_for_service localhost 80 "Nginx"
|
||||||
|
log_success "Nginx 启动完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动所有服务
|
||||||
|
start_all() {
|
||||||
|
local mode=${1:-docker}
|
||||||
|
|
||||||
|
log_info "开始启动所有服务 (模式: $mode)..."
|
||||||
|
|
||||||
|
# 创建必要目录
|
||||||
|
mkdir -p "$PROJECT_ROOT/logs"
|
||||||
|
mkdir -p "$PROJECT_ROOT/pids"
|
||||||
|
|
||||||
|
start_infrastructure
|
||||||
|
start_kong
|
||||||
|
start_all_backend "$mode"
|
||||||
|
start_nginx
|
||||||
|
|
||||||
|
log_success "所有服务启动完成"
|
||||||
|
do_status
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动入口
|
||||||
|
do_start() {
|
||||||
|
local target=${1:-all}
|
||||||
|
local mode=${2:-docker}
|
||||||
|
|
||||||
|
load_env
|
||||||
|
|
||||||
|
case $target in
|
||||||
|
all)
|
||||||
|
start_all "$mode"
|
||||||
|
;;
|
||||||
|
infra|infrastructure)
|
||||||
|
start_infrastructure
|
||||||
|
;;
|
||||||
|
kong)
|
||||||
|
start_kong
|
||||||
|
;;
|
||||||
|
nginx)
|
||||||
|
start_nginx
|
||||||
|
;;
|
||||||
|
postgres|redis|neo4j)
|
||||||
|
docker-compose up -d "$target"
|
||||||
|
;;
|
||||||
|
conversation|user|payment|knowledge|evolution)
|
||||||
|
if [ "$mode" = "docker" ]; then
|
||||||
|
start_backend_service_docker "$target"
|
||||||
|
else
|
||||||
|
start_backend_service_local "$target"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
backend)
|
||||||
|
start_all_backend "$mode"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "未知启动目标: $target"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 停止函数
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
# 停止单个服务 (本地模式)
|
||||||
|
stop_service_local() {
|
||||||
|
local service=$1
|
||||||
|
|
||||||
|
log_step "停止 $service..."
|
||||||
|
|
||||||
|
if command -v pm2 &> /dev/null; then
|
||||||
|
pm2 stop "iconsulting-$service" 2>/dev/null || true
|
||||||
|
pm2 delete "iconsulting-$service" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
local pid_file="$PROJECT_ROOT/pids/$service.pid"
|
||||||
|
if [ -f "$pid_file" ]; then
|
||||||
|
kill $(cat "$pid_file") 2>/dev/null || true
|
||||||
|
rm -f "$pid_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "$service 已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 停止单个服务 (Docker 模式)
|
||||||
|
stop_service_docker() {
|
||||||
|
local service=$1
|
||||||
|
local docker_service="${DOCKER_SERVICES[$service]}"
|
||||||
|
|
||||||
|
if [ -n "$docker_service" ]; then
|
||||||
|
log_step "停止 $service..."
|
||||||
|
docker-compose stop "$docker_service"
|
||||||
|
log_success "$service 已停止"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 停止所有服务
|
||||||
|
stop_all() {
|
||||||
|
local mode=${1:-docker}
|
||||||
|
|
||||||
|
log_info "停止所有服务..."
|
||||||
|
|
||||||
|
if [ "$mode" = "docker" ]; then
|
||||||
|
docker-compose down
|
||||||
|
else
|
||||||
|
for service in conversation user payment knowledge evolution; do
|
||||||
|
stop_service_local "$service"
|
||||||
|
done
|
||||||
|
docker-compose down
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "所有服务已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 停止入口
|
||||||
|
do_stop() {
|
||||||
|
local target=${1:-all}
|
||||||
|
local mode=${2:-docker}
|
||||||
|
|
||||||
|
case $target in
|
||||||
|
all)
|
||||||
|
stop_all "$mode"
|
||||||
|
;;
|
||||||
|
infra|infrastructure)
|
||||||
|
docker-compose stop postgres redis neo4j
|
||||||
|
;;
|
||||||
|
conversation|user|payment|knowledge|evolution)
|
||||||
|
if [ "$mode" = "docker" ]; then
|
||||||
|
stop_service_docker "$target"
|
||||||
|
else
|
||||||
|
stop_service_local "$target"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
kong|postgres|redis|neo4j|nginx)
|
||||||
|
docker-compose stop "$target"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "未知停止目标: $target"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 重启函数
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
do_restart() {
|
||||||
|
local target=${1:-all}
|
||||||
|
local mode=${2:-docker}
|
||||||
|
|
||||||
|
log_info "重启 $target..."
|
||||||
|
do_stop "$target" "$mode"
|
||||||
|
sleep 2
|
||||||
|
do_start "$target" "$mode"
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 状态查看
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
do_status() {
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${CYAN} iConsulting 服务状态 ${NC}"
|
||||||
|
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Docker 服务状态
|
||||||
|
echo -e "${PURPLE}Docker 容器状态:${NC}"
|
||||||
|
docker-compose ps
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 端口检查
|
||||||
|
echo -e "${PURPLE}服务端口检查:${NC}"
|
||||||
|
printf "%-20s %-10s %-10s\n" "服务" "端口" "状态"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
|
||||||
|
for service in "${!SERVICE_PORTS[@]}"; do
|
||||||
|
local port="${SERVICE_PORTS[$service]}"
|
||||||
|
if nc -z localhost "$port" 2>/dev/null; then
|
||||||
|
printf "%-20s %-10s ${GREEN}%-10s${NC}\n" "$service" "$port" "运行中"
|
||||||
|
else
|
||||||
|
printf "%-20s %-10s ${RED}%-10s${NC}\n" "$service" "$port" "未运行"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# PM2 状态 (如果使用)
|
||||||
|
if command -v pm2 &> /dev/null; then
|
||||||
|
echo -e "${PURPLE}PM2 进程状态:${NC}"
|
||||||
|
pm2 list 2>/dev/null | grep iconsulting || echo "无 PM2 管理的服务"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 日志查看
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
do_logs() {
|
||||||
|
local service=${1:-all}
|
||||||
|
local lines=${2:-100}
|
||||||
|
|
||||||
|
if [ "$service" = "all" ]; then
|
||||||
|
docker-compose logs -f --tail="$lines"
|
||||||
|
else
|
||||||
|
local docker_service="${DOCKER_SERVICES[$service]}"
|
||||||
|
if [ -n "$docker_service" ]; then
|
||||||
|
docker-compose logs -f --tail="$lines" "$docker_service"
|
||||||
|
else
|
||||||
|
# 本地日志
|
||||||
|
local log_file="$PROJECT_ROOT/logs/$service.log"
|
||||||
|
if [ -f "$log_file" ]; then
|
||||||
|
tail -f -n "$lines" "$log_file"
|
||||||
|
else
|
||||||
|
log_error "日志文件不存在: $log_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 清理函数
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
do_clean() {
|
||||||
|
local target=${1:-build}
|
||||||
|
|
||||||
|
case $target in
|
||||||
|
build)
|
||||||
|
log_step "清理构建产物..."
|
||||||
|
for dir in "${SERVICE_DIRS[@]}"; do
|
||||||
|
rm -rf "$PROJECT_ROOT/$dir/dist"
|
||||||
|
done
|
||||||
|
log_success "构建产物已清理"
|
||||||
|
;;
|
||||||
|
deps)
|
||||||
|
log_step "清理依赖..."
|
||||||
|
rm -rf node_modules
|
||||||
|
for dir in "${SERVICE_DIRS[@]}"; do
|
||||||
|
rm -rf "$PROJECT_ROOT/$dir/node_modules"
|
||||||
|
done
|
||||||
|
log_success "依赖已清理"
|
||||||
|
;;
|
||||||
|
docker)
|
||||||
|
log_step "清理 Docker 资源..."
|
||||||
|
docker-compose down -v --rmi local
|
||||||
|
docker system prune -f
|
||||||
|
log_success "Docker 资源已清理"
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
log_step "清理日志..."
|
||||||
|
rm -rf "$PROJECT_ROOT/logs/*"
|
||||||
|
log_success "日志已清理"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
do_clean build
|
||||||
|
do_clean deps
|
||||||
|
do_clean docker
|
||||||
|
do_clean logs
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "未知清理目标: $target (可选: build, deps, docker, logs, all)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 完整部署
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
do_deploy() {
|
||||||
|
local mode=${1:-docker}
|
||||||
|
|
||||||
|
log_info "开始完整部署 (模式: $mode)..."
|
||||||
|
|
||||||
|
check_environment
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
do_build all
|
||||||
|
|
||||||
|
# 如果是 Docker 模式,构建镜像
|
||||||
|
if [ "$mode" = "docker" ]; then
|
||||||
|
build_docker_images
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 启动
|
||||||
|
do_start all "$mode"
|
||||||
|
|
||||||
|
log_success "部署完成!"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}访问地址:${NC}"
|
||||||
|
echo " 用户前端: http://localhost"
|
||||||
|
echo " 管理后台: http://localhost/admin"
|
||||||
|
echo " API 网关: http://localhost:8000"
|
||||||
|
echo " Kong 管理: http://localhost:8001"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 数据库操作
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
do_db() {
|
||||||
|
local action=${1:-status}
|
||||||
|
|
||||||
|
case $action in
|
||||||
|
migrate)
|
||||||
|
log_step "执行数据库迁移..."
|
||||||
|
# 可以添加 TypeORM 迁移命令
|
||||||
|
for service in user payment knowledge conversation evolution; do
|
||||||
|
local dir="${SERVICE_DIRS[$service]}"
|
||||||
|
cd "$PROJECT_ROOT/$dir"
|
||||||
|
pnpm run migration:run 2>/dev/null || log_warning "$service 无迁移或迁移失败"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
done
|
||||||
|
log_success "数据库迁移完成"
|
||||||
|
;;
|
||||||
|
seed)
|
||||||
|
log_step "初始化种子数据..."
|
||||||
|
# 添加种子数据脚本
|
||||||
|
log_success "种子数据初始化完成"
|
||||||
|
;;
|
||||||
|
backup)
|
||||||
|
local backup_dir="$PROJECT_ROOT/backups/$(date +%Y%m%d_%H%M%S)"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
|
||||||
|
log_step "备份数据库..."
|
||||||
|
docker-compose exec -T postgres pg_dump -U postgres iconsulting > "$backup_dir/postgres.sql"
|
||||||
|
log_success "数据库备份到: $backup_dir"
|
||||||
|
;;
|
||||||
|
restore)
|
||||||
|
local backup_file=$2
|
||||||
|
if [ -z "$backup_file" ]; then
|
||||||
|
log_error "请指定备份文件: ./deploy.sh db restore <backup_file>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "恢复数据库..."
|
||||||
|
docker-compose exec -T postgres psql -U postgres iconsulting < "$backup_file"
|
||||||
|
log_success "数据库恢复完成"
|
||||||
|
;;
|
||||||
|
reset)
|
||||||
|
log_warning "这将删除所有数据!"
|
||||||
|
read -p "确认继续? (y/N) " confirm
|
||||||
|
if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose up -d postgres redis neo4j
|
||||||
|
wait_for_service localhost 5432 "PostgreSQL"
|
||||||
|
do_db migrate
|
||||||
|
log_success "数据库已重置"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
echo -e "${PURPLE}数据库状态:${NC}"
|
||||||
|
docker-compose exec postgres psql -U postgres -c "SELECT version();" 2>/dev/null || echo "PostgreSQL 未运行"
|
||||||
|
docker-compose exec redis redis-cli ping 2>/dev/null || echo "Redis 未运行"
|
||||||
|
curl -s http://localhost:7474 > /dev/null && echo "Neo4j 运行中" || echo "Neo4j 未运行"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "未知数据库操作: $action (可选: migrate, seed, backup, restore, reset, status)"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 帮助信息
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
cat << 'EOF'
|
||||||
|
|
||||||
|
╔═══════════════════════════════════════════════════════════════════════════════╗
|
||||||
|
║ iConsulting 部署管理脚本 ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
用法: ./deploy.sh <command> [target] [options]
|
||||||
|
|
||||||
|
命令:
|
||||||
|
build [target] 编译构建
|
||||||
|
target: all, shared, backend, frontend,
|
||||||
|
conversation, user, payment, knowledge, evolution,
|
||||||
|
web-client, admin-client
|
||||||
|
|
||||||
|
start [target] [mode] 启动服务
|
||||||
|
target: all, infra, kong, nginx, backend,
|
||||||
|
conversation, user, payment, knowledge, evolution,
|
||||||
|
postgres, redis, neo4j
|
||||||
|
mode: docker (默认), local
|
||||||
|
|
||||||
|
stop [target] [mode] 停止服务
|
||||||
|
(target 同上)
|
||||||
|
|
||||||
|
restart [target] [mode] 重启服务
|
||||||
|
(target 同上)
|
||||||
|
|
||||||
|
status 查看所有服务状态
|
||||||
|
|
||||||
|
logs [service] [lines] 查看日志
|
||||||
|
service: 服务名或 all
|
||||||
|
lines: 显示行数 (默认 100)
|
||||||
|
|
||||||
|
clean [target] 清理
|
||||||
|
target: build, deps, docker, logs, all
|
||||||
|
|
||||||
|
deploy [mode] 完整部署 (构建 + 启动)
|
||||||
|
mode: docker (默认), local
|
||||||
|
|
||||||
|
db <action> 数据库操作
|
||||||
|
action: migrate, seed, backup, restore, reset, status
|
||||||
|
|
||||||
|
help 显示此帮助信息
|
||||||
|
|
||||||
|
示例:
|
||||||
|
./deploy.sh deploy # 完整部署
|
||||||
|
./deploy.sh build conversation # 只构建对话服务
|
||||||
|
./deploy.sh start backend local # 本地模式启动所有后端
|
||||||
|
./deploy.sh restart user docker # 重启用户服务 (Docker)
|
||||||
|
./deploy.sh logs conversation 200 # 查看对话服务最近200行日志
|
||||||
|
./deploy.sh clean all # 清理所有构建产物和依赖
|
||||||
|
./deploy.sh db backup # 备份数据库
|
||||||
|
./deploy.sh db migrate # 执行数据库迁移
|
||||||
|
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 主入口
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local command=${1:-help}
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case $command in
|
||||||
|
build)
|
||||||
|
do_build "$@"
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
do_start "$@"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
do_stop "$@"
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
do_restart "$@"
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
do_status
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
do_logs "$@"
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
do_clean "$@"
|
||||||
|
;;
|
||||||
|
deploy)
|
||||||
|
do_deploy "$@"
|
||||||
|
;;
|
||||||
|
db)
|
||||||
|
do_db "$@"
|
||||||
|
;;
|
||||||
|
help|--help|-h)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "未知命令: $command"
|
||||||
|
show_help
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# 执行主函数
|
||||||
|
main "$@"
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
#===============================================================================
|
||||||
|
# iConsulting Docker Compose 配置
|
||||||
|
#
|
||||||
|
# 服务架构:
|
||||||
|
# - 基础设施: PostgreSQL, Redis, Neo4j
|
||||||
|
# - API网关: Kong
|
||||||
|
# - 后端服务: conversation, user, payment, knowledge, evolution
|
||||||
|
# - 前端服务: nginx (托管 web-client 和 admin-client)
|
||||||
|
#
|
||||||
|
# 网络配置:
|
||||||
|
# - 对外网卡: 14.215.128.96 (用户访问)
|
||||||
|
# - 出口网卡: 154.84.135.121 (Claude API 调用)
|
||||||
|
#
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
#=============================================================================
|
||||||
|
# 基础设施服务
|
||||||
|
#=============================================================================
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: iconsulting-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-iconsulting}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: iconsulting-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redis123}
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5-community
|
||||||
|
container_name: iconsulting-neo4j
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-neo4j123}
|
||||||
|
NEO4J_PLUGINS: '["apoc"]'
|
||||||
|
NEO4J_dbms_memory_heap_max__size: 1G
|
||||||
|
ports:
|
||||||
|
- "7474:7474" # HTTP
|
||||||
|
- "7687:7687" # Bolt
|
||||||
|
volumes:
|
||||||
|
- neo4j_data:/data
|
||||||
|
- neo4j_logs:/logs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
#=============================================================================
|
||||||
|
# Kong API 网关
|
||||||
|
#=============================================================================
|
||||||
|
|
||||||
|
kong-database:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: iconsulting-kong-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: kong
|
||||||
|
POSTGRES_PASSWORD: kong
|
||||||
|
POSTGRES_DB: kong
|
||||||
|
volumes:
|
||||||
|
- kong_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U kong"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
kong:
|
||||||
|
image: kong:3.4-alpine
|
||||||
|
container_name: iconsulting-kong
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
kong-database:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
KONG_DATABASE: postgres
|
||||||
|
KONG_PG_HOST: kong-database
|
||||||
|
KONG_PG_USER: kong
|
||||||
|
KONG_PG_PASSWORD: kong
|
||||||
|
KONG_PG_DATABASE: kong
|
||||||
|
KONG_PROXY_ACCESS_LOG: /dev/stdout
|
||||||
|
KONG_ADMIN_ACCESS_LOG: /dev/stdout
|
||||||
|
KONG_PROXY_ERROR_LOG: /dev/stderr
|
||||||
|
KONG_ADMIN_ERROR_LOG: /dev/stderr
|
||||||
|
KONG_ADMIN_LISTEN: 0.0.0.0:8001
|
||||||
|
KONG_PROXY_LISTEN: 0.0.0.0:8000, 0.0.0.0:8443 ssl
|
||||||
|
ports:
|
||||||
|
- "8000:8000" # Proxy
|
||||||
|
- "8443:8443" # Proxy SSL
|
||||||
|
- "8001:8001" # Admin API
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "kong", "health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
#=============================================================================
|
||||||
|
# 后端微服务
|
||||||
|
#=============================================================================
|
||||||
|
|
||||||
|
user-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/services/user-service/Dockerfile
|
||||||
|
container_name: iconsulting-user
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3001
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key}
|
||||||
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
payment-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/services/payment-service/Dockerfile
|
||||||
|
container_name: iconsulting-payment
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3002
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||||
|
ALIPAY_APP_ID: ${ALIPAY_APP_ID}
|
||||||
|
ALIPAY_PRIVATE_KEY: ${ALIPAY_PRIVATE_KEY}
|
||||||
|
WECHAT_APP_ID: ${WECHAT_APP_ID}
|
||||||
|
WECHAT_MCH_ID: ${WECHAT_MCH_ID}
|
||||||
|
WECHAT_API_KEY: ${WECHAT_API_KEY}
|
||||||
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
|
ports:
|
||||||
|
- "3002:3002"
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
knowledge-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/services/knowledge-service/Dockerfile
|
||||||
|
container_name: iconsulting-knowledge
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
neo4j:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3003
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||||
|
NEO4J_URI: bolt://neo4j:7687
|
||||||
|
NEO4J_USER: ${NEO4J_USER:-neo4j}
|
||||||
|
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-neo4j123}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "3003:3003"
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
conversation-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/services/conversation-service/Dockerfile
|
||||||
|
container_name: iconsulting-conversation
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
knowledge-service:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3004
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||||
|
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com}
|
||||||
|
KNOWLEDGE_SERVICE_URL: http://knowledge-service:3003
|
||||||
|
# Claude API 出口配置 (如需指定出口IP,在宿主机配置路由)
|
||||||
|
ports:
|
||||||
|
- "3004:3004"
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
evolution-service:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: packages/services/evolution-service/Dockerfile
|
||||||
|
container_name: iconsulting-evolution
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3005
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting}
|
||||||
|
REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
|
||||||
|
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com}
|
||||||
|
ports:
|
||||||
|
- "3005:3005"
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
#=============================================================================
|
||||||
|
# 前端 Nginx
|
||||||
|
#=============================================================================
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: iconsulting-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- kong
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./packages/web-client/dist:/usr/share/nginx/html/web:ro
|
||||||
|
- ./packages/admin-client/dist:/usr/share/nginx/html/admin:ro
|
||||||
|
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
|
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 网络配置
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
networks:
|
||||||
|
iconsulting-network:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
|
|
||||||
|
#===============================================================================
|
||||||
|
# 数据卷
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
redis_data:
|
||||||
|
driver: local
|
||||||
|
neo4j_data:
|
||||||
|
driver: local
|
||||||
|
neo4j_logs:
|
||||||
|
driver: local
|
||||||
|
kong_data:
|
||||||
|
driver: local
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
|
|
@ -0,0 +1,134 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL with pgvector extension
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg15
|
||||||
|
container_name: iconsulting-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: iconsulting
|
||||||
|
POSTGRES_PASSWORD: dev_password_123
|
||||||
|
POSTGRES_DB: iconsulting
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./services/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U iconsulting"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
# Neo4j Graph Database
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5
|
||||||
|
container_name: iconsulting-neo4j
|
||||||
|
environment:
|
||||||
|
NEO4J_AUTH: neo4j/dev_password_123
|
||||||
|
NEO4J_PLUGINS: '["apoc"]'
|
||||||
|
NEO4J_dbms_security_procedures_unrestricted: apoc.*
|
||||||
|
ports:
|
||||||
|
- "7474:7474" # HTTP
|
||||||
|
- "7687:7687" # Bolt
|
||||||
|
volumes:
|
||||||
|
- neo4j_data:/data
|
||||||
|
- neo4j_logs:/logs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
# Redis Cache
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: iconsulting-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
# Zookeeper for Kafka
|
||||||
|
zookeeper:
|
||||||
|
image: confluentinc/cp-zookeeper:7.4.0
|
||||||
|
container_name: iconsulting-zookeeper
|
||||||
|
environment:
|
||||||
|
ZOOKEEPER_CLIENT_PORT: 2181
|
||||||
|
ZOOKEEPER_TICK_TIME: 2000
|
||||||
|
ports:
|
||||||
|
- "2181:2181"
|
||||||
|
volumes:
|
||||||
|
- zookeeper_data:/var/lib/zookeeper/data
|
||||||
|
- zookeeper_logs:/var/lib/zookeeper/log
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
# Kafka Message Broker
|
||||||
|
kafka:
|
||||||
|
image: confluentinc/cp-kafka:7.4.0
|
||||||
|
container_name: iconsulting-kafka
|
||||||
|
depends_on:
|
||||||
|
- zookeeper
|
||||||
|
ports:
|
||||||
|
- "9092:9092"
|
||||||
|
- "29092:29092"
|
||||||
|
environment:
|
||||||
|
KAFKA_BROKER_ID: 1
|
||||||
|
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
|
||||||
|
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
|
||||||
|
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
|
||||||
|
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
||||||
|
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
||||||
|
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
||||||
|
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
|
||||||
|
KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
|
||||||
|
volumes:
|
||||||
|
- kafka_data:/var/lib/kafka/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
# Kafka UI (optional, for debugging)
|
||||||
|
kafka-ui:
|
||||||
|
image: provectuslabs/kafka-ui:latest
|
||||||
|
container_name: iconsulting-kafka-ui
|
||||||
|
depends_on:
|
||||||
|
- kafka
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
KAFKA_CLUSTERS_0_NAME: iconsulting
|
||||||
|
KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092
|
||||||
|
KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181
|
||||||
|
networks:
|
||||||
|
- iconsulting-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
iconsulting-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
neo4j_data:
|
||||||
|
neo4j_logs:
|
||||||
|
redis_data:
|
||||||
|
zookeeper_data:
|
||||||
|
zookeeper_logs:
|
||||||
|
kafka_data:
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,130 @@
|
||||||
|
#===============================================================================
|
||||||
|
# iConsulting Nginx 配置
|
||||||
|
#
|
||||||
|
# 路由规则:
|
||||||
|
# / -> web-client (用户前端)
|
||||||
|
# /admin -> admin-client (管理后台)
|
||||||
|
# /api/v1/* -> Kong API Gateway
|
||||||
|
# /ws/* -> WebSocket (conversation-service)
|
||||||
|
#
|
||||||
|
#===============================================================================
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# 健康检查端点
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 'OK';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 用户前端 (web-client)
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html/web;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# 缓存静态资源
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 管理后台 (admin-client)
|
||||||
|
location /admin {
|
||||||
|
alias /usr/share/nginx/html/admin;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /admin/index.html;
|
||||||
|
|
||||||
|
# 缓存静态资源
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 请求代理到 Kong
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://kong_upstream/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# 超时设置
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
|
# 缓冲设置
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket 代理
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://websocket_upstream/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket 超时 (保持长连接)
|
||||||
|
proxy_connect_timeout 7d;
|
||||||
|
proxy_send_timeout 7d;
|
||||||
|
proxy_read_timeout 7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Socket.IO 专用路径
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://websocket_upstream/socket.io/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 7d;
|
||||||
|
proxy_send_timeout 7d;
|
||||||
|
proxy_read_timeout 7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 禁止访问隐藏文件
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS 配置 (如有SSL证书,取消注释)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name _;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||||
|
# ssl_session_timeout 1d;
|
||||||
|
# ssl_session_cache shared:SSL:50m;
|
||||||
|
# ssl_session_tickets off;
|
||||||
|
#
|
||||||
|
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
# ssl_prefer_server_ciphers off;
|
||||||
|
#
|
||||||
|
# # HSTS
|
||||||
|
# add_header Strict-Transport-Security "max-age=63072000" always;
|
||||||
|
#
|
||||||
|
# # 其他配置同上...
|
||||||
|
# include /etc/nginx/conf.d/locations.conf;
|
||||||
|
# }
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log notice;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
use epoll;
|
||||||
|
multi_accept on;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
# Gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||||
|
|
||||||
|
# 请求大小限制
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# 上游服务定义
|
||||||
|
upstream kong_upstream {
|
||||||
|
server kong:8000;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream websocket_upstream {
|
||||||
|
server conversation-service:3004;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# 此目录用于存放 SSL 证书
|
||||||
|
# 请将 fullchain.pem 和 privkey.pem 放置于此
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "iconsulting",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Hong Kong Immigration Consulting System based on Claude Agent SDK",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*",
|
||||||
|
"packages/services/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo run dev",
|
||||||
|
"build": "turbo run build",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"test": "turbo run test",
|
||||||
|
"clean": "turbo run clean && rm -rf node_modules",
|
||||||
|
"db:migrate": "turbo run db:migrate",
|
||||||
|
"docker:dev": "docker-compose -f infrastructure/docker/docker-compose.dev.yml up -d",
|
||||||
|
"docker:down": "docker-compose -f infrastructure/docker/docker-compose.dev.yml down"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.0.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"prettier": "^3.1.0",
|
||||||
|
"eslint": "^8.55.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"pnpm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@8.15.0"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>iConsulting 管理后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"name": "@iconsulting/admin-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "iConsulting 管理后台",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"antd": "^5.12.8",
|
||||||
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.1",
|
||||||
|
"zustand": "^4.4.7",
|
||||||
|
"recharts": "^2.10.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.47",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||||
|
"@typescript-eslint/parser": "^6.18.1",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { MainLayout } from './shared/components/MainLayout';
|
||||||
|
import { ProtectedRoute } from './shared/components/ProtectedRoute';
|
||||||
|
import { LoginPage } from './features/auth/presentation/pages/LoginPage';
|
||||||
|
import { DashboardPage } from './features/dashboard/presentation/pages/DashboardPage';
|
||||||
|
import { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage';
|
||||||
|
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* 登录页 */}
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* 需要认证的路由 */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="knowledge" element={<KnowledgePage />} />
|
||||||
|
<Route path="experience" element={<ExperiencePage />} />
|
||||||
|
<Route path="users" element={<div className="p-6">用户管理(开发中)</div>} />
|
||||||
|
<Route path="settings" element={<div className="p-6">系统设置(开发中)</div>} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* 未匹配路由重定向 */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Form, Input, Button, Card, message } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
|
import { useAuth } from '../../../../shared/hooks/useAuth';
|
||||||
|
|
||||||
|
interface LoginFormValues {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const login = useAuth((state) => state.login);
|
||||||
|
|
||||||
|
const onFinish = async (values: LoginFormValues) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(values.username, values.password);
|
||||||
|
message.success('登录成功');
|
||||||
|
navigate('/');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('用户名或密码错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<Card className="w-96 shadow-lg">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">iConsulting</h1>
|
||||||
|
<p className="text-gray-500">管理后台</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
name="login"
|
||||||
|
onFinish={onFinish}
|
||||||
|
autoComplete="off"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
placeholder="用户名"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined />}
|
||||||
|
placeholder="密码"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="text-center text-gray-400 text-sm">
|
||||||
|
默认账号: admin / admin123
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Card, Row, Col, Statistic, Tag, Progress, List, Typography } from 'antd';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
import api from '../../../../shared/utils/api';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
// Mock数据 - 实际应该从API获取
|
||||||
|
const mockTrendData = [
|
||||||
|
{ date: '01-01', conversations: 120, users: 45 },
|
||||||
|
{ date: '01-02', conversations: 150, users: 52 },
|
||||||
|
{ date: '01-03', conversations: 180, users: 68 },
|
||||||
|
{ date: '01-04', conversations: 145, users: 55 },
|
||||||
|
{ date: '01-05', conversations: 200, users: 75 },
|
||||||
|
{ date: '01-06', conversations: 230, users: 88 },
|
||||||
|
{ date: '01-07', conversations: 210, users: 82 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockCategoryData = [
|
||||||
|
{ name: 'QMAS', value: 35, color: '#1890ff' },
|
||||||
|
{ name: 'GEP', value: 25, color: '#52c41a' },
|
||||||
|
{ name: 'IANG', value: 20, color: '#faad14' },
|
||||||
|
{ name: 'TTPS', value: 10, color: '#722ed1' },
|
||||||
|
{ name: 'CIES', value: 7, color: '#eb2f96' },
|
||||||
|
{ name: 'TechTAS', value: 3, color: '#13c2c2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { data: evolutionStats } = useQuery({
|
||||||
|
queryKey: ['evolution-stats'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/evolution/statistics');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: healthReport } = useQuery({
|
||||||
|
queryKey: ['system-health'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/evolution/health');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getHealthColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'healthy':
|
||||||
|
return 'success';
|
||||||
|
case 'warning':
|
||||||
|
return 'warning';
|
||||||
|
case 'critical':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Title level={4} className="mb-6">仪表盘</Title>
|
||||||
|
|
||||||
|
{/* 核心指标 */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="今日用户"
|
||||||
|
value={156}
|
||||||
|
prefix={<UserOutlined />}
|
||||||
|
valueStyle={{ color: '#1890ff' }}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-gray-500 text-sm">
|
||||||
|
较昨日 <span className="text-green-500">+12%</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="今日对话"
|
||||||
|
value={428}
|
||||||
|
prefix={<MessageOutlined />}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-gray-500 text-sm">
|
||||||
|
较昨日 <span className="text-green-500">+8%</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="今日收入"
|
||||||
|
value={3580}
|
||||||
|
prefix={<DollarOutlined />}
|
||||||
|
suffix="元"
|
||||||
|
valueStyle={{ color: '#faad14' }}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-gray-500 text-sm">
|
||||||
|
较昨日 <span className="text-green-500">+15%</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="转化率"
|
||||||
|
value={8.5}
|
||||||
|
suffix="%"
|
||||||
|
prefix={<RobotOutlined />}
|
||||||
|
valueStyle={{ color: '#722ed1' }}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-gray-500 text-sm">
|
||||||
|
较昨日 <span className="text-red-500">-2%</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 趋势图表 */}
|
||||||
|
<Row gutter={[16, 16]} className="mt-4">
|
||||||
|
<Col xs={24} lg={16}>
|
||||||
|
<Card title="对话趋势">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<LineChart data={mockTrendData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" />
|
||||||
|
<YAxis />
|
||||||
|
<Tooltip />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="conversations"
|
||||||
|
stroke="#1890ff"
|
||||||
|
name="对话数"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="users"
|
||||||
|
stroke="#52c41a"
|
||||||
|
name="用户数"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={8}>
|
||||||
|
<Card title="类别分布">
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={mockCategoryData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name} ${(percent * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{mockCategoryData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 系统状态 */}
|
||||||
|
<Row gutter={[16, 16]} className="mt-4">
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>系统健康</span>
|
||||||
|
<Tag color={getHealthColor(healthReport?.overall || 'healthy')}>
|
||||||
|
{healthReport?.overall === 'healthy'
|
||||||
|
? '健康'
|
||||||
|
: healthReport?.overall === 'warning'
|
||||||
|
? '警告'
|
||||||
|
: '异常'}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
dataSource={healthReport?.metrics || []}
|
||||||
|
renderItem={(item: { name: string; value: number; threshold: number; status: string }) => (
|
||||||
|
<List.Item>
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between mb-1">
|
||||||
|
<Text>{item.name}</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
{item.value} / {item.threshold}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(100, (item.value / item.threshold) * 100)}
|
||||||
|
status={
|
||||||
|
item.status === 'good'
|
||||||
|
? 'success'
|
||||||
|
: item.status === 'warning'
|
||||||
|
? 'exception'
|
||||||
|
: 'active'
|
||||||
|
}
|
||||||
|
showInfo={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{healthReport?.recommendations?.length > 0 && (
|
||||||
|
<div className="mt-4 p-3 bg-yellow-50 rounded">
|
||||||
|
<Text type="warning">建议:</Text>
|
||||||
|
<ul className="mt-2 ml-4 text-sm text-gray-600">
|
||||||
|
{healthReport.recommendations.map((rec: string, i: number) => (
|
||||||
|
<li key={i}>{rec}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="经验学习进度">
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="总经验"
|
||||||
|
value={evolutionStats?.totalExperiences || 0}
|
||||||
|
prefix={<RobotOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="活跃经验"
|
||||||
|
value={evolutionStats?.activeExperiences || 0}
|
||||||
|
prefix={<CheckCircleOutlined />}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="待审核"
|
||||||
|
value={evolutionStats?.pendingExperiences || 0}
|
||||||
|
prefix={<ClockCircleOutlined />}
|
||||||
|
valueStyle={{ color: '#faad14' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic
|
||||||
|
title="已审核"
|
||||||
|
value={evolutionStats?.approvedExperiences || 0}
|
||||||
|
prefix={<CheckCircleOutlined />}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Text type="secondary">经验类型分布</Text>
|
||||||
|
<div className="mt-2">
|
||||||
|
{evolutionStats?.topExperienceTypes?.map(
|
||||||
|
(item: { type: string; count: number }) => (
|
||||||
|
<Tag key={item.type} className="mb-1">
|
||||||
|
{item.type}: {item.count}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,393 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
message,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
Statistic,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import api from '../../../../shared/utils/api';
|
||||||
|
import { useAuth } from '../../../../shared/hooks/useAuth';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const EXPERIENCE_TYPES = [
|
||||||
|
{ value: 'COMMON_QUESTION', label: '常见问题' },
|
||||||
|
{ value: 'ANSWER_TEMPLATE', label: '回答模板' },
|
||||||
|
{ value: 'CLARIFICATION', label: '澄清方式' },
|
||||||
|
{ value: 'USER_PATTERN', label: '用户模式' },
|
||||||
|
{ value: 'CONVERSION_TRIGGER', label: '转化触发' },
|
||||||
|
{ value: 'KNOWLEDGE_GAP', label: '知识缺口' },
|
||||||
|
{ value: 'CONVERSATION_SKILL', label: '对话技巧' },
|
||||||
|
{ value: 'OBJECTION_HANDLING', label: '异议处理' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Experience {
|
||||||
|
id: string;
|
||||||
|
experienceType: string;
|
||||||
|
content: string;
|
||||||
|
scenario: string;
|
||||||
|
confidence: number;
|
||||||
|
relatedCategory: string;
|
||||||
|
sourceConversationIds: string[];
|
||||||
|
verificationStatus: string;
|
||||||
|
usageCount: number;
|
||||||
|
positiveCount: number;
|
||||||
|
negativeCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExperiencePage() {
|
||||||
|
const [activeTab, setActiveTab] = useState('pending');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>();
|
||||||
|
const [selectedExperience, setSelectedExperience] = useState<Experience | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const admin = useAuth((state) => state.admin);
|
||||||
|
|
||||||
|
const { data: pendingData, isLoading: pendingLoading } = useQuery({
|
||||||
|
queryKey: ['pending-experiences', typeFilter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (typeFilter) params.append('type', typeFilter);
|
||||||
|
const response = await api.get(`/memory/experience/pending?${params}`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
enabled: activeTab === 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['experience-stats'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get('/memory/experience/statistics');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
api.post(`/memory/experience/${id}/approve`, { adminId: admin?.id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('经验已批准');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pending-experiences'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['experience-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
api.post(`/memory/experience/${id}/reject`, { adminId: admin?.id }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('经验已拒绝');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pending-experiences'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['experience-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runEvolutionMutation = useMutation({
|
||||||
|
mutationFn: () => api.post('/evolution/run', { hoursBack: 24, limit: 50 }),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
const result = response.data.data;
|
||||||
|
message.success(
|
||||||
|
`进化任务完成:分析了${result.conversationsAnalyzed}个对话,提取了${result.experiencesExtracted}条经验`
|
||||||
|
);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['pending-experiences'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['experience-stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleView = (exp: Experience) => {
|
||||||
|
setSelectedExperience(exp);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
return EXPERIENCE_TYPES.find((t) => t.value === type)?.label || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusTag = (status: string) => {
|
||||||
|
const statusMap: Record<string, { color: string; label: string }> = {
|
||||||
|
PENDING: { color: 'orange', label: '待审核' },
|
||||||
|
APPROVED: { color: 'green', label: '已通过' },
|
||||||
|
REJECTED: { color: 'red', label: '已拒绝' },
|
||||||
|
DEPRECATED: { color: 'default', label: '已弃用' },
|
||||||
|
};
|
||||||
|
const s = statusMap[status] || { color: 'default', label: status };
|
||||||
|
return <Tag color={s.color}>{s.label}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'experienceType',
|
||||||
|
key: 'experienceType',
|
||||||
|
render: (type: string) => <Tag>{getTypeLabel(type)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '场景',
|
||||||
|
dataIndex: 'scenario',
|
||||||
|
key: 'scenario',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '内容',
|
||||||
|
dataIndex: 'content',
|
||||||
|
key: 'content',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (text: string) => (
|
||||||
|
<Text ellipsis style={{ maxWidth: 200 }}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '置信度',
|
||||||
|
dataIndex: 'confidence',
|
||||||
|
key: 'confidence',
|
||||||
|
render: (confidence: number) => (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
confidence >= 70
|
||||||
|
? 'text-green-600'
|
||||||
|
: confidence >= 40
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{confidence}%
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源对话',
|
||||||
|
dataIndex: 'sourceConversationIds',
|
||||||
|
key: 'sources',
|
||||||
|
render: (ids: string[]) => <span>{ids?.length || 0}个</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'verificationStatus',
|
||||||
|
key: 'status',
|
||||||
|
render: getStatusTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: unknown, record: Experience) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleView(record)}
|
||||||
|
/>
|
||||||
|
{record.verificationStatus === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
className="text-green-600"
|
||||||
|
onClick={() => approveMutation.mutate(record.id)}
|
||||||
|
loading={approveMutation.isPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
danger
|
||||||
|
onClick={() => rejectMutation.mutate(record.id)}
|
||||||
|
loading={rejectMutation.isPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<Title level={4} className="mb-0">系统经验管理</Title>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={() => runEvolutionMutation.mutate()}
|
||||||
|
loading={runEvolutionMutation.isPending}
|
||||||
|
>
|
||||||
|
运行进化任务
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-4">
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="总经验" value={stats?.total || 0} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="待审核"
|
||||||
|
value={stats?.byStatus?.PENDING || 0}
|
||||||
|
valueStyle={{ color: '#faad14' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="已通过"
|
||||||
|
value={stats?.byStatus?.APPROVED || 0}
|
||||||
|
valueStyle={{ color: '#52c41a' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title="已拒绝"
|
||||||
|
value={stats?.byStatus?.REJECTED || 0}
|
||||||
|
valueStyle={{ color: '#ff4d4f' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{ key: 'pending', label: '待审核' },
|
||||||
|
{ key: 'approved', label: '已通过' },
|
||||||
|
{ key: 'rejected', label: '已拒绝' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<Select
|
||||||
|
placeholder="筛选类型"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 180 }}
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={setTypeFilter}
|
||||||
|
options={EXPERIENCE_TYPES}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={pendingData?.items || []}
|
||||||
|
rowKey="id"
|
||||||
|
loading={pendingLoading}
|
||||||
|
pagination={{
|
||||||
|
total: pendingData?.total || 0,
|
||||||
|
pageSize: 20,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 详情弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="经验详情"
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedExperience(null);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
selectedExperience?.verificationStatus === 'PENDING'
|
||||||
|
? [
|
||||||
|
<Button
|
||||||
|
key="reject"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
rejectMutation.mutate(selectedExperience.id);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="approve"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
approveMutation.mutate(selectedExperience.id);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
批准
|
||||||
|
</Button>,
|
||||||
|
]
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
{selectedExperience && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Tag>{getTypeLabel(selectedExperience.experienceType)}</Tag>
|
||||||
|
{getStatusTag(selectedExperience.verificationStatus)}
|
||||||
|
{selectedExperience.isActive && <Tag color="blue">激活中</Tag>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<Text type="secondary">场景</Text>
|
||||||
|
<Paragraph>{selectedExperience.scenario}</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<Text type="secondary">内容</Text>
|
||||||
|
<Paragraph>{selectedExperience.content}</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic
|
||||||
|
title="置信度"
|
||||||
|
value={selectedExperience.confidence}
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic
|
||||||
|
title="使用次数"
|
||||||
|
value={selectedExperience.usageCount}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic
|
||||||
|
title="来源对话"
|
||||||
|
value={selectedExperience.sourceConversationIds?.length || 0}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{selectedExperience.relatedCategory && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Text type="secondary">相关类别: </Text>
|
||||||
|
<Tag color="blue">{selectedExperience.relatedCategory}</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,404 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Typography,
|
||||||
|
Drawer,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import api from '../../../../shared/utils/api';
|
||||||
|
|
||||||
|
const { Title, Paragraph } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ value: 'QMAS', label: '优秀人才入境计划' },
|
||||||
|
{ value: 'GEP', label: '一般就业政策' },
|
||||||
|
{ value: 'IANG', label: '非本地毕业生留港/回港就业安排' },
|
||||||
|
{ value: 'TTPS', label: '科技人才入境计划' },
|
||||||
|
{ value: 'CIES', label: '资本投资者入境计划' },
|
||||||
|
{ value: 'TechTAS', label: '顶尖人才通行证计划' },
|
||||||
|
{ value: 'GENERAL', label: '通用知识' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
summary: string;
|
||||||
|
category: string;
|
||||||
|
tags: string[];
|
||||||
|
source: string;
|
||||||
|
isPublished: boolean;
|
||||||
|
citationCount: number;
|
||||||
|
qualityScore: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KnowledgePage() {
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string>();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['knowledge-articles', categoryFilter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (categoryFilter) params.append('category', categoryFilter);
|
||||||
|
const response = await api.get(`/knowledge/articles?${params}`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (values: Partial<Article>) =>
|
||||||
|
api.post('/knowledge/articles', values),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('文章创建成功');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||||
|
setIsModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, ...values }: { id: string } & Partial<Article>) =>
|
||||||
|
api.put(`/knowledge/articles/${id}`, values),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('文章更新成功');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||||
|
setIsModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
setSelectedArticle(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/knowledge/articles/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('文章已删除');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const publishMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.post(`/knowledge/articles/${id}/publish`),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('文章已发布');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const unpublishMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.post(`/knowledge/articles/${id}/unpublish`),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('文章已取消发布');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-articles'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (article: Article) => {
|
||||||
|
setSelectedArticle(article);
|
||||||
|
form.setFieldsValue({
|
||||||
|
title: article.title,
|
||||||
|
content: article.content,
|
||||||
|
category: article.category,
|
||||||
|
tags: article.tags,
|
||||||
|
});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (article: Article) => {
|
||||||
|
setSelectedArticle(article);
|
||||||
|
setIsDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (values: Partial<Article>) => {
|
||||||
|
if (selectedArticle) {
|
||||||
|
updateMutation.mutate({ id: selectedArticle.id, ...values });
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
key: 'title',
|
||||||
|
render: (text: string, record: Article) => (
|
||||||
|
<a onClick={() => handleView(record)}>{text}</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类别',
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category',
|
||||||
|
render: (category: string) => {
|
||||||
|
const cat = CATEGORIES.find((c) => c.value === category);
|
||||||
|
return <Tag color="blue">{cat?.label || category}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
key: 'source',
|
||||||
|
render: (source: string) => {
|
||||||
|
const sourceMap: Record<string, { color: string; label: string }> = {
|
||||||
|
MANUAL: { color: 'green', label: '手动' },
|
||||||
|
CRAWL: { color: 'orange', label: '爬取' },
|
||||||
|
EXTRACT: { color: 'purple', label: '提取' },
|
||||||
|
IMPORT: { color: 'cyan', label: '导入' },
|
||||||
|
};
|
||||||
|
const s = sourceMap[source] || { color: 'default', label: source };
|
||||||
|
return <Tag color={s.color}>{s.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'isPublished',
|
||||||
|
key: 'isPublished',
|
||||||
|
render: (isPublished: boolean) =>
|
||||||
|
isPublished ? (
|
||||||
|
<Tag color="success">已发布</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="default">草稿</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '质量分',
|
||||||
|
dataIndex: 'qualityScore',
|
||||||
|
key: 'qualityScore',
|
||||||
|
render: (score: number) => (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
score >= 70
|
||||||
|
? 'text-green-600'
|
||||||
|
: score >= 40
|
||||||
|
? 'text-yellow-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '引用次数',
|
||||||
|
dataIndex: 'citationCount',
|
||||||
|
key: 'citationCount',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: unknown, record: Article) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleView(record)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
/>
|
||||||
|
{record.isPublished ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定取消发布?"
|
||||||
|
onConfirm={() => unpublishMutation.mutate(record.id)}
|
||||||
|
>
|
||||||
|
<Button type="text" icon={<StopOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
onClick={() => publishMutation.mutate(record.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此文章?"
|
||||||
|
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||||
|
>
|
||||||
|
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<Title level={4} className="mb-6">知识库管理</Title>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="flex justify-between mb-4">
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索文章"
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="选择类别"
|
||||||
|
allowClear
|
||||||
|
style={{ width: 180 }}
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={setCategoryFilter}
|
||||||
|
options={CATEGORIES}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedArticle(null);
|
||||||
|
form.resetFields();
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建文章
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items || []}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total || 0,
|
||||||
|
pageSize: 20,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 编辑/新建弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={selectedArticle ? '编辑文章' : '新建文章'}
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedArticle(null);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="标题"
|
||||||
|
rules={[{ required: true, message: '请输入标题' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="文章标题" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="category"
|
||||||
|
label="类别"
|
||||||
|
rules={[{ required: true, message: '请选择类别' }]}
|
||||||
|
>
|
||||||
|
<Select options={CATEGORIES} placeholder="选择移民类别" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="tags" label="标签">
|
||||||
|
<Select mode="tags" placeholder="输入标签后回车" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="content"
|
||||||
|
label="内容"
|
||||||
|
rules={[{ required: true, message: '请输入内容' }]}
|
||||||
|
>
|
||||||
|
<TextArea rows={12} placeholder="支持Markdown格式" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item className="mb-0 text-right">
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 预览抽屉 */}
|
||||||
|
<Drawer
|
||||||
|
title={selectedArticle?.title}
|
||||||
|
open={isDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDrawerOpen(false);
|
||||||
|
setSelectedArticle(null);
|
||||||
|
}}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
{selectedArticle && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Tag color="blue">
|
||||||
|
{CATEGORIES.find((c) => c.value === selectedArticle.category)
|
||||||
|
?.label || selectedArticle.category}
|
||||||
|
</Tag>
|
||||||
|
{selectedArticle.isPublished ? (
|
||||||
|
<Tag color="success">已发布</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag>草稿</Tag>
|
||||||
|
)}
|
||||||
|
<span className="ml-2 text-gray-500 text-sm">
|
||||||
|
质量分: {selectedArticle.qualityScore} | 引用:{' '}
|
||||||
|
{selectedArticle.citationCount}次
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedArticle.tags?.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
{selectedArticle.tags.map((tag) => (
|
||||||
|
<Tag key={tag}>{tag}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paragraph className="text-gray-600 mb-4">
|
||||||
|
{selectedArticle.summary}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<div className="whitespace-pre-wrap">{selectedArticle.content}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1890ff',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ConfigProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||||
|
import { Layout, Menu, Avatar, Dropdown, Typography } from 'antd';
|
||||||
|
import type { MenuProps } from 'antd';
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const menuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: '/',
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
label: '仪表盘',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/knowledge',
|
||||||
|
icon: <BookOutlined />,
|
||||||
|
label: '知识库',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/experience',
|
||||||
|
icon: <RobotOutlined />,
|
||||||
|
label: '系统经验',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/users',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: '用户管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '/settings',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '系统设置',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { admin, logout } = useAuth();
|
||||||
|
|
||||||
|
const handleMenuClick = (e: { key: string }) => {
|
||||||
|
navigate(e.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const userMenuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: '个人设置',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录',
|
||||||
|
danger: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
|
if (key === 'logout') {
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="min-h-screen">
|
||||||
|
<Sider
|
||||||
|
trigger={null}
|
||||||
|
collapsible
|
||||||
|
collapsed={collapsed}
|
||||||
|
theme="light"
|
||||||
|
className="shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="h-16 flex items-center justify-center border-b">
|
||||||
|
<Text strong className="text-lg">
|
||||||
|
{collapsed ? 'iC' : 'iConsulting'}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
className="border-none"
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
<Layout>
|
||||||
|
<Header className="bg-white px-4 flex items-center justify-between shadow-sm">
|
||||||
|
<div
|
||||||
|
className="cursor-pointer text-lg"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
>
|
||||||
|
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: userMenuItems, onClick: handleUserMenuClick }}
|
||||||
|
placement="bottomRight"
|
||||||
|
>
|
||||||
|
<div className="flex items-center cursor-pointer">
|
||||||
|
<Avatar icon={<UserOutlined />} className="mr-2" />
|
||||||
|
<Text>{admin?.name || 'Admin'}</Text>
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</Header>
|
||||||
|
<Content className="bg-gray-100 min-h-0 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
const location = useLocation();
|
||||||
|
const { isAuthenticated, checkAuth } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const verify = async () => {
|
||||||
|
await checkAuth();
|
||||||
|
setChecking(false);
|
||||||
|
};
|
||||||
|
verify();
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
|
if (checking) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
interface AdminInfo {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
admin: AdminInfo | null;
|
||||||
|
token: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
checkAuth: () => Promise<boolean>;
|
||||||
|
hasPermission: (permission: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
admin: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
const response = await api.post('/admin/login', { username, password });
|
||||||
|
const { data } = response.data;
|
||||||
|
|
||||||
|
localStorage.setItem('admin_token', data.token);
|
||||||
|
|
||||||
|
set({
|
||||||
|
admin: data.admin,
|
||||||
|
token: data.token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
set({
|
||||||
|
admin: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
checkAuth: async () => {
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
if (!token) {
|
||||||
|
set({ isAuthenticated: false, admin: null, token: null });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get('/admin/verify');
|
||||||
|
if (response.data.success) {
|
||||||
|
set({
|
||||||
|
admin: response.data.data,
|
||||||
|
token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isAuthenticated: false, admin: null, token: null });
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
hasPermission: (permission: string) => {
|
||||||
|
const { admin } = get();
|
||||||
|
if (!admin) return false;
|
||||||
|
|
||||||
|
const permissions = admin.permissions || [];
|
||||||
|
|
||||||
|
// 超管拥有所有权限
|
||||||
|
if (permissions.includes('*')) return true;
|
||||||
|
|
||||||
|
// 完全匹配
|
||||||
|
if (permissions.includes(permission)) return true;
|
||||||
|
|
||||||
|
// 通配符匹配
|
||||||
|
const [resource] = permission.split(':');
|
||||||
|
if (permissions.includes(`${resource}:*`)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({
|
||||||
|
admin: state.admin,
|
||||||
|
token: state.token,
|
||||||
|
isAuthenticated: state.isAuthenticated,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api/v1',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 请求拦截器 - 添加Token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('admin_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 响应拦截器 - 处理认证错误
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('admin_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
corePlugins: {
|
||||||
|
preflight: false, // 禁用Tailwind的reset,避免与Antd冲突
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3005',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# ===========================================
|
||||||
|
# iConsulting Conversation Service Dockerfile
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
COPY packages/services/conversation-service/package.json ./packages/services/conversation-service/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
COPY packages/services/conversation-service ./packages/services/conversation-service
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
|
||||||
|
RUN pnpm --filter @iconsulting/shared build
|
||||||
|
RUN pnpm --filter @iconsulting/conversation-service build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nestjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/packages/services/conversation-service/dist ./dist
|
||||||
|
COPY --from=builder /app/packages/services/conversation-service/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3004
|
||||||
|
|
||||||
|
USER nestjs
|
||||||
|
EXPOSE 3004
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3004/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "@iconsulting/conversation-service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Conversation service with Claude Agent SDK integration",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.52.0",
|
||||||
|
"@iconsulting/shared": "workspace:*",
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.2.0",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/platform-socket.io": "^10.0.0",
|
||||||
|
"@nestjs/typeorm": "^10.0.0",
|
||||||
|
"@nestjs/websockets": "^10.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"ioredis": "^5.3.0",
|
||||||
|
"kafkajs": "^2.2.4",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"typeorm": "^0.3.19",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.0",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/socket.io": "^3.0.2",
|
||||||
|
"@types/uuid": "^9.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConversationModule } from './conversation/conversation.module';
|
||||||
|
import { ClaudeModule } from './infrastructure/claude/claude.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// Configuration
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: ['.env.local', '.env'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Database
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: configService.get('POSTGRES_HOST', 'localhost'),
|
||||||
|
port: configService.get<number>('POSTGRES_PORT', 5432),
|
||||||
|
username: configService.get('POSTGRES_USER', 'iconsulting'),
|
||||||
|
password: configService.get('POSTGRES_PASSWORD'),
|
||||||
|
database: configService.get('POSTGRES_DB', 'iconsulting'),
|
||||||
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
|
synchronize: configService.get('NODE_ENV') === 'development',
|
||||||
|
logging: configService.get('NODE_ENV') === 'development',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Feature modules
|
||||||
|
ConversationModule,
|
||||||
|
ClaudeModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConversationService } from './conversation.service';
|
||||||
|
|
||||||
|
class CreateConversationDto {
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendMessageDto {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('conversations')
|
||||||
|
export class ConversationController {
|
||||||
|
constructor(private conversationService: ConversationService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new conversation
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async createConversation(
|
||||||
|
@Headers('x-user-id') userId: string,
|
||||||
|
@Body() dto: CreateConversationDto,
|
||||||
|
) {
|
||||||
|
const conversation = await this.conversationService.createConversation({
|
||||||
|
userId,
|
||||||
|
title: dto.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: conversation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's conversations
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async getConversations(@Headers('x-user-id') userId: string) {
|
||||||
|
const conversations = await this.conversationService.getUserConversations(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: conversations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific conversation
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
async getConversation(
|
||||||
|
@Headers('x-user-id') userId: string,
|
||||||
|
@Param('id') conversationId: string,
|
||||||
|
) {
|
||||||
|
const conversation = await this.conversationService.getConversation(
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: conversation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get conversation messages
|
||||||
|
*/
|
||||||
|
@Get(':id/messages')
|
||||||
|
async getMessages(
|
||||||
|
@Headers('x-user-id') userId: string,
|
||||||
|
@Param('id') conversationId: string,
|
||||||
|
) {
|
||||||
|
const messages = await this.conversationService.getMessages(
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message (non-streaming HTTP endpoint)
|
||||||
|
* For streaming, use WebSocket
|
||||||
|
*/
|
||||||
|
@Post(':id/messages')
|
||||||
|
async sendMessage(
|
||||||
|
@Headers('x-user-id') userId: string,
|
||||||
|
@Param('id') conversationId: string,
|
||||||
|
@Body() dto: SendMessageDto,
|
||||||
|
) {
|
||||||
|
let fullResponse = '';
|
||||||
|
|
||||||
|
for await (const chunk of this.conversationService.sendMessage({
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
content: dto.content,
|
||||||
|
})) {
|
||||||
|
if (chunk.type === 'text' && chunk.content) {
|
||||||
|
fullResponse += chunk.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
response: fullResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a conversation
|
||||||
|
*/
|
||||||
|
@Post(':id/end')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async endConversation(
|
||||||
|
@Headers('x-user-id') userId: string,
|
||||||
|
@Param('id') conversationId: string,
|
||||||
|
) {
|
||||||
|
await this.conversationService.endConversation(conversationId, userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Conversation ended',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
import {
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { ConversationService } from './conversation.service';
|
||||||
|
|
||||||
|
interface SendMessagePayload {
|
||||||
|
conversationId: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
cors: {
|
||||||
|
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'],
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
namespace: '/ws/conversation',
|
||||||
|
})
|
||||||
|
export class ConversationGateway
|
||||||
|
implements OnGatewayConnection, OnGatewayDisconnect
|
||||||
|
{
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
// Map socket ID to user ID
|
||||||
|
private connections = new Map<string, string>();
|
||||||
|
|
||||||
|
constructor(private conversationService: ConversationService) {}
|
||||||
|
|
||||||
|
async handleConnection(client: Socket) {
|
||||||
|
// Extract user ID from query or headers
|
||||||
|
const userId =
|
||||||
|
(client.handshake.query.userId as string) ||
|
||||||
|
(client.handshake.headers['x-user-id'] as string);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
console.log(`Client ${client.id} connected without user ID`);
|
||||||
|
client.emit('error', { message: 'User ID is required' });
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connections.set(client.id, userId);
|
||||||
|
console.log(`Client ${client.id} connected as user ${userId}`);
|
||||||
|
|
||||||
|
client.emit('connected', { userId, socketId: client.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
const userId = this.connections.get(client.id);
|
||||||
|
this.connections.delete(client.id);
|
||||||
|
console.log(`Client ${client.id} (user ${userId}) disconnected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('message')
|
||||||
|
async handleMessage(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() payload: SendMessagePayload,
|
||||||
|
) {
|
||||||
|
const userId = this.connections.get(client.id);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
client.emit('error', { message: 'Not authenticated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { conversationId, content } = payload;
|
||||||
|
|
||||||
|
if (!conversationId || !content) {
|
||||||
|
client.emit('error', { message: 'conversationId and content are required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate unique message ID for this response
|
||||||
|
const messageId = `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
// Emit stream start
|
||||||
|
client.emit('stream_start', { messageId, conversationId });
|
||||||
|
|
||||||
|
let chunkIndex = 0;
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
for await (const chunk of this.conversationService.sendMessage({
|
||||||
|
conversationId,
|
||||||
|
userId,
|
||||||
|
content,
|
||||||
|
})) {
|
||||||
|
if (chunk.type === 'text' && chunk.content) {
|
||||||
|
client.emit('stream_chunk', {
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
content: chunk.content,
|
||||||
|
index: chunkIndex++,
|
||||||
|
});
|
||||||
|
} else if (chunk.type === 'tool_use') {
|
||||||
|
client.emit('tool_call', {
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
tool: chunk.toolName,
|
||||||
|
input: chunk.toolInput,
|
||||||
|
});
|
||||||
|
} else if (chunk.type === 'tool_result') {
|
||||||
|
client.emit('tool_result', {
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
tool: chunk.toolName,
|
||||||
|
result: chunk.toolResult,
|
||||||
|
});
|
||||||
|
} else if (chunk.type === 'end') {
|
||||||
|
client.emit('stream_end', {
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing message:', error);
|
||||||
|
client.emit('error', {
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to process message',
|
||||||
|
conversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('typing_start')
|
||||||
|
handleTypingStart(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() payload: { conversationId: string },
|
||||||
|
) {
|
||||||
|
// Could be used to show typing indicator to admin monitoring
|
||||||
|
console.log(`User typing in conversation ${payload.conversationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('typing_end')
|
||||||
|
handleTypingEnd(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() payload: { conversationId: string },
|
||||||
|
) {
|
||||||
|
console.log(`User stopped typing in conversation ${payload.conversationId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConversationEntity } from '../domain/entities/conversation.entity';
|
||||||
|
import { MessageEntity } from '../domain/entities/message.entity';
|
||||||
|
import { ConversationService } from './conversation.service';
|
||||||
|
import { ConversationController } from './conversation.controller';
|
||||||
|
import { ConversationGateway } from './conversation.gateway';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ConversationEntity, MessageEntity])],
|
||||||
|
controllers: [ConversationController],
|
||||||
|
providers: [ConversationService, ConversationGateway],
|
||||||
|
exports: [ConversationService],
|
||||||
|
})
|
||||||
|
export class ConversationModule {}
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import {
|
||||||
|
ConversationEntity,
|
||||||
|
ConversationStatus,
|
||||||
|
} from '../domain/entities/conversation.entity';
|
||||||
|
import {
|
||||||
|
MessageEntity,
|
||||||
|
MessageRole,
|
||||||
|
MessageType,
|
||||||
|
} from '../domain/entities/message.entity';
|
||||||
|
import {
|
||||||
|
ClaudeAgentService,
|
||||||
|
ConversationContext,
|
||||||
|
StreamChunk,
|
||||||
|
} from '../infrastructure/claude/claude-agent.service';
|
||||||
|
|
||||||
|
export interface CreateConversationDto {
|
||||||
|
userId: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageDto {
|
||||||
|
conversationId: string;
|
||||||
|
userId: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConversationService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ConversationEntity)
|
||||||
|
private conversationRepo: Repository<ConversationEntity>,
|
||||||
|
@InjectRepository(MessageEntity)
|
||||||
|
private messageRepo: Repository<MessageEntity>,
|
||||||
|
private claudeAgentService: ClaudeAgentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new conversation
|
||||||
|
*/
|
||||||
|
async createConversation(dto: CreateConversationDto): Promise<ConversationEntity> {
|
||||||
|
const conversation = this.conversationRepo.create({
|
||||||
|
userId: dto.userId,
|
||||||
|
title: dto.title || '新对话',
|
||||||
|
status: ConversationStatus.ACTIVE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.conversationRepo.save(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get conversation by ID
|
||||||
|
*/
|
||||||
|
async getConversation(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ConversationEntity> {
|
||||||
|
const conversation = await this.conversationRepo.findOne({
|
||||||
|
where: { id: conversationId, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!conversation) {
|
||||||
|
throw new NotFoundException('Conversation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's conversations
|
||||||
|
*/
|
||||||
|
async getUserConversations(userId: string): Promise<ConversationEntity[]> {
|
||||||
|
return this.conversationRepo.find({
|
||||||
|
where: { userId },
|
||||||
|
order: { updatedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get conversation messages
|
||||||
|
*/
|
||||||
|
async getMessages(
|
||||||
|
conversationId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<MessageEntity[]> {
|
||||||
|
// Verify user owns the conversation
|
||||||
|
await this.getConversation(conversationId, userId);
|
||||||
|
|
||||||
|
return this.messageRepo.find({
|
||||||
|
where: { conversationId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message and get streaming response
|
||||||
|
*/
|
||||||
|
async *sendMessage(dto: SendMessageDto): AsyncGenerator<StreamChunk> {
|
||||||
|
// Verify conversation exists and belongs to user
|
||||||
|
const conversation = await this.getConversation(dto.conversationId, dto.userId);
|
||||||
|
|
||||||
|
if (conversation.status !== ConversationStatus.ACTIVE) {
|
||||||
|
throw new Error('Conversation is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user message
|
||||||
|
const userMessage = this.messageRepo.create({
|
||||||
|
conversationId: dto.conversationId,
|
||||||
|
role: MessageRole.USER,
|
||||||
|
type: MessageType.TEXT,
|
||||||
|
content: dto.content,
|
||||||
|
});
|
||||||
|
await this.messageRepo.save(userMessage);
|
||||||
|
|
||||||
|
// Get previous messages for context
|
||||||
|
const previousMessages = await this.messageRepo.find({
|
||||||
|
where: { conversationId: dto.conversationId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
take: 20, // Last 20 messages for context
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build context
|
||||||
|
const context: ConversationContext = {
|
||||||
|
userId: dto.userId,
|
||||||
|
conversationId: dto.conversationId,
|
||||||
|
previousMessages: previousMessages.map((m) => ({
|
||||||
|
role: m.role as 'user' | 'assistant',
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect full response for saving
|
||||||
|
let fullResponse = '';
|
||||||
|
const toolCalls: Array<{ name: string; input: unknown; result: unknown }> = [];
|
||||||
|
|
||||||
|
// Stream response from Claude
|
||||||
|
for await (const chunk of this.claudeAgentService.sendMessage(
|
||||||
|
dto.content,
|
||||||
|
context,
|
||||||
|
)) {
|
||||||
|
if (chunk.type === 'text' && chunk.content) {
|
||||||
|
fullResponse += chunk.content;
|
||||||
|
} else if (chunk.type === 'tool_use') {
|
||||||
|
toolCalls.push({
|
||||||
|
name: chunk.toolName!,
|
||||||
|
input: chunk.toolInput!,
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
} else if (chunk.type === 'tool_result') {
|
||||||
|
const lastToolCall = toolCalls[toolCalls.length - 1];
|
||||||
|
if (lastToolCall) {
|
||||||
|
lastToolCall.result = chunk.toolResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save assistant response
|
||||||
|
const assistantMessage = this.messageRepo.create({
|
||||||
|
conversationId: dto.conversationId,
|
||||||
|
role: MessageRole.ASSISTANT,
|
||||||
|
type: MessageType.TEXT,
|
||||||
|
content: fullResponse,
|
||||||
|
metadata: toolCalls.length > 0 ? { toolCalls } : undefined,
|
||||||
|
});
|
||||||
|
await this.messageRepo.save(assistantMessage);
|
||||||
|
|
||||||
|
// Update conversation title if first message
|
||||||
|
if (conversation.messageCount === 0) {
|
||||||
|
const title = await this.generateTitle(dto.content);
|
||||||
|
await this.conversationRepo.update(conversation.id, { title });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End a conversation
|
||||||
|
*/
|
||||||
|
async endConversation(conversationId: string, userId: string): Promise<void> {
|
||||||
|
const conversation = await this.getConversation(conversationId, userId);
|
||||||
|
|
||||||
|
await this.conversationRepo.update(conversation.id, {
|
||||||
|
status: ConversationStatus.ENDED,
|
||||||
|
endedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a title from the first message
|
||||||
|
*/
|
||||||
|
private async generateTitle(firstMessage: string): Promise<string> {
|
||||||
|
// Simple title generation - take first 50 chars
|
||||||
|
const title = firstMessage.substring(0, 50);
|
||||||
|
return title.length < firstMessage.length ? `${title}...` : title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { MessageEntity } from './message.entity';
|
||||||
|
|
||||||
|
export enum ConversationStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
ENDED = 'ENDED',
|
||||||
|
ARCHIVED = 'ARCHIVED',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('conversations')
|
||||||
|
export class ConversationEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ConversationStatus,
|
||||||
|
default: ConversationStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
status: ConversationStatus;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
summary: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
@Column({ name: 'message_count', default: 0 })
|
||||||
|
messageCount: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'ended_at', nullable: true })
|
||||||
|
endedAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => MessageEntity, (message) => message.conversation)
|
||||||
|
messages: MessageEntity[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ConversationEntity } from './conversation.entity';
|
||||||
|
|
||||||
|
export enum MessageRole {
|
||||||
|
USER = 'user',
|
||||||
|
ASSISTANT = 'assistant',
|
||||||
|
SYSTEM = 'system',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MessageType {
|
||||||
|
TEXT = 'TEXT',
|
||||||
|
TOOL_CALL = 'TOOL_CALL',
|
||||||
|
TOOL_RESULT = 'TOOL_RESULT',
|
||||||
|
PAYMENT_REQUEST = 'PAYMENT_REQUEST',
|
||||||
|
ASSESSMENT_START = 'ASSESSMENT_START',
|
||||||
|
ASSESSMENT_RESULT = 'ASSESSMENT_RESULT',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('messages')
|
||||||
|
export class MessageEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'conversation_id', type: 'uuid' })
|
||||||
|
conversationId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: MessageRole,
|
||||||
|
})
|
||||||
|
role: MessageRole;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: MessageType,
|
||||||
|
default: MessageType.TEXT,
|
||||||
|
})
|
||||||
|
type: MessageType;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => ConversationEntity, (conversation) => conversation.messages)
|
||||||
|
@JoinColumn({ name: 'conversation_id' })
|
||||||
|
conversation: ConversationEntity;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
import { ImmigrationToolsService } from './tools/immigration-tools.service';
|
||||||
|
import { buildSystemPrompt, SystemPromptConfig } from './prompts/system-prompt';
|
||||||
|
|
||||||
|
export interface ConversationContext {
|
||||||
|
userId: string;
|
||||||
|
conversationId: string;
|
||||||
|
userMemory?: string[];
|
||||||
|
previousMessages?: Array<{
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamChunk {
|
||||||
|
type: 'text' | 'tool_use' | 'tool_result' | 'end';
|
||||||
|
content?: string;
|
||||||
|
toolName?: string;
|
||||||
|
toolInput?: Record<string, unknown>;
|
||||||
|
toolResult?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClaudeAgentService implements OnModuleInit {
|
||||||
|
private client: Anthropic;
|
||||||
|
private systemPromptConfig: SystemPromptConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private immigrationToolsService: ImmigrationToolsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.client = new Anthropic({
|
||||||
|
apiKey: this.configService.get<string>('ANTHROPIC_API_KEY'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize with default config
|
||||||
|
this.systemPromptConfig = {
|
||||||
|
identity: '专业、友善、耐心的香港移民顾问',
|
||||||
|
conversationStyle: '专业但不生硬,用简洁明了的语言解答',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update system prompt configuration (for evolution)
|
||||||
|
*/
|
||||||
|
updateSystemPromptConfig(config: Partial<SystemPromptConfig>) {
|
||||||
|
this.systemPromptConfig = {
|
||||||
|
...this.systemPromptConfig,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message and get streaming response
|
||||||
|
*/
|
||||||
|
async *sendMessage(
|
||||||
|
message: string,
|
||||||
|
context: ConversationContext,
|
||||||
|
): AsyncGenerator<StreamChunk> {
|
||||||
|
const tools = this.immigrationToolsService.getTools();
|
||||||
|
const systemPrompt = buildSystemPrompt(this.systemPromptConfig);
|
||||||
|
|
||||||
|
// Build messages array
|
||||||
|
const messages: Anthropic.MessageParam[] = [];
|
||||||
|
|
||||||
|
// Add previous messages if any
|
||||||
|
if (context.previousMessages) {
|
||||||
|
for (const msg of context.previousMessages) {
|
||||||
|
messages.push({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add current message
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create streaming message
|
||||||
|
const stream = await this.client.messages.stream({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 4096,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
tools: tools as Anthropic.Tool[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let currentToolUse: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
if (event.type === 'content_block_start') {
|
||||||
|
if (event.content_block.type === 'tool_use') {
|
||||||
|
currentToolUse = {
|
||||||
|
id: event.content_block.id,
|
||||||
|
name: event.content_block.name,
|
||||||
|
input: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (event.type === 'content_block_delta') {
|
||||||
|
if (event.delta.type === 'text_delta') {
|
||||||
|
yield {
|
||||||
|
type: 'text',
|
||||||
|
content: event.delta.text,
|
||||||
|
};
|
||||||
|
} else if (event.delta.type === 'input_json_delta' && currentToolUse) {
|
||||||
|
// Accumulate tool input
|
||||||
|
try {
|
||||||
|
const partialInput = JSON.parse(event.delta.partial_json || '{}');
|
||||||
|
currentToolUse.input = {
|
||||||
|
...currentToolUse.input,
|
||||||
|
...partialInput,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors for partial JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (event.type === 'content_block_stop') {
|
||||||
|
if (currentToolUse) {
|
||||||
|
yield {
|
||||||
|
type: 'tool_use',
|
||||||
|
toolName: currentToolUse.name,
|
||||||
|
toolInput: currentToolUse.input,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute the tool
|
||||||
|
const toolResult = await this.immigrationToolsService.executeTool(
|
||||||
|
currentToolUse.name,
|
||||||
|
currentToolUse.input,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
yield {
|
||||||
|
type: 'tool_result',
|
||||||
|
toolName: currentToolUse.name,
|
||||||
|
toolResult,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentToolUse = null;
|
||||||
|
}
|
||||||
|
} else if (event.type === 'message_stop') {
|
||||||
|
yield { type: 'end' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Claude API error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-streaming message for simple queries
|
||||||
|
*/
|
||||||
|
async sendMessageSync(
|
||||||
|
message: string,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<string> {
|
||||||
|
const tools = this.immigrationToolsService.getTools();
|
||||||
|
const systemPrompt = buildSystemPrompt(this.systemPromptConfig);
|
||||||
|
|
||||||
|
const messages: Anthropic.MessageParam[] = [];
|
||||||
|
|
||||||
|
if (context.previousMessages) {
|
||||||
|
for (const msg of context.previousMessages) {
|
||||||
|
messages.push({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 4096,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
tools: tools as Anthropic.Tool[],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract text response
|
||||||
|
let result = '';
|
||||||
|
for (const block of response.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
result += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze content (for evolution service)
|
||||||
|
*/
|
||||||
|
async analyze(prompt: string): Promise<string> {
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 8192,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
for (const block of response.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
result += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ClaudeAgentService } from './claude-agent.service';
|
||||||
|
import { ImmigrationToolsService } from './tools/immigration-tools.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [ClaudeAgentService, ImmigrationToolsService],
|
||||||
|
exports: [ClaudeAgentService, ImmigrationToolsService],
|
||||||
|
})
|
||||||
|
export class ClaudeModule {}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* System prompt builder for the immigration consultant agent
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SystemPromptConfig {
|
||||||
|
identity?: string;
|
||||||
|
conversationStyle?: string;
|
||||||
|
accumulatedExperience?: string;
|
||||||
|
adminInstructions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSystemPrompt = (config: SystemPromptConfig): string => `
|
||||||
|
你是 iConsulting 的香港移民咨询顾问,专门为用户提供香港各类移民政策的咨询服务。
|
||||||
|
|
||||||
|
## 身份定位
|
||||||
|
${config.identity || '专业、友善、耐心的移民顾问'}
|
||||||
|
|
||||||
|
## 服务范围
|
||||||
|
你只回答与香港移民相关的问题,包括以下6大类别:
|
||||||
|
|
||||||
|
### 1. 优才计划 (QMAS - Quality Migrant Admission Scheme)
|
||||||
|
- **目的**: 吸纳优秀人才来港定居
|
||||||
|
- **审核标准**: 年龄、教育经历、工作经验、语言能力、年收入证明、良好品格、是否持有公司等
|
||||||
|
- **人才类型**: 行业翘楚、精英人士
|
||||||
|
- **名额**: 暂无名额限制
|
||||||
|
- **申请要点**: 年龄、教育经历、工作经验、年收入证明、良好品格等方面的要求(共12项),满足其中6项为基本门槛,择优批准
|
||||||
|
|
||||||
|
### 2. 专才计划 (GEP - General Employment Policy)
|
||||||
|
- **目的**: 吸引专业人才来港就业
|
||||||
|
- **审核标准**: 雇佣企业的行业背景、行业内的地位、政策扶持力度、所属行业经济地位
|
||||||
|
- **人才类型**: 专业人士(≥200万年薪,属于人才清单)
|
||||||
|
- **名额**: 无配额限制(不限行业)
|
||||||
|
- **申请要点**: 必须有雇主担保、要保持工作连续性、工作和居住在非粤港澳、申请前至少有一年半社保记录
|
||||||
|
|
||||||
|
### 3. 留学IANG (Immigration Arrangements for Non-local Graduates)
|
||||||
|
- **目的**: 吸引优秀学生人才来港续读
|
||||||
|
- **审核标准**: 有无完成学业、是否有雇主担保
|
||||||
|
- **人才类型**: 成绩优异、拥有国际化视野的学生
|
||||||
|
- **名额**: 扩展到本港大学大湾区校区毕业生,为期2年、1年后续签
|
||||||
|
- **申请要点**: 必须有雇主担保、要保持工作连续性
|
||||||
|
|
||||||
|
### 4. 高才通计划 (TTPS - Top Talent Pass Scheme)
|
||||||
|
- **目的**: 吸引高端人才来港
|
||||||
|
- **审核标准**:
|
||||||
|
- A类: 年薪≥250万港币
|
||||||
|
- B类: 毕业于世界前100名大学,5年内有3年以上工作经验
|
||||||
|
- C类: 毕业于世界前100名大学,5年内有少于3年工作经验(年度限额10,000名)
|
||||||
|
- **人才类型**: 顶尖人才
|
||||||
|
- **名额**: 无限期计划
|
||||||
|
|
||||||
|
### 5. 投资移民 (新资本投资者入境计划 CIES)
|
||||||
|
- **目的**: 吸引投资者
|
||||||
|
- **审核标准**: 投资不少于3,000万港币(或等值外币)净资产于其指定对实益拥有者的许可投资
|
||||||
|
- **人才类型**: 投资者
|
||||||
|
- **名额**: 无名额限制
|
||||||
|
|
||||||
|
### 6. 科技人才入境计划 (TechTAS)
|
||||||
|
- **目的**: 吸引科技人才来港就业
|
||||||
|
- **审核标准**: 从事先进通讯技术、人工智能、生物科技、网络安全、数据分析、金融科技等领域研究工作,薪酬不低于香港市场水平
|
||||||
|
- **人才类型**: 科技人才
|
||||||
|
- **名额**: 需用公司高新创新科技营配出有效配额
|
||||||
|
|
||||||
|
## 行为准则
|
||||||
|
1. 如果用户询问与移民无关的问题,礼貌地说明你专注于香港移民咨询,并引导回移民话题
|
||||||
|
2. 对于复杂的评估需求,主动建议使用付费评估服务
|
||||||
|
3. 提供信息时注明信息来源和更新时间(如适用)
|
||||||
|
4. 不做任何法律承诺或申请成功率的保证
|
||||||
|
5. 对于敏感问题(如政治、法律纠纷),建议用户咨询专业律师
|
||||||
|
|
||||||
|
## 付费服务
|
||||||
|
当用户需要个性化移民评估时,介绍付费评估服务:
|
||||||
|
- 先了解用户的基本情况(年龄、学历、工作经验、收入等)
|
||||||
|
- 说明评估服务的内容和价值
|
||||||
|
- 确认用户意愿后,使用工具生成支付码
|
||||||
|
|
||||||
|
## 对话风格
|
||||||
|
${config.conversationStyle || '专业但不生硬,用简洁明了的语言解答'}
|
||||||
|
|
||||||
|
## 工具使用说明
|
||||||
|
你有以下工具可以使用:
|
||||||
|
1. **search_knowledge**: 搜索知识库获取最新的移民政策信息
|
||||||
|
2. **check_off_topic**: 检查用户问题是否与移民相关
|
||||||
|
3. **collect_assessment_info**: 收集用户信息用于评估
|
||||||
|
4. **generate_payment**: 为付费服务生成支付二维码
|
||||||
|
5. **save_user_memory**: 保存用户的重要信息以便后续对话记忆
|
||||||
|
|
||||||
|
## 已积累的经验
|
||||||
|
${config.accumulatedExperience || '暂无'}
|
||||||
|
|
||||||
|
## 管理员特别指示
|
||||||
|
${config.adminInstructions || '暂无'}
|
||||||
|
|
||||||
|
请始终保持专业、热情的态度,帮助用户了解香港移民政策,并在适当时机引导用户使用付费评估服务。
|
||||||
|
`;
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConversationContext } from '../claude-agent.service';
|
||||||
|
|
||||||
|
export interface Tool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
input_schema: {
|
||||||
|
type: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImmigrationToolsService {
|
||||||
|
/**
|
||||||
|
* Get all available tools for the agent
|
||||||
|
*/
|
||||||
|
getTools(): Tool[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'search_knowledge',
|
||||||
|
description: '搜索香港移民相关知识库,获取最新的政策信息和常见问题解答',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: '搜索查询内容',
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
|
||||||
|
description: '移民类别代码(可选)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'check_off_topic',
|
||||||
|
description: '检查用户的问题是否与香港移民相关,用于判断是否需要拒绝回答',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
question: {
|
||||||
|
type: 'string',
|
||||||
|
description: '用户的问题',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['question'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'collect_assessment_info',
|
||||||
|
description: '收集用户的个人信息用于移民资格评估',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
category: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
|
||||||
|
description: '用户感兴趣的移民类别',
|
||||||
|
},
|
||||||
|
age: {
|
||||||
|
type: 'number',
|
||||||
|
description: '用户年龄',
|
||||||
|
},
|
||||||
|
education: {
|
||||||
|
type: 'string',
|
||||||
|
description: '最高学历',
|
||||||
|
},
|
||||||
|
university: {
|
||||||
|
type: 'string',
|
||||||
|
description: '毕业院校',
|
||||||
|
},
|
||||||
|
yearsOfExperience: {
|
||||||
|
type: 'number',
|
||||||
|
description: '工作年限',
|
||||||
|
},
|
||||||
|
currentJobTitle: {
|
||||||
|
type: 'string',
|
||||||
|
description: '当前职位',
|
||||||
|
},
|
||||||
|
industry: {
|
||||||
|
type: 'string',
|
||||||
|
description: '所属行业',
|
||||||
|
},
|
||||||
|
annualIncome: {
|
||||||
|
type: 'number',
|
||||||
|
description: '年收入(人民币)',
|
||||||
|
},
|
||||||
|
hasHKEmployer: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: '是否有香港雇主',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['category'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'generate_payment',
|
||||||
|
description: '为付费评估服务生成支付二维码',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
serviceType: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['ASSESSMENT'],
|
||||||
|
description: '服务类型',
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
|
||||||
|
description: '移民类别',
|
||||||
|
},
|
||||||
|
paymentMethod: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['ALIPAY', 'WECHAT', 'CREDIT_CARD'],
|
||||||
|
description: '支付方式',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['serviceType', 'category', 'paymentMethod'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'save_user_memory',
|
||||||
|
description: '保存用户的重要信息到长期记忆,以便后续对话中记住用户情况',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
memoryType: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['FACT', 'PREFERENCE', 'INTENT'],
|
||||||
|
description: '记忆类型:FACT-用户陈述的事实,PREFERENCE-用户偏好,INTENT-用户意图',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: 'string',
|
||||||
|
description: '要记住的内容',
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TECHTAS'],
|
||||||
|
description: '相关的移民类别(可选)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['memoryType', 'content'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a tool and return the result
|
||||||
|
*/
|
||||||
|
async executeTool(
|
||||||
|
toolName: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
switch (toolName) {
|
||||||
|
case 'search_knowledge':
|
||||||
|
return this.searchKnowledge(input);
|
||||||
|
|
||||||
|
case 'check_off_topic':
|
||||||
|
return this.checkOffTopic(input);
|
||||||
|
|
||||||
|
case 'collect_assessment_info':
|
||||||
|
return this.collectAssessmentInfo(input, context);
|
||||||
|
|
||||||
|
case 'generate_payment':
|
||||||
|
return this.generatePayment(input, context);
|
||||||
|
|
||||||
|
case 'save_user_memory':
|
||||||
|
return this.saveUserMemory(input, context);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { error: `Unknown tool: ${toolName}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search knowledge base
|
||||||
|
*/
|
||||||
|
private async searchKnowledge(input: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const { query, category } = input as { query: string; category?: string };
|
||||||
|
|
||||||
|
// TODO: Implement actual RAG search via Knowledge Service
|
||||||
|
// For now, return a placeholder response
|
||||||
|
console.log(`[Knowledge Search] Query: ${query}, Category: ${category || 'all'}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
content: '这里将返回从知识库检索到的相关信息',
|
||||||
|
source: '香港入境事务处官网',
|
||||||
|
relevance: 0.95,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: '知识库搜索功能即将上线,目前请基于内置知识回答',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if question is off-topic
|
||||||
|
*/
|
||||||
|
private async checkOffTopic(input: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const { question } = input as { question: string };
|
||||||
|
|
||||||
|
// Simple keyword-based check
|
||||||
|
const immigrationKeywords = [
|
||||||
|
'移民', '签证', '香港', '优才', '专才', '高才通', '投资', '留学',
|
||||||
|
'IANG', 'QMAS', 'GEP', 'TTPS', '入境', '定居', '永居', '身份',
|
||||||
|
'申请', '条件', '资格', '评估', '审核', '批准', '拒签',
|
||||||
|
];
|
||||||
|
|
||||||
|
const isRelated = immigrationKeywords.some((keyword) =>
|
||||||
|
question.toLowerCase().includes(keyword.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOffTopic: !isRelated,
|
||||||
|
confidence: isRelated ? 0.9 : 0.7,
|
||||||
|
suggestion: isRelated
|
||||||
|
? null
|
||||||
|
: '这个问题似乎与香港移民无关。作为移民咨询顾问,我专注于香港各类移民政策的咨询。请问您有香港移民相关的问题吗?',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect assessment info
|
||||||
|
*/
|
||||||
|
private async collectAssessmentInfo(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const info = input as {
|
||||||
|
category: string;
|
||||||
|
age?: number;
|
||||||
|
education?: string;
|
||||||
|
university?: string;
|
||||||
|
yearsOfExperience?: number;
|
||||||
|
currentJobTitle?: string;
|
||||||
|
industry?: string;
|
||||||
|
annualIncome?: number;
|
||||||
|
hasHKEmployer?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[Assessment Info] User ${context.userId} - Category: ${info.category}`);
|
||||||
|
|
||||||
|
// Store the collected info for later use
|
||||||
|
// TODO: Save to database via User Service
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
collectedInfo: info,
|
||||||
|
message: '已记录您的信息。如需完整评估,请选择付费评估服务。',
|
||||||
|
nextStep: 'payment',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate payment QR code
|
||||||
|
*/
|
||||||
|
private async generatePayment(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const { serviceType, category, paymentMethod } = input as {
|
||||||
|
serviceType: string;
|
||||||
|
category: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Payment] User ${context.userId} - ${serviceType} for ${category} via ${paymentMethod}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Call Payment Service to generate actual payment
|
||||||
|
// For now, return a placeholder
|
||||||
|
|
||||||
|
const priceMap: Record<string, number> = {
|
||||||
|
QMAS: 99,
|
||||||
|
GEP: 99,
|
||||||
|
IANG: 79,
|
||||||
|
TTPS: 99,
|
||||||
|
CIES: 199,
|
||||||
|
TECHTAS: 99,
|
||||||
|
};
|
||||||
|
|
||||||
|
const price = priceMap[category] || 99;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
orderId: `ORD_${Date.now()}`,
|
||||||
|
amount: price,
|
||||||
|
currency: 'CNY',
|
||||||
|
paymentMethod,
|
||||||
|
qrCodeUrl: `https://placeholder-payment-qr.com/${paymentMethod.toLowerCase()}/${Date.now()}`,
|
||||||
|
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 minutes
|
||||||
|
message: `请扫描二维码支付 ¥${price} 完成${category}类别的移民资格评估服务`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save user memory
|
||||||
|
*/
|
||||||
|
private async saveUserMemory(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
context: ConversationContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const { memoryType, content, category } = input as {
|
||||||
|
memoryType: string;
|
||||||
|
content: string;
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Memory] User ${context.userId} - Type: ${memoryType}, Content: ${content}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Save to Neo4j via Knowledge Service
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
memoryId: `MEM_${Date.now()}`,
|
||||||
|
message: '已记住您的信息',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Global validation pipe
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'],
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// API prefix
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
const port = configService.get<number>('CONVERSATION_SERVICE_PORT') || 3002;
|
||||||
|
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Conversation Service is running on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# ===========================================
|
||||||
|
# iConsulting Evolution Service Dockerfile
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
COPY packages/services/evolution-service/package.json ./packages/services/evolution-service/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
COPY packages/services/evolution-service ./packages/services/evolution-service
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
|
||||||
|
RUN pnpm --filter @iconsulting/shared build
|
||||||
|
RUN pnpm --filter @iconsulting/evolution-service build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nestjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/packages/services/evolution-service/dist ./dist
|
||||||
|
COPY --from=builder /app/packages/services/evolution-service/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3005
|
||||||
|
|
||||||
|
USER nestjs
|
||||||
|
EXPOSE 3005
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3005/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"name": "@iconsulting/evolution-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "进化服务 - 系统自我学习与进化引擎",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.52.0",
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.2.0",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/typeorm": "^10.0.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.19",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/schematics": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
UnauthorizedException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AdminService, AdminRole, LoginResult } from './admin.service';
|
||||||
|
|
||||||
|
// ========== DTOs ==========
|
||||||
|
|
||||||
|
class LoginDto {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreateAdminDto {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
role: AdminRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateAdminDto {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: AdminRole;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChangePasswordDto {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResetPasswordDto {
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Controller ==========
|
||||||
|
|
||||||
|
@Controller('admin')
|
||||||
|
export class AdminController {
|
||||||
|
constructor(private adminService: AdminService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录
|
||||||
|
*/
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async login(@Body() dto: LoginDto): Promise<{ success: boolean; data: LoginResult }> {
|
||||||
|
try {
|
||||||
|
const result = await this.adminService.login(dto.username, dto.password);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new UnauthorizedException((error as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Token
|
||||||
|
*/
|
||||||
|
@Get('verify')
|
||||||
|
async verifyToken(@Headers('authorization') auth: string) {
|
||||||
|
const token = auth?.replace('Bearer ', '');
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Missing token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.adminService.verifyToken(token);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.admin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前管理员信息
|
||||||
|
*/
|
||||||
|
@Get('me')
|
||||||
|
async getCurrentAdmin(@Headers('authorization') auth: string) {
|
||||||
|
const token = auth?.replace('Bearer ', '');
|
||||||
|
const result = await this.adminService.verifyToken(token);
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.admin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建管理员(需要ADMIN权限)
|
||||||
|
*/
|
||||||
|
@Post('admins')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async createAdmin(
|
||||||
|
@Headers('authorization') auth: string,
|
||||||
|
@Body() dto: CreateAdminDto,
|
||||||
|
) {
|
||||||
|
await this.checkPermission(auth, 'admin:create');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = await this.adminService.createAdmin(dto);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
name: admin.name,
|
||||||
|
role: admin.role,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取管理员列表
|
||||||
|
*/
|
||||||
|
@Get('admins')
|
||||||
|
async listAdmins(
|
||||||
|
@Headers('authorization') auth: string,
|
||||||
|
@Query('role') role?: AdminRole,
|
||||||
|
@Query('isActive') isActive?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
await this.checkPermission(auth, 'admin:read');
|
||||||
|
|
||||||
|
const result = await this.adminService.listAdmins({
|
||||||
|
role,
|
||||||
|
isActive: isActive === undefined ? undefined : isActive === 'true',
|
||||||
|
page: page ? parseInt(page) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: result.items.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
username: a.username,
|
||||||
|
name: a.name,
|
||||||
|
email: a.email,
|
||||||
|
phone: a.phone,
|
||||||
|
role: a.role,
|
||||||
|
isActive: a.isActive,
|
||||||
|
lastLoginAt: a.lastLoginAt,
|
||||||
|
createdAt: a.createdAt,
|
||||||
|
})),
|
||||||
|
total: result.total,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新管理员
|
||||||
|
*/
|
||||||
|
@Put('admins/:id')
|
||||||
|
async updateAdmin(
|
||||||
|
@Headers('authorization') auth: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateAdminDto,
|
||||||
|
) {
|
||||||
|
await this.checkPermission(auth, 'admin:update');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const admin = await this.adminService.updateAdmin(id, dto);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
name: admin.name,
|
||||||
|
role: admin.role,
|
||||||
|
isActive: admin.isActive,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码(当前用户)
|
||||||
|
*/
|
||||||
|
@Post('change-password')
|
||||||
|
async changePassword(
|
||||||
|
@Headers('authorization') auth: string,
|
||||||
|
@Body() dto: ChangePasswordDto,
|
||||||
|
) {
|
||||||
|
const token = auth?.replace('Bearer ', '');
|
||||||
|
const result = await this.adminService.verifyToken(token);
|
||||||
|
|
||||||
|
if (!result.valid || !result.admin) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.adminService.changePassword(
|
||||||
|
result.admin.id,
|
||||||
|
dto.oldPassword,
|
||||||
|
dto.newPassword,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '密码修改成功',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码(超管功能)
|
||||||
|
*/
|
||||||
|
@Post('admins/:id/reset-password')
|
||||||
|
async resetPassword(
|
||||||
|
@Headers('authorization') auth: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: ResetPasswordDto,
|
||||||
|
) {
|
||||||
|
// 只有超管可以重置密码
|
||||||
|
const token = auth?.replace('Bearer ', '');
|
||||||
|
const result = await this.adminService.verifyToken(token);
|
||||||
|
|
||||||
|
if (!result.valid || !result.admin) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.admin.role !== AdminRole.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Only super admin can reset passwords');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.adminService.resetPassword(id, dto.newPassword);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '密码重置成功',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查权限
|
||||||
|
*/
|
||||||
|
private async checkPermission(auth: string, permission: string): Promise<void> {
|
||||||
|
const token = auth?.replace('Bearer ', '');
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException('Missing token');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.adminService.verifyToken(token);
|
||||||
|
if (!result.valid || !result.admin) {
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.adminService.hasPermission(result.admin.permissions, permission)) {
|
||||||
|
throw new ForbiddenException(`Permission denied: ${permission}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { AdminController } from './admin.controller';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
import { AdminORM } from '../infrastructure/database/entities/admin.orm';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([AdminORM]),
|
||||||
|
],
|
||||||
|
controllers: [AdminController],
|
||||||
|
providers: [AdminService],
|
||||||
|
exports: [AdminService],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AdminORM } from '../infrastructure/database/entities/admin.orm';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员角色
|
||||||
|
*/
|
||||||
|
export enum AdminRole {
|
||||||
|
SUPER_ADMIN = 'SUPER_ADMIN',
|
||||||
|
ADMIN = 'ADMIN',
|
||||||
|
OPERATOR = 'OPERATOR',
|
||||||
|
VIEWER = 'VIEWER',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色权限映射
|
||||||
|
*/
|
||||||
|
const ROLE_PERMISSIONS: Record<AdminRole, string[]> = {
|
||||||
|
[AdminRole.SUPER_ADMIN]: ['*'],
|
||||||
|
[AdminRole.ADMIN]: [
|
||||||
|
'knowledge:*',
|
||||||
|
'experience:*',
|
||||||
|
'user:read',
|
||||||
|
'conversation:read',
|
||||||
|
'statistics:*',
|
||||||
|
'admin:read',
|
||||||
|
],
|
||||||
|
[AdminRole.OPERATOR]: [
|
||||||
|
'knowledge:read',
|
||||||
|
'knowledge:create',
|
||||||
|
'knowledge:update',
|
||||||
|
'experience:read',
|
||||||
|
'experience:approve',
|
||||||
|
'user:read',
|
||||||
|
'conversation:read',
|
||||||
|
'statistics:read',
|
||||||
|
],
|
||||||
|
[AdminRole.VIEWER]: [
|
||||||
|
'knowledge:read',
|
||||||
|
'experience:read',
|
||||||
|
'user:read',
|
||||||
|
'conversation:read',
|
||||||
|
'statistics:read',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录结果
|
||||||
|
*/
|
||||||
|
export interface LoginResult {
|
||||||
|
admin: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
token: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员服务
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
private readonly jwtSecret: string;
|
||||||
|
private readonly jwtExpiresIn: number = 24 * 60 * 60; // 24小时
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AdminORM)
|
||||||
|
private adminRepo: Repository<AdminORM>,
|
||||||
|
private configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.jwtSecret = this.configService.get('JWT_SECRET') || 'iconsulting-secret-key';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员登录
|
||||||
|
*/
|
||||||
|
async login(username: string, password: string, ip?: string): Promise<LoginResult> {
|
||||||
|
const admin = await this.adminRepo.findOne({ where: { username } });
|
||||||
|
|
||||||
|
if (!admin || !admin.isActive) {
|
||||||
|
throw new Error('用户名或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, admin.passwordHash);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new Error('用户名或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新登录信息
|
||||||
|
admin.lastLoginAt = new Date();
|
||||||
|
admin.lastLoginIp = ip;
|
||||||
|
await this.adminRepo.save(admin);
|
||||||
|
|
||||||
|
// 生成Token
|
||||||
|
const token = jwt.sign(
|
||||||
|
{
|
||||||
|
sub: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
role: admin.role,
|
||||||
|
},
|
||||||
|
this.jwtSecret,
|
||||||
|
{ expiresIn: this.jwtExpiresIn },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取权限
|
||||||
|
const permissions = this.getPermissions(admin.role as AdminRole, admin.permissions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
name: admin.name,
|
||||||
|
role: admin.role,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
expiresIn: this.jwtExpiresIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证Token
|
||||||
|
*/
|
||||||
|
async verifyToken(token: string): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
admin?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
permissions: string[];
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, this.jwtSecret) as {
|
||||||
|
sub: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const admin = await this.adminRepo.findOne({ where: { id: decoded.sub } });
|
||||||
|
if (!admin || !admin.isActive) {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = this.getPermissions(admin.role as AdminRole, admin.permissions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
role: admin.role,
|
||||||
|
permissions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { valid: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建管理员
|
||||||
|
*/
|
||||||
|
async createAdmin(params: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
role: AdminRole;
|
||||||
|
}): Promise<AdminORM> {
|
||||||
|
// 检查用户名是否存在
|
||||||
|
const existing = await this.adminRepo.findOne({
|
||||||
|
where: { username: params.username },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('用户名已存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
const passwordHash = await bcrypt.hash(params.password, 10);
|
||||||
|
|
||||||
|
const admin = this.adminRepo.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
username: params.username,
|
||||||
|
passwordHash,
|
||||||
|
name: params.name,
|
||||||
|
email: params.email,
|
||||||
|
phone: params.phone,
|
||||||
|
role: params.role,
|
||||||
|
permissions: ROLE_PERMISSIONS[params.role],
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.adminRepo.save(admin);
|
||||||
|
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取管理员列表
|
||||||
|
*/
|
||||||
|
async listAdmins(options?: {
|
||||||
|
role?: AdminRole;
|
||||||
|
isActive?: boolean;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{
|
||||||
|
items: AdminORM[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const page = options?.page || 1;
|
||||||
|
const pageSize = options?.pageSize || 20;
|
||||||
|
|
||||||
|
const query = this.adminRepo.createQueryBuilder('admin');
|
||||||
|
|
||||||
|
if (options?.role) {
|
||||||
|
query.andWhere('admin.role = :role', { role: options.role });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.isActive !== undefined) {
|
||||||
|
query.andWhere('admin.isActive = :active', { active: options.isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('admin.createdAt', 'DESC');
|
||||||
|
|
||||||
|
const [items, total] = await query
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新管理员
|
||||||
|
*/
|
||||||
|
async updateAdmin(
|
||||||
|
adminId: string,
|
||||||
|
params: {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
role?: AdminRole;
|
||||||
|
isActive?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<AdminORM> {
|
||||||
|
const admin = await this.adminRepo.findOne({ where: { id: adminId } });
|
||||||
|
if (!admin) {
|
||||||
|
throw new Error('管理员不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.name) admin.name = params.name;
|
||||||
|
if (params.email !== undefined) admin.email = params.email;
|
||||||
|
if (params.phone !== undefined) admin.phone = params.phone;
|
||||||
|
if (params.role) {
|
||||||
|
admin.role = params.role;
|
||||||
|
admin.permissions = ROLE_PERMISSIONS[params.role];
|
||||||
|
}
|
||||||
|
if (params.isActive !== undefined) admin.isActive = params.isActive;
|
||||||
|
|
||||||
|
await this.adminRepo.save(admin);
|
||||||
|
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改密码
|
||||||
|
*/
|
||||||
|
async changePassword(
|
||||||
|
adminId: string,
|
||||||
|
oldPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const admin = await this.adminRepo.findOne({ where: { id: adminId } });
|
||||||
|
if (!admin) {
|
||||||
|
throw new Error('管理员不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOldPasswordValid = await bcrypt.compare(oldPassword, admin.passwordHash);
|
||||||
|
if (!isOldPasswordValid) {
|
||||||
|
throw new Error('原密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
admin.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await this.adminRepo.save(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码(超管功能)
|
||||||
|
*/
|
||||||
|
async resetPassword(adminId: string, newPassword: string): Promise<void> {
|
||||||
|
const admin = await this.adminRepo.findOne({ where: { id: adminId } });
|
||||||
|
if (!admin) {
|
||||||
|
throw new Error('管理员不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
admin.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await this.adminRepo.save(admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查权限
|
||||||
|
*/
|
||||||
|
hasPermission(adminPermissions: string[], requiredPermission: string): boolean {
|
||||||
|
// 超管拥有所有权限
|
||||||
|
if (adminPermissions.includes('*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完全匹配
|
||||||
|
if (adminPermissions.includes(requiredPermission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通配符匹配 (如 knowledge:* 匹配 knowledge:read)
|
||||||
|
const [resource, action] = requiredPermission.split(':');
|
||||||
|
if (adminPermissions.includes(`${resource}:*`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取管理员权限列表
|
||||||
|
*/
|
||||||
|
private getPermissions(role: AdminRole, customPermissions?: string[]): string[] {
|
||||||
|
const rolePermissions = ROLE_PERMISSIONS[role] || [];
|
||||||
|
if (customPermissions && customPermissions.length > 0) {
|
||||||
|
return [...new Set([...rolePermissions, ...customPermissions])];
|
||||||
|
}
|
||||||
|
return rolePermissions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { EvolutionModule } from './evolution/evolution.module';
|
||||||
|
import { AdminModule } from './admin/admin.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// 配置模块
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: ['.env.local', '.env'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 数据库连接
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: config.get('DB_HOST', 'localhost'),
|
||||||
|
port: config.get('DB_PORT', 5432),
|
||||||
|
username: config.get('DB_USER', 'iconsulting'),
|
||||||
|
password: config.get('DB_PASSWORD', 'iconsulting_dev'),
|
||||||
|
database: config.get('DB_NAME', 'iconsulting'),
|
||||||
|
autoLoadEntities: true,
|
||||||
|
synchronize: config.get('NODE_ENV') !== 'production',
|
||||||
|
logging: config.get('NODE_ENV') === 'development',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 功能模块
|
||||||
|
EvolutionModule,
|
||||||
|
AdminModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { EvolutionService } from './evolution.service';
|
||||||
|
|
||||||
|
// ========== DTOs ==========
|
||||||
|
|
||||||
|
class RunEvolutionTaskDto {
|
||||||
|
hoursBack?: number;
|
||||||
|
limit?: number;
|
||||||
|
minMessageCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Controller ==========
|
||||||
|
|
||||||
|
@Controller('evolution')
|
||||||
|
export class EvolutionController {
|
||||||
|
constructor(private evolutionService: EvolutionService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发进化任务
|
||||||
|
*/
|
||||||
|
@Post('run')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async runEvolutionTask(@Body() dto: RunEvolutionTaskDto) {
|
||||||
|
const result = await this.evolutionService.runEvolutionTask(dto);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取进化统计信息
|
||||||
|
*/
|
||||||
|
@Get('statistics')
|
||||||
|
async getStatistics() {
|
||||||
|
const stats = await this.evolutionService.getEvolutionStatistics();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统健康报告
|
||||||
|
*/
|
||||||
|
@Get('health')
|
||||||
|
async getHealthReport() {
|
||||||
|
const report = await this.evolutionService.getSystemHealthReport();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: report,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { EvolutionController } from './evolution.controller';
|
||||||
|
import { EvolutionService } from './evolution.service';
|
||||||
|
import { ExperienceExtractorService } from '../infrastructure/claude/experience-extractor.service';
|
||||||
|
import { ConversationORM } from '../infrastructure/database/entities/conversation.orm';
|
||||||
|
import { MessageORM } from '../infrastructure/database/entities/message.orm';
|
||||||
|
import { SystemExperienceORM } from '../infrastructure/database/entities/system-experience.orm';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
ConversationORM,
|
||||||
|
MessageORM,
|
||||||
|
SystemExperienceORM,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [EvolutionController],
|
||||||
|
providers: [
|
||||||
|
EvolutionService,
|
||||||
|
ExperienceExtractorService,
|
||||||
|
],
|
||||||
|
exports: [EvolutionService],
|
||||||
|
})
|
||||||
|
export class EvolutionModule {}
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, MoreThan, LessThan } from 'typeorm';
|
||||||
|
import { ExperienceExtractorService } from '../infrastructure/claude/experience-extractor.service';
|
||||||
|
import { ConversationORM } from '../infrastructure/database/entities/conversation.orm';
|
||||||
|
import { MessageORM } from '../infrastructure/database/entities/message.orm';
|
||||||
|
import { SystemExperienceORM } from '../infrastructure/database/entities/system-experience.orm';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进化任务结果
|
||||||
|
*/
|
||||||
|
export interface EvolutionTaskResult {
|
||||||
|
taskId: string;
|
||||||
|
status: 'success' | 'partial' | 'failed';
|
||||||
|
conversationsAnalyzed: number;
|
||||||
|
experiencesExtracted: number;
|
||||||
|
knowledgeGapsFound: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进化服务
|
||||||
|
* 负责系统的自我学习和进化
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EvolutionService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ConversationORM)
|
||||||
|
private conversationRepo: Repository<ConversationORM>,
|
||||||
|
@InjectRepository(MessageORM)
|
||||||
|
private messageRepo: Repository<MessageORM>,
|
||||||
|
@InjectRepository(SystemExperienceORM)
|
||||||
|
private experienceRepo: Repository<SystemExperienceORM>,
|
||||||
|
private experienceExtractor: ExperienceExtractorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行进化任务 - 分析最近的对话并提取经验
|
||||||
|
*/
|
||||||
|
async runEvolutionTask(options?: {
|
||||||
|
hoursBack?: number;
|
||||||
|
limit?: number;
|
||||||
|
minMessageCount?: number;
|
||||||
|
}): Promise<EvolutionTaskResult> {
|
||||||
|
const taskId = uuidv4();
|
||||||
|
const hoursBack = options?.hoursBack || 24;
|
||||||
|
const limit = options?.limit || 50;
|
||||||
|
const minMessageCount = options?.minMessageCount || 4;
|
||||||
|
|
||||||
|
const result: EvolutionTaskResult = {
|
||||||
|
taskId,
|
||||||
|
status: 'success',
|
||||||
|
conversationsAnalyzed: 0,
|
||||||
|
experiencesExtracted: 0,
|
||||||
|
knowledgeGapsFound: 0,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[Evolution] Starting task ${taskId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取待分析的对话
|
||||||
|
const cutoffTime = new Date();
|
||||||
|
cutoffTime.setHours(cutoffTime.getHours() - hoursBack);
|
||||||
|
|
||||||
|
const conversations = await this.conversationRepo.find({
|
||||||
|
where: {
|
||||||
|
status: 'ENDED',
|
||||||
|
createdAt: MoreThan(cutoffTime),
|
||||||
|
messageCount: MoreThan(minMessageCount),
|
||||||
|
},
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Evolution] Found ${conversations.length} conversations to analyze`);
|
||||||
|
|
||||||
|
// 2. 分析每个对话
|
||||||
|
const allKnowledgeGaps: string[] = [];
|
||||||
|
|
||||||
|
for (const conversation of conversations) {
|
||||||
|
try {
|
||||||
|
// 获取对话消息
|
||||||
|
const messages = await this.messageRepo.find({
|
||||||
|
where: { conversationId: conversation.id },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 分析对话
|
||||||
|
const analysis = await this.experienceExtractor.analyzeConversation({
|
||||||
|
conversationId: conversation.id,
|
||||||
|
messages: messages.map(m => ({
|
||||||
|
role: m.role as 'user' | 'assistant',
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
category: conversation.category,
|
||||||
|
hasConverted: conversation.hasConverted,
|
||||||
|
rating: conversation.rating,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存提取的经验
|
||||||
|
for (const exp of analysis.experiences) {
|
||||||
|
await this.saveExperience({
|
||||||
|
experienceType: exp.type,
|
||||||
|
content: exp.content,
|
||||||
|
scenario: exp.scenario,
|
||||||
|
confidence: exp.confidence,
|
||||||
|
relatedCategory: exp.relatedCategory,
|
||||||
|
sourceConversationId: conversation.id,
|
||||||
|
});
|
||||||
|
result.experiencesExtracted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集知识缺口
|
||||||
|
allKnowledgeGaps.push(...analysis.knowledgeGaps);
|
||||||
|
|
||||||
|
result.conversationsAnalyzed++;
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Conversation ${conversation.id}: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 汇总知识缺口
|
||||||
|
const uniqueGaps = [...new Set(allKnowledgeGaps)];
|
||||||
|
result.knowledgeGapsFound = uniqueGaps.length;
|
||||||
|
|
||||||
|
// 可以在这里调用知识服务创建待处理的知识缺口任务
|
||||||
|
|
||||||
|
console.log(`[Evolution] Task ${taskId} completed:`, result);
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
result.status = 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Evolution] Task ${taskId} failed:`, error);
|
||||||
|
result.status = 'failed';
|
||||||
|
result.errors.push((error as Error).message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存经验(带去重逻辑)
|
||||||
|
*/
|
||||||
|
private async saveExperience(params: {
|
||||||
|
experienceType: string;
|
||||||
|
content: string;
|
||||||
|
scenario: string;
|
||||||
|
confidence: number;
|
||||||
|
relatedCategory?: string;
|
||||||
|
sourceConversationId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
// 查找相似经验
|
||||||
|
const existingExperiences = await this.experienceRepo.find({
|
||||||
|
where: {
|
||||||
|
experienceType: params.experienceType,
|
||||||
|
relatedCategory: params.relatedCategory,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 简单的相似度检查(实际应该用向量相似度)
|
||||||
|
const similar = existingExperiences.find(
|
||||||
|
exp => this.simpleSimilarity(exp.content, params.content) > 0.8,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (similar) {
|
||||||
|
// 合并到现有经验
|
||||||
|
if (!similar.sourceConversationIds.includes(params.sourceConversationId)) {
|
||||||
|
similar.sourceConversationIds.push(params.sourceConversationId);
|
||||||
|
similar.confidence = Math.min(100, similar.confidence + 5);
|
||||||
|
await this.experienceRepo.save(similar);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建新经验
|
||||||
|
const newExperience = this.experienceRepo.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
experienceType: params.experienceType,
|
||||||
|
content: params.content,
|
||||||
|
scenario: params.scenario,
|
||||||
|
confidence: params.confidence,
|
||||||
|
relatedCategory: params.relatedCategory,
|
||||||
|
sourceConversationIds: [params.sourceConversationId],
|
||||||
|
verificationStatus: 'PENDING',
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
await this.experienceRepo.save(newExperience);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简单的文本相似度计算
|
||||||
|
*/
|
||||||
|
private simpleSimilarity(a: string, b: string): number {
|
||||||
|
const aWords = new Set(a.toLowerCase().split(/\s+/));
|
||||||
|
const bWords = new Set(b.toLowerCase().split(/\s+/));
|
||||||
|
const intersection = [...aWords].filter(x => bWords.has(x)).length;
|
||||||
|
const union = new Set([...aWords, ...bWords]).size;
|
||||||
|
return intersection / union;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取进化统计信息
|
||||||
|
*/
|
||||||
|
async getEvolutionStatistics(): Promise<{
|
||||||
|
totalExperiences: number;
|
||||||
|
pendingExperiences: number;
|
||||||
|
approvedExperiences: number;
|
||||||
|
activeExperiences: number;
|
||||||
|
recentConversationsAnalyzed: number;
|
||||||
|
topExperienceTypes: Array<{ type: string; count: number }>;
|
||||||
|
}> {
|
||||||
|
const [total, pending, approved, active] = await Promise.all([
|
||||||
|
this.experienceRepo.count(),
|
||||||
|
this.experienceRepo.count({ where: { verificationStatus: 'PENDING' } }),
|
||||||
|
this.experienceRepo.count({ where: { verificationStatus: 'APPROVED' } }),
|
||||||
|
this.experienceRepo.count({ where: { isActive: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 获取最近分析的对话数(过去7天)
|
||||||
|
const weekAgo = new Date();
|
||||||
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
|
const recentConversations = await this.conversationRepo.count({
|
||||||
|
where: {
|
||||||
|
status: 'ENDED',
|
||||||
|
updatedAt: MoreThan(weekAgo),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取经验类型分布
|
||||||
|
const typeDistribution = await this.experienceRepo
|
||||||
|
.createQueryBuilder('exp')
|
||||||
|
.select('exp.experienceType', 'type')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.groupBy('exp.experienceType')
|
||||||
|
.orderBy('count', 'DESC')
|
||||||
|
.limit(5)
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalExperiences: total,
|
||||||
|
pendingExperiences: pending,
|
||||||
|
approvedExperiences: approved,
|
||||||
|
activeExperiences: active,
|
||||||
|
recentConversationsAnalyzed: recentConversations,
|
||||||
|
topExperienceTypes: typeDistribution.map(t => ({
|
||||||
|
type: t.type,
|
||||||
|
count: parseInt(t.count),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统健康报告
|
||||||
|
*/
|
||||||
|
async getSystemHealthReport(): Promise<{
|
||||||
|
overall: 'healthy' | 'warning' | 'critical';
|
||||||
|
metrics: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
threshold: number;
|
||||||
|
status: 'good' | 'warning' | 'critical';
|
||||||
|
}>;
|
||||||
|
recommendations: string[];
|
||||||
|
}> {
|
||||||
|
const stats = await this.getEvolutionStatistics();
|
||||||
|
const metrics: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
threshold: number;
|
||||||
|
status: 'good' | 'warning' | 'critical';
|
||||||
|
}> = [];
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
|
||||||
|
// 检查待验证经验堆积
|
||||||
|
const pendingRatio = stats.pendingExperiences / Math.max(1, stats.totalExperiences);
|
||||||
|
metrics.push({
|
||||||
|
name: '待验证经验比例',
|
||||||
|
value: Math.round(pendingRatio * 100),
|
||||||
|
threshold: 50,
|
||||||
|
status: pendingRatio > 0.5 ? 'warning' : 'good',
|
||||||
|
});
|
||||||
|
if (pendingRatio > 0.5) {
|
||||||
|
recommendations.push('待验证经验过多,建议及时审核');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查活跃经验数量
|
||||||
|
metrics.push({
|
||||||
|
name: '活跃经验数量',
|
||||||
|
value: stats.activeExperiences,
|
||||||
|
threshold: 10,
|
||||||
|
status: stats.activeExperiences < 10 ? 'warning' : 'good',
|
||||||
|
});
|
||||||
|
if (stats.activeExperiences < 10) {
|
||||||
|
recommendations.push('活跃经验较少,系统学习能力有限');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查最近分析的对话
|
||||||
|
metrics.push({
|
||||||
|
name: '近7天分析对话数',
|
||||||
|
value: stats.recentConversationsAnalyzed,
|
||||||
|
threshold: 50,
|
||||||
|
status: stats.recentConversationsAnalyzed < 50 ? 'warning' : 'good',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算总体健康状态
|
||||||
|
const criticalCount = metrics.filter(m => m.status === 'critical').length;
|
||||||
|
const warningCount = metrics.filter(m => m.status === 'warning').length;
|
||||||
|
|
||||||
|
let overall: 'healthy' | 'warning' | 'critical' = 'healthy';
|
||||||
|
if (criticalCount > 0) {
|
||||||
|
overall = 'critical';
|
||||||
|
} else if (warningCount > 1) {
|
||||||
|
overall = 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { overall, metrics, recommendations };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Anthropic from '@anthropic-ai/sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取的经验结构
|
||||||
|
*/
|
||||||
|
export interface ExtractedExperience {
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
scenario: string;
|
||||||
|
confidence: number;
|
||||||
|
relatedCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对话分析结果
|
||||||
|
*/
|
||||||
|
export interface ConversationAnalysis {
|
||||||
|
experiences: ExtractedExperience[];
|
||||||
|
userInsights: UserInsight[];
|
||||||
|
knowledgeGaps: string[];
|
||||||
|
conversionSignals: ConversionSignal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInsight {
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
importance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionSignal {
|
||||||
|
type: 'positive' | 'negative';
|
||||||
|
signal: string;
|
||||||
|
context: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 经验提取服务
|
||||||
|
* 使用Claude分析对话,提取有价值的系统经验
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ExperienceExtractorService implements OnModuleInit {
|
||||||
|
private client: Anthropic;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const apiKey = this.configService.get<string>('ANTHROPIC_API_KEY');
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn('[ExperienceExtractor] ANTHROPIC_API_KEY not set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new Anthropic({ apiKey });
|
||||||
|
console.log('[ExperienceExtractor] Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析对话并提取经验
|
||||||
|
*/
|
||||||
|
async analyzeConversation(params: {
|
||||||
|
conversationId: string;
|
||||||
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||||
|
category?: string;
|
||||||
|
hasConverted: boolean;
|
||||||
|
rating?: number;
|
||||||
|
}): Promise<ConversationAnalysis> {
|
||||||
|
if (!this.client) {
|
||||||
|
return this.getMockAnalysis();
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = this.buildAnalysisPrompt(params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 2000,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.content[0];
|
||||||
|
if (content.type !== 'text') {
|
||||||
|
return this.getMockAnalysis();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.parseAnalysisResult(content.text);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ExperienceExtractor] Analysis failed:', error);
|
||||||
|
return this.getMockAnalysis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建分析提示词
|
||||||
|
*/
|
||||||
|
private buildAnalysisPrompt(params: {
|
||||||
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||||
|
category?: string;
|
||||||
|
hasConverted: boolean;
|
||||||
|
rating?: number;
|
||||||
|
}): string {
|
||||||
|
const conversationText = params.messages
|
||||||
|
.map(m => `${m.role === 'user' ? '用户' : '助手'}: ${m.content}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
return `你是一个专门分析香港移民咨询对话的AI专家。请分析以下对话,提取有价值的系统经验。
|
||||||
|
|
||||||
|
## 对话背景
|
||||||
|
- 移民类别: ${params.category || '未知'}
|
||||||
|
- 是否转化付费: ${params.hasConverted ? '是' : '否'}
|
||||||
|
- 用户评分: ${params.rating || '未评分'}
|
||||||
|
|
||||||
|
## 对话内容
|
||||||
|
${conversationText}
|
||||||
|
|
||||||
|
## 分析任务
|
||||||
|
请提取以下信息,以JSON格式返回:
|
||||||
|
|
||||||
|
1. **experiences** (系统经验数组):
|
||||||
|
- type: 经验类型 (COMMON_QUESTION/ANSWER_TEMPLATE/CLARIFICATION/USER_PATTERN/CONVERSION_TRIGGER/KNOWLEDGE_GAP/CONVERSATION_SKILL/OBJECTION_HANDLING)
|
||||||
|
- content: 经验内容
|
||||||
|
- scenario: 适用场景
|
||||||
|
- confidence: 置信度 (0-100)
|
||||||
|
- relatedCategory: 相关移民类别
|
||||||
|
|
||||||
|
2. **userInsights** (用户洞察数组):
|
||||||
|
- type: 洞察类型 (PERSONAL_INFO/WORK_EXPERIENCE/EDUCATION/LANGUAGE/IMMIGRATION_INTENT/CONCERN)
|
||||||
|
- content: 洞察内容
|
||||||
|
- importance: 重要性 (0-100)
|
||||||
|
|
||||||
|
3. **knowledgeGaps** (知识缺口数组):
|
||||||
|
- 对话中暴露出的知识库缺失内容
|
||||||
|
|
||||||
|
4. **conversionSignals** (转化信号数组):
|
||||||
|
- type: positive/negative
|
||||||
|
- signal: 信号描述
|
||||||
|
- context: 上下文
|
||||||
|
|
||||||
|
请只返回JSON,不要其他内容。示例格式:
|
||||||
|
{
|
||||||
|
"experiences": [...],
|
||||||
|
"userInsights": [...],
|
||||||
|
"knowledgeGaps": [...],
|
||||||
|
"conversionSignals": [...]
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析分析结果
|
||||||
|
*/
|
||||||
|
private parseAnalysisResult(text: string): ConversationAnalysis {
|
||||||
|
try {
|
||||||
|
// 尝试从文本中提取JSON
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) {
|
||||||
|
throw new Error('No JSON found in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(jsonMatch[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
experiences: result.experiences || [],
|
||||||
|
userInsights: result.userInsights || [],
|
||||||
|
knowledgeGaps: result.knowledgeGaps || [],
|
||||||
|
conversionSignals: result.conversionSignals || [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ExperienceExtractor] Failed to parse result:', error);
|
||||||
|
return this.getMockAnalysis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock分析结果(开发用)
|
||||||
|
*/
|
||||||
|
private getMockAnalysis(): ConversationAnalysis {
|
||||||
|
return {
|
||||||
|
experiences: [],
|
||||||
|
userInsights: [],
|
||||||
|
knowledgeGaps: [],
|
||||||
|
conversionSignals: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从多个对话中总结经验
|
||||||
|
*/
|
||||||
|
async summarizeExperiences(
|
||||||
|
experiences: ExtractedExperience[],
|
||||||
|
): Promise<ExtractedExperience[]> {
|
||||||
|
if (!this.client || experiences.length < 3) {
|
||||||
|
return experiences;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `你是一个经验总结专家。请分析以下从多个对话中提取的经验,找出共同模式并合并相似经验。
|
||||||
|
|
||||||
|
## 原始经验
|
||||||
|
${JSON.stringify(experiences, null, 2)}
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
1. 合并相似的经验
|
||||||
|
2. 提高置信度(多次出现的经验置信度更高)
|
||||||
|
3. 移除重复或低质量的经验
|
||||||
|
|
||||||
|
请返回优化后的经验数组(JSON格式):`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 2000,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.content[0];
|
||||||
|
if (content.type !== 'text') {
|
||||||
|
return experiences;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonMatch = content.text.match(/\[[\s\S]*\]/);
|
||||||
|
if (!jsonMatch) {
|
||||||
|
return experiences;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(jsonMatch[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ExperienceExtractor] Summary failed:', error);
|
||||||
|
return experiences;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析知识库缺口
|
||||||
|
*/
|
||||||
|
async analyzeKnowledgeGaps(
|
||||||
|
gaps: string[],
|
||||||
|
existingArticles: string[],
|
||||||
|
): Promise<Array<{
|
||||||
|
topic: string;
|
||||||
|
priority: number;
|
||||||
|
suggestedContent: string;
|
||||||
|
}>> {
|
||||||
|
if (!this.client || gaps.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `你是香港移民知识库的内容规划专家。
|
||||||
|
|
||||||
|
## 现有知识库文章标题
|
||||||
|
${existingArticles.join('\n')}
|
||||||
|
|
||||||
|
## 对话中发现的知识缺口
|
||||||
|
${gaps.join('\n')}
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
分析哪些知识缺口是真正需要补充的,并按优先级排序。
|
||||||
|
对于每个需要补充的主题,提供建议的内容大纲。
|
||||||
|
|
||||||
|
请返回JSON数组:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"topic": "主题",
|
||||||
|
"priority": 1-100,
|
||||||
|
"suggestedContent": "建议的内容大纲"
|
||||||
|
}
|
||||||
|
]`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-20250514',
|
||||||
|
max_tokens: 2000,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = response.content[0];
|
||||||
|
if (content.type !== 'text') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonMatch = content.text.match(/\[[\s\S]*\]/);
|
||||||
|
if (!jsonMatch) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(jsonMatch[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ExperienceExtractor] Gap analysis failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('admins')
|
||||||
|
export class AdminORM {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 50, unique: true })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column({ name: 'password_hash', length: 255 })
|
||||||
|
passwordHash: string;
|
||||||
|
|
||||||
|
@Column({ length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, nullable: true })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, default: 'OPERATOR' })
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@Column('jsonb', { default: '[]' })
|
||||||
|
permissions: string[];
|
||||||
|
|
||||||
|
@Column({ length: 500, nullable: true })
|
||||||
|
avatar: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_login_at', nullable: true })
|
||||||
|
lastLoginAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_login_ip', length: 50, nullable: true })
|
||||||
|
lastLoginIp?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('conversations')
|
||||||
|
export class ConversationORM {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', nullable: true })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, default: 'ACTIVE' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ length: 255, nullable: true })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
summary: string;
|
||||||
|
|
||||||
|
@Column({ length: 50, nullable: true })
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
@Column({ name: 'message_count', default: 0 })
|
||||||
|
messageCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'user_message_count', default: 0 })
|
||||||
|
userMessageCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'assistant_message_count', default: 0 })
|
||||||
|
assistantMessageCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_input_tokens', default: 0 })
|
||||||
|
totalInputTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'total_output_tokens', default: 0 })
|
||||||
|
totalOutputTokens: number;
|
||||||
|
|
||||||
|
@Column({ type: 'smallint', nullable: true })
|
||||||
|
rating: number;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
feedback: string;
|
||||||
|
|
||||||
|
@Column({ name: 'has_converted', default: false })
|
||||||
|
hasConverted: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'ended_at', nullable: true })
|
||||||
|
endedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('messages')
|
||||||
|
export class MessageORM {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'conversation_id' })
|
||||||
|
conversationId: string;
|
||||||
|
|
||||||
|
@Column({ length: 20 })
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ name: 'input_tokens', default: 0 })
|
||||||
|
inputTokens: number;
|
||||||
|
|
||||||
|
@Column({ name: 'output_tokens', default: 0 })
|
||||||
|
outputTokens: number;
|
||||||
|
|
||||||
|
@Column('jsonb', { nullable: true })
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('system_experiences')
|
||||||
|
export class SystemExperienceORM {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'experience_type', length: 30 })
|
||||||
|
experienceType: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ default: 50 })
|
||||||
|
confidence: number;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
scenario: string;
|
||||||
|
|
||||||
|
@Column({ name: 'related_category', length: 50, nullable: true })
|
||||||
|
relatedCategory: string;
|
||||||
|
|
||||||
|
@Column('uuid', { name: 'source_conversation_ids', array: true, default: '{}' })
|
||||||
|
sourceConversationIds: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'verification_status', length: 20, default: 'PENDING' })
|
||||||
|
verificationStatus: string;
|
||||||
|
|
||||||
|
@Column({ name: 'verified_by', nullable: true })
|
||||||
|
verifiedBy: string;
|
||||||
|
|
||||||
|
@Column({ name: 'verified_at', nullable: true })
|
||||||
|
verifiedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'usage_count', default: 0 })
|
||||||
|
usageCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'positive_count', default: 0 })
|
||||||
|
positiveCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'negative_count', default: 0 })
|
||||||
|
negativeCount: number;
|
||||||
|
|
||||||
|
@Column('float', { array: true, nullable: true })
|
||||||
|
embedding: number[];
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', default: false })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// 设置全局前缀
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// 启用CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.CORS_ORIGIN || '*',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3005;
|
||||||
|
await app.listen(port);
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
╔══════════════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ 🧬 iConsulting Evolution Service ║
|
||||||
|
║ ║
|
||||||
|
║ Server running at: http://localhost:${port} ║
|
||||||
|
║ API prefix: /api/v1 ║
|
||||||
|
║ ║
|
||||||
|
║ Endpoints: ║
|
||||||
|
║ - POST /api/v1/admin/login Admin login ║
|
||||||
|
║ - GET /api/v1/admin/me Current admin ║
|
||||||
|
║ - POST /api/v1/admin/admins Create admin ║
|
||||||
|
║ - GET /api/v1/admin/admins List admins ║
|
||||||
|
║ - POST /api/v1/evolution/run Run evolution ║
|
||||||
|
║ - GET /api/v1/evolution/statistics Evolution stats ║
|
||||||
|
║ - GET /api/v1/evolution/health System health ║
|
||||||
|
║ ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# ===========================================
|
||||||
|
# iConsulting Knowledge Service Dockerfile
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
COPY packages/services/knowledge-service/package.json ./packages/services/knowledge-service/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
COPY packages/services/knowledge-service ./packages/services/knowledge-service
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
|
||||||
|
RUN pnpm --filter @iconsulting/shared build
|
||||||
|
RUN pnpm --filter @iconsulting/knowledge-service build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nestjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/packages/services/knowledge-service/dist ./dist
|
||||||
|
COPY --from=builder /app/packages/services/knowledge-service/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3003
|
||||||
|
|
||||||
|
USER nestjs
|
||||||
|
EXPOSE 3003
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"name": "@iconsulting/knowledge-service",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "知识服务 - RAG检索增强生成 + Neo4j知识图谱",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.52.0",
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.2.0",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/typeorm": "^10.0.0",
|
||||||
|
"neo4j-driver": "^5.17.0",
|
||||||
|
"openai": "^4.28.0",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"pgvector": "^0.1.8",
|
||||||
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.19",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/schematics": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { KnowledgeModule } from './knowledge/knowledge.module';
|
||||||
|
import { MemoryModule } from './memory/memory.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// 配置模块
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: ['.env.local', '.env'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 数据库连接
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: config.get('DB_HOST', 'localhost'),
|
||||||
|
port: config.get('DB_PORT', 5432),
|
||||||
|
username: config.get('DB_USER', 'iconsulting'),
|
||||||
|
password: config.get('DB_PASSWORD', 'iconsulting_dev'),
|
||||||
|
database: config.get('DB_NAME', 'iconsulting'),
|
||||||
|
autoLoadEntities: true,
|
||||||
|
synchronize: config.get('NODE_ENV') !== 'production',
|
||||||
|
logging: config.get('NODE_ENV') === 'development',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 功能模块
|
||||||
|
KnowledgeModule,
|
||||||
|
MemoryModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
KnowledgeChunkEntity,
|
||||||
|
ChunkType,
|
||||||
|
ChunkMetadata,
|
||||||
|
} from '../../domain/entities/knowledge-chunk.entity';
|
||||||
|
import { KnowledgeArticleEntity } from '../../domain/entities/knowledge-article.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本分块策略配置
|
||||||
|
*/
|
||||||
|
export interface ChunkingConfig {
|
||||||
|
/** 最大块大小(字符数) */
|
||||||
|
maxChunkSize: number;
|
||||||
|
/** 块重叠大小 */
|
||||||
|
overlapSize: number;
|
||||||
|
/** 是否按语义边界分割 */
|
||||||
|
semanticSplit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: ChunkingConfig = {
|
||||||
|
maxChunkSize: 500,
|
||||||
|
overlapSize: 50,
|
||||||
|
semanticSplit: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文本分块服务
|
||||||
|
* 将长文本智能分割为适合检索的小块
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ChunkingService {
|
||||||
|
/**
|
||||||
|
* 将文章内容分割为块
|
||||||
|
*/
|
||||||
|
chunkArticle(
|
||||||
|
article: KnowledgeArticleEntity,
|
||||||
|
config: ChunkingConfig = DEFAULT_CONFIG,
|
||||||
|
): KnowledgeChunkEntity[] {
|
||||||
|
const content = article.content;
|
||||||
|
|
||||||
|
// 检测内容格式
|
||||||
|
if (this.isMarkdown(content)) {
|
||||||
|
return this.chunkMarkdown(article.id, content, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 纯文本分块
|
||||||
|
return this.chunkPlainText(article.id, content, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测是否为Markdown格式
|
||||||
|
*/
|
||||||
|
private isMarkdown(content: string): boolean {
|
||||||
|
const markdownPatterns = [
|
||||||
|
/^#+\s/m, // 标题
|
||||||
|
/\*\*.+\*\*/, // 加粗
|
||||||
|
/\[.+\]\(.+\)/, // 链接
|
||||||
|
/^-\s/m, // 列表
|
||||||
|
/^\d+\.\s/m, // 有序列表
|
||||||
|
/```/, // 代码块
|
||||||
|
];
|
||||||
|
|
||||||
|
return markdownPatterns.some(pattern => pattern.test(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown内容分块
|
||||||
|
*/
|
||||||
|
private chunkMarkdown(
|
||||||
|
articleId: string,
|
||||||
|
content: string,
|
||||||
|
config: ChunkingConfig,
|
||||||
|
): KnowledgeChunkEntity[] {
|
||||||
|
const chunks: KnowledgeChunkEntity[] = [];
|
||||||
|
const sections = this.splitByHeadings(content);
|
||||||
|
|
||||||
|
let chunkIndex = 0;
|
||||||
|
let currentSectionTitle = '';
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
// 更新当前章节标题
|
||||||
|
const headingMatch = section.match(/^(#+)\s+(.+)$/m);
|
||||||
|
if (headingMatch) {
|
||||||
|
currentSectionTitle = headingMatch[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果章节内容过长,进一步分割
|
||||||
|
if (section.length > config.maxChunkSize) {
|
||||||
|
const subChunks = this.splitLongSection(section, config);
|
||||||
|
|
||||||
|
for (const subContent of subChunks) {
|
||||||
|
const chunkType = this.detectChunkType(subContent);
|
||||||
|
const metadata: ChunkMetadata = {
|
||||||
|
sectionTitle: currentSectionTitle,
|
||||||
|
headingLevel: headingMatch ? headingMatch[1].length : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunk = KnowledgeChunkEntity.create({
|
||||||
|
articleId,
|
||||||
|
content: subContent.trim(),
|
||||||
|
chunkIndex,
|
||||||
|
chunkType,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置前后链接
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
metadata.prevChunkId = chunks[chunks.length - 1].id;
|
||||||
|
chunks[chunks.length - 1].metadata.nextChunkId = chunk.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(chunk);
|
||||||
|
chunkIndex++;
|
||||||
|
}
|
||||||
|
} else if (section.trim()) {
|
||||||
|
const chunkType = this.detectChunkType(section);
|
||||||
|
const metadata: ChunkMetadata = {
|
||||||
|
sectionTitle: currentSectionTitle,
|
||||||
|
headingLevel: headingMatch ? headingMatch[1].length : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunk = KnowledgeChunkEntity.create({
|
||||||
|
articleId,
|
||||||
|
content: section.trim(),
|
||||||
|
chunkIndex,
|
||||||
|
chunkType,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chunks.length > 0) {
|
||||||
|
metadata.prevChunkId = chunks[chunks.length - 1].id;
|
||||||
|
chunks[chunks.length - 1].metadata.nextChunkId = chunk.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(chunk);
|
||||||
|
chunkIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按标题分割Markdown
|
||||||
|
*/
|
||||||
|
private splitByHeadings(content: string): string[] {
|
||||||
|
// 在标题前分割,但保留标题
|
||||||
|
const parts = content.split(/(?=^#+\s)/m);
|
||||||
|
return parts.filter(part => part.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分割过长的章节
|
||||||
|
*/
|
||||||
|
private splitLongSection(content: string, config: ChunkingConfig): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
|
||||||
|
if (config.semanticSplit) {
|
||||||
|
// 优先按段落分割
|
||||||
|
const paragraphs = content.split(/\n\n+/);
|
||||||
|
let currentChunk = '';
|
||||||
|
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
if (currentChunk.length + para.length > config.maxChunkSize) {
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
}
|
||||||
|
// 如果单个段落就超过限制,按句子分割
|
||||||
|
if (para.length > config.maxChunkSize) {
|
||||||
|
chunks.push(...this.splitBySentences(para, config));
|
||||||
|
} else {
|
||||||
|
currentChunk = para;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentChunk = currentChunk ? `${currentChunk}\n\n${para}` : para;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 简单按字符数分割(带重叠)
|
||||||
|
chunks.push(...this.splitBySize(content, config));
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按句子分割
|
||||||
|
*/
|
||||||
|
private splitBySentences(content: string, config: ChunkingConfig): string[] {
|
||||||
|
// 中英文句子结束符
|
||||||
|
const sentenceEndings = /([。!?.!?])/g;
|
||||||
|
const sentences = content.split(sentenceEndings);
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let currentChunk = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < sentences.length; i += 2) {
|
||||||
|
const sentence = sentences[i] + (sentences[i + 1] || '');
|
||||||
|
|
||||||
|
if (currentChunk.length + sentence.length > config.maxChunkSize) {
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
}
|
||||||
|
currentChunk = sentence;
|
||||||
|
} else {
|
||||||
|
currentChunk += sentence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按大小分割(带重叠)
|
||||||
|
*/
|
||||||
|
private splitBySize(content: string, config: ChunkingConfig): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let start = 0;
|
||||||
|
|
||||||
|
while (start < content.length) {
|
||||||
|
const end = Math.min(start + config.maxChunkSize, content.length);
|
||||||
|
chunks.push(content.slice(start, end));
|
||||||
|
start = end - config.overlapSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯文本分块
|
||||||
|
*/
|
||||||
|
private chunkPlainText(
|
||||||
|
articleId: string,
|
||||||
|
content: string,
|
||||||
|
config: ChunkingConfig,
|
||||||
|
): KnowledgeChunkEntity[] {
|
||||||
|
const textChunks = config.semanticSplit
|
||||||
|
? this.splitLongSection(content, config)
|
||||||
|
: this.splitBySize(content, config);
|
||||||
|
|
||||||
|
return textChunks.map((text, index) =>
|
||||||
|
KnowledgeChunkEntity.create({
|
||||||
|
articleId,
|
||||||
|
content: text.trim(),
|
||||||
|
chunkIndex: index,
|
||||||
|
chunkType: ChunkType.PARAGRAPH,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测块类型
|
||||||
|
*/
|
||||||
|
private detectChunkType(content: string): ChunkType {
|
||||||
|
// 标题
|
||||||
|
if (/^#+\s/.test(content)) {
|
||||||
|
return ChunkType.TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代码块
|
||||||
|
if (/```[\s\S]*```/.test(content)) {
|
||||||
|
return ChunkType.CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
if (/^[-*]\s/m.test(content) || /^\d+\.\s/m.test(content)) {
|
||||||
|
return ChunkType.LIST;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格
|
||||||
|
if (/\|.+\|/.test(content) && /\|-+\|/.test(content)) {
|
||||||
|
return ChunkType.TABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAQ(问答格式)
|
||||||
|
if (/^[问Q][::]/.test(content) || /^[答A][::]/.test(content)) {
|
||||||
|
return ChunkType.FAQ;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChunkType.PARAGRAPH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为FAQ内容创建专门的块
|
||||||
|
*/
|
||||||
|
createFAQChunks(
|
||||||
|
articleId: string,
|
||||||
|
faqs: Array<{ question: string; answer: string }>,
|
||||||
|
): KnowledgeChunkEntity[] {
|
||||||
|
return faqs.map((faq, index) =>
|
||||||
|
KnowledgeChunkEntity.create({
|
||||||
|
articleId,
|
||||||
|
content: `问:${faq.question}\n答:${faq.answer}`,
|
||||||
|
chunkIndex: index,
|
||||||
|
chunkType: ChunkType.FAQ,
|
||||||
|
metadata: {
|
||||||
|
hasKeywords: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { EmbeddingService } from '../../infrastructure/embedding/embedding.service';
|
||||||
|
import {
|
||||||
|
IKnowledgeRepository,
|
||||||
|
KNOWLEDGE_REPOSITORY,
|
||||||
|
} from '../../domain/repositories/knowledge.repository.interface';
|
||||||
|
import {
|
||||||
|
IUserMemoryRepository,
|
||||||
|
ISystemExperienceRepository,
|
||||||
|
USER_MEMORY_REPOSITORY,
|
||||||
|
SYSTEM_EXPERIENCE_REPOSITORY,
|
||||||
|
} from '../../domain/repositories/memory.repository.interface';
|
||||||
|
import { KnowledgeArticleEntity } from '../../domain/entities/knowledge-article.entity';
|
||||||
|
import { KnowledgeChunkEntity } from '../../domain/entities/knowledge-chunk.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG检索结果
|
||||||
|
*/
|
||||||
|
export interface RAGResult {
|
||||||
|
/** 检索到的知识内容 */
|
||||||
|
content: string;
|
||||||
|
/** 来源(用于引用) */
|
||||||
|
sources: Array<{
|
||||||
|
articleId: string;
|
||||||
|
title: string;
|
||||||
|
similarity: number;
|
||||||
|
}>;
|
||||||
|
/** 用户相关记忆 */
|
||||||
|
userMemories?: string[];
|
||||||
|
/** 系统经验 */
|
||||||
|
systemExperiences?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG服务 - 检索增强生成
|
||||||
|
* 负责从知识库中检索相关内容,增强AI回答
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RAGService {
|
||||||
|
constructor(
|
||||||
|
private embeddingService: EmbeddingService,
|
||||||
|
@Inject(KNOWLEDGE_REPOSITORY)
|
||||||
|
private knowledgeRepo: IKnowledgeRepository,
|
||||||
|
@Inject(USER_MEMORY_REPOSITORY)
|
||||||
|
private memoryRepo: IUserMemoryRepository,
|
||||||
|
@Inject(SYSTEM_EXPERIENCE_REPOSITORY)
|
||||||
|
private experienceRepo: ISystemExperienceRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索相关知识
|
||||||
|
*/
|
||||||
|
async retrieve(params: {
|
||||||
|
query: string;
|
||||||
|
userId?: string;
|
||||||
|
category?: string;
|
||||||
|
includeMemories?: boolean;
|
||||||
|
includeExperiences?: boolean;
|
||||||
|
topK?: number;
|
||||||
|
}): Promise<RAGResult> {
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
userId,
|
||||||
|
category,
|
||||||
|
includeMemories = true,
|
||||||
|
includeExperiences = true,
|
||||||
|
topK = 5,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
// 1. 生成查询向量
|
||||||
|
const queryEmbedding = await this.embeddingService.getEmbedding(query);
|
||||||
|
|
||||||
|
// 2. 并行检索
|
||||||
|
const [chunkResults, memoryResults, experienceResults] = await Promise.all([
|
||||||
|
// 检索知识块
|
||||||
|
this.knowledgeRepo.searchChunksByVector(queryEmbedding, {
|
||||||
|
category,
|
||||||
|
limit: topK,
|
||||||
|
minSimilarity: 0.6,
|
||||||
|
}),
|
||||||
|
// 检索用户记忆
|
||||||
|
includeMemories && userId
|
||||||
|
? this.memoryRepo.searchByVector(userId, queryEmbedding, {
|
||||||
|
limit: 3,
|
||||||
|
minSimilarity: 0.7,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
// 检索系统经验
|
||||||
|
includeExperiences
|
||||||
|
? this.experienceRepo.searchByVector(queryEmbedding, {
|
||||||
|
activeOnly: true,
|
||||||
|
limit: 3,
|
||||||
|
minSimilarity: 0.75,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. 获取完整文章信息(用于引用)
|
||||||
|
const articleIds = [...new Set(chunkResults.map(r => r.chunk.articleId))];
|
||||||
|
const articles = await Promise.all(
|
||||||
|
articleIds.map(id => this.knowledgeRepo.findArticleById(id)),
|
||||||
|
);
|
||||||
|
const articleMap = new Map(
|
||||||
|
articles.filter(Boolean).map(a => [a!.id, a!]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. 组装结果
|
||||||
|
const content = this.formatRetrievedContent(chunkResults.map(r => r.chunk), articleMap);
|
||||||
|
|
||||||
|
const sources = chunkResults.map(r => {
|
||||||
|
const article = articleMap.get(r.chunk.articleId);
|
||||||
|
return {
|
||||||
|
articleId: r.chunk.articleId,
|
||||||
|
title: article?.title || 'Unknown',
|
||||||
|
similarity: r.similarity,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const userMemories = memoryResults.map(r => r.memory.content);
|
||||||
|
const systemExperiences = experienceResults.map(r => r.experience.content);
|
||||||
|
|
||||||
|
// 5. 更新引用计数
|
||||||
|
this.updateCitationCounts(articleIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
sources,
|
||||||
|
userMemories: userMemories.length > 0 ? userMemories : undefined,
|
||||||
|
systemExperiences: systemExperiences.length > 0 ? systemExperiences : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索并格式化为提示词上下文
|
||||||
|
*/
|
||||||
|
async retrieveForPrompt(params: {
|
||||||
|
query: string;
|
||||||
|
userId?: string;
|
||||||
|
category?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const result = await this.retrieve(params);
|
||||||
|
|
||||||
|
let context = '';
|
||||||
|
|
||||||
|
// 知识库内容
|
||||||
|
if (result.content) {
|
||||||
|
context += `## 相关知识\n${result.content}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户记忆
|
||||||
|
if (result.userMemories?.length) {
|
||||||
|
context += `## 用户背景信息\n`;
|
||||||
|
result.userMemories.forEach((m, i) => {
|
||||||
|
context += `${i + 1}. ${m}\n`;
|
||||||
|
});
|
||||||
|
context += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统经验
|
||||||
|
if (result.systemExperiences?.length) {
|
||||||
|
context += `## 参考经验\n`;
|
||||||
|
result.systemExperiences.forEach((e, i) => {
|
||||||
|
context += `${i + 1}. ${e}\n`;
|
||||||
|
});
|
||||||
|
context += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 来源引用
|
||||||
|
if (result.sources.length > 0) {
|
||||||
|
context += `## 来源\n`;
|
||||||
|
result.sources.forEach((s, i) => {
|
||||||
|
context += `[${i + 1}] ${s.title}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化检索到的内容
|
||||||
|
*/
|
||||||
|
private formatRetrievedContent(
|
||||||
|
chunks: KnowledgeChunkEntity[],
|
||||||
|
articleMap: Map<string, KnowledgeArticleEntity>,
|
||||||
|
): string {
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按文章分组
|
||||||
|
const groupedByArticle = new Map<string, KnowledgeChunkEntity[]>();
|
||||||
|
chunks.forEach(chunk => {
|
||||||
|
const existing = groupedByArticle.get(chunk.articleId) || [];
|
||||||
|
existing.push(chunk);
|
||||||
|
groupedByArticle.set(chunk.articleId, existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
let articleIndex = 1;
|
||||||
|
|
||||||
|
groupedByArticle.forEach((articleChunks, articleId) => {
|
||||||
|
const article = articleMap.get(articleId);
|
||||||
|
if (!article) return;
|
||||||
|
|
||||||
|
content += `### [${articleIndex}] ${article.title}\n`;
|
||||||
|
|
||||||
|
// 按块序号排序
|
||||||
|
articleChunks.sort((a, b) => a.chunkIndex - b.chunkIndex);
|
||||||
|
|
||||||
|
articleChunks.forEach(chunk => {
|
||||||
|
content += `${chunk.content}\n\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
articleIndex++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步更新引用计数
|
||||||
|
*/
|
||||||
|
private async updateCitationCounts(articleIds: string[]): Promise<void> {
|
||||||
|
// 异步执行,不阻塞检索
|
||||||
|
setImmediate(async () => {
|
||||||
|
for (const id of articleIds) {
|
||||||
|
try {
|
||||||
|
const article = await this.knowledgeRepo.findArticleById(id);
|
||||||
|
if (article) {
|
||||||
|
article.incrementCitation();
|
||||||
|
await this.knowledgeRepo.updateArticle(article);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to update citation count for article ${id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否为离题问题
|
||||||
|
*/
|
||||||
|
async checkOffTopic(query: string): Promise<{
|
||||||
|
isOffTopic: boolean;
|
||||||
|
confidence: number;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
// 使用向量相似度检查是否与知识库相关
|
||||||
|
const queryEmbedding = await this.embeddingService.getEmbedding(query);
|
||||||
|
|
||||||
|
const results = await this.knowledgeRepo.searchChunksByVector(queryEmbedding, {
|
||||||
|
limit: 1,
|
||||||
|
minSimilarity: 0.3, // 使用较低阈值
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return {
|
||||||
|
isOffTopic: true,
|
||||||
|
confidence: 0.8,
|
||||||
|
reason: '问题与香港移民主题无关',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSimilarity = results[0].similarity;
|
||||||
|
|
||||||
|
if (maxSimilarity < 0.5) {
|
||||||
|
return {
|
||||||
|
isOffTopic: true,
|
||||||
|
confidence: 0.9 - maxSimilarity,
|
||||||
|
reason: '问题与香港移民主题相关性较低',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOffTopic: false,
|
||||||
|
confidence: maxSimilarity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识文章实体 - 存储移民相关的知识内容
|
||||||
|
*/
|
||||||
|
export class KnowledgeArticleEntity {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 文章标题 */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** 文章内容(纯文本或Markdown) */
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
/** 内容摘要(用于预览) */
|
||||||
|
summary: string;
|
||||||
|
|
||||||
|
/** 移民类别: QMAS, GEP, IANG, TTPS, CIES, TechTAS */
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
/** 内容标签 */
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
/** 来源: MANUAL(手动添加), CRAWL(爬取), EXTRACT(对话提取) */
|
||||||
|
source: KnowledgeSource;
|
||||||
|
|
||||||
|
/** 来源URL(如果是爬取的) */
|
||||||
|
sourceUrl?: string;
|
||||||
|
|
||||||
|
/** 内容向量(用于语义搜索) */
|
||||||
|
embedding?: number[];
|
||||||
|
|
||||||
|
/** 是否已发布 */
|
||||||
|
isPublished: boolean;
|
||||||
|
|
||||||
|
/** 引用次数(被对话引用) */
|
||||||
|
citationCount: number;
|
||||||
|
|
||||||
|
/** 点赞数(用户反馈有用) */
|
||||||
|
helpfulCount: number;
|
||||||
|
|
||||||
|
/** 点踩数(用户反馈无用) */
|
||||||
|
unhelpfulCount: number;
|
||||||
|
|
||||||
|
/** 质量评分 0-100 */
|
||||||
|
qualityScore: number;
|
||||||
|
|
||||||
|
/** 创建者ID */
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
/** 最后更新者ID */
|
||||||
|
updatedBy?: string;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static create(params: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
tags?: string[];
|
||||||
|
source: KnowledgeSource;
|
||||||
|
sourceUrl?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
}): KnowledgeArticleEntity {
|
||||||
|
const article = new KnowledgeArticleEntity();
|
||||||
|
article.id = uuidv4();
|
||||||
|
article.title = params.title;
|
||||||
|
article.content = params.content;
|
||||||
|
article.summary = KnowledgeArticleEntity.generateSummary(params.content);
|
||||||
|
article.category = params.category;
|
||||||
|
article.tags = params.tags || [];
|
||||||
|
article.source = params.source;
|
||||||
|
article.sourceUrl = params.sourceUrl;
|
||||||
|
article.isPublished = false;
|
||||||
|
article.citationCount = 0;
|
||||||
|
article.helpfulCount = 0;
|
||||||
|
article.unhelpfulCount = 0;
|
||||||
|
article.qualityScore = 50; // 默认中等质量
|
||||||
|
article.createdBy = params.createdBy;
|
||||||
|
article.createdAt = new Date();
|
||||||
|
article.updatedAt = new Date();
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPersistence(data: Partial<KnowledgeArticleEntity>): KnowledgeArticleEntity {
|
||||||
|
const article = new KnowledgeArticleEntity();
|
||||||
|
Object.assign(article, data);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成内容摘要
|
||||||
|
*/
|
||||||
|
private static generateSummary(content: string, maxLength = 200): string {
|
||||||
|
const plainText = content
|
||||||
|
.replace(/#+\s/g, '') // 移除Markdown标题
|
||||||
|
.replace(/\*\*/g, '') // 移除加粗
|
||||||
|
.replace(/\n+/g, ' ') // 换行转空格
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (plainText.length <= maxLength) {
|
||||||
|
return plainText;
|
||||||
|
}
|
||||||
|
return plainText.substring(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新内容
|
||||||
|
*/
|
||||||
|
updateContent(title: string, content: string, updatedBy?: string): void {
|
||||||
|
this.title = title;
|
||||||
|
this.content = content;
|
||||||
|
this.summary = KnowledgeArticleEntity.generateSummary(content);
|
||||||
|
this.updatedBy = updatedBy;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
// 内容更新后需要重新生成向量
|
||||||
|
this.embedding = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置内容向量
|
||||||
|
*/
|
||||||
|
setEmbedding(embedding: number[]): void {
|
||||||
|
this.embedding = embedding;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布文章
|
||||||
|
*/
|
||||||
|
publish(): void {
|
||||||
|
this.isPublished = true;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消发布
|
||||||
|
*/
|
||||||
|
unpublish(): void {
|
||||||
|
this.isPublished = false;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增加引用次数
|
||||||
|
*/
|
||||||
|
incrementCitation(): void {
|
||||||
|
this.citationCount++;
|
||||||
|
this.recalculateQualityScore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户反馈
|
||||||
|
*/
|
||||||
|
recordFeedback(helpful: boolean): void {
|
||||||
|
if (helpful) {
|
||||||
|
this.helpfulCount++;
|
||||||
|
} else {
|
||||||
|
this.unhelpfulCount++;
|
||||||
|
}
|
||||||
|
this.recalculateQualityScore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重新计算质量评分
|
||||||
|
*/
|
||||||
|
private recalculateQualityScore(): void {
|
||||||
|
// 基础分50分
|
||||||
|
let score = 50;
|
||||||
|
|
||||||
|
// 引用次数加分(最多+20)
|
||||||
|
score += Math.min(this.citationCount * 2, 20);
|
||||||
|
|
||||||
|
// 用户反馈(最多±30)
|
||||||
|
const totalFeedback = this.helpfulCount + this.unhelpfulCount;
|
||||||
|
if (totalFeedback > 0) {
|
||||||
|
const helpfulRatio = this.helpfulCount / totalFeedback;
|
||||||
|
score += Math.round((helpfulRatio - 0.5) * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.qualityScore = Math.max(0, Math.min(100, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum KnowledgeSource {
|
||||||
|
MANUAL = 'MANUAL', // 手动添加
|
||||||
|
CRAWL = 'CRAWL', // 网页爬取
|
||||||
|
EXTRACT = 'EXTRACT', // 对话提取
|
||||||
|
IMPORT = 'IMPORT', // 批量导入
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识块实体 - 将文章拆分为更小的检索单元
|
||||||
|
* 用于RAG检索时提高精确度
|
||||||
|
*/
|
||||||
|
export class KnowledgeChunkEntity {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 所属文章ID */
|
||||||
|
articleId: string;
|
||||||
|
|
||||||
|
/** 块内容 */
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
/** 块序号(在文章中的位置) */
|
||||||
|
chunkIndex: number;
|
||||||
|
|
||||||
|
/** 块类型 */
|
||||||
|
chunkType: ChunkType;
|
||||||
|
|
||||||
|
/** 内容向量 */
|
||||||
|
embedding?: number[];
|
||||||
|
|
||||||
|
/** 元数据(如标题层级、列表项等) */
|
||||||
|
metadata: ChunkMetadata;
|
||||||
|
|
||||||
|
/** Token数量(估算) */
|
||||||
|
tokenCount: number;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static create(params: {
|
||||||
|
articleId: string;
|
||||||
|
content: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
chunkType: ChunkType;
|
||||||
|
metadata?: ChunkMetadata;
|
||||||
|
}): KnowledgeChunkEntity {
|
||||||
|
const chunk = new KnowledgeChunkEntity();
|
||||||
|
chunk.id = uuidv4();
|
||||||
|
chunk.articleId = params.articleId;
|
||||||
|
chunk.content = params.content;
|
||||||
|
chunk.chunkIndex = params.chunkIndex;
|
||||||
|
chunk.chunkType = params.chunkType;
|
||||||
|
chunk.metadata = params.metadata || {};
|
||||||
|
chunk.tokenCount = KnowledgeChunkEntity.estimateTokens(params.content);
|
||||||
|
chunk.createdAt = new Date();
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPersistence(data: Partial<KnowledgeChunkEntity>): KnowledgeChunkEntity {
|
||||||
|
const chunk = new KnowledgeChunkEntity();
|
||||||
|
Object.assign(chunk, data);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 估算Token数量(简单估算:中文字符约1.5token,英文单词约1.3token)
|
||||||
|
*/
|
||||||
|
private static estimateTokens(content: string): number {
|
||||||
|
const chineseChars = (content.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||||
|
const englishWords = (content.match(/[a-zA-Z]+/g) || []).length;
|
||||||
|
return Math.ceil(chineseChars * 1.5 + englishWords * 1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置向量
|
||||||
|
*/
|
||||||
|
setEmbedding(embedding: number[]): void {
|
||||||
|
this.embedding = embedding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChunkType {
|
||||||
|
TITLE = 'TITLE', // 标题
|
||||||
|
PARAGRAPH = 'PARAGRAPH', // 段落
|
||||||
|
LIST = 'LIST', // 列表
|
||||||
|
TABLE = 'TABLE', // 表格
|
||||||
|
CODE = 'CODE', // 代码块
|
||||||
|
FAQ = 'FAQ', // 问答对
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChunkMetadata {
|
||||||
|
/** 所属章节标题 */
|
||||||
|
sectionTitle?: string;
|
||||||
|
/** 标题层级 */
|
||||||
|
headingLevel?: number;
|
||||||
|
/** 是否包含重要关键词 */
|
||||||
|
hasKeywords?: boolean;
|
||||||
|
/** 前一个块ID(用于上下文) */
|
||||||
|
prevChunkId?: string;
|
||||||
|
/** 后一个块ID */
|
||||||
|
nextChunkId?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统经验实体 - 存储系统从对话中学习到的经验
|
||||||
|
* 用于系统自我进化
|
||||||
|
*/
|
||||||
|
export class SystemExperienceEntity {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 经验类型 */
|
||||||
|
experienceType: ExperienceType;
|
||||||
|
|
||||||
|
/** 经验内容/描述 */
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
/** 经验的置信度 0-100 */
|
||||||
|
confidence: number;
|
||||||
|
|
||||||
|
/** 应用场景描述 */
|
||||||
|
scenario: string;
|
||||||
|
|
||||||
|
/** 相关移民类别 */
|
||||||
|
relatedCategory?: string;
|
||||||
|
|
||||||
|
/** 来源对话ID列表 */
|
||||||
|
sourceConversationIds: string[];
|
||||||
|
|
||||||
|
/** 验证状态 */
|
||||||
|
verificationStatus: VerificationStatus;
|
||||||
|
|
||||||
|
/** 验证者(管理员)ID */
|
||||||
|
verifiedBy?: string;
|
||||||
|
|
||||||
|
/** 验证时间 */
|
||||||
|
verifiedAt?: Date;
|
||||||
|
|
||||||
|
/** 使用次数(被应用到对话中) */
|
||||||
|
usageCount: number;
|
||||||
|
|
||||||
|
/** 正面反馈次数 */
|
||||||
|
positiveCount: number;
|
||||||
|
|
||||||
|
/** 负面反馈次数 */
|
||||||
|
negativeCount: number;
|
||||||
|
|
||||||
|
/** 内容向量 */
|
||||||
|
embedding?: number[];
|
||||||
|
|
||||||
|
/** 是否激活使用 */
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static create(params: {
|
||||||
|
experienceType: ExperienceType;
|
||||||
|
content: string;
|
||||||
|
scenario: string;
|
||||||
|
relatedCategory?: string;
|
||||||
|
sourceConversationId: string;
|
||||||
|
confidence?: number;
|
||||||
|
}): SystemExperienceEntity {
|
||||||
|
const experience = new SystemExperienceEntity();
|
||||||
|
experience.id = uuidv4();
|
||||||
|
experience.experienceType = params.experienceType;
|
||||||
|
experience.content = params.content;
|
||||||
|
experience.scenario = params.scenario;
|
||||||
|
experience.relatedCategory = params.relatedCategory;
|
||||||
|
experience.sourceConversationIds = [params.sourceConversationId];
|
||||||
|
experience.confidence = params.confidence ?? 50;
|
||||||
|
experience.verificationStatus = VerificationStatus.PENDING;
|
||||||
|
experience.usageCount = 0;
|
||||||
|
experience.positiveCount = 0;
|
||||||
|
experience.negativeCount = 0;
|
||||||
|
experience.isActive = false; // 默认不激活,需要验证后才激活
|
||||||
|
experience.createdAt = new Date();
|
||||||
|
experience.updatedAt = new Date();
|
||||||
|
return experience;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPersistence(data: Partial<SystemExperienceEntity>): SystemExperienceEntity {
|
||||||
|
const experience = new SystemExperienceEntity();
|
||||||
|
Object.assign(experience, data);
|
||||||
|
return experience;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加来源对话
|
||||||
|
*/
|
||||||
|
addSourceConversation(conversationId: string): void {
|
||||||
|
if (!this.sourceConversationIds.includes(conversationId)) {
|
||||||
|
this.sourceConversationIds.push(conversationId);
|
||||||
|
// 多个对话都产生相同经验,提高置信度
|
||||||
|
this.confidence = Math.min(100, this.confidence + 5);
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置向量
|
||||||
|
*/
|
||||||
|
setEmbedding(embedding: number[]): void {
|
||||||
|
this.embedding = embedding;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员验证通过
|
||||||
|
*/
|
||||||
|
approve(adminId: string): void {
|
||||||
|
this.verificationStatus = VerificationStatus.APPROVED;
|
||||||
|
this.verifiedBy = adminId;
|
||||||
|
this.verifiedAt = new Date();
|
||||||
|
this.isActive = true;
|
||||||
|
this.confidence = Math.min(100, this.confidence + 20);
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员验证拒绝
|
||||||
|
*/
|
||||||
|
reject(adminId: string): void {
|
||||||
|
this.verificationStatus = VerificationStatus.REJECTED;
|
||||||
|
this.verifiedBy = adminId;
|
||||||
|
this.verifiedAt = new Date();
|
||||||
|
this.isActive = false;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录使用
|
||||||
|
*/
|
||||||
|
recordUsage(): void {
|
||||||
|
this.usageCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录反馈
|
||||||
|
*/
|
||||||
|
recordFeedback(positive: boolean): void {
|
||||||
|
if (positive) {
|
||||||
|
this.positiveCount++;
|
||||||
|
this.confidence = Math.min(100, this.confidence + 2);
|
||||||
|
} else {
|
||||||
|
this.negativeCount++;
|
||||||
|
this.confidence = Math.max(0, this.confidence - 5);
|
||||||
|
|
||||||
|
// 负面反馈过多则自动停用
|
||||||
|
if (this.negativeCount > 5 && this.negativeCount > this.positiveCount * 2) {
|
||||||
|
this.isActive = false;
|
||||||
|
this.verificationStatus = VerificationStatus.DEPRECATED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并相似经验
|
||||||
|
*/
|
||||||
|
mergeWith(other: SystemExperienceEntity): void {
|
||||||
|
// 合并来源对话
|
||||||
|
other.sourceConversationIds.forEach(id => {
|
||||||
|
if (!this.sourceConversationIds.includes(id)) {
|
||||||
|
this.sourceConversationIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 合并统计数据
|
||||||
|
this.usageCount += other.usageCount;
|
||||||
|
this.positiveCount += other.positiveCount;
|
||||||
|
this.negativeCount += other.negativeCount;
|
||||||
|
|
||||||
|
// 重新计算置信度
|
||||||
|
this.confidence = Math.min(100, (this.confidence + other.confidence) / 2 + 10);
|
||||||
|
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ExperienceType {
|
||||||
|
// 问答相关
|
||||||
|
COMMON_QUESTION = 'COMMON_QUESTION', // 常见问题
|
||||||
|
ANSWER_TEMPLATE = 'ANSWER_TEMPLATE', // 回答模板
|
||||||
|
CLARIFICATION = 'CLARIFICATION', // 澄清方式
|
||||||
|
|
||||||
|
// 用户行为
|
||||||
|
USER_PATTERN = 'USER_PATTERN', // 用户行为模式
|
||||||
|
CONVERSION_TRIGGER = 'CONVERSION_TRIGGER', // 转化触发点
|
||||||
|
|
||||||
|
// 知识相关
|
||||||
|
KNOWLEDGE_GAP = 'KNOWLEDGE_GAP', // 知识缺口
|
||||||
|
KNOWLEDGE_UPDATE = 'KNOWLEDGE_UPDATE', // 知识更新
|
||||||
|
|
||||||
|
// 对话技巧
|
||||||
|
CONVERSATION_SKILL = 'CONVERSATION_SKILL', // 对话技巧
|
||||||
|
OBJECTION_HANDLING = 'OBJECTION_HANDLING', // 异议处理
|
||||||
|
|
||||||
|
// 其他
|
||||||
|
CUSTOM = 'CUSTOM', // 自定义经验
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VerificationStatus {
|
||||||
|
PENDING = 'PENDING', // 待验证
|
||||||
|
APPROVED = 'APPROVED', // 已通过
|
||||||
|
REJECTED = 'REJECTED', // 已拒绝
|
||||||
|
DEPRECATED = 'DEPRECATED', // 已弃用
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户记忆实体 - 存储用户的长期记忆
|
||||||
|
* 用于个性化对话和用户画像
|
||||||
|
*/
|
||||||
|
export class UserMemoryEntity {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 用户ID(可以是匿名用户ID) */
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
/** 记忆类型 */
|
||||||
|
memoryType: MemoryType;
|
||||||
|
|
||||||
|
/** 记忆内容 */
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
/** 记忆的重要性 0-100 */
|
||||||
|
importance: number;
|
||||||
|
|
||||||
|
/** 记忆来源(哪次对话提取) */
|
||||||
|
sourceConversationId?: string;
|
||||||
|
|
||||||
|
/** 相关移民类别 */
|
||||||
|
relatedCategory?: string;
|
||||||
|
|
||||||
|
/** 内容向量 */
|
||||||
|
embedding?: number[];
|
||||||
|
|
||||||
|
/** 访问次数(记忆被检索使用) */
|
||||||
|
accessCount: number;
|
||||||
|
|
||||||
|
/** 最后访问时间 */
|
||||||
|
lastAccessedAt?: Date;
|
||||||
|
|
||||||
|
/** 是否已过期(用户情况已变化) */
|
||||||
|
isExpired: boolean;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static create(params: {
|
||||||
|
userId: string;
|
||||||
|
memoryType: MemoryType;
|
||||||
|
content: string;
|
||||||
|
importance?: number;
|
||||||
|
sourceConversationId?: string;
|
||||||
|
relatedCategory?: string;
|
||||||
|
}): UserMemoryEntity {
|
||||||
|
const memory = new UserMemoryEntity();
|
||||||
|
memory.id = uuidv4();
|
||||||
|
memory.userId = params.userId;
|
||||||
|
memory.memoryType = params.memoryType;
|
||||||
|
memory.content = params.content;
|
||||||
|
memory.importance = params.importance ?? 50;
|
||||||
|
memory.sourceConversationId = params.sourceConversationId;
|
||||||
|
memory.relatedCategory = params.relatedCategory;
|
||||||
|
memory.accessCount = 0;
|
||||||
|
memory.isExpired = false;
|
||||||
|
memory.createdAt = new Date();
|
||||||
|
memory.updatedAt = new Date();
|
||||||
|
return memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromPersistence(data: Partial<UserMemoryEntity>): UserMemoryEntity {
|
||||||
|
const memory = new UserMemoryEntity();
|
||||||
|
Object.assign(memory, data);
|
||||||
|
return memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置向量
|
||||||
|
*/
|
||||||
|
setEmbedding(embedding: number[]): void {
|
||||||
|
this.embedding = embedding;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录访问
|
||||||
|
*/
|
||||||
|
recordAccess(): void {
|
||||||
|
this.accessCount++;
|
||||||
|
this.lastAccessedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记为过期
|
||||||
|
*/
|
||||||
|
markAsExpired(): void {
|
||||||
|
this.isExpired = true;
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新重要性
|
||||||
|
*/
|
||||||
|
updateImportance(importance: number): void {
|
||||||
|
this.importance = Math.max(0, Math.min(100, importance));
|
||||||
|
this.updatedAt = new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MemoryType {
|
||||||
|
// 用户基本信息
|
||||||
|
PERSONAL_INFO = 'PERSONAL_INFO', // 个人信息(年龄、学历等)
|
||||||
|
WORK_EXPERIENCE = 'WORK_EXPERIENCE', // 工作经历
|
||||||
|
EDUCATION = 'EDUCATION', // 教育背景
|
||||||
|
LANGUAGE = 'LANGUAGE', // 语言能力
|
||||||
|
|
||||||
|
// 移民相关
|
||||||
|
IMMIGRATION_INTENT = 'IMMIGRATION_INTENT', // 移民意向
|
||||||
|
PREFERRED_CATEGORY = 'PREFERRED_CATEGORY', // 倾向的移民类别
|
||||||
|
ASSESSMENT_RESULT = 'ASSESSMENT_RESULT', // 评估结果
|
||||||
|
|
||||||
|
// 对话相关
|
||||||
|
QUESTION_ASKED = 'QUESTION_ASKED', // 问过的问题
|
||||||
|
CONCERN = 'CONCERN', // 关注点/顾虑
|
||||||
|
PREFERENCE = 'PREFERENCE', // 偏好设置
|
||||||
|
|
||||||
|
// 其他
|
||||||
|
CUSTOM = 'CUSTOM', // 自定义记忆
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { KnowledgeArticleEntity } from '../entities/knowledge-article.entity';
|
||||||
|
import { KnowledgeChunkEntity } from '../entities/knowledge-chunk.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库仓储接口
|
||||||
|
*/
|
||||||
|
export interface IKnowledgeRepository {
|
||||||
|
// ========== 文章操作 ==========
|
||||||
|
|
||||||
|
/** 保存文章 */
|
||||||
|
saveArticle(article: KnowledgeArticleEntity): Promise<void>;
|
||||||
|
|
||||||
|
/** 根据ID获取文章 */
|
||||||
|
findArticleById(id: string): Promise<KnowledgeArticleEntity | null>;
|
||||||
|
|
||||||
|
/** 根据类别获取文章列表 */
|
||||||
|
findArticlesByCategory(category: string, options?: {
|
||||||
|
publishedOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<KnowledgeArticleEntity[]>;
|
||||||
|
|
||||||
|
/** 搜索文章(关键词) */
|
||||||
|
searchArticles(query: string, options?: {
|
||||||
|
category?: string;
|
||||||
|
publishedOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<KnowledgeArticleEntity[]>;
|
||||||
|
|
||||||
|
/** 向量相似度搜索文章 */
|
||||||
|
searchArticlesByVector(embedding: number[], options?: {
|
||||||
|
category?: string;
|
||||||
|
publishedOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
minSimilarity?: number;
|
||||||
|
}): Promise<Array<{ article: KnowledgeArticleEntity; similarity: number }>>;
|
||||||
|
|
||||||
|
/** 更新文章 */
|
||||||
|
updateArticle(article: KnowledgeArticleEntity): Promise<void>;
|
||||||
|
|
||||||
|
/** 删除文章 */
|
||||||
|
deleteArticle(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/** 获取文章总数 */
|
||||||
|
countArticles(options?: {
|
||||||
|
category?: string;
|
||||||
|
publishedOnly?: boolean;
|
||||||
|
}): Promise<number>;
|
||||||
|
|
||||||
|
// ========== 块操作 ==========
|
||||||
|
|
||||||
|
/** 保存知识块 */
|
||||||
|
saveChunk(chunk: KnowledgeChunkEntity): Promise<void>;
|
||||||
|
|
||||||
|
/** 批量保存知识块 */
|
||||||
|
saveChunks(chunks: KnowledgeChunkEntity[]): Promise<void>;
|
||||||
|
|
||||||
|
/** 根据文章ID获取所有块 */
|
||||||
|
findChunksByArticleId(articleId: string): Promise<KnowledgeChunkEntity[]>;
|
||||||
|
|
||||||
|
/** 向量相似度搜索块 */
|
||||||
|
searchChunksByVector(embedding: number[], options?: {
|
||||||
|
category?: string;
|
||||||
|
limit?: number;
|
||||||
|
minSimilarity?: number;
|
||||||
|
}): Promise<Array<{ chunk: KnowledgeChunkEntity; similarity: number }>>;
|
||||||
|
|
||||||
|
/** 删除文章的所有块 */
|
||||||
|
deleteChunksByArticleId(articleId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KNOWLEDGE_REPOSITORY = Symbol('IKnowledgeRepository');
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { UserMemoryEntity, MemoryType } from '../entities/user-memory.entity';
|
||||||
|
import { SystemExperienceEntity, ExperienceType, VerificationStatus } from '../entities/system-experience.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户记忆仓储接口
|
||||||
|
*/
|
||||||
|
export interface IUserMemoryRepository {
|
||||||
|
/** 保存用户记忆 */
|
||||||
|
save(memory: UserMemoryEntity): Promise<void>;
|
||||||
|
|
||||||
|
/** 根据ID获取记忆 */
|
||||||
|
findById(id: string): Promise<UserMemoryEntity | null>;
|
||||||
|
|
||||||
|
/** 获取用户所有记忆 */
|
||||||
|
findByUserId(userId: string, options?: {
|
||||||
|
memoryType?: MemoryType;
|
||||||
|
includeExpired?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<UserMemoryEntity[]>;
|
||||||
|
|
||||||
|
/** 向量相似度搜索用户记忆 */
|
||||||
|
searchByVector(userId: string, embedding: number[], options?: {
|
||||||
|
memoryType?: MemoryType;
|
||||||
|
limit?: number;
|
||||||
|
minSimilarity?: number;
|
||||||
|
}): Promise<Array<{ memory: UserMemoryEntity; similarity: number }>>;
|
||||||
|
|
||||||
|
/** 获取用户最重要的记忆 */
|
||||||
|
findTopMemories(userId: string, limit: number): Promise<UserMemoryEntity[]>;
|
||||||
|
|
||||||
|
/** 更新记忆 */
|
||||||
|
update(memory: UserMemoryEntity): Promise<void>;
|
||||||
|
|
||||||
|
/** 删除记忆 */
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/** 删除用户所有记忆 */
|
||||||
|
deleteByUserId(userId: string): Promise<void>;
|
||||||
|
|
||||||
|
/** 标记用户过期的记忆 */
|
||||||
|
markExpiredMemories(userId: string, olderThanDays: number): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统经验仓储接口
|
||||||
|
*/
|
||||||
|
export interface ISystemExperienceRepository {
|
||||||
|
/** 保存系统经验 */
|
||||||
|
save(experience: SystemExperienceEntity): Promise<void>;
|
||||||
|
|
||||||
|
/** 根据ID获取经验 */
|
||||||
|
findById(id: string): Promise<SystemExperienceEntity | null>;
|
||||||
|
|
||||||
|
/** 获取待验证的经验 */
|
||||||
|
findPendingExperiences(options?: {
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<SystemExperienceEntity[]>;
|
||||||
|
|
||||||
|
/** 获取已激活的经验 */
|
||||||
|
findActiveExperiences(options?: {
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
category?: string;
|
||||||
|
minConfidence?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<SystemExperienceEntity[]>;
|
||||||
|
|
||||||
|
/** 向量相似度搜索经验 */
|
||||||
|
searchByVector(embedding: number[], options?: {
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
activeOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
minSimilarity?: number;
|
||||||
|
}): Promise<Array<{ experience: SystemExperienceEntity; similarity: number }>>;
|
||||||
|
|
||||||
|
/** 查找相似经验(用于合并) */
|
||||||
|
findSimilarExperiences(embedding: number[], threshold: number): Promise<SystemExperienceEntity[]>;
|
||||||
|
|
||||||
|
/** 更新经验 */
|
||||||
|
update(experience: SystemExperienceEntity): Promise<void>;
|
||||||
|
|
||||||
|
/** 删除经验 */
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
/** 获取统计数据 */
|
||||||
|
getStatistics(): Promise<{
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<VerificationStatus, number>;
|
||||||
|
byType: Record<ExperienceType, number>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_MEMORY_REPOSITORY = Symbol('IUserMemoryRepository');
|
||||||
|
export const SYSTEM_EXPERIENCE_REPOSITORY = Symbol('ISystemExperienceRepository');
|
||||||
|
|
@ -0,0 +1,422 @@
|
||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import neo4j, { Driver, Session, Transaction } from 'neo4j-driver';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neo4j知识图谱服务
|
||||||
|
* 用于存储和查询用户时间线、实体关系等
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class Neo4jService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private driver: Driver;
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const uri = this.configService.get<string>('NEO4J_URI') || 'bolt://localhost:7687';
|
||||||
|
const user = this.configService.get<string>('NEO4J_USER') || 'neo4j';
|
||||||
|
const password = this.configService.get<string>('NEO4J_PASSWORD') || 'password';
|
||||||
|
|
||||||
|
this.driver = neo4j.driver(uri, neo4j.auth.basic(user, password), {
|
||||||
|
maxConnectionPoolSize: 50,
|
||||||
|
connectionAcquisitionTimeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证连接
|
||||||
|
try {
|
||||||
|
await this.driver.verifyConnectivity();
|
||||||
|
console.log('[Neo4j] Connected successfully');
|
||||||
|
|
||||||
|
// 初始化schema
|
||||||
|
await this.initializeSchema();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Neo4j] Connection failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.driver?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话
|
||||||
|
*/
|
||||||
|
getSession(): Session {
|
||||||
|
return this.driver.session();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行读查询
|
||||||
|
*/
|
||||||
|
async read<T>(query: string, params?: Record<string, unknown>): Promise<T[]> {
|
||||||
|
const session = this.getSession();
|
||||||
|
try {
|
||||||
|
const result = await session.run(query, params);
|
||||||
|
return result.records.map(record => record.toObject() as T);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行写查询
|
||||||
|
*/
|
||||||
|
async write<T>(query: string, params?: Record<string, unknown>): Promise<T[]> {
|
||||||
|
const session = this.getSession();
|
||||||
|
try {
|
||||||
|
const result = await session.executeWrite(tx => tx.run(query, params));
|
||||||
|
return result.records.map(record => record.toObject() as T);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化图谱Schema(约束和索引)
|
||||||
|
*/
|
||||||
|
private async initializeSchema() {
|
||||||
|
const session = this.getSession();
|
||||||
|
try {
|
||||||
|
// 用户节点约束
|
||||||
|
await session.run(`
|
||||||
|
CREATE CONSTRAINT user_id IF NOT EXISTS
|
||||||
|
FOR (u:User) REQUIRE u.id IS UNIQUE
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 对话节点约束
|
||||||
|
await session.run(`
|
||||||
|
CREATE CONSTRAINT conversation_id IF NOT EXISTS
|
||||||
|
FOR (c:Conversation) REQUIRE c.id IS UNIQUE
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 知识实体约束
|
||||||
|
await session.run(`
|
||||||
|
CREATE CONSTRAINT entity_id IF NOT EXISTS
|
||||||
|
FOR (e:Entity) REQUIRE e.id IS UNIQUE
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 事件节点约束
|
||||||
|
await session.run(`
|
||||||
|
CREATE CONSTRAINT event_id IF NOT EXISTS
|
||||||
|
FOR (e:Event) REQUIRE e.id IS UNIQUE
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 时间索引
|
||||||
|
await session.run(`
|
||||||
|
CREATE INDEX event_timestamp IF NOT EXISTS
|
||||||
|
FOR (e:Event) ON (e.timestamp)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 类别索引
|
||||||
|
await session.run(`
|
||||||
|
CREATE INDEX entity_category IF NOT EXISTS
|
||||||
|
FOR (e:Entity) ON (e.category)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('[Neo4j] Schema initialized');
|
||||||
|
} catch (error) {
|
||||||
|
// 约束可能已存在,忽略错误
|
||||||
|
console.log('[Neo4j] Schema initialization skipped (may already exist)');
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 用户时间线操作 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户节点
|
||||||
|
*/
|
||||||
|
async createUserNode(userId: string, properties?: Record<string, unknown>): Promise<void> {
|
||||||
|
await this.write(
|
||||||
|
`
|
||||||
|
MERGE (u:User {id: $userId})
|
||||||
|
SET u += $properties, u.updatedAt = datetime()
|
||||||
|
`,
|
||||||
|
{ userId, properties: properties || {} },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户事件(时间线)
|
||||||
|
*/
|
||||||
|
async recordUserEvent(params: {
|
||||||
|
userId: string;
|
||||||
|
eventId: string;
|
||||||
|
eventType: string;
|
||||||
|
content: string;
|
||||||
|
timestamp?: Date;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { userId, eventId, eventType, content, metadata } = params;
|
||||||
|
const timestamp = params.timestamp || new Date();
|
||||||
|
|
||||||
|
await this.write(
|
||||||
|
`
|
||||||
|
MATCH (u:User {id: $userId})
|
||||||
|
CREATE (e:Event {
|
||||||
|
id: $eventId,
|
||||||
|
type: $eventType,
|
||||||
|
content: $content,
|
||||||
|
timestamp: datetime($timestamp),
|
||||||
|
metadata: $metadata
|
||||||
|
})
|
||||||
|
CREATE (u)-[:HAS_EVENT]->(e)
|
||||||
|
|
||||||
|
WITH u, e
|
||||||
|
OPTIONAL MATCH (u)-[:HAS_EVENT]->(prev:Event)
|
||||||
|
WHERE prev.timestamp < e.timestamp AND prev.id <> e.id
|
||||||
|
WITH e, prev
|
||||||
|
ORDER BY prev.timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOREACH (_ IN CASE WHEN prev IS NOT NULL THEN [1] ELSE [] END |
|
||||||
|
CREATE (prev)-[:FOLLOWED_BY]->(e)
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
eventId,
|
||||||
|
eventType,
|
||||||
|
content,
|
||||||
|
timestamp: timestamp.toISOString(),
|
||||||
|
metadata: JSON.stringify(metadata || {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户时间线
|
||||||
|
*/
|
||||||
|
async getUserTimeline(
|
||||||
|
userId: string,
|
||||||
|
options?: { limit?: number; beforeDate?: Date; eventTypes?: string[] },
|
||||||
|
): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}>> {
|
||||||
|
let query = `
|
||||||
|
MATCH (u:User {id: $userId})-[:HAS_EVENT]->(e:Event)
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = { userId };
|
||||||
|
|
||||||
|
if (options?.beforeDate) {
|
||||||
|
query += ` WHERE e.timestamp < datetime($beforeDate)`;
|
||||||
|
params.beforeDate = options.beforeDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.eventTypes?.length) {
|
||||||
|
const typeCondition = options.beforeDate ? ' AND' : ' WHERE';
|
||||||
|
query += `${typeCondition} e.type IN $eventTypes`;
|
||||||
|
params.eventTypes = options.eventTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
RETURN e.id as id, e.type as type, e.content as content,
|
||||||
|
e.timestamp as timestamp, e.metadata as metadata
|
||||||
|
ORDER BY e.timestamp DESC
|
||||||
|
LIMIT $limit
|
||||||
|
`;
|
||||||
|
params.limit = options?.limit || 20;
|
||||||
|
|
||||||
|
const results = await this.read<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: { toString: () => string };
|
||||||
|
metadata: string;
|
||||||
|
}>(query, params);
|
||||||
|
|
||||||
|
return results.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
type: r.type,
|
||||||
|
content: r.content,
|
||||||
|
timestamp: new Date(r.timestamp.toString()),
|
||||||
|
metadata: JSON.parse(r.metadata || '{}'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 知识图谱操作 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识实体
|
||||||
|
*/
|
||||||
|
async createEntity(params: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
category?: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.write(
|
||||||
|
`
|
||||||
|
MERGE (e:Entity {id: $id})
|
||||||
|
SET e.name = $name,
|
||||||
|
e.type = $type,
|
||||||
|
e.category = $category,
|
||||||
|
e += $properties,
|
||||||
|
e.updatedAt = datetime()
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
id: params.id,
|
||||||
|
name: params.name,
|
||||||
|
type: params.type,
|
||||||
|
category: params.category || '',
|
||||||
|
properties: params.properties || {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建实体关系
|
||||||
|
*/
|
||||||
|
async createRelation(params: {
|
||||||
|
fromId: string;
|
||||||
|
toId: string;
|
||||||
|
relationType: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
// 动态创建关系类型
|
||||||
|
const relationQuery = `
|
||||||
|
MATCH (from:Entity {id: $fromId})
|
||||||
|
MATCH (to:Entity {id: $toId})
|
||||||
|
MERGE (from)-[r:${params.relationType}]->(to)
|
||||||
|
SET r += $properties, r.updatedAt = datetime()
|
||||||
|
`;
|
||||||
|
|
||||||
|
await this.write(relationQuery, {
|
||||||
|
fromId: params.fromId,
|
||||||
|
toId: params.toId,
|
||||||
|
properties: params.properties || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询相关实体
|
||||||
|
*/
|
||||||
|
async findRelatedEntities(
|
||||||
|
entityId: string,
|
||||||
|
options?: { relationTypes?: string[]; maxDepth?: number; limit?: number },
|
||||||
|
): Promise<Array<{
|
||||||
|
entity: { id: string; name: string; type: string };
|
||||||
|
relation: string;
|
||||||
|
depth: number;
|
||||||
|
}>> {
|
||||||
|
const maxDepth = options?.maxDepth || 2;
|
||||||
|
const limit = options?.limit || 20;
|
||||||
|
|
||||||
|
let relationPattern = '*1..' + maxDepth;
|
||||||
|
if (options?.relationTypes?.length) {
|
||||||
|
relationPattern = options.relationTypes.join('|') + relationPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
MATCH path = (start:Entity {id: $entityId})-[r:${relationPattern}]-(related:Entity)
|
||||||
|
WHERE start <> related
|
||||||
|
RETURN related.id as id, related.name as name, related.type as type,
|
||||||
|
type(r) as relation, length(path) as depth
|
||||||
|
ORDER BY depth ASC
|
||||||
|
LIMIT $limit
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results = await this.read<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
relation: string;
|
||||||
|
depth: number;
|
||||||
|
}>(query, { entityId, limit });
|
||||||
|
|
||||||
|
return results.map(r => ({
|
||||||
|
entity: { id: r.id, name: r.name, type: r.type },
|
||||||
|
relation: r.relation,
|
||||||
|
depth: r.depth,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索实体
|
||||||
|
*/
|
||||||
|
async searchEntities(
|
||||||
|
keyword: string,
|
||||||
|
options?: { category?: string; type?: string; limit?: number },
|
||||||
|
): Promise<Array<{ id: string; name: string; type: string; score: number }>> {
|
||||||
|
let query = `
|
||||||
|
MATCH (e:Entity)
|
||||||
|
WHERE e.name CONTAINS $keyword
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = { keyword };
|
||||||
|
|
||||||
|
if (options?.category) {
|
||||||
|
query += ` AND e.category = $category`;
|
||||||
|
params.category = options.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.type) {
|
||||||
|
query += ` AND e.type = $type`;
|
||||||
|
params.type = options.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
query += `
|
||||||
|
RETURN e.id as id, e.name as name, e.type as type,
|
||||||
|
CASE WHEN e.name STARTS WITH $keyword THEN 1.0 ELSE 0.5 END as score
|
||||||
|
ORDER BY score DESC
|
||||||
|
LIMIT $limit
|
||||||
|
`;
|
||||||
|
params.limit = options?.limit || 10;
|
||||||
|
|
||||||
|
return this.read(query, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 对话关联 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关联对话与实体
|
||||||
|
*/
|
||||||
|
async linkConversationToEntity(
|
||||||
|
conversationId: string,
|
||||||
|
entityId: string,
|
||||||
|
mentionType: 'MENTIONED' | 'DISCUSSED' | 'ASKED_ABOUT',
|
||||||
|
): Promise<void> {
|
||||||
|
await this.write(
|
||||||
|
`
|
||||||
|
MERGE (c:Conversation {id: $conversationId})
|
||||||
|
WITH c
|
||||||
|
MATCH (e:Entity {id: $entityId})
|
||||||
|
MERGE (c)-[r:${mentionType}]->(e)
|
||||||
|
SET r.timestamp = datetime()
|
||||||
|
`,
|
||||||
|
{ conversationId, entityId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取对话相关实体
|
||||||
|
*/
|
||||||
|
async getConversationEntities(conversationId: string): Promise<Array<{
|
||||||
|
entity: { id: string; name: string; type: string };
|
||||||
|
mentionType: string;
|
||||||
|
}>> {
|
||||||
|
const results = await this.read<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
mentionType: string;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
MATCH (c:Conversation {id: $conversationId})-[r]->(e:Entity)
|
||||||
|
RETURN e.id as id, e.name as name, e.type as type, type(r) as mentionType
|
||||||
|
`,
|
||||||
|
{ conversationId },
|
||||||
|
);
|
||||||
|
|
||||||
|
return results.map(r => ({
|
||||||
|
entity: { id: r.id, name: r.name, type: r.type },
|
||||||
|
mentionType: r.mentionType,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('knowledge_articles')
|
||||||
|
@Index('idx_knowledge_articles_category', ['category'])
|
||||||
|
@Index('idx_knowledge_articles_published', ['isPublished'])
|
||||||
|
export class KnowledgeArticleORM {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ length: 500 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
summary: string;
|
||||||
|
|
||||||
|
@Column({ length: 50 })
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
@Column('text', { array: true, default: '{}' })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Column({ length: 20 })
|
||||||
|
source: string;
|
||||||
|
|
||||||
|
@Column({ name: 'source_url', length: 1000, nullable: true })
|
||||||
|
sourceUrl?: string;
|
||||||
|
|
||||||
|
@Column('float', { array: true, nullable: true })
|
||||||
|
embedding?: number[];
|
||||||
|
|
||||||
|
@Column({ name: 'is_published', default: false })
|
||||||
|
isPublished: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'citation_count', default: 0 })
|
||||||
|
citationCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'helpful_count', default: 0 })
|
||||||
|
helpfulCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'unhelpful_count', default: 0 })
|
||||||
|
unhelpfulCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'quality_score', default: 50 })
|
||||||
|
qualityScore: number;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', nullable: true })
|
||||||
|
createdBy?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', nullable: true })
|
||||||
|
updatedBy?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('knowledge_chunks')
|
||||||
|
@Index('idx_knowledge_chunks_article', ['articleId'])
|
||||||
|
export class KnowledgeChunkORM {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'article_id' })
|
||||||
|
articleId: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ name: 'chunk_index' })
|
||||||
|
chunkIndex: number;
|
||||||
|
|
||||||
|
@Column({ name: 'chunk_type', length: 20 })
|
||||||
|
chunkType: string;
|
||||||
|
|
||||||
|
@Column('float', { array: true, nullable: true })
|
||||||
|
embedding?: number[];
|
||||||
|
|
||||||
|
@Column('jsonb', { default: '{}' })
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Column({ name: 'token_count', default: 0 })
|
||||||
|
tokenCount: number;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('system_experiences')
|
||||||
|
@Index('idx_system_experiences_type', ['experienceType'])
|
||||||
|
@Index('idx_system_experiences_status', ['verificationStatus'])
|
||||||
|
@Index('idx_system_experiences_active', ['isActive'])
|
||||||
|
export class SystemExperienceORM {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'experience_type', length: 30 })
|
||||||
|
experienceType: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ default: 50 })
|
||||||
|
confidence: number;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
scenario: string;
|
||||||
|
|
||||||
|
@Column({ name: 'related_category', length: 50, nullable: true })
|
||||||
|
relatedCategory?: string;
|
||||||
|
|
||||||
|
@Column('text', { name: 'source_conversation_ids', array: true, default: '{}' })
|
||||||
|
sourceConversationIds: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'verification_status', length: 20, default: 'PENDING' })
|
||||||
|
verificationStatus: string;
|
||||||
|
|
||||||
|
@Column({ name: 'verified_by', nullable: true })
|
||||||
|
verifiedBy?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'verified_at', nullable: true })
|
||||||
|
verifiedAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'usage_count', default: 0 })
|
||||||
|
usageCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'positive_count', default: 0 })
|
||||||
|
positiveCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'negative_count', default: 0 })
|
||||||
|
negativeCount: number;
|
||||||
|
|
||||||
|
@Column('float', { array: true, nullable: true })
|
||||||
|
embedding?: number[];
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', default: false })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
Column,
|
||||||
|
PrimaryColumn,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('user_memories')
|
||||||
|
@Index('idx_user_memories_user', ['userId'])
|
||||||
|
@Index('idx_user_memories_type', ['memoryType'])
|
||||||
|
export class UserMemoryORM {
|
||||||
|
@PrimaryColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'memory_type', length: 30 })
|
||||||
|
memoryType: string;
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@Column({ default: 50 })
|
||||||
|
importance: number;
|
||||||
|
|
||||||
|
@Column({ name: 'source_conversation_id', nullable: true })
|
||||||
|
sourceConversationId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'related_category', length: 50, nullable: true })
|
||||||
|
relatedCategory?: string;
|
||||||
|
|
||||||
|
@Column('float', { array: true, nullable: true })
|
||||||
|
embedding?: number[];
|
||||||
|
|
||||||
|
@Column({ name: 'access_count', default: 0 })
|
||||||
|
accessCount: number;
|
||||||
|
|
||||||
|
@Column({ name: 'last_accessed_at', nullable: true })
|
||||||
|
lastAccessedAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'is_expired', default: false })
|
||||||
|
isExpired: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, ILike } from 'typeorm';
|
||||||
|
import { IKnowledgeRepository } from '../../../domain/repositories/knowledge.repository.interface';
|
||||||
|
import { KnowledgeArticleEntity, KnowledgeSource } from '../../../domain/entities/knowledge-article.entity';
|
||||||
|
import { KnowledgeChunkEntity, ChunkType } from '../../../domain/entities/knowledge-chunk.entity';
|
||||||
|
import { KnowledgeArticleORM } from './entities/knowledge-article.orm';
|
||||||
|
import { KnowledgeChunkORM } from './entities/knowledge-chunk.orm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class KnowledgePostgresRepository implements IKnowledgeRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(KnowledgeArticleORM)
|
||||||
|
private articleRepo: Repository<KnowledgeArticleORM>,
|
||||||
|
@InjectRepository(KnowledgeChunkORM)
|
||||||
|
private chunkRepo: Repository<KnowledgeChunkORM>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ========== 文章操作 ==========
|
||||||
|
|
||||||
|
async saveArticle(article: KnowledgeArticleEntity): Promise<void> {
|
||||||
|
const orm = this.toArticleORM(article);
|
||||||
|
await this.articleRepo.save(orm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findArticleById(id: string): Promise<KnowledgeArticleEntity | null> {
|
||||||
|
const orm = await this.articleRepo.findOne({ where: { id } });
|
||||||
|
return orm ? this.toArticleEntity(orm) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findArticlesByCategory(
|
||||||
|
category: string,
|
||||||
|
options?: { publishedOnly?: boolean; limit?: number; offset?: number },
|
||||||
|
): Promise<KnowledgeArticleEntity[]> {
|
||||||
|
const query = this.articleRepo.createQueryBuilder('article')
|
||||||
|
.where('article.category = :category', { category });
|
||||||
|
|
||||||
|
if (options?.publishedOnly) {
|
||||||
|
query.andWhere('article.isPublished = true');
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('article.qualityScore', 'DESC')
|
||||||
|
.addOrderBy('article.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
query.take(options.limit);
|
||||||
|
}
|
||||||
|
if (options?.offset) {
|
||||||
|
query.skip(options.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orms = await query.getMany();
|
||||||
|
return orms.map(orm => this.toArticleEntity(orm));
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchArticles(
|
||||||
|
queryStr: string,
|
||||||
|
options?: { category?: string; publishedOnly?: boolean; limit?: number },
|
||||||
|
): Promise<KnowledgeArticleEntity[]> {
|
||||||
|
const query = this.articleRepo.createQueryBuilder('article')
|
||||||
|
.where('(article.title ILIKE :search OR article.content ILIKE :search)', {
|
||||||
|
search: `%${queryStr}%`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options?.category) {
|
||||||
|
query.andWhere('article.category = :category', { category: options.category });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.publishedOnly) {
|
||||||
|
query.andWhere('article.isPublished = true');
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('article.qualityScore', 'DESC')
|
||||||
|
.take(options?.limit || 10);
|
||||||
|
|
||||||
|
const orms = await query.getMany();
|
||||||
|
return orms.map(orm => this.toArticleEntity(orm));
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchArticlesByVector(
|
||||||
|
embedding: number[],
|
||||||
|
options?: {
|
||||||
|
category?: string;
|
||||||
|
publishedOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
minSimilarity?: number;
|
||||||
|
},
|
||||||
|
): Promise<Array<{ article: KnowledgeArticleEntity; similarity: number }>> {
|
||||||
|
const embeddingStr = `[${embedding.join(',')}]`;
|
||||||
|
const limit = options?.limit || 5;
|
||||||
|
const minSimilarity = options?.minSimilarity || 0.7;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT *,
|
||||||
|
1 - (embedding <=> '${embeddingStr}'::vector) as similarity
|
||||||
|
FROM knowledge_articles
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options?.category) {
|
||||||
|
sql += ` AND category = '${options.category}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.publishedOnly) {
|
||||||
|
sql += ` AND is_published = true`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `
|
||||||
|
HAVING 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity}
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 使用原生查询以利用pgvector
|
||||||
|
const results = await this.articleRepo.query(sql);
|
||||||
|
|
||||||
|
return results.map((row: any) => ({
|
||||||
|
article: this.toArticleEntityFromRaw(row),
|
||||||
|
similarity: parseFloat(row.similarity),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateArticle(article: KnowledgeArticleEntity): Promise<void> {
|
||||||
|
const orm = this.toArticleORM(article);
|
||||||
|
await this.articleRepo.save(orm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteArticle(id: string): Promise<void> {
|
||||||
|
await this.articleRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async countArticles(options?: { category?: string; publishedOnly?: boolean }): Promise<number> {
|
||||||
|
const query = this.articleRepo.createQueryBuilder('article');
|
||||||
|
|
||||||
|
if (options?.category) {
|
||||||
|
query.andWhere('article.category = :category', { category: options.category });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.publishedOnly) {
|
||||||
|
query.andWhere('article.isPublished = true');
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.getCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 块操作 ==========
|
||||||
|
|
||||||
|
async saveChunk(chunk: KnowledgeChunkEntity): Promise<void> {
|
||||||
|
const orm = this.toChunkORM(chunk);
|
||||||
|
await this.chunkRepo.save(orm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunks(chunks: KnowledgeChunkEntity[]): Promise<void> {
|
||||||
|
const orms = chunks.map(chunk => this.toChunkORM(chunk));
|
||||||
|
await this.chunkRepo.save(orms);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findChunksByArticleId(articleId: string): Promise<KnowledgeChunkEntity[]> {
|
||||||
|
const orms = await this.chunkRepo.find({
|
||||||
|
where: { articleId },
|
||||||
|
order: { chunkIndex: 'ASC' },
|
||||||
|
});
|
||||||
|
return orms.map(orm => this.toChunkEntity(orm));
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchChunksByVector(
|
||||||
|
embedding: number[],
|
||||||
|
options?: {
|
||||||
|
category?: string;
|
||||||
|
limit?: number;
|
||||||
|
minSimilarity?: number;
|
||||||
|
},
|
||||||
|
): Promise<Array<{ chunk: KnowledgeChunkEntity; similarity: number }>> {
|
||||||
|
const embeddingStr = `[${embedding.join(',')}]`;
|
||||||
|
const limit = options?.limit || 5;
|
||||||
|
const minSimilarity = options?.minSimilarity || 0.7;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT c.*,
|
||||||
|
1 - (c.embedding <=> '${embeddingStr}'::vector) as similarity
|
||||||
|
FROM knowledge_chunks c
|
||||||
|
JOIN knowledge_articles a ON c.article_id = a.id
|
||||||
|
WHERE c.embedding IS NOT NULL
|
||||||
|
AND a.is_published = true
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options?.category) {
|
||||||
|
sql += ` AND a.category = '${options.category}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `
|
||||||
|
AND 1 - (c.embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity}
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results = await this.chunkRepo.query(sql);
|
||||||
|
|
||||||
|
return results.map((row: any) => ({
|
||||||
|
chunk: this.toChunkEntityFromRaw(row),
|
||||||
|
similarity: parseFloat(row.similarity),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChunksByArticleId(articleId: string): Promise<void> {
|
||||||
|
await this.chunkRepo.delete({ articleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 转换方法 ==========
|
||||||
|
|
||||||
|
private toArticleORM(entity: KnowledgeArticleEntity): KnowledgeArticleORM {
|
||||||
|
const orm = new KnowledgeArticleORM();
|
||||||
|
orm.id = entity.id;
|
||||||
|
orm.title = entity.title;
|
||||||
|
orm.content = entity.content;
|
||||||
|
orm.summary = entity.summary;
|
||||||
|
orm.category = entity.category;
|
||||||
|
orm.tags = entity.tags;
|
||||||
|
orm.source = entity.source;
|
||||||
|
orm.sourceUrl = entity.sourceUrl;
|
||||||
|
orm.embedding = entity.embedding;
|
||||||
|
orm.isPublished = entity.isPublished;
|
||||||
|
orm.citationCount = entity.citationCount;
|
||||||
|
orm.helpfulCount = entity.helpfulCount;
|
||||||
|
orm.unhelpfulCount = entity.unhelpfulCount;
|
||||||
|
orm.qualityScore = entity.qualityScore;
|
||||||
|
orm.createdBy = entity.createdBy;
|
||||||
|
orm.updatedBy = entity.updatedBy;
|
||||||
|
orm.createdAt = entity.createdAt;
|
||||||
|
orm.updatedAt = entity.updatedAt;
|
||||||
|
return orm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toArticleEntity(orm: KnowledgeArticleORM): KnowledgeArticleEntity {
|
||||||
|
return KnowledgeArticleEntity.fromPersistence({
|
||||||
|
id: orm.id,
|
||||||
|
title: orm.title,
|
||||||
|
content: orm.content,
|
||||||
|
summary: orm.summary,
|
||||||
|
category: orm.category,
|
||||||
|
tags: orm.tags,
|
||||||
|
source: orm.source as KnowledgeSource,
|
||||||
|
sourceUrl: orm.sourceUrl,
|
||||||
|
embedding: orm.embedding,
|
||||||
|
isPublished: orm.isPublished,
|
||||||
|
citationCount: orm.citationCount,
|
||||||
|
helpfulCount: orm.helpfulCount,
|
||||||
|
unhelpfulCount: orm.unhelpfulCount,
|
||||||
|
qualityScore: orm.qualityScore,
|
||||||
|
createdBy: orm.createdBy,
|
||||||
|
updatedBy: orm.updatedBy,
|
||||||
|
createdAt: orm.createdAt,
|
||||||
|
updatedAt: orm.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toArticleEntityFromRaw(row: any): KnowledgeArticleEntity {
|
||||||
|
return KnowledgeArticleEntity.fromPersistence({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
content: row.content,
|
||||||
|
summary: row.summary,
|
||||||
|
category: row.category,
|
||||||
|
tags: row.tags,
|
||||||
|
source: row.source as KnowledgeSource,
|
||||||
|
sourceUrl: row.source_url,
|
||||||
|
embedding: row.embedding,
|
||||||
|
isPublished: row.is_published,
|
||||||
|
citationCount: row.citation_count,
|
||||||
|
helpfulCount: row.helpful_count,
|
||||||
|
unhelpfulCount: row.unhelpful_count,
|
||||||
|
qualityScore: row.quality_score,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
updatedBy: row.updated_by,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: new Date(row.updated_at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChunkORM(entity: KnowledgeChunkEntity): KnowledgeChunkORM {
|
||||||
|
const orm = new KnowledgeChunkORM();
|
||||||
|
orm.id = entity.id;
|
||||||
|
orm.articleId = entity.articleId;
|
||||||
|
orm.content = entity.content;
|
||||||
|
orm.chunkIndex = entity.chunkIndex;
|
||||||
|
orm.chunkType = entity.chunkType;
|
||||||
|
orm.embedding = entity.embedding;
|
||||||
|
orm.metadata = entity.metadata as Record<string, unknown>;
|
||||||
|
orm.tokenCount = entity.tokenCount;
|
||||||
|
orm.createdAt = entity.createdAt;
|
||||||
|
return orm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChunkEntity(orm: KnowledgeChunkORM): KnowledgeChunkEntity {
|
||||||
|
return KnowledgeChunkEntity.fromPersistence({
|
||||||
|
id: orm.id,
|
||||||
|
articleId: orm.articleId,
|
||||||
|
content: orm.content,
|
||||||
|
chunkIndex: orm.chunkIndex,
|
||||||
|
chunkType: orm.chunkType as ChunkType,
|
||||||
|
embedding: orm.embedding,
|
||||||
|
metadata: orm.metadata,
|
||||||
|
tokenCount: orm.tokenCount,
|
||||||
|
createdAt: orm.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChunkEntityFromRaw(row: any): KnowledgeChunkEntity {
|
||||||
|
return KnowledgeChunkEntity.fromPersistence({
|
||||||
|
id: row.id,
|
||||||
|
articleId: row.article_id,
|
||||||
|
content: row.content,
|
||||||
|
chunkIndex: row.chunk_index,
|
||||||
|
chunkType: row.chunk_type as ChunkType,
|
||||||
|
embedding: row.embedding,
|
||||||
|
metadata: row.metadata,
|
||||||
|
tokenCount: row.token_count,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,426 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository, LessThan, MoreThan } from 'typeorm';
|
||||||
|
import {
|
||||||
|
IUserMemoryRepository,
|
||||||
|
ISystemExperienceRepository,
|
||||||
|
} from '../../../domain/repositories/memory.repository.interface';
|
||||||
|
import { UserMemoryEntity, MemoryType } from '../../../domain/entities/user-memory.entity';
|
||||||
|
import {
|
||||||
|
SystemExperienceEntity,
|
||||||
|
ExperienceType,
|
||||||
|
VerificationStatus,
|
||||||
|
} from '../../../domain/entities/system-experience.entity';
|
||||||
|
import { UserMemoryORM } from './entities/user-memory.orm';
|
||||||
|
import { SystemExperienceORM } from './entities/system-experience.orm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserMemoryPostgresRepository implements IUserMemoryRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(UserMemoryORM)
|
||||||
|
private memoryRepo: Repository<UserMemoryORM>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async save(memory: UserMemoryEntity): Promise<void> {
|
||||||
|
const orm = this.toORM(memory);
|
||||||
|
await this.memoryRepo.save(orm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<UserMemoryEntity | null> {
|
||||||
|
const orm = await this.memoryRepo.findOne({ where: { id } });
|
||||||
|
return orm ? this.toEntity(orm) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(
|
||||||
|
userId: string,
|
||||||
|
options?: { memoryType?: MemoryType; includeExpired?: boolean; limit?: number },
|
||||||
|
): Promise<UserMemoryEntity[]> {
|
||||||
|
const query = this.memoryRepo.createQueryBuilder('memory')
|
||||||
|
.where('memory.userId = :userId', { userId });
|
||||||
|
|
||||||
|
if (options?.memoryType) {
|
||||||
|
query.andWhere('memory.memoryType = :type', { type: options.memoryType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.includeExpired) {
|
||||||
|
query.andWhere('memory.isExpired = false');
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('memory.importance', 'DESC')
|
||||||
|
.addOrderBy('memory.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
query.take(options.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orms = await query.getMany();
|
||||||
|
return orms.map(orm => this.toEntity(orm));
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchByVector(
|
||||||
|
userId: string,
|
||||||
|
embedding: number[],
|
||||||
|
options?: { memoryType?: MemoryType; limit?: number; minSimilarity?: number },
|
||||||
|
): Promise<Array<{ memory: UserMemoryEntity; similarity: number }>> {
|
||||||
|
const embeddingStr = `[${embedding.join(',')}]`;
|
||||||
|
const limit = options?.limit || 5;
|
||||||
|
const minSimilarity = options?.minSimilarity || 0.7;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT *,
|
||||||
|
1 - (embedding <=> '${embeddingStr}'::vector) as similarity
|
||||||
|
FROM user_memories
|
||||||
|
WHERE user_id = '${userId}'
|
||||||
|
AND embedding IS NOT NULL
|
||||||
|
AND is_expired = false
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options?.memoryType) {
|
||||||
|
sql += ` AND memory_type = '${options.memoryType}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `
|
||||||
|
AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity}
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results = await this.memoryRepo.query(sql);
|
||||||
|
|
||||||
|
return results.map((row: any) => ({
|
||||||
|
memory: this.toEntityFromRaw(row),
|
||||||
|
similarity: parseFloat(row.similarity),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findTopMemories(userId: string, limit: number): Promise<UserMemoryEntity[]> {
|
||||||
|
const orms = await this.memoryRepo.find({
|
||||||
|
where: { userId, isExpired: false },
|
||||||
|
order: { importance: 'DESC', accessCount: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return orms.map(orm => this.toEntity(orm));
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(memory: UserMemoryEntity): Promise<void> {
|
||||||
|
const orm = this.toORM(memory);
|
||||||
|
await this.memoryRepo.save(orm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.memoryRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteByUserId(userId: string): Promise<void> {
|
||||||
|
await this.memoryRepo.delete({ userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async markExpiredMemories(userId: string, olderThanDays: number): Promise<number> {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||||
|
|
||||||
|
const result = await this.memoryRepo.update(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
isExpired: false,
|
||||||
|
updatedAt: LessThan(cutoffDate),
|
||||||
|
},
|
||||||
|
{ isExpired: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.affected || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toORM(entity: UserMemoryEntity): UserMemoryORM {
|
||||||
|
const orm = new UserMemoryORM();
|
||||||
|
orm.id = entity.id;
|
||||||
|
orm.userId = entity.userId;
|
||||||
|
orm.memoryType = entity.memoryType;
|
||||||
|
orm.content = entity.content;
|
||||||
|
orm.importance = entity.importance;
|
||||||
|
orm.sourceConversationId = entity.sourceConversationId;
|
||||||
|
orm.relatedCategory = entity.relatedCategory;
|
||||||
|
orm.embedding = entity.embedding;
|
||||||
|
orm.accessCount = entity.accessCount;
|
||||||
|
orm.lastAccessedAt = entity.lastAccessedAt;
|
||||||
|
orm.isExpired = entity.isExpired;
|
||||||
|
orm.createdAt = entity.createdAt;
|
||||||
|
orm.updatedAt = entity.updatedAt;
|
||||||
|
return orm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEntity(orm: UserMemoryORM): UserMemoryEntity {
|
||||||
|
return UserMemoryEntity.fromPersistence({
|
||||||
|
id: orm.id,
|
||||||
|
userId: orm.userId,
|
||||||
|
memoryType: orm.memoryType as MemoryType,
|
||||||
|
content: orm.content,
|
||||||
|
importance: orm.importance,
|
||||||
|
sourceConversationId: orm.sourceConversationId,
|
||||||
|
relatedCategory: orm.relatedCategory,
|
||||||
|
embedding: orm.embedding,
|
||||||
|
accessCount: orm.accessCount,
|
||||||
|
lastAccessedAt: orm.lastAccessedAt,
|
||||||
|
isExpired: orm.isExpired,
|
||||||
|
createdAt: orm.createdAt,
|
||||||
|
updatedAt: orm.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEntityFromRaw(row: any): UserMemoryEntity {
|
||||||
|
return UserMemoryEntity.fromPersistence({
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
memoryType: row.memory_type as MemoryType,
|
||||||
|
content: row.content,
|
||||||
|
importance: row.importance,
|
||||||
|
sourceConversationId: row.source_conversation_id,
|
||||||
|
relatedCategory: row.related_category,
|
||||||
|
embedding: row.embedding,
|
||||||
|
accessCount: row.access_count,
|
||||||
|
lastAccessedAt: row.last_accessed_at ? new Date(row.last_accessed_at) : undefined,
|
||||||
|
isExpired: row.is_expired,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: new Date(row.updated_at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SystemExperiencePostgresRepository implements ISystemExperienceRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SystemExperienceORM)
|
||||||
|
private experienceRepo: Repository<SystemExperienceORM>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async save(experience: SystemExperienceEntity): Promise<void> {
|
||||||
|
const orm = this.toORM(experience);
|
||||||
|
await this.experienceRepo.save(orm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<SystemExperienceEntity | null> {
|
||||||
|
const orm = await this.experienceRepo.findOne({ where: { id } });
|
||||||
|
return orm ? this.toEntity(orm) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPendingExperiences(options?: {
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<SystemExperienceEntity[]> {
|
||||||
|
const query = this.experienceRepo.createQueryBuilder('exp')
|
||||||
|
.where('exp.verificationStatus = :status', { status: VerificationStatus.PENDING });
|
||||||
|
|
||||||
|
if (options?.experienceType) {
|
||||||
|
query.andWhere('exp.experienceType = :type', { type: options.experienceType });
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('exp.confidence', 'DESC')
|
||||||
|
.addOrderBy('exp.createdAt', 'DESC');
|
||||||
|
|
||||||
|
if (options?.limit) query.take(options.limit);
|
||||||
|
if (options?.offset) query.skip(options.offset);
|
||||||
|
|
||||||
|
const orms = await query.getMany();
|
||||||
|
return orms.map(orm => this.toEntity(orm));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActiveExperiences(options?: {
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
category?: string;
|
||||||
|
minConfidence?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<SystemExperienceEntity[]> {
|
||||||
|
const query = this.experienceRepo.createQueryBuilder('exp')
|
||||||
|
.where('exp.isActive = true');
|
||||||
|
|
||||||
|
if (options?.experienceType) {
|
||||||
|
query.andWhere('exp.experienceType = :type', { type: options.experienceType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.category) {
|
||||||
|
query.andWhere('exp.relatedCategory = :category', { category: options.category });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.minConfidence) {
|
||||||
|
query.andWhere('exp.confidence >= :minConf', { minConf: options.minConfidence });
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy('exp.confidence', 'DESC');
|
||||||
|
|
||||||
|
if (options?.limit) query.take(options.limit);
|
||||||
|
|
||||||
|
const orms = await query.getMany();
|
||||||
|
return orms.map(orm => this.toEntity(orm));
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchByVector(
|
||||||
|
embedding: number[],
|
||||||
|
options?: {
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
activeOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
minSimilarity?: number;
|
||||||
|
},
|
||||||
|
): Promise<Array<{ experience: SystemExperienceEntity; similarity: number }>> {
|
||||||
|
const embeddingStr = `[${embedding.join(',')}]`;
|
||||||
|
const limit = options?.limit || 5;
|
||||||
|
const minSimilarity = options?.minSimilarity || 0.7;
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT *,
|
||||||
|
1 - (embedding <=> '${embeddingStr}'::vector) as similarity
|
||||||
|
FROM system_experiences
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (options?.activeOnly !== false) {
|
||||||
|
sql += ` AND is_active = true`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.experienceType) {
|
||||||
|
sql += ` AND experience_type = '${options.experienceType}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `
|
||||||
|
AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${minSimilarity}
|
||||||
|
ORDER BY similarity DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results = await this.experienceRepo.query(sql);
|
||||||
|
|
||||||
|
return results.map((row: any) => ({
|
||||||
|
experience: this.toEntityFromRaw(row),
|
||||||
|
similarity: parseFloat(row.similarity),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSimilarExperiences(
|
||||||
|
embedding: number[],
|
||||||
|
threshold: number,
|
||||||
|
): Promise<SystemExperienceEntity[]> {
|
||||||
|
const embeddingStr = `[${embedding.join(',')}]`;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT *
|
||||||
|
FROM system_experiences
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
AND 1 - (embedding <=> '${embeddingStr}'::vector) >= ${threshold}
|
||||||
|
ORDER BY 1 - (embedding <=> '${embeddingStr}'::vector) DESC
|
||||||
|
LIMIT 10
|
||||||
|
`;
|
||||||
|
|
||||||
|
const results = await this.experienceRepo.query(sql);
|
||||||
|
return results.map((row: any) => this.toEntityFromRaw(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(experience: SystemExperienceEntity): Promise<void> {
|
||||||
|
const orm = this.toORM(experience);
|
||||||
|
await this.experienceRepo.save(orm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.experienceRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatistics(): Promise<{
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<VerificationStatus, number>;
|
||||||
|
byType: Record<ExperienceType, number>;
|
||||||
|
}> {
|
||||||
|
const total = await this.experienceRepo.count();
|
||||||
|
|
||||||
|
const statusCounts = await this.experienceRepo
|
||||||
|
.createQueryBuilder('exp')
|
||||||
|
.select('exp.verificationStatus', 'status')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.groupBy('exp.verificationStatus')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const typeCounts = await this.experienceRepo
|
||||||
|
.createQueryBuilder('exp')
|
||||||
|
.select('exp.experienceType', 'type')
|
||||||
|
.addSelect('COUNT(*)', 'count')
|
||||||
|
.groupBy('exp.experienceType')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
const byStatus = {} as Record<VerificationStatus, number>;
|
||||||
|
statusCounts.forEach((item: any) => {
|
||||||
|
byStatus[item.status as VerificationStatus] = parseInt(item.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byType = {} as Record<ExperienceType, number>;
|
||||||
|
typeCounts.forEach((item: any) => {
|
||||||
|
byType[item.type as ExperienceType] = parseInt(item.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { total, byStatus, byType };
|
||||||
|
}
|
||||||
|
|
||||||
|
private toORM(entity: SystemExperienceEntity): SystemExperienceORM {
|
||||||
|
const orm = new SystemExperienceORM();
|
||||||
|
orm.id = entity.id;
|
||||||
|
orm.experienceType = entity.experienceType;
|
||||||
|
orm.content = entity.content;
|
||||||
|
orm.confidence = entity.confidence;
|
||||||
|
orm.scenario = entity.scenario;
|
||||||
|
orm.relatedCategory = entity.relatedCategory;
|
||||||
|
orm.sourceConversationIds = entity.sourceConversationIds;
|
||||||
|
orm.verificationStatus = entity.verificationStatus;
|
||||||
|
orm.verifiedBy = entity.verifiedBy;
|
||||||
|
orm.verifiedAt = entity.verifiedAt;
|
||||||
|
orm.usageCount = entity.usageCount;
|
||||||
|
orm.positiveCount = entity.positiveCount;
|
||||||
|
orm.negativeCount = entity.negativeCount;
|
||||||
|
orm.embedding = entity.embedding;
|
||||||
|
orm.isActive = entity.isActive;
|
||||||
|
orm.createdAt = entity.createdAt;
|
||||||
|
orm.updatedAt = entity.updatedAt;
|
||||||
|
return orm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEntity(orm: SystemExperienceORM): SystemExperienceEntity {
|
||||||
|
return SystemExperienceEntity.fromPersistence({
|
||||||
|
id: orm.id,
|
||||||
|
experienceType: orm.experienceType as ExperienceType,
|
||||||
|
content: orm.content,
|
||||||
|
confidence: orm.confidence,
|
||||||
|
scenario: orm.scenario,
|
||||||
|
relatedCategory: orm.relatedCategory,
|
||||||
|
sourceConversationIds: orm.sourceConversationIds,
|
||||||
|
verificationStatus: orm.verificationStatus as VerificationStatus,
|
||||||
|
verifiedBy: orm.verifiedBy,
|
||||||
|
verifiedAt: orm.verifiedAt,
|
||||||
|
usageCount: orm.usageCount,
|
||||||
|
positiveCount: orm.positiveCount,
|
||||||
|
negativeCount: orm.negativeCount,
|
||||||
|
embedding: orm.embedding,
|
||||||
|
isActive: orm.isActive,
|
||||||
|
createdAt: orm.createdAt,
|
||||||
|
updatedAt: orm.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toEntityFromRaw(row: any): SystemExperienceEntity {
|
||||||
|
return SystemExperienceEntity.fromPersistence({
|
||||||
|
id: row.id,
|
||||||
|
experienceType: row.experience_type as ExperienceType,
|
||||||
|
content: row.content,
|
||||||
|
confidence: row.confidence,
|
||||||
|
scenario: row.scenario,
|
||||||
|
relatedCategory: row.related_category,
|
||||||
|
sourceConversationIds: row.source_conversation_ids,
|
||||||
|
verificationStatus: row.verification_status as VerificationStatus,
|
||||||
|
verifiedBy: row.verified_by,
|
||||||
|
verifiedAt: row.verified_at ? new Date(row.verified_at) : undefined,
|
||||||
|
usageCount: row.usage_count,
|
||||||
|
positiveCount: row.positive_count,
|
||||||
|
negativeCount: row.negative_count,
|
||||||
|
embedding: row.embedding,
|
||||||
|
isActive: row.is_active,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: new Date(row.updated_at),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向量嵌入服务 - 使用OpenAI的text-embedding-3-small模型
|
||||||
|
* 用于将文本转换为向量,支持语义搜索
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EmbeddingService implements OnModuleInit {
|
||||||
|
private openai: OpenAI;
|
||||||
|
private readonly modelName = 'text-embedding-3-small';
|
||||||
|
private readonly dimensions = 1536; // text-embedding-3-small 默认维度
|
||||||
|
|
||||||
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
console.warn('[EmbeddingService] OPENAI_API_KEY not set, using mock embeddings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openai = new OpenAI({
|
||||||
|
apiKey,
|
||||||
|
baseURL: this.configService.get<string>('OPENAI_BASE_URL'), // 支持自定义endpoint
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[EmbeddingService] Initialized with OpenAI embedding model');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个文本的向量
|
||||||
|
*/
|
||||||
|
async getEmbedding(text: string): Promise<number[]> {
|
||||||
|
if (!this.openai) {
|
||||||
|
return this.getMockEmbedding(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.openai.embeddings.create({
|
||||||
|
model: this.modelName,
|
||||||
|
input: this.preprocessText(text),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data[0].embedding;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EmbeddingService] Failed to get embedding:', error);
|
||||||
|
// 降级到mock embedding
|
||||||
|
return this.getMockEmbedding(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取向量
|
||||||
|
*/
|
||||||
|
async getEmbeddings(texts: string[]): Promise<number[][]> {
|
||||||
|
if (!this.openai) {
|
||||||
|
return texts.map(text => this.getMockEmbedding(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const processedTexts = texts.map(text => this.preprocessText(text));
|
||||||
|
|
||||||
|
const response = await this.openai.embeddings.create({
|
||||||
|
model: this.modelName,
|
||||||
|
input: processedTexts,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按原始顺序返回
|
||||||
|
return response.data
|
||||||
|
.sort((a, b) => a.index - b.index)
|
||||||
|
.map(item => item.embedding);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EmbeddingService] Failed to get batch embeddings:', error);
|
||||||
|
return texts.map(text => this.getMockEmbedding(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算两个向量的余弦相似度
|
||||||
|
*/
|
||||||
|
cosineSimilarity(a: number[], b: number[]): number {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
throw new Error('Vectors must have the same length');
|
||||||
|
}
|
||||||
|
|
||||||
|
let dotProduct = 0;
|
||||||
|
let normA = 0;
|
||||||
|
let normB = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dotProduct += a[i] * b[i];
|
||||||
|
normA += a[i] * a[i];
|
||||||
|
normB += b[i] * b[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normA === 0 || normB === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取向量维度
|
||||||
|
*/
|
||||||
|
getDimensions(): number {
|
||||||
|
return this.dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预处理文本
|
||||||
|
*/
|
||||||
|
private preprocessText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\s+/g, ' ') // 合并多个空白
|
||||||
|
.trim()
|
||||||
|
.substring(0, 8000); // OpenAI限制
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成Mock向量(用于开发测试)
|
||||||
|
* 基于文本hash生成确定性的伪随机向量
|
||||||
|
*/
|
||||||
|
private getMockEmbedding(text: string): number[] {
|
||||||
|
const embedding: number[] = [];
|
||||||
|
let hash = 0;
|
||||||
|
|
||||||
|
// 简单hash
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基于hash生成伪随机向量
|
||||||
|
const random = (seed: number) => {
|
||||||
|
const x = Math.sin(seed) * 10000;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < this.dimensions; i++) {
|
||||||
|
const value = random(hash + i) * 2 - 1; // -1 to 1
|
||||||
|
embedding.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 归一化
|
||||||
|
const norm = Math.sqrt(embedding.reduce((sum, v) => sum + v * v, 0));
|
||||||
|
return embedding.map(v => v / norm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,267 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { KnowledgeService } from './knowledge.service';
|
||||||
|
import { RAGService } from '../application/services/rag.service';
|
||||||
|
import { KnowledgeSource } from '../domain/entities/knowledge-article.entity';
|
||||||
|
|
||||||
|
// ========== DTOs ==========
|
||||||
|
|
||||||
|
class CreateArticleDto {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
tags?: string[];
|
||||||
|
sourceUrl?: string;
|
||||||
|
autoPublish?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateArticleDto {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchArticlesDto {
|
||||||
|
query: string;
|
||||||
|
category?: string;
|
||||||
|
useVector?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RetrieveKnowledgeDto {
|
||||||
|
query: string;
|
||||||
|
userId?: string;
|
||||||
|
category?: string;
|
||||||
|
includeMemories?: boolean;
|
||||||
|
includeExperiences?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportArticlesDto {
|
||||||
|
articles: Array<{
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
tags?: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeedbackDto {
|
||||||
|
helpful: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Controller ==========
|
||||||
|
|
||||||
|
@Controller('knowledge')
|
||||||
|
export class KnowledgeController {
|
||||||
|
constructor(
|
||||||
|
private knowledgeService: KnowledgeService,
|
||||||
|
private ragService: RAGService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文章
|
||||||
|
*/
|
||||||
|
@Post('articles')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async createArticle(@Body() dto: CreateArticleDto) {
|
||||||
|
const article = await this.knowledgeService.createArticle({
|
||||||
|
...dto,
|
||||||
|
source: KnowledgeSource.MANUAL,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: article,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文章详情
|
||||||
|
*/
|
||||||
|
@Get('articles/:id')
|
||||||
|
async getArticle(@Param('id') id: string) {
|
||||||
|
const article = await this.knowledgeService.getArticle(id);
|
||||||
|
if (!article) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Article not found',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: article,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文章列表
|
||||||
|
*/
|
||||||
|
@Get('articles')
|
||||||
|
async listArticles(
|
||||||
|
@Query('category') category?: string,
|
||||||
|
@Query('publishedOnly') publishedOnly?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const result = await this.knowledgeService.listArticles({
|
||||||
|
category,
|
||||||
|
publishedOnly: publishedOnly === 'true',
|
||||||
|
page: page ? parseInt(page) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文章
|
||||||
|
*/
|
||||||
|
@Put('articles/:id')
|
||||||
|
async updateArticle(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateArticleDto,
|
||||||
|
) {
|
||||||
|
const article = await this.knowledgeService.updateArticle(id, dto);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: article,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文章
|
||||||
|
*/
|
||||||
|
@Delete('articles/:id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deleteArticle(@Param('id') id: string) {
|
||||||
|
await this.knowledgeService.deleteArticle(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布文章
|
||||||
|
*/
|
||||||
|
@Post('articles/:id/publish')
|
||||||
|
async publishArticle(@Param('id') id: string) {
|
||||||
|
await this.knowledgeService.publishArticle(id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Article published',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消发布
|
||||||
|
*/
|
||||||
|
@Post('articles/:id/unpublish')
|
||||||
|
async unpublishArticle(@Param('id') id: string) {
|
||||||
|
await this.knowledgeService.unpublishArticle(id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Article unpublished',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索文章
|
||||||
|
*/
|
||||||
|
@Post('articles/search')
|
||||||
|
async searchArticles(@Body() dto: SearchArticlesDto) {
|
||||||
|
const results = await this.knowledgeService.searchArticles(dto);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录反馈
|
||||||
|
*/
|
||||||
|
@Post('articles/:id/feedback')
|
||||||
|
async recordFeedback(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: FeedbackDto,
|
||||||
|
) {
|
||||||
|
await this.knowledgeService.recordFeedback(id, dto.helpful);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Feedback recorded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入
|
||||||
|
*/
|
||||||
|
@Post('articles/import')
|
||||||
|
async importArticles(@Body() dto: ImportArticlesDto) {
|
||||||
|
const result = await this.knowledgeService.importArticles(dto.articles);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取统计信息
|
||||||
|
*/
|
||||||
|
@Get('statistics')
|
||||||
|
async getStatistics() {
|
||||||
|
const stats = await this.knowledgeService.getStatistics();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RAG检索接口 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG知识检索(供对话服务调用)
|
||||||
|
*/
|
||||||
|
@Post('retrieve')
|
||||||
|
async retrieveKnowledge(@Body() dto: RetrieveKnowledgeDto) {
|
||||||
|
const result = await this.ragService.retrieve(dto);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索并格式化为提示词
|
||||||
|
*/
|
||||||
|
@Post('retrieve/prompt')
|
||||||
|
async retrieveForPrompt(@Body() dto: RetrieveKnowledgeDto) {
|
||||||
|
const context = await this.ragService.retrieveForPrompt({
|
||||||
|
query: dto.query,
|
||||||
|
userId: dto.userId,
|
||||||
|
category: dto.category,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { context },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否离题
|
||||||
|
*/
|
||||||
|
@Post('check-off-topic')
|
||||||
|
async checkOffTopic(@Body() body: { query: string }) {
|
||||||
|
const result = await this.ragService.checkOffTopic(body.query);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { KnowledgeController } from './knowledge.controller';
|
||||||
|
import { KnowledgeService } from './knowledge.service';
|
||||||
|
import { RAGService } from '../application/services/rag.service';
|
||||||
|
import { ChunkingService } from '../application/services/chunking.service';
|
||||||
|
import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
|
||||||
|
import { KnowledgePostgresRepository } from '../infrastructure/database/postgres/knowledge-postgres.repository';
|
||||||
|
import {
|
||||||
|
UserMemoryPostgresRepository,
|
||||||
|
SystemExperiencePostgresRepository,
|
||||||
|
} from '../infrastructure/database/postgres/memory-postgres.repository';
|
||||||
|
import { KnowledgeArticleORM } from '../infrastructure/database/postgres/entities/knowledge-article.orm';
|
||||||
|
import { KnowledgeChunkORM } from '../infrastructure/database/postgres/entities/knowledge-chunk.orm';
|
||||||
|
import { UserMemoryORM } from '../infrastructure/database/postgres/entities/user-memory.orm';
|
||||||
|
import { SystemExperienceORM } from '../infrastructure/database/postgres/entities/system-experience.orm';
|
||||||
|
import { KNOWLEDGE_REPOSITORY } from '../domain/repositories/knowledge.repository.interface';
|
||||||
|
import {
|
||||||
|
USER_MEMORY_REPOSITORY,
|
||||||
|
SYSTEM_EXPERIENCE_REPOSITORY,
|
||||||
|
} from '../domain/repositories/memory.repository.interface';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
KnowledgeArticleORM,
|
||||||
|
KnowledgeChunkORM,
|
||||||
|
UserMemoryORM,
|
||||||
|
SystemExperienceORM,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [KnowledgeController],
|
||||||
|
providers: [
|
||||||
|
KnowledgeService,
|
||||||
|
RAGService,
|
||||||
|
ChunkingService,
|
||||||
|
EmbeddingService,
|
||||||
|
{
|
||||||
|
provide: KNOWLEDGE_REPOSITORY,
|
||||||
|
useClass: KnowledgePostgresRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: USER_MEMORY_REPOSITORY,
|
||||||
|
useClass: UserMemoryPostgresRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SYSTEM_EXPERIENCE_REPOSITORY,
|
||||||
|
useClass: SystemExperiencePostgresRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [KnowledgeService, RAGService, EmbeddingService],
|
||||||
|
})
|
||||||
|
export class KnowledgeModule {}
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
|
||||||
|
import { ChunkingService } from '../application/services/chunking.service';
|
||||||
|
import {
|
||||||
|
IKnowledgeRepository,
|
||||||
|
KNOWLEDGE_REPOSITORY,
|
||||||
|
} from '../domain/repositories/knowledge.repository.interface';
|
||||||
|
import {
|
||||||
|
KnowledgeArticleEntity,
|
||||||
|
KnowledgeSource,
|
||||||
|
} from '../domain/entities/knowledge-article.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识管理服务
|
||||||
|
* 提供知识库的CRUD操作和处理
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class KnowledgeService {
|
||||||
|
constructor(
|
||||||
|
private embeddingService: EmbeddingService,
|
||||||
|
private chunkingService: ChunkingService,
|
||||||
|
@Inject(KNOWLEDGE_REPOSITORY)
|
||||||
|
private knowledgeRepo: IKnowledgeRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识文章
|
||||||
|
*/
|
||||||
|
async createArticle(params: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
tags?: string[];
|
||||||
|
source?: KnowledgeSource;
|
||||||
|
sourceUrl?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
autoPublish?: boolean;
|
||||||
|
}): Promise<KnowledgeArticleEntity> {
|
||||||
|
// 1. 创建文章实体
|
||||||
|
const article = KnowledgeArticleEntity.create({
|
||||||
|
title: params.title,
|
||||||
|
content: params.content,
|
||||||
|
category: params.category,
|
||||||
|
tags: params.tags,
|
||||||
|
source: params.source || KnowledgeSource.MANUAL,
|
||||||
|
sourceUrl: params.sourceUrl,
|
||||||
|
createdBy: params.createdBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 生成文章向量
|
||||||
|
const embedding = await this.embeddingService.getEmbedding(
|
||||||
|
`${article.title}\n${article.summary}`,
|
||||||
|
);
|
||||||
|
article.setEmbedding(embedding);
|
||||||
|
|
||||||
|
// 3. 自动发布(可选)
|
||||||
|
if (params.autoPublish) {
|
||||||
|
article.publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 保存文章
|
||||||
|
await this.knowledgeRepo.saveArticle(article);
|
||||||
|
|
||||||
|
// 5. 分块并保存
|
||||||
|
await this.processArticleChunks(article);
|
||||||
|
|
||||||
|
console.log(`[KnowledgeService] Created article: ${article.id} - ${article.title}`);
|
||||||
|
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文章分块
|
||||||
|
*/
|
||||||
|
private async processArticleChunks(article: KnowledgeArticleEntity): Promise<void> {
|
||||||
|
// 1. 删除旧的块
|
||||||
|
await this.knowledgeRepo.deleteChunksByArticleId(article.id);
|
||||||
|
|
||||||
|
// 2. 分块
|
||||||
|
const chunks = this.chunkingService.chunkArticle(article);
|
||||||
|
|
||||||
|
// 3. 批量生成向量
|
||||||
|
const embeddings = await this.embeddingService.getEmbeddings(
|
||||||
|
chunks.map(c => c.content),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. 设置向量
|
||||||
|
chunks.forEach((chunk, index) => {
|
||||||
|
chunk.setEmbedding(embeddings[index]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 保存块
|
||||||
|
await this.knowledgeRepo.saveChunks(chunks);
|
||||||
|
|
||||||
|
console.log(`[KnowledgeService] Processed ${chunks.length} chunks for article ${article.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文章
|
||||||
|
*/
|
||||||
|
async updateArticle(
|
||||||
|
articleId: string,
|
||||||
|
params: {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
category?: string;
|
||||||
|
tags?: string[];
|
||||||
|
updatedBy?: string;
|
||||||
|
},
|
||||||
|
): Promise<KnowledgeArticleEntity> {
|
||||||
|
const article = await this.knowledgeRepo.findArticleById(articleId);
|
||||||
|
if (!article) {
|
||||||
|
throw new Error(`Article not found: ${articleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
if (params.title || params.content) {
|
||||||
|
article.updateContent(
|
||||||
|
params.title || article.title,
|
||||||
|
params.content || article.content,
|
||||||
|
params.updatedBy,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 重新生成向量
|
||||||
|
const embedding = await this.embeddingService.getEmbedding(
|
||||||
|
`${article.title}\n${article.summary}`,
|
||||||
|
);
|
||||||
|
article.setEmbedding(embedding);
|
||||||
|
|
||||||
|
// 重新分块
|
||||||
|
await this.processArticleChunks(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.category) {
|
||||||
|
article.category = params.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.tags) {
|
||||||
|
article.tags = params.tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.knowledgeRepo.updateArticle(article);
|
||||||
|
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文章详情
|
||||||
|
*/
|
||||||
|
async getArticle(articleId: string): Promise<KnowledgeArticleEntity | null> {
|
||||||
|
return this.knowledgeRepo.findArticleById(articleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文章列表
|
||||||
|
*/
|
||||||
|
async listArticles(params: {
|
||||||
|
category?: string;
|
||||||
|
publishedOnly?: boolean;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{
|
||||||
|
items: KnowledgeArticleEntity[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}> {
|
||||||
|
const page = params.page || 1;
|
||||||
|
const pageSize = params.pageSize || 20;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
this.knowledgeRepo.findArticlesByCategory(params.category || '', {
|
||||||
|
publishedOnly: params.publishedOnly,
|
||||||
|
limit: pageSize,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
this.knowledgeRepo.countArticles({
|
||||||
|
category: params.category,
|
||||||
|
publishedOnly: params.publishedOnly,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索文章
|
||||||
|
*/
|
||||||
|
async searchArticles(params: {
|
||||||
|
query: string;
|
||||||
|
category?: string;
|
||||||
|
useVector?: boolean;
|
||||||
|
}): Promise<Array<{
|
||||||
|
article: KnowledgeArticleEntity;
|
||||||
|
similarity?: number;
|
||||||
|
}>> {
|
||||||
|
if (params.useVector) {
|
||||||
|
// 向量搜索
|
||||||
|
const embedding = await this.embeddingService.getEmbedding(params.query);
|
||||||
|
const results = await this.knowledgeRepo.searchArticlesByVector(embedding, {
|
||||||
|
category: params.category,
|
||||||
|
publishedOnly: true,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键词搜索
|
||||||
|
const articles = await this.knowledgeRepo.searchArticles(params.query, {
|
||||||
|
category: params.category,
|
||||||
|
publishedOnly: true,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
return articles.map(article => ({ article }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布文章
|
||||||
|
*/
|
||||||
|
async publishArticle(articleId: string): Promise<void> {
|
||||||
|
const article = await this.knowledgeRepo.findArticleById(articleId);
|
||||||
|
if (!article) {
|
||||||
|
throw new Error(`Article not found: ${articleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
article.publish();
|
||||||
|
await this.knowledgeRepo.updateArticle(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消发布
|
||||||
|
*/
|
||||||
|
async unpublishArticle(articleId: string): Promise<void> {
|
||||||
|
const article = await this.knowledgeRepo.findArticleById(articleId);
|
||||||
|
if (!article) {
|
||||||
|
throw new Error(`Article not found: ${articleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
article.unpublish();
|
||||||
|
await this.knowledgeRepo.updateArticle(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文章
|
||||||
|
*/
|
||||||
|
async deleteArticle(articleId: string): Promise<void> {
|
||||||
|
// 先删除分块
|
||||||
|
await this.knowledgeRepo.deleteChunksByArticleId(articleId);
|
||||||
|
// 再删除文章
|
||||||
|
await this.knowledgeRepo.deleteArticle(articleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户反馈
|
||||||
|
*/
|
||||||
|
async recordFeedback(articleId: string, helpful: boolean): Promise<void> {
|
||||||
|
const article = await this.knowledgeRepo.findArticleById(articleId);
|
||||||
|
if (!article) {
|
||||||
|
throw new Error(`Article not found: ${articleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
article.recordFeedback(helpful);
|
||||||
|
await this.knowledgeRepo.updateArticle(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入文章
|
||||||
|
*/
|
||||||
|
async importArticles(
|
||||||
|
articles: Array<{
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
tags?: string[];
|
||||||
|
}>,
|
||||||
|
createdBy?: string,
|
||||||
|
): Promise<{ success: number; failed: number; errors: string[] }> {
|
||||||
|
let success = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const articleData of articles) {
|
||||||
|
try {
|
||||||
|
await this.createArticle({
|
||||||
|
...articleData,
|
||||||
|
source: KnowledgeSource.IMPORT,
|
||||||
|
createdBy,
|
||||||
|
autoPublish: false, // 导入后需要审核
|
||||||
|
});
|
||||||
|
success++;
|
||||||
|
} catch (error) {
|
||||||
|
failed++;
|
||||||
|
errors.push(`Failed to import "${articleData.title}": ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success, failed, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识库统计
|
||||||
|
*/
|
||||||
|
async getStatistics(): Promise<{
|
||||||
|
totalArticles: number;
|
||||||
|
publishedArticles: number;
|
||||||
|
byCategory: Record<string, number>;
|
||||||
|
recentArticles: KnowledgeArticleEntity[];
|
||||||
|
}> {
|
||||||
|
const categories = ['QMAS', 'GEP', 'IANG', 'TTPS', 'CIES', 'TechTAS', 'GENERAL'];
|
||||||
|
|
||||||
|
const [total, published, byCategoryResults, recent] = await Promise.all([
|
||||||
|
this.knowledgeRepo.countArticles(),
|
||||||
|
this.knowledgeRepo.countArticles({ publishedOnly: true }),
|
||||||
|
Promise.all(
|
||||||
|
categories.map(async cat => ({
|
||||||
|
category: cat,
|
||||||
|
count: await this.knowledgeRepo.countArticles({ category: cat }),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
this.knowledgeRepo.findArticlesByCategory('', { limit: 5 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byCategory: Record<string, number> = {};
|
||||||
|
byCategoryResults.forEach(r => {
|
||||||
|
byCategory[r.category] = r.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalArticles: total,
|
||||||
|
publishedArticles: published,
|
||||||
|
byCategory,
|
||||||
|
recentArticles: recent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// 设置全局前缀
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
|
// 启用CORS
|
||||||
|
app.enableCors({
|
||||||
|
origin: process.env.CORS_ORIGIN || '*',
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3004;
|
||||||
|
await app.listen(port);
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
╔══════════════════════════════════════════════════════════════╗
|
||||||
|
║ ║
|
||||||
|
║ 🧠 iConsulting Knowledge Service ║
|
||||||
|
║ ║
|
||||||
|
║ Server running at: http://localhost:${port} ║
|
||||||
|
║ API prefix: /api/v1 ║
|
||||||
|
║ ║
|
||||||
|
║ Endpoints: ║
|
||||||
|
║ - POST /api/v1/knowledge/articles Create article ║
|
||||||
|
║ - GET /api/v1/knowledge/articles List articles ║
|
||||||
|
║ - POST /api/v1/knowledge/retrieve RAG retrieval ║
|
||||||
|
║ - POST /api/v1/memory/user Save memory ║
|
||||||
|
║ - POST /api/v1/memory/experience Save experience ║
|
||||||
|
║ - GET /api/v1/memory/experience/pending Pending exp. ║
|
||||||
|
║ ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { MemoryService } from './memory.service';
|
||||||
|
import { MemoryType } from '../domain/entities/user-memory.entity';
|
||||||
|
import { ExperienceType } from '../domain/entities/system-experience.entity';
|
||||||
|
|
||||||
|
// ========== DTOs ==========
|
||||||
|
|
||||||
|
class SaveMemoryDto {
|
||||||
|
userId: string;
|
||||||
|
memoryType: MemoryType;
|
||||||
|
content: string;
|
||||||
|
importance?: number;
|
||||||
|
sourceConversationId?: string;
|
||||||
|
relatedCategory?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchMemoryDto {
|
||||||
|
userId: string;
|
||||||
|
query: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExtractExperienceDto {
|
||||||
|
experienceType: ExperienceType;
|
||||||
|
content: string;
|
||||||
|
scenario: string;
|
||||||
|
relatedCategory?: string;
|
||||||
|
sourceConversationId: string;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FeedbackDto {
|
||||||
|
positive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Controller ==========
|
||||||
|
|
||||||
|
@Controller('memory')
|
||||||
|
export class MemoryController {
|
||||||
|
constructor(private memoryService: MemoryService) {}
|
||||||
|
|
||||||
|
// ========== 用户记忆 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存用户记忆
|
||||||
|
*/
|
||||||
|
@Post('user')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async saveUserMemory(@Body() dto: SaveMemoryDto) {
|
||||||
|
const memory = await this.memoryService.saveUserMemory(dto);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: memory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索用户相关记忆
|
||||||
|
*/
|
||||||
|
@Post('user/search')
|
||||||
|
async searchUserMemories(@Body() dto: SearchMemoryDto) {
|
||||||
|
const memories = await this.memoryService.getUserRelevantMemories(
|
||||||
|
dto.userId,
|
||||||
|
dto.query,
|
||||||
|
{ limit: dto.limit },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: memories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户最重要的记忆
|
||||||
|
*/
|
||||||
|
@Get('user/:userId/top')
|
||||||
|
async getUserTopMemories(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
const memories = await this.memoryService.getUserTopMemories(
|
||||||
|
userId,
|
||||||
|
limit ? parseInt(limit) : 5,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: memories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有记忆
|
||||||
|
*/
|
||||||
|
@Get('user/:userId')
|
||||||
|
async getUserMemories(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Query('type') memoryType?: MemoryType,
|
||||||
|
@Query('includeExpired') includeExpired?: string,
|
||||||
|
) {
|
||||||
|
const memories = await this.memoryService.getUserMemories(userId, {
|
||||||
|
memoryType,
|
||||||
|
includeExpired: includeExpired === 'true',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: memories,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新记忆重要性
|
||||||
|
*/
|
||||||
|
@Put('user/memory/:id/importance')
|
||||||
|
async updateMemoryImportance(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { importance: number },
|
||||||
|
) {
|
||||||
|
await this.memoryService.updateMemoryImportance(id, body.importance);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Importance updated',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记记忆为过期
|
||||||
|
*/
|
||||||
|
@Post('user/memory/:id/expire')
|
||||||
|
async expireMemory(@Param('id') id: string) {
|
||||||
|
await this.memoryService.expireMemory(id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Memory expired',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户所有记忆
|
||||||
|
*/
|
||||||
|
@Delete('user/:userId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deleteUserMemories(@Param('userId') userId: string) {
|
||||||
|
await this.memoryService.deleteUserMemories(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户时间线
|
||||||
|
*/
|
||||||
|
@Get('user/:userId/timeline')
|
||||||
|
async getUserTimeline(
|
||||||
|
@Param('userId') userId: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
const timeline = await this.memoryService.getUserTimeline(userId, {
|
||||||
|
limit: limit ? parseInt(limit) : 20,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: timeline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 系统经验 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取并保存经验
|
||||||
|
*/
|
||||||
|
@Post('experience')
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
async extractExperience(@Body() dto: ExtractExperienceDto) {
|
||||||
|
const experience = await this.memoryService.extractAndSaveExperience(dto);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: experience,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索相关经验
|
||||||
|
*/
|
||||||
|
@Post('experience/search')
|
||||||
|
async searchExperiences(@Body() body: {
|
||||||
|
query: string;
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
category?: string;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const experiences = await this.memoryService.getRelevantExperiences(body.query, {
|
||||||
|
experienceType: body.experienceType,
|
||||||
|
category: body.category,
|
||||||
|
limit: body.limit,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: experiences,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取待验证的经验
|
||||||
|
*/
|
||||||
|
@Get('experience/pending')
|
||||||
|
async getPendingExperiences(
|
||||||
|
@Query('type') experienceType?: ExperienceType,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const result = await this.memoryService.getPendingExperiences({
|
||||||
|
experienceType,
|
||||||
|
page: page ? parseInt(page) : 1,
|
||||||
|
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批经验
|
||||||
|
*/
|
||||||
|
@Post('experience/:id/approve')
|
||||||
|
async approveExperience(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { adminId: string },
|
||||||
|
) {
|
||||||
|
await this.memoryService.approveExperience(id, body.adminId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Experience approved',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒绝经验
|
||||||
|
*/
|
||||||
|
@Post('experience/:id/reject')
|
||||||
|
async rejectExperience(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { adminId: string },
|
||||||
|
) {
|
||||||
|
await this.memoryService.rejectExperience(id, body.adminId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Experience rejected',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录经验反馈
|
||||||
|
*/
|
||||||
|
@Post('experience/:id/feedback')
|
||||||
|
async recordExperienceFeedback(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: FeedbackDto,
|
||||||
|
) {
|
||||||
|
await this.memoryService.recordExperienceFeedback(id, dto.positive);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Feedback recorded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取经验统计
|
||||||
|
*/
|
||||||
|
@Get('experience/statistics')
|
||||||
|
async getExperienceStatistics() {
|
||||||
|
const stats = await this.memoryService.getExperienceStatistics();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { MemoryController } from './memory.controller';
|
||||||
|
import { MemoryService } from './memory.service';
|
||||||
|
import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
|
||||||
|
import { Neo4jService } from '../infrastructure/database/neo4j/neo4j.service';
|
||||||
|
import {
|
||||||
|
UserMemoryPostgresRepository,
|
||||||
|
SystemExperiencePostgresRepository,
|
||||||
|
} from '../infrastructure/database/postgres/memory-postgres.repository';
|
||||||
|
import { UserMemoryORM } from '../infrastructure/database/postgres/entities/user-memory.orm';
|
||||||
|
import { SystemExperienceORM } from '../infrastructure/database/postgres/entities/system-experience.orm';
|
||||||
|
import {
|
||||||
|
USER_MEMORY_REPOSITORY,
|
||||||
|
SYSTEM_EXPERIENCE_REPOSITORY,
|
||||||
|
} from '../domain/repositories/memory.repository.interface';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
UserMemoryORM,
|
||||||
|
SystemExperienceORM,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
controllers: [MemoryController],
|
||||||
|
providers: [
|
||||||
|
MemoryService,
|
||||||
|
EmbeddingService,
|
||||||
|
Neo4jService,
|
||||||
|
{
|
||||||
|
provide: USER_MEMORY_REPOSITORY,
|
||||||
|
useClass: UserMemoryPostgresRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SYSTEM_EXPERIENCE_REPOSITORY,
|
||||||
|
useClass: SystemExperiencePostgresRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [MemoryService, Neo4jService],
|
||||||
|
})
|
||||||
|
export class MemoryModule {}
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
|
import { EmbeddingService } from '../infrastructure/embedding/embedding.service';
|
||||||
|
import { Neo4jService } from '../infrastructure/database/neo4j/neo4j.service';
|
||||||
|
import {
|
||||||
|
IUserMemoryRepository,
|
||||||
|
ISystemExperienceRepository,
|
||||||
|
USER_MEMORY_REPOSITORY,
|
||||||
|
SYSTEM_EXPERIENCE_REPOSITORY,
|
||||||
|
} from '../domain/repositories/memory.repository.interface';
|
||||||
|
import { UserMemoryEntity, MemoryType } from '../domain/entities/user-memory.entity';
|
||||||
|
import {
|
||||||
|
SystemExperienceEntity,
|
||||||
|
ExperienceType,
|
||||||
|
VerificationStatus,
|
||||||
|
} from '../domain/entities/system-experience.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记忆管理服务
|
||||||
|
* 管理用户长期记忆和系统经验
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MemoryService {
|
||||||
|
constructor(
|
||||||
|
private embeddingService: EmbeddingService,
|
||||||
|
private neo4jService: Neo4jService,
|
||||||
|
@Inject(USER_MEMORY_REPOSITORY)
|
||||||
|
private memoryRepo: IUserMemoryRepository,
|
||||||
|
@Inject(SYSTEM_EXPERIENCE_REPOSITORY)
|
||||||
|
private experienceRepo: ISystemExperienceRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ========== 用户记忆 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存用户记忆
|
||||||
|
*/
|
||||||
|
async saveUserMemory(params: {
|
||||||
|
userId: string;
|
||||||
|
memoryType: MemoryType;
|
||||||
|
content: string;
|
||||||
|
importance?: number;
|
||||||
|
sourceConversationId?: string;
|
||||||
|
relatedCategory?: string;
|
||||||
|
}): Promise<UserMemoryEntity> {
|
||||||
|
const memory = UserMemoryEntity.create(params);
|
||||||
|
|
||||||
|
// 生成向量
|
||||||
|
const embedding = await this.embeddingService.getEmbedding(params.content);
|
||||||
|
memory.setEmbedding(embedding);
|
||||||
|
|
||||||
|
// 保存到PostgreSQL
|
||||||
|
await this.memoryRepo.save(memory);
|
||||||
|
|
||||||
|
// 同时记录到Neo4j时间线
|
||||||
|
await this.neo4jService.recordUserEvent({
|
||||||
|
userId: params.userId,
|
||||||
|
eventId: memory.id,
|
||||||
|
eventType: `MEMORY_${params.memoryType}`,
|
||||||
|
content: params.content,
|
||||||
|
metadata: {
|
||||||
|
importance: params.importance,
|
||||||
|
relatedCategory: params.relatedCategory,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[MemoryService] Saved memory for user ${params.userId}: ${params.memoryType}`);
|
||||||
|
|
||||||
|
return memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的相关记忆
|
||||||
|
*/
|
||||||
|
async getUserRelevantMemories(
|
||||||
|
userId: string,
|
||||||
|
query: string,
|
||||||
|
options?: { limit?: number; memoryTypes?: MemoryType[] },
|
||||||
|
): Promise<UserMemoryEntity[]> {
|
||||||
|
const embedding = await this.embeddingService.getEmbedding(query);
|
||||||
|
|
||||||
|
const results = await this.memoryRepo.searchByVector(userId, embedding, {
|
||||||
|
limit: options?.limit || 5,
|
||||||
|
minSimilarity: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录访问
|
||||||
|
for (const { memory } of results) {
|
||||||
|
memory.recordAccess();
|
||||||
|
await this.memoryRepo.update(memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map(r => r.memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户最重要的记忆(用于对话上下文)
|
||||||
|
*/
|
||||||
|
async getUserTopMemories(userId: string, limit = 5): Promise<UserMemoryEntity[]> {
|
||||||
|
return this.memoryRepo.findTopMemories(userId, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有记忆
|
||||||
|
*/
|
||||||
|
async getUserMemories(
|
||||||
|
userId: string,
|
||||||
|
options?: { memoryType?: MemoryType; includeExpired?: boolean },
|
||||||
|
): Promise<UserMemoryEntity[]> {
|
||||||
|
return this.memoryRepo.findByUserId(userId, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新记忆重要性
|
||||||
|
*/
|
||||||
|
async updateMemoryImportance(memoryId: string, importance: number): Promise<void> {
|
||||||
|
const memory = await this.memoryRepo.findById(memoryId);
|
||||||
|
if (memory) {
|
||||||
|
memory.updateImportance(importance);
|
||||||
|
await this.memoryRepo.update(memory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记记忆为过期
|
||||||
|
*/
|
||||||
|
async expireMemory(memoryId: string): Promise<void> {
|
||||||
|
const memory = await this.memoryRepo.findById(memoryId);
|
||||||
|
if (memory) {
|
||||||
|
memory.markAsExpired();
|
||||||
|
await this.memoryRepo.update(memory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期记忆
|
||||||
|
*/
|
||||||
|
async cleanupExpiredMemories(userId: string, olderThanDays = 180): Promise<number> {
|
||||||
|
return this.memoryRepo.markExpiredMemories(userId, olderThanDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户所有记忆(GDPR合规)
|
||||||
|
*/
|
||||||
|
async deleteUserMemories(userId: string): Promise<void> {
|
||||||
|
await this.memoryRepo.deleteByUserId(userId);
|
||||||
|
console.log(`[MemoryService] Deleted all memories for user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 系统经验 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取并保存系统经验
|
||||||
|
*/
|
||||||
|
async extractAndSaveExperience(params: {
|
||||||
|
experienceType: ExperienceType;
|
||||||
|
content: string;
|
||||||
|
scenario: string;
|
||||||
|
relatedCategory?: string;
|
||||||
|
sourceConversationId: string;
|
||||||
|
confidence?: number;
|
||||||
|
}): Promise<SystemExperienceEntity> {
|
||||||
|
// 生成向量
|
||||||
|
const embedding = await this.embeddingService.getEmbedding(
|
||||||
|
`${params.scenario}\n${params.content}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 查找相似经验(用于合并)
|
||||||
|
const similarExperiences = await this.experienceRepo.findSimilarExperiences(
|
||||||
|
embedding,
|
||||||
|
0.9, // 高阈值,只合并非常相似的
|
||||||
|
);
|
||||||
|
|
||||||
|
if (similarExperiences.length > 0) {
|
||||||
|
// 合并到现有经验
|
||||||
|
const existingExperience = similarExperiences[0];
|
||||||
|
existingExperience.addSourceConversation(params.sourceConversationId);
|
||||||
|
existingExperience.setEmbedding(embedding); // 可以选择更新向量
|
||||||
|
await this.experienceRepo.update(existingExperience);
|
||||||
|
|
||||||
|
console.log(`[MemoryService] Merged experience into ${existingExperience.id}`);
|
||||||
|
return existingExperience;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新经验
|
||||||
|
const experience = SystemExperienceEntity.create(params);
|
||||||
|
experience.setEmbedding(embedding);
|
||||||
|
await this.experienceRepo.save(experience);
|
||||||
|
|
||||||
|
console.log(`[MemoryService] Created new experience: ${experience.id}`);
|
||||||
|
return experience;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取相关系统经验
|
||||||
|
*/
|
||||||
|
async getRelevantExperiences(
|
||||||
|
query: string,
|
||||||
|
options?: {
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
category?: string;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
): Promise<SystemExperienceEntity[]> {
|
||||||
|
const embedding = await this.embeddingService.getEmbedding(query);
|
||||||
|
|
||||||
|
const results = await this.experienceRepo.searchByVector(embedding, {
|
||||||
|
experienceType: options?.experienceType,
|
||||||
|
activeOnly: true,
|
||||||
|
limit: options?.limit || 5,
|
||||||
|
minSimilarity: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录使用
|
||||||
|
for (const { experience } of results) {
|
||||||
|
experience.recordUsage();
|
||||||
|
await this.experienceRepo.update(experience);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map(r => r.experience);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取待验证的经验
|
||||||
|
*/
|
||||||
|
async getPendingExperiences(options?: {
|
||||||
|
experienceType?: ExperienceType;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{
|
||||||
|
items: SystemExperienceEntity[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const page = options?.page || 1;
|
||||||
|
const pageSize = options?.pageSize || 20;
|
||||||
|
|
||||||
|
const items = await this.experienceRepo.findPendingExperiences({
|
||||||
|
experienceType: options?.experienceType,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: (page - 1) * pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 简单计数(实际应该有专门的count方法)
|
||||||
|
const stats = await this.experienceRepo.getStatistics();
|
||||||
|
const total = stats.byStatus[VerificationStatus.PENDING] || 0;
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批经验
|
||||||
|
*/
|
||||||
|
async approveExperience(experienceId: string, adminId: string): Promise<void> {
|
||||||
|
const experience = await this.experienceRepo.findById(experienceId);
|
||||||
|
if (!experience) {
|
||||||
|
throw new Error(`Experience not found: ${experienceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
experience.approve(adminId);
|
||||||
|
await this.experienceRepo.update(experience);
|
||||||
|
|
||||||
|
console.log(`[MemoryService] Experience ${experienceId} approved by ${adminId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒绝经验
|
||||||
|
*/
|
||||||
|
async rejectExperience(experienceId: string, adminId: string): Promise<void> {
|
||||||
|
const experience = await this.experienceRepo.findById(experienceId);
|
||||||
|
if (!experience) {
|
||||||
|
throw new Error(`Experience not found: ${experienceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
experience.reject(adminId);
|
||||||
|
await this.experienceRepo.update(experience);
|
||||||
|
|
||||||
|
console.log(`[MemoryService] Experience ${experienceId} rejected by ${adminId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录经验反馈
|
||||||
|
*/
|
||||||
|
async recordExperienceFeedback(experienceId: string, positive: boolean): Promise<void> {
|
||||||
|
const experience = await this.experienceRepo.findById(experienceId);
|
||||||
|
if (experience) {
|
||||||
|
experience.recordFeedback(positive);
|
||||||
|
await this.experienceRepo.update(experience);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取经验统计
|
||||||
|
*/
|
||||||
|
async getExperienceStatistics(): Promise<{
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<VerificationStatus, number>;
|
||||||
|
byType: Record<ExperienceType, number>;
|
||||||
|
}> {
|
||||||
|
return this.experienceRepo.getStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 用户时间线(Neo4j) ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户时间线
|
||||||
|
*/
|
||||||
|
async getUserTimeline(
|
||||||
|
userId: string,
|
||||||
|
options?: {
|
||||||
|
limit?: number;
|
||||||
|
beforeDate?: Date;
|
||||||
|
eventTypes?: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return this.neo4jService.getUserTimeline(userId, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化用户节点
|
||||||
|
*/
|
||||||
|
async initializeUserNode(userId: string, properties?: Record<string, unknown>): Promise<void> {
|
||||||
|
await this.neo4jService.createUserNode(userId, properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# ===========================================
|
||||||
|
# iConsulting Payment Service Dockerfile
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
|
COPY packages/services/payment-service/package.json ./packages/services/payment-service/
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
COPY packages/services/payment-service ./packages/services/payment-service
|
||||||
|
COPY tsconfig.base.json ./
|
||||||
|
|
||||||
|
RUN pnpm --filter @iconsulting/shared build
|
||||||
|
RUN pnpm --filter @iconsulting/payment-service build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 nestjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/packages/services/payment-service/dist ./dist
|
||||||
|
COPY --from=builder /app/packages/services/payment-service/package.json ./
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3002
|
||||||
|
|
||||||
|
USER nestjs
|
||||||
|
EXPOSE 3002
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "@iconsulting/payment-service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Payment service for handling Alipay, WeChat Pay, and Stripe",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
|
"test": "jest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0",
|
||||||
|
"@nestjs/config": "^3.2.0",
|
||||||
|
"@nestjs/core": "^10.0.0",
|
||||||
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/typeorm": "^10.0.0",
|
||||||
|
"typeorm": "^0.3.19",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"alipay-sdk": "^3.6.0",
|
||||||
|
"stripe": "^14.0.0",
|
||||||
|
"qrcode": "^1.5.0",
|
||||||
|
"rxjs": "^7.8.0",
|
||||||
|
"class-validator": "^0.14.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"@iconsulting/shared": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
"@nestjs/testing": "^10.0.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/qrcode": "^1.5.0",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/uuid": "^9.0.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { PaymentModule } from './payment/payment.module';
|
||||||
|
import { OrderModule } from './order/order.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
envFilePath: ['.env.local', '.env'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: configService.get('POSTGRES_HOST', 'localhost'),
|
||||||
|
port: configService.get<number>('POSTGRES_PORT', 5432),
|
||||||
|
username: configService.get('POSTGRES_USER', 'iconsulting'),
|
||||||
|
password: configService.get('POSTGRES_PASSWORD'),
|
||||||
|
database: configService.get('POSTGRES_DB', 'iconsulting'),
|
||||||
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
|
synchronize: configService.get('NODE_ENV') === 'development',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
OrderModule,
|
||||||
|
PaymentModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { PaymentEntity } from './payment.entity';
|
||||||
|
|
||||||
|
export enum OrderStatus {
|
||||||
|
CREATED = 'CREATED',
|
||||||
|
PENDING_PAYMENT = 'PENDING_PAYMENT',
|
||||||
|
PAID = 'PAID',
|
||||||
|
PROCESSING = 'PROCESSING',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
CANCELLED = 'CANCELLED',
|
||||||
|
REFUNDED = 'REFUNDED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ServiceType {
|
||||||
|
ASSESSMENT = 'ASSESSMENT',
|
||||||
|
CONSULTATION = 'CONSULTATION',
|
||||||
|
DOCUMENT_REVIEW = 'DOCUMENT_REVIEW',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('orders')
|
||||||
|
export class OrderEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'conversation_id', type: 'uuid', nullable: true })
|
||||||
|
conversationId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'service_type',
|
||||||
|
type: 'enum',
|
||||||
|
enum: ServiceType,
|
||||||
|
})
|
||||||
|
serviceType: ServiceType;
|
||||||
|
|
||||||
|
@Column({ name: 'service_category', nullable: true })
|
||||||
|
serviceCategory: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Column({ default: 'CNY' })
|
||||||
|
currency: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: OrderStatus,
|
||||||
|
default: OrderStatus.CREATED,
|
||||||
|
})
|
||||||
|
status: OrderStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_method', nullable: true })
|
||||||
|
paymentMethod: string;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_id', type: 'uuid', nullable: true })
|
||||||
|
paymentId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'paid_at', nullable: true })
|
||||||
|
paidAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', nullable: true })
|
||||||
|
completedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => PaymentEntity, (payment) => payment.order)
|
||||||
|
payments: PaymentEntity[];
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue