package service import ( "context" "crypto/sha256" "encoding/hex" "fmt" "log/slog" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/windsurf" ) // cascadeReuseTTL 是 Windsurf Cascade 会话 ID 在 Redis 中的存活时长。 // 与 LS 端 cascade 老化窗口对齐:超过 30 分钟即使本地 cache 命中, // LS 也大概率返回 panel-not-found,由 chatCascade 的回退路径兜底。 const cascadeReuseTTL = 30 * time.Minute type WindsurfChatService struct { cfg config.WindsurfConfig lsService *WindsurfLSService tokenProvider *WindsurfTokenProvider cache GatewayCache } func NewWindsurfChatService( cfg config.WindsurfConfig, lsService *WindsurfLSService, tokenProvider *WindsurfTokenProvider, cache GatewayCache, ) *WindsurfChatService { return &WindsurfChatService{ cfg: cfg, lsService: lsService, tokenProvider: tokenProvider, cache: cache, } } type WindsurfChatRequest struct { AccountID int64 // GroupID 来自 apiKey.GroupID,用于 Cascade 复用 cache 隔离。0 表示不参与缓存。 GroupID int64 // SessionHash 与 sticky session 共用,标识同一对话流。空串表示不复用 cascade。 SessionHash string Model string Messages []windsurf.ChatMessage Stream bool Tools []windsurf.OpenAITool ToolChoice interface{} ToolPreamble string // computed by handler, passed through to Cascade // Images 当前 user turn 的 sidecar 图像(Cascade proto 的 SendUserCascadeMessageRequest.images field 6)。 // 内容必须已通过 ValidateCascadeImages(或等价校验)。 Images []windsurf.CascadeImage } type WindsurfChatResponse struct { Text string Thinking string Model string Mode string Usage *windsurf.StepUsage // server-reported; nil if unavailable FirstTextAt time.Time // when first text appeared (zero if no text) ToolCalls []windsurf.NativeToolCall } func (s *WindsurfChatService) Chat(ctx context.Context, req *WindsurfChatRequest) (*WindsurfChatResponse, error) { token, err := s.tokenProvider.GetToken(ctx, req.AccountID) if err != nil { return nil, fmt.Errorf("get token: %w", err) } modelKey := windsurf.ResolveModel(req.Model) meta := windsurf.GetModelInfo(modelKey) mode := s.resolveMode(meta) // Tool emulation requires cascade mode for proto section injection if mode == "legacy" && req.ToolPreamble != "" { mode = "cascade" } var lease *windsurf.LSLease if token.LSBinding.ContainerID != "" || token.LSBinding.ContainerName != "" { lease, err = s.lsService.AcquireByBinding(token.LSBinding) } else { lease, err = s.lsService.Acquire(ctx, token.ProxyURL) } if err != nil { return nil, fmt.Errorf("acquire LS: %w", err) } defer lease.Release() var resp *WindsurfChatResponse switch mode { case "cascade": resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images, req.AccountID, req.GroupID, req.SessionHash) case "legacy": resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey) default: resp, err = s.chatCascade(ctx, lease.Client, token.APIKey, meta, req.Messages, req.ToolPreamble, modelKey, lease.Endpoint, req.Images, req.AccountID, req.GroupID, req.SessionHash) } if err != nil { if mode == "cascade" && s.cfg.Chat.AllowModeFallback && meta != nil && meta.EnumValue > 0 { slog.Warn("windsurf_cascade_fallback_to_legacy", "model", modelKey, "error", err) resp, err = s.chatLegacy(ctx, lease.Client, token.APIKey, meta, req.Messages, modelKey) if err == nil { resp.Mode = "legacy" } } if err != nil { return nil, fmt.Errorf("chat (%s): %w", mode, err) } } return resp, nil } func (s *WindsurfChatService) resolveMode(meta *windsurf.ModelMeta) string { configMode := s.cfg.Chat.DefaultMode if configMode == "cascade" || configMode == "legacy" { return configMode } return windsurf.GetChatMode(meta, int(s.cfg.Chat.LegacyEnumCutoff)) } var modelIdentityTemplates = map[string]string{ "anthropic": "You are %s, a large language model created by Anthropic. You are helpful, harmless, and honest. When asked about your identity or which model you are, you MUST respond that you are %s, made by Anthropic.", "openai": "You are %s, a large language model created by OpenAI. When asked about your identity, you MUST respond that you are %s, made by OpenAI.", "google": "You are %s, a large language model created by Google. When asked about your identity, you MUST respond that you are %s, made by Google.", "deepseek": "You are %s, a large language model created by DeepSeek. When asked about your identity, you MUST respond that you are %s, made by DeepSeek.", "xai": "You are %s, a large language model created by xAI. When asked about your identity, you MUST respond that you are %s, made by xAI.", } func injectModelIdentity(messages []windsurf.ChatMessage, meta *windsurf.ModelMeta, modelKey string) []windsurf.ChatMessage { if meta == nil || meta.Provider == "" { return messages } for _, m := range messages { if m.Role == "system" { return messages } } tmpl, ok := modelIdentityTemplates[meta.Provider] if !ok { return messages } displayName := modelKey if meta.Name != "" { displayName = meta.Name } identity := windsurf.ChatMessage{ Role: "system", Content: fmt.Sprintf(tmpl, displayName, displayName), } return append([]windsurf.ChatMessage{identity}, messages...) } func (s *WindsurfChatService) chatCascade( ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, toolPreamble string, modelKey string, lsEndpoint string, images []windsurf.CascadeImage, accountID int64, groupID int64, sessionHash string, ) (*WindsurfChatResponse, error) { modelUID := "" modelEnumHint := 0 if meta != nil { modelUID = meta.ModelUID modelEnumHint = meta.EnumValue } messages = injectModelIdentity(messages, meta, modelKey) if len(images) > 0 { found, ok, err := client.ModelSupportsImages(ctx, apiKey, modelUID) if err != nil { slog.Warn("windsurf_cascade_caps_fetch_failed", "model", modelUID, "error", err) } else if found && !ok { return nil, fmt.Errorf("model %q does not support image inputs in Windsurf Cascade", modelUID) } } // reuse 路径:sessionHash 非空且 cache 命中即复用 cascade, // userText 缩为"system + 最后一条 user"——cascade trajectory 已承载历史。 canReuse := sessionHash != "" && s.cache != nil && groupID != 0 cacheKey := "" reuseID := "" if canReuse { cacheKey = buildCascadeCacheKey(groupID, accountID, modelUID, lsEndpoint, sessionHash, sysPromptHash(messages)) if id, err := s.cache.GetCascadeID(ctx, cacheKey); err == nil && id != "" { reuseID = id } else if err != nil { slog.Warn("windsurf_cascade_cache_get_failed", "error", err) } } var userText string if reuseID != "" { userText = buildCascadeTextForReuse(messages) } else { userText = buildCascadeText(messages, modelUID) } result, err := client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, reuseID, modelEnumHint, images) // reuse 触发 panel-not-found:清缓存 + 用 full-history 重试一次。 if err != nil && reuseID != "" && isPanelNotFound(err) { slog.Info("windsurf_cascade_reuse_invalidated", "cascade_id", reuseID, "reason", "panel_not_found") if cacheKey != "" { _ = s.cache.DeleteCascadeID(ctx, cacheKey) } userText = buildCascadeText(messages, modelUID) result, err = client.StreamCascadeChat(ctx, apiKey, modelUID, userText, toolPreamble, "", modelEnumHint, images) } if err != nil { // 任何错误都失效缓存(保守策略,避免下次复用到坏 cascade)。 if canReuse && cacheKey != "" { _ = s.cache.DeleteCascadeID(ctx, cacheKey) } return nil, err } // 成功:写回 cache。 if canReuse && cacheKey != "" && result.CascadeID != "" { if setErr := s.cache.SetCascadeID(ctx, cacheKey, result.CascadeID, cascadeReuseTTL); setErr != nil { slog.Warn("windsurf_cascade_cache_set_failed", "error", setErr) } } return &WindsurfChatResponse{ Text: result.Text, Thinking: result.Thinking, Model: modelKey, Mode: "cascade", Usage: result.Usage, FirstTextAt: result.FirstTextAt, ToolCalls: result.ToolCalls, }, nil } // buildCascadeCacheKey 构造 Cascade 复用 cache 的 key。 // 任一组件变化(账号、模型、LS 实例、会话、system prompt)都会自动 cache miss。 func buildCascadeCacheKey(groupID, accountID int64, modelUID, lsEndpoint, sessionHash, sysHash string) string { h := sha256.New() fmt.Fprintf(h, "%d|%d|%s|%s|%s|%s", groupID, accountID, modelUID, lsEndpoint, sessionHash, sysHash) return hex.EncodeToString(h.Sum(nil))[:24] } // sysPromptHash 计算 system 消息内容的指纹。system 变化必须强制开新 cascade, // 否则旧 cascade 内已建立的"角色/约束"语境会污染新对话。 func sysPromptHash(messages []windsurf.ChatMessage) string { h := sha256.New() for _, m := range messages { if m.Role == "system" { h.Write([]byte(m.Content)) h.Write([]byte{0}) } } return hex.EncodeToString(h.Sum(nil))[:16] } // isPanelNotFound 判定 LS 端"cascade panel state not found"错误。 // 与 windsurf.LocalLSClient 内部 panel-state-not-found 检测保持一致。 func isPanelNotFound(err error) bool { if err == nil { return false } s := strings.ToLower(err.Error()) if strings.Contains(s, "panel state not found") { return true } return strings.Contains(s, "not_found") && strings.Contains(s, "panel") } // buildCascadeTextForReuse 构造 reuse 模式下的 user turn 文本: // 仅含 system instructions + 最新一条 user 消息。Cascade 内部已通过 trajectory 保留前序历史。 // 注意:cacheKey 已包含 sysPromptHash,system 变化会强制 cache miss → 走 buildCascadeText 全量路径。 func buildCascadeTextForReuse(messages []windsurf.ChatMessage) string { var sysParts []string var lastUser string for _, m := range messages { if m.Role == "system" { sysParts = append(sysParts, m.Content) } } for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { lastUser = messages[i].Content break } } sys := strings.TrimSpace(strings.Join(sysParts, "\n")) if sys != "" { return "\n" + sys + "\n\n\n" + lastUser } return lastUser } func (s *WindsurfChatService) chatLegacy(ctx context.Context, client *windsurf.LocalLSClient, apiKey string, meta *windsurf.ModelMeta, messages []windsurf.ChatMessage, modelKey string) (*WindsurfChatResponse, error) { modelEnum := 0 modelName := "" if meta != nil { modelEnum = meta.EnumValue modelName = meta.Name } text, err := client.StreamLegacyChat(ctx, apiKey, messages, modelEnum, modelName) if err != nil { return nil, err } return &WindsurfChatResponse{ Text: text, Model: modelKey, Mode: "legacy", }, nil } const ( cascadeMaxHistoryBytes = 200_000 cascade1MHistoryBytes = 900_000 cascadeMultiTurnPreamble = "The following is a multi-turn conversation. You MUST remember and use all information from prior turns." ) func cascadeHistoryBudget(modelUID string) int { if strings.Contains(strings.ToLower(modelUID), "1m") { return cascade1MHistoryBytes } return cascadeMaxHistoryBytes } // buildCascadeText constructs the full text payload for SendUserCascadeMessage. // System prompt is wrapped in , multi-turn history uses // / tags with a budget cap to trim the oldest turns. func buildCascadeText(messages []windsurf.ChatMessage, modelUID string) string { var systemParts []string var convo []windsurf.ChatMessage for _, m := range messages { if m.Role == "system" { systemParts = append(systemParts, m.Content) } else if m.Role == "user" || m.Role == "assistant" { convo = append(convo, m) } } if len(convo) == 0 { return "" } sysText := strings.TrimSpace(strings.Join(systemParts, "\n")) if sysText != "" { sysText = "\n" + sysText + "\n" } // Single turn: system + last message if len(convo) <= 1 { text := convo[len(convo)-1].Content if sysText != "" { text = sysText + "\n\n" + text } return text } // Multi-turn: build history with budget trimming maxBytes := cascadeHistoryBudget(modelUID) historyBytes := len(sysText) var lines []string droppedTurns := 0 for i := len(convo) - 2; i >= 0; i-- { m := convo[i] tag := "human" if m.Role == "assistant" { tag = "assistant" } line := fmt.Sprintf("<%s>\n%s\n", tag, m.Content, tag) if historyBytes+len(line) > maxBytes && len(lines) > 0 { droppedTurns = i + 1 slog.Info("windsurf_cascade_history_trimmed", "turn", i, "total_turns", len(convo), "kept_kb", historyBytes/1024, "dropped_turns", droppedTurns, ) break } lines = append([]string{line}, lines...) historyBytes += len(line) } latest := convo[len(convo)-1] text := cascadeMultiTurnPreamble + "\n\n" + strings.Join(lines, "\n\n") + "\n\n" + "\n" + latest.Content + "\n" if sysText != "" { text = sysText + "\n\n" + text } return text }