服务端自动上下文压缩功能 (Server-Side Auto Context Compact) 技术设计文档


文档元数据

属性
文档版本V2.0
作者Claude Code
创建日期2025-12-01
最后更新2025-12-02
状态✅ 已完成实现

修订记录

版本日期作者变更说明
V1.02025-12-01Claude Code初始版本 - 服务端自动压缩方案
V1.12025-12-01Claude Code审查修订 - 修复文件路径、代码变量冲突、补充 VIP 校验逻辑
V1.22025-12-01Claude Code二次审查修订 - 补充完整 getModelAdd()、明确字段取舍、更新快速参考表
V1.32025-12-01Claude Code新增第三触发条件 - 距上次 Summary 超过 20 条消息触发压缩
V2.02025-12-02Claude Code最终实现版本 - 新增第四触发条件 (summary_trimmed)、移除 VIP 限制、移除 CompactPrompt 自定义(改用服务端 i18n)、新增可折叠 Summary UI、新增用户积分扣除、移除手动压缩指令

1. 需求背景

1.1 PRD 引用

1.2 业务背景

1.2.1 原方案问题

V1.0 客户端方案存在关键缺陷:信息链断裂

┌─────────────────────────────────────────────────────────────────┐
│ 问题场景:                                                        │
│                                                                   │
│ 1. 客户端在第 N 轮触发压缩 → 生成 Summary#1                        │
│ 2. 对话继续,服务端因上下文限制裁剪了 Summary#1                     │
│ 3. 客户端在第 2N 轮再次触发压缩 → 但 Summary#1 已不在上下文中       │
│ 4. 结果:早期对话信息完全丢失,AI 无法感知完整历史                   │
└─────────────────────────────────────────────────────────────────┘

1.2.2 根本原因

  1. 客户端无法准确判断触发时机

    • 不知道服务端何时会裁剪消息
    • 不知道当前上下文使用了多少字符
    • HTML 卡等内容输出量不可预测(3-4轮可能用满 x1 上下文)
  2. 固定轮数阈值不适配动态上下文

    • 不同记忆轮数设置(x1~MAX)对应不同的上下文窗口
    • 不同模型有不同的 modelAdd 倍数
    • 消息内容长度差异巨大

1.2.3 解决方案核心思路

将压缩逻辑移至服务端,在最佳时机自动触发:

┌─────────────────────────────────────────────────────────────────┐
│ 新方案:服务端精准触发(四重条件,满足任一即触发)                  │
│                                                                   │
│ 条件 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 中          │
└─────────────────────────────────────────────────────────────────┘

1.3 核心需求

优先级需求描述验收标准实现状态
P0 必须服务端自动检测压缩时机在上下文即将被裁剪前自动触发压缩✅ 已完成
P0 必须Summary 连续性保证新 Summary 必须包含旧 Summary 的关键信息✅ 已完成
P0 必须客户端开关控制用户可开启/关闭自动压缩功能✅ 已完成
P0 必须VIP 权限校验非 VIP 用户无法使用该功能❌ 已移除 (所有用户可用)
P1 应该Summary 消息标识Summary 消息需有特殊标识,便于识别和处理✅ 已完成 (可折叠 UI)
P1 应该压缩状态反馈客户端能感知压缩正在进行✅ 已完成 (SSE 事件)
P2 可选自定义压缩提示词用户可自定义压缩指令❌ 已移除 (服务端 i18n)
P1 新增用户积分扣除压缩操作扣除用户积分✅ 已完成

2. 快速参考表(代码审查专用)

本表用于代码审查时快速定位核心组件,审查者可通过遍历表中所有组件建立功能理解。

核心组件/配置实现状态文件路径核心功能/用途关键代码位置
后端文件
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 UIhandlerMessage(), 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 文件

3. 技术方案设计

3.1 整体架构

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

架构说明

  • 核心逻辑在服务端:压缩触发检测、Summary 生成、消息存储
  • 客户端职责简化:仅传递开关配置、显示压缩状态、渲染 Summary 消息
  • 精准触发时机:服务端拥有完整上下文信息,可精确判断何时需要压缩
  • 信息链连续性:Summary 即将被裁剪前触发新压缩,确保信息不丢失

3.2 核心流程

3.2.1 自动压缩触发流程

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 回复

3.2.2 压缩触发条件判断(四重条件)

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触发场景优先级
条件 1summary_trimmed原始 chats 中没有 Summary,但补充查询发现有被裁剪的 Summary最高
条件 2first_compact没有 Summary,且当前字符数超过限制的 80%
条件 3summary_expiring有 Summary,且 Summary 即将被裁剪出上下文窗口
条件 4message_count_exceeded距离上次 Summary 超过 20 条消息(防护性逻辑)

3.3 接口定义

3.3.1 数据库 Chat 表扩展

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/>用于压缩触发检测和前端样式区分"

3.3.2 GetChatUseHistory 返回值扩展

classDiagram
    class ChatHistoryResult {
        <<struct>>
        +[]Chat Chats
        +bool NeedCompact   新增
        +int LastSummaryIndex   新增
    }

    note for ChatHistoryResult "扩展返回值:<br/>- NeedCompact: 是否需要触发压缩<br/>- LastSummaryIndex: 最近 Summary 的索引位置<br/>  (-1 表示没有 Summary)"

3.3.3 SSE 事件扩展

classDiagram
    class SSEEvents {
        <<events>>
        +answer: 普通回复内容
        +error: 错误信息
        +compacting: 压缩开始   新增
        +compactDone: 压缩完成   新增
    }

    note for SSEEvents "新增 SSE 事件:<br/>- compacting: 通知客户端压缩开始<br/>- compactDone: 通知客户端压缩完成<br/>  携带 summaryId 供前端定位"

3.4 数据结构

3.4.1 压缩触发参数(最终实现)

// 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条)
)

3.4.2 用户配置扩展(最终实现)

// UserRole 用户角色配置(服务端需接收)
// 注意:CompactPrompt 已移除,压缩提示词从服务端 i18n 文件获取
type UserRole struct {
    // ... 现有字段
    AutoCompactEnabled bool `json:"autoCompactEnabled"` // 是否开启自动压缩
    // CompactPrompt 已移除 - 改用服务端 i18n 文件
}

4. 详细设计

4.1 服务端核心逻辑

4.1.1 chat.go 修改

步骤 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
}

4.1.2 character_router.go 修改

📍 插入位置:在聊天处理函数中,获取对话历史之后、发送 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. 正常处理用户消息
    // ... 现有逻辑 ...
}

4.2 客户端修改

4.2.1 chat.vue SSE 事件处理(已实现)

📍 位置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'
    });
}

4.2.2 可折叠 Summary 消息 UI(已实现)

📍 位置: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 消息限制

  • ❌ 不显示语音播放按钮
  • ❌ 不显示重说/改写/继续说按钮
  • ❌ 气泡菜单仅保留“复制”,移除“删除”、“追溯”、“记住”
  • ✅ 默认折叠,显示“📋 上下文摘要”

4.2.3 UserDefineMixin 配置(已实现)

📍 位置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;
        }
    });
}

4.2.4 modelSelect.vue 开关配置(已实现,无 VIP 限制)

📍 位置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() 保存到服务端
}

4.3 压缩提示词设计(服务端 i18n)

4.3.1 默认压缩提示词

📍 位置:服务端 lunatalk-server/common/languate.go,通过 GetLanTag(language, "auto_compact_default_prompt") 获取

提示词特点:

  • 分层压缩策略(核心层/重要层/细节层)
  • 标记系统(🔴 核心/🟡 重要)
  • 关键台词保留
  • 排除世界观设定等固有内容
  • 支持 5 种语言:en/zh-Hans/zh-Hant/ja/ko

4.3.2 连续性压缩特殊处理(服务端实现)

📍 位置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
}

4.3.3 Summary 标签清理(服务端实现)

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)

4.4 用户积分扣除(已实现)

📍 位置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)
  • 根据模型类型和上下文设置计算积分
  • Summary 内容超过 5 字符才扣费(防止空压缩)

5. 平台适配

5.1 H5 平台

// #ifdef H5
// SSE 事件处理使用 EventSource
// 压缩状态显示使用 CSS 动画
// #endif

5.2 原生应用

// #ifdef APP-PLUS
// SSE 事件处理使用 plus.net
// 压缩状态显示使用原生 Loading
// #endif

6. 多语言支持

6.1 已实现的翻译 Key(客户端)

Keyzh-Hanszh-Hantenjako
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...有効にすると、サーバーが...활성화하면 서버가...

6.2 服务端 i18n Key(压缩提示词)

📍 位置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

6.3 已移除的 Key

Key原用途移除原因
chat.compactRequiresVipVIP 提示VIP 限制已移除
chat.defaultCompactPrompt客户端压缩提示词改用服务端 i18n

7. 风险评估

风险类型风险描述影响程度发生概率应对措施状态
技术风险AI 压缩请求超时3 次重试 + 递增等待(1s, 2s)✅ 已实现
技术风险Summary 质量不稳定优化的压缩提示词 + 分层压缩策略✅ 已实现
性能风险压缩增加响应延迟压缩在 AI 回复后异步执行 (go handleAutoCompact())✅ 已实现
兼容风险现有对话无 Summary 标记首次触发时正常生成 Summary✅ 已实现
数据风险Summary 内容过长在提示词中限制输出长度✅ 已实现
数据风险Summary 被裁剪后信息丢失第四触发条件 summary_trimmed 检测✅ 已实现

8. 测试计划

8.1 功能测试

测试场景测试步骤预期结果状态
首次压缩触发1. 开启自动压缩 2. 对话至 80% 上下文 3. 发送消息触发压缩,生成 Summary
连续性压缩1. 已有 Summary 2. Summary 即将被裁剪 3. 发送消息生成新 Summary,包含旧信息
压缩开关关闭1. 关闭自动压缩 2. 对话至上下文满不触发压缩,正常裁剪
VIP 权限1. 非 VIP 用户 2. 开启自动压缩提示需要 VIP❌ 已移除
压缩失败恢复1. 模拟 AI 请求超时3 次重试,失败后继续正常对话
Summary UI 折叠1. 生成 Summary 2. 查看聊天记录Summary 默认折叠,点击可展开
用户积分扣除1. 触发压缩成功扣除相应积分

8.2 边界测试

测试场景测试步骤预期结果状态
HTML 卡高输出使用 HTML 卡角色,3-4 轮对话正确触发压缩(按字符数判断)
x1 小上下文设置 x1,发送多条消息约 16000 字符时触发
MAX 大上下文设置 MAX,长对话约 240000 * 0.8 字符时触发
快速连续消息压缩进行中发送新消息新消息等待压缩完成后处理
Summary 被裁剪长对话后 Summary 被裁剪出上下文触发 summary_trimmed 条件

8.3 兼容性测试

  • [ ] H5 - Chrome
  • [ ] H5 - Safari
  • [ ] H5 - 微信内置浏览器
  • [ ] iOS APP
  • [ ] Android APP

9. 上线计划

9.1 开发阶段(✅ 已完成)

阶段内容依赖状态
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✅ 已完成

9.2 数据库迁移

-- 添加 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);

9.3 回滚方案

  1. 功能回滚:关闭服务端压缩检测逻辑,恢复原有 GetChatUseHistory
  2. 数据兼容:isSummary 字段对现有功能无影响,无需删除
  3. 客户端兼容:前端忽略未知 SSE 事件,无需回滚

10. 附录

10.1 与 V1.0 客户端方案对比

对比项V1.0 客户端方案V2.0 服务端方案
触发时机固定轮数阈值精准字符数检测
信息连续性❌ 可能断裂✅ 保证连续
HTML 卡适配❌ 无法适配✅ 自动适配
客户端复杂度高(追踪轮数/字符)低(仅传开关)
后端改动中等(新增检测逻辑)
数据库改动新增 isSummary 字段

10.2 服务端上下文限制公式

消息数限制 = 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

10.3 参考文档


文档结束