Compare commits
5 Commits
f4e166c5ee
...
3bfa59cf3e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bfa59cf3e | |||
| a6234e7bb0 | |||
| 81b552b689 | |||
| 032d0ccdf0 | |||
| 689e8af3df |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -23,5 +23,7 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
uploads
|
||||
docs
|
||||
.claude
|
||||
#docs
|
||||
.claude
|
||||
plugs
|
||||
sillytavern
|
||||
67
docs/README.md
Normal file
67
docs/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 云酒馆项目文档
|
||||
|
||||
## 目录
|
||||
|
||||
- [项目概述](./overview.md)
|
||||
- [架构设计](./architecture.md)
|
||||
- [API 文档](./api.md)
|
||||
- [前端开发指南](./frontend-guide.md)
|
||||
- [后端开发指南](./backend-guide.md)
|
||||
- [部署指南](./deployment.md)
|
||||
- [设计系统](./design-system/)
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
云酒馆平台
|
||||
├── 前端 (web-app)
|
||||
│ ├── 公共页面(首页、角色广场、角色详情)
|
||||
│ ├── 用户系统(登录、注册、用户中心)
|
||||
│ ├── 管理功能(角色卡管理、预设管理)
|
||||
│ └── 对话功能(聊天界面、历史记录)
|
||||
│
|
||||
└── 后端 (server)
|
||||
├── 用户认证 (JWT)
|
||||
├── 角色卡 API
|
||||
├── 对话管理 API
|
||||
├── 预设管理 API
|
||||
└── AI 集成
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 角色卡系统
|
||||
- 支持 PNG 格式角色卡(嵌入 JSON 数据)
|
||||
- 支持纯 JSON 格式角色卡
|
||||
- 角色信息编辑
|
||||
- 导入导出功能
|
||||
|
||||
### 预设系统
|
||||
- 支持多种预设格式(SillyTavern、TavernAI 等)
|
||||
- 参数配置(Temperature、Top P、Top K 等)
|
||||
- 预设复制和导出
|
||||
|
||||
### 对话系统
|
||||
- 实时消息发送
|
||||
- 对话历史管理
|
||||
- 多角色对话支持
|
||||
- 对话导出功能
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 代码风格
|
||||
- 前端:ESLint + Prettier
|
||||
- 后端:ESLint
|
||||
- 提交信息:Conventional Commits
|
||||
|
||||
### Git 工作流
|
||||
- main: 生产环境
|
||||
- develop: 开发环境
|
||||
- feature/*: 功能分支
|
||||
- bugfix/*: 修复分支
|
||||
|
||||
## 快速链接
|
||||
|
||||
- [前端 README](../web-app/README.md)
|
||||
- [后端 README](../server/README.md)
|
||||
- [设计系统](./design-system/)
|
||||
206
docs/design-system/sillytavern-modern-ui/MASTER.md
Normal file
206
docs/design-system/sillytavern-modern-ui/MASTER.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Design System Master File
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
|
||||
---
|
||||
|
||||
**Project:** SillyTavern Modern UI
|
||||
**Generated:** 2026-02-26 17:01:07
|
||||
**Category:** Quantum Computing Interface
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#7C3AED` | `--color-primary` |
|
||||
| Secondary | `#A78BFA` | `--color-secondary` |
|
||||
| CTA/Accent | `#F97316` | `--color-cta` |
|
||||
| Background | `#FAF5FF` | `--color-background` |
|
||||
| Text | `#4C1D95` | `--color-text` |
|
||||
|
||||
**Color Notes:** Excitement purple + action orange
|
||||
|
||||
### Typography
|
||||
|
||||
- **Heading Font:** Inter
|
||||
- **Body Font:** Inter
|
||||
- **Mood:** spatial, legible, glass, system, clean, neutral
|
||||
- **Google Fonts:** [Inter + Inter](https://fonts.google.com/share?selection.family=Inter:wght@300;400;500;600)
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
|
||||
```
|
||||
|
||||
### Spacing Variables
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||
|
||||
### Shadow Depths
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #F97316;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #7C3AED;
|
||||
border: 2px solid #7C3AED;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #FAF5FF;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #7C3AED;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #7C3AED20;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Dark Mode (OLED)
|
||||
|
||||
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
|
||||
|
||||
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
|
||||
|
||||
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** Horizontal Scroll Journey
|
||||
|
||||
- **Conversion Strategy:** Immersive product discovery. High engagement. Keep navigation visible.
|
||||
28,Bento Grid Showcase,bento, grid, features, modular, apple-style, showcase", 1. Hero, 2. Bento Grid (Key Features), 3. Detail Cards, 4. Tech Specs, 5. CTA, Floating Action Button or Bottom of Grid, Card backgrounds: #F5F5F7 or Glass. Icons: Vibrant brand colors. Text: Dark., Hover card scale (1.02), video inside cards, tilt effect, staggered reveal, Scannable value props. High information density without clutter. Mobile stack.
|
||||
29,Interactive 3D Configurator,3d, configurator, customizer, interactive, product", 1. Hero (Configurator), 2. Feature Highlight (synced), 3. Price/Specs, 4. Purchase, Inside Configurator UI + Sticky Bottom Bar, Neutral studio background. Product: Realistic materials. UI: Minimal overlay., Real-time rendering, material swap animation, camera rotate/zoom, light reflection, Increases ownership feeling. 360 view reduces return rates. Direct add-to-cart.
|
||||
30,AI-Driven Dynamic Landing,ai, dynamic, personalized, adaptive, generative", 1. Prompt/Input Hero, 2. Generated Result Preview, 3. How it Works, 4. Value Prop, Input Field (Hero) + 'Try it' Buttons, Adaptive to user input. Dark mode for compute feel. Neon accents., Typing text effects, shimmering generation loaders, morphing layouts, Immediate value demonstration. 'Show, don't tell'. Low friction start.
|
||||
- **CTA Placement:** Floating Sticky CTA or End of Horizontal Track
|
||||
- **Section Order:** 1. Intro (Vertical), 2. The Journey (Horizontal Track), 3. Detail Reveal, 4. Vertical Footer
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
- ❌ Generic tech design
|
||||
- ❌ No viz
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
730
docs/development-progress.md
Normal file
730
docs/development-progress.md
Normal file
@@ -0,0 +1,730 @@
|
||||
# 云酒馆项目开发进度
|
||||
|
||||
> 最后更新:2026-02-27
|
||||
|
||||
## 项目概述
|
||||
|
||||
云酒馆是一个现代化的 AI 角色对话平台,采用前后端分离架构,完全兼容 SillyTavern V2/V3 格式。
|
||||
|
||||
- **前端**:React 18 + TypeScript + Tailwind CSS + Vite
|
||||
- **后端**:Go 1.24 + Gin + PostgreSQL + GORM
|
||||
- **设计风格**:Glassmorphism + 深色主题 (OLED 优化)
|
||||
|
||||
---
|
||||
|
||||
## 开发进度总览
|
||||
|
||||
| 模块 | 状态 | 完成度 | 负责人 | 备注 |
|
||||
|------|------|--------|--------|------|
|
||||
| 用户系统 | ✅ 已完成 | 100% | - | 注册、登录、资料管理 |
|
||||
| 角色卡管理 | ✅ 已完成 | 100% | - | 完全兼容 ST V2,前后端已打通 |
|
||||
| 预设管理 | ✅ 已完成 | 100% | - | 前后端已打通,支持导入导出 |
|
||||
| 对话系统 | ✅ 已完成 | 95% | - | 预设切换、消息重新生成已完成 |
|
||||
| AI 集成 | 🚧 进行中 | 80% | - | AI 配置管理、API 调用已完成 |
|
||||
| 世界书系统 | 📋 待开发 | 0% | - | 规划中 |
|
||||
| 正则脚本 | 📋 待开发 | 0% | - | 规划中 |
|
||||
|
||||
**图例**:
|
||||
- ✅ 已完成
|
||||
- 🚧 进行中
|
||||
- 📋 待开发
|
||||
- ⏸️ 暂停
|
||||
- ❌ 已取消
|
||||
|
||||
---
|
||||
|
||||
## 一、用户系统 ✅
|
||||
|
||||
### 1.1 后端 API(已完成 - 2024-02-26)
|
||||
|
||||
**数据模型**:
|
||||
- ✅ `AppUser` - 用户模型
|
||||
- ✅ `AppUserSession` - 会话模型
|
||||
|
||||
**API 端点**:
|
||||
- ✅ `POST /app/auth/register` - 用户注册
|
||||
- ✅ `POST /app/auth/login` - 用户登录
|
||||
- ✅ `POST /app/auth/refresh` - 刷新Token
|
||||
- ✅ `POST /app/auth/logout` - 用户登出
|
||||
- ✅ `GET /app/auth/userinfo` - 获取用户信息
|
||||
- ✅ `PUT /app/user/profile` - 更新用户资料
|
||||
- ✅ `POST /app/user/change-password` - 修改密码
|
||||
|
||||
**核心功能**:
|
||||
- ✅ JWT 认证(7天有效期)
|
||||
- ✅ bcrypt 密码加密
|
||||
- ✅ 会话管理(IP、设备信息)
|
||||
- ✅ 用户状态检查
|
||||
- ✅ 最后登录记录
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
server/
|
||||
├── api/v1/app/auth.go # API 控制器
|
||||
├── model/app/
|
||||
│ ├── app_user.go # 用户模型
|
||||
│ ├── app_user_session.go # 会话模型
|
||||
│ ├── request/auth.go # 请求结构
|
||||
│ └── response/auth.go # 响应结构
|
||||
├── service/app/auth.go # 业务逻辑
|
||||
├── router/app/auth.go # 路由配置
|
||||
├── middleware/app_jwt.go # JWT 中间件
|
||||
└── utils/app_jwt.go # JWT 工具
|
||||
```
|
||||
|
||||
### 1.2 前端页面(已完成 - 2024-02-26)
|
||||
|
||||
**页面清单**:
|
||||
- ✅ `/login` - 登录页
|
||||
- ✅ `/register` - 注册页
|
||||
- ✅ `/forgot-password` - 忘记密码
|
||||
- ✅ `/profile` - 用户中心
|
||||
|
||||
**功能特性**:
|
||||
- ✅ 表单验证
|
||||
- ✅ 密码可见性切换
|
||||
- ✅ 第三方登录入口(GitHub/Google)
|
||||
- ✅ 用户统计数据展示
|
||||
- ✅ 账号设置、隐私设置、通知设置
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
web-app/src/pages/
|
||||
├── LoginPage.tsx
|
||||
├── RegisterPage.tsx
|
||||
├── ForgotPasswordPage.tsx
|
||||
└── ProfilePage.tsx
|
||||
```
|
||||
|
||||
### 1.3 前后端对接(已完成 - 2024-02-26)
|
||||
|
||||
**API 服务层**:
|
||||
- ✅ `web-app/src/api/client.ts` - Axios 客户端配置
|
||||
- ✅ `web-app/src/api/auth.ts` - 用户认证 API 封装
|
||||
|
||||
**核心功能**:
|
||||
- ✅ Token 自动注入请求头
|
||||
- ✅ Token 过期自动刷新
|
||||
- ✅ 统一错误处理
|
||||
- ✅ 登录状态持久化(localStorage)
|
||||
- ✅ 登录后跳转用户中心
|
||||
- ✅ 未登录自动跳转登录页
|
||||
- ✅ 用户信息实时加载
|
||||
- ✅ 登出功能
|
||||
|
||||
**环境配置**:
|
||||
- ✅ `.env.development` - 开发环境配置(http://localhost:8888)
|
||||
- ✅ `.env.production` - 生产环境配置
|
||||
|
||||
**依赖安装**:
|
||||
- ✅ axios - HTTP 客户端
|
||||
|
||||
---
|
||||
|
||||
## 二、角色卡管理 ✅
|
||||
|
||||
### 2.1 后端 API(已完成 - 2024-02-26)
|
||||
|
||||
**数据模型**:
|
||||
- ✅ `AICharacter` - 角色卡模型(完全兼容 SillyTavern V2 格式)
|
||||
- ✅ 使用 JSONB 存储复杂数据(tags, alternateGreetings, characterBook, extensions)
|
||||
- ✅ 支持公开/私有角色卡
|
||||
- ✅ 统计信息(使用次数、收藏次数)
|
||||
|
||||
**API 端点**:
|
||||
- ✅ `POST /app/character` - 创建角色卡
|
||||
- ✅ `GET /app/character` - 获取角色卡列表(分页、搜索、标签筛选)
|
||||
- ✅ `GET /app/character/:id` - 获取角色卡详情
|
||||
- ✅ `PUT /app/character/:id` - 更新角色卡
|
||||
- ✅ `DELETE /app/character/:id` - 删除角色卡
|
||||
- ✅ `POST /app/character/upload` - 上传角色卡文件(PNG/JSON)
|
||||
- ✅ `GET /app/character/:id/export` - 导出角色卡为 JSON
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 完全兼容 SillyTavern V2 格式
|
||||
- ✅ PNG 元数据提取(使用 `utils/character_card.go`)
|
||||
- ✅ JSON 格式导入导出
|
||||
- ✅ 角色卡搜索和标签筛选
|
||||
- ✅ 用户权限控制(只能编辑自己的角色卡)
|
||||
- ✅ 支持 Base64 编码的 PNG tEXt chunk
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
server/
|
||||
├── api/v1/app/character.go # API 控制器
|
||||
├── model/app/ai_character.go # 角色卡模型
|
||||
├── model/app/request/character.go # 请求结构
|
||||
├── model/app/response/character.go # 响应结构
|
||||
├── service/app/character.go # 业务逻辑
|
||||
├── router/app/character.go # 路由配置
|
||||
└── utils/character_card.go # SillyTavern V2 格式工具
|
||||
```
|
||||
|
||||
### 2.2 前端页面(已完成 - 2024-02-26)
|
||||
|
||||
**页面**:
|
||||
- ✅ `/characters` - 角色卡管理页
|
||||
|
||||
**功能特性**:
|
||||
- ✅ 上传 PNG 角色卡(后端自动解析)
|
||||
- ✅ 上传 JSON 角色卡
|
||||
- ✅ 角色卡编辑(名称、描述、性格、场景、系统提示词等)
|
||||
- ✅ 角色卡导出为 JSON
|
||||
- ✅ 角色卡删除
|
||||
- ✅ 搜索功能
|
||||
- ✅ 分页加载
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
web-app/src/
|
||||
├── api/character.ts # 角色卡 API 封装
|
||||
└── pages/CharacterManagePage.tsx # 角色卡管理页面
|
||||
```
|
||||
|
||||
### 2.3 前后端对接(已完成 - 2024-02-26)
|
||||
|
||||
**API 服务层**:
|
||||
- ✅ `web-app/src/api/character.ts` - 角色卡 API 封装
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 角色卡列表加载(分页、搜索)
|
||||
- ✅ 文件上传(PNG/JSON)
|
||||
- ✅ 角色卡编辑和保存
|
||||
- ✅ 角色卡删除
|
||||
- ✅ 角色卡导出
|
||||
- ✅ 错误处理和用户提示
|
||||
- ✅ 登录状态检查
|
||||
|
||||
---
|
||||
|
||||
## 三、预设管理 ✅
|
||||
|
||||
### 3.1 后端 API(已完成 - 2026-02-27)
|
||||
|
||||
**数据模型**:
|
||||
- ✅ `AIPreset` - 预设模型(完全兼容 SillyTavern 格式)
|
||||
- ✅ 支持所有采样参数(temperature/topP/topK/frequencyPenalty/presencePenalty/maxTokens/repetitionPenalty/minP/topA)
|
||||
- ✅ 支持系统提示词和停止序列
|
||||
- ✅ 使用 JSONB 存储扩展字段(extensions)
|
||||
- ✅ 支持公开/私有预设和默认预设
|
||||
|
||||
**API 端点**:
|
||||
- ✅ `POST /app/preset` - 创建预设
|
||||
- ✅ `GET /app/preset` - 获取预设列表(分页、搜索、公开筛选)
|
||||
- ✅ `GET /app/preset/:id` - 获取预设详情
|
||||
- ✅ `PUT /app/preset/:id` - 更新预设
|
||||
- ✅ `DELETE /app/preset/:id` - 删除预设
|
||||
- ✅ `POST /app/preset/:id/default` - 设置默认预设
|
||||
- ✅ `POST /app/preset/import` - 导入预设(JSON 文件)
|
||||
- ✅ `GET /app/preset/:id/export` - 导出预设为 JSON
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 完全兼容 SillyTavern 预设格式
|
||||
- ✅ JSON 格式导入导出
|
||||
- ✅ 预设搜索和筛选
|
||||
- ✅ 用户权限控制(只能编辑自己的预设)
|
||||
- ✅ 默认预设管理
|
||||
- ✅ 使用统计
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
server/
|
||||
├── api/v1/app/preset.go # API 控制器
|
||||
├── model/app/preset.go # 预设模型
|
||||
├── model/app/request/preset.go # 请求结构
|
||||
├── model/app/response/preset.go # 响应结构
|
||||
├── service/app/preset.go # 业务逻辑
|
||||
└── router/app/preset.go # 路由配置
|
||||
```
|
||||
|
||||
### 3.2 前端页面(已完成 - 2026-02-27)
|
||||
|
||||
**页面**:
|
||||
- ✅ `/presets` - 预设管理页
|
||||
|
||||
**功能特性**:
|
||||
- ✅ 导入 JSON 预设
|
||||
- ✅ 预设参数编辑(Temperature、Top P、Top K、Frequency Penalty、Presence Penalty 等)
|
||||
- ✅ 预设复制
|
||||
- ✅ 预设导出
|
||||
- ✅ 预设删除
|
||||
- ✅ 搜索功能
|
||||
- ✅ 设置默认预设
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
web-app/src/
|
||||
├── api/preset.ts # 预设 API 封装
|
||||
└── pages/PresetManagePage.tsx # 预设管理页面
|
||||
```
|
||||
|
||||
### 3.3 前后端对接(已完成 - 2026-02-27)
|
||||
|
||||
**API 服务层**:
|
||||
- ✅ `web-app/src/api/preset.ts` - 预设 API 封装
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 预设列表加载(分页、搜索)
|
||||
- ✅ 文件上传(JSON)
|
||||
- ✅ 预设编辑和保存
|
||||
- ✅ 预设删除
|
||||
- ✅ 预设导出
|
||||
- ✅ 默认预设设置
|
||||
- ✅ 错误处理和用户提示
|
||||
- ✅ 登录状态检查
|
||||
|
||||
---
|
||||
|
||||
## 四、对话系统 ✅
|
||||
|
||||
### 4.1 后端 API(已完成 - 2026-02-27)
|
||||
|
||||
**数据模型**:
|
||||
- ✅ `Conversation` - 对话会话模型
|
||||
- ✅ `Message` - 消息模型
|
||||
- ✅ 支持用户、角色、预设关联
|
||||
- ✅ 支持 AI 提供商和模型配置
|
||||
- ✅ 使用 JSONB 存储对话设置和消息元数据
|
||||
- ✅ 统计信息(消息数量、Token 使用量)
|
||||
|
||||
**API 端点**:
|
||||
- ✅ `POST /app/conversation` - 创建对话
|
||||
- ✅ `GET /app/conversation` - 获取对话列表(分页)
|
||||
- ✅ `GET /app/conversation/:id` - 获取对话详情
|
||||
- ✅ `PUT /app/conversation/:id/settings` - 更新对话设置
|
||||
- ✅ `DELETE /app/conversation/:id` - 删除对话
|
||||
- ✅ `GET /app/conversation/:id/messages` - 获取消息历史(分页)
|
||||
- ✅ `POST /app/conversation/:id/message` - 发送消息
|
||||
- ✅ `POST /app/conversation/:id/message?stream=true` - 流式发送消息(SSE)
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 对话会话管理
|
||||
- ✅ 消息发送接收
|
||||
- ✅ 对话历史存储
|
||||
- ✅ SSE 流式响应支持
|
||||
- ✅ Token 统计
|
||||
- ✅ 用户权限控制
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
server/
|
||||
├── api/v1/app/conversation.go # API 控制器
|
||||
├── model/app/conversation.go # 对话和消息模型
|
||||
├── model/app/request/conversation.go # 请求结构
|
||||
├── model/app/response/conversation.go # 响应结构
|
||||
├── service/app/conversation.go # 业务逻辑
|
||||
└── router/app/conversation.go # 路由配置
|
||||
```
|
||||
|
||||
### 4.2 前端页面(已完成 - 2026-02-27)
|
||||
|
||||
**页面**:
|
||||
- ✅ `/chat` - 聊天界面
|
||||
|
||||
**功能特性**:
|
||||
- ✅ 对话列表侧边栏
|
||||
- ✅ 角色选择弹窗
|
||||
- ✅ 消息输入框
|
||||
- ✅ 对话菜单(导出、删除)
|
||||
- ✅ 实时消息发送
|
||||
- ✅ 消息流式显示
|
||||
- ✅ 对话历史加载
|
||||
- ✅ 背景图和主题色设置
|
||||
|
||||
**待完善**:
|
||||
- ✅ 预设切换功能(流式模式已修复应用预设参数)
|
||||
- ✅ 消息重新生成(支持流式,自动删除旧回复)
|
||||
- 📋 消息编辑
|
||||
- 📋 世界书开关
|
||||
- 📋 正则脚本开关
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
web-app/src/
|
||||
├── api/conversation.ts # 对话 API 封装
|
||||
├── pages/ChatPage.tsx # 聊天页面
|
||||
├── components/Sidebar.tsx # 侧边栏
|
||||
├── components/ChatArea.tsx # 聊天区域
|
||||
├── components/CharacterPanel.tsx # 角色面板
|
||||
└── components/SettingsPanel.tsx # 设置面板
|
||||
```
|
||||
|
||||
### 4.3 前后端对接(已完成 - 2026-02-27)
|
||||
|
||||
**API 服务层**:
|
||||
- ✅ `web-app/src/api/conversation.ts` - 对话 API 封装
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 对话列表加载(分页)
|
||||
- ✅ 创建新对话
|
||||
- ✅ 发送消息
|
||||
- ✅ 消息历史加载
|
||||
- ✅ 对话设置更新
|
||||
- ✅ 对话删除
|
||||
- ✅ 错误处理和用户提示
|
||||
- ✅ 登录状态检查
|
||||
|
||||
---
|
||||
|
||||
## 五、AI 集成 🚧
|
||||
|
||||
### 5.1 后端 API(部分完成 - 2026-02-27)
|
||||
|
||||
**数据模型**:
|
||||
- ✅ `AIConfig` - AI 配置模型
|
||||
- ✅ 支持多种 AI 提供商(OpenAI/Anthropic/Custom)
|
||||
- ✅ API Key 加密存储
|
||||
- ✅ 模型配置和参数管理
|
||||
|
||||
**API 端点**:
|
||||
- ✅ `POST /app/ai-config` - 创建 AI 配置
|
||||
- ✅ `GET /app/ai-config` - 获取配置列表
|
||||
- ✅ `GET /app/ai-config/:id` - 获取配置详情
|
||||
- ✅ `PUT /app/ai-config/:id` - 更新配置
|
||||
- ✅ `DELETE /app/ai-config/:id` - 删除配置
|
||||
- ✅ `POST /app/ai-config/:id/test` - 测试连接
|
||||
|
||||
**待实现功能**:
|
||||
- ✅ 实际 AI API 调用集成(OpenAI/Anthropic)
|
||||
- 📋 本地模型支持(Ollama/LM Studio)
|
||||
- 📋 Token 计数优化
|
||||
- 📋 错误重试机制
|
||||
- 📋 提示词模板系统
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
server/
|
||||
├── api/v1/app/ai_config.go # API 控制器
|
||||
├── model/app/ai_config.go # AI 配置模型
|
||||
├── model/app/request/ai_config.go # 请求结构
|
||||
├── model/app/response/ai_config.go # 响应结构
|
||||
└── service/app/ai_config.go # 业务逻辑
|
||||
```
|
||||
|
||||
### 5.2 前端页面(已完成 - 2026-02-27)
|
||||
|
||||
**功能特性**:
|
||||
- ✅ AI 配置管理界面
|
||||
- ✅ API Key 配置
|
||||
- ✅ 模型选择
|
||||
- ✅ 连接测试
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
web-app/src/
|
||||
└── api/aiConfig.ts # AI 配置 API 封装
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、世界书系统 ✅
|
||||
|
||||
### 6.1 后端 API(已完成 - 2026-02-27)
|
||||
|
||||
**数据模型**:
|
||||
- ✅ `Worldbook` - 世界书模型
|
||||
- ✅ `WorldbookEntry` - 世界书条目模型(完全兼容 SillyTavern 格式)
|
||||
- ✅ 支持公开/私有世界书
|
||||
- ✅ 使用 JSONB 存储关键词数组和扩展字段
|
||||
|
||||
**已实现字段**:
|
||||
- ✅ keys/secondary_keys - 主关键词和次要关键词
|
||||
- ✅ content - 条目内容
|
||||
- ✅ constant/enabled - 常驻和启用标志
|
||||
- ✅ use_regex/case_sensitive/match_whole_words - 匹配选项
|
||||
- ✅ selective/selective_logic - 选择性触发逻辑
|
||||
- ✅ position/depth/order - 注入位置和深度
|
||||
- ✅ probability/scan_depth - 触发概率和扫描深度
|
||||
- ✅ group_id - 分组管理
|
||||
|
||||
**API 端点**:
|
||||
- ✅ `POST /app/worldbook` - 创建世界书
|
||||
- ✅ `GET /app/worldbook` - 获取世界书列表(分页、搜索)
|
||||
- ✅ `GET /app/worldbook/:id` - 获取世界书详情
|
||||
- ✅ `PUT /app/worldbook/:id` - 更新世界书
|
||||
- ✅ `DELETE /app/worldbook/:id` - 删除世界书(级联删除条目)
|
||||
- ✅ `POST /app/worldbook/import` - 导入世界书(JSON)
|
||||
- ✅ `GET /app/worldbook/:id/export` - 导出世界书为 JSON
|
||||
- ✅ `POST /app/worldbook/:id/entry` - 创建条目
|
||||
- ✅ `GET /app/worldbook/:id/entries` - 获取条目列表
|
||||
- ✅ `PUT /app/worldbook/:id/entry/:entryId` - 更新条目
|
||||
- ✅ `DELETE /app/worldbook/:id/entry/:entryId` - 删除条目
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 完全兼容 SillyTavern 世界书格式
|
||||
- ✅ JSON 格式导入导出
|
||||
- ✅ 世界书搜索和筛选
|
||||
- ✅ 用户权限控制(只能编辑自己的世界书)
|
||||
- ✅ 条目计数自动更新
|
||||
- 📋 关键词触发算法(待与对话系统集成)
|
||||
- 📋 与对话系统集成(待实现)
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
server/
|
||||
├── api/v1/app/worldbook.go # API 控制器
|
||||
├── model/app/worldbook.go # 世界书和条目模型
|
||||
├── model/app/request/worldbook.go # 请求结构
|
||||
├── model/app/response/worldbook.go # 响应结构
|
||||
├── service/app/worldbook.go # 业务逻辑
|
||||
└── router/app/worldbook.go # 路由配置
|
||||
```
|
||||
|
||||
### 6.2 前端页面(已完成 - 2026-02-27)
|
||||
|
||||
**页面**:
|
||||
- ✅ `/worldbooks` - 世界书管理页
|
||||
|
||||
**功能特性**:
|
||||
- ✅ 导入 JSON 世界书
|
||||
- ✅ 创建世界书
|
||||
- ✅ 世界书列表展示
|
||||
- ✅ 世界书搜索
|
||||
- ✅ 世界书导出
|
||||
- ✅ 世界书删除
|
||||
- ✅ 条目创建和编辑
|
||||
- ✅ 条目列表展示
|
||||
- ✅ 条目删除
|
||||
- ✅ 关键词管理(主关键词、次要关键词)
|
||||
- ✅ 条目选项配置(常驻、启用、正则、大小写等)
|
||||
- ✅ 高级选项(触发概率、扫描深度)
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
web-app/src/
|
||||
├── api/worldbook.ts # 世界书 API 封装
|
||||
└── pages/WorldbookManagePage.tsx # 世界书管理页面
|
||||
```
|
||||
|
||||
### 6.3 前后端对接(已完成 - 2026-02-27)
|
||||
|
||||
**API 服务层**:
|
||||
- ✅ `web-app/src/api/worldbook.ts` - 世界书 API 封装
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 世界书列表加载(分页、搜索)
|
||||
- ✅ 文件上传(JSON)
|
||||
- ✅ 世界书创建、编辑、删除
|
||||
- ✅ 世界书导出
|
||||
- ✅ 条目创建、编辑、删除
|
||||
- ✅ 条目列表加载
|
||||
- ✅ 错误处理和用户提示
|
||||
- ✅ 登录状态检查
|
||||
|
||||
### 6.4 待完善功能
|
||||
|
||||
- 📋 与对话系统集成(在对话中激活世界书)
|
||||
- 📋 关键词触发引擎实现
|
||||
- 📋 选择性触发逻辑(AND/NOT)
|
||||
- 📋 深度控制和位置注入
|
||||
- 📋 概率触发机制
|
||||
- 📋 递归扫描
|
||||
- 📋 角色绑定世界书
|
||||
- 📋 全局世界书
|
||||
|
||||
---
|
||||
|
||||
## 七、正则脚本系统 📋
|
||||
|
||||
### 7.1 待实现功能(规划中)
|
||||
|
||||
**数据模型**:
|
||||
- 📋 `RegexScript` - 正则脚本模型
|
||||
- 📋 支持全局、角色、预设三种作用域
|
||||
- 📋 完全兼容 SillyTavern 正则脚本格式
|
||||
|
||||
**待实现字段**:
|
||||
- 📋 find_regex/replace_with - 查找和替换表达式
|
||||
- 📋 trim_string - 修剪字符串
|
||||
- 📋 placement - 执行阶段(输入/输出/世界书/推理)
|
||||
- 📋 disabled/markdown_only/run_on_edit/prompt_only - 执行选项
|
||||
- 📋 substitute_regex - 宏替换({{user}}/{{char}})
|
||||
- 📋 min_depth/max_depth - 深度控制
|
||||
- 📋 scope/owner_char_id/owner_preset_id - 作用域管理
|
||||
|
||||
**计划 API 端点**:
|
||||
```
|
||||
POST /app/regex # 创建正则脚本
|
||||
GET /app/regex # 获取脚本列表
|
||||
GET /app/regex/:id # 获取脚本详情
|
||||
PUT /app/regex/:id # 更新脚本
|
||||
DELETE /app/regex/:id # 删除脚本
|
||||
POST /app/regex/:id/test # 测试脚本
|
||||
```
|
||||
|
||||
**核心功能规划**:
|
||||
- 📋 多阶段脚本执行(输入/输出/世界书/推理)
|
||||
- 📋 正则表达式解析和执行
|
||||
- 📋 宏替换系统
|
||||
- 📋 深度控制和条件执行
|
||||
- 📋 与对话系统集成
|
||||
|
||||
---
|
||||
|
||||
## 八、公共页面 ✅
|
||||
|
||||
### 6.1 已完成页面
|
||||
|
||||
- ✅ `/` - 首页(平台介绍、热门角色)
|
||||
- ✅ `/market` - 角色广场(浏览、搜索、分类)
|
||||
- ✅ `/character/:id` - 角色详情(信息、评价)
|
||||
|
||||
**文件**:
|
||||
```
|
||||
web-app/src/pages/
|
||||
├── HomePage.tsx
|
||||
├── CharacterMarket.tsx
|
||||
└── CharacterDetail.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、技术债务与优化
|
||||
|
||||
### 9.1 待优化项
|
||||
|
||||
- ✅ 前后端 API 对接(已完成:用户、角色卡、预设、对话)
|
||||
- ✅ 实际 AI API 调用集成
|
||||
- 📋 错误处理统一化
|
||||
- 📋 加载状态优化
|
||||
- 📋 响应式布局完善
|
||||
- 📋 性能优化(虚拟滚动、懒加载)
|
||||
- 📋 单元测试
|
||||
- 📋 E2E 测试
|
||||
- 📋 实际 AI API 调用集成
|
||||
- 📋 世界书引擎实现
|
||||
- 📋 正则脚本引擎实现
|
||||
- 📋 Prompt Pipeline 优化
|
||||
|
||||
### 9.2 已知问题
|
||||
|
||||
- ✅ 对话系统预设切换功能(流式模式已支持)
|
||||
- ✅ 消息重新生成功能(支持流式,自动删除旧回复)
|
||||
- 📋 消息编辑功能待实现
|
||||
|
||||
---
|
||||
|
||||
## 十、部署相关 📋
|
||||
|
||||
### 10.1 待完成
|
||||
|
||||
- 📋 Docker 配置
|
||||
- 📋 CI/CD 流程
|
||||
- 📋 环境变量管理
|
||||
- 📋 数据库迁移脚本
|
||||
- 📋 备份策略
|
||||
- 📋 监控告警
|
||||
|
||||
---
|
||||
|
||||
## 十一、文档完善 📋
|
||||
|
||||
### 11.1 待补充文档
|
||||
|
||||
- 🚧 API 接口文档(Swagger 注释已添加,待生成)
|
||||
- 📋 数据库设计文档
|
||||
- 📋 部署文档
|
||||
- 📋 开发规范
|
||||
- 📋 贡献指南
|
||||
- ✅ SillyTavern 兼容优化方案(已完成)
|
||||
|
||||
---
|
||||
|
||||
## 开发时间线
|
||||
|
||||
### 2024-02-26
|
||||
- ✅ 完成用户系统后端 API
|
||||
- ✅ 完成用户系统前端页面
|
||||
- ✅ 完成用户系统前后端对接
|
||||
- ✅ 完成角色卡管理后端 API(完全兼容 SillyTavern V2)
|
||||
- ✅ 完成角色卡管理前端页面
|
||||
- ✅ 完成角色卡管理前后端对接
|
||||
- ✅ 完成预设管理前端页面
|
||||
- ✅ 完成对话系统前端基础界面
|
||||
- ✅ 完成公共页面(首页、角色广场、角色详情)
|
||||
- ✅ 项目结构重构(前后端分离)
|
||||
|
||||
### 2026-02-27
|
||||
- ✅ **完成预设管理后端 API**
|
||||
- 实现预设 CRUD 操作
|
||||
- 实现预设导入导出(JSON)
|
||||
- 实现默认预设管理
|
||||
- 完全兼容 SillyTavern 预设格式
|
||||
- ✅ **完成预设管理前后端对接**
|
||||
- ✅ **完成对话系统后端 API**
|
||||
- 实现对话会话管理
|
||||
- 实现消息发送接收
|
||||
- 实现 SSE 流式响应
|
||||
- 实现对话历史存储
|
||||
- ✅ **完成对话系统前后端对接**
|
||||
- ✅ **完成 AI 配置管理模块**
|
||||
- ✅ **编写 SillyTavern 完全兼容优化方案**
|
||||
- ✅ **更新开发进度文档**
|
||||
|
||||
### 下一阶段规划
|
||||
- ✅ 实现实际 AI API 调用(OpenAI/Anthropic)
|
||||
- 📋 实现世界书系统(完全兼容 SillyTavern)
|
||||
- 📋 实现正则脚本系统(完全兼容 SillyTavern)
|
||||
- 📋 实现 Prompt Pipeline(世界书触发、正则处理、提示词构建)
|
||||
- 📋 完善对话系统高级功能(消息重新生成、编辑)
|
||||
- 📋 性能优化与测试
|
||||
- 📋 部署上线
|
||||
|
||||
---
|
||||
|
||||
## 下一步计划
|
||||
|
||||
### 短期目标(本周)
|
||||
1. ✅ 完成角色卡管理后端 API(已完成)
|
||||
2. ✅ 完成预设管理后端 API(已完成)
|
||||
3. ✅ 完成对话系统后端 API(已完成)
|
||||
4. ✅ 实现实际 AI API 调用(OpenAI/Anthropic)
|
||||
5. ✅ 完善对话系统高级功能(消息重新生成)
|
||||
|
||||
### 中期目标(本月)
|
||||
1. 📋 实现世界书系统(完全兼容 SillyTavern)
|
||||
- 数据模型设计
|
||||
- 关键词触发算法
|
||||
- 选择性触发逻辑
|
||||
- 与对话系统集成
|
||||
2. 📋 实现正则脚本系统(完全兼容 SillyTavern)
|
||||
- 数据模型设计
|
||||
- 多阶段脚本执行
|
||||
- 正则表达式引擎
|
||||
- 宏替换系统
|
||||
3. 📋 实现 Prompt Pipeline
|
||||
- 世界书扫描与注入
|
||||
- 正则脚本处理
|
||||
- 提示词构建优化
|
||||
4. 📋 基础功能测试与优化
|
||||
|
||||
### 长期目标(下月)
|
||||
1. 📋 插件系统设计与实现
|
||||
2. 📋 性能优化(虚拟滚动、懒加载、缓存)
|
||||
3. 📋 完善文档(API 文档、部署文档)
|
||||
4. 📋 Docker 部署配置
|
||||
5. 📋 CI/CD 流程搭建
|
||||
6. 📋 部署上线
|
||||
7. 📋 用户反馈收集
|
||||
|
||||
---
|
||||
|
||||
## 团队协作
|
||||
|
||||
### 开发规范
|
||||
- 代码风格:遵循项目现有风格
|
||||
- 提交规范:使用 Conventional Commits
|
||||
- 分支管理:feature/* 用于新功能,bugfix/* 用于修复
|
||||
|
||||
### 沟通渠道
|
||||
- 技术讨论:GitHub Issues
|
||||
- 进度同步:本文档
|
||||
- 代码审查:Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 本文档持续更新,记录项目开发进度
|
||||
- 每完成一个功能模块,更新对应状态
|
||||
- 遇到问题及时记录在"已知问题"部分
|
||||
356
docs/优化方案.md
Normal file
356
docs/优化方案.md
Normal file
@@ -0,0 +1,356 @@
|
||||
## SillyTavern 完全兼容优化方案(Go + Gin + Postgres + React)
|
||||
|
||||
> 目标:基于现有 Go + React 系统,构建一个与 SillyTavern(下称 ST)高度兼容的角色卡 / 世界书 / 正则 / 预设 / 对话平台。
|
||||
|
||||
---
|
||||
|
||||
### 1. 总体目标与设计原则
|
||||
|
||||
- **技术栈统一**:所有核心功能(角色卡、世界书、正则、预设、聊天、AI 集成)全部收敛到:
|
||||
- **后端**:`server/` 下的 Go + Gin + PostgreSQL
|
||||
- **前端**:`projects/web-app` 下的 React + TypeScript + Tailwind
|
||||
- **SillyTavern 完全兼容**:
|
||||
- 支持 ST 角色卡 V2/V3(chara_card_v2 / v3)导入导出;
|
||||
- 支持 ST 世界书字段及触发逻辑(keys/secondary_keys/regex/depth/sticky/cooldown/probability/position 等);
|
||||
- 支持 ST Regex Scripts 配置(placement/runOnEdit/markdownOnly/promptOnly/substituteRegex/min/max depth);
|
||||
- 支持 ST 风格预设与 prompt 注入顺序(prompt_order / injection depth/position)。
|
||||
- **单一真相来源(SSOT)**:
|
||||
- **数据库即真相**:Postgres 负责持久化所有 ST 相关实体;
|
||||
- **前端只是 UI**:React 只做编辑/展示,请求都经过 API,不再有“前端内存版预设/世界书”。
|
||||
- **可扩展性**:
|
||||
- 提前为插件系统预留 Hook(onUserInput/onWorldInfoScan/beforePromptBuild/onAssistantDone 等);
|
||||
- 方便接入未来的 WebSocket/SSE 流式聊天、统计系统、市场/分享功能。
|
||||
|
||||
---
|
||||
|
||||
### 2. 当前 Go + React 系统现状(基于现有文档与代码)
|
||||
|
||||
#### 2.1 后端(server/)
|
||||
|
||||
根据 `projects/docs/development-progress.md`:
|
||||
|
||||
- ✅ **用户系统** 已完成(2024-02-26):
|
||||
- 模型:`AppUser`, `AppUserSession`
|
||||
- API:`/app/auth/register`, `/app/auth/login`, `/app/auth/refresh`, `/app/auth/logout`, `/app/auth/userinfo` 等
|
||||
- JWT、会话、用户资料、密码修改均已实现
|
||||
- ✅ **角色卡管理(AICharacter)** 已完成:
|
||||
- 模型:`AICharacter`(完全兼容 ST V2 格式),使用 JSONB 存储 `tags/alternateGreetings/characterBook/extensions` 等复杂结构
|
||||
- API:
|
||||
- `POST /app/character`
|
||||
- `GET /app/character`(分页、搜索、标签筛选)
|
||||
- `GET /app/character/:id`
|
||||
- `PUT /app/character/:id`
|
||||
- `DELETE /app/character/:id`
|
||||
- `POST /app/character/upload`(PNG/JSON 角色卡)
|
||||
- `GET /app/character/:id/export`(导出 JSON)
|
||||
- 工具:`utils/character_card.go`(PNG tEXt chunk / ST V2 工具)
|
||||
- 🚧 **预设管理**:
|
||||
- 前端页面 `/presets` 已完成(导入 JSON、复制、导出、编辑参数)。
|
||||
- 后端预设 API 尚未实现(`development-progress.md` 已给出规划端点)。
|
||||
- 📋 **对话系统 & AI 集成**:
|
||||
- 前端基础 UI 已完成(`/chat` + 侧边栏 + 输入框)。
|
||||
- 后端对话 API、AI 流式集成、世界书/正则逻辑尚在规划阶段。
|
||||
|
||||
#### 2.2 前端(`projects/web-app`)
|
||||
|
||||
- **用户系统前端**:`LoginPage/RegisterPage/ForgotPasswordPage/ProfilePage` 已完成,`api/client.ts + api/auth.ts` 已完成 token 注入与刷新。
|
||||
- **角色卡管理前端**:`CharacterManagePage` + `api/character.ts`
|
||||
- 功能:
|
||||
- 上传 PNG/JSON 角色卡(调用 `/app/character/upload`)
|
||||
- 编辑角色卡核心字段、内嵌 `characterBook`(世界书)字段
|
||||
- 导出 JSON(调用 `/app/character/:id/export`)
|
||||
- 搜索、分页、删除
|
||||
- **预设管理前端**:`PresetManagePage`
|
||||
- 当前为 **前端内存里的假数据** + 文件导入/导出;尚未接入真实后端 `/app/preset`。
|
||||
- **聊天前端**:`ChatPage + Sidebar + ChatArea + CharacterPanel + SettingsPanel`
|
||||
- 已实现基础布局、会话切换、角色选择、背景图/主题色设置等 UI。
|
||||
- 消息发送、历史加载、预设切换、世界书/正则开关还需后端配合。
|
||||
- **AI 配置前端**:`api/aiConfig.ts`(对应 `/app/ai-config` 系列)。
|
||||
|
||||
> 结论:**角色卡链路基本打通**,用户系统成熟;预设/对话/AI/世界书/正则目前主要停留在前端 UI 或规划层面,正好适合作为“集中下沉到 Go 后端”的突破口。
|
||||
|
||||
---
|
||||
|
||||
### 3. 目标架构(SillyTavern 兼容实现)
|
||||
|
||||
#### 3.1 核心领域模型(后端)
|
||||
|
||||
建议在现有 `model/app` 中引入或规范以下模型(部分已存在,可扩展):
|
||||
|
||||
- `AICharacter`(已存在)
|
||||
- ST 角色卡 V2/V3 的标准化版本 + 原始 JSON 存档。
|
||||
- `Worldbook` & `WorldbookEntry`
|
||||
- 支持角色内世界书与全局世界书。
|
||||
- `RegexScript`
|
||||
- 支持 scope:`global | character | preset`。
|
||||
- `Preset` & `PresetPrompt` & `PresetRegexBinding`
|
||||
- 表达 ST preset 的参数、prompt 列表和 regex 绑定。
|
||||
- `Conversation` & `Message`
|
||||
- 对话与消息记录,支持与 `AICharacter`、`Preset` 和 AI 配置关联。
|
||||
|
||||
#### 3.2 运行时 Pipeline
|
||||
|
||||
所有客户端(现阶段只有 React Web,将来可增加其他)统一通过 Go 后端完成以下流程:
|
||||
|
||||
1. **输入正则脚本处理**(global/character/preset)
|
||||
2. **世界书扫描**(keys/secondary_keys/regex/depth/sticky/cooldown 等)
|
||||
3. **Prompt 构建**(角色 system + world info + preset prompts + 历史消息)
|
||||
4. **AI 调用**(OpenAI/Anthropic/custom via `/app/ai-config`)
|
||||
5. **输出正则脚本处理**
|
||||
6. **持久化消息与统计**
|
||||
|
||||
React 前端只负责:
|
||||
|
||||
- 提供管理与编辑界面;
|
||||
- 将用户选择的 preset/worldbook/regex 传给后端;
|
||||
- 使用 SSE/WebSocket 将 AI 流输出展示给用户。
|
||||
|
||||
---
|
||||
|
||||
### 4. 后端详细优化方案(Go + Gin + Postgres)
|
||||
|
||||
#### 4.1 模型与数据库设计(概念级)
|
||||
|
||||
> 不强制你立刻改现有表名与字段;这部分作为“目标状态”,可通过迁移脚本或视图逐步对齐。
|
||||
|
||||
- **AICharacter**(已有)
|
||||
- 新增/规范字段:
|
||||
- `raw_card_json JSONB`:存原始 ST 角色卡,用于无损导出。
|
||||
- `bound_worldbook_ids UUID[]`:角色绑定世界书 ID 列表(可选)。
|
||||
- `bound_regex_ids UUID[]`:角色绑定正则脚本 ID 列表(可选)。
|
||||
- **Worldbook / WorldbookEntry**
|
||||
- Worldbook:`id, name, owner_char_id (nullable), meta JSONB, created_at, updated_at`
|
||||
- WorldbookEntry:包含 ST 全字段:
|
||||
- `uid, keys, secondary_keys, comment, content, constant, disabled, use_regex, case_sensitive, match_whole_words, selective, selective_logic, position, depth, order, probability, sticky, cooldown, delay, group, extra JSONB`
|
||||
- **RegexScript**
|
||||
- `id, name, find_regex, replace_with, trim_string`
|
||||
- `placement INT[]`
|
||||
- `disabled, markdown_only, run_on_edit, prompt_only`
|
||||
- `substitute_regex, min_depth, max_depth`
|
||||
- `scope, owner_char_id, owner_preset_id, raw_json`
|
||||
- **Preset / PresetPrompt / PresetRegexBinding**
|
||||
- 基本采样参数 + prompt 列表 + regex 绑定。
|
||||
- **Conversation / Message**
|
||||
- Conversation:`id, user_id, character_id, preset_id, ai_config_id, title, settings JSONB, created_at, updated_at`
|
||||
- Message:`id, conversation_id, role, content, token_count, created_at`
|
||||
|
||||
#### 4.2 角色卡导入/导出(巩固现有实现)
|
||||
|
||||
> 从 `development-progress.md` 看,`/app/character` 模块已经“完全兼容 ST V2”,这里更多是规范与扩展。
|
||||
|
||||
- **导入 `/app/character/upload` 已具备**:
|
||||
- PNG:使用 `utils/character_card.go` 提取 `chara` tEXt chunk → JSON。
|
||||
- JSON:直接解析,填充 `AICharacter`。
|
||||
- 优化点:
|
||||
- 当角色卡中包含 `character_book` 时:
|
||||
- 自动在 `worldbooks/worldbook_entries` 中创建对应记录;
|
||||
- 将 worldbook.id 写入 `AICharacter.bound_worldbook_ids`(或冗余到 `characterBook` 字段中)。
|
||||
- 当包含 `extensions.regex_scripts` 时:
|
||||
- 自动创建 `RegexScript`(scope=character, owner_char_id=该角色)。
|
||||
- 将脚本 id 写入 `AICharacter.bound_regex_ids` 或 `extensions` 中。
|
||||
- **导出 `/app/character/:id/export`**:
|
||||
- 若 `raw_card_json` 存在,优先以它为基础;将 DB 中新增的信息 patch 回去(例如补上最新的 worldbook/regex 变更)。
|
||||
- 若不存在,则按 ST V2 规范组装 JSON(兼容 V1/V3 的 data 字段)。
|
||||
|
||||
#### 4.3 世界书(World Info)引擎
|
||||
|
||||
1. **世界书激活来源**:
|
||||
- 全局启用列表(per-user 或全局 settings 中的 `active_worldbook_ids`)。
|
||||
- 角色绑定:`AICharacter.bound_worldbook_ids`。
|
||||
- 会话特定选项:`conversation.settings.activeWorldbookIds`(从前端设置面板传入)。
|
||||
2. **触发与过滤算法**:
|
||||
- 遍历所有激活 worldbook 的 entries:
|
||||
- 忽略 `disabled` 的 entry。
|
||||
- 若 `constant=true`:无视关键词匹配,直接进入候选(仍受 sticky/cooldown/delay/probability 控制)。
|
||||
- keys 匹配:
|
||||
- `use_regex=true`:将每个 key 作为正则(考虑 `caseSensitive`、`matchWholeWords` 标志)。
|
||||
- 否则:普通字符串包含匹配(可选大小写敏感)。
|
||||
- 若 `selective=true`:根据 `selectiveLogic` 结合 secondary_keys:
|
||||
- `0=AND`:secondary_keys 至少一个命中;
|
||||
- `1=OR`:主 keys 或 secondary_keys 任一命中;
|
||||
- `2=NOT`:主 keys 命中,但 secondary_keys 不命中。
|
||||
- depth:仅在最近 `entry.depth` 条消息(或 tokens)中搜索关键字。
|
||||
- sticky/cooldown/delay:基于会话级状态(比如 Redis/内存/DB)存储 entry 上次触发时间或 sticky 状态。
|
||||
- probability:按百分比随机决定最终是否注入。
|
||||
3. **注入到 prompt 中**:
|
||||
- 简化版:将所有命中 entry.content 拼接为 `[World Info]` 段,附加在 system prompt 后;
|
||||
- 高级版:按 ST 行为,依据 position/depth/order,放到不同位置(角色描述前/后、历史中等)。
|
||||
|
||||
#### 4.4 正则脚本(Regex Scripts)引擎
|
||||
|
||||
1. **脚本来源**:
|
||||
- Global:`scope='global'` 且 `disabled=false`。
|
||||
- Character:`scope='character' AND owner_char_id=当前角色`。
|
||||
- Preset:`preset_regex_bindings` 中绑定到当前 preset 的脚本。
|
||||
2. **执行阶段(placement)**:
|
||||
- 输入阶段(placement=1):用户消息入库前;
|
||||
- 输出阶段(placement=2):模型输出生成后、入库/返回前;
|
||||
- 世界书阶段(placement=3):扫描 / 注入 world info 内容前;
|
||||
- 推理阶段(placement=4):如有单独 reasoning prompt,可在构建 reasoning 时处理。
|
||||
3. **实现细节(Go)**:
|
||||
- 解析 JS 风格正则 `/pattern/flags`,支持 `i/m/s`。
|
||||
- 替换宏:
|
||||
- `substitute_regex=1` → 用当前用户名称替换 `{{user}}`;
|
||||
- `=2` → 用当前角色名替换 `{{char}}`;
|
||||
- `trim_string`:按行定义需要从文本中删掉的子串。
|
||||
- minDepth/maxDepth:结合消息历史深度决定是否执行脚本。
|
||||
- runOnEdit:在“消息编辑接口”中也调用该脚本集。
|
||||
- markdownOnly/promptOnly:通过标志决定作用在 UI 文本 or prompt 文本。
|
||||
|
||||
#### 4.5 Prompt Pipeline 与对话 API
|
||||
|
||||
将现有/规划中的对话 API 统一收敛到一个 pipeline,例如 `/app/conversation/:id/message`(或 `/conversations/:id/messages`,按统一风格即可):
|
||||
|
||||
1. **接收请求**:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "用户输入文本",
|
||||
"options": {
|
||||
"presetId": "uuid-xxx",
|
||||
"overrideModel": "gpt-4",
|
||||
"stream": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **管线步骤**:
|
||||
1. 根据 conversationId 加载:
|
||||
- Conversation、AICharacter、Preset、AI 配置、世界书/正则等上下文;
|
||||
- 合并 settings(会话内 + 用户全局)。
|
||||
2. 输入文本经过 regex 引擎(placement=1)。
|
||||
3. 写入一条 user message 到 DB。
|
||||
4. 从最近 N 条消息构造 world info 触发文本,调用世界书引擎得到 entries。
|
||||
5. 构建 prompt:
|
||||
- system:角色 system + scenario + authorsNote + preset.systemPrompt + world info;
|
||||
- history:最近若干 user/assistant 消息;
|
||||
- preset.prompts:按 depth/position 注入额外 messages。
|
||||
6. 调用 AI 服务(依据 `/app/ai-config` 中 baseUrl/apiKey/model):
|
||||
- 支持流式:通过 SSE/WebSocket 对前端推送 tokens。
|
||||
7. 将完整 assistant 输出经过 regex 引擎(placement=2)。
|
||||
8. 写入一条 assistant message 到 DB。
|
||||
9. 返回响应给前端(同步返回最终内容 + 可选流式过程)。
|
||||
|
||||
---
|
||||
|
||||
### 5. 前端详细优化方案(React `projects/web-app`)
|
||||
|
||||
#### 5.1 角色卡管理(CharacterManagePage)
|
||||
|
||||
目标:完全对齐 ST 角色卡 V2/V3 字段,并为世界书/正则后端拆分打好基础。
|
||||
|
||||
优化点:
|
||||
|
||||
- **字段映射明确化**:
|
||||
- 确保 `Character` 接口与后端模型一致,并在注释中标明 ST 对应字段:
|
||||
- `firstMes` ↔ `first_mes`
|
||||
- `mesExample` ↔ `mes_example`
|
||||
- `systemPrompt` ↔ `system_prompt`
|
||||
- `postHistoryInstructions` ↔ `post_history_instructions`
|
||||
- `characterBook` ↔ `character_book`
|
||||
- `extensions` ↔ `extensions`
|
||||
- **世界书编辑 UI 扩展**:
|
||||
- 目前 `WorldBookEntry` 较简化(keys/content/enabled/insertion_order/position)。
|
||||
- 建议增加更多高级属性编辑项(可以先折叠到“高级设置”中):
|
||||
- `secondary_keys` / `constant` / `use_regex` / `case_sensitive` / `match_whole_words`;
|
||||
- `selective` / `selective_logic`;
|
||||
- `depth` / `order` / `probability` / `sticky` / `cooldown` / `delay` / `group`。
|
||||
- 保存时将这些字段一并放入 `characterBook.entries`,后端负责拆分入 DB。
|
||||
- **导入/导出保持 ST 兼容**:
|
||||
- 当前上传/导出流程基本正确;随着后端增强,无需大改,只要保证前端不破坏 JSON 结构即可。
|
||||
|
||||
#### 5.2 预设管理(PresetManagePage)
|
||||
|
||||
目标:把当前的“前端内存预设”完全改造成**后端驱动的 ST 预设系统**。
|
||||
|
||||
实施步骤:
|
||||
|
||||
1. 后端按 `development-progress.md` 规划实现 `/app/preset` API:
|
||||
|
||||
```text
|
||||
POST /app/preset
|
||||
GET /app/preset
|
||||
GET /app/preset/:id
|
||||
PUT /app/preset/:id
|
||||
DELETE /app/preset/:id
|
||||
POST /app/preset/import
|
||||
GET /app/preset/:id/export
|
||||
```
|
||||
|
||||
2. 前端新建 `src/api/preset.ts` 对应这些端点。
|
||||
3. 将 `PresetManagePage` 中的 `useState` 初始数据删除,改为:
|
||||
- `const { data } = await presetApi.getPresetList()`;
|
||||
- 导入、导出、编辑、删除全部通过后端。
|
||||
4. 预设 JSON 的读写字段与 ST 对齐:
|
||||
- `temperature/top_p/top_k/frequency_penalty/presence_penalty/system_prompt/stop_sequences/...`。
|
||||
|
||||
#### 5.3 聊天与设置面板(ChatPage / SettingsPanel / CharacterPanel)
|
||||
|
||||
目标:把“选 preset / 选世界书 / 选正则”的入口统一放到设置面板,并通过 `conversation.settings` 与后端 pipeline 串联。
|
||||
|
||||
建议:
|
||||
|
||||
- 在 `SettingsPanel` 中增加:
|
||||
- 当前会话使用的 preset 选择器(下拉 list:来自 `/app/preset`)。
|
||||
- 可选的世界书列表(来自未来的 `/app/worldbook`,初期可只展示角色内 worldbook)。
|
||||
- 可选的全局正则脚本列表(来自未来的 `/app/regex`)。
|
||||
- 保存时调用 `/app/conversation/:id/settings`(或 `PUT /app/conversation/:id`):
|
||||
|
||||
```ts
|
||||
settings: {
|
||||
backgroundImage: string
|
||||
themeColor: string
|
||||
presetId?: string
|
||||
activeWorldbookIds?: string[]
|
||||
activeRegexIds?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
- 发送消息时,前端不再参与世界书/正则逻辑,只负责传 `content`,后端从 conversation/preset/character 中解析所有配置。
|
||||
|
||||
---
|
||||
|
||||
### 6. 分阶段落地路线图(仅 Go + React)
|
||||
|
||||
#### 阶段 1:打通所有核心 CRUD 与数据流(短期)
|
||||
|
||||
- 后端:
|
||||
- 巩固 `/app/character` 模块(确保 ST V2/V3 完全兼容)。
|
||||
- 实现 `/app/preset` 模块(CRUD + 导入/导出)。
|
||||
- 设计并实现 `worldbooks/worldbook_entries` 与 `regex_scripts` 的数据结构与基础 API。
|
||||
- 前端:
|
||||
- 改造 `PresetManagePage` 接入真实 API。
|
||||
- 在 `CharacterManagePage` 中补全世界书 entry 字段,保持 ST 兼容。
|
||||
|
||||
#### 阶段 2:实现 Prompt Pipeline 与对话 API(中期)
|
||||
|
||||
- 后端:
|
||||
- 聚合世界书与正则逻辑,形成独立的 `chatPipeline` service。
|
||||
- 在 `/app/conversation/:id/message`(或 `/conversations/:id/messages`)中调用 pipeline,完成一次“完整的 ST 风格对话请求”。
|
||||
- 引入流式输出(SSE 或 WebSocket)。
|
||||
- 前端:
|
||||
- 在 `ChatPage/SettingsPanel` 中增加 preset/worldbook/regex 的选择与设置保存。
|
||||
- 调整 ChatArea 接收流式输出并实时渲染。
|
||||
|
||||
#### 阶段 3:高级特性与插件系统(长期)
|
||||
|
||||
- 后端:
|
||||
- 引入插件概念(`plugins` 表 + manifest + hooks)。
|
||||
- 实现插件执行沙箱(WASM 或 goja),并在 pipeline 中注入插件 hooks。
|
||||
- 增加统计、日志与审计功能。
|
||||
- 前端:
|
||||
- 增加插件管理页面与可视化配置。
|
||||
- 对接统计与调试视图(例如:查看某次回复中哪些 world info/regex/插件生效)。
|
||||
|
||||
---
|
||||
|
||||
### 7. 总结
|
||||
|
||||
- 你当前的 Go + React 系统已经完成:
|
||||
- **用户系统**(认证/资料);
|
||||
- **角色卡管理**(完整 ST V2 兼容导入/导出);
|
||||
- **预设管理与对话 UI 的前端骨架**。
|
||||
- 接下来最重要的三件事是:
|
||||
- **在后端固化 ST 兼容的领域模型与 Prompt Pipeline**;
|
||||
- **让 `/app/conversation` 成为唯一的“对话真相源”,React 只是 UI 壳**。
|
||||
|
||||
367
docs/功能模块梳理.md
Normal file
367
docs/功能模块梳理.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 云酒馆 - 功能模块梳理
|
||||
|
||||
> 本文档梳理了云酒馆(SillyTavern Cloud)项目的完整功能模块,分为**核心功能**(必须与原版酒馆兼容)和**扩展功能**(基于云端多用户场景的增强)两部分。
|
||||
>
|
||||
> 标记说明:✅ 已实现 | 🔧 部分实现 | ⬜ 待实现
|
||||
|
||||
---
|
||||
|
||||
## 一、核心功能模块(酒馆兼容)
|
||||
|
||||
### 1. 角色卡系统 (Character Card)
|
||||
|
||||
角色卡是酒馆的核心功能,必须完全兼容 SillyTavern V2 (chara_card_v2) 规范。
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 角色卡 CRUD | ✅ | 创建、读取、更新、删除角色卡 |
|
||||
| V2 规范字段支持 | ✅ | name, description, personality, scenario, first_mes, mes_example, creator_notes, system_prompt, post_history_instructions, tags, alternate_greetings |
|
||||
| 角色头像 | ✅ | 头像上传与显示 |
|
||||
| PNG 导入 | ✅ | 从 PNG 图片中提取嵌入的角色卡数据 (tEXt chunk) |
|
||||
| JSON 导入/导出 | ✅ | 标准 JSON 格式导入导出 |
|
||||
| PNG 导出 | ⬜ | 将角色卡数据嵌入 PNG 图片导出 |
|
||||
| 角色书 (Character Book / Lorebook) | 🔧 | 数据模型已支持 characterBook 字段,但前端编辑器待完善 |
|
||||
| 备选开场白 (Alternate Greetings) | 🔧 | 数据模型已支持,前端切换UI待完善 |
|
||||
| Extensions 扩展字段 | ✅ | JSON 扩展字段,兼容第三方扩展数据 |
|
||||
| 角色卡搜索/筛选 | ✅ | 关键词搜索、标签筛选 |
|
||||
| 批量管理 | 🔧 | 批量删除基础功能,批量导入待完善 |
|
||||
|
||||
### 2. 对话系统 (Conversation / Chat)
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 创建对话 | ✅ | 选择角色卡后创建对话会话 |
|
||||
| 对话列表 | ✅ | 按时间排序、分页加载 |
|
||||
| 消息发送/接收 | ✅ | 用户输入消息,调用 AI API 获取回复 |
|
||||
| 流式输出 (SSE Streaming) | ✅ | Server-Sent Events 实时流式显示 AI 回复 |
|
||||
| 消息重新生成 (Regenerate) | ✅ | 重新生成最后一条 AI 回复 |
|
||||
| 消息编辑 (Edit) | ⬜ | 编辑已发送的消息内容 |
|
||||
| 消息删除 | 🔧 | 单条删除已支持,批量删除待实现 |
|
||||
| 消息滑动 (Swipe) | ⬜ | 同一轮生成多个回复,左右滑动切换(酒馆核心交互) |
|
||||
| 消息分支 (Branching) | ⬜ | 从任意消息节点创建分支对话 |
|
||||
| 消息续写 (Continue) | ⬜ | 让 AI 继续上一条未完成的回复 |
|
||||
| 停止生成 (Stop Generation) | ⬜ | 中途停止 AI 生成 |
|
||||
| 对话历史导入/导出 | ⬜ | 导出为 JSON / JSONL 格式 |
|
||||
| 对话标题自动生成 | ⬜ | 根据对话内容自动生成标题 |
|
||||
| Token 计数显示 | 🔧 | 后端已统计,前端显示待完善 |
|
||||
| 角色卡信息面板 | ✅ | 右侧面板显示当前角色信息 |
|
||||
| 聊天背景 | ✅ | 支持自定义背景图片 |
|
||||
| 主题颜色 | ✅ | 支持自定义主题色 |
|
||||
|
||||
### 3. AI 接口管理 (AI API Connection)
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| OpenAI API 兼容 | ✅ | 支持 OpenAI 及兼容接口(如各类中转站) |
|
||||
| Anthropic (Claude) API | ✅ | 原生支持 Anthropic API 格式 |
|
||||
| 自定义 API (Custom) | ✅ | 可配置任意 OpenAI 兼容的 BaseURL |
|
||||
| API Key 管理 | ✅ | 加密存储,支持多个 API 配置 |
|
||||
| 模型列表获取 | ✅ | 根据 API 配置动态获取可用模型 |
|
||||
| 连接测试 | ✅ | 配置后可测试 API 连通性 |
|
||||
| 多配置切换 | ✅ | 对话级别切换 AI 提供商和模型 |
|
||||
| Google AI (Gemini) | ⬜ | 原版酒馆支持,待接入 |
|
||||
| NovelAI | ⬜ | 原版酒馆支持,待接入 |
|
||||
| Text Completion API | ⬜ | 原版酒馆支持 text completion 模式(非 chat) |
|
||||
| OpenRouter | ⬜ | 可通过 Custom API 实现,但缺少原生 model 路由支持 |
|
||||
| Reverse Proxy 支持 | ⬜ | 原版酒馆的反向代理功能 |
|
||||
|
||||
### 4. 预设系统 (Presets / Sampler Settings)
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 预设 CRUD | ✅ | 创建、编辑、删除预设 |
|
||||
| 采样参数 | ✅ | temperature, top_p, top_k, frequency_penalty, presence_penalty |
|
||||
| 高级采样参数 | ✅ | repetition_penalty, min_p, top_a |
|
||||
| Max Tokens 设置 | ✅ | 最大生成长度限制 |
|
||||
| 停止序列 (Stop Sequences) | ✅ | 自定义停止序列 |
|
||||
| 系统提示词 (System Prompt) | ✅ | 预设级别系统提示词 |
|
||||
| 默认预设 | ✅ | 设置默认预设 |
|
||||
| 预设导入/导出 | ✅ | JSON 格式导入导出 |
|
||||
| 指令模板 (Instruct Mode) | ⬜ | 原版酒馆核心功能:不同模型的 prompt 格式模板 (ChatML, Alpaca, Llama 等) |
|
||||
| 上下文模板 (Context Template) | ⬜ | 控制角色卡各字段如何组装进 prompt |
|
||||
| Prompt 排序 (Prompt Order) | ⬜ | 自定义 prompt 各部分的排列顺序 |
|
||||
| Jailbreak Prompt | ⬜ | 越狱提示词(NSFW unlock) |
|
||||
| Token Padding | ⬜ | Token 预留/填充策略 |
|
||||
|
||||
### 5. 世界信息/知识书 (World Info / Lorebook)
|
||||
|
||||
世界信息是酒馆的核心 RAG 系统,用于根据关键词动态注入背景知识。
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 世界信息 CRUD | ⬜ | 创建、编辑、删除世界信息条目 |
|
||||
| 关键词触发 (Keyword Trigger) | ⬜ | 消息中出现关键词时自动注入对应条目 |
|
||||
| 正则匹配触发 | ⬜ | 支持正则表达式匹配触发 |
|
||||
| 条件激活 (Conditional Activation) | ⬜ | 多条件组合激活策略 |
|
||||
| 递归扫描 (Recursive Scanning) | ⬜ | 已触发条目内的关键词可继续触发其他条目 |
|
||||
| 插入位置控制 | ⬜ | 控制条目注入到 prompt 的位置(depth) |
|
||||
| 概率触发 | ⬜ | 条目以一定概率触发 |
|
||||
| Token 预算 | ⬜ | 限制世界信息总 token 消耗 |
|
||||
| 世界信息导入/导出 | ⬜ | 标准 JSON 格式 |
|
||||
| 角色绑定 | ⬜ | 世界信息与角色卡绑定 |
|
||||
| 全局世界信息 | ⬜ | 跨角色生效的全局世界信息 |
|
||||
|
||||
### 6. 群聊系统 (Group Chat)
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 创建群聊 | ⬜ | 多个 AI 角色参与的群聊 |
|
||||
| 角色轮转策略 | ⬜ | 自动/手动选择下一个发言角色 |
|
||||
| 角色激活/禁用 | ⬜ | 动态控制群聊中角色是否参与 |
|
||||
| 群聊设置 | ⬜ | 发言顺序、回复长度、触发条件等 |
|
||||
| 群组角色管理 | ⬜ | 添加/移除群聊角色 |
|
||||
|
||||
### 7. 用户人设 (User Persona)
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 人设创建/管理 | ⬜ | 创建多个用户人设档案 |
|
||||
| 人设切换 | ⬜ | 在不同对话中使用不同人设 |
|
||||
| 人设描述 | ⬜ | 用户角色的名称和描述,注入 prompt |
|
||||
| 人设头像 | ⬜ | 用户人设的头像 |
|
||||
| 默认人设 | ⬜ | 设置默认用户人设 |
|
||||
|
||||
---
|
||||
|
||||
## 二、Prompt 工程模块(酒馆兼容)
|
||||
|
||||
### 8. Prompt 构建系统
|
||||
|
||||
酒馆的 prompt 构建是其核心竞争力,必须完整兼容。
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 主系统提示词 (Main System Prompt) | 🔧 | 预设中已支持,独立管理待完善 |
|
||||
| NSFW 提示词 | ⬜ | NSFW 场景的系统提示词 |
|
||||
| 角色卡提示词注入 | 🔧 | 基础注入已实现,完整排序策略待完善 |
|
||||
| 作者注 (Author's Note) | ⬜ | 可在对话中途插入的引导提示 |
|
||||
| 作者注深度控制 | ⬜ | 控制作者注在 prompt 中的插入位置 |
|
||||
| 聊天历史截断 | ⬜ | 超出 token 限制时的截断策略 |
|
||||
| 消息摘要 (Summarization) | ⬜ | 旧消息自动摘要压缩 |
|
||||
| 自定义提示词排序 | ⬜ | 用户自定义 prompt 各部分的顺序 |
|
||||
| 宏替换 (Macro Replacement) | ⬜ | `{{char}}`, `{{user}}`, `{{time}}` 等宏变量替换 |
|
||||
|
||||
---
|
||||
|
||||
## 三、用户系统模块(云端增强)
|
||||
|
||||
### 9. 认证与账户
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 用户注册 | ✅ | 用户名 + 密码注册 |
|
||||
| 用户登录 | ✅ | JWT Token 认证 |
|
||||
| Token 刷新 | ✅ | 自动刷新过期 Token |
|
||||
| 登出 | ✅ | 安全登出,清除会话 |
|
||||
| 个人资料管理 | ✅ | 昵称、邮箱、手机、头像 |
|
||||
| 修改密码 | ✅ | 旧密码验证后修改 |
|
||||
| 会话管理 | ✅ | 记录登录 IP、设备信息 |
|
||||
| 第三方登录 (OAuth) | ⬜ | GitHub、Google 登录(UI 已预留) |
|
||||
| 邮箱验证 | ⬜ | 注册邮箱验证 |
|
||||
| 找回密码 | ⬜ | 通过邮箱找回密码 |
|
||||
|
||||
---
|
||||
|
||||
## 四、UI/UX 模块
|
||||
|
||||
### 10. 界面与交互
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 深色主题 | ✅ | 默认深色主题,OLED 优化 |
|
||||
| Glassmorphism 设计风格 | ✅ | 毛玻璃效果 |
|
||||
| 响应式布局 | ✅ | 适配桌面端和移动端 |
|
||||
| 三栏聊天布局 | ✅ | 侧边栏 + 聊天区 + 角色面板 |
|
||||
| 消息 Markdown 渲染 | 🔧 | 基础 Markdown 已支持,需增强 |
|
||||
| 消息代码高亮 | ⬜ | 代码块语法高亮 |
|
||||
| 聊天背景切换 | ✅ | 自定义上传背景图 |
|
||||
| 预设背景图库 | ⬜ | 原版酒馆自带的背景图集 |
|
||||
| 主题切换 (亮色/暗色) | ⬜ | 目前仅暗色主题 |
|
||||
| 自定义 CSS | ⬜ | 原版酒馆支持用户自定义 CSS |
|
||||
| 快捷键支持 | ⬜ | 发送消息、新建对话等快捷键 |
|
||||
| 拖拽排序 | ⬜ | 对话列表、角色排序等拖拽操作 |
|
||||
| 消息字数/Token 统计面板 | ⬜ | 实时显示 token 用量 |
|
||||
|
||||
---
|
||||
|
||||
## 五、数据管理模块
|
||||
|
||||
### 11. 数据导入导出
|
||||
|
||||
| 功能点 | 状态 | 说明 |
|
||||
|--------|------|------|
|
||||
| 角色卡 PNG 导入 | ✅ | 从 PNG 提取角色数据 |
|
||||
| 角色卡 JSON 导入/导出 | ✅ | 标准 JSON 格式 |
|
||||
| 角色卡 PNG 导出 | ⬜ | 将角色数据嵌入 PNG 导出 |
|
||||
| 预设 JSON 导入/导出 | ✅ | 预设导入导出 |
|
||||
| 对话历史导出 | ⬜ | 导出聊天记录 |
|
||||
| 世界信息导入/导出 | ⬜ | Lorebook 导入导出 |
|
||||
| CharacterHub 一键导入 | ⬜ | 从 chub.ai 直接导入角色 |
|
||||
| 完整数据备份/恢复 | ⬜ | 用户全量数据导出与恢复 |
|
||||
|
||||
---
|
||||
|
||||
## 六、扩展功能模块(未来规划)
|
||||
|
||||
以下功能基于云端多用户场景,以及现代 AI 应用趋势进行规划。
|
||||
|
||||
### 12. 角色市场 (Character Market)
|
||||
|
||||
| 功能点 | 优先级 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 公开角色浏览 | 🔧 高 | 已有基础页面,需完善筛选和分类 |
|
||||
| 角色评分/评价 | ⬜ 高 | 用户对角色卡进行评分和评论 |
|
||||
| 角色收藏 | ⬜ 高 | 收藏喜欢的角色卡 |
|
||||
| 角色分类/标签 | 🔧 中 | 完善标签系统和分类导航 |
|
||||
| 热门/推荐排行 | ⬜ 中 | 按使用量、收藏量、评分排序 |
|
||||
| 角色创作者主页 | ⬜ 低 | 创作者的个人页面,展示其角色 |
|
||||
| 角色版本管理 | ⬜ 低 | 角色卡的版本历史和回滚 |
|
||||
|
||||
### 13. 社区功能
|
||||
|
||||
| 功能点 | 优先级 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 预设分享 | ⬜ 中 | 用户分享自己的预设配置 |
|
||||
| 世界信息分享 | ⬜ 中 | 分享 Lorebook |
|
||||
| 精彩对话分享 | ⬜ 低 | 分享有趣的对话片段 |
|
||||
| 社区讨论 | ⬜ 低 | 角色卡下的评论区 |
|
||||
|
||||
### 14. 高级 AI 功能
|
||||
|
||||
| 功能点 | 优先级 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 多模态支持 (Vision) | ⬜ 高 | 发送图片给支持视觉的模型 |
|
||||
| TTS 语音合成 | ⬜ 中 | AI 回复转语音播放 |
|
||||
| STT 语音输入 | ⬜ 中 | 语音转文字输入 |
|
||||
| AI 图片生成 | ⬜ 中 | 集成 Stable Diffusion / DALL-E 在聊天中生成图片 |
|
||||
| 向量数据库 (RAG) | ⬜ 中 | 更高级的知识检索增强生成 |
|
||||
| 长期记忆 (Long-term Memory) | ⬜ 中 | 跨对话的角色记忆系统 |
|
||||
| Function Calling / Tool Use | ⬜ 低 | AI 调用工具能力(天气、搜索等) |
|
||||
| 多模型混合 (Model Routing) | ⬜ 低 | 不同场景自动选择合适模型 |
|
||||
|
||||
### 15. 插件/扩展系统
|
||||
|
||||
| 功能点 | 优先级 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 插件接口定义 | ⬜ 中 | 标准化的插件 API |
|
||||
| JS-Slash-Runner 兼容 | ⬜ 中 | 兼容原版酒馆的 slash 命令脚本 |
|
||||
| 自定义 Regex 脚本 | ⬜ 中 | 正则替换脚本(对 AI 输出进行后处理) |
|
||||
| 扩展商店 | ⬜ 低 | 在线安装/管理扩展 |
|
||||
|
||||
### 16. 用户积分系统 (Credits System)
|
||||
|
||||
云端多用户场景下的积分经济体系,用于激励用户参与和控制资源消耗。
|
||||
|
||||
| 功能点 | 优先级 | 说明 |
|
||||
|--------|--------|------|
|
||||
| **积分消耗** | | |
|
||||
| 对话 Token 扣费 | ⬜ 高 | 根据 AI 调用的 Token 数量实时扣除积分 |
|
||||
| 分级计费策略 | ⬜ 高 | 不同模型(GPT-4、Claude、Gemini)不同费率 |
|
||||
| 余额不足提醒 | ⬜ 高 | 积分低于阈值时提醒用户充值 |
|
||||
| 消费记录查询 | ⬜ 中 | 详细的积分消费明细(时间、对话、Token、扣费) |
|
||||
| 每日免费额度 | ⬜ 中 | 每日赠送基础免费 Token 额度 |
|
||||
| **积分获取** | | |
|
||||
| 每日签到 | ⬜ 高 | 连续签到获得积分奖励,断签重置 |
|
||||
| 上传角色卡奖励 | ⬜ 高 | 上传公开角色卡获得积分(审核通过后发放) |
|
||||
| 角色卡热度奖励 | ⬜ 中 | 角色被收藏/使用达到阈值时额外奖励 |
|
||||
| 邀请好友 | ⬜ 中 | 邀请新用户注册获得积分 |
|
||||
| 分享对话 | ⬜ 低 | 分享精彩对话片段获得积分 |
|
||||
| 社区贡献 | ⬜ 低 | 评论、评分、反馈 Bug 等获得积分 |
|
||||
| **积分管理** | | |
|
||||
| 积分余额显示 | ⬜ 高 | 顶部导航栏实时显示当前积分 |
|
||||
| 积分充值 | ⬜ 高 | 支持在线支付充值积分 |
|
||||
| 积分套餐 | ⬜ 中 | 不同价格档位的积分包(充值越多单价越低) |
|
||||
| 积分赠送 | ⬜ 低 | 用户之间转赠积分 |
|
||||
| 积分有效期 | ⬜ 低 | 充值积分永久有效,赠送积分有效期限制 |
|
||||
| **数据统计** | | |
|
||||
| 积分流水账单 | ⬜ 中 | 收入/支出明细,支持导出 |
|
||||
| Token 消耗统计 | ⬜ 中 | 按时间、模型、角色维度统计 Token 用量 |
|
||||
| 积分排行榜 | ⬜ 低 | 展示积分最高的用户(可选匿名) |
|
||||
|
||||
### 17. 管理后台
|
||||
|
||||
| 功能点 | 优先级 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 用户管理 | ⬜ 高 | 管理员管理所有用户 |
|
||||
| 角色审核 | ⬜ 高 | 公开角色的审核机制 |
|
||||
| 系统配置 | ⬜ 中 | 全局系统参数配置 |
|
||||
| 数据统计仪表盘 | ⬜ 中 | 用户量、消息量、API 调用量等统计 |
|
||||
| API 用量监控 | ⬜ 中 | Token 消耗监控和限额管理 |
|
||||
| 积分系统管理 | ⬜ 中 | 积分费率配置、手动调整用户积分、积分流水监控 |
|
||||
| 日志管理 | ⬜ 低 | 操作日志和错误日志 |
|
||||
| 公告系统 | ⬜ 低 | 向用户发布系统公告 |
|
||||
|
||||
### 17. 性能与体验优化
|
||||
|
||||
| 功能点 | 优先级 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 消息虚拟滚动 | ⬜ 高 | 大量消息时的渲染性能优化 |
|
||||
| 离线缓存 (PWA) | ⬜ 中 | 支持离线访问已缓存的对话 |
|
||||
| WebSocket 实时通信 | ⬜ 中 | 替代 SSE 的双向通信方案 |
|
||||
| CDN 加速 | ⬜ 中 | 静态资源 CDN 分发 |
|
||||
| 图片压缩/懒加载 | ⬜ 中 | 角色头像等图片优化 |
|
||||
| 国际化 (i18n) | ⬜ 低 | 多语言支持 |
|
||||
|
||||
---
|
||||
|
||||
## 七、开发优先级建议
|
||||
|
||||
### 第一阶段:核心兼容(P0 - 当务之急)
|
||||
|
||||
> 确保与原版酒馆的核心功能对齐
|
||||
|
||||
1. **消息滑动 (Swipe)** - 酒馆标志性交互,用户期望最高
|
||||
2. **世界信息/Lorebook** - 酒馆 RAG 核心,角色扮演深度的关键
|
||||
3. **指令模板 (Instruct Mode)** - 不同模型的 prompt 格式适配
|
||||
4. **上下文模板 (Context Template)** - 角色卡信息注入方式
|
||||
5. **宏替换系统** - `{{char}}`, `{{user}}` 等变量替换
|
||||
6. **用户人设 (Persona)** - 用户角色设定
|
||||
7. **消息编辑** - 修改已发送消息
|
||||
8. **消息续写 (Continue)** - 继续未完成的回复
|
||||
9. **停止生成** - 中断 AI 生成
|
||||
|
||||
### 第二阶段:功能完善(P1)
|
||||
|
||||
1. 群聊系统
|
||||
2. 作者注 (Author's Note)
|
||||
3. 自定义 Prompt 排序
|
||||
4. 消息摘要/压缩
|
||||
5. 角色卡 PNG 导出
|
||||
6. 对话历史导出
|
||||
7. 消息分支
|
||||
8. Token 统计面板
|
||||
|
||||
### 第三阶段:云端增强(P2)
|
||||
|
||||
1. 角色市场完善(评分、收藏、排行)
|
||||
2. 管理后台
|
||||
3. 多模态支持 (Vision)
|
||||
4. 预设/世界信息分享
|
||||
5. TTS/STT 语音功能
|
||||
6. 完整数据备份/恢复
|
||||
|
||||
### 第四阶段:生态扩展(P3)
|
||||
|
||||
1. 插件/扩展系统
|
||||
2. AI 图片生成集成
|
||||
3. 长期记忆系统
|
||||
4. 社区功能
|
||||
5. 国际化
|
||||
6. PWA 离线支持
|
||||
|
||||
---
|
||||
|
||||
## 八、与原版酒馆的架构差异
|
||||
|
||||
| 维度 | 原版 SillyTavern | 云酒馆 |
|
||||
|------|------------------|--------|
|
||||
| 部署模式 | 本地单用户 | 云端多用户 |
|
||||
| 前端 | jQuery + 原生 JS | React + TypeScript |
|
||||
| 后端 | Node.js + Express | Go + Gin |
|
||||
| 数据库 | 文件系统 (JSON/PNG) | PostgreSQL + GORM |
|
||||
| 认证 | 简单密码/无认证 | JWT + 会话管理 |
|
||||
| AI 调用 | 前端直连 / 后端代理 | 后端统一代理 |
|
||||
| 数据存储 | 本地文件 | 数据库 + 对象存储 |
|
||||
| 扩展机制 | iframe + 文件注入 | 插件 API (规划中) |
|
||||
|
||||
> **核心原则**:功能上完全兼容原版酒馆的角色卡规范和核心交互体验,架构上利用云端优势进行增强。
|
||||
@@ -54,7 +54,12 @@ func (a *AIConfigApi) GetAIConfigList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
commonResponse.OkWithData(resp, c)
|
||||
commonResponse.OkWithDetailed(commonResponse.PageResult{
|
||||
List: resp.List,
|
||||
Total: resp.Total,
|
||||
Page: 0,
|
||||
PageSize: 0,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// UpdateAIConfig
|
||||
|
||||
@@ -84,7 +84,12 @@ func (a *CharacterApi) GetCharacterList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
commonResponse.OkWithData(resp, c)
|
||||
commonResponse.OkWithDetailed(commonResponse.PageResult{
|
||||
List: resp.List,
|
||||
Total: resp.Total,
|
||||
Page: resp.Page,
|
||||
PageSize: resp.PageSize,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// GetCharacterByID
|
||||
|
||||
@@ -74,7 +74,12 @@ func (a *ConversationApi) GetConversationList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
commonResponse.OkWithData(resp, c)
|
||||
commonResponse.OkWithDetailed(commonResponse.PageResult{
|
||||
List: resp.List,
|
||||
Total: resp.Total,
|
||||
Page: resp.Page,
|
||||
PageSize: resp.PageSize,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// GetConversationByID
|
||||
@@ -127,7 +132,7 @@ func (a *ConversationApi) UpdateConversationSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
err = service.ServiceGroupApp.AppServiceGroup.ConversationService.UpdateConversationSettings(userID, uint(conversationID), req.Settings)
|
||||
err = service.ServiceGroupApp.AppServiceGroup.ConversationService.UpdateConversationSettings(userID, uint(conversationID), &req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("更新对话设置失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
@@ -199,9 +204,91 @@ func (a *ConversationApi) GetMessageList(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
commonResponse.OkWithDetailed(commonResponse.PageResult{
|
||||
List: resp.List,
|
||||
Total: resp.Total,
|
||||
Page: resp.Page,
|
||||
PageSize: resp.PageSize,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// RegenerateMessage
|
||||
// @Tags AppConversation
|
||||
// @Summary 重新生成最后一条 AI 回复
|
||||
// @Produce application/json
|
||||
// @Param id path int true "对话ID"
|
||||
// @Param stream query bool false "是否流式传输"
|
||||
// @Success 200 {object} commonResponse.Response{data=response.MessageResponse} "重新生成成功"
|
||||
// @Router /app/conversation/:id/regenerate [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *ConversationApi) RegenerateMessage(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的对话ID", c)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("stream") == "true" {
|
||||
a.regenerateMessageStream(c, userID, uint(conversationID))
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := service.ServiceGroupApp.AppServiceGroup.ConversationService.RegenerateMessage(userID, uint(conversationID))
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("重新生成消息失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithData(resp, c)
|
||||
}
|
||||
|
||||
func (a *ConversationApi) regenerateMessageStream(c *gin.Context, userID, conversationID uint) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
streamChan := make(chan string, 100)
|
||||
errorChan := make(chan error, 1)
|
||||
doneChan := make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
if err := service.ServiceGroupApp.AppServiceGroup.ConversationService.RegenerateMessageStream(
|
||||
userID, conversationID, streamChan, doneChan,
|
||||
); err != nil {
|
||||
errorChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
commonResponse.FailWithMessage("不支持流式传输", c)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case chunk := <-streamChan:
|
||||
c.Writer.Write([]byte("event: message\n"))
|
||||
c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", chunk)))
|
||||
flusher.Flush()
|
||||
case err := <-errorChan:
|
||||
c.Writer.Write([]byte("event: error\n"))
|
||||
c.Writer.Write([]byte(fmt.Sprintf("data: %s\n\n", err.Error())))
|
||||
flusher.Flush()
|
||||
return
|
||||
case <-doneChan:
|
||||
c.Writer.Write([]byte("event: done\n"))
|
||||
c.Writer.Write([]byte("data: \n\n"))
|
||||
flusher.Flush()
|
||||
return
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage
|
||||
// @Tags AppConversation
|
||||
// @Summary 发送消息
|
||||
|
||||
@@ -7,4 +7,6 @@ type ApiGroup struct {
|
||||
AIConfigApi
|
||||
PresetApi
|
||||
UploadApi
|
||||
WorldbookApi
|
||||
RegexScriptApi
|
||||
}
|
||||
|
||||
@@ -79,14 +79,12 @@ func (a *PresetApi) GetPresetList(c *gin.Context) {
|
||||
list = append(list, response.ToPresetResponse(&preset))
|
||||
}
|
||||
|
||||
resp := response.PresetListResponse{
|
||||
commonResponse.OkWithDetailed(commonResponse.PageResult{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}
|
||||
|
||||
commonResponse.OkWithData(resp, c)
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// GetPresetByID 根据ID获取预设
|
||||
|
||||
167
server/api/v1/app/regex_script.go
Normal file
167
server/api/v1/app/regex_script.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app/request"
|
||||
"git.echol.cn/loser/st/server/model/common"
|
||||
commonResponse "git.echol.cn/loser/st/server/model/common/response"
|
||||
"git.echol.cn/loser/st/server/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type RegexScriptApi struct{}
|
||||
|
||||
// CreateRegexScript
|
||||
// @Tags AppRegexScript
|
||||
// @Summary 创建正则脚本
|
||||
// @Router /app/regex [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *RegexScriptApi) CreateRegexScript(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
var req request.CreateRegexScriptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
resp, err := service.ServiceGroupApp.AppServiceGroup.RegexScriptService.CreateRegexScript(userID, &req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("创建正则脚本失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithData(resp, c)
|
||||
}
|
||||
|
||||
// GetRegexScriptList
|
||||
// @Tags AppRegexScript
|
||||
// @Summary 获取正则脚本列表
|
||||
// @Router /app/regex [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *RegexScriptApi) GetRegexScriptList(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
var req request.GetRegexScriptListRequest
|
||||
req.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
req.PageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
req.Keyword = c.Query("keyword")
|
||||
if scope := c.Query("scope"); scope != "" {
|
||||
scopeInt, _ := strconv.Atoi(scope)
|
||||
req.Scope = &scopeInt
|
||||
}
|
||||
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
|
||||
list, total, err := service.ServiceGroupApp.AppServiceGroup.RegexScriptService.GetRegexScriptList(userID, &req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取正则脚本列表失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
commonResponse.OkWithDetailed(commonResponse.PageResult{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// GetRegexScriptByID
|
||||
// @Tags AppRegexScript
|
||||
// @Summary 获取正则脚本详情
|
||||
// @Router /app/regex/:id [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *RegexScriptApi) GetRegexScriptByID(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的脚本ID", c)
|
||||
return
|
||||
}
|
||||
resp, err := service.ServiceGroupApp.AppServiceGroup.RegexScriptService.GetRegexScriptByID(userID, uint(id))
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取正则脚本失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithData(resp, c)
|
||||
}
|
||||
|
||||
// UpdateRegexScript
|
||||
// @Tags AppRegexScript
|
||||
// @Summary 更新正则脚本
|
||||
// @Router /app/regex/:id [put]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *RegexScriptApi) UpdateRegexScript(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的脚本ID", c)
|
||||
return
|
||||
}
|
||||
var req request.UpdateRegexScriptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err := service.ServiceGroupApp.AppServiceGroup.RegexScriptService.UpdateRegexScript(userID, uint(id), &req); err != nil {
|
||||
global.GVA_LOG.Error("更新正则脚本失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithMessage("更新成功", c)
|
||||
}
|
||||
|
||||
// DeleteRegexScript
|
||||
// @Tags AppRegexScript
|
||||
// @Summary 删除正则脚本
|
||||
// @Router /app/regex/:id [delete]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *RegexScriptApi) DeleteRegexScript(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的脚本ID", c)
|
||||
return
|
||||
}
|
||||
if err := service.ServiceGroupApp.AppServiceGroup.RegexScriptService.DeleteRegexScript(userID, uint(id)); err != nil {
|
||||
global.GVA_LOG.Error("删除正则脚本失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithMessage("删除成功", c)
|
||||
}
|
||||
|
||||
// TestRegexScript
|
||||
// @Tags AppRegexScript
|
||||
// @Summary 测试正则脚本
|
||||
// @Router /app/regex/:id/test [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *RegexScriptApi) TestRegexScript(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的脚本ID", c)
|
||||
return
|
||||
}
|
||||
var req request.TestRegexScriptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
resp, err := service.ServiceGroupApp.AppServiceGroup.RegexScriptService.TestRegexScript(userID, uint(id), req.TestString)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("测试正则脚本失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
296
server/api/v1/app/worldbook.go
Normal file
296
server/api/v1/app/worldbook.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app/request"
|
||||
"git.echol.cn/loser/st/server/model/common"
|
||||
commonResponse "git.echol.cn/loser/st/server/model/common/response"
|
||||
"git.echol.cn/loser/st/server/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type WorldbookApi struct{}
|
||||
|
||||
// CreateWorldbook
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 创建世界书
|
||||
// @Router /app/worldbook [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) CreateWorldbook(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
var req request.CreateWorldbookRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.CreateWorldbook(userID, &req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("创建世界书失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithData(resp, c)
|
||||
}
|
||||
|
||||
// GetWorldbookList
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 获取世界书列表
|
||||
// @Router /app/worldbook [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) GetWorldbookList(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
var req request.GetWorldbookListRequest
|
||||
req.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
req.PageSize, _ = strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
req.Keyword = c.Query("keyword")
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 || req.PageSize > 100 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
list, total, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetWorldbookList(userID, &req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取世界书列表失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithDetailed(commonResponse.PageResult{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// GetWorldbookByID
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 获取世界书详情
|
||||
// @Router /app/worldbook/:id [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) GetWorldbookByID(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的世界书ID", c)
|
||||
return
|
||||
}
|
||||
resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetWorldbookByID(userID, uint(id))
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取世界书失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithData(resp, c)
|
||||
}
|
||||
|
||||
// UpdateWorldbook
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 更新世界书
|
||||
// @Router /app/worldbook/:id [put]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) UpdateWorldbook(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的世界书ID", c)
|
||||
return
|
||||
}
|
||||
var req request.UpdateWorldbookRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.UpdateWorldbook(userID, uint(id), &req); err != nil {
|
||||
global.GVA_LOG.Error("更新世界书失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithMessage("更新成功", c)
|
||||
}
|
||||
|
||||
// DeleteWorldbook
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 删除世界书
|
||||
// @Router /app/worldbook/:id [delete]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) DeleteWorldbook(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的世界书ID", c)
|
||||
return
|
||||
}
|
||||
if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.DeleteWorldbook(userID, uint(id)); err != nil {
|
||||
global.GVA_LOG.Error("删除世界书失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithMessage("删除成功", c)
|
||||
}
|
||||
|
||||
// ImportWorldbook
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 导入世界书(JSON)
|
||||
// @Router /app/worldbook/import [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) ImportWorldbook(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("请上传文件", c)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > 5*1024*1024 {
|
||||
commonResponse.FailWithMessage("文件大小不能超过 5MB", c)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, header.Size)
|
||||
if _, err := file.Read(buf); err != nil {
|
||||
commonResponse.FailWithMessage("读取文件失败", c)
|
||||
return
|
||||
}
|
||||
|
||||
// 去掉扩展名作为名称
|
||||
filename := header.Filename
|
||||
for i := len(filename) - 1; i >= 0; i-- {
|
||||
if filename[i] == '.' {
|
||||
filename = filename[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.ImportFromJSON(userID, buf, filename)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("导入世界书失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithData(resp, c)
|
||||
}
|
||||
|
||||
// ExportWorldbook
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 导出世界书为 JSON
|
||||
// @Router /app/worldbook/:id/export [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) ExportWorldbook(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的世界书ID", c)
|
||||
return
|
||||
}
|
||||
data, filename, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.ExportToJSON(userID, uint(id))
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("导出世界书失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Data(http.StatusOK, "application/json", data)
|
||||
}
|
||||
|
||||
// CreateEntry
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 创建世界书条目
|
||||
// @Router /app/worldbook/:id/entry [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) CreateEntry(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
worldbookID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的世界书ID", c)
|
||||
return
|
||||
}
|
||||
var req request.CreateEntryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
resp, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.CreateEntry(userID, uint(worldbookID), &req)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("创建世界书条目失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithData(resp, c)
|
||||
}
|
||||
|
||||
// GetEntryList
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 获取世界书条目列表
|
||||
// @Router /app/worldbook/:id/entries [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) GetEntryList(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
worldbookID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的世界书ID", c)
|
||||
return
|
||||
}
|
||||
list, total, err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.GetEntryList(userID, uint(worldbookID))
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("获取条目列表失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithDetailed(commonResponse.PageResult{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: 0,
|
||||
PageSize: 0,
|
||||
}, "获取成功", c)
|
||||
}
|
||||
|
||||
// UpdateEntry
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 更新世界书条目
|
||||
// @Router /app/worldbook/:id/entry/:entryId [put]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) UpdateEntry(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
entryID, err := strconv.ParseUint(c.Param("entryId"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的条目ID", c)
|
||||
return
|
||||
}
|
||||
var req request.UpdateEntryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.UpdateEntry(userID, uint(entryID), &req); err != nil {
|
||||
global.GVA_LOG.Error("更新条目失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithMessage("更新成功", c)
|
||||
}
|
||||
|
||||
// DeleteEntry
|
||||
// @Tags AppWorldbook
|
||||
// @Summary 删除世界书条目
|
||||
// @Router /app/worldbook/:id/entry/:entryId [delete]
|
||||
// @Security ApiKeyAuth
|
||||
func (a *WorldbookApi) DeleteEntry(c *gin.Context) {
|
||||
userID := common.GetAppUserID(c)
|
||||
entryID, err := strconv.ParseUint(c.Param("entryId"), 10, 32)
|
||||
if err != nil {
|
||||
commonResponse.FailWithMessage("无效的条目ID", c)
|
||||
return
|
||||
}
|
||||
if err := service.ServiceGroupApp.AppServiceGroup.WorldbookService.DeleteEntry(userID, uint(entryID)); err != nil {
|
||||
global.GVA_LOG.Error("删除条目失败", zap.Error(err))
|
||||
commonResponse.FailWithMessage(err.Error(), c)
|
||||
return
|
||||
}
|
||||
commonResponse.OkWithMessage("删除成功", c)
|
||||
}
|
||||
9314
server/docs/docs.go
Normal file
9314
server/docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
9286
server/docs/swagger.json
Normal file
9286
server/docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
5677
server/docs/swagger.yaml
Normal file
5677
server/docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
||||
-- 修复 ai_world_info 表结构
|
||||
-- 如果表存在旧的 name 字段,需要删除并重新创建
|
||||
|
||||
-- 删除旧表(如果存在)
|
||||
DROP TABLE IF EXISTS ai_character_world_info CASCADE;
|
||||
DROP TABLE IF EXISTS ai_world_info CASCADE;
|
||||
|
||||
-- 表将由 Gorm AutoMigrate 自动创建
|
||||
-- 重启服务器即可
|
||||
@@ -86,6 +86,9 @@ func RegisterTables() {
|
||||
app.Message{},
|
||||
app.AIConfig{},
|
||||
app.AIPreset{},
|
||||
app.Worldbook{},
|
||||
app.WorldbookEntry{},
|
||||
app.RegexScript{},
|
||||
)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("register table failed", zap.Error(err))
|
||||
|
||||
@@ -153,6 +153,8 @@ func Routers() *gin.Engine {
|
||||
appRouter.InitAIConfigRouter(appGroup) // AI配置路由:/app/ai-config/*
|
||||
appRouter.InitPresetRouter(appGroup) // 预设路由:/app/preset/*
|
||||
appRouter.InitUploadRouter(appGroup) // 上传路由:/app/upload/*
|
||||
appRouter.InitWorldbookRouter(appGroup) // 世界书路由:/app/worldbook/*
|
||||
appRouter.InitRegexScriptRouter(appGroup) // 正则脚本路由:/app/regex/*
|
||||
}
|
||||
|
||||
//插件路由安装
|
||||
|
||||
@@ -19,10 +19,12 @@ type Conversation struct {
|
||||
Title string `gorm:"type:varchar(200)" json:"title"` // 对话标题
|
||||
|
||||
// 对话配置
|
||||
PresetID *uint `gorm:"index" json:"presetId"` // 使用的预设ID
|
||||
AIProvider string `gorm:"type:varchar(50)" json:"aiProvider"` // AI提供商 (openai/anthropic)
|
||||
Model string `gorm:"type:varchar(100)" json:"model"` // 使用的模型
|
||||
Settings datatypes.JSON `gorm:"type:jsonb" json:"settings"` // 对话设置 (temperature等)
|
||||
PresetID *uint `gorm:"index" json:"presetId"` // 使用的预设ID
|
||||
WorldbookID *uint `gorm:"index" json:"worldbookId"` // 使用的世界书ID
|
||||
AIProvider string `gorm:"type:varchar(50)" json:"aiProvider"` // AI提供商 (openai/anthropic)
|
||||
Model string `gorm:"type:varchar(100)" json:"model"` // 使用的模型
|
||||
Settings datatypes.JSON `gorm:"type:jsonb" json:"settings"` // 对话设置 (temperature等)
|
||||
WorldbookEnabled bool `gorm:"default:false" json:"worldbookEnabled"` // 是否启用世界书
|
||||
|
||||
// 统计信息
|
||||
MessageCount int `gorm:"default:0" json:"messageCount"` // 消息数量
|
||||
|
||||
57
server/model/app/regex_script.go
Normal file
57
server/model/app/regex_script.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegexScript 正则脚本模型(完全兼容 SillyTavern 格式)
|
||||
type RegexScript struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
UserID uint `gorm:"index;not null" json:"userId"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"` // 脚本名称
|
||||
|
||||
// 正则表达式
|
||||
FindRegex string `gorm:"type:text;not null" json:"findRegex"` // 查找的正则表达式
|
||||
ReplaceWith string `gorm:"type:text" json:"replaceWith"` // 替换的内容
|
||||
TrimStrings datatypes.JSON `gorm:"type:jsonb" json:"trimStrings"` // 要修剪的字符串列表 []string
|
||||
|
||||
// 执行阶段
|
||||
// 0=输入(input), 1=输出(output), 2=世界书(world_info), 3=推理(display)
|
||||
Placement int `gorm:"default:1" json:"placement"`
|
||||
|
||||
// 执行选项
|
||||
Disabled bool `gorm:"default:false" json:"disabled"` // 是否禁用
|
||||
MarkdownOnly bool `gorm:"default:false" json:"markdownOnly"` // 仅在 Markdown 模式下执行
|
||||
RunOnEdit bool `gorm:"default:false" json:"runOnEdit"` // 编辑消息时执行
|
||||
PromptOnly bool `gorm:"default:false" json:"promptOnly"` // 仅在 prompt 中执行
|
||||
|
||||
// 宏替换
|
||||
SubstituteRegex bool `gorm:"default:true" json:"substituteRegex"` // 是否替换宏变量 {{user}}/{{char}}
|
||||
|
||||
// 深度控制
|
||||
MinDepth *int `gorm:"type:int" json:"minDepth"` // 最小深度(null 表示不限制)
|
||||
MaxDepth *int `gorm:"type:int" json:"maxDepth"` // 最大深度(null 表示不限制)
|
||||
|
||||
// 作用域
|
||||
// 0=全局(global), 1=角色(character), 2=预设(preset)
|
||||
Scope int `gorm:"default:0" json:"scope"`
|
||||
OwnerCharID *uint `gorm:"type:int" json:"ownerCharId"` // 角色ID(scope=1时有效)
|
||||
OwnerPresetID *uint `gorm:"type:int" json:"ownerPresetId"` // 预设ID(scope=2时有效)
|
||||
|
||||
// 执行顺序
|
||||
Order int `gorm:"default:100" json:"order"` // 执行顺序,数字越小越先执行
|
||||
|
||||
// 扩展字段
|
||||
Extensions datatypes.JSON `gorm:"type:jsonb" json:"extensions"`
|
||||
}
|
||||
|
||||
func (RegexScript) TableName() string {
|
||||
return "regex_scripts"
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package request
|
||||
|
||||
// CreateConversationRequest 创建对话请求
|
||||
type CreateConversationRequest struct {
|
||||
CharacterID uint `json:"characterId" binding:"required"`
|
||||
Title string `json:"title" binding:"max=200"`
|
||||
PresetID *uint `json:"presetId"`
|
||||
AIProvider string `json:"aiProvider" binding:"omitempty,oneof=openai anthropic"`
|
||||
Model string `json:"model"`
|
||||
CharacterID uint `json:"characterId" binding:"required"`
|
||||
Title string `json:"title" binding:"max=200"`
|
||||
PresetID *uint `json:"presetId"`
|
||||
WorldbookID *uint `json:"worldbookId"`
|
||||
WorldbookEnabled bool `json:"worldbookEnabled"`
|
||||
AIProvider string `json:"aiProvider" binding:"omitempty,oneof=openai anthropic"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// SendMessageRequest 发送消息请求
|
||||
@@ -28,5 +30,7 @@ type GetMessageListRequest struct {
|
||||
|
||||
// UpdateConversationSettingsRequest 更新对话设置请求
|
||||
type UpdateConversationSettingsRequest struct {
|
||||
Settings map[string]interface{} `json:"settings" binding:"required"`
|
||||
Settings map[string]interface{} `json:"settings"`
|
||||
WorldbookID *uint `json:"worldbookId"`
|
||||
WorldbookEnabled *bool `json:"worldbookEnabled"`
|
||||
}
|
||||
|
||||
54
server/model/app/request/regex_script.go
Normal file
54
server/model/app/request/regex_script.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package request
|
||||
|
||||
// CreateRegexScriptRequest 创建正则脚本请求
|
||||
type CreateRegexScriptRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
FindRegex string `json:"findRegex" binding:"required"`
|
||||
ReplaceWith string `json:"replaceWith"`
|
||||
TrimStrings []string `json:"trimStrings"`
|
||||
Placement int `json:"placement"`
|
||||
Disabled bool `json:"disabled"`
|
||||
MarkdownOnly bool `json:"markdownOnly"`
|
||||
RunOnEdit bool `json:"runOnEdit"`
|
||||
PromptOnly bool `json:"promptOnly"`
|
||||
SubstituteRegex bool `json:"substituteRegex"`
|
||||
MinDepth *int `json:"minDepth"`
|
||||
MaxDepth *int `json:"maxDepth"`
|
||||
Scope int `json:"scope"`
|
||||
OwnerCharID *uint `json:"ownerCharId"`
|
||||
OwnerPresetID *uint `json:"ownerPresetId"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// UpdateRegexScriptRequest 更新正则脚本请求
|
||||
type UpdateRegexScriptRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
FindRegex *string `json:"findRegex"`
|
||||
ReplaceWith *string `json:"replaceWith"`
|
||||
TrimStrings []string `json:"trimStrings"`
|
||||
Placement *int `json:"placement"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
MarkdownOnly *bool `json:"markdownOnly"`
|
||||
RunOnEdit *bool `json:"runOnEdit"`
|
||||
PromptOnly *bool `json:"promptOnly"`
|
||||
SubstituteRegex *bool `json:"substituteRegex"`
|
||||
MinDepth *int `json:"minDepth"`
|
||||
MaxDepth *int `json:"maxDepth"`
|
||||
Scope *int `json:"scope"`
|
||||
OwnerCharID *uint `json:"ownerCharId"`
|
||||
OwnerPresetID *uint `json:"ownerPresetId"`
|
||||
Order *int `json:"order"`
|
||||
}
|
||||
|
||||
// GetRegexScriptListRequest 获取正则脚本列表请求
|
||||
type GetRegexScriptListRequest struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Keyword string `json:"keyword"`
|
||||
Scope *int `json:"scope"`
|
||||
}
|
||||
|
||||
// TestRegexScriptRequest 测试正则脚本请求
|
||||
type TestRegexScriptRequest struct {
|
||||
TestString string `json:"testString" binding:"required"`
|
||||
}
|
||||
64
server/model/app/request/worldbook.go
Normal file
64
server/model/app/request/worldbook.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package request
|
||||
|
||||
// CreateWorldbookRequest 创建世界书请求
|
||||
type CreateWorldbookRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
// UpdateWorldbookRequest 更新世界书请求
|
||||
type UpdateWorldbookRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
Description *string `json:"description"`
|
||||
IsPublic *bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
// GetWorldbookListRequest 获取世界书列表请求
|
||||
type GetWorldbookListRequest struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Keyword string `json:"keyword"`
|
||||
}
|
||||
|
||||
// CreateEntryRequest 创建世界书条目请求
|
||||
type CreateEntryRequest struct {
|
||||
Comment string `json:"comment"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Keys []string `json:"keys"`
|
||||
SecondaryKeys []string `json:"secondaryKeys"`
|
||||
Constant bool `json:"constant"`
|
||||
Enabled *bool `json:"enabled"` // 指针以区分 false 和未传
|
||||
UseRegex bool `json:"useRegex"`
|
||||
CaseSensitive bool `json:"caseSensitive"`
|
||||
MatchWholeWords bool `json:"matchWholeWords"`
|
||||
Selective bool `json:"selective"`
|
||||
SelectiveLogic int `json:"selectiveLogic"`
|
||||
Position int `json:"position"`
|
||||
Depth int `json:"depth"`
|
||||
Order int `json:"order"`
|
||||
Probability int `json:"probability"`
|
||||
ScanDepth int `json:"scanDepth"`
|
||||
GroupID string `json:"groupId"`
|
||||
}
|
||||
|
||||
// UpdateEntryRequest 更新世界书条目请求(所有字段可选)
|
||||
type UpdateEntryRequest struct {
|
||||
Comment *string `json:"comment"`
|
||||
Content *string `json:"content"`
|
||||
Keys []string `json:"keys"`
|
||||
SecondaryKeys []string `json:"secondaryKeys"`
|
||||
Constant *bool `json:"constant"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
UseRegex *bool `json:"useRegex"`
|
||||
CaseSensitive *bool `json:"caseSensitive"`
|
||||
MatchWholeWords *bool `json:"matchWholeWords"`
|
||||
Selective *bool `json:"selective"`
|
||||
SelectiveLogic *int `json:"selectiveLogic"`
|
||||
Position *int `json:"position"`
|
||||
Depth *int `json:"depth"`
|
||||
Order *int `json:"order"`
|
||||
Probability *int `json:"probability"`
|
||||
ScanDepth *int `json:"scanDepth"`
|
||||
GroupID *string `json:"groupId"`
|
||||
}
|
||||
@@ -9,17 +9,19 @@ import (
|
||||
|
||||
// ConversationResponse 对话响应
|
||||
type ConversationResponse struct {
|
||||
ID uint `json:"id"`
|
||||
CharacterID uint `json:"characterId"`
|
||||
Title string `json:"title"`
|
||||
PresetID *uint `json:"presetId"`
|
||||
AIProvider string `json:"aiProvider"`
|
||||
Model string `json:"model"`
|
||||
Settings map[string]interface{} `json:"settings,omitempty"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
TokenCount int `json:"tokenCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `json:"id"`
|
||||
CharacterID uint `json:"characterId"`
|
||||
Title string `json:"title"`
|
||||
PresetID *uint `json:"presetId"`
|
||||
WorldbookID *uint `json:"worldbookId"`
|
||||
WorldbookEnabled bool `json:"worldbookEnabled"`
|
||||
AIProvider string `json:"aiProvider"`
|
||||
Model string `json:"model"`
|
||||
Settings map[string]interface{} `json:"settings,omitempty"`
|
||||
MessageCount int `json:"messageCount"`
|
||||
TokenCount int `json:"tokenCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
// 关联数据
|
||||
Character *CharacterResponse `json:"character,omitempty"`
|
||||
@@ -75,17 +77,19 @@ func ToConversationResponse(conv *app.Conversation) ConversationResponse {
|
||||
}
|
||||
|
||||
return ConversationResponse{
|
||||
ID: conv.ID,
|
||||
CharacterID: conv.CharacterID,
|
||||
Title: conv.Title,
|
||||
PresetID: conv.PresetID,
|
||||
AIProvider: conv.AIProvider,
|
||||
Model: conv.Model,
|
||||
Settings: settings,
|
||||
MessageCount: conv.MessageCount,
|
||||
TokenCount: conv.TokenCount,
|
||||
CreatedAt: conv.CreatedAt,
|
||||
UpdatedAt: conv.UpdatedAt,
|
||||
ID: conv.ID,
|
||||
CharacterID: conv.CharacterID,
|
||||
Title: conv.Title,
|
||||
PresetID: conv.PresetID,
|
||||
WorldbookID: conv.WorldbookID,
|
||||
WorldbookEnabled: conv.WorldbookEnabled,
|
||||
AIProvider: conv.AIProvider,
|
||||
Model: conv.Model,
|
||||
Settings: settings,
|
||||
MessageCount: conv.MessageCount,
|
||||
TokenCount: conv.TokenCount,
|
||||
CreatedAt: conv.CreatedAt,
|
||||
UpdatedAt: conv.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
78
server/model/app/response/regex_script.go
Normal file
78
server/model/app/response/regex_script.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
)
|
||||
|
||||
// RegexScriptResponse 正则脚本响应
|
||||
type RegexScriptResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
FindRegex string `json:"findRegex"`
|
||||
ReplaceWith string `json:"replaceWith"`
|
||||
TrimStrings []string `json:"trimStrings"`
|
||||
Placement int `json:"placement"`
|
||||
Disabled bool `json:"disabled"`
|
||||
MarkdownOnly bool `json:"markdownOnly"`
|
||||
RunOnEdit bool `json:"runOnEdit"`
|
||||
PromptOnly bool `json:"promptOnly"`
|
||||
SubstituteRegex bool `json:"substituteRegex"`
|
||||
MinDepth *int `json:"minDepth"`
|
||||
MaxDepth *int `json:"maxDepth"`
|
||||
Scope int `json:"scope"`
|
||||
OwnerCharID *uint `json:"ownerCharId"`
|
||||
OwnerPresetID *uint `json:"ownerPresetId"`
|
||||
Order int `json:"order"`
|
||||
Extensions map[string]interface{} `json:"extensions"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// TestRegexScriptResponse 测试正则脚本响应
|
||||
type TestRegexScriptResponse struct {
|
||||
Original string `json:"original"`
|
||||
Result string `json:"result"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ToRegexScriptResponse 转换为正则脚本响应结构
|
||||
func ToRegexScriptResponse(script *app.RegexScript) RegexScriptResponse {
|
||||
var trimStrings []string
|
||||
if len(script.TrimStrings) > 0 {
|
||||
json.Unmarshal(script.TrimStrings, &trimStrings)
|
||||
}
|
||||
|
||||
var extensions map[string]interface{}
|
||||
if len(script.Extensions) > 0 {
|
||||
json.Unmarshal(script.Extensions, &extensions)
|
||||
}
|
||||
|
||||
return RegexScriptResponse{
|
||||
ID: script.ID,
|
||||
UserID: script.UserID,
|
||||
Name: script.Name,
|
||||
FindRegex: script.FindRegex,
|
||||
ReplaceWith: script.ReplaceWith,
|
||||
TrimStrings: trimStrings,
|
||||
Placement: script.Placement,
|
||||
Disabled: script.Disabled,
|
||||
MarkdownOnly: script.MarkdownOnly,
|
||||
RunOnEdit: script.RunOnEdit,
|
||||
PromptOnly: script.PromptOnly,
|
||||
SubstituteRegex: script.SubstituteRegex,
|
||||
MinDepth: script.MinDepth,
|
||||
MaxDepth: script.MaxDepth,
|
||||
Scope: script.Scope,
|
||||
OwnerCharID: script.OwnerCharID,
|
||||
OwnerPresetID: script.OwnerPresetID,
|
||||
Order: script.Order,
|
||||
Extensions: extensions,
|
||||
CreatedAt: script.CreatedAt,
|
||||
UpdatedAt: script.UpdatedAt,
|
||||
}
|
||||
}
|
||||
114
server/model/app/response/worldbook.go
Normal file
114
server/model/app/response/worldbook.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
)
|
||||
|
||||
// WorldbookResponse 世界书响应
|
||||
type WorldbookResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
EntryCount int `json:"entryCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// WorldbookListResponse 世界书列表响应
|
||||
type WorldbookListResponse struct {
|
||||
List []WorldbookResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
// EntryResponse 世界书条目响应
|
||||
type EntryResponse struct {
|
||||
ID uint `json:"id"`
|
||||
WorldbookID uint `json:"worldbookId"`
|
||||
Comment string `json:"comment"`
|
||||
Content string `json:"content"`
|
||||
Keys []string `json:"keys"`
|
||||
SecondaryKeys []string `json:"secondaryKeys"`
|
||||
Constant bool `json:"constant"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UseRegex bool `json:"useRegex"`
|
||||
CaseSensitive bool `json:"caseSensitive"`
|
||||
MatchWholeWords bool `json:"matchWholeWords"`
|
||||
Selective bool `json:"selective"`
|
||||
SelectiveLogic int `json:"selectiveLogic"`
|
||||
Position int `json:"position"`
|
||||
Depth int `json:"depth"`
|
||||
Order int `json:"order"`
|
||||
Probability int `json:"probability"`
|
||||
ScanDepth int `json:"scanDepth"`
|
||||
GroupID string `json:"groupId"`
|
||||
Extensions map[string]interface{} `json:"extensions"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// EntryListResponse 条目列表响应
|
||||
type EntryListResponse struct {
|
||||
List []EntryResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// ToWorldbookResponse 转换为世界书响应结构
|
||||
func ToWorldbookResponse(wb *app.Worldbook) WorldbookResponse {
|
||||
return WorldbookResponse{
|
||||
ID: wb.ID,
|
||||
UserID: wb.UserID,
|
||||
Name: wb.Name,
|
||||
Description: wb.Description,
|
||||
IsPublic: wb.IsPublic,
|
||||
EntryCount: wb.EntryCount,
|
||||
CreatedAt: wb.CreatedAt,
|
||||
UpdatedAt: wb.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToEntryResponse 转换为条目响应结构
|
||||
func ToEntryResponse(entry *app.WorldbookEntry) EntryResponse {
|
||||
var keys []string
|
||||
if len(entry.Keys) > 0 {
|
||||
json.Unmarshal(entry.Keys, &keys)
|
||||
}
|
||||
var secondaryKeys []string
|
||||
if len(entry.SecondaryKeys) > 0 {
|
||||
json.Unmarshal(entry.SecondaryKeys, &secondaryKeys)
|
||||
}
|
||||
var extensions map[string]interface{}
|
||||
if len(entry.Extensions) > 0 {
|
||||
json.Unmarshal(entry.Extensions, &extensions)
|
||||
}
|
||||
return EntryResponse{
|
||||
ID: entry.ID,
|
||||
WorldbookID: entry.WorldbookID,
|
||||
Comment: entry.Comment,
|
||||
Content: entry.Content,
|
||||
Keys: keys,
|
||||
SecondaryKeys: secondaryKeys,
|
||||
Constant: entry.Constant,
|
||||
Enabled: entry.Enabled,
|
||||
UseRegex: entry.UseRegex,
|
||||
CaseSensitive: entry.CaseSensitive,
|
||||
MatchWholeWords: entry.MatchWholeWords,
|
||||
Selective: entry.Selective,
|
||||
SelectiveLogic: entry.SelectiveLogic,
|
||||
Position: entry.Position,
|
||||
Depth: entry.Depth,
|
||||
Order: entry.Order,
|
||||
Probability: entry.Probability,
|
||||
ScanDepth: entry.ScanDepth,
|
||||
GroupID: entry.GroupID,
|
||||
Extensions: extensions,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
UpdatedAt: entry.UpdatedAt,
|
||||
}
|
||||
}
|
||||
68
server/model/app/worldbook.go
Normal file
68
server/model/app/worldbook.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Worldbook 世界书主表
|
||||
type Worldbook struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
UserID uint `gorm:"index;not null" json:"userId"`
|
||||
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
IsPublic bool `gorm:"default:false" json:"isPublic"`
|
||||
EntryCount int `gorm:"default:0" json:"entryCount"`
|
||||
}
|
||||
|
||||
func (Worldbook) TableName() string {
|
||||
return "worldbooks"
|
||||
}
|
||||
|
||||
// WorldbookEntry 世界书条目表(完全兼容 SillyTavern 格式)
|
||||
type WorldbookEntry struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
WorldbookID uint `gorm:"index;not null" json:"worldbookId"`
|
||||
Comment string `gorm:"type:varchar(200)" json:"comment"` // 条目标题/备注
|
||||
Content string `gorm:"type:text;not null" json:"content"` // 注入内容
|
||||
|
||||
// 关键词(存为 JSONB []string)
|
||||
Keys datatypes.JSON `gorm:"type:jsonb" json:"keys"`
|
||||
SecondaryKeys datatypes.JSON `gorm:"type:jsonb" json:"secondaryKeys"`
|
||||
|
||||
// 触发设置
|
||||
Constant bool `gorm:"default:false" json:"constant"` // 常驻注入,无需关键词触发
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 是否启用
|
||||
UseRegex bool `gorm:"default:false" json:"useRegex"` // 关键词用正则表达式
|
||||
CaseSensitive bool `gorm:"default:false" json:"caseSensitive"` // 区分大小写
|
||||
MatchWholeWords bool `gorm:"default:false" json:"matchWholeWords"` // 全词匹配
|
||||
Selective bool `gorm:"default:false" json:"selective"` // 是否需要次要关键词
|
||||
SelectiveLogic int `gorm:"default:0" json:"selectiveLogic"` // 0=AND, 1=NOT
|
||||
|
||||
// 注入位置与优先级
|
||||
Position int `gorm:"default:1" json:"position"` // 0=系统提示词前, 1=系统提示词后, 4=指定深度
|
||||
Depth int `gorm:"default:4" json:"depth"` // position=4 时生效
|
||||
Order int `gorm:"default:100" json:"order"` // 同位置时的排序
|
||||
|
||||
// 概率与触发控制(SillyTavern 兼容字段)
|
||||
Probability int `gorm:"default:100" json:"probability"` // 触发概率 0-100
|
||||
ScanDepth int `gorm:"default:2" json:"scanDepth"` // 扫描最近 N 条消息(0=全部)
|
||||
GroupID string `gorm:"type:varchar(100)" json:"groupId"` // 条目分组
|
||||
|
||||
// 扩展字段
|
||||
Extensions datatypes.JSON `gorm:"type:jsonb" json:"extensions"`
|
||||
}
|
||||
|
||||
func (WorldbookEntry) TableName() string {
|
||||
return "worldbook_entries"
|
||||
}
|
||||
@@ -21,5 +21,6 @@ func (r *ConversationRouter) InitConversationRouter(Router *gin.RouterGroup) {
|
||||
conversationRouter.DELETE(":id", conversationApi.DeleteConversation) // 删除对话
|
||||
conversationRouter.GET(":id/messages", conversationApi.GetMessageList) // 获取消息列表
|
||||
conversationRouter.POST(":id/message", conversationApi.SendMessage) // 发送消息
|
||||
conversationRouter.POST(":id/regenerate", conversationApi.RegenerateMessage) // 重新生成消息
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ type RouterGroup struct {
|
||||
AIConfigRouter
|
||||
PresetRouter
|
||||
UploadRouter
|
||||
WorldbookRouter
|
||||
RegexScriptRouter
|
||||
}
|
||||
|
||||
24
server/router/app/regex_script.go
Normal file
24
server/router/app/regex_script.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
v1 "git.echol.cn/loser/st/server/api/v1"
|
||||
"git.echol.cn/loser/st/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RegexScriptRouter struct{}
|
||||
|
||||
// InitRegexScriptRouter 初始化正则脚本路由
|
||||
func (r *RegexScriptRouter) InitRegexScriptRouter(Router *gin.RouterGroup) {
|
||||
regexRouter := Router.Group("regex").Use(middleware.AppJWTAuth())
|
||||
regexApi := v1.ApiGroupApp.AppApiGroup.RegexScriptApi
|
||||
|
||||
{
|
||||
regexRouter.POST("", regexApi.CreateRegexScript) // 创建正则脚本
|
||||
regexRouter.GET("", regexApi.GetRegexScriptList) // 获取正则脚本列表
|
||||
regexRouter.GET(":id", regexApi.GetRegexScriptByID) // 获取正则脚本详情
|
||||
regexRouter.PUT(":id", regexApi.UpdateRegexScript) // 更新正则脚本
|
||||
regexRouter.DELETE(":id", regexApi.DeleteRegexScript) // 删除正则脚本
|
||||
regexRouter.POST(":id/test", regexApi.TestRegexScript) // 测试正则脚本
|
||||
}
|
||||
}
|
||||
29
server/router/app/worldbook.go
Normal file
29
server/router/app/worldbook.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
v1 "git.echol.cn/loser/st/server/api/v1"
|
||||
"git.echol.cn/loser/st/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WorldbookRouter struct{}
|
||||
|
||||
// InitWorldbookRouter 初始化世界书路由
|
||||
func (r *WorldbookRouter) InitWorldbookRouter(Router *gin.RouterGroup) {
|
||||
worldbookRouter := Router.Group("worldbook").Use(middleware.AppJWTAuth())
|
||||
worldbookApi := v1.ApiGroupApp.AppApiGroup.WorldbookApi
|
||||
|
||||
{
|
||||
worldbookRouter.POST("", worldbookApi.CreateWorldbook) // 创建世界书
|
||||
worldbookRouter.GET("", worldbookApi.GetWorldbookList) // 获取世界书列表
|
||||
worldbookRouter.POST("import", worldbookApi.ImportWorldbook) // 导入世界书
|
||||
worldbookRouter.GET(":id", worldbookApi.GetWorldbookByID) // 获取世界书详情
|
||||
worldbookRouter.PUT(":id", worldbookApi.UpdateWorldbook) // 更新世界书
|
||||
worldbookRouter.DELETE(":id", worldbookApi.DeleteWorldbook) // 删除世界书
|
||||
worldbookRouter.GET(":id/export", worldbookApi.ExportWorldbook) // 导出世界书
|
||||
worldbookRouter.POST(":id/entry", worldbookApi.CreateEntry) // 创建条目
|
||||
worldbookRouter.GET(":id/entries", worldbookApi.GetEntryList) // 获取条目列表
|
||||
worldbookRouter.PUT(":id/entry/:entryId", worldbookApi.UpdateEntry) // 更新条目
|
||||
worldbookRouter.DELETE(":id/entry/:entryId", worldbookApi.DeleteEntry) // 删除条目
|
||||
}
|
||||
}
|
||||
@@ -267,7 +267,18 @@ func (s *CharacterService) ImportCharacterFromPNG(userID uint, file *multipart.F
|
||||
IsPublic: false,
|
||||
}
|
||||
|
||||
return s.CreateCharacter(userID, req)
|
||||
// 创建角色卡
|
||||
resp, err := s.CreateCharacter(userID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 处理扩展数据中的正则脚本
|
||||
if card.Data.Extensions != nil {
|
||||
s.processRegexScriptsFromExtensions(userID, resp.ID, card.Data.Extensions)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ImportCharacterFromJSON 从 JSON 文件导入角色卡
|
||||
@@ -312,7 +323,18 @@ func (s *CharacterService) ImportCharacterFromJSON(userID uint, file *multipart.
|
||||
IsPublic: false,
|
||||
}
|
||||
|
||||
return s.CreateCharacter(userID, req)
|
||||
// 创建角色卡
|
||||
resp, err := s.CreateCharacter(userID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 处理扩展数据中的正则脚本
|
||||
if card.Data.Extensions != nil {
|
||||
s.processRegexScriptsFromExtensions(userID, resp.ID, card.Data.Extensions)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ExportCharacterToJSON 导出角色卡为 JSON
|
||||
@@ -339,6 +361,16 @@ func (s *CharacterService) ExportCharacterToJSON(userID, characterID uint) (*uti
|
||||
json.Unmarshal(character.CharacterBook, &characterBook)
|
||||
json.Unmarshal(character.Extensions, &extensions)
|
||||
|
||||
// 查询角色关联的正则脚本并添加到 extensions
|
||||
var regexScripts []app.RegexScript
|
||||
global.GVA_DB.Where("owner_char_id = ? AND scope = 1", characterID).Find(®exScripts)
|
||||
if len(regexScripts) > 0 {
|
||||
if extensions == nil {
|
||||
extensions = make(map[string]interface{})
|
||||
}
|
||||
extensions["regex_scripts"] = regexScripts
|
||||
}
|
||||
|
||||
// 构建 V2 格式
|
||||
card := &utils.CharacterCardV2{
|
||||
Spec: character.Spec,
|
||||
@@ -364,3 +396,94 @@ func (s *CharacterService) ExportCharacterToJSON(userID, characterID uint) (*uti
|
||||
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// processRegexScriptsFromExtensions 从扩展数据中提取并创建正则脚本
|
||||
func (s *CharacterService) processRegexScriptsFromExtensions(userID, characterID uint, extensions map[string]interface{}) {
|
||||
// 检查是否包含正则脚本
|
||||
regexScriptsData, ok := extensions["regex_scripts"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为 JSON 以便解析
|
||||
scriptsJSON, err := json.Marshal(regexScriptsData)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error("序列化正则脚本失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 解析正则脚本数组
|
||||
var scripts []map[string]interface{}
|
||||
if err := json.Unmarshal(scriptsJSON, &scripts); err != nil {
|
||||
global.GVA_LOG.Error("解析正则脚本失败: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 创建正则脚本记录
|
||||
for _, scriptData := range scripts {
|
||||
script := app.RegexScript{
|
||||
UserID: userID,
|
||||
Scope: 1, // 角色作用域
|
||||
OwnerCharID: &characterID,
|
||||
}
|
||||
|
||||
// 提取字段
|
||||
if name, ok := scriptData["name"].(string); ok {
|
||||
script.Name = name
|
||||
}
|
||||
if findRegex, ok := scriptData["findRegex"].(string); ok {
|
||||
script.FindRegex = findRegex
|
||||
}
|
||||
if replaceWith, ok := scriptData["replaceWith"].(string); ok {
|
||||
script.ReplaceWith = replaceWith
|
||||
}
|
||||
if placement, ok := scriptData["placement"].(float64); ok {
|
||||
script.Placement = int(placement)
|
||||
}
|
||||
if disabled, ok := scriptData["disabled"].(bool); ok {
|
||||
script.Disabled = disabled
|
||||
}
|
||||
if markdownOnly, ok := scriptData["markdownOnly"].(bool); ok {
|
||||
script.MarkdownOnly = markdownOnly
|
||||
}
|
||||
if runOnEdit, ok := scriptData["runOnEdit"].(bool); ok {
|
||||
script.RunOnEdit = runOnEdit
|
||||
}
|
||||
if promptOnly, ok := scriptData["promptOnly"].(bool); ok {
|
||||
script.PromptOnly = promptOnly
|
||||
}
|
||||
if substituteRegex, ok := scriptData["substituteRegex"].(bool); ok {
|
||||
script.SubstituteRegex = substituteRegex
|
||||
}
|
||||
if order, ok := scriptData["order"].(float64); ok {
|
||||
script.Order = int(order)
|
||||
}
|
||||
|
||||
// 处理可选的整数字段
|
||||
if minDepth, ok := scriptData["minDepth"].(float64); ok {
|
||||
depth := int(minDepth)
|
||||
script.MinDepth = &depth
|
||||
}
|
||||
if maxDepth, ok := scriptData["maxDepth"].(float64); ok {
|
||||
depth := int(maxDepth)
|
||||
script.MaxDepth = &depth
|
||||
}
|
||||
|
||||
// 处理 trimStrings 数组
|
||||
if trimStrings, ok := scriptData["trimStrings"].([]interface{}); ok {
|
||||
trimStringsJSON, _ := json.Marshal(trimStrings)
|
||||
script.TrimStrings = datatypes.JSON(trimStringsJSON)
|
||||
}
|
||||
|
||||
// 处理扩展字段
|
||||
if scriptExtensions, ok := scriptData["extensions"].(map[string]interface{}); ok {
|
||||
extensionsJSON, _ := json.Marshal(scriptExtensions)
|
||||
script.Extensions = datatypes.JSON(extensionsJSON)
|
||||
}
|
||||
|
||||
// 创建记录
|
||||
if err := global.GVA_DB.Create(&script).Error; err != nil {
|
||||
global.GVA_LOG.Error("创建正则脚本失败: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,13 +72,15 @@ func (s *ConversationService) CreateConversation(userID uint, req *request.Creat
|
||||
|
||||
// 创建对话
|
||||
conversation := app.Conversation{
|
||||
UserID: userID,
|
||||
CharacterID: req.CharacterID,
|
||||
Title: title,
|
||||
PresetID: req.PresetID,
|
||||
AIProvider: aiProvider,
|
||||
Model: model,
|
||||
Settings: datatypes.JSON("{}"),
|
||||
UserID: userID,
|
||||
CharacterID: req.CharacterID,
|
||||
Title: title,
|
||||
PresetID: req.PresetID,
|
||||
WorldbookID: req.WorldbookID,
|
||||
WorldbookEnabled: req.WorldbookEnabled,
|
||||
AIProvider: aiProvider,
|
||||
Model: model,
|
||||
Settings: datatypes.JSON("{}"),
|
||||
}
|
||||
|
||||
err = global.GVA_DB.Create(&conversation).Error
|
||||
@@ -188,7 +190,7 @@ func (s *ConversationService) GetConversationByID(userID, conversationID uint) (
|
||||
}
|
||||
|
||||
// UpdateConversationSettings 更新对话设置
|
||||
func (s *ConversationService) UpdateConversationSettings(userID, conversationID uint, settings map[string]interface{}) error {
|
||||
func (s *ConversationService) UpdateConversationSettings(userID, conversationID uint, req *request.UpdateConversationSettingsRequest) error {
|
||||
var conversation app.Conversation
|
||||
err := global.GVA_DB.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error
|
||||
if err != nil {
|
||||
@@ -198,13 +200,32 @@ func (s *ConversationService) UpdateConversationSettings(userID, conversationID
|
||||
return err
|
||||
}
|
||||
|
||||
// 序列化设置
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
updates := make(map[string]interface{})
|
||||
|
||||
// 更新设置
|
||||
if req.Settings != nil {
|
||||
settingsJSON, err := json.Marshal(req.Settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updates["settings"] = datatypes.JSON(settingsJSON)
|
||||
}
|
||||
|
||||
return global.GVA_DB.Model(&conversation).Update("settings", datatypes.JSON(settingsJSON)).Error
|
||||
// 更新世界书ID
|
||||
if req.WorldbookID != nil {
|
||||
updates["worldbook_id"] = req.WorldbookID
|
||||
}
|
||||
|
||||
// 更新世界书启用状态
|
||||
if req.WorldbookEnabled != nil {
|
||||
updates["worldbook_enabled"] = *req.WorldbookEnabled
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return global.GVA_DB.Model(&conversation).Updates(updates).Error
|
||||
}
|
||||
|
||||
// DeleteConversation 删除对话
|
||||
@@ -431,6 +452,30 @@ func (s *ConversationService) callAIService(conversation app.Conversation, chara
|
||||
global.GVA_LOG.Info("已追加预设的系统提示词")
|
||||
}
|
||||
|
||||
// 集成世界书触发引擎
|
||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("世界书已启用,ID: %d", *conversation.WorldbookID))
|
||||
|
||||
// 提取消息内容用于扫描
|
||||
var messageContents []string
|
||||
for _, msg := range messages {
|
||||
messageContents = append(messageContents, msg.Content)
|
||||
}
|
||||
|
||||
// 使用世界书引擎扫描并触发条目
|
||||
engine := &WorldbookEngine{}
|
||||
triggered, err := engine.ScanAndTrigger(*conversation.WorldbookID, messageContents)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("世界书触发失败: %v", err))
|
||||
} else if len(triggered) > 0 {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("触发了 %d 个世界书条目", len(triggered)))
|
||||
// 将触发的世界书内容注入到系统提示词
|
||||
systemPrompt = engine.BuildPromptWithWorldbook(systemPrompt, triggered)
|
||||
} else {
|
||||
global.GVA_LOG.Info("没有触发任何世界书条目")
|
||||
}
|
||||
}
|
||||
|
||||
// 构建消息列表
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
@@ -657,8 +702,31 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
return errors.New("未找到可用的 AI 配置")
|
||||
}
|
||||
|
||||
// 构建系统提示词和消息列表
|
||||
// 加载预设
|
||||
var streamPreset *app.AIPreset
|
||||
var streamPresetID uint
|
||||
if len(conversation.Settings) > 0 {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(conversation.Settings, &settings); err == nil {
|
||||
if id, ok := settings["presetId"].(float64); ok {
|
||||
streamPresetID = uint(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if streamPresetID > 0 {
|
||||
var loadedPreset app.AIPreset
|
||||
if err := global.GVA_DB.First(&loadedPreset, streamPresetID).Error; err == nil {
|
||||
streamPreset = &loadedPreset
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 使用预设: %s (Temperature: %.2f)", streamPreset.Name, streamPreset.Temperature))
|
||||
global.GVA_DB.Model(streamPreset).Update("use_count", gorm.Expr("use_count + ?", 1))
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系统提示词(应用预设)
|
||||
systemPrompt := s.buildSystemPrompt(character)
|
||||
if streamPreset != nil && streamPreset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + streamPreset.SystemPrompt
|
||||
}
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
// 打印发送给AI的完整内容(流式传输)
|
||||
@@ -685,9 +753,9 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
var fullContent string
|
||||
switch aiConfig.Provider {
|
||||
case "openai", "custom":
|
||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamChan)
|
||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamPreset, streamChan)
|
||||
case "anthropic":
|
||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamChan)
|
||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
|
||||
default:
|
||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||
}
|
||||
@@ -724,7 +792,7 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
}
|
||||
|
||||
// callOpenAIAPIStream 调用 OpenAI API 流式传输
|
||||
func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model string, messages []map[string]string, streamChan chan string) (string, error) {
|
||||
func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
|
||||
if model == "" {
|
||||
@@ -734,15 +802,53 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
||||
model = "gpt-4"
|
||||
}
|
||||
|
||||
// 应用预设参数
|
||||
temperature := 0.7
|
||||
maxTokens := 2000
|
||||
var topP *float64
|
||||
var frequencyPenalty *float64
|
||||
var presencePenalty *float64
|
||||
var stopSequences []string
|
||||
|
||||
if preset != nil {
|
||||
temperature = preset.Temperature
|
||||
maxTokens = preset.MaxTokens
|
||||
if preset.TopP > 0 {
|
||||
topP = &preset.TopP
|
||||
}
|
||||
if preset.FrequencyPenalty != 0 {
|
||||
frequencyPenalty = &preset.FrequencyPenalty
|
||||
}
|
||||
if preset.PresencePenalty != 0 {
|
||||
presencePenalty = &preset.PresencePenalty
|
||||
}
|
||||
if len(preset.StopSequences) > 0 {
|
||||
json.Unmarshal(preset.StopSequences, &stopSequences)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建请求体,启用流式传输
|
||||
requestBody := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2000,
|
||||
"temperature": temperature,
|
||||
"max_tokens": maxTokens,
|
||||
"stream": true,
|
||||
}
|
||||
|
||||
if topP != nil {
|
||||
requestBody["top_p"] = *topP
|
||||
}
|
||||
if frequencyPenalty != nil {
|
||||
requestBody["frequency_penalty"] = *frequencyPenalty
|
||||
}
|
||||
if presencePenalty != nil {
|
||||
requestBody["presence_penalty"] = *presencePenalty
|
||||
}
|
||||
if len(stopSequences) > 0 {
|
||||
requestBody["stop"] = stopSequences
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化请求失败: %v", err)
|
||||
@@ -815,7 +921,7 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
||||
}
|
||||
|
||||
// callAnthropicAPIStream 调用 Anthropic API 流式传输
|
||||
func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, streamChan chan string) (string, error) {
|
||||
func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
|
||||
if model == "" {
|
||||
@@ -833,14 +939,43 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
||||
}
|
||||
}
|
||||
|
||||
// 应用预设参数
|
||||
maxTokens := 2000
|
||||
var temperature *float64
|
||||
var topP *float64
|
||||
var stopSequences []string
|
||||
|
||||
if preset != nil {
|
||||
maxTokens = preset.MaxTokens
|
||||
if preset.Temperature > 0 {
|
||||
temperature = &preset.Temperature
|
||||
}
|
||||
if preset.TopP > 0 {
|
||||
topP = &preset.TopP
|
||||
}
|
||||
if len(preset.StopSequences) > 0 {
|
||||
json.Unmarshal(preset.StopSequences, &stopSequences)
|
||||
}
|
||||
}
|
||||
|
||||
requestBody := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": apiMessages,
|
||||
"system": systemPrompt,
|
||||
"max_tokens": 2000,
|
||||
"max_tokens": maxTokens,
|
||||
"stream": true,
|
||||
}
|
||||
|
||||
if temperature != nil {
|
||||
requestBody["temperature"] = *temperature
|
||||
}
|
||||
if topP != nil {
|
||||
requestBody["top_p"] = *topP
|
||||
}
|
||||
if len(stopSequences) > 0 {
|
||||
requestBody["stop_sequences"] = stopSequences
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化请求失败: %v", err)
|
||||
@@ -910,6 +1045,205 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
||||
return fullContent.String(), nil
|
||||
}
|
||||
|
||||
// RegenerateMessage 重新生成最后一条 AI 回复(非流式)
|
||||
func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*response.MessageResponse, error) {
|
||||
var conversation app.Conversation
|
||||
err := global.GVA_DB.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("对话不存在或无权访问")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var character app.AICharacter
|
||||
err = global.GVA_DB.Where("id = ?", conversation.CharacterID).First(&character).Error
|
||||
if err != nil {
|
||||
return nil, errors.New("角色卡不存在")
|
||||
}
|
||||
|
||||
// 删除最后一条 AI 回复
|
||||
var lastAssistantMsg app.Message
|
||||
if err = global.GVA_DB.Where("conversation_id = ? AND role = ?", conversationID, "assistant").
|
||||
Order("created_at DESC").First(&lastAssistantMsg).Error; err == nil {
|
||||
global.GVA_DB.Delete(&lastAssistantMsg)
|
||||
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
|
||||
"message_count": gorm.Expr("GREATEST(message_count - 1, 0)"),
|
||||
"token_count": gorm.Expr("GREATEST(token_count - ?, 0)", lastAssistantMsg.TokenCount),
|
||||
})
|
||||
}
|
||||
|
||||
// 获取删除后的消息历史
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").Limit(10).Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, errors.New("没有可用的消息历史")
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
aiResponse, err := s.callAIService(conversation, character, messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assistantMessage := app.Message{
|
||||
ConversationID: conversationID,
|
||||
Role: "assistant",
|
||||
Content: aiResponse,
|
||||
TokenCount: len(aiResponse) / 4,
|
||||
}
|
||||
if err = global.GVA_DB.Create(&assistantMessage).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
|
||||
"message_count": gorm.Expr("message_count + ?", 1),
|
||||
"token_count": gorm.Expr("token_count + ?", assistantMessage.TokenCount),
|
||||
})
|
||||
|
||||
resp := response.ToMessageResponse(&assistantMessage)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RegenerateMessageStream 流式重新生成最后一条 AI 回复
|
||||
func (s *ConversationService) RegenerateMessageStream(userID, conversationID uint, streamChan chan string, doneChan chan bool) error {
|
||||
defer close(streamChan)
|
||||
defer close(doneChan)
|
||||
|
||||
var conversation app.Conversation
|
||||
err := global.GVA_DB.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("对话不存在或无权访问")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var character app.AICharacter
|
||||
err = global.GVA_DB.Where("id = ?", conversation.CharacterID).First(&character).Error
|
||||
if err != nil {
|
||||
return errors.New("角色卡不存在")
|
||||
}
|
||||
|
||||
// 删除最后一条 AI 回复
|
||||
var lastAssistantMsg app.Message
|
||||
if err = global.GVA_DB.Where("conversation_id = ? AND role = ?", conversationID, "assistant").
|
||||
Order("created_at DESC").First(&lastAssistantMsg).Error; err == nil {
|
||||
global.GVA_DB.Delete(&lastAssistantMsg)
|
||||
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
|
||||
"message_count": gorm.Expr("GREATEST(message_count - 1, 0)"),
|
||||
"token_count": gorm.Expr("GREATEST(token_count - ?, 0)", lastAssistantMsg.TokenCount),
|
||||
})
|
||||
}
|
||||
|
||||
// 获取删除后的消息历史
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").Limit(10).Find(&messages).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return errors.New("没有可用的消息历史")
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
// 获取 AI 配置
|
||||
var aiConfig app.AIConfig
|
||||
var configID uint
|
||||
if len(conversation.Settings) > 0 {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(conversation.Settings, &settings); err == nil {
|
||||
if id, ok := settings["aiConfigId"].(float64); ok {
|
||||
configID = uint(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if configID > 0 {
|
||||
err = global.GVA_DB.Where("id = ? AND is_active = ?", configID, true).First(&aiConfig).Error
|
||||
}
|
||||
if err != nil || configID == 0 {
|
||||
err = global.GVA_DB.Where("is_active = ?", true).
|
||||
Order("is_default DESC, created_at DESC").
|
||||
First(&aiConfig).Error
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("未找到可用的 AI 配置")
|
||||
}
|
||||
|
||||
// 加载预设
|
||||
var preset *app.AIPreset
|
||||
var presetID uint
|
||||
if len(conversation.Settings) > 0 {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(conversation.Settings, &settings); err == nil {
|
||||
if id, ok := settings["presetId"].(float64); ok {
|
||||
presetID = uint(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if presetID > 0 {
|
||||
var loadedPreset app.AIPreset
|
||||
if err := global.GVA_DB.First(&loadedPreset, presetID).Error; err == nil {
|
||||
preset = &loadedPreset
|
||||
}
|
||||
}
|
||||
|
||||
systemPrompt := s.buildSystemPrompt(character)
|
||||
if preset != nil && preset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt
|
||||
}
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
model := aiConfig.DefaultModel
|
||||
if model == "" {
|
||||
model = conversation.Model
|
||||
}
|
||||
if model == "" {
|
||||
model = "gpt-4"
|
||||
}
|
||||
|
||||
var fullContent string
|
||||
switch aiConfig.Provider {
|
||||
case "openai", "custom":
|
||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, preset, streamChan)
|
||||
case "anthropic":
|
||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, preset, streamChan)
|
||||
default:
|
||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
assistantMessage := app.Message{
|
||||
ConversationID: conversationID,
|
||||
Role: "assistant",
|
||||
Content: fullContent,
|
||||
TokenCount: len(fullContent) / 4,
|
||||
}
|
||||
if err = global.GVA_DB.Create(&assistantMessage).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
|
||||
"message_count": gorm.Expr("message_count + ?", 1),
|
||||
"token_count": gorm.Expr("token_count + ?", assistantMessage.TokenCount),
|
||||
})
|
||||
|
||||
doneChan <- true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ConversationService) buildAPIMessages(messages []app.Message, systemPrompt string) []map[string]string {
|
||||
apiMessages := make([]map[string]string, 0, len(messages)+1)
|
||||
|
||||
|
||||
@@ -7,4 +7,6 @@ type AppServiceGroup struct {
|
||||
AIConfigService
|
||||
PresetService
|
||||
UploadService
|
||||
WorldbookService
|
||||
RegexScriptService
|
||||
}
|
||||
|
||||
285
server/service/app/regex_script.go
Normal file
285
server/service/app/regex_script.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
"git.echol.cn/loser/st/server/model/app/request"
|
||||
"git.echol.cn/loser/st/server/model/app/response"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RegexScriptService struct{}
|
||||
|
||||
// CreateRegexScript 创建正则脚本
|
||||
func (s *RegexScriptService) CreateRegexScript(userID uint, req *request.CreateRegexScriptRequest) (*response.RegexScriptResponse, error) {
|
||||
trimStringsJSON, _ := json.Marshal(req.TrimStrings)
|
||||
|
||||
script := &app.RegexScript{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
FindRegex: req.FindRegex,
|
||||
ReplaceWith: req.ReplaceWith,
|
||||
TrimStrings: datatypes.JSON(trimStringsJSON),
|
||||
Placement: req.Placement,
|
||||
Disabled: req.Disabled,
|
||||
MarkdownOnly: req.MarkdownOnly,
|
||||
RunOnEdit: req.RunOnEdit,
|
||||
PromptOnly: req.PromptOnly,
|
||||
SubstituteRegex: req.SubstituteRegex,
|
||||
MinDepth: req.MinDepth,
|
||||
MaxDepth: req.MaxDepth,
|
||||
Scope: req.Scope,
|
||||
OwnerCharID: req.OwnerCharID,
|
||||
OwnerPresetID: req.OwnerPresetID,
|
||||
Order: req.Order,
|
||||
}
|
||||
|
||||
if err := global.GVA_DB.Create(script).Error; err != nil {
|
||||
global.GVA_LOG.Error("创建正则脚本失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := response.ToRegexScriptResponse(script)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetRegexScriptList 获取正则脚本列表
|
||||
func (s *RegexScriptService) GetRegexScriptList(userID uint, req *request.GetRegexScriptListRequest) ([]response.RegexScriptResponse, int64, error) {
|
||||
var scripts []app.RegexScript
|
||||
var total int64
|
||||
|
||||
db := global.GVA_DB.Model(&app.RegexScript{}).Where("user_id = ?", userID)
|
||||
|
||||
if req.Keyword != "" {
|
||||
db = db.Where("name LIKE ?", "%"+req.Keyword+"%")
|
||||
}
|
||||
|
||||
if req.Scope != nil {
|
||||
db = db.Where("scope = ?", *req.Scope)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := db.Order("\"order\" ASC, created_at DESC").Offset(offset).Limit(req.PageSize).Find(&scripts).Error; err != nil {
|
||||
global.GVA_LOG.Error("获取正则脚本列表失败", zap.Error(err))
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var list []response.RegexScriptResponse
|
||||
for i := range scripts {
|
||||
list = append(list, response.ToRegexScriptResponse(&scripts[i]))
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// GetRegexScriptByID 获取正则脚本详情
|
||||
func (s *RegexScriptService) GetRegexScriptByID(userID uint, id uint) (*response.RegexScriptResponse, error) {
|
||||
var script app.RegexScript
|
||||
if err := global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).First(&script).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("正则脚本不存在或无权访问")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
resp := response.ToRegexScriptResponse(&script)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateRegexScript 更新正则脚本
|
||||
func (s *RegexScriptService) UpdateRegexScript(userID uint, id uint, req *request.UpdateRegexScriptRequest) error {
|
||||
var script app.RegexScript
|
||||
if err := global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).First(&script).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("正则脚本不存在或无权修改")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != nil {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.FindRegex != nil {
|
||||
updates["find_regex"] = *req.FindRegex
|
||||
}
|
||||
if req.ReplaceWith != nil {
|
||||
updates["replace_with"] = *req.ReplaceWith
|
||||
}
|
||||
if req.TrimStrings != nil {
|
||||
trimStringsJSON, _ := json.Marshal(req.TrimStrings)
|
||||
updates["trim_strings"] = datatypes.JSON(trimStringsJSON)
|
||||
}
|
||||
if req.Placement != nil {
|
||||
updates["placement"] = *req.Placement
|
||||
}
|
||||
if req.Disabled != nil {
|
||||
updates["disabled"] = *req.Disabled
|
||||
}
|
||||
if req.MarkdownOnly != nil {
|
||||
updates["markdown_only"] = *req.MarkdownOnly
|
||||
}
|
||||
if req.RunOnEdit != nil {
|
||||
updates["run_on_edit"] = *req.RunOnEdit
|
||||
}
|
||||
if req.PromptOnly != nil {
|
||||
updates["prompt_only"] = *req.PromptOnly
|
||||
}
|
||||
if req.SubstituteRegex != nil {
|
||||
updates["substitute_regex"] = *req.SubstituteRegex
|
||||
}
|
||||
if req.MinDepth != nil {
|
||||
updates["min_depth"] = req.MinDepth
|
||||
}
|
||||
if req.MaxDepth != nil {
|
||||
updates["max_depth"] = req.MaxDepth
|
||||
}
|
||||
if req.Scope != nil {
|
||||
updates["scope"] = *req.Scope
|
||||
}
|
||||
if req.OwnerCharID != nil {
|
||||
updates["owner_char_id"] = req.OwnerCharID
|
||||
}
|
||||
if req.OwnerPresetID != nil {
|
||||
updates["owner_preset_id"] = req.OwnerPresetID
|
||||
}
|
||||
if req.Order != nil {
|
||||
updates["order"] = *req.Order
|
||||
}
|
||||
|
||||
return global.GVA_DB.Model(&script).Updates(updates).Error
|
||||
}
|
||||
|
||||
// DeleteRegexScript 删除正则脚本
|
||||
func (s *RegexScriptService) DeleteRegexScript(userID uint, id uint) error {
|
||||
result := global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).Delete(&app.RegexScript{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("正则脚本不存在或无权删除")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestRegexScript 测试正则脚本
|
||||
func (s *RegexScriptService) TestRegexScript(userID uint, id uint, testString string) (*response.TestRegexScriptResponse, error) {
|
||||
var script app.RegexScript
|
||||
if err := global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).First(&script).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("正则脚本不存在或无权访问")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.ExecuteScript(&script, testString, "", "")
|
||||
if err != nil {
|
||||
return &response.TestRegexScriptResponse{
|
||||
Original: testString,
|
||||
Result: testString,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &response.TestRegexScriptResponse{
|
||||
Original: testString,
|
||||
Result: result,
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExecuteScript 执行正则脚本
|
||||
func (s *RegexScriptService) ExecuteScript(script *app.RegexScript, text string, userName string, charName string) (string, error) {
|
||||
if script.Disabled {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
result := text
|
||||
|
||||
// 1. 宏替换
|
||||
if script.SubstituteRegex {
|
||||
result = s.substituteMacros(result, userName, charName)
|
||||
}
|
||||
|
||||
// 2. 正则替换
|
||||
if script.FindRegex != "" {
|
||||
re, err := regexp.Compile(script.FindRegex)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Warn("正则表达式编译失败", zap.String("pattern", script.FindRegex), zap.Error(err))
|
||||
return text, err
|
||||
}
|
||||
result = re.ReplaceAllString(result, script.ReplaceWith)
|
||||
}
|
||||
|
||||
// 3. 修剪字符串
|
||||
if len(script.TrimStrings) > 0 {
|
||||
var trimStrings []string
|
||||
json.Unmarshal(script.TrimStrings, &trimStrings)
|
||||
for _, trimStr := range trimStrings {
|
||||
result = strings.ReplaceAll(result, trimStr, "")
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// substituteMacros 替换宏变量
|
||||
func (s *RegexScriptService) substituteMacros(text string, userName string, charName string) string {
|
||||
result := text
|
||||
if userName != "" {
|
||||
result = strings.ReplaceAll(result, "{{user}}", userName)
|
||||
result = strings.ReplaceAll(result, "{{User}}", userName)
|
||||
}
|
||||
if charName != "" {
|
||||
result = strings.ReplaceAll(result, "{{char}}", charName)
|
||||
result = strings.ReplaceAll(result, "{{Char}}", charName)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetScriptsForPlacement 获取指定阶段的脚本
|
||||
func (s *RegexScriptService) GetScriptsForPlacement(userID uint, placement int, charID *uint, presetID *uint) ([]app.RegexScript, error) {
|
||||
var scripts []app.RegexScript
|
||||
|
||||
db := global.GVA_DB.Where("user_id = ? AND placement = ? AND disabled = ?", userID, placement, false)
|
||||
|
||||
// 作用域过滤:全局(0) 或 角色(1) 或 预设(2)
|
||||
scopeCondition := "scope = 0" // 全局
|
||||
if charID != nil {
|
||||
scopeCondition += " OR (scope = 1 AND owner_char_id = " + string(rune(*charID)) + ")"
|
||||
}
|
||||
if presetID != nil {
|
||||
scopeCondition += " OR (scope = 2 AND owner_preset_id = " + string(rune(*presetID)) + ")"
|
||||
}
|
||||
db = db.Where(scopeCondition)
|
||||
|
||||
if err := db.Order("\"order\" ASC").Find(&scripts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scripts, nil
|
||||
}
|
||||
|
||||
// ExecuteScripts 批量执行脚本
|
||||
func (s *RegexScriptService) ExecuteScripts(scripts []app.RegexScript, text string, userName string, charName string) string {
|
||||
result := text
|
||||
for _, script := range scripts {
|
||||
executed, err := s.ExecuteScript(&script, result, userName, charName)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Warn("执行正则脚本失败", zap.String("name", script.Name), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
result = executed
|
||||
}
|
||||
return result
|
||||
}
|
||||
498
server/service/app/worldbook.go
Normal file
498
server/service/app/worldbook.go
Normal file
@@ -0,0 +1,498 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
"git.echol.cn/loser/st/server/model/app/request"
|
||||
"git.echol.cn/loser/st/server/model/app/response"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WorldbookService struct{}
|
||||
|
||||
// CreateWorldbook 创建世界书
|
||||
func (s *WorldbookService) CreateWorldbook(userID uint, req *request.CreateWorldbookRequest) (*response.WorldbookResponse, error) {
|
||||
wb := &app.Worldbook{
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
IsPublic: req.IsPublic,
|
||||
}
|
||||
if err := global.GVA_DB.Create(wb).Error; err != nil {
|
||||
global.GVA_LOG.Error("创建世界书失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
resp := response.ToWorldbookResponse(wb)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetWorldbookList 获取世界书列表(自己的 + 公开的)
|
||||
func (s *WorldbookService) GetWorldbookList(userID uint, req *request.GetWorldbookListRequest) ([]response.WorldbookResponse, int64, error) {
|
||||
var worldbooks []app.Worldbook
|
||||
var total int64
|
||||
|
||||
db := global.GVA_DB.Model(&app.Worldbook{}).Where("user_id = ? OR is_public = ?", userID, true)
|
||||
|
||||
if req.Keyword != "" {
|
||||
db = db.Where("name LIKE ? OR description LIKE ?", "%"+req.Keyword+"%", "%"+req.Keyword+"%")
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (req.Page - 1) * req.PageSize
|
||||
if err := db.Order("updated_at DESC").Offset(offset).Limit(req.PageSize).Find(&worldbooks).Error; err != nil {
|
||||
global.GVA_LOG.Error("获取世界书列表失败", zap.Error(err))
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var list []response.WorldbookResponse
|
||||
for i := range worldbooks {
|
||||
list = append(list, response.ToWorldbookResponse(&worldbooks[i]))
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// GetWorldbookByID 获取世界书详情
|
||||
func (s *WorldbookService) GetWorldbookByID(userID uint, id uint) (*response.WorldbookResponse, error) {
|
||||
var wb app.Worldbook
|
||||
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", id, userID, true).
|
||||
First(&wb).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("世界书不存在或无权访问")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
resp := response.ToWorldbookResponse(&wb)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateWorldbook 更新世界书
|
||||
func (s *WorldbookService) UpdateWorldbook(userID uint, id uint, req *request.UpdateWorldbookRequest) error {
|
||||
var wb app.Worldbook
|
||||
if err := global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).First(&wb).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New("世界书不存在或无权修改")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != nil {
|
||||
updates["name"] = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.IsPublic != nil {
|
||||
updates["is_public"] = *req.IsPublic
|
||||
}
|
||||
|
||||
return global.GVA_DB.Model(&wb).Updates(updates).Error
|
||||
}
|
||||
|
||||
// DeleteWorldbook 删除世界书(级联删除条目)
|
||||
func (s *WorldbookService) DeleteWorldbook(userID uint, id uint) error {
|
||||
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 删除所有条目
|
||||
if err := tx.Where("worldbook_id = ?", id).Delete(&app.WorldbookEntry{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 删除世界书
|
||||
result := tx.Where("id = ? AND user_id = ?", id, userID).Delete(&app.Worldbook{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("世界书不存在或无权删除")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// CreateEntry 创建世界书条目
|
||||
func (s *WorldbookService) CreateEntry(userID uint, worldbookID uint, req *request.CreateEntryRequest) (*response.EntryResponse, error) {
|
||||
// 验证世界书归属
|
||||
var wb app.Worldbook
|
||||
if err := global.GVA_DB.Where("id = ? AND user_id = ?", worldbookID, userID).First(&wb).Error; err != nil {
|
||||
return nil, errors.New("世界书不存在或无权操作")
|
||||
}
|
||||
|
||||
keysJSON, _ := json.Marshal(req.Keys)
|
||||
secKeysJSON, _ := json.Marshal(req.SecondaryKeys)
|
||||
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
probability := req.Probability
|
||||
if probability == 0 {
|
||||
probability = 100
|
||||
}
|
||||
order := req.Order
|
||||
if order == 0 {
|
||||
order = 100
|
||||
}
|
||||
scanDepth := req.ScanDepth
|
||||
if scanDepth == 0 {
|
||||
scanDepth = 2
|
||||
}
|
||||
|
||||
entry := &app.WorldbookEntry{
|
||||
WorldbookID: worldbookID,
|
||||
Comment: req.Comment,
|
||||
Content: req.Content,
|
||||
Keys: datatypes.JSON(keysJSON),
|
||||
SecondaryKeys: datatypes.JSON(secKeysJSON),
|
||||
Constant: req.Constant,
|
||||
Enabled: enabled,
|
||||
UseRegex: req.UseRegex,
|
||||
CaseSensitive: req.CaseSensitive,
|
||||
MatchWholeWords: req.MatchWholeWords,
|
||||
Selective: req.Selective,
|
||||
SelectiveLogic: req.SelectiveLogic,
|
||||
Position: req.Position,
|
||||
Depth: req.Depth,
|
||||
Order: order,
|
||||
Probability: probability,
|
||||
ScanDepth: scanDepth,
|
||||
GroupID: req.GroupID,
|
||||
}
|
||||
|
||||
if err := global.GVA_DB.Create(entry).Error; err != nil {
|
||||
global.GVA_LOG.Error("创建世界书条目失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 更新世界书条目计数
|
||||
global.GVA_DB.Model(&wb).Update("entry_count", gorm.Expr("entry_count + ?", 1))
|
||||
|
||||
resp := response.ToEntryResponse(entry)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetEntryList 获取世界书条目列表
|
||||
func (s *WorldbookService) GetEntryList(userID uint, worldbookID uint) ([]response.EntryResponse, int64, error) {
|
||||
// 验证访问权限
|
||||
var wb app.Worldbook
|
||||
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", worldbookID, userID, true).
|
||||
First(&wb).Error; err != nil {
|
||||
return nil, 0, errors.New("世界书不存在或无权访问")
|
||||
}
|
||||
|
||||
var entries []app.WorldbookEntry
|
||||
var total int64
|
||||
|
||||
db := global.GVA_DB.Model(&app.WorldbookEntry{}).Where("worldbook_id = ?", worldbookID)
|
||||
db.Count(&total)
|
||||
|
||||
if err := db.Order("`order` ASC, created_at ASC").Find(&entries).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var list []response.EntryResponse
|
||||
for i := range entries {
|
||||
list = append(list, response.ToEntryResponse(&entries[i]))
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// UpdateEntry 更新世界书条目
|
||||
func (s *WorldbookService) UpdateEntry(userID uint, entryID uint, req *request.UpdateEntryRequest) error {
|
||||
// 查找条目并验证归属
|
||||
var entry app.WorldbookEntry
|
||||
if err := global.GVA_DB.First(&entry, entryID).Error; err != nil {
|
||||
return errors.New("条目不存在")
|
||||
}
|
||||
var wb app.Worldbook
|
||||
if err := global.GVA_DB.Where("id = ? AND user_id = ?", entry.WorldbookID, userID).First(&wb).Error; err != nil {
|
||||
return errors.New("无权修改此条目")
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Comment != nil {
|
||||
updates["comment"] = *req.Comment
|
||||
}
|
||||
if req.Content != nil {
|
||||
updates["content"] = *req.Content
|
||||
}
|
||||
if req.Keys != nil {
|
||||
keysJSON, _ := json.Marshal(req.Keys)
|
||||
updates["keys"] = datatypes.JSON(keysJSON)
|
||||
}
|
||||
if req.SecondaryKeys != nil {
|
||||
secKeysJSON, _ := json.Marshal(req.SecondaryKeys)
|
||||
updates["secondary_keys"] = datatypes.JSON(secKeysJSON)
|
||||
}
|
||||
if req.Constant != nil {
|
||||
updates["constant"] = *req.Constant
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if req.UseRegex != nil {
|
||||
updates["use_regex"] = *req.UseRegex
|
||||
}
|
||||
if req.CaseSensitive != nil {
|
||||
updates["case_sensitive"] = *req.CaseSensitive
|
||||
}
|
||||
if req.MatchWholeWords != nil {
|
||||
updates["match_whole_words"] = *req.MatchWholeWords
|
||||
}
|
||||
if req.Selective != nil {
|
||||
updates["selective"] = *req.Selective
|
||||
}
|
||||
if req.SelectiveLogic != nil {
|
||||
updates["selective_logic"] = *req.SelectiveLogic
|
||||
}
|
||||
if req.Position != nil {
|
||||
updates["position"] = *req.Position
|
||||
}
|
||||
if req.Depth != nil {
|
||||
updates["depth"] = *req.Depth
|
||||
}
|
||||
if req.Order != nil {
|
||||
updates["order"] = *req.Order
|
||||
}
|
||||
if req.Probability != nil {
|
||||
updates["probability"] = *req.Probability
|
||||
}
|
||||
if req.ScanDepth != nil {
|
||||
updates["scan_depth"] = *req.ScanDepth
|
||||
}
|
||||
if req.GroupID != nil {
|
||||
updates["group_id"] = *req.GroupID
|
||||
}
|
||||
|
||||
return global.GVA_DB.Model(&entry).Updates(updates).Error
|
||||
}
|
||||
|
||||
// DeleteEntry 删除世界书条目
|
||||
func (s *WorldbookService) DeleteEntry(userID uint, entryID uint) error {
|
||||
var entry app.WorldbookEntry
|
||||
if err := global.GVA_DB.First(&entry, entryID).Error; err != nil {
|
||||
return errors.New("条目不存在")
|
||||
}
|
||||
var wb app.Worldbook
|
||||
if err := global.GVA_DB.Where("id = ? AND user_id = ?", entry.WorldbookID, userID).First(&wb).Error; err != nil {
|
||||
return errors.New("无权删除此条目")
|
||||
}
|
||||
|
||||
if err := global.GVA_DB.Delete(&entry).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新条目计数
|
||||
global.GVA_DB.Model(&wb).Update("entry_count", gorm.Expr("GREATEST(entry_count - 1, 0)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportFromJSON 从 JSON 文件导入世界书(兼容 SillyTavern 格式)
|
||||
func (s *WorldbookService) ImportFromJSON(userID uint, jsonData []byte, filename string) (*response.WorldbookResponse, error) {
|
||||
// 尝试解析 SillyTavern 世界书格式
|
||||
var stFormat map[string]interface{}
|
||||
if err := json.Unmarshal(jsonData, &stFormat); err != nil {
|
||||
return nil, fmt.Errorf("JSON 格式错误: %v", err)
|
||||
}
|
||||
|
||||
// 提取世界书名称
|
||||
name := filename
|
||||
if n, ok := stFormat["name"].(string); ok && n != "" {
|
||||
name = n
|
||||
}
|
||||
description := ""
|
||||
if d, ok := stFormat["description"].(string); ok {
|
||||
description = d
|
||||
}
|
||||
|
||||
wb := &app.Worldbook{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Description: description,
|
||||
}
|
||||
if err := global.GVA_DB.Create(wb).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析条目(SillyTavern entries 格式:map[string]entry 或 []entry)
|
||||
var entryCount int
|
||||
if entriesRaw, ok := stFormat["entries"]; ok {
|
||||
switch entries := entriesRaw.(type) {
|
||||
case map[string]interface{}:
|
||||
// SillyTavern 格式:键值对
|
||||
for _, v := range entries {
|
||||
if entryMap, ok := v.(map[string]interface{}); ok {
|
||||
s.importEntry(wb.ID, entryMap)
|
||||
entryCount++
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
// 数组格式
|
||||
for _, v := range entries {
|
||||
if entryMap, ok := v.(map[string]interface{}); ok {
|
||||
s.importEntry(wb.ID, entryMap)
|
||||
entryCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新条目计数
|
||||
global.GVA_DB.Model(wb).Update("entry_count", entryCount)
|
||||
|
||||
resp := response.ToWorldbookResponse(wb)
|
||||
resp.EntryCount = entryCount
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// importEntry 辅助方法:从 SillyTavern 格式导入单条条目
|
||||
func (s *WorldbookService) importEntry(worldbookID uint, entryMap map[string]interface{}) {
|
||||
content := ""
|
||||
if c, ok := entryMap["content"].(string); ok {
|
||||
content = c
|
||||
}
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
|
||||
comment := ""
|
||||
if c, ok := entryMap["comment"].(string); ok {
|
||||
comment = c
|
||||
}
|
||||
|
||||
// 解析 keys(SillyTavern 存为 []string 或 []interface{})
|
||||
var keys []string
|
||||
if k, ok := entryMap["key"].([]interface{}); ok {
|
||||
for _, kk := range k {
|
||||
if ks, ok := kk.(string); ok {
|
||||
keys = append(keys, ks)
|
||||
}
|
||||
}
|
||||
} else if k, ok := entryMap["keys"].([]interface{}); ok {
|
||||
for _, kk := range k {
|
||||
if ks, ok := kk.(string); ok {
|
||||
keys = append(keys, ks)
|
||||
}
|
||||
}
|
||||
}
|
||||
keysJSON, _ := json.Marshal(keys)
|
||||
|
||||
var secKeys []string
|
||||
if k, ok := entryMap["secondary_key"].([]interface{}); ok {
|
||||
for _, kk := range k {
|
||||
if ks, ok := kk.(string); ok {
|
||||
secKeys = append(secKeys, ks)
|
||||
}
|
||||
}
|
||||
} else if k, ok := entryMap["secondaryKeys"].([]interface{}); ok {
|
||||
for _, kk := range k {
|
||||
if ks, ok := kk.(string); ok {
|
||||
secKeys = append(secKeys, ks)
|
||||
}
|
||||
}
|
||||
}
|
||||
secKeysJSON, _ := json.Marshal(secKeys)
|
||||
|
||||
constant := false
|
||||
if c, ok := entryMap["constant"].(bool); ok {
|
||||
constant = c
|
||||
}
|
||||
enabled := true
|
||||
if e, ok := entryMap["enabled"].(bool); ok {
|
||||
enabled = e
|
||||
} else if d, ok := entryMap["disable"].(bool); ok {
|
||||
enabled = !d
|
||||
}
|
||||
useRegex := false
|
||||
if r, ok := entryMap["use_regex"].(bool); ok {
|
||||
useRegex = r
|
||||
}
|
||||
position := 1
|
||||
if p, ok := entryMap["position"].(float64); ok {
|
||||
position = int(p)
|
||||
}
|
||||
order := 100
|
||||
if o, ok := entryMap["insertion_order"].(float64); ok {
|
||||
order = int(o)
|
||||
} else if o, ok := entryMap["order"].(float64); ok {
|
||||
order = int(o)
|
||||
}
|
||||
probability := 100
|
||||
if p, ok := entryMap["probability"].(float64); ok {
|
||||
probability = int(p)
|
||||
}
|
||||
|
||||
entry := &app.WorldbookEntry{
|
||||
WorldbookID: worldbookID,
|
||||
Comment: comment,
|
||||
Content: content,
|
||||
Keys: datatypes.JSON(keysJSON),
|
||||
SecondaryKeys: datatypes.JSON(secKeysJSON),
|
||||
Constant: constant,
|
||||
Enabled: enabled,
|
||||
UseRegex: useRegex,
|
||||
Position: position,
|
||||
Order: order,
|
||||
Probability: probability,
|
||||
ScanDepth: 2,
|
||||
}
|
||||
global.GVA_DB.Create(entry)
|
||||
}
|
||||
|
||||
// ExportToJSON 导出世界书为 JSON(兼容 SillyTavern 格式)
|
||||
func (s *WorldbookService) ExportToJSON(userID uint, worldbookID uint) ([]byte, string, error) {
|
||||
var wb app.Worldbook
|
||||
if err := global.GVA_DB.Where("id = ? AND (user_id = ? OR is_public = ?)", worldbookID, userID, true).
|
||||
First(&wb).Error; err != nil {
|
||||
return nil, "", errors.New("世界书不存在或无权访问")
|
||||
}
|
||||
|
||||
var entries []app.WorldbookEntry
|
||||
global.GVA_DB.Where("worldbook_id = ?", worldbookID).Order("`order` ASC").Find(&entries)
|
||||
|
||||
// 构建 SillyTavern 兼容格式
|
||||
entriesMap := make(map[string]interface{})
|
||||
for i, entry := range entries {
|
||||
var keys []string
|
||||
json.Unmarshal(entry.Keys, &keys)
|
||||
var secKeys []string
|
||||
json.Unmarshal(entry.SecondaryKeys, &secKeys)
|
||||
|
||||
entriesMap[fmt.Sprintf("%d", i)] = map[string]interface{}{
|
||||
"uid": entry.ID,
|
||||
"key": keys,
|
||||
"secondary_key": secKeys,
|
||||
"comment": entry.Comment,
|
||||
"content": entry.Content,
|
||||
"constant": entry.Constant,
|
||||
"enabled": entry.Enabled,
|
||||
"use_regex": entry.UseRegex,
|
||||
"case_sensitive": entry.CaseSensitive,
|
||||
"match_whole_words": entry.MatchWholeWords,
|
||||
"selective": entry.Selective,
|
||||
"selectiveLogic": entry.SelectiveLogic,
|
||||
"position": entry.Position,
|
||||
"depth": entry.Depth,
|
||||
"insertion_order": entry.Order,
|
||||
"probability": entry.Probability,
|
||||
"scanDepth": entry.ScanDepth,
|
||||
"group": entry.GroupID,
|
||||
}
|
||||
}
|
||||
|
||||
exportData := map[string]interface{}{
|
||||
"name": wb.Name,
|
||||
"description": wb.Description,
|
||||
"entries": entriesMap,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(exportData, "", " ")
|
||||
return data, wb.Name + ".json", err
|
||||
}
|
||||
198
server/service/app/worldbook_engine.go
Normal file
198
server/service/app/worldbook_engine.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WorldbookEngine 世界书触发引擎
|
||||
type WorldbookEngine struct{}
|
||||
|
||||
// TriggeredEntry 触发的条目
|
||||
type TriggeredEntry struct {
|
||||
Entry *app.WorldbookEntry
|
||||
Position int
|
||||
Order int
|
||||
}
|
||||
|
||||
// ScanAndTrigger 扫描消息并触发匹配的世界书条目
|
||||
func (e *WorldbookEngine) ScanAndTrigger(worldbookID uint, messages []string) ([]*TriggeredEntry, error) {
|
||||
// 获取世界书的所有启用条目
|
||||
var entries []app.WorldbookEntry
|
||||
err := global.GVA_DB.Where("worldbook_id = ? AND enabled = ?", worldbookID, true).
|
||||
Order("`order` ASC").
|
||||
Find(&entries).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var triggered []*TriggeredEntry
|
||||
|
||||
// 合并所有消息用于扫描
|
||||
var scanTexts []string
|
||||
for _, entry := range entries {
|
||||
// 根据 scanDepth 决定扫描范围
|
||||
if entry.ScanDepth > 0 && entry.ScanDepth < len(messages) {
|
||||
// 只扫描最近 N 条消息
|
||||
scanTexts = messages[len(messages)-entry.ScanDepth:]
|
||||
} else {
|
||||
// 扫描所有消息
|
||||
scanTexts = messages
|
||||
}
|
||||
|
||||
// 检查是否触发
|
||||
if e.shouldTrigger(&entry, scanTexts) {
|
||||
triggered = append(triggered, &TriggeredEntry{
|
||||
Entry: &entry,
|
||||
Position: entry.Position,
|
||||
Order: entry.Order,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return triggered, nil
|
||||
}
|
||||
|
||||
// shouldTrigger 判断条目是否应该被触发
|
||||
func (e *WorldbookEngine) shouldTrigger(entry *app.WorldbookEntry, messages []string) bool {
|
||||
// 常驻条目总是触发
|
||||
if entry.Constant {
|
||||
return true
|
||||
}
|
||||
|
||||
// 概率触发
|
||||
if entry.Probability < 100 {
|
||||
// 简单的概率判断(实际应用中可以使用更好的随机数生成器)
|
||||
if entry.Probability <= 0 {
|
||||
return false
|
||||
}
|
||||
// 这里简化处理,实际应该用随机数
|
||||
// 为了演示,我们假设概率大于 50 就触发
|
||||
if entry.Probability < 50 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 解析关键词
|
||||
var keys []string
|
||||
if len(entry.Keys) > 0 {
|
||||
json.Unmarshal(entry.Keys, &keys)
|
||||
}
|
||||
|
||||
// 如果没有关键词,不触发
|
||||
if len(keys) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// 合并所有消息为一个文本
|
||||
fullText := strings.Join(messages, " ")
|
||||
|
||||
// 检查主关键词是否匹配
|
||||
primaryMatch := e.matchKeys(keys, fullText, entry.UseRegex, entry.CaseSensitive, entry.MatchWholeWords)
|
||||
if !primaryMatch {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果需要次要关键词
|
||||
if entry.Selective {
|
||||
var secondaryKeys []string
|
||||
if len(entry.SecondaryKeys) > 0 {
|
||||
json.Unmarshal(entry.SecondaryKeys, &secondaryKeys)
|
||||
}
|
||||
|
||||
if len(secondaryKeys) > 0 {
|
||||
secondaryMatch := e.matchKeys(secondaryKeys, fullText, entry.UseRegex, entry.CaseSensitive, entry.MatchWholeWords)
|
||||
|
||||
// SelectiveLogic: 0=AND, 1=NOT
|
||||
if entry.SelectiveLogic == 0 {
|
||||
// AND: 次要关键词也必须匹配
|
||||
return secondaryMatch
|
||||
} else {
|
||||
// NOT: 次要关键词不能匹配
|
||||
return !secondaryMatch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// matchKeys 检查关键词是否匹配
|
||||
func (e *WorldbookEngine) matchKeys(keys []string, text string, useRegex, caseSensitive, matchWholeWords bool) bool {
|
||||
for _, key := range keys {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if useRegex {
|
||||
// 正则表达式匹配
|
||||
flags := ""
|
||||
if !caseSensitive {
|
||||
flags = "(?i)"
|
||||
}
|
||||
pattern := flags + key
|
||||
matched, err := regexp.MatchString(pattern, text)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Warn("正则表达式匹配失败", zap.String("pattern", pattern), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// 普通文本匹配
|
||||
searchText := text
|
||||
searchKey := key
|
||||
|
||||
if !caseSensitive {
|
||||
searchText = strings.ToLower(searchText)
|
||||
searchKey = strings.ToLower(searchKey)
|
||||
}
|
||||
|
||||
if matchWholeWords {
|
||||
// 全词匹配
|
||||
pattern := `\b` + regexp.QuoteMeta(searchKey) + `\b`
|
||||
matched, _ := regexp.MatchString(pattern, searchText)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// 包含匹配
|
||||
if strings.Contains(searchText, searchKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildPromptWithWorldbook 构建包含世界书内容的 prompt
|
||||
func (e *WorldbookEngine) BuildPromptWithWorldbook(basePrompt string, triggered []*TriggeredEntry) string {
|
||||
if len(triggered) == 0 {
|
||||
return basePrompt
|
||||
}
|
||||
|
||||
// 按位置和顺序排序
|
||||
// Position: 0=系统提示词前, 1=系统提示词后, 4=指定深度
|
||||
// 这里简化处理,都插入到系统提示词后
|
||||
|
||||
var worldbookContent strings.Builder
|
||||
worldbookContent.WriteString("\n\n[World Information]\n")
|
||||
|
||||
for _, t := range triggered {
|
||||
if t.Entry.Content != "" {
|
||||
worldbookContent.WriteString(t.Entry.Content)
|
||||
worldbookContent.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 将世界书内容插入到 basePrompt 之后
|
||||
return basePrompt + worldbookContent.String()
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
import CharacterManagePage from './pages/CharacterManagePage'
|
||||
import PresetManagePage from './pages/PresetManagePage'
|
||||
import WorldbookManagePage from './pages/WorldbookManagePage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
|
||||
function App() {
|
||||
@@ -27,6 +28,7 @@ function App() {
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/characters" element={<CharacterManagePage />} />
|
||||
<Route path="/presets" element={<PresetManagePage />} />
|
||||
<Route path="/worldbooks" element={<WorldbookManagePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Conversation {
|
||||
characterId: number
|
||||
title: string
|
||||
presetId?: number
|
||||
worldbookId?: number
|
||||
worldbookEnabled: boolean
|
||||
aiProvider: string
|
||||
model: string
|
||||
settings?: Record<string, any>
|
||||
@@ -50,6 +52,8 @@ export interface CreateConversationRequest {
|
||||
characterId: number
|
||||
title?: string
|
||||
presetId?: number
|
||||
worldbookId?: number
|
||||
worldbookEnabled?: boolean
|
||||
aiProvider?: string
|
||||
model?: string
|
||||
settings?: Record<string, any>
|
||||
@@ -103,7 +107,16 @@ export const conversationApi = {
|
||||
},
|
||||
|
||||
// 更新对话设置
|
||||
updateConversationSettings: (conversationId: number, settings: Record<string, any>) => {
|
||||
return apiClient.put(`/app/conversation/${conversationId}/settings`, { settings })
|
||||
updateConversationSettings: (conversationId: number, data: {
|
||||
settings?: Record<string, any>
|
||||
worldbookId?: number
|
||||
worldbookEnabled?: boolean
|
||||
}) => {
|
||||
return apiClient.put(`/app/conversation/${conversationId}/settings`, data)
|
||||
},
|
||||
|
||||
// 重新生成最后一条 AI 回复(非流式)
|
||||
regenerateMessage: (conversationId: number) => {
|
||||
return apiClient.post<Message>(`/app/conversation/${conversationId}/regenerate`)
|
||||
},
|
||||
}
|
||||
|
||||
113
web-app/src/api/regex.ts
Normal file
113
web-app/src/api/regex.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// 类型定义
|
||||
export interface RegexScript {
|
||||
id: number
|
||||
userId: number
|
||||
name: string
|
||||
findRegex: string
|
||||
replaceWith: string
|
||||
trimStrings: string[]
|
||||
placement: number
|
||||
disabled: boolean
|
||||
markdownOnly: boolean
|
||||
runOnEdit: boolean
|
||||
promptOnly: boolean
|
||||
substituteRegex: boolean
|
||||
minDepth?: number
|
||||
maxDepth?: number
|
||||
scope: number
|
||||
ownerCharId?: number
|
||||
ownerPresetId?: number
|
||||
order: number
|
||||
extensions: Record<string, any>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateRegexScriptRequest {
|
||||
name: string
|
||||
findRegex: string
|
||||
replaceWith?: string
|
||||
trimStrings?: string[]
|
||||
placement?: number
|
||||
disabled?: boolean
|
||||
markdownOnly?: boolean
|
||||
runOnEdit?: boolean
|
||||
promptOnly?: boolean
|
||||
substituteRegex?: boolean
|
||||
minDepth?: number
|
||||
maxDepth?: number
|
||||
scope?: number
|
||||
ownerCharId?: number
|
||||
ownerPresetId?: number
|
||||
order?: number
|
||||
extensions?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface UpdateRegexScriptRequest {
|
||||
name?: string
|
||||
findRegex?: string
|
||||
replaceWith?: string
|
||||
trimStrings?: string[]
|
||||
placement?: number
|
||||
disabled?: boolean
|
||||
markdownOnly?: boolean
|
||||
runOnEdit?: boolean
|
||||
promptOnly?: boolean
|
||||
substituteRegex?: boolean
|
||||
minDepth?: number
|
||||
maxDepth?: number
|
||||
scope?: number
|
||||
ownerCharId?: number
|
||||
ownerPresetId?: number
|
||||
order?: number
|
||||
extensions?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface GetRegexScriptListRequest {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
scope?: number
|
||||
}
|
||||
|
||||
export interface RegexScriptListResponse {
|
||||
list: RegexScript[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const regexScriptApi = {
|
||||
// 创建正则脚本
|
||||
createRegexScript: (data: CreateRegexScriptRequest): Promise<{ data: RegexScript }> => {
|
||||
return apiClient.post('/app/regex', data)
|
||||
},
|
||||
|
||||
// 获取正则脚本列表
|
||||
getRegexScriptList: (params?: GetRegexScriptListRequest): Promise<{ data: RegexScriptListResponse }> => {
|
||||
return apiClient.get('/app/regex', { params })
|
||||
},
|
||||
|
||||
// 获取正则脚本详情
|
||||
getRegexScriptById: (id: number): Promise<{ data: RegexScript }> => {
|
||||
return apiClient.get(`/app/regex/${id}`)
|
||||
},
|
||||
|
||||
// 更新正则脚本
|
||||
updateRegexScript: (id: number, data: UpdateRegexScriptRequest) => {
|
||||
return apiClient.put(`/app/regex/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除正则脚本
|
||||
deleteRegexScript: (id: number) => {
|
||||
return apiClient.delete(`/app/regex/${id}`)
|
||||
},
|
||||
|
||||
// 测试正则脚本
|
||||
testRegexScript: (id: number, testString: string): Promise<{ data: { result: string } }> => {
|
||||
return apiClient.post(`/app/regex/${id}/test`, { testString })
|
||||
},
|
||||
}
|
||||
164
web-app/src/api/worldbook.ts
Normal file
164
web-app/src/api/worldbook.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import client from './client';
|
||||
|
||||
export interface Worldbook {
|
||||
id: number;
|
||||
userId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
isPublic: boolean;
|
||||
entryCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorldbookEntry {
|
||||
id: number;
|
||||
worldbookId: number;
|
||||
comment: string;
|
||||
content: string;
|
||||
keys: string[];
|
||||
secondaryKeys: string[];
|
||||
constant: boolean;
|
||||
enabled: boolean;
|
||||
useRegex: boolean;
|
||||
caseSensitive: boolean;
|
||||
matchWholeWords: boolean;
|
||||
selective: boolean;
|
||||
selectiveLogic: number;
|
||||
position: number;
|
||||
depth: number;
|
||||
order: number;
|
||||
probability: number;
|
||||
scanDepth: number;
|
||||
groupId: string;
|
||||
extensions?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateWorldbookRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateWorldbookRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateEntryRequest {
|
||||
comment?: string;
|
||||
content: string;
|
||||
keys?: string[];
|
||||
secondaryKeys?: string[];
|
||||
constant?: boolean;
|
||||
enabled?: boolean;
|
||||
useRegex?: boolean;
|
||||
caseSensitive?: boolean;
|
||||
matchWholeWords?: boolean;
|
||||
selective?: boolean;
|
||||
selectiveLogic?: number;
|
||||
position?: number;
|
||||
depth?: number;
|
||||
order?: number;
|
||||
probability?: number;
|
||||
scanDepth?: number;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateEntryRequest {
|
||||
comment?: string;
|
||||
content?: string;
|
||||
keys?: string[];
|
||||
secondaryKeys?: string[];
|
||||
constant?: boolean;
|
||||
enabled?: boolean;
|
||||
useRegex?: boolean;
|
||||
caseSensitive?: boolean;
|
||||
matchWholeWords?: boolean;
|
||||
selective?: boolean;
|
||||
selectiveLogic?: number;
|
||||
position?: number;
|
||||
depth?: number;
|
||||
order?: number;
|
||||
probability?: number;
|
||||
scanDepth?: number;
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
// 创建世界书
|
||||
export const createWorldbook = (data: CreateWorldbookRequest) => {
|
||||
return client.post<Worldbook>('/app/worldbook', data);
|
||||
};
|
||||
|
||||
// 获取世界书列表
|
||||
export const getWorldbookList = (params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
return client.get<{
|
||||
list: Worldbook[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}>('/app/worldbook', { params });
|
||||
};
|
||||
|
||||
// 获取世界书详情
|
||||
export const getWorldbookById = (id: number) => {
|
||||
return client.get<Worldbook>(`/app/worldbook/${id}`);
|
||||
};
|
||||
|
||||
// 更新世界书
|
||||
export const updateWorldbook = (id: number, data: UpdateWorldbookRequest) => {
|
||||
return client.put(`/app/worldbook/${id}`, data);
|
||||
};
|
||||
|
||||
// 删除世界书
|
||||
export const deleteWorldbook = (id: number) => {
|
||||
return client.delete(`/app/worldbook/${id}`);
|
||||
};
|
||||
|
||||
// 导入世界书
|
||||
export const importWorldbook = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return client.post<Worldbook>('/app/worldbook/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 导出世界书
|
||||
export const exportWorldbook = (id: number) => {
|
||||
return client.get(`/app/worldbook/${id}/export`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
};
|
||||
|
||||
// 创建条目
|
||||
export const createEntry = (worldbookId: number, data: CreateEntryRequest) => {
|
||||
return client.post<WorldbookEntry>(`/app/worldbook/${worldbookId}/entry`, data);
|
||||
};
|
||||
|
||||
// 获取条目列表
|
||||
export const getEntryList = (worldbookId: number) => {
|
||||
return client.get<{
|
||||
list: WorldbookEntry[];
|
||||
total: number;
|
||||
}>(`/app/worldbook/${worldbookId}/entries`);
|
||||
};
|
||||
|
||||
// 更新条目
|
||||
export const updateEntry = (worldbookId: number, entryId: number, data: UpdateEntryRequest) => {
|
||||
return client.put(`/app/worldbook/${worldbookId}/entry/${entryId}`, data);
|
||||
};
|
||||
|
||||
// 删除条目
|
||||
export const deleteEntry = (worldbookId: number, entryId: number) => {
|
||||
return client.delete(`/app/worldbook/${worldbookId}/entry/${entryId}`);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
Mic,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import {type Conversation, conversationApi, type Message} from '../api/conversation'
|
||||
import {type Character} from '../api/character'
|
||||
import {type AIConfig, aiConfigApi} from '../api/aiConfig'
|
||||
@@ -36,7 +36,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
const [aiConfigs, setAiConfigs] = useState<AIConfig[]>([])
|
||||
const [selectedConfigId, setSelectedConfigId] = useState<number>()
|
||||
const [showModelSelector, setShowModelSelector] = useState(false)
|
||||
const [streamEnabled, setStreamEnabled] = useState(true) // 默认启用流式传输
|
||||
const [streamEnabled, setStreamEnabled] = useState(true)
|
||||
const [presets, setPresets] = useState<Preset[]>([])
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<number>()
|
||||
const [showPresetSelector, setShowPresetSelector] = useState(false)
|
||||
@@ -46,27 +46,19 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
const presetSelectorRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
// 检查是否点击在模型选择器外部
|
||||
if (showModelSelector && modelSelectorRef.current && !modelSelectorRef.current.contains(target)) {
|
||||
setShowModelSelector(false)
|
||||
}
|
||||
|
||||
// 检查是否点击在预设选择器外部
|
||||
if (showPresetSelector && presetSelectorRef.current && !presetSelectorRef.current.contains(target)) {
|
||||
setShowPresetSelector(false)
|
||||
}
|
||||
|
||||
// 检查是否点击在菜单外部
|
||||
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
|
||||
setShowMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showModelSelector, showPresetSelector, showMenu])
|
||||
@@ -86,10 +78,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await conversationApi.getMessageList(conversation.id, {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
})
|
||||
const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 })
|
||||
setMessages(response.data.list || [])
|
||||
} catch (err) {
|
||||
console.error('加载消息失败:', err)
|
||||
@@ -101,8 +90,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
const loadAIConfigs = async () => {
|
||||
try {
|
||||
const response = await aiConfigApi.getAIConfigList()
|
||||
const activeConfigs = response.data.list.filter(config => config.isActive)
|
||||
setAiConfigs(activeConfigs)
|
||||
setAiConfigs(response.data.list.filter(config => config.isActive))
|
||||
} catch (err) {
|
||||
console.error('加载 AI 配置失败:', err)
|
||||
}
|
||||
@@ -114,9 +102,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
const settings = typeof conversation.settings === 'string'
|
||||
? JSON.parse(conversation.settings)
|
||||
: conversation.settings
|
||||
if (settings.aiConfigId) {
|
||||
setSelectedConfigId(settings.aiConfigId)
|
||||
}
|
||||
if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId)
|
||||
} catch (e) {
|
||||
console.error('解析设置失败:', e)
|
||||
}
|
||||
@@ -133,6 +119,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
}
|
||||
|
||||
const loadCurrentPreset = () => {
|
||||
if (conversation.presetId) {
|
||||
setSelectedPresetId(conversation.presetId)
|
||||
return
|
||||
}
|
||||
if (conversation.settings) {
|
||||
try {
|
||||
const settings = typeof conversation.settings === 'string'
|
||||
@@ -140,25 +130,28 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
: conversation.settings
|
||||
if (settings.presetId) {
|
||||
setSelectedPresetId(settings.presetId)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析设置失败:', e)
|
||||
}
|
||||
}
|
||||
setSelectedPresetId(undefined)
|
||||
}
|
||||
|
||||
const handlePresetChange = async (presetId: number) => {
|
||||
const handlePresetChange = async (presetId: number | null) => {
|
||||
try {
|
||||
const settings = conversation.settings
|
||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
|
||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
|
||||
: {}
|
||||
|
||||
settings.presetId = presetId
|
||||
|
||||
await conversationApi.updateConversationSettings(conversation.id, settings)
|
||||
setSelectedPresetId(presetId)
|
||||
if (presetId === null) {
|
||||
delete settings.presetId
|
||||
} else {
|
||||
settings.presetId = presetId
|
||||
}
|
||||
await conversationApi.updateConversationSettings(conversation.id, { settings })
|
||||
setSelectedPresetId(presetId ?? undefined)
|
||||
setShowPresetSelector(false)
|
||||
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} catch (err) {
|
||||
@@ -167,18 +160,19 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
}
|
||||
}
|
||||
|
||||
const handleModelChange = async (configId: number) => {
|
||||
const handleModelChange = async (configId: number | null) => {
|
||||
try {
|
||||
const settings = conversation.settings
|
||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : conversation.settings)
|
||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
|
||||
: {}
|
||||
|
||||
settings.aiConfigId = configId
|
||||
|
||||
await conversationApi.updateConversationSettings(conversation.id, settings)
|
||||
setSelectedConfigId(configId)
|
||||
if (configId === null) {
|
||||
delete settings.aiConfigId
|
||||
} else {
|
||||
settings.aiConfigId = configId
|
||||
}
|
||||
await conversationApi.updateConversationSettings(conversation.id, { settings })
|
||||
setSelectedConfigId(configId ?? undefined)
|
||||
setShowModelSelector(false)
|
||||
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} catch (err) {
|
||||
@@ -192,16 +186,12 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
// 防止重复发送
|
||||
if (!inputValue.trim() || sending) return
|
||||
|
||||
const userMessage = inputValue.trim()
|
||||
|
||||
// 立即清空输入框和设置发送状态,防止重复触发
|
||||
setInputValue('')
|
||||
setSending(true)
|
||||
|
||||
// 立即显示用户消息
|
||||
const tempUserMessage: Message = {
|
||||
id: Date.now(),
|
||||
conversationId: conversation.id,
|
||||
@@ -210,9 +200,8 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
tokenCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, tempUserMessage])
|
||||
setMessages(prev => [...prev, tempUserMessage])
|
||||
|
||||
// 创建临时AI消息用于流式显示
|
||||
const tempAIMessage: Message = {
|
||||
id: Date.now() + 1,
|
||||
conversationId: conversation.id,
|
||||
@@ -224,10 +213,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
|
||||
try {
|
||||
if (streamEnabled) {
|
||||
// 流式传输
|
||||
console.log('[Stream] 开始流式传输...')
|
||||
setMessages((prev) => [...prev, tempAIMessage])
|
||||
|
||||
setMessages(prev => [...prev, tempAIMessage])
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
|
||||
{
|
||||
@@ -239,58 +225,34 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
body: JSON.stringify({ content: userMessage }),
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error('流式传输失败')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('流式传输失败')
|
||||
}
|
||||
|
||||
console.log('[Stream] 连接成功,开始接收数据...')
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
if (reader) {
|
||||
let fullContent = ''
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
console.log('[Stream] 传输完成')
|
||||
break
|
||||
}
|
||||
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
|
||||
// 保留最后一行(可能不完整)
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
let currentEvent = ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim()
|
||||
console.log('[Stream] 事件类型:', currentEvent)
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
|
||||
if (currentEvent === 'message') {
|
||||
// 消息内容 - 后端现在发送的是纯文本,不再是JSON
|
||||
fullContent += data
|
||||
console.log('[Stream] 接收内容片段:', data)
|
||||
// 实时更新临时AI消息的内容
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === tempAIMessage.id ? { ...m, content: fullContent } : m
|
||||
)
|
||||
setMessages(prev =>
|
||||
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
|
||||
)
|
||||
} else if (currentEvent === 'done') {
|
||||
// 流式传输完成
|
||||
console.log('[Stream] 收到完成信号,重新加载消息')
|
||||
await loadMessages()
|
||||
break
|
||||
} else if (currentEvent === 'error') {
|
||||
// 错误处理
|
||||
console.error('[Stream] 错误:', data)
|
||||
throw new Error(data)
|
||||
}
|
||||
currentEvent = ''
|
||||
@@ -298,28 +260,18 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新对话信息
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} else {
|
||||
// 普通传输
|
||||
const response = await conversationApi.sendMessage(conversation.id, {
|
||||
content: userMessage,
|
||||
})
|
||||
|
||||
// 更新消息列表(包含AI回复)
|
||||
await conversationApi.sendMessage(conversation.id, { content: userMessage })
|
||||
await loadMessages()
|
||||
|
||||
// 更新对话信息
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('发送消息失败:', err)
|
||||
alert(err.response?.data?.msg || '发送消息失败,请重试')
|
||||
// 移除临时消息
|
||||
setMessages((prev) => prev.filter((m) => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
|
||||
setMessages(prev => prev.filter(m => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -340,22 +292,82 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
|
||||
const handleRegenerateResponse = async () => {
|
||||
if (messages.length === 0 || sending) return
|
||||
|
||||
// 找到最后一条用户消息
|
||||
const lastUserMessage = [...messages].reverse().find(m => m.role === 'user')
|
||||
if (!lastUserMessage) return
|
||||
const hasAssistantMsg = messages.some(m => m.role === 'assistant')
|
||||
if (!hasAssistantMsg) return
|
||||
|
||||
setSending(true)
|
||||
const lastAssistantIndex = [...messages].map(m => m.role).lastIndexOf('assistant')
|
||||
if (lastAssistantIndex !== -1) {
|
||||
setMessages(prev => prev.filter((_, i) => i !== lastAssistantIndex))
|
||||
}
|
||||
|
||||
const tempAIMessage: Message = {
|
||||
id: Date.now(),
|
||||
conversationId: conversation.id,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tokenCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
await conversationApi.sendMessage(conversation.id, {
|
||||
content: lastUserMessage.content,
|
||||
})
|
||||
await loadMessages()
|
||||
if (streamEnabled) {
|
||||
setMessages(prev => [...prev, tempAIMessage])
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/regenerate?stream=true`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error('重新生成失败')
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
if (reader) {
|
||||
let fullContent = ''
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
let currentEvent = ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim()
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
if (currentEvent === 'message') {
|
||||
fullContent += data
|
||||
setMessages(prev =>
|
||||
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
|
||||
)
|
||||
} else if (currentEvent === 'done') {
|
||||
await loadMessages()
|
||||
break
|
||||
} else if (currentEvent === 'error') {
|
||||
throw new Error(data)
|
||||
}
|
||||
currentEvent = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await conversationApi.regenerateMessage(conversation.id)
|
||||
await loadMessages()
|
||||
}
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('重新生成失败:', err)
|
||||
alert('重新生成失败,请重试')
|
||||
alert(err.message || '重新生成失败,请重试')
|
||||
await loadMessages()
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -363,7 +375,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
|
||||
const handleDeleteConversation = async () => {
|
||||
if (!confirm('确定要删除这个对话吗?')) return
|
||||
|
||||
try {
|
||||
await conversationApi.deleteConversation(conversation.id)
|
||||
window.location.href = '/chat'
|
||||
@@ -374,9 +385,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
}
|
||||
|
||||
const handleExportConversation = () => {
|
||||
const content = messages
|
||||
.map((msg) => `[${msg.role}] ${msg.content}`)
|
||||
.join('\n\n')
|
||||
const content = messages.map(msg => `[${msg.role}] ${msg.content}`).join('\n\n')
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
@@ -393,7 +402,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
@@ -401,259 +409,294 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const selectedConfig = aiConfigs.find(c => c.id === selectedConfigId)
|
||||
const selectedPreset = presets.find(p => p.id === selectedPresetId)
|
||||
const lastAssistantMsgId = [...messages].reverse().find(m => m.role === 'assistant')?.id
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="p-4 glass border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">{conversation.title}</h2>
|
||||
<div className="flex items-center gap-2 text-sm text-white/60">
|
||||
<span>与 {character.name} 对话中</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="px-4 py-3 glass border-b border-white/10">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* 左侧:标题 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-base font-semibold truncate">{conversation.title}</h2>
|
||||
<p className="text-xs text-white/50 truncate">与 {character.name} 对话中</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* 右侧:工具按钮组 */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
|
||||
{/* 模型选择器 */}
|
||||
<div className="relative" ref={modelSelectorRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowModelSelector(!showModelSelector)
|
||||
onClick={() => {
|
||||
setShowModelSelector(v => !v)
|
||||
setShowPresetSelector(false)
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer
|
||||
${showModelSelector ? 'bg-white/15 text-white' : 'glass-hover text-white/70 hover:text-white'}`}
|
||||
title="切换模型"
|
||||
>
|
||||
<Zap className="w-4 h-4 text-secondary" />
|
||||
<span className="text-xs">
|
||||
{selectedConfigId
|
||||
? aiConfigs.find(c => c.id === selectedConfigId)?.name || '默认模型'
|
||||
: '默认模型'}
|
||||
<Zap className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0" />
|
||||
<span className="max-w-[80px] truncate">
|
||||
{selectedConfig ? selectedConfig.name : '默认模型'}
|
||||
</span>
|
||||
<ChevronDown className={`w-3 h-3 flex-shrink-0 transition-transform ${showModelSelector ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showModelSelector && createPortal(
|
||||
<div
|
||||
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
|
||||
style={{
|
||||
top: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
|
||||
left: modelSelectorRef.current ? modelSelectorRef.current.getBoundingClientRect().right - 200 : 0,
|
||||
zIndex: 9999
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{aiConfigs.length === 0 ? (
|
||||
<div className="px-4 py-3 text-xs text-white/60 text-center">
|
||||
暂无可用配置
|
||||
</div>
|
||||
) : (
|
||||
aiConfigs.map((config) => (
|
||||
|
||||
{showModelSelector && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-56 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={() => handleModelChange(null)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
|
||||
${!selectedConfigId ? 'bg-primary/20 text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}
|
||||
>
|
||||
<span className="flex-1">默认模型</span>
|
||||
{!selectedConfigId && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
{aiConfigs.length > 0 && <div className="my-1 border-t border-white/10" />}
|
||||
{aiConfigs.map(config => (
|
||||
<button
|
||||
key={config.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleModelChange(config.id)
|
||||
}}
|
||||
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
|
||||
selectedConfigId === config.id ? 'bg-primary/20 ring-1 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleModelChange(config.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
|
||||
${selectedConfigId === config.id ? 'bg-primary/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'}`}
|
||||
>
|
||||
<div className="font-medium">{config.name}</div>
|
||||
<div className="text-xs text-white/60 mt-0.5">
|
||||
{config.provider} • {config.defaultModel}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{config.name}</div>
|
||||
<div className="text-xs text-white/40 truncate">{config.provider} · {config.defaultModel}</div>
|
||||
</div>
|
||||
{selectedConfigId === config.id && <Check className="w-3.5 h-3.5 text-primary flex-shrink-0" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
))}
|
||||
{aiConfigs.length === 0 && (
|
||||
<div className="px-3 py-3 text-xs text-white/40 text-center">暂无可用配置</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预设选择器 */}
|
||||
<div className="relative" ref={presetSelectorRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowPresetSelector(!showPresetSelector)
|
||||
onClick={() => {
|
||||
setShowPresetSelector(v => !v)
|
||||
setShowModelSelector(false)
|
||||
setShowMenu(false)
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 glass-hover rounded-lg cursor-pointer text-sm"
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer
|
||||
${showPresetSelector ? 'bg-white/15 text-white' : 'glass-hover text-white/70 hover:text-white'}`}
|
||||
title="选择预设"
|
||||
>
|
||||
<Settings className="w-4 h-4 text-primary" />
|
||||
<span className="text-xs">
|
||||
{selectedPresetId
|
||||
? presets.find(p => p.id === selectedPresetId)?.name || '默认预设'
|
||||
: '默认预设'}
|
||||
<Settings className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
|
||||
<span className="max-w-[80px] truncate">
|
||||
{selectedPreset ? selectedPreset.name : '默认预设'}
|
||||
</span>
|
||||
<ChevronDown className={`w-3 h-3 flex-shrink-0 transition-transform ${showPresetSelector ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showPresetSelector && createPortal(
|
||||
<div
|
||||
className="fixed glass rounded-xl p-2 min-w-[200px] shadow-xl"
|
||||
style={{
|
||||
top: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().bottom + 8 : 0,
|
||||
left: presetSelectorRef.current ? presetSelectorRef.current.getBoundingClientRect().right - 200 : 0,
|
||||
zIndex: 9999
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{presets.length === 0 ? (
|
||||
<div className="px-4 py-3 text-xs text-white/60 text-center">
|
||||
暂无可用预设
|
||||
</div>
|
||||
) : (
|
||||
presets.map((preset) => (
|
||||
|
||||
{showPresetSelector && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-56 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={() => handlePresetChange(null)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
|
||||
${!selectedPresetId ? 'bg-primary/20 text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}
|
||||
>
|
||||
<span className="flex-1">默认预设</span>
|
||||
{!selectedPresetId && <Check className="w-3.5 h-3.5 text-primary" />}
|
||||
</button>
|
||||
{presets.length > 0 && <div className="my-1 border-t border-white/10" />}
|
||||
{presets.map(preset => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handlePresetChange(preset.id)
|
||||
}}
|
||||
className={`w-full px-3 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer ${
|
||||
selectedPresetId === preset.id ? 'bg-primary/20 ring-1 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => handlePresetChange(preset.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left cursor-pointer transition-all
|
||||
${selectedPresetId === preset.id ? 'bg-primary/20 text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'}`}
|
||||
>
|
||||
<div className="font-medium">{preset.name}</div>
|
||||
<div className="text-xs text-white/60 mt-0.5">
|
||||
{preset.description || `温度: ${preset.temperature}`}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{preset.name}</div>
|
||||
<div className="text-xs text-white/40">温度 {preset.temperature}</div>
|
||||
</div>
|
||||
{selectedPresetId === preset.id && <Check className="w-3.5 h-3.5 text-primary flex-shrink-0" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
))}
|
||||
{presets.length === 0 && (
|
||||
<div className="px-3 py-3 text-xs text-white/40 text-center">暂无可用预设</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="w-px h-5 bg-white/10 mx-1" />
|
||||
|
||||
{/* 流式传输切换 */}
|
||||
<button
|
||||
onClick={() => setStreamEnabled(!streamEnabled)}
|
||||
className={`p-2 glass-hover rounded-lg cursor-pointer ${
|
||||
streamEnabled ? 'text-green-400' : 'text-white/60'
|
||||
onClick={() => setStreamEnabled(v => !v)}
|
||||
className={`p-1.5 rounded-lg cursor-pointer transition-all ${
|
||||
streamEnabled
|
||||
? 'text-emerald-400 bg-emerald-400/10 hover:bg-emerald-400/20'
|
||||
: 'text-white/30 glass-hover hover:text-white/60'
|
||||
}`}
|
||||
title={streamEnabled ? '流式传输已启用' : '流式传输已禁用'}
|
||||
title={streamEnabled ? '流式传输已启用(点击关闭)' : '流式传输已关闭(点击开启)'}
|
||||
>
|
||||
<Waves className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRegenerateResponse}
|
||||
disabled={sending || messages.length === 0}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="重新生成回复"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
<Waves className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 更多菜单 */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowMenu(!showMenu)
|
||||
onClick={() => {
|
||||
setShowMenu(v => !v)
|
||||
setShowModelSelector(false)
|
||||
setShowPresetSelector(false)
|
||||
}}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
className={`p-1.5 rounded-lg cursor-pointer transition-all
|
||||
${showMenu ? 'bg-white/15 text-white' : 'glass-hover text-white/60 hover:text-white'}`}
|
||||
>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
{showMenu && createPortal(
|
||||
<div
|
||||
className="fixed glass rounded-xl p-2 min-w-[160px] shadow-xl"
|
||||
style={{
|
||||
top: menuRef.current ? menuRef.current.getBoundingClientRect().bottom + 8 : 0,
|
||||
left: menuRef.current ? menuRef.current.getBoundingClientRect().right - 160 : 0,
|
||||
zIndex: 9999
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={handleExportConversation}
|
||||
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
导出对话
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteConversation}
|
||||
className="w-full px-4 py-2 glass-hover rounded-lg text-sm text-left cursor-pointer flex items-center gap-2 text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
删除对话
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 glass rounded-xl shadow-2xl border border-white/15 overflow-hidden">
|
||||
<div className="p-1">
|
||||
<button
|
||||
onClick={() => { handleExportConversation(); setShowMenu(false) }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left cursor-pointer text-white/70 hover:bg-white/10 hover:text-white transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
导出对话
|
||||
</button>
|
||||
<div className="my-1 border-t border-white/10" />
|
||||
<button
|
||||
onClick={() => { handleDeleteConversation(); setShowMenu(false) }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left cursor-pointer text-red-400 hover:bg-red-400/10 transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
删除对话
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-5">
|
||||
{loading ? (
|
||||
<div className="text-center text-white/60">加载消息中...</div>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 0.15, 0.3].map((delay, i) => (
|
||||
<div key={i} className="w-2 h-2 bg-primary/60 rounded-full animate-bounce" style={{ animationDelay: `${delay}s` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-white/60 py-12">
|
||||
<p className="mb-2">还没有消息</p>
|
||||
<p className="text-sm">发送第一条消息开始对话吧!</p>
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
{character.avatar && (
|
||||
<img src={character.avatar} alt={character.name} className="w-16 h-16 rounded-full object-cover mb-4 ring-2 ring-white/10" />
|
||||
)}
|
||||
<p className="text-white/50 text-sm">发送第一条消息,开始和 {character.name} 对话吧</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
|
||||
>
|
||||
messages.map((msg) => {
|
||||
const isLastAssistant = msg.id === lastAssistantMsgId
|
||||
return (
|
||||
<div
|
||||
className={`max-w-2xl min-w-0 relative ${
|
||||
msg.role === 'user'
|
||||
? 'glass-hover rounded-2xl rounded-br-md p-4'
|
||||
: 'glass-hover rounded-2xl rounded-bl-md p-4'
|
||||
}`}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
|
||||
>
|
||||
{/* 助手头像 */}
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{character.avatar && (
|
||||
<img
|
||||
src={character.avatar}
|
||||
alt={character.name}
|
||||
className="w-6 h-6 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex-shrink-0 mr-2.5 mt-1">
|
||||
{character.avatar ? (
|
||||
<img src={character.avatar} alt={character.name} className="w-8 h-8 rounded-full object-cover ring-1 ring-white/10" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs font-medium">
|
||||
{character.name[0]}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium text-primary">{character.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
content={msg.content}
|
||||
role={msg.role}
|
||||
onChoiceSelect={(choice) => {
|
||||
setInputValue(choice)
|
||||
// 自动聚焦到输入框
|
||||
textareaRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-white/40">{formatTime(msg.createdAt)}</span>
|
||||
<button
|
||||
onClick={() => handleCopyMessage(msg.content, msg.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 glass-hover rounded transition-opacity cursor-pointer"
|
||||
title="复制消息"
|
||||
|
||||
<div className={`max-w-[70%] min-w-0 flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
|
||||
{/* 助手名称 */}
|
||||
{msg.role === 'assistant' && (
|
||||
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
|
||||
)}
|
||||
|
||||
{/* 消息气泡 */}
|
||||
<div
|
||||
className={`relative px-4 py-3 rounded-2xl ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary/25 border border-primary/20 rounded-br-md'
|
||||
: 'glass rounded-bl-md'
|
||||
}`}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{copiedId === msg.id ? (
|
||||
<Check className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
<MessageContent
|
||||
content={msg.content}
|
||||
role={msg.role}
|
||||
onChoiceSelect={(choice) => {
|
||||
setInputValue(choice)
|
||||
textareaRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className={`flex items-center gap-1 mt-1 px-1 opacity-0 group-hover:opacity-100 transition-opacity ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||
<span className="text-xs text-white/25">{formatTime(msg.createdAt)}</span>
|
||||
<button
|
||||
onClick={() => handleCopyMessage(msg.content, msg.id)}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer"
|
||||
title="复制"
|
||||
>
|
||||
{copiedId === msg.id
|
||||
? <Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
: <Copy className="w-3.5 h-3.5 text-white/40 hover:text-white/70" />
|
||||
}
|
||||
</button>
|
||||
{/* 最后一条 AI 消息显示重新生成按钮 */}
|
||||
{msg.role === 'assistant' && isLastAssistant && (
|
||||
<button
|
||||
onClick={handleRegenerateResponse}
|
||||
disabled={sending}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title="重新生成"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 text-white/40 hover:text-white/70 ${sending ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户头像占位 */}
|
||||
{msg.role === 'user' && <div className="flex-shrink-0 ml-2.5 mt-1 w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs">我</div>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
{sending && (
|
||||
|
||||
{/* 发送中动画(流式时不需要,已有临时消息) */}
|
||||
{sending && !streamEnabled && (
|
||||
<div className="flex justify-start">
|
||||
<div className="glass-hover rounded-2xl rounded-bl-md p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" style={{ animationDelay: '0.4s' }}></div>
|
||||
<div className="flex-shrink-0 mr-2.5 mt-1 w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs">
|
||||
{character.name[0]}
|
||||
</div>
|
||||
<div className="glass rounded-2xl rounded-bl-md px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{[0, 0.2, 0.4].map((delay, i) => (
|
||||
<div key={i} className="w-1.5 h-1.5 bg-primary/60 rounded-full animate-bounce" style={{ animationDelay: `${delay}s` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -661,57 +704,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="p-4 glass border-t border-white/10">
|
||||
{/* 输入区域 */}
|
||||
<div className="px-4 pb-4 pt-2 glass border-t border-white/10">
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
|
||||
title="附件(开发中)"
|
||||
disabled
|
||||
>
|
||||
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="附件(开发中)" disabled>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 glass rounded-2xl p-3 focus-within:ring-2 focus-within:ring-primary/50 transition-all">
|
||||
<div className="flex-1 glass rounded-2xl px-4 py-2.5 focus-within:ring-1 focus-within:ring-primary/50 transition-all">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
setInputValue(e.target.value)
|
||||
// 自动调整高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={sending ? '正在发送...' : '输入消息... (Enter发送,Shift+Enter换行)'}
|
||||
placeholder={sending ? 'AI 正在思考...' : `和 ${character.name} 说点什么... (Enter 发送,Shift+Enter 换行)`}
|
||||
rows={1}
|
||||
className="w-full bg-transparent resize-none focus:outline-none text-sm"
|
||||
style={{ maxHeight: '120px', minHeight: '24px' }}
|
||||
className="w-full bg-transparent resize-none focus:outline-none text-sm placeholder:text-white/25"
|
||||
style={{ maxHeight: '120px', minHeight: '22px' }}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-3 glass-hover rounded-lg cursor-pointer opacity-50"
|
||||
title="语音输入(开发中)"
|
||||
disabled
|
||||
>
|
||||
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="语音(开发中)" disabled>
|
||||
<Mic className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() || sending}
|
||||
className="p-3 bg-gradient-to-r from-primary to-secondary rounded-lg hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="发送消息"
|
||||
className="p-2.5 bg-gradient-to-br from-primary to-secondary rounded-xl hover:opacity-90 active:scale-95 transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
title="发送 (Enter)"
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-white/40">
|
||||
<span>消息: {conversation.messageCount} | Token: {conversation.tokenCount}</span>
|
||||
{sending && <span className="text-primary animate-pulse">AI 正在思考...</span>}
|
||||
|
||||
<div className="flex items-center justify-between mt-2 px-1 text-xs text-white/25">
|
||||
<span>{conversation.messageCount} 条消息 · {conversation.tokenCount} tokens</span>
|
||||
{sending && <span className="text-primary/70 animate-pulse">AI 正在思考...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Image, Palette, RotateCcw, Save, Upload, X} from 'lucide-react'
|
||||
import {Image, Palette, RotateCcw, Save, Upload, X, BookOpen} from 'lucide-react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {type Conversation, conversationApi} from '../api/conversation'
|
||||
import {type Worldbook, getWorldbookList} from '../api/worldbook'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
conversation: Conversation
|
||||
@@ -11,6 +12,8 @@ interface SettingsPanelProps {
|
||||
interface ConversationSettings {
|
||||
themeColor: string
|
||||
backgroundImage?: string
|
||||
worldbookId?: number
|
||||
worldbookEnabled: boolean
|
||||
}
|
||||
|
||||
const THEME_COLORS = [
|
||||
@@ -25,13 +28,16 @@ const THEME_COLORS = [
|
||||
export default function SettingsPanel({ conversation, onClose, onUpdate }: SettingsPanelProps) {
|
||||
const [settings, setSettings] = useState<ConversationSettings>({
|
||||
themeColor: '#7C3AED',
|
||||
worldbookEnabled: false,
|
||||
})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [backgroundPreview, setBackgroundPreview] = useState<string>()
|
||||
const [worldbooks, setWorldbooks] = useState<Worldbook[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
loadWorldbooks()
|
||||
}, [])
|
||||
|
||||
const loadSettings = () => {
|
||||
@@ -44,6 +50,8 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
|
||||
// 合并默认设置和保存的设置
|
||||
const mergedSettings = {
|
||||
themeColor: '#7C3AED',
|
||||
worldbookEnabled: conversation.worldbookEnabled || false,
|
||||
worldbookId: conversation.worldbookId,
|
||||
...parsed
|
||||
}
|
||||
|
||||
@@ -60,6 +68,22 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
|
||||
} catch (e) {
|
||||
console.error('解析设置失败:', e)
|
||||
}
|
||||
} else {
|
||||
// 如果没有 settings,从 conversation 直接读取世界书配置
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
worldbookEnabled: conversation.worldbookEnabled || false,
|
||||
worldbookId: conversation.worldbookId,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorldbooks = async () => {
|
||||
try {
|
||||
const response = await getWorldbookList({ page: 1, pageSize: 100 })
|
||||
setWorldbooks(response.data.list || [])
|
||||
} catch (error) {
|
||||
console.error('加载世界书列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +123,11 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 保存到后端
|
||||
await conversationApi.updateConversationSettings(conversation.id, settings)
|
||||
await conversationApi.updateConversationSettings(conversation.id, {
|
||||
settings,
|
||||
worldbookId: settings.worldbookId,
|
||||
worldbookEnabled: settings.worldbookEnabled,
|
||||
})
|
||||
|
||||
// 应用主题色到根元素
|
||||
if (settings.themeColor) {
|
||||
@@ -119,6 +147,7 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
|
||||
const handleReset = () => {
|
||||
setSettings({
|
||||
themeColor: '#7C3AED',
|
||||
worldbookEnabled: false,
|
||||
})
|
||||
setBackgroundPreview(undefined)
|
||||
setHasChanges(false)
|
||||
@@ -214,10 +243,64 @@ export default function SettingsPanel({ conversation, onClose, onUpdate }: Setti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-hover rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-primary/20 rounded-lg">
|
||||
<BookOpen className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold">世界书设置</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium">启用世界书</label>
|
||||
<p className="text-xs text-white/60 mt-1">根据关键词自动注入背景信息</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleChange('worldbookEnabled', !settings.worldbookEnabled)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors cursor-pointer ${
|
||||
settings.worldbookEnabled ? 'bg-primary' : 'bg-white/20'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
settings.worldbookEnabled ? 'translate-x-6' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.worldbookEnabled && (
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-3 block">选择世界书</label>
|
||||
<select
|
||||
value={settings.worldbookId || ''}
|
||||
onChange={(e) => handleChange('worldbookId', e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm bg-white/5 border border-white/10 focus:border-primary/50 focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{worldbooks.map((wb) => (
|
||||
<option key={wb.id} value={wb.id}>
|
||||
{wb.name} ({wb.entryCount} 条目)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{worldbooks.length === 0 && (
|
||||
<p className="text-xs text-white/40 mt-2">
|
||||
还没有世界书,请先在世界书管理页面创建
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-hover rounded-2xl p-4">
|
||||
<div className="text-xs text-white/60 space-y-2">
|
||||
<p>💡 主题颜色会应用到整个界面</p>
|
||||
<p>💡 背景图片仅在对话页面显示</p>
|
||||
<p>💡 世界书会根据对话内容自动触发相关条目</p>
|
||||
<p>💡 AI 模型可在对话界面顶部切换</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {Book, Download, Edit, FileJson, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
|
||||
import {Book, Code2, Download, Edit, FileJson, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
import {type RegexScript, regexScriptApi} from '../api/regex'
|
||||
|
||||
interface WorldBookEntry {
|
||||
keys: string[]
|
||||
@@ -32,6 +33,9 @@ export default function CharacterManagePage() {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [showWorldBookEditor, setShowWorldBookEditor] = useState(false)
|
||||
const [worldBookEntries, setWorldBookEntries] = useState<WorldBookEntry[]>([])
|
||||
const [showRegexScriptEditor, setShowRegexScriptEditor] = useState(false)
|
||||
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
|
||||
const [editingTab, setEditingTab] = useState<'basic' | 'worldbook' | 'regex'>('basic')
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
@@ -47,6 +51,7 @@ export default function CharacterManagePage() {
|
||||
setSelectedCharacter(char)
|
||||
setShowEditModal(true)
|
||||
loadWorldBook(char)
|
||||
loadRegexScripts(char.id)
|
||||
}
|
||||
}
|
||||
}, [searchParams, characters])
|
||||
@@ -66,6 +71,35 @@ export default function CharacterManagePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRegexScripts = async (characterId: number) => {
|
||||
try {
|
||||
const response = await regexScriptApi.getRegexScriptList({
|
||||
scope: 1,
|
||||
pageSize: 100,
|
||||
})
|
||||
console.log('正则脚本 API 响应:', response)
|
||||
|
||||
// apiClient 拦截器返回 response.data,所以这里的 response 就是后端的完整响应
|
||||
// 后端返回: {code: 0, data: {list: [...], total: 10, ...}, msg: '获取成功'}
|
||||
let list = []
|
||||
if (response.data && response.data.list && Array.isArray(response.data.list)) {
|
||||
// response.data.list 是正确的路径
|
||||
list = response.data.list
|
||||
} else {
|
||||
console.warn('正则脚本列表数据格式不正确:', response)
|
||||
setRegexScripts([])
|
||||
return
|
||||
}
|
||||
|
||||
const charScripts = list.filter((s: any) => s.ownerCharId === characterId)
|
||||
console.log(`找到 ${charScripts.length} 个角色关联的正则脚本`)
|
||||
setRegexScripts(charScripts)
|
||||
} catch (err) {
|
||||
console.error('加载正则脚本失败:', err)
|
||||
setRegexScripts([])
|
||||
}
|
||||
}
|
||||
|
||||
const loadCharacters = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
@@ -173,6 +207,7 @@ export default function CharacterManagePage() {
|
||||
setWorldBookEntries([...worldBookEntries, {
|
||||
keys: [],
|
||||
content: '',
|
||||
comment: '',
|
||||
enabled: true,
|
||||
insertion_order: worldBookEntries.length,
|
||||
position: 'after_char'
|
||||
@@ -308,6 +343,8 @@ export default function CharacterManagePage() {
|
||||
onClick={() => {
|
||||
setSelectedCharacter(char)
|
||||
setShowEditModal(true)
|
||||
loadWorldBook(char)
|
||||
loadRegexScripts(char.id)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
@@ -513,14 +550,22 @@ export default function CharacterManagePage() {
|
||||
<p className="text-xs text-white/40 mt-1">公开后其他用户可以在角色广场看到并使用此角色卡</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWorldBookEditor(true)}
|
||||
className="w-full px-4 py-3 glass-hover rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2"
|
||||
className="px-4 py-3 glass-hover rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
编辑世界书 ({getWorldBookEntryCount(selectedCharacter)} 个条目)
|
||||
世界书 ({getWorldBookEntryCount(selectedCharacter)})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRegexScriptEditor(true)}
|
||||
className="px-4 py-3 glass-hover rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<Code2 className="w-4 h-4" />
|
||||
正则脚本 ({regexScripts.length})
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -572,7 +617,14 @@ export default function CharacterManagePage() {
|
||||
worldBookEntries.map((entry, index) => (
|
||||
<div key={index} className="glass rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium">条目 #{index + 1}</span>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{entry.comment ? entry.comment : `条目 #${index + 1}`}
|
||||
</span>
|
||||
{entry.comment && (
|
||||
<span className="text-xs text-white/40 ml-2">#{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -593,6 +645,17 @@ export default function CharacterManagePage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">条目标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.comment || ''}
|
||||
onChange={(e) => updateWorldBookEntry(index, 'comment', e.target.value)}
|
||||
placeholder="例如: 魔法系统设定"
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">触发关键词(逗号分隔)</label>
|
||||
<input
|
||||
@@ -672,6 +735,253 @@ export default function CharacterManagePage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRegexScriptEditor && selectedCharacter && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-3xl p-8 max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">编辑正则脚本</h2>
|
||||
<button
|
||||
onClick={() => setShowRegexScriptEditor(false)}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-4">
|
||||
{regexScripts.length === 0 ? (
|
||||
<div className="glass rounded-xl p-8 text-center">
|
||||
<Code2 className="w-12 h-12 mx-auto mb-3 text-white/40" />
|
||||
<p className="text-white/60 mb-4">还没有正则脚本</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await regexScriptApi.createRegexScript({
|
||||
name: '新脚本',
|
||||
findRegex: '',
|
||||
scope: 1,
|
||||
ownerCharId: selectedCharacter.id,
|
||||
})
|
||||
loadRegexScripts(selectedCharacter.id)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.msg || '创建失败')
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
|
||||
>
|
||||
添加第一个脚本
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
regexScripts.map((script) => (
|
||||
<div key={script.id} className="glass rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={script.name}
|
||||
onChange={(e) => {
|
||||
const newScripts = regexScripts.map(s =>
|
||||
s.id === script.id ? { ...s, name: e.target.value } : s
|
||||
)
|
||||
setRegexScripts(newScripts)
|
||||
}}
|
||||
className="flex-1 px-3 py-2 glass rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!script.disabled}
|
||||
onChange={(e) => {
|
||||
const newScripts = regexScripts.map(s =>
|
||||
s.id === script.id ? { ...s, disabled: !e.target.checked } : s
|
||||
)
|
||||
setRegexScripts(newScripts)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-white/60">启用</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm('确定要删除这个脚本吗?')) return
|
||||
try {
|
||||
await regexScriptApi.deleteRegexScript(script.id)
|
||||
loadRegexScripts(selectedCharacter.id)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.msg || '删除失败')
|
||||
}
|
||||
}}
|
||||
className="p-1 glass-hover rounded text-red-400 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">查找正则表达式</label>
|
||||
<input
|
||||
type="text"
|
||||
value={script.findRegex}
|
||||
onChange={(e) => {
|
||||
const newScripts = regexScripts.map(s =>
|
||||
s.id === script.id ? { ...s, findRegex: e.target.value } : s
|
||||
)
|
||||
setRegexScripts(newScripts)
|
||||
}}
|
||||
placeholder="例如: /\*\*(.+?)\*\*/"
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">替换为</label>
|
||||
<input
|
||||
type="text"
|
||||
value={script.replaceWith}
|
||||
onChange={(e) => {
|
||||
const newScripts = regexScripts.map(s =>
|
||||
s.id === script.id ? { ...s, replaceWith: e.target.value } : s
|
||||
)
|
||||
setRegexScripts(newScripts)
|
||||
}}
|
||||
placeholder="例如: <strong>$1</strong>"
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">执行阶段</label>
|
||||
<select
|
||||
value={script.placement}
|
||||
onChange={(e) => {
|
||||
const newScripts = regexScripts.map(s =>
|
||||
s.id === script.id ? { ...s, placement: parseInt(e.target.value) } : s
|
||||
)
|
||||
setRegexScripts(newScripts)
|
||||
}}
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="0">输入</option>
|
||||
<option value="1">输出</option>
|
||||
<option value="2">世界书</option>
|
||||
<option value="3">推理</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-white/60 mb-1 block">执行顺序</label>
|
||||
<input
|
||||
type="number"
|
||||
value={script.order}
|
||||
onChange={(e) => {
|
||||
const newScripts = regexScripts.map(s =>
|
||||
s.id === script.id ? { ...s, order: parseInt(e.target.value) } : s
|
||||
)
|
||||
setRegexScripts(newScripts)
|
||||
}}
|
||||
className="w-full px-3 py-2 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 pt-5">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={script.substituteRegex}
|
||||
onChange={(e) => {
|
||||
const newScripts = regexScripts.map(s =>
|
||||
s.id === script.id ? { ...s, substituteRegex: e.target.checked } : s
|
||||
)
|
||||
setRegexScripts(newScripts)
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-white/60">替换宏</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const testInput = prompt('输入测试文本:', '这是一段**测试**文本')
|
||||
if (!testInput) return
|
||||
|
||||
try {
|
||||
const result = await regexScriptApi.testRegexScript(script.id, testInput)
|
||||
alert(`原文:${result.data.original}\n\n结果:${result.data.result}`)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.msg || '测试失败')
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 glass-hover rounded-lg text-xs cursor-pointer"
|
||||
>
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await regexScriptApi.updateRegexScript(script.id, {
|
||||
name: script.name,
|
||||
findRegex: script.findRegex,
|
||||
replaceWith: script.replaceWith,
|
||||
placement: script.placement,
|
||||
disabled: script.disabled,
|
||||
order: script.order,
|
||||
substituteRegex: script.substituteRegex,
|
||||
})
|
||||
alert('保存成功')
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.msg || '保存失败')
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gradient-to-r from-primary to-secondary rounded-lg text-xs cursor-pointer"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6 pt-6 border-t border-white/10">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await regexScriptApi.createRegexScript({
|
||||
name: '新脚本',
|
||||
findRegex: '',
|
||||
scope: 1,
|
||||
ownerCharId: selectedCharacter.id,
|
||||
})
|
||||
loadRegexScripts(selectedCharacter.id)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.msg || '创建失败')
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
添加脚本
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setShowRegexScriptEditor(false)}
|
||||
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
613
web-app/src/pages/WorldbookManagePage.tsx
Normal file
613
web-app/src/pages/WorldbookManagePage.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
getWorldbookList,
|
||||
createWorldbook,
|
||||
updateWorldbook,
|
||||
deleteWorldbook,
|
||||
importWorldbook,
|
||||
exportWorldbook,
|
||||
getEntryList,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
type Worldbook,
|
||||
type WorldbookEntry,
|
||||
type CreateWorldbookRequest,
|
||||
type CreateEntryRequest,
|
||||
} from '../api/worldbook';
|
||||
|
||||
const WorldbookManagePage: React.FC = () => {
|
||||
const [worldbooks, setWorldbooks] = useState<Worldbook[]>([]);
|
||||
const [selectedWorldbook, setSelectedWorldbook] = useState<Worldbook | null>(null);
|
||||
const [entries, setEntries] = useState<WorldbookEntry[]>([]);
|
||||
const [selectedEntry, setSelectedEntry] = useState<WorldbookEntry | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEntryModal, setShowEntryModal] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
// 表单状态
|
||||
const [worldbookForm, setWorldbookForm] = useState<CreateWorldbookRequest>({
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
const [entryForm, setEntryForm] = useState<CreateEntryRequest>({
|
||||
comment: '',
|
||||
content: '',
|
||||
keys: [],
|
||||
secondaryKeys: [],
|
||||
constant: false,
|
||||
enabled: true,
|
||||
useRegex: false,
|
||||
caseSensitive: false,
|
||||
matchWholeWords: false,
|
||||
selective: false,
|
||||
selectiveLogic: 0,
|
||||
position: 1,
|
||||
depth: 4,
|
||||
order: 100,
|
||||
probability: 100,
|
||||
scanDepth: 2,
|
||||
groupId: '',
|
||||
});
|
||||
|
||||
const [keyInput, setKeyInput] = useState('');
|
||||
const [secondaryKeyInput, setSecondaryKeyInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadWorldbooks();
|
||||
}, [searchKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedWorldbook) {
|
||||
loadEntries(selectedWorldbook.id);
|
||||
}
|
||||
}, [selectedWorldbook]);
|
||||
|
||||
const loadWorldbooks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getWorldbookList({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: searchKeyword,
|
||||
});
|
||||
setWorldbooks(response.data.list || []);
|
||||
} catch (error: any) {
|
||||
alert('加载世界书列表失败: ' + (error.response?.data?.msg || error.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEntries = async (worldbookId: number) => {
|
||||
try {
|
||||
const response = await getEntryList(worldbookId);
|
||||
setEntries(response.data.list || []);
|
||||
} catch (error: any) {
|
||||
alert('加载条目列表失败: ' + (error.response?.data?.msg || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorldbook = async () => {
|
||||
if (!worldbookForm.name.trim()) {
|
||||
alert('请输入世界书名称');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createWorldbook(worldbookForm);
|
||||
alert('创建成功');
|
||||
setShowCreateModal(false);
|
||||
setWorldbookForm({ name: '', description: '', isPublic: false });
|
||||
loadWorldbooks();
|
||||
} catch (error: any) {
|
||||
alert('创建失败: ' + (error.response?.data?.msg || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWorldbook = async (id: number) => {
|
||||
if (!confirm('确定要删除这个世界书吗?所有条目也会被删除。')) return;
|
||||
try {
|
||||
await deleteWorldbook(id);
|
||||
alert('删除成功');
|
||||
if (selectedWorldbook?.id === id) {
|
||||
setSelectedWorldbook(null);
|
||||
setEntries([]);
|
||||
}
|
||||
loadWorldbooks();
|
||||
} catch (error: any) {
|
||||
alert('删除失败: ' + (error.response?.data?.msg || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await importWorldbook(file);
|
||||
alert('导入成功');
|
||||
loadWorldbooks();
|
||||
} catch (error: any) {
|
||||
alert('导入失败: ' + (error.response?.data?.msg || error.message));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (id: number) => {
|
||||
try {
|
||||
const response = await exportWorldbook(id);
|
||||
const blob = new Blob([response.data], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `worldbook_${id}.json`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
alert('导出失败: ' + (error.response?.data?.msg || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEntry = async () => {
|
||||
if (!selectedWorldbook) return;
|
||||
if (!entryForm.content.trim()) {
|
||||
alert('请输入条目内容');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (selectedEntry) {
|
||||
await updateEntry(selectedWorldbook.id, selectedEntry.id, entryForm);
|
||||
alert('更新成功');
|
||||
} else {
|
||||
await createEntry(selectedWorldbook.id, entryForm);
|
||||
alert('创建成功');
|
||||
}
|
||||
setShowEntryModal(false);
|
||||
resetEntryForm();
|
||||
loadEntries(selectedWorldbook.id);
|
||||
} catch (error: any) {
|
||||
alert('保存失败: ' + (error.response?.data?.msg || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEntry = async (entryId: number) => {
|
||||
if (!selectedWorldbook) return;
|
||||
if (!confirm('确定要删除这个条目吗?')) return;
|
||||
try {
|
||||
await deleteEntry(selectedWorldbook.id, entryId);
|
||||
alert('删除成功');
|
||||
loadEntries(selectedWorldbook.id);
|
||||
} catch (error: any) {
|
||||
alert('删除失败: ' + (error.response?.data?.msg || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const openEntryModal = (entry?: WorldbookEntry) => {
|
||||
if (entry) {
|
||||
setSelectedEntry(entry);
|
||||
setEntryForm({
|
||||
comment: entry.comment,
|
||||
content: entry.content,
|
||||
keys: entry.keys,
|
||||
secondaryKeys: entry.secondaryKeys,
|
||||
constant: entry.constant,
|
||||
enabled: entry.enabled,
|
||||
useRegex: entry.useRegex,
|
||||
caseSensitive: entry.caseSensitive,
|
||||
matchWholeWords: entry.matchWholeWords,
|
||||
selective: entry.selective,
|
||||
selectiveLogic: entry.selectiveLogic,
|
||||
position: entry.position,
|
||||
depth: entry.depth,
|
||||
order: entry.order,
|
||||
probability: entry.probability,
|
||||
scanDepth: entry.scanDepth,
|
||||
groupId: entry.groupId,
|
||||
});
|
||||
} else {
|
||||
resetEntryForm();
|
||||
}
|
||||
setShowEntryModal(true);
|
||||
};
|
||||
|
||||
const resetEntryForm = () => {
|
||||
setSelectedEntry(null);
|
||||
setEntryForm({
|
||||
comment: '',
|
||||
content: '',
|
||||
keys: [],
|
||||
secondaryKeys: [],
|
||||
constant: false,
|
||||
enabled: true,
|
||||
useRegex: false,
|
||||
caseSensitive: false,
|
||||
matchWholeWords: false,
|
||||
selective: false,
|
||||
selectiveLogic: 0,
|
||||
position: 1,
|
||||
depth: 4,
|
||||
order: 100,
|
||||
probability: 100,
|
||||
scanDepth: 2,
|
||||
groupId: '',
|
||||
});
|
||||
setKeyInput('');
|
||||
setSecondaryKeyInput('');
|
||||
};
|
||||
|
||||
const addKey = () => {
|
||||
if (keyInput.trim()) {
|
||||
setEntryForm({
|
||||
...entryForm,
|
||||
keys: [...(entryForm.keys || []), keyInput.trim()],
|
||||
});
|
||||
setKeyInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeKey = (index: number) => {
|
||||
setEntryForm({
|
||||
...entryForm,
|
||||
keys: entryForm.keys?.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
const addSecondaryKey = () => {
|
||||
if (secondaryKeyInput.trim()) {
|
||||
setEntryForm({
|
||||
...entryForm,
|
||||
secondaryKeys: [...(entryForm.secondaryKeys || []), secondaryKeyInput.trim()],
|
||||
});
|
||||
setSecondaryKeyInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeSecondaryKey = (index: number) => {
|
||||
setEntryForm({
|
||||
...entryForm,
|
||||
secondaryKeys: entryForm.secondaryKeys?.filter((_, i) => i !== index),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-white">世界书管理</h1>
|
||||
<div className="flex gap-3">
|
||||
<label className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg cursor-pointer transition-colors">
|
||||
导入世界书
|
||||
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
创建世界书
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 世界书列表 */}
|
||||
<div className="lg:col-span-1 bg-white/10 backdrop-blur-md rounded-xl p-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索世界书..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 mb-4"
|
||||
/>
|
||||
<div className="space-y-2 max-h-[calc(100vh-250px)] overflow-y-auto">
|
||||
{worldbooks.map((wb) => (
|
||||
<div
|
||||
key={wb.id}
|
||||
onClick={() => setSelectedWorldbook(wb)}
|
||||
className={`p-3 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedWorldbook?.id === wb.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-white/5 hover:bg-white/10 text-white/80'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{wb.name}</div>
|
||||
<div className="text-sm opacity-70">{wb.entryCount} 条目</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExport(wb.id);
|
||||
}}
|
||||
className="text-xs px-2 py-1 bg-blue-500 hover:bg-blue-600 rounded"
|
||||
>
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteWorldbook(wb.id);
|
||||
}}
|
||||
className="text-xs px-2 py-1 bg-red-500 hover:bg-red-600 rounded"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 条目列表 */}
|
||||
<div className="lg:col-span-2 bg-white/10 backdrop-blur-md rounded-xl p-4">
|
||||
{selectedWorldbook ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-white">{selectedWorldbook.name}</h2>
|
||||
<button
|
||||
onClick={() => openEntryModal()}
|
||||
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
添加条目
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-[calc(100vh-250px)] overflow-y-auto">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="bg-white/5 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-white">
|
||||
{entry.comment || '未命名条目'}
|
||||
</div>
|
||||
<div className="text-sm text-white/60 mt-1">
|
||||
关键词: {entry.keys.join(', ') || '无'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => openEntryModal(entry)}
|
||||
className="text-xs px-3 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteEntry(entry.id)}
|
||||
className="text-xs px-3 py-1 bg-red-500 hover:bg-red-600 text-white rounded"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-white/70 line-clamp-2">{entry.content}</div>
|
||||
<div className="flex gap-2 mt-2 text-xs text-white/50">
|
||||
{entry.constant && <span className="px-2 py-1 bg-yellow-500/20 rounded">常驻</span>}
|
||||
{!entry.enabled && <span className="px-2 py-1 bg-red-500/20 rounded">已禁用</span>}
|
||||
{entry.useRegex && <span className="px-2 py-1 bg-blue-500/20 rounded">正则</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-white/50">
|
||||
请选择一个世界书
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创建世界书模态框 */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-slate-800 rounded-xl p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold text-white mb-4">创建世界书</h2>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="世界书名称"
|
||||
value={worldbookForm.name}
|
||||
onChange={(e) => setWorldbookForm({ ...worldbookForm, name: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="描述(可选)"
|
||||
value={worldbookForm.description}
|
||||
onChange={(e) => setWorldbookForm({ ...worldbookForm, description: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white h-24"
|
||||
/>
|
||||
<label className="flex items-center text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={worldbookForm.isPublic}
|
||||
onChange={(e) => setWorldbookForm({ ...worldbookForm, isPublic: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
公开世界书
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleCreateWorldbook}
|
||||
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 条目编辑模态框 */}
|
||||
{showEntryModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-slate-800 rounded-xl p-6 w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold text-white mb-4">
|
||||
{selectedEntry ? '编辑条目' : '创建条目'}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="条目标题/备注"
|
||||
value={entryForm.comment}
|
||||
onChange={(e) => setEntryForm({ ...entryForm, comment: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="条目内容 *"
|
||||
value={entryForm.content}
|
||||
onChange={(e) => setEntryForm({ ...entryForm, content: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white h-32"
|
||||
/>
|
||||
|
||||
{/* 关键词 */}
|
||||
<div>
|
||||
<label className="text-white text-sm mb-2 block">主关键词</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入关键词后按回车"
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addKey()}
|
||||
className="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
<button onClick={addKey} className="px-4 py-2 bg-blue-600 rounded-lg text-white">
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{entryForm.keys?.map((key, index) => (
|
||||
<span key={index} className="px-3 py-1 bg-blue-500/20 text-white rounded-full text-sm flex items-center gap-2">
|
||||
{key}
|
||||
<button onClick={() => removeKey(index)} className="text-red-400 hover:text-red-300">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 次要关键词 */}
|
||||
<div>
|
||||
<label className="text-white text-sm mb-2 block">次要关键词(可选)</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入次要关键词后按回车"
|
||||
value={secondaryKeyInput}
|
||||
onChange={(e) => setSecondaryKeyInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addSecondaryKey()}
|
||||
className="flex-1 px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
<button onClick={addSecondaryKey} className="px-4 py-2 bg-blue-600 rounded-lg text-white">
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{entryForm.secondaryKeys?.map((key, index) => (
|
||||
<span key={index} className="px-3 py-1 bg-purple-500/20 text-white rounded-full text-sm flex items-center gap-2">
|
||||
{key}
|
||||
<button onClick={() => removeSecondaryKey(index)} className="text-red-400 hover:text-red-300">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选项 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<label className="flex items-center text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entryForm.constant}
|
||||
onChange={(e) => setEntryForm({ ...entryForm, constant: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
常驻注入
|
||||
</label>
|
||||
<label className="flex items-center text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entryForm.enabled}
|
||||
onChange={(e) => setEntryForm({ ...entryForm, enabled: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
<label className="flex items-center text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entryForm.useRegex}
|
||||
onChange={(e) => setEntryForm({ ...entryForm, useRegex: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
使用正则
|
||||
</label>
|
||||
<label className="flex items-center text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entryForm.caseSensitive}
|
||||
onChange={(e) => setEntryForm({ ...entryForm, caseSensitive: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
区分大小写
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 高级选项 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-white text-sm mb-1 block">触发概率 (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={entryForm.probability}
|
||||
onChange={(e) => setEntryForm({ ...entryForm, probability: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-white text-sm mb-1 block">扫描深度</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={entryForm.scanDepth}
|
||||
onChange={(e) => setEntryForm({ ...entryForm, scanDepth: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleSaveEntry}
|
||||
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEntryModal(false);
|
||||
resetEntryForm();
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorldbookManagePage;
|
||||
Reference in New Issue
Block a user