| 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" |
| ) |
|
|
| |
|
|
| 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-----` |
| ) |
|
|
| |
|
|
| 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, "; ") |
| } |
|
|
| |
|
|
| 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 |
| } |
| |
| |
| 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) { |
| |
| for len(cache) > maxSessions { |
| |
| 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 "" |
| } |
| |
| |
| 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 |
| } |
| } |
| |
| minIdx := 0 |
| minCount := counts[0] |
| for i := 1; i < poolSize; i++ { |
| if counts[i] < minCount { |
| minIdx = i |
| minCount = counts[i] |
| } |
| } |
| return minIdx |
| } |
|
|
| |
|
|
| 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() |
| } |
| } |
| } |
|
|
| |
|
|
| 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) |
| } |
|
|
| |
|
|
| 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, "</think>"); idx >= 0 { |
| if idx > 0 { |
| emit("", state.carry[:idx]) |
| } |
| state.carry = state.carry[idx+len("</think>"):] |
| state.inThink = false |
| continue |
| } |
| keep := partialTagSuffixLen(state.carry, "</think>") |
| emitLen := len(state.carry) - keep |
| if emitLen > 0 { |
| emit("", state.carry[:emitLen]) |
| state.carry = state.carry[emitLen:] |
| } |
| return |
| } |
|
|
| if idx := strings.Index(state.carry, "<think>"); idx >= 0 { |
| if idx > 0 { |
| emit(state.carry[:idx], "") |
| } |
| state.carry = state.carry[idx+len("<think>"):] |
| state.inThink = true |
| continue |
| } |
|
|
| keep := partialTagSuffixLen(state.carry, "<think>") |
| 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 |
| } |
|
|
| |
|
|
| const ( |
| MaxFileSize = 50000 |
| MaxFileCount = 5 |
| ) |
|
|
| 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") |
| } |
|
|
| |
| const scnetContentLimit = 10000 |
|
|
| |
| func extractFilesFromContent(content interface{}, cookieIdx int) (string, []FileInfo) { |
| var files []FileInfo |
|
|
| switch v := content.(type) { |
| case string: |
| if len(v) > scnetContentLimit { |
| |
| 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 |
| } |
| |
| 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 { |
| |
| 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 |
| } |
| } |
|
|
| |
| 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") |
| } |
|
|
| |
| 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 |
| } |
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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)) |
| } |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| if conversationID == "" { |
| systemContent = injectToolsPrompt(systemContent, req.Tools) |
| if systemContent != "" { |
| userContent = systemContent + "\n\n" + userContent |
| } |
| } |
|
|
| |
| 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 |
| } |
| } |
|
|
| |
| 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") |
| |
| 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) |
| } |
| } |
|
|
| |
|
|
| 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" |
|
|
| |
| 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) |
|
|
| |
| 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 <API_KEY>") |
| } 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] + "..." |
| } |
|
|