| 属性 | 值 |
|---|---|
| 文档版本 | V2.0 |
| 作者 | Claude Code |
| 创建日期 | 2025-12-01 |
| 最后更新 | 2025-12-02 |
| 状态 | ✅ 已完成实现 |
| 版本 | 日期 | 作者 | 变更说明 |
|---|---|---|---|
| V1.0 | 2025-12-01 | Claude Code | 初始版本 - 服务端自动压缩方案 |
| V1.1 | 2025-12-01 | Claude Code | 审查修订 - 修复文件路径、代码变量冲突、补充 VIP 校验逻辑 |
| V1.2 | 2025-12-01 | Claude Code | 二次审查修订 - 补充完整 getModelAdd()、明确字段取舍、更新快速参考表 |
| V1.3 | 2025-12-01 | Claude Code | 新增第三触发条件 - 距上次 Summary 超过 20 条消息触发压缩 |
| V2.0 | 2025-12-02 | Claude Code | 最终实现版本 - 新增第四触发条件 (summary_trimmed)、移除 VIP 限制、移除 CompactPrompt 自定义(改用服务端 i18n)、新增可折叠 Summary UI、新增用户积分扣除、移除手动压缩指令 |
V1.0 客户端方案存在关键缺陷:信息链断裂
┌─────────────────────────────────────────────────────────────────┐ │ 问题场景: │ │ │ │ 1. 客户端在第 N 轮触发压缩 → 生成 Summary#1 │ │ 2. 对话继续,服务端因上下文限制裁剪了 Summary#1 │ │ 3. 客户端在第 2N 轮再次触发压缩 → 但 Summary#1 已不在上下文中 │ │ 4. 结果:早期对话信息完全丢失,AI 无法感知完整历史 │ └─────────────────────────────────────────────────────────────────┘
客户端无法准确判断触发时机
固定轮数阈值不适配动态上下文
modelAdd 倍数将压缩逻辑移至服务端,在最佳时机自动触发:
┌─────────────────────────────────────────────────────────────────┐ │ 新方案:服务端精准触发(四重条件,满足任一即触发) │ │ │ │ 条件 1: Summary 已被裁剪 → 立即触发新 Summary │ │ (原始 chats 无 Summary,补充查询到被裁剪的 Summary) │ │ │ │ 条件 2: 没有 Summary 且即将首次裁剪 → 触发 Summary#1 │ │ (totalChars > limitLength * 0.8) │ │ │ │ 条件 3: Summary 即将被裁剪 → 触发 Summary#2 │ │ (Summary#2 包含 Summary#1 的关键信息) │ │ │ │ 条件 4: 距离上次 Summary 超过 20 条消息 → 触发新 Summary │ │ (防护性逻辑,防止长时间不触发) │ │ │ │ 结果: 信息链永不断裂,早期历史始终被保留在最新 Summary 中 │ └─────────────────────────────────────────────────────────────────┘
| 优先级 | 需求描述 | 验收标准 | 实现状态 |
|---|---|---|---|
| P0 必须 | 服务端自动检测压缩时机 | 在上下文即将被裁剪前自动触发压缩 | ✅ 已完成 |
| P0 必须 | Summary 连续性保证 | 新 Summary 必须包含旧 Summary 的关键信息 | ✅ 已完成 |
| P0 必须 | 客户端开关控制 | 用户可开启/关闭自动压缩功能 | ✅ 已完成 |
| ❌ 已移除 (所有用户可用) | |||
| P1 应该 | Summary 消息标识 | Summary 消息需有特殊标识,便于识别和处理 | ✅ 已完成 (可折叠 UI) |
| P1 应该 | 压缩状态反馈 | 客户端能感知压缩正在进行 | ✅ 已完成 (SSE 事件) |
| ❌ 已移除 (服务端 i18n) | |||
| P1 新增 | 用户积分扣除 | 压缩操作扣除用户积分 | ✅ 已完成 |
本表用于代码审查时快速定位核心组件,审查者可通过遍历表中所有组件建立功能理解。
| 核心组件/配置 | 实现状态 | 文件路径 | 核心功能/用途 | 关键代码位置 |
|---|---|---|---|---|
| 后端文件 | ||||
chat.go | ✅ 已完成 | lunatalk-server/model/characterModel/ | 压缩触发检测、Summary 存储 | CheckCompactNeed(), getModelAdd(), InsertSummaryChat(), CompactCheckResult |
character_router.go | ✅ 已完成 | lunatalk-server/router/ | 压缩调度、SSE 事件、AI 调用、积分扣除 | handleAutoCompact(), buildCompactPromptServer(), callAIForSummaryServer(), sendCompactSSEEvent() |
userRole.go | ✅ 已完成 | lunatalk-server/model/characterModel/ | 用户自动压缩配置存储 | AutoCompactEnabled 字段 |
languate.go | ✅ 已完成 | lunatalk-server/common/ | 多语言压缩提示词 | auto_compact_default_prompt, auto_compact_chain_prefix |
chatApi.go | ✅ 已完成 | lunatalk-server/characterai/ | 统一 API 路由调用 | ChatCompletions_Direct(), GetModelAPIConfig() |
| 前端页面文件 | ||||
chat.vue | ✅ 已完成 | src/pages/chat/ | SSE 事件处理、可折叠 Summary UI | handlerMessage(), toggleSummaryExpand(), highlightText() (isSummary 处理) |
modelSelect.vue | ✅ 已完成 | src/pages/chat/ | 自动压缩开关(无 VIP 限制) | autoCompactSwitchChange() |
| 前端 Mixin 文件 | ||||
UserDefineMixin.js | ✅ 已完成 | src/mixins/ | autoCompactEnabled 字段同步 | formData.autoCompactEnabled, setUserDefine(), getUserDefine() |
| 国际化 | ||||
zh-Hans.json | ✅ 已完成 | src/locale/ | 中文简体文案 | chat.compacting, chat.compactSuccess, chat.compactFailed, chat.summaryLabel, modelSelect.autoCompactV2Tips |
zh-Hant.json | ✅ 已完成 | src/locale/ | 中文繁体文案 | 同上 |
en.json | ✅ 已完成 | src/locale/ | 英文文案 | 同上 |
uni-app.ja.json | ✅ 已完成 | src/locale/ | 日文文案 | 同上 |
uni-app.ko.json | ✅ 已完成 | src/locale/ | 韩文文案 | 同上 |
| 数据库 | ||||
chat 表 | ✅ 已完成 | 数据库 | isSummary 字段 | ALTER TABLE chat ADD COLUMN isSummary BOOLEAN DEFAULT FALSE |
userrole 表 | ✅ 已完成 | 数据库 | autoCompactEnabled 字段 | ALTER TABLE userrole ADD COLUMN autoCompactEnabled BOOLEAN DEFAULT FALSE |
| 已移除功能 | ||||
| 手动压缩指令 | ❌ 已移除 | src/pages/chat/chat.vue | 预设指令中的 “压缩上下文” | 从 presetCommands 数组中移除 |
| VIP 限制 | ❌ 已移除 | 服务端 + 客户端 | VIP 校验逻辑 | 服务端 handleAutoCompact() 和客户端 modelSelect.vue |
| CompactPrompt 自定义 | ❌ 已移除 | 全部 | 用户自定义压缩提示词 | 改用服务端 i18n 文件 |
flowchart TB subgraph Client["前端 (uni-app)"] ChatPage["chat.vue<br/>聊天主页面"] ModelSelect["modelSelect.vue<br/>模型选择页面<br/>(设置入口)"] ChatPage --> ModelSelect end subgraph Backend["后端服务 (Go)"] ChatHandler["character_router.go<br/>聊天处理器<br/>✅ 核心压缩调度逻辑"] ChatModel["chat.go<br/>对话模型<br/>✅ 压缩触发检测"] AIService["AI 服务层<br/>调用 LLM 生成 Summary"] ChatHandler --> ChatModel ChatHandler --> AIService end subgraph Storage["数据存储"] ChatDB["chat 表<br/>✅ 新增 isSummary 字段<br/>标识 Summary 消息"] end Client <--> ChatHandler ChatModel <--> ChatDB AIService --> ChatDB
架构说明:
sequenceDiagram participant User as 用户 participant Client as 客户端 participant Handler as chatHandler participant Model as chat.go participant AI as AI 模型 participant DB as 数据库 User->>Client: 发送消息 Client->>Handler: POST /chat<br/>(autoCompactEnabled=true) Handler->>Model: GetChatUseHistory()<br/>获取历史 + 检测压缩需求 Model->>DB: 查询对话历史 DB-->>Model: 返回消息列表 Model->>Model: 计算上下文使用量<br/>检测 Summary 位置<br/>判断是否需要压缩 Model-->>Handler: 返回 (messages, needCompact) alt needCompact = true Handler->>Client: SSE: event=compacting<br/>通知压缩开始 Handler->>AI: 发送压缩提示词<br/>(包含历史对话) AI-->>Handler: 返回 Summary 内容 Handler->>DB: 保存 Summary<br/>(isSummary=true) Handler->>Client: SSE: event=compactDone<br/>压缩完成 end Handler->>AI: 发送用户消息<br/>(上下文包含最新 Summary) AI-->>Handler: 流式返回回复 Handler-->>Client: SSE: event=answer<br/>流式返回 Client-->>User: 显示 AI 回复
flowchart TD A[获取对话历史] --> B{autoCompactEnabled?} B -->|否| Z[返回历史,不压缩] B -->|是| C[计算上下文限制<br/>limitLength = 4000 * modelAdd * context] C --> D[补充查询被裁剪的 Summary] D --> E{原始 chats 有 Summary?<br/>但补充查询到了 Summary?} E -->|是: Summary 被裁剪| F1[needCompact = true<br/>reason: summary_trimmed] E -->|否| F[查找最近 Summary 位置] F --> G{存在 Summary?} G -->|否| H{条件2: 即将首次裁剪?<br/>totalChars > limitLength * 0.8} H -->|是| I[needCompact = true<br/>reason: first_compact] H -->|否| Z G -->|是| J{条件3: Summary 即将被裁剪?<br/>lastSummaryIndex >= cutoffIndex} J -->|是| K[needCompact = true<br/>reason: summary_expiring] J -->|否| L{条件4: 距上次 Summary<br/>超过 20 条消息?} L -->|是| M[needCompact = true<br/>reason: message_count_exceeded] L -->|否| Z F1 --> N[返回 needCompact=true] I --> N K --> N M --> N
四重触发条件说明:
| 条件 | Reason | 触发场景 | 优先级 |
|---|---|---|---|
| 条件 1 | summary_trimmed | 原始 chats 中没有 Summary,但补充查询发现有被裁剪的 Summary | 最高 |
| 条件 2 | first_compact | 没有 Summary,且当前字符数超过限制的 80% | 高 |
| 条件 3 | summary_expiring | 有 Summary,且 Summary 即将被裁剪出上下文窗口 | 中 |
| 条件 4 | message_count_exceeded | 距离上次 Summary 超过 20 条消息(防护性逻辑) | 低 |
classDiagram class Chat { <<entity>> +int64 ID +string ConversationId +string ChatId +string ChatRole +string ChatMessage +bool IsFirst +bool IsComplete +string MemId +bool IsPinned +bool IsSummary ← 新增 +time.Time CreateTime +time.Time LastUpdateTime } note for Chat "新增字段:<br/>IsSummary: bool<br/>标识该消息是否为自动生成的 Summary<br/>用于压缩触发检测和前端样式区分"
classDiagram class ChatHistoryResult { <<struct>> +[]Chat Chats +bool NeedCompact ← 新增 +int LastSummaryIndex ← 新增 } note for ChatHistoryResult "扩展返回值:<br/>- NeedCompact: 是否需要触发压缩<br/>- LastSummaryIndex: 最近 Summary 的索引位置<br/> (-1 表示没有 Summary)"
classDiagram class SSEEvents { <<events>> +answer: 普通回复内容 +error: 错误信息 +compacting: 压缩开始 ← 新增 +compactDone: 压缩完成 ← 新增 } note for SSEEvents "新增 SSE 事件:<br/>- compacting: 通知客户端压缩开始<br/>- compactDone: 通知客户端压缩完成<br/> 携带 summaryId 供前端定位"
// CompactCheckResult 压缩检测结果 type CompactCheckResult struct { NeedCompact bool // 是否需要压缩 Reason string // 压缩原因: "summary_trimmed" | "first_compact" | "summary_expiring" | "message_count_exceeded" LastSummaryIndex int // 最近 Summary 索引 (-1 表示无) MessagesSinceSummary int // 距上次 Summary 的消息数 CurrentUsage int // 当前上下文字符使用量 Limit int // 上下文字符限制 } // 触发阈值常量 const ( CharThresholdRatio = 0.8 // 字符数阈值比例(80%) MaxMessagesSinceSummary = 20 // 最大消息数阈值(20条) )
// UserRole 用户角色配置(服务端需接收) // 注意:CompactPrompt 已移除,压缩提示词从服务端 i18n 文件获取 type UserRole struct { // ... 现有字段 AutoCompactEnabled bool `json:"autoCompactEnabled"` // 是否开启自动压缩 // CompactPrompt 已移除 - 改用服务端 i18n 文件 }
步骤 1:扩展 Chat 结构体(新增 IsSummary 字段)
⚠️ 重要:必须先添加此字段,并执行数据库迁移(见 9.2 节),才能部署后续代码。
// 在现有 Chat 结构体中新增 IsSummary 字段 type Chat struct { // ... 现有字段保持不变 ... ID int64 `ddb:"id" json:"id" gorm:"column:id" structs:"id"` ConversationId string `ddb:"conversationId" json:"conversationId" gorm:"column:conversationId" structs:"conversationId"` ChatId string `ddb:"chatId" json:"chatId" gorm:"column:chatId" structs:"chatId"` ChatRole string `ddb:"chatRole" json:"chatRole" gorm:"column:chatRole" structs:"chatRole"` ChatMessage string `ddb:"chatMessage" json:"chatMessage" gorm:"column:chatMessage" structs:"chatMessage"` IsFirst bool `ddb:"isFirst" json:"isFirst" gorm:"column:isFirst" structs:"isFirst"` IsComplete bool `ddb:"isComplete" json:"isComplete" gorm:"column:isComplete" structs:"isComplete"` MemId string `ddb:"memId" json:"memId" gorm:"column:memId" structs:"memId"` IsPinned bool `ddb:"isPinned" json:"isPinned" gorm:"column:isPinned" structs:"isPinned"` IsSummary bool `ddb:"isSummary" json:"isSummary" gorm:"column:isSummary" structs:"isSummary"` // ← 新增 CreateTime *time.Time `ddb:"createTime" json:"createTime" gorm:"column:createTime"` LastUpdateTime *time.Time `ddb:"lastUpdateTime" json:"lastUpdateTime" gorm:"column:lastUpdateTime"` }
步骤 2:提取 modelAdd 计算逻辑为独立函数
当前
modelAdd计算逻辑内联在GetChatUseHistory()函数中(第 166-238 行),需提取为独立函数以便复用。
// getModelAdd 根据模型名称返回对应的 modelAdd 倍数 // 提取自现有 GetChatUseHistory() 函数的 modelAdd 计算逻辑 func getModelAdd(model string) int { modelAdd := 1 // 基础模型判断 if model == "claude" || model == "claude3.5new" || model == "claude-3-7-sonnet-20250219" || strings.Contains(model, "gemini") || strings.Contains(model, "claude3.5new-aws") || strings.Contains(model, "deepseek") || strings.Contains(model, "qwen") || strings.Contains(model, "yi:34b") || strings.Contains(model, "claude3Opus") || model == "windsurf/gpt4-o3-mini" || model == "claude-nonfsw" { modelAdd = 2 } // DeepSeek 系列 if strings.Contains(model, "deepseek") { modelAdd = 4 } // Gemini 系列 if strings.Contains(model, "gemini") { modelAdd = 4 } if strings.Contains(model, "gemini-2.5-pro") || strings.Contains(model, "wolfstride") || strings.Contains(model, "gemini-2.5-pro-free") || strings.Contains(model, "gemini-2.5-flash") { modelAdd = 4 } if strings.Contains(model, "gemini-3-pro-preview") { modelAdd = 4 } // 其他国产/第三方模型 if strings.Contains(model, "doubao") { modelAdd = 4 } if strings.Contains(model, "grok") { modelAdd = 4 } if strings.Contains(model, "kimi") { modelAdd = 4 } if strings.Contains(model, "qwen") { modelAdd = 4 } if strings.Contains(model, "gpt") { modelAdd = 4 } if strings.Contains(model, "zai-org/GLM-4.6") { modelAdd = 4 } if strings.Contains(model, "MiniMaxAI/MiniMax-M2") { modelAdd = 4 } // Claude 4.x 系列(主流模型) if model == "claude" || model == "claude3.5new" || model == "claude-3-7-sonnet-20250219" || model == "claude-3-7-sonnet-20250219-thinking" || model == "claude-sonnet-4-5-20250929" || model == "claude-haiku-4-5-20251001" || model == "claude-opus-4-5-20251101" { modelAdd = 4 } // Azure 托管模型 if model == "claude-3-7-sonnet-20250219-azure" { modelAdd = 3 } if model == "claude-opus-4-20250514-azure" { modelAdd = 3 } // 高级/GCP 托管模型 if model == "claude3.5new-high" || model == "claude-3-7-sonnet-20250219-high" || model == "claude-3-7-sonnet-20250219-thinking-high" || model == "claude-opus-4-5-20251101-high" || model == "claude-sonnet-4-5-20250929-gcp" || model == "claude-sonnet-4-5-20250929-high" || model == "claude-3-7-sonnet-20250219-gcp" || model == "claude-3-7-sonnet-20250219-thinking-high-gcp" || model == "claude3.5new-gcp" { modelAdd = 3 } // 最终覆盖 if strings.Contains(model, "qwen") || strings.Contains(model, "yi:34b") { modelAdd = 4 } return modelAdd }
步骤 3:新增压缩检测函数
// CheckCompactNeed 检测是否需要触发压缩 // 参数: // - ctx: 上下文 // - conversationId: 对话ID // - contextMultiplier: 记忆轮数设置 (1-5 或 100=MAX) // - model: 模型名称 // - autoCompactEnabled: 是否开启自动压缩 // 返回: // - chats: 对话历史 // - result: 压缩检测结果 func CheckCompactNeed(ctx context.Context, conversationId string, contextMultiplier float64, model string, autoCompactEnabled bool) ([]Chat, *CompactCheckResult, error) { // 1. 获取原始对话历史(复用现有逻辑) chats, err := GetChatUseHistory(ctx, conversationId, contextMultiplier, model) if err != nil { return nil, nil, err } result := &CompactCheckResult{ NeedCompact: false, LastSummaryIndex: -1, } // 2. 未开启自动压缩,直接返回 if !autoCompactEnabled { return chats, result, nil } // 3. 计算上下文限制(避免变量名冲突) modelAdd := getModelAdd(model) ctxValue := contextMultiplier if ctxValue == 100 { ctxValue = 15 } limitLength := 4000 * modelAdd * int(ctxValue) result.Limit = limitLength // 4. 查找最近的 Summary 位置,并计算距上次 Summary 的消息数 for i, chat := range chats { if chat.IsSummary { result.LastSummaryIndex = i result.MessagesSinceSummary = i // 索引即为距离 Summary 的消息数(chats 按时间倒序) break } } // 如果没有找到 Summary,则所有消息都算作距离 if result.LastSummaryIndex < 0 { result.MessagesSinceSummary = len(chats) } // 5. 计算当前使用量 totalChars := 0 cutoffIndex := 0 for i, chat := range chats { totalChars += len(chat.ChatMessage) if totalChars > limitLength { cutoffIndex = i break } } result.CurrentUsage = totalChars // 6. 判断是否需要压缩(三重条件,满足任一即触发) if result.LastSummaryIndex < 0 { // 没有 Summary,检查条件 1: 即将首次裁剪(80% 阈值预留缓冲) if float64(totalChars) > float64(limitLength)*CharThresholdRatio { result.NeedCompact = true result.Reason = "first_compact" } } else { // 有 Summary,检查条件 2: Summary 即将被裁剪 if result.LastSummaryIndex >= cutoffIndex && cutoffIndex > 0 { result.NeedCompact = true result.Reason = "summary_expiring" } } // 条件 3: 距上次 Summary 超过 20 条消息(防护性逻辑) if !result.NeedCompact && result.MessagesSinceSummary >= MaxMessagesSinceSummary { result.NeedCompact = true result.Reason = "message_count_exceeded" } return chats, result, nil }
新增函数:保存 Summary 消息
// InsertSummaryChat 保存 Summary 消息 func InsertSummaryChat(ctx context.Context, conversationId string, summary string) (string, error) { session := database.GormClient.WithContext(ctx) chat := Chat{ ConversationId: conversationId, ChatId: uuid.NewString(), ChatMessage: summary, ChatRole: ChatAIROLE, IsSummary: true, // 标记为 Summary } now := time.Now() chat.CreateTime = &now chat.LastUpdateTime = &now if err := session.Table(ChatTABLE).Create(&chat).Error; err != nil { return "", err } return chat.ChatId, nil }
📍 插入位置:在聊天处理函数中,获取对话历史之后、发送 AI 请求之前插入压缩检测逻辑。
核心处理流程
func HandleChat(c *gin.Context) { // ... 解析请求参数 ... // ... 获取用户信息 info ... // 1. 检测压缩需求 chats, compactResult, err := characterModel.CheckCompactNeed( ctx, conversationId, contextMultiplier, model, userDefine.AutoCompactEnabled) if err != nil { // 错误处理 return } // 2. 需要压缩时,先执行压缩 if compactResult.NeedCompact { // 2.1 VIP 权限校验(P0 需求) if !info.IsMember && !info.IsTryMember { sendSSEEvent(c, "error", map[string]interface{}{ "code": "VIP_REQUIRED", "message": "自动压缩功能需要 VIP 会员", }) // 继续正常对话,但不执行压缩 } else { // 2.2 发送 SSE 事件通知客户端 sendSSEEvent(c, "compacting", map[string]interface{}{ "reason": compactResult.Reason, }) // 2.3 构造压缩提示词 compactPrompt := buildCompactPrompt(userDefine.CompactPrompt, locale) // 2.4 调用 AI 生成 Summary(设置 30 秒超时) summary, err := callAIForSummary(chats, compactPrompt, model) if err != nil { // 压缩失败,记录日志但继续正常对话 log.Printf("[AutoCompact] 压缩失败: %v", err) sendSSEEvent(c, "compactFailed", map[string]interface{}{ "error": err.Error(), }) } else { // 2.5 保存 Summary summaryId, _ := characterModel.InsertSummaryChat(ctx, conversationId, summary) // 2.6 通知客户端压缩完成 sendSSEEvent(c, "compactDone", map[string]interface{}{ "summaryId": summaryId, }) } // 2.7 重新获取历史(包含新 Summary) chats, _, _ = characterModel.CheckCompactNeed( ctx, conversationId, contextMultiplier, model, false) } } // 3. 正常处理用户消息 // ... 现有逻辑 ... }
📍 位置:
handlerMessage()方法中,约 2975-3003 行
// SSE 事件处理 - 压缩相关事件 } else if (eventName === 'compacting') { // 服务端自动压缩开始 const dataLine = lines[++i].trim(); const eventData = JSON.parse(dataLine.slice(6)); console.log('[AutoCompact] 服务端触发压缩,原因:', eventData.reason); uni.showLoading({ title: uni.$t('chat.compacting'), mask: false }); } else if (eventName === 'compactDone') { // 服务端自动压缩完成 const dataLine = lines[++i].trim(); const eventData = JSON.parse(dataLine.slice(6)); console.log('[AutoCompact] 压缩完成,summaryId:', eventData.summaryId); uni.hideLoading(); uni.showToast({ title: uni.$t('chat.compactSuccess'), icon: 'success' }); } else if (eventName === 'compactFailed') { // 服务端自动压缩失败 const dataLine = lines[++i].trim(); const eventData = JSON.parse(dataLine.slice(6)); console.log('[AutoCompact] 压缩失败:', eventData.error); uni.hideLoading(); uni.showToast({ title: uni.$t('chat.compactFailed'), icon: 'none' }); }
📍 位置:chat.vue 模板中消息渲染部分,约 63-75 行
<!-- Summary 消息:可折叠设计 --> <view v-if="item.isSummary" class="summary-container"> <!-- 折叠头部(始终显示) --> <view class="summary-header-inline" @click="toggleSummaryExpand(index)"> <text class="summary-icon">📋</text> <text class="summary-label">{{ $t('chat.summaryLabel') }}</text> <text class="summary-expand-arrow">{{ item.summaryExpanded ? '▼' : '▶' }}</text> </view> <!-- 展开内容 --> <view v-show="item.summaryExpanded" class="summary-content" @longpress="onLongpress(index)" @click="onLongpress(index)"> <view v-html="renderMarkdown(item)"></view> </view> </view>
样式定义:
.summary-container { padding: 16rpx; } .summary-header-inline { display: flex; align-items: center; cursor: pointer; padding: 8rpx 0; } .summary-icon { font-size: 32rpx; margin-right: 12rpx; } .summary-label { color: #FED880; font-size: 28rpx; font-weight: 500; } .summary-expand-arrow { margin-left: auto; color: #FED880; font-size: 24rpx; } .summary-content { margin-top: 16rpx; padding: 16rpx; background: rgba(254, 216, 128, 0.1); border-radius: 12rpx; } .summary-message { background: linear-gradient(135deg, #2c2c2f 0%, #3a3a3d 100%); border-left: 3rpx solid #FED880; }
相关方法:
// 切换 Summary 展开/折叠状态 toggleSummaryExpand(index) { this.talkList[index].summaryExpanded = !this.talkList[index].summaryExpanded; this.$forceUpdate(); }
Summary 消息限制:
📍 位置:
src/mixins/UserDefineMixin.js
最终实现(已移除 compactPrompt,改用服务端 i18n):
// formData 定义 formData: { // ... 其他字段 autoCompactEnabled: false, // 是否开启自动压缩(同步到后端) // 注意:compactThreshold 在 V2.0 已废弃,由服务端动态判断触发时机 // 注意:compactPrompt 已移除,压缩提示词由服务端从 i18n 文件获取 }, // getUserDefine 中从服务端获取配置 getUserDefine() { this.http.get(this.requestUrl.getUserDefine, { data: { roleId: this.formData.roleId } }).then(res => { if (res.statusCode == 200) { // ... 其他字段 // 从服务端获取自动压缩配置(V2.0 服务端方案) _this.formData.autoCompactEnabled = res.data.autoCompactEnabled || false; } }); }
📍 位置:
src/pages/chat/modelSelect.vue
<!-- 自动压缩开关(无 VIP 限制) --> <view class="auto-compact-switch-row"> <view class="switch-label"> <text class="switch-text">{{$t('modelSelect.enableAutoCompact')}}</text> <!-- VIP 徽章已移除 --> </view> <fui-switch :checked="formData.autoCompactEnabled" @change="autoCompactSwitchChange" color="#FED880" :scaleRatio="0.9"> </fui-switch> </view>
// 开关切换(无 VIP 检查) autoCompactSwitchChange(e) { this.formData.autoCompactEnabled = e.detail.value; // V2.0: 配置会在点击"确认"时通过 setUserDefine() 保存到服务端 }
📍 位置:服务端
lunatalk-server/common/languate.go,通过GetLanTag(language, "auto_compact_default_prompt")获取
提示词特点:
📍 位置:
buildCompactPromptServer()函数
当检测到 reason = "summary_expiring" 或 reason = "summary_trimmed" 时,自动添加 auto_compact_chain_prefix 前缀:
func buildCompactPromptServer(reason string, language string) string { defaultPrompt := common.GetLanTag(language, "auto_compact_default_prompt") prompt := "" // 如果是连续压缩,添加特殊说明 if reason == "summary_expiring" { chainPrefix := common.GetLanTag(language, "auto_compact_chain_prefix") prompt += chainPrefix } prompt += defaultPrompt return prompt }
AI 生成的 Summary 可能包含 [CONTEXT_SUMMARY] 标签,存储前会自动清理:
// 清理 Summary 中的标签 summary = strings.ReplaceAll(summary, "[CONTEXT_SUMMARY]\n", "") summary = strings.ReplaceAll(summary, "\n[/CONTEXT_SUMMARY]", "") summary = strings.ReplaceAll(summary, "[CONTEXT_SUMMARY]", "") summary = strings.ReplaceAll(summary, "[/CONTEXT_SUMMARY]", "") summary = strings.TrimSpace(summary)
📍 位置:
handleAutoCompact()函数,约 4187-4192 行
压缩操作会消耗 API 成本,因此需要扣除用户积分:
// 扣除用户积分(压缩也消耗 API 成本) recordMName, recordScore := getScoreByModel(model, userRoleDefined.Context, accountId) if len(summary) > 5 { characterModel.CreateCharacterScoreRecord(ctx, accountId, "sub", "[自动上下文压缩] "+recordMName+" -"+strconv.Itoa(recordScore)) characterModel.SubScore(ctx, accountId, recordScore) }
扣费规则:
getScoreByModel)// #ifdef H5 // SSE 事件处理使用 EventSource // 压缩状态显示使用 CSS 动画 // #endif
// #ifdef APP-PLUS // SSE 事件处理使用 plus.net // 压缩状态显示使用原生 Loading // #endif
| Key | zh-Hans | zh-Hant | en | ja | ko |
|---|---|---|---|---|---|
| chat.summaryLabel | 上下文摘要 | 上下文摘要 | Context Summary | コンテキスト要約 | 컨텍스트 요약 |
| chat.compacting | 正在压缩上下文... | 正在壓縮上下文... | Compacting context... | コンテキストを圧縮中... | 컨텍스트 압축 중... |
| chat.compactSuccess | 压缩完成 | 壓縮完成 | Compact completed | 圧縮完了 | 압축 완료 |
| chat.compactFailed | 压缩失败,请稍后重试 | 壓縮失敗,請稍後重試 | Compact failed, please try again | 圧縮に失敗しました | 압축 실패 |
| modelSelect.autoCompact | 自动上下文压缩 | 自動上下文壓縮 | Auto Context Compact | 自動コンテキスト圧縮 | 자동 컨텍스트 압축 |
| modelSelect.enableAutoCompact | 启用自动压缩 | 啟用自動壓縮 | Enable Auto Compact | 自動圧縮を有効にする | 자동 압축 활성화 |
| modelSelect.autoCompactV2Tips | 开启后,服务端将智能判断... | 開啟後,服務端將智能判斷... | When enabled, the server will intelligently... | 有効にすると、サーバーが... | 활성화하면 서버가... |
📍 位置:
lunatalk-server/common/languate.go
| Key | 用途 | 支持语言 |
|---|---|---|
| auto_compact_default_prompt | 默认压缩提示词 | en/zh-Hans/zh-Hant/ja/ko |
| auto_compact_chain_prefix | 连续压缩前缀 | en/zh-Hans/zh-Hant/ja/ko |
| Key | 原用途 | 移除原因 |
|---|---|---|
| chat.compactRequiresVip | VIP 提示 | VIP 限制已移除 |
| chat.defaultCompactPrompt | 客户端压缩提示词 | 改用服务端 i18n |
| 风险类型 | 风险描述 | 影响程度 | 发生概率 | 应对措施 | 状态 |
|---|---|---|---|---|---|
| 技术风险 | AI 压缩请求超时 | 中 | 中 | 3 次重试 + 递增等待(1s, 2s) | ✅ 已实现 |
| 技术风险 | Summary 质量不稳定 | 中 | 中 | 优化的压缩提示词 + 分层压缩策略 | ✅ 已实现 |
| 性能风险 | 压缩增加响应延迟 | 中 | 高 | 压缩在 AI 回复后异步执行 (go handleAutoCompact()) | ✅ 已实现 |
| 兼容风险 | 现有对话无 Summary 标记 | 低 | 高 | 首次触发时正常生成 Summary | ✅ 已实现 |
| 数据风险 | Summary 内容过长 | 低 | 中 | 在提示词中限制输出长度 | ✅ 已实现 |
| 数据风险 | Summary 被裁剪后信息丢失 | 高 | 中 | 第四触发条件 summary_trimmed 检测 | ✅ 已实现 |
| 测试场景 | 测试步骤 | 预期结果 | 状态 |
|---|---|---|---|
| 首次压缩触发 | 1. 开启自动压缩 2. 对话至 80% 上下文 3. 发送消息 | 触发压缩,生成 Summary | ✅ |
| 连续性压缩 | 1. 已有 Summary 2. Summary 即将被裁剪 3. 发送消息 | 生成新 Summary,包含旧信息 | ✅ |
| 压缩开关关闭 | 1. 关闭自动压缩 2. 对话至上下文满 | 不触发压缩,正常裁剪 | ✅ |
| ❌ 已移除 | |||
| 压缩失败恢复 | 1. 模拟 AI 请求超时 | 3 次重试,失败后继续正常对话 | ✅ |
| Summary UI 折叠 | 1. 生成 Summary 2. 查看聊天记录 | Summary 默认折叠,点击可展开 | ✅ |
| 用户积分扣除 | 1. 触发压缩成功 | 扣除相应积分 | ✅ |
| 测试场景 | 测试步骤 | 预期结果 | 状态 |
|---|---|---|---|
| HTML 卡高输出 | 使用 HTML 卡角色,3-4 轮对话 | 正确触发压缩(按字符数判断) | ✅ |
| x1 小上下文 | 设置 x1,发送多条消息 | 约 16000 字符时触发 | ✅ |
| MAX 大上下文 | 设置 MAX,长对话 | 约 240000 * 0.8 字符时触发 | ✅ |
| 快速连续消息 | 压缩进行中发送新消息 | 新消息等待压缩完成后处理 | ✅ |
| Summary 被裁剪 | 长对话后 Summary 被裁剪出上下文 | 触发 summary_trimmed 条件 | ✅ |
| 阶段 | 内容 | 依赖 | 状态 |
|---|---|---|---|
| 1 | 数据库 chat 表添加 isSummary 字段 | - | ✅ 已完成 |
| 2 | 数据库 userrole 表添加 autoCompactEnabled 字段 | - | ✅ 已完成 |
| 3 | 后端 chat.go 添加压缩检测逻辑 | 阶段 1, 2 | ✅ 已完成 |
| 4 | 后端 character_router.go 添加压缩调度逻辑 | 阶段 3 | ✅ 已完成 |
| 5 | 后端 languate.go 添加多语言压缩提示词 | - | ✅ 已完成 |
| 6 | 后端 chatApi.go 添加统一 API 调用 | - | ✅ 已完成 |
| 7 | 前端 chat.vue 添加 SSE 事件处理和可折叠 Summary UI | 阶段 4 | ✅ 已完成 |
| 8 | 前端 modelSelect.vue 添加自动压缩开关(无 VIP 限制) | - | ✅ 已完成 |
| 9 | 前端 UserDefineMixin 确保配置同步 | 阶段 8 | ✅ 已完成 |
| 10 | 前端移除手动压缩指令 | 阶段 7 | ✅ 已完成 |
| 11 | 多语言 locale 文件更新 | 阶段 7, 8 | ✅ 已完成 |
-- 添加 isSummary 字段 ALTER TABLE chat ADD COLUMN isSummary BOOLEAN DEFAULT FALSE; -- 添加 autoCompactEnabled 字段 ALTER TABLE userrole ADD COLUMN autoCompactEnabled BOOLEAN DEFAULT FALSE; -- 添加索引(可选,提高查询效率) CREATE INDEX idx_chat_summary ON chat(conversationId, isSummary);
| 对比项 | V1.0 客户端方案 | V2.0 服务端方案 |
|---|---|---|
| 触发时机 | 固定轮数阈值 | 精准字符数检测 |
| 信息连续性 | ❌ 可能断裂 | ✅ 保证连续 |
| HTML 卡适配 | ❌ 无法适配 | ✅ 自动适配 |
| 客户端复杂度 | 高(追踪轮数/字符) | 低(仅传开关) |
| 后端改动 | 无 | 中等(新增检测逻辑) |
| 数据库改动 | 无 | 新增 isSummary 字段 |
消息数限制 = 10 * modelAdd * context 字符数限制 = 4000 * modelAdd * context 其中: - context: 1(x1), 2(x2), 3(x3), 4(x4), 5(x5), 15(MAX) - modelAdd: 根据模型不同,通常为 2-4 - Claude 4.5 系列: 4 - Gemini 2.5 Pro: 4 - 基础模型: 1-2
文档结束