diff --git a/backend/services/ai_chat_budget_test.go b/backend/services/ai_chat_budget_test.go new file mode 100644 index 0000000..8dae92f --- /dev/null +++ b/backend/services/ai_chat_budget_test.go @@ -0,0 +1,34 @@ +package services + +import ( + "strings" + "testing" +) + +// TestChatHistoryBudgetTrim 历史按字符预算裁剪:超预算时最旧被丢、标记注入。 +// 通过构造超长历史走 RunAIChat 会因未启用而提前返回,这里直接测裁剪语义的独立函数化验证—— +// 为可测性把裁剪抽成了 trimHistory。 +func TestChatHistoryBudgetTrim(t *testing.T) { + long := strings.Repeat("a", 15000) + history := []ChatMessage{ + {Role: "user", Content: long}, + {Role: "assistant", Content: long}, + {Role: "user", Content: "最后一句"}, + } + kept, truncated := trimHistory(history, 24000) + if !truncated { + t.Fatal("超出预算应标记 truncated") + } + if len(kept) != 2 { + t.Fatalf("应保留最近 2 条(丢弃最旧的 15000 字条目),got %d", len(kept)) + } + if kept[0].Content != long || kept[len(kept)-1].Content != "最后一句" { + t.Fatal("应保留最近的消息") + } + // 不超预算时全保留 + small := []ChatMessage{{Role: "user", Content: "hi"}, {Role: "assistant", Content: "ok"}} + kept2, truncated2 := trimHistory(small, 24000) + if truncated2 || len(kept2) != 2 { + t.Fatalf("小历史不应裁剪: %v %d", truncated2, len(kept2)) + } +} diff --git a/backend/services/ai_provider.go b/backend/services/ai_provider.go index 09c4deb..f4cc9b4 100644 --- a/backend/services/ai_provider.go +++ b/backend/services/ai_provider.go @@ -162,6 +162,37 @@ type ChatMessage struct { Content string `json:"content" binding:"required"` } +// trimHistory 按字符预算从最旧开始裁剪对话历史(指南 07 TrimMessages); +// 返回保留的历史与是否发生了裁剪(调用方在 system 里标注省略) +func trimHistory(history []ChatMessage, budget int) ([]ChatMessage, bool) { + var kept []ChatMessage + total := 0 + truncated := false + for i := len(history) - 1; i >= 0; i-- { + size := len([]rune(history[i].Content)) + if total+size > budget && len(kept) > 0 { + truncated = true + break + } + kept = append([]ChatMessage{{Role: history[i].Role, Content: history[i].Content}}, kept...) + total += size + } + return kept, truncated +} + +// isRetryableAIError 网络抖动/限流/上游临时故障才值得重试;鉴权与参数错重试无意义 +func isRetryableAIError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "429") || + strings.Contains(msg, "500") || strings.Contains(msg, "502") || + strings.Contains(msg, "503") || strings.Contains(msg, "504") || + strings.Contains(msg, "connection refused") || strings.Contains(msg, "context deadline exceeded") || + strings.Contains(msg, "EOF") || strings.Contains(msg, "timeout") +} + // RunAIChat 全站 AI 助手浮窗:多轮对话 + 当前页面上下文注入(轻量信号摘要)。 // 与整页分析共享配置/配额/脱敏/Pro 闸门;context 里的 sourceId 可选,带了就注入日志摘要。 func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history []ChatMessage, page string, pageContext string) (string, error) { @@ -169,7 +200,8 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history [] return "", fmt.Errorf("AI 未启用:请先在「智能运维 → 模型配置」中开启并保存") } if cfg.DailyTokenCap > 0 && AITokenUsedToday(userID) >= cfg.DailyTokenCap { - return "", fmt.Errorf("今日 AI 用量已达上限,明天再试或调整上限") + // 指南 05.3:超限不伪装成功 —— 结构化 notice 而非报错,对话上下文在前端保留 + return "\u2014\u2014 今日 AI 配额已用完(" + fmt.Sprintf("%d tokens),明天再试或在「AI 模型接入」调整上限 \u2014\u2014", cfg.DailyTokenCap), nil } // 提示词设计:身份固定(不猜底层模型)、风格收敛、话题回引导、上下文数据 @@ -187,8 +219,13 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history [] } system += "\n\n" + RedactTextOnly(trimmed, cfg.RedactExtra) } + // 上下文预算(指南 07 TrimMessages):按字符预算从最旧开始丢弃,优先保留近期对话 + trimmed, truncated := trimHistory(history, 24000) + if truncated { + system += "\n(更早的对话已省略)" + } msgs := []aiMessage{{Role: "system", Content: system}} - for _, m := range history { + for _, m := range trimmed { msgs = append(msgs, aiMessage{Role: m.Role, Content: m.Content}) } // 脱敏:用户消息里的敏感串同样替换 @@ -197,10 +234,27 @@ func RunAIChat(ctx context.Context, cfg models.AIConfig, userID uint, history [] msgs[i].Content = RedactTextOnly(msgs[i].Content, cfg.RedactExtra) } } - reply, promptTokens, completionTokens, err := aiChat(ctx, cfg, msgs) + // 指南 05.2:429/5xx/网络错误指数退避重试(1s→2s,最多 2 次);鉴权/参数错不重试 + var reply string + var promptTokens, completionTokens int + var err error + for attempt := 0; ; attempt++ { + reply, promptTokens, completionTokens, err = aiChat(ctx, cfg, msgs) + if err == nil || attempt >= 2 || !isRetryableAIError(err) { + break + } + backoff := time.Duration(1<{t('assistant.emptyHint')}

)} - {messages.map((m, i) => ( -
-
- {m.content} + {messages.map((m, i) => { + const isQuota = m.role === 'assistant' && m.content.includes('今日 AI 配额已用完') + return ( +
+
+ {m.content} +
-
- ))} + ) + })} {sending && (