package main import ( "bufio" "bytes" "crypto/md5" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" "fmt" "io" "log" "mime/multipart" "net" "net/http" "os" "os/signal" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "syscall" "time" ) // ==================== Config ==================== var ( scnetBase = "https://www.scnet.cn" defaultModelID = 510 defaultModel = "deepseek-v4-pro" apiKey string scnetUsername string scnetPassword string maxSessions = 10 isLoggedIn bool verbose bool modelMap = map[string]int{ "deepseek-v4-pro": 510, "deepseek-v4-flash": 520, } modelMapSearch = map[string]int{ "deepseek-v4-pro-search": 510, "deepseek-v4-flash-search": 520, } rsaPubPEM = `-----BEGIN PUBLIC KEY----- MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALaXEnbjI6fjy+t9W9AiO/KS0q+b/OZF S+7ykinLbiriUx9P8BcuuHnVbXNiZp5jW70eVGBtX4DhGUPzJa1YT/8CAwEAAQ== -----END PUBLIC KEY-----` ) // ==================== Cookie Pool ==================== const convPerCookie = 5 type CookieSet map[string]string var ( cookiePool []CookieSet cookieMu sync.RWMutex ) func getCookieForIdx(idx int) CookieSet { cookieMu.RLock() defer cookieMu.RUnlock() if idx < len(cookiePool) { return cookiePool[idx] } if len(cookiePool) > 0 { return cookiePool[0] } return CookieSet{"language": "zh"} } func getCookieString(idx int) string { c := getCookieForIdx(idx) parts := make([]string, 0, len(c)) for k, v := range c { parts = append(parts, k+"="+v) } return strings.Join(parts, "; ") } // ==================== Cache ==================== type CacheEntry struct { ConvID string `json:"conv_id"` CookieIdx int `json:"cookie_idx"` } var cacheFile string func init() { cacheFile = defaultCacheFilePath() } func defaultCacheFilePath() string { if v := os.Getenv("CACHE_FILE"); v != "" { return v } // Hugging Face persistent storage, when enabled, is mounted at /data. // Fall back to /tmp so the app can run on the default ephemeral Space disk. if st, err := os.Stat("/data"); err == nil && st.IsDir() { return filepath.Join("/data", "conversations.json") } return filepath.Join(os.TempDir(), "conversations.json") } func loadCache() map[string]CacheEntry { data, err := os.ReadFile(cacheFile) if err != nil { return make(map[string]CacheEntry) } var cache map[string]CacheEntry if json.Unmarshal(data, &cache) != nil { return make(map[string]CacheEntry) } return cache } func saveCache(cache map[string]CacheEntry) { // Trim to maxSessions for len(cache) > maxSessions { // Find oldest key var oldestKey string for k := range cache { oldestKey = k break } entry := cache[oldestKey] delete(cache, oldestKey) go deleteScnetConversation(entry.ConvID, entry.CookieIdx) } data, _ := json.MarshalIndent(cache, "", " ") if dir := filepath.Dir(cacheFile); dir != "." && dir != "" { _ = os.MkdirAll(dir, 0755) } if err := os.WriteFile(cacheFile, data, 0644); err != nil { log.Printf("[CACHE] Failed to write %s: %v", cacheFile, err) } } func hashUserMessages(messages []Message) string { var parts []string for _, m := range messages { switch m.Role { case "user": if s := formatMessageContent(m.Content); s != "" { parts = append(parts, s) } case "assistant": s := formatMessageContent(m.Content) tc := formatToolCallsText(m.ToolCalls) if s != "" || tc != "" { parts = append(parts, "assistant:"+s+tc) } } } if len(parts) <= 1 { return "" } // 使用最后2条消息的哈希来查找缓存,而不是除最后一条外的所有消息 // 这样可以更好地支持多轮对话 if len(parts) >= 2 { lastTwo := parts[len(parts)-2:] raw, _ := json.Marshal(lastTwo) h := md5.Sum(raw) return hex.EncodeToString(h[:])[:16] } prev := parts[:len(parts)-1] raw, _ := json.Marshal(prev) h := md5.Sum(raw) return hex.EncodeToString(h[:])[:16] } func hashForStore(messages []Message) string { var parts []string for _, m := range messages { switch m.Role { case "user": if s := formatMessageContent(m.Content); s != "" { parts = append(parts, s) } case "assistant": s := formatMessageContent(m.Content) tc := formatToolCallsText(m.ToolCalls) if s != "" || tc != "" { parts = append(parts, "assistant:"+s+tc) } } } if len(parts) == 0 { return "" } raw, _ := json.Marshal(parts) h := md5.Sum(raw) return hex.EncodeToString(h[:])[:16] } func getAvailableCookieIdx(cache map[string]CacheEntry) int { counts := make(map[int]int) for _, entry := range cache { counts[entry.CookieIdx]++ } cookieMu.RLock() poolSize := len(cookiePool) cookieMu.RUnlock() for i := 0; i < poolSize; i++ { if counts[i] < convPerCookie { return i } } // Find min minIdx := 0 minCount := counts[0] for i := 1; i < poolSize; i++ { if counts[i] < minCount { minIdx = i minCount = counts[i] } } return minIdx } // ==================== Auth ==================== func encryptPassword(pw string) (string, error) { block, _ := pem.Decode([]byte(rsaPubPEM)) if block == nil { return "", fmt.Errorf("invalid PEM") } pub, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return "", err } rsaPub := pub.(*rsa.PublicKey) cipher, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPub, []byte(pw)) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(cipher), nil } func scnetLogin() bool { if scnetUsername == "" || scnetPassword == "" { return false } log.Printf("[LOGIN] Logging in as '%s'...", scnetUsername) encPW, err := encryptPassword(scnetPassword) if err != nil { log.Printf("[LOGIN] RSA error: %v", err) return false } form := fmt.Sprintf("username=%s&password=%s&encrypted=true&ph=1", scnetUsername, encPW) req, _ := http.NewRequest("POST", scnetBase+"/ac/api/auth/loginDesktopClient.action", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") resp, err := http.DefaultClient.Do(req) if err != nil { log.Printf("[LOGIN] Error: %v", err) return false } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var result struct { Code string `json:"code"` Msg string `json:"msg"` } json.Unmarshal(body, &result) if result.Code != "0" { log.Printf("[LOGIN] Failed: %s", result.Msg) return false } cookies := CookieSet{ "language": "zh", "org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE": "zh_CN", } for _, v := range resp.Header.Values("Set-Cookie") { kv := strings.SplitN(v, ";", 2)[0] if idx := strings.Index(kv, "="); idx > 0 { cookies[strings.TrimSpace(kv[:idx])] = strings.TrimSpace(kv[idx+1:]) } } cookies["SKIP_SESSION_UPDATE"] = strconv.FormatInt(time.Now().UnixMilli(), 10) cookieMu.Lock() cookiePool = append(cookiePool, cookies) cookieMu.Unlock() isLoggedIn = true return true } func fetchOneAnonymousCookie() CookieSet { req, _ := http.NewRequest("GET", scnetBase+"/acx/chatbot/config/list", nil) req.Header.Set("Accept", "*/*") req.Header.Set("Referer", scnetBase+"/ui/chatbot/") resp, err := http.DefaultClient.Do(req) if err != nil { return nil } defer resp.Body.Close() cookies := CookieSet{ "language": "zh", "org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE": "zh_CN", } for _, v := range resp.Header.Values("Set-Cookie") { kv := strings.SplitN(v, ";", 2)[0] if idx := strings.Index(kv, "="); idx > 0 { cookies[strings.TrimSpace(kv[:idx])] = strings.TrimSpace(kv[idx+1:]) } } if _, ok := cookies["CHATBOT_ANONYMOUS_ID"]; ok { return cookies } return nil } func fetchAnonymousCookies() { needed := (maxSessions + convPerCookie - 1) / convPerCookie for i := 0; i < needed; i++ { c := fetchOneAnonymousCookie() if c != nil { cookieMu.Lock() cookiePool = append(cookiePool, c) cookieMu.Unlock() } } } // ==================== Cleanup ==================== func deleteScnetConversation(convID string, cookieIdx int) { if convID == "" { return } req, _ := http.NewRequest("DELETE", scnetBase+"/acx/chatbot/conversation/"+convID, nil) req.Header.Set("Accept", "*/*") req.Header.Set("Origin", scnetBase) req.Header.Set("Referer", scnetBase+"/ui/chatbot/"+convID) req.Header.Set("Cookie", getCookieString(cookieIdx)) resp, err := http.DefaultClient.Do(req) if err != nil { return } resp.Body.Close() } func deleteAllConversations() { total := 0 cookieMu.RLock() poolCopy := make([]CookieSet, len(cookiePool)) copy(poolCopy, cookiePool) cookieMu.RUnlock() for _, cookies := range poolCopy { page := 1 for { req, _ := http.NewRequest("GET", fmt.Sprintf("%s/acx/chatbot/conversation/list?page=%d&size=50&source=chat", scnetBase, page), nil) req.Header.Set("Accept", "*/*") req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Referer", scnetBase+"/ui/chatbot/") parts := make([]string, 0, len(cookies)) for k, v := range cookies { parts = append(parts, k+"="+v) } req.Header.Set("Cookie", strings.Join(parts, "; ")) resp, err := http.DefaultClient.Do(req) if err != nil { break } body, _ := io.ReadAll(resp.Body) resp.Body.Close() var data struct { Data struct { Records []struct { ID string `json:"id"` } `json:"records"` } `json:"data"` } json.Unmarshal(body, &data) if len(data.Data.Records) == 0 { break } for _, r := range data.Data.Records { if r.ID != "" { delReq, _ := http.NewRequest("DELETE", scnetBase+"/acx/chatbot/conversation/"+r.ID, nil) delReq.Header.Set("Accept", "*/*") delReq.Header.Set("Origin", scnetBase) delReq.Header.Set("Referer", scnetBase+"/ui/chatbot/"+r.ID) delReq.Header.Set("Cookie", strings.Join(parts, "; ")) http.DefaultClient.Do(delReq) total++ } } if len(data.Data.Records) < 50 { break } page++ } } os.Remove(cacheFile) } // ==================== SSE ==================== type SSEChunk struct { ID string `json:"id"` Object string `json:"object"` Created int64 `json:"created"` Model string `json:"model"` Choices []SSEChoice `json:"choices"` } type SSEChoice struct { Index int `json:"index"` Delta map[string]string `json:"delta"` FinishReason *string `json:"finish_reason"` } func makeSSEChunk(content, reasoning, model, chatID string, finishReason *string) string { delta := map[string]string{} if content != "" { delta["content"] = content } if reasoning != "" { delta["reasoning_content"] = reasoning } return makeSSEChunkWithDelta(delta, model, chatID, finishReason) } func makeSSERoleChunk(model, chatID string) string { return makeSSEChunkWithDelta(map[string]string{"role": "assistant"}, model, chatID, nil) } func makeSSEChunkWithDelta(delta map[string]string, model, chatID string, finishReason *string) string { if chatID == "" { chatID = fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())[:19] } if delta == nil { delta = map[string]string{} } chunk := SSEChunk{ ID: chatID, Object: "chat.completion.chunk", Created: time.Now().Unix(), Model: model, Choices: []SSEChoice{{Index: 0, Delta: delta, FinishReason: finishReason}}, } b, _ := json.Marshal(chunk) return "data: " + string(b) + "\n\n" } func makeSSEDone() string { return "data: [DONE]\n\n" } func makeSSEPrelude() string { return ": prelude " + strings.Repeat("x", 2048) + "\n\n" } func randomID() string { b := make([]byte, 9) rand.Read(b) return hex.EncodeToString(b) } func makeSSEToolCallChunk(toolCalls []ToolCall, model, chatID string, finishReason *string) string { delta := map[string]interface{}{ "tool_calls": toolCalls, } if chatID == "" { chatID = fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())[:19] } chunk := map[string]interface{}{ "id": chatID, "object": "chat.completion.chunk", "created": time.Now().Unix(), "model": model, "choices": []map[string]interface{}{{ "index": 0, "delta": delta, "finish_reason": finishReason, }}, } b, _ := json.Marshal(chunk) return "data: " + string(b) + "\n\n" } func injectToolsPrompt(systemContent string, tools []Tool) string { if len(tools) == 0 { return systemContent } var desc strings.Builder for _, tool := range tools { if tool.Function == nil { continue } fn := tool.Function desc.WriteString(fmt.Sprintf("### %s\n", fn.Name)) if fn.Description != "" { desc.WriteString(fmt.Sprintf("Description: %s\n", fn.Description)) } if fn.Parameters != nil { paramJSON, _ := json.Marshal(fn.Parameters) desc.WriteString(fmt.Sprintf("Parameters: %s\n", string(paramJSON))) } desc.WriteString("\n") } prompt := `You are an assistant that can use tools. Follow these rules STRICTLY: 1. If you need to use a tool, your ENTIRE response must be a single JSON object in this exact format: {"tool_calls":[{"name":"TOOL_NAME","arguments":{"param":"value"}}]} 2. Do NOT include markdown code blocks, explanations, or any other text before or after the JSON. 3. If no tool is needed, answer normally and do NOT output any JSON. Available tools: ` + desc.String() + `Examples: User: What is the weather in Beijing? Assistant: {"tool_calls":[{"name":"get_weather","arguments":{"location":"Beijing"}}]} User: Hello Assistant: Hello! How can I help you today?` if systemContent != "" { return systemContent + "\n\n" + prompt } return prompt } func parseToolCalls(content string) []ToolCall { idx := strings.Index(content, "tool_calls") if idx < 0 { return nil } start := strings.LastIndex(content[:idx], "{") if start < 0 { return nil } depth := 0 end := -1 for i := start; i < len(content); i++ { switch content[i] { case '{': depth++ case '}': depth-- if depth == 0 { end = i + 1 } } if end > 0 { break } } if end < 0 { return nil } raw := content[start:end] var parsed struct { ToolCalls []struct { Name string `json:"name"` Arguments interface{} `json:"arguments"` } `json:"tool_calls"` } if err := json.Unmarshal([]byte(raw), &parsed); err != nil { raw = strings.ReplaceAll(raw, "'", "\"") re := regexp.MustCompile(`(?:(\w+)\s*:)`) raw = re.ReplaceAllString(raw, `"$1":`) if err2 := json.Unmarshal([]byte(raw), &parsed); err2 != nil { return nil } } if len(parsed.ToolCalls) == 0 { return nil } result := make([]ToolCall, len(parsed.ToolCalls)) for i, tc := range parsed.ToolCalls { args := "{}" switch v := tc.Arguments.(type) { case string: args = v default: if b, err := json.Marshal(v); err == nil { args = string(b) } } result[i] = ToolCall{ ID: fmt.Sprintf("call_%s_%d", randomID(), i), Type: "function", Function: ToolCallFunc{ Name: tc.Name, Arguments: args, }, } } return result } func writeSSE(w http.ResponseWriter, flusher http.Flusher, canFlush bool, payload string) { w.Write([]byte(payload)) if canFlush { flusher.Flush() } } type thinkStreamState struct { inThink bool carry string } func partialTagSuffixLen(s, tag string) int { limit := min(len(s), len(tag)-1) for n := limit; n > 0; n-- { if strings.HasSuffix(s, tag[:n]) { return n } } return 0 } func feedThinkStream(raw string, state *thinkStreamState, emit func(content, reasoning string)) { if raw == "" { return } state.carry += raw for { if state.inThink { if idx := strings.Index(state.carry, ""); idx >= 0 { if idx > 0 { emit("", state.carry[:idx]) } state.carry = state.carry[idx+len(""):] state.inThink = false continue } keep := partialTagSuffixLen(state.carry, "") emitLen := len(state.carry) - keep if emitLen > 0 { emit("", state.carry[:emitLen]) state.carry = state.carry[emitLen:] } return } if idx := strings.Index(state.carry, ""); idx >= 0 { if idx > 0 { emit(state.carry[:idx], "") } state.carry = state.carry[idx+len(""):] state.inThink = true continue } keep := partialTagSuffixLen(state.carry, "") emitLen := len(state.carry) - keep if emitLen > 0 { emit(state.carry[:emitLen], "") state.carry = state.carry[emitLen:] } return } } func flushThinkStream(state *thinkStreamState, emit func(content, reasoning string)) { if state.carry == "" { return } if state.inThink { emit("", state.carry) } else { emit(state.carry, "") } state.carry = "" } func streamScnetResponse(w http.ResponseWriter, conversationID, content string, modelID int, modelName, storeHash string, cookieIdx int, thinkingEnable bool, onlineEnable bool, textFile, imageFile []FileInfo, tools []Tool) string { body := map[string]interface{}{ "conversationId": conversationID, "content": content, "thinkingEnable": thinkingEnable, "onlineEnable": onlineEnable, "modelId": modelID, "textFile": textFile, "imageFile": imageFile, "autoRun": 0, "clusterId": "", } bodyJSON, _ := json.Marshal(body) chatID := fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())[:19] cookies := getCookieForIdx(cookieIdx) if verbose { log.Printf("[SCNET] conv=%s model=%s", orDefault(conversationID, "(new)"), modelName) anonID := cookies["CHATBOT_ANONYMOUS_ID"] if len(anonID) > 16 { anonID = anonID[:16] } log.Printf(" cookie=%s...", anonID) } flusher, canFlush := w.(http.Flusher) writeSSE(w, flusher, canFlush, makeSSEPrelude()) writeSSE(w, flusher, canFlush, makeSSERoleChunk(modelName, chatID)) req, _ := http.NewRequest("POST", scnetBase+"/acx/chatbot/v1/chat/completion", bytes.NewReader(bodyJSON)) req.Header.Set("Accept", "text/event-stream") req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", scnetBase) req.Header.Set("Referer", scnetBase+"/ui/chatbot/") req.Header.Set("Cookie", getCookieString(cookieIdx)) resp, err := http.DefaultClient.Do(req) if err != nil { stop := "stop" writeSSE(w, flusher, canFlush, makeSSEChunk(fmt.Sprintf("[Error] %v", err), "", modelName, chatID, &stop)) writeSSE(w, flusher, canFlush, makeSSEDone()) return "" } defer resp.Body.Close() if resp.StatusCode != 200 { errBody, _ := io.ReadAll(resp.Body) stop := "stop" writeSSE(w, flusher, canFlush, makeSSEChunk(fmt.Sprintf("[Error] %d: %s", resp.StatusCode, string(errBody[:min(len(errBody), 200)])), "", modelName, chatID, &stop)) writeSSE(w, flusher, canFlush, makeSSEDone()) return "" } newConvID := "" buffer := "" reader := bufio.NewReader(resp.Body) thinkState := thinkStreamState{} hasTools := len(tools) > 0 var toolCallBuffer strings.Builder for { line, err := reader.ReadString('\n') if len(line) > 0 { buffer += line } for { idx := strings.Index(buffer, "\n") if idx < 0 { break } originalLine := buffer[:idx] buffer = buffer[idx+1:] trimmed := strings.TrimSpace(originalLine) if trimmed == "" || strings.HasPrefix(trimmed, ":") || !strings.HasPrefix(trimmed, "data:") { continue } dataStr := strings.TrimSpace(trimmed[5:]) if dataStr == "[DONE]" { flushThinkStream(&thinkState, func(content, reasoning string) { if hasTools { if content != "" { toolCallBuffer.WriteString(content) } if reasoning != "" { writeSSE(w, flusher, canFlush, makeSSEChunk("", reasoning, modelName, chatID, nil)) } } else { if content != "" || reasoning != "" { writeSSE(w, flusher, canFlush, makeSSEChunk(content, reasoning, modelName, chatID, nil)) } } }) if hasTools && toolCallBuffer.Len() > 0 { bufContent := toolCallBuffer.String() if tc := parseToolCalls(bufContent); tc != nil { stop := "tool_calls" writeSSE(w, flusher, canFlush, makeSSEToolCallChunk(tc, modelName, chatID, &stop)) } else { writeSSE(w, flusher, canFlush, makeSSEChunk(bufContent, "", modelName, chatID, nil)) stop := "stop" writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop)) } } else if !hasTools { stop := "stop" writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop)) } writeSSE(w, flusher, canFlush, makeSSEDone()) return newConvID } var data map[string]interface{} if json.Unmarshal([]byte(dataStr), &data) != nil { continue } if verbose { log.Printf("[RAW] %s", dataStr[:min(len(dataStr), 500)]) } if cid, ok := data["conversationId"].(string); ok && cid != "" && newConvID == "" { newConvID = cid if verbose { log.Printf("[SCNET] Got conversationId: %s", cid) } } if ct, ok := data["contentType"].(string); ok && ct == "1002" { flushThinkStream(&thinkState, func(content, reasoning string) { if hasTools { if content != "" { toolCallBuffer.WriteString(content) } if reasoning != "" { writeSSE(w, flusher, canFlush, makeSSEChunk("", reasoning, modelName, chatID, nil)) } } else { if content != "" || reasoning != "" { writeSSE(w, flusher, canFlush, makeSSEChunk(content, reasoning, modelName, chatID, nil)) } } }) if hasTools && toolCallBuffer.Len() > 0 { bufContent := toolCallBuffer.String() if tc := parseToolCalls(bufContent); tc != nil { stop := "tool_calls" writeSSE(w, flusher, canFlush, makeSSEToolCallChunk(tc, modelName, chatID, &stop)) } else { writeSSE(w, flusher, canFlush, makeSSEChunk(bufContent, "", modelName, chatID, nil)) stop := "stop" writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop)) } } else if !hasTools { stop := "stop" writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop)) } writeSSE(w, flusher, canFlush, makeSSEDone()) if newConvID != "" && storeHash != "" { cache := loadCache() cache[storeHash] = CacheEntry{ConvID: newConvID, CookieIdx: cookieIdx} saveCache(cache) if verbose { log.Printf("[CACHE] Saved: %s → %s (cookie %d)", storeHash, newConvID, cookieIdx) } } return newConvID } textContent := "" thinkingContent := "" if choices, ok := data["choices"].([]interface{}); ok { for _, c := range choices { if choice, ok := c.(map[string]interface{}); ok { if delta, ok := choice["delta"].(map[string]interface{}); ok { if ct, ok := delta["content"].(string); ok { textContent = ct } if th, ok := delta["reasoning_content"].(string); ok { thinkingContent = th } if th, ok := delta["thinking"].(string); ok && thinkingContent == "" { thinkingContent = th } } } } } if textContent == "" { if raw, ok := data["content"].(string); ok && raw != "" && raw != "[done]" { textContent = raw } } if thinkingContent == "" { if th, ok := data["thinking"].(string); ok && th != "" { thinkingContent = th } } if thinkingContent == "" { if th, ok := data["reasoning_content"].(string); ok && th != "" { thinkingContent = th } } if textContent == "" || thinkingContent == "" { if d, ok := data["data"].(map[string]interface{}); ok { if textContent == "" { if raw, ok := d["content"].(string); ok && raw != "" { textContent = raw } } if thinkingContent == "" { if th, ok := d["thinking"].(string); ok && th != "" { thinkingContent = th } } if thinkingContent == "" { if th, ok := d["reasoning_content"].(string); ok && th != "" { thinkingContent = th } } } } if textContent != "" || thinkingContent != "" { if thinkingContent != "" { writeSSE(w, flusher, canFlush, makeSSEChunk("", thinkingContent, modelName, chatID, nil)) } if textContent != "" { feedThinkStream(textContent, &thinkState, func(content, reasoning string) { if hasTools { if content != "" { toolCallBuffer.WriteString(content) } if reasoning != "" { writeSSE(w, flusher, canFlush, makeSSEChunk("", reasoning, modelName, chatID, nil)) } } else { if content != "" || reasoning != "" { writeSSE(w, flusher, canFlush, makeSSEChunk(content, reasoning, modelName, chatID, nil)) } } }) } } } if err != nil { break } } flushThinkStream(&thinkState, func(content, reasoning string) { if hasTools { if content != "" { toolCallBuffer.WriteString(content) } if reasoning != "" { writeSSE(w, flusher, canFlush, makeSSEChunk("", reasoning, modelName, chatID, nil)) } } else { if content != "" || reasoning != "" { writeSSE(w, flusher, canFlush, makeSSEChunk(content, reasoning, modelName, chatID, nil)) } } }) if hasTools && toolCallBuffer.Len() > 0 { bufContent := toolCallBuffer.String() if tc := parseToolCalls(bufContent); tc != nil { stop := "tool_calls" writeSSE(w, flusher, canFlush, makeSSEToolCallChunk(tc, modelName, chatID, &stop)) } else { writeSSE(w, flusher, canFlush, makeSSEChunk(bufContent, "", modelName, chatID, nil)) stop := "stop" writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop)) } } else if !hasTools { stop := "stop" writeSSE(w, flusher, canFlush, makeSSEChunk("", "", modelName, chatID, &stop)) } writeSSE(w, flusher, canFlush, makeSSEDone()) if newConvID != "" && storeHash != "" { cache := loadCache() cache[storeHash] = CacheEntry{ConvID: newConvID, CookieIdx: cookieIdx} saveCache(cache) if verbose { log.Printf("[CACHE] Saved: %s → %s (cookie %d)", storeHash, newConvID, cookieIdx) } } return newConvID } // ==================== Handlers ==================== const ( MaxFileSize = 50000 // 50KB max file size MaxFileCount = 5 // Max 5 files per request ) type FileInfo struct { Name string `json:"name"` Path string `json:"path"` Size int64 `json:"size"` Type string `json:"type"` } type ChatRequest struct { Messages []Message `json:"messages"` Model string `json:"model"` ModelID interface{} `json:"modelId"` Stream bool `json:"stream"` OnlineEnable bool `json:"online_enable"` WebSearch bool `json:"web_search"` Tools []Tool `json:"tools"` TextFile []FileInfo `json:"textFile"` ImageFile []FileInfo `json:"imageFile"` } type Message struct { Role string `json:"role"` Content interface{} `json:"content"` ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` } type Tool struct { Type string `json:"type"` Function *ToolFunction `json:"function,omitempty"` } type ToolFunction struct { Name string `json:"name"` Description string `json:"description,omitempty"` Parameters interface{} `json:"parameters,omitempty"` } type ToolCall struct { ID string `json:"id"` Type string `json:"type"` Function ToolCallFunc `json:"function"` } type ToolCallFunc struct { Name string `json:"name"` Arguments string `json:"arguments"` } func formatMessageContent(content interface{}) string { switch v := content.(type) { case string: return v case []interface{}: parts := []string{} for _, block := range v { b, ok := block.(map[string]interface{}) if !ok { continue } switch b["type"] { case "text": if t, ok := b["text"].(string); ok { parts = append(parts, t) } case "tool_use": name, _ := b["name"].(string) input, _ := json.Marshal(b["input"]) parts = append(parts, fmt.Sprintf("工具调用: %s\n参数: %s", name, string(input))) case "tool_result": id, _ := b["tool_use_id"].(string) var c string switch cv := b["content"].(type) { case string: c = cv default: cb, _ := json.Marshal(cv) c = string(cb) } parts = append(parts, fmt.Sprintf("工具调用结果 (%s):\n%s", id, c)) } } return strings.Join(parts, "\n") default: return "" } } func formatToolCallsText(toolCalls []ToolCall) string { if len(toolCalls) == 0 { return "" } parts := []string{} for _, tc := range toolCalls { parts = append(parts, fmt.Sprintf("工具调用: %s\n参数: %s", tc.Function.Name, tc.Function.Arguments)) } return strings.Join(parts, "\n") } // Scnet content 字段最大限制(保守估计,scnet 限制约 5000 字符) const scnetContentLimit = 10000 // extractFilesFromContent 从 content 数组中提取过大的文本块,自动上传到 OSS 并转为 textFile func extractFilesFromContent(content interface{}, cookieIdx int) (string, []FileInfo) { var files []FileInfo switch v := content.(type) { case string: if len(v) > scnetContentLimit { // 整个 content 太大,尝试上传 if info, err := uploadTextToOSS(v, "content.txt", cookieIdx); err == nil { files = append(files, info) return "", files } } return v, nil case []interface{}: var shortParts []string for _, block := range v { b, ok := block.(map[string]interface{}) if !ok { continue } if b["type"] != "text" { continue } text, _ := b["text"].(string) if text == "" { continue } // 检测是否有文件名前缀(如 "temp_file_xxx.txt\n...") lines := strings.SplitN(text, "\n", 2) fileName := "" fileContent := text if len(lines) == 2 && (strings.HasSuffix(lines[0], ".txt") || strings.HasSuffix(lines[0], ".md") || strings.HasSuffix(lines[0], ".json") || strings.HasSuffix(lines[0], ".csv")) { // 第行看起来像文件名 parts := strings.Split(lines[0], "/") fileName = parts[len(parts)-1] fileContent = lines[1] } if len(fileContent) > scnetContentLimit { // 内容太大,上传到 OSS name := fileName if name == "" { name = "content.txt" } if info, err := uploadTextToOSS(fileContent, name, cookieIdx); err == nil { files = append(files, info) if verbose { if fileName != "" { log.Printf("[AUTO-UPLOAD] Extracted large text block as file: %s (%d bytes)", name, len(fileContent)) } else { log.Printf("[AUTO-UPLOAD] Content too large (%d bytes), uploaded as file: %s", len(fileContent), name) } } continue } } shortParts = append(shortParts, text) } return strings.Join(shortParts, "\n"), files default: return "", nil } } // uploadTextToOSS 将文本内容上传到阿里云 OSS func uploadTextToOSS(content, fileName string, cookieIdx int) (FileInfo, error) { // 获取上传签名 signReq, _ := http.NewRequest("GET", scnetBase+"/acx/chatbot/file/sso/form/signature", nil) signReq.Header.Set("Accept", "*/*") signReq.Header.Set("Referer", scnetBase+"/ui/chatbot/") signReq.Header.Set("Cookie", getCookieString(cookieIdx)) signResp, err := http.DefaultClient.Do(signReq) if err != nil { return FileInfo{}, err } defer signResp.Body.Close() var signResult struct { Code string `json:"code"` Data struct { AccessID string `json:"accessid"` Policy string `json:"policy"` Signature string `json:"signature"` Dir string `json:"dir"` Host string `json:"host"` AccessControl string `json:"accessControl"` } `json:"data"` } if err := json.NewDecoder(signResp.Body).Decode(&signResult); err != nil || signResult.Code != "0" { return FileInfo{}, fmt.Errorf("failed to parse signature") } // 上传到 OSS ext := filepath.Ext(fileName) if ext == "" { ext = ".txt" } objectKey := signResult.Data.Dir + "/" + fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%s%d", fileName, time.Now().UnixNano())))) + ext var buf bytes.Buffer mw := multipart.NewWriter(&buf) mw.WriteField("key", objectKey) mw.WriteField("policy", signResult.Data.Policy) mw.WriteField("OSSAccessKeyId", signResult.Data.AccessID) mw.WriteField("signature", signResult.Data.Signature) mw.WriteField("success_action_status", "200") mw.WriteField("x-oss-object-acl", signResult.Data.AccessControl) part, _ := mw.CreateFormFile("file", fileName) part.Write([]byte(content)) mw.Close() ossReq, _ := http.NewRequest("POST", signResult.Data.Host, &buf) ossReq.Header.Set("Content-Type", mw.FormDataContentType()) ossResp, err := http.DefaultClient.Do(ossReq) if err != nil { return FileInfo{}, err } defer ossResp.Body.Close() if ossResp.StatusCode != 200 { return FileInfo{}, fmt.Errorf("OSS upload failed: %d", ossResp.StatusCode) } return FileInfo{ Name: fileName, Path: signResult.Data.Host + "/" + objectKey, Size: int64(len(content)), Type: "text/plain", }, nil } func handleChat(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", 405) return } // Auth if apiKey != "" { auth := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if auth != apiKey { http.Error(w, `{"error":{"message":"Invalid API key","type":"authentication_error"}}`, 401) return } } var req ChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":{"message":"Invalid JSON"}}`, 400) return } // 检查文件数量限制 totalFiles := len(req.TextFile) + len(req.ImageFile) if totalFiles > MaxFileCount { http.Error(w, fmt.Sprintf(`{"error":{"message":"Too many files. Max %d files per request"}}`, MaxFileCount), 400) return } // Model modelID := defaultModelID if req.ModelID != nil { if f, ok := req.ModelID.(float64); ok { modelID = int(f) } } if req.Model != "" { if id, ok := modelMapSearch[req.Model]; ok { modelID = id } else if id, ok := modelMap[req.Model]; ok { modelID = id } } modelName := req.Model if modelName == "" { modelName = defaultModel } // Search requires login onlineEnable := req.OnlineEnable || req.WebSearch if !onlineEnable { for _, t := range req.Tools { if t.Type == "web_search" { onlineEnable = true break } } } if strings.HasSuffix(modelName, "-search") { if !isLoggedIn { http.Error(w, `{"error":{"message":"Search requires login. Set SCNET_USERNAME/SCNET_PASSWORD."}}`, 403) return } onlineEnable = true } // Extract system + user messages systemContent := "" for _, msg := range req.Messages { if msg.Role == "system" { if s := formatMessageContent(msg.Content); s != "" { systemContent = s } } } userContent := "" var userMsgContent interface{} for i := len(req.Messages) - 1; i >= 0; i-- { msg := req.Messages[i] if msg.Role == "user" || msg.Role == "tool" { userContent = formatMessageContent(msg.Content) userMsgContent = msg.Content if userContent != "" { break } } } if userContent == "" { http.Error(w, `{"error":{"message":"No user message found"}}`, 400) return } // 自动提取过大的 content 为 textFile tempCache := loadCache() tempCookieIdx := getAvailableCookieIdx(tempCache) autoContent, autoExtractedFiles := extractFilesFromContent(userMsgContent, tempCookieIdx) if len(autoExtractedFiles) > 0 { userContent = autoContent req.TextFile = append(req.TextFile, autoExtractedFiles...) if verbose { log.Printf("[AUTO-UPLOAD] Extracted %d file(s) from content", len(autoExtractedFiles)) } } // Conversation matching lookupHash := hashUserMessages(req.Messages) storeHash := hashForStore(req.Messages) cache := loadCache() conversationID := "" cookieIdx := 0 // 尝试查找缓存的会话 if lookupHash != "" { if entry, ok := cache[lookupHash]; ok { conversationID = entry.ConvID cookieIdx = entry.CookieIdx } } // 如果缓存未命中,且消息数量 > 2(多轮对话),尝试查找最近的会话 if conversationID == "" && len(req.Messages) > 2 { // 查找最近保存的会话(按存储顺序) var latestEntry CacheEntry found := false for _, v := range cache { latestEntry = v found = true } if found { conversationID = latestEntry.ConvID cookieIdx = latestEntry.CookieIdx if verbose { log.Printf("[CACHE] Reusing latest conversation: %s", conversationID) } } } if conversationID == "" { cookieIdx = getAvailableCookieIdx(cache) } // System prompt only for new conversations if conversationID == "" { systemContent = injectToolsPrompt(systemContent, req.Tools) if systemContent != "" { userContent = systemContent + "\n\n" + userContent } } // Cache miss with history: send full context if conversationID == "" && len(req.Messages) > 2 { contextParts := []string{} for _, msg := range req.Messages[:len(req.Messages)-1] { switch msg.Role { case "system": continue case "user": if s := formatMessageContent(msg.Content); s != "" { contextParts = append(contextParts, "用户: "+s) } case "assistant": s := formatMessageContent(msg.Content) tc := formatToolCallsText(msg.ToolCalls) if s != "" && tc != "" { contextParts = append(contextParts, "助手: "+s+"\n"+tc) } else if s != "" { contextParts = append(contextParts, "助手: "+s) } else if tc != "" { contextParts = append(contextParts, "助手: "+tc) } case "tool": if s := formatMessageContent(msg.Content); s != "" { id := msg.ToolCallID if id == "" { id = "unknown" } contextParts = append(contextParts, fmt.Sprintf("工具调用结果 (%s): %s", id, s)) } } } if len(contextParts) > 0 { userContent = "以下是之前的对话历史:\n" + strings.Join(contextParts, "\n") + "\n\n" + userContent } } // Log if verbose { log.Printf("[REQUEST] model=%s stream=%v online=%v | msgs=%d", modelName, req.Stream, onlineEnable, len(req.Messages)) if systemContent != "" { s := systemContent if len(s) > 80 { s = s[:80] + "..." } log.Printf(" System: %s", s) } for i, msg := range req.Messages { s := formatMessageContent(msg.Content) if len(s) > 80 { s = s[:80] + "..." } extra := "" if len(msg.ToolCalls) > 0 { extra = fmt.Sprintf(" [tool_calls=%d]", len(msg.ToolCalls)) } if msg.ToolCallID != "" { extra = fmt.Sprintf(" [tool_call_id=%s]", msg.ToolCallID) } log.Printf(" [%d] %s%s: %s", i, msg.Role, extra, s) } log.Printf(" userContent: %s", truncate(userContent, 120)) } if verbose { lookupDisplay := lookupHash if lookupDisplay == "" { lookupDisplay = "(first)" } hitMiss := "MISS" if conversationID != "" { hitMiss = "HIT" } log.Printf(" Cache: %s → %s | store=%s cookie=%d", lookupDisplay, hitMiss, storeHash, cookieIdx) } if req.Stream { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") newConvID := streamScnetResponse(w, conversationID, userContent, modelID, modelName, storeHash, cookieIdx, true, onlineEnable, req.TextFile, req.ImageFile, req.Tools) if newConvID != "" { w.Header().Set("X-Conversation-Id", newConvID) } } else { var buf bytes.Buffer newConvID := streamScnetResponse(&capture{buf: &buf}, conversationID, userContent, modelID, modelName, storeHash, cookieIdx, true, onlineEnable, req.TextFile, req.ImageFile, req.Tools) fullContent := "" fullReasoning := "" var fullToolCalls []ToolCall for _, line := range strings.Split(buf.String(), "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "data: ") || strings.HasPrefix(line, "data: [DONE]") { continue } var chunk map[string]interface{} if json.Unmarshal([]byte(line[6:]), &chunk) != nil { continue } if choices, ok := chunk["choices"].([]interface{}); ok { for _, c := range choices { if choice, ok := c.(map[string]interface{}); ok { if delta, ok := choice["delta"].(map[string]interface{}); ok { if content, ok := delta["content"].(string); ok { fullContent += content } if reasoning, ok := delta["reasoning_content"].(string); ok { fullReasoning += reasoning } if tcRaw, ok := delta["tool_calls"]; ok { tcJSON, _ := json.Marshal(tcRaw) var tcs []ToolCall if json.Unmarshal(tcJSON, &tcs) == nil { fullToolCalls = append(fullToolCalls, tcs...) } } } } } } } if len(fullToolCalls) == 0 && len(req.Tools) > 0 { fullToolCalls = parseToolCalls(fullContent) if fullToolCalls != nil { fullContent = "" } } msg := map[string]interface{}{"role": "assistant", "content": fullContent} if fullReasoning != "" { msg["reasoning_content"] = fullReasoning } finishReason := "stop" if len(fullToolCalls) > 0 { finishReason = "tool_calls" msg["tool_calls"] = fullToolCalls } w.Header().Set("Content-Type", "application/json") // 返回 conversationId 在响应头中,方便客户端后续请求使用 if newConvID != "" { w.Header().Set("X-Conversation-Id", newConvID) } json.NewEncoder(w).Encode(map[string]interface{}{ "id": fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())[:19], "object": "chat.completion", "created": time.Now().Unix(), "model": modelName, "choices": []map[string]interface{}{{"index": 0, "message": msg, "finish_reason": finishReason}}, "usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, "conversation_id": newConvID, }) } } type capture struct { buf *bytes.Buffer } func (c *capture) Header() http.Header { return http.Header{} } func (c *capture) Write(b []byte) (int, error) { return c.buf.Write(b) } func (c *capture) WriteHeader(statusCode int) {} func handleModels(w http.ResponseWriter, r *http.Request) { allModels := make(map[string]int) for k, v := range modelMap { allModels[k] = v } if isLoggedIn { for k, v := range modelMapSearch { allModels[k] = v } } models := []map[string]interface{}{} for name, mid := range allModels { models = append(models, map[string]interface{}{ "id": name, "object": "model", "created": 1700000000, "owned_by": "scnet", "metadata": map[string]int{"modelId": mid}, }) } sort.Slice(models, func(i, j int) bool { return models[i]["id"].(string) < models[j]["id"].(string) }) json.NewEncoder(w).Encode(map[string]interface{}{"object": "list", "data": models}) } func handleHome(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") models := []string{"deepseek-v4-pro", "deepseek-v4-flash"} if isLoggedIn { models = append(models, "deepseek-v4-pro-search", "deepseek-v4-flash-search") } _ = json.NewEncoder(w).Encode(map[string]interface{}{ "name": "SCNet OpenAI Compatible API Proxy", "status": "ok", "endpoints": map[string]string{ "chat_completions": "/v1/chat/completions", "models": "/v1/models", "health": "/healthz", }, "models": models, }) } func handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } func withCommonHeaders(w http.ResponseWriter, r *http.Request) bool { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Expose-Headers", "X-Conversation-Id") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return true } return false } func router(w http.ResponseWriter, r *http.Request) { if withCommonHeaders(w, r) { return } switch { case r.URL.Path == "/" && r.Method == "GET": handleHome(w, r) case (r.URL.Path == "/healthz" || r.URL.Path == "/health") && r.Method == "GET": handleHealth(w, r) case r.URL.Path == "/v1/chat/completions" && r.Method == "POST": handleChat(w, r) case r.URL.Path == "/v1/models" && r.Method == "GET": handleModels(w, r) default: http.NotFound(w, r) } } // ==================== Main ==================== func loadDotEnv() { data, err := os.ReadFile(".env") if err != nil { return } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } idx := strings.Index(line, "=") if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+1:]) if len(val) >= 2 && (val[0] == '"' && val[len(val)-1] == '"' || val[0] == '\'' && val[len(val)-1] == '\'') { val = val[1 : len(val)-1] } if os.Getenv(key) == "" { os.Setenv(key, val) } } } func main() { loadDotEnv() cacheFile = defaultCacheFilePath() apiKey = os.Getenv("API_KEY") scnetUsername = os.Getenv("SCNET_USERNAME") scnetPassword = os.Getenv("SCNET_PASSWORD") if v := os.Getenv("SCNET_MAX_SESSIONS"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { maxSessions = n } } verbose = strings.EqualFold(os.Getenv("VERBOSE"), "true") || os.Getenv("VERBOSE") == "1" // Login or fetch anonymous cookies if !scnetLogin() { fetchAnonymousCookies() } if isTruthyEnv("SCNET_CLEAN_ON_START") { deleteAllConversations() } printBanner() cookieMu.RLock() poolSize := len(cookiePool) cookieMu.RUnlock() mode := "anonymous" if isLoggedIn { mode = "logged-in" } log.Printf("[INIT] Mode: %s, %d cookies", mode, poolSize) // Graceful shutdown sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigCh if !isFalseyEnv("SCNET_CLEAN_ON_SHUTDOWN") { log.Println("[SHUTDOWN] Cleaning up...") deleteAllConversations() } os.Exit(0) }() http.HandleFunc("/", router) host := os.Getenv("HOST") if host == "" { host = "0.0.0.0" } port := os.Getenv("PORT") if port == "" { port = "7860" } addr := net.JoinHostPort(host, port) log.Printf("Running on http://%s", addr) if err := http.ListenAndServe(addr, nil); err != nil { log.Fatal(err) } } func isTruthyEnv(key string) bool { v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) return v == "1" || v == "true" || v == "yes" || v == "on" } func isFalseyEnv(key string) bool { v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) return v == "0" || v == "false" || v == "no" || v == "off" } func printBanner() { fmt.Println() fmt.Println(" SCNet → OpenAI Compatible API Proxy") fmt.Println() // 鉴权信息 if apiKey != "" { fmt.Printf(" API Key: %s\n", apiKey) } else { fmt.Println(" API Key: (无鉴权)") } // 账号信息 if scnetUsername != "" { fmt.Printf(" 账号: %s\n", scnetUsername) } else { fmt.Println(" 账号: (匿名模式)") } fmt.Println() // 调用端点 fmt.Println(" 端点:") fmt.Println(" POST /v1/chat/completions — 聊天补全") fmt.Println(" GET /v1/models — 模型列表") fmt.Println() // 鉴权方式 fmt.Println(" 鉴权:") if apiKey != "" { fmt.Println(" Authorization: Bearer ") } else { fmt.Println(" 无需鉴权") } fmt.Println() // 可用模型 fmt.Println(" 模型:") if isLoggedIn { fmt.Println(" deepseek-v4-pro — DeepSeek V4 Pro") fmt.Println(" deepseek-v4-flash — DeepSeek V4 Flash") fmt.Println(" deepseek-v4-pro-search — DeepSeek V4 Pro + 联网搜索") fmt.Println(" deepseek-v4-flash-search — DeepSeek V4 Flash + 联网搜索") } else { fmt.Println(" deepseek-v4-pro — DeepSeek V4 Pro") fmt.Println(" deepseek-v4-flash — DeepSeek V4 Flash") } fmt.Println() // 调用示例 fmt.Println(" 示例:") fmt.Printf(` curl http://localhost:%s/v1/chat/completions \ `, orDefault(os.Getenv("PORT"), "7860")) if apiKey != "" { fmt.Printf(` -H "Authorization: Bearer %s" \`, apiKey) fmt.Println() } fmt.Println(` -H "Content-Type: application/json" \`) fmt.Println(` -d '{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"你好"}]}'`) fmt.Println() } func orDefault(s, def string) string { if s == "" { return def } return s } func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." }