akashyadav758 commited on
Commit
6ada2bc
Β·
1 Parent(s): 2a601f2

Add and load all three extensions in Chrome on Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. Dockerfile +8 -0
  2. chatgpt-free-api/.gitignore +13 -0
  3. chatgpt-free-api/README.md +56 -0
  4. chatgpt-free-api/agent.go +144 -0
  5. chatgpt-free-api/api_bridge.go +136 -0
  6. chatgpt-free-api/chatgpt.go +1009 -0
  7. chatgpt-free-api/config.go +102 -0
  8. chatgpt-free-api/config.json +14 -0
  9. chatgpt-free-api/cookies.go +477 -0
  10. chatgpt-free-api/cookies_test.go +104 -0
  11. chatgpt-free-api/extension.go +161 -0
  12. chatgpt-free-api/go.mod +8 -0
  13. chatgpt-free-api/go.sum +4 -0
  14. chatgpt-free-api/gpt-extension/background.js +1030 -0
  15. chatgpt-free-api/gpt-extension/icon128.png +0 -0
  16. chatgpt-free-api/gpt-extension/icon16.png +0 -0
  17. chatgpt-free-api/gpt-extension/icon48.png +0 -0
  18. chatgpt-free-api/gpt-extension/icon_large.png +0 -0
  19. chatgpt-free-api/gpt-extension/manifest.json +28 -0
  20. chatgpt-free-api/gpt-extension/popup.html +158 -0
  21. chatgpt-free-api/gpt-extension/popup.js +56 -0
  22. chatgpt-free-api/handlers.go +483 -0
  23. chatgpt-free-api/helpers.go +49 -0
  24. chatgpt-free-api/main.go +5 -0
  25. chatgpt-free-api/sniff.go +73 -0
  26. chatgpt-free-api/test.sh +124 -0
  27. flow-agent/.gitignore +15 -0
  28. flow-agent/README.md +468 -0
  29. flow-agent/SNIFFING.md +205 -0
  30. flow-agent/cli/api.py +617 -0
  31. flow-agent/cli/edit.py +243 -0
  32. flow-agent/cli/generate.py +150 -0
  33. flow-agent/cli/image.py +91 -0
  34. flow-agent/cli/sniff.py +125 -0
  35. flow-agent/cli/upload.py +55 -0
  36. flow-agent/error.md +74 -0
  37. flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1 +0 -0
  38. flow-agent/extension/background.js +879 -0
  39. flow-agent/extension/content.js +91 -0
  40. flow-agent/extension/icon128.png +0 -0
  41. flow-agent/extension/icon16.png +0 -0
  42. flow-agent/extension/icon48.png +0 -0
  43. flow-agent/extension/injected.js +163 -0
  44. flow-agent/extension/manifest.json +59 -0
  45. flow-agent/extension/popup.html +337 -0
  46. flow-agent/extension/popup.js +138 -0
  47. flow-agent/extension/rules.json +25 -0
  48. flow-agent/extension/side_panel.html +840 -0
  49. flow-agent/extension/side_panel.js +293 -0
  50. flow-agent/media-id.js +12 -0
Dockerfile CHANGED
@@ -2,6 +2,14 @@ FROM akashyadav758/chrome:latest
2
 
3
  USER root
4
 
 
 
 
 
 
 
 
 
5
  # Replace the start script with the non-root version
6
  COPY --chmod=755 start_hf.sh /start.sh
7
 
 
2
 
3
  USER root
4
 
5
+ # Copy extensions into the container
6
+ COPY chatgpt-free-api/gpt-extension /opt/gpt-extension
7
+ COPY free-gemini-api/extension /opt/gemini-extension
8
+ COPY flow-agent/extension /opt/flow-extension
9
+
10
+ # Set correct permissions
11
+ RUN chmod -R 755 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension
12
+
13
  # Replace the start script with the non-root version
14
  COPY --chmod=755 start_hf.sh /start.sh
15
 
chatgpt-free-api/.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Binaries
2
+ agent
3
+ agent.exe
4
+
5
+ # Private Session Cookies
6
+ cookies.json
7
+
8
+ # Logs & Debug
9
+ *.log
10
+ debug_poll.json
11
+
12
+ # Local Output Files
13
+ output/
chatgpt-free-api/README.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ChatGPT Free API Agent
2
+
3
+ A flat-structure Go server that connects with the Chrome extension (`gpt-extension`) to execute ChatGPT backend API requests directly from your authenticated browser session.
4
+
5
+ ## Setup & Running
6
+
7
+ 1. **Build & Run the Server:**
8
+ ```bash
9
+ go build -o agent .
10
+ ./agent
11
+ ```
12
+ 2. **Load Chrome Extension:**
13
+ - Open `chrome://extensions` in Chrome.
14
+ - Enable **Developer mode** (top-right).
15
+ - Click **Load unpacked** and select the `gpt-extension` folder (in the parent directory).
16
+ 3. **Login to ChatGPT:**
17
+ - Open `https://chatgpt.com/` and log in. The extension will automatically connect to the running Go agent (green status badge = connected).
18
+
19
+ ---
20
+
21
+ ## API Usage
22
+
23
+ ### 1. Send Chat Request
24
+ ```bash
25
+ curl -X POST "http://127.0.0.1:9225/api/chat" \
26
+ -H "Content-Type: application/json" \
27
+ -d '{"prompt": "Why is sky blue?", "conversation_id": ""}'
28
+ ```
29
+
30
+ ### 2. Bulk Sequential Requests
31
+ ```bash
32
+ curl -X POST "http://127.0.0.1:9225/api/chat/bulk" \
33
+ -H "Content-Type: application/json" \
34
+ -d '{"prompts": ["hello", "how are you"]}'
35
+ ```
36
+
37
+ ### 3. Edit Existing Message
38
+ ```bash
39
+ curl -X POST "http://127.0.0.1:9225/api/chat/edit" \
40
+ -H "Content-Type: application/json" \
41
+ -d '{"prompt": "updated prompt", "conversation_id": "conv-id", "message_id": "msg-id", "parent_id": "parent-id"}'
42
+ ```
43
+
44
+ ### 4. Health & Cookie Status
45
+ - **Health Check:** `GET http://127.0.0.1:9225/health`
46
+ - **Cookie Status:** `GET http://127.0.0.1:9225/api/cookies/status`
47
+
48
+ ---
49
+
50
+ ## Testing
51
+
52
+ Run the integration tests (requires agent running on port 9225):
53
+ ```bash
54
+ ./test.sh # Run all tests (text, thread, bulk, image gen/edit)
55
+ ./test_image_only.sh # Run only image generation/editing tests
56
+ ```
chatgpt-free-api/agent.go ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "flag"
5
+ "fmt"
6
+ "log"
7
+ "net/http"
8
+ "os"
9
+ "sync"
10
+ "time"
11
+ )
12
+
13
+ var (
14
+ activeConvMu sync.Mutex
15
+ activeConvID string
16
+ )
17
+
18
+ func getActiveConversationID() string {
19
+ activeConvMu.Lock()
20
+ defer activeConvMu.Unlock()
21
+ return activeConvID
22
+ }
23
+
24
+ func setActiveConversationID(id string) {
25
+ activeConvMu.Lock()
26
+ activeConvID = id
27
+ activeConvMu.Unlock()
28
+ }
29
+
30
+ func clearActiveConversationID() {
31
+ activeConvMu.Lock()
32
+ activeConvID = ""
33
+ activeConvMu.Unlock()
34
+ }
35
+
36
+ func Start() {
37
+ loadConfig()
38
+ LoadCookiesFromFile()
39
+
40
+ promptFlag := flag.String("prompt", "", "Prompt to send to ChatGPT after the extension connects")
41
+ onceFlag := flag.Bool("once", false, "Send the prompt, print the response, then exit")
42
+ timeoutFlag := flag.Duration("timeout", cfg.Timeout(), "Maximum time to wait for extension/response")
43
+ flag.Parse()
44
+
45
+ http.HandleFunc("/", handleWS)
46
+ http.HandleFunc("/health", handleHealth)
47
+ http.HandleFunc("/api/ext/callback", handleCallback)
48
+ http.HandleFunc("/api/ext/reload", func(w http.ResponseWriter, r *http.Request) {
49
+ extMu.Lock()
50
+ conn := extConn
51
+ extMu.Unlock()
52
+ if conn == nil {
53
+ writeJSONStatus(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "extension not connected"})
54
+ return
55
+ }
56
+ err := conn.WriteJSON(WSMessage{
57
+ Method: "reload_extension",
58
+ })
59
+ if err != nil {
60
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
61
+ return
62
+ }
63
+ writeJSON(w, map[string]any{"ok": true, "message": "reload request sent"})
64
+ })
65
+ http.HandleFunc("/api/conversation", func(w http.ResponseWriter, r *http.Request) {
66
+ convID := r.URL.Query().Get("conversation_id")
67
+ if convID == "" {
68
+ writeJSONStatus(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "conversation_id required"})
69
+ return
70
+ }
71
+ token, err := getSessionToken()
72
+ if err != nil {
73
+ writeJSONStatus(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": err.Error()})
74
+ return
75
+ }
76
+ res, err := callChatGPTAPI(apiCallParams{
77
+ URL: "https://chatgpt.com/backend-api/conversation/" + convID,
78
+ Method: "GET",
79
+ Headers: map[string]string{
80
+ "Authorization": "Bearer " + token,
81
+ },
82
+ }, cfg.APITimeout())
83
+ if err != nil {
84
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
85
+ return
86
+ }
87
+ w.Header().Set("Content-Type", "application/json")
88
+ w.WriteHeader(res.Status)
89
+ w.Write([]byte(res.Body))
90
+ })
91
+ http.HandleFunc("/api/chat", handleChat)
92
+ http.HandleFunc("/api/chat/bulk", handleChatBulk)
93
+ http.HandleFunc("/api/chat/edit", handleChatEdit)
94
+ http.HandleFunc("/api/chatgpt/test", handleChat)
95
+ http.HandleFunc("/api/sniffs", handleSniffs)
96
+ http.HandleFunc("/api/download", handleDownload)
97
+ http.HandleFunc("/v1/chat/completions", handleOpenAIChat)
98
+ http.HandleFunc("/api/cookies/status", func(w http.ResponseWriter, r *http.Request) {
99
+ writeJSON(w, map[string]any{
100
+ "ok": true,
101
+ "has_cookies": HasCookies(),
102
+ "last_sync": GetLastCookieSync().Format(time.RFC3339),
103
+ "cookie_header_len": len(GetCachedCookieHeader()),
104
+ "extension_connected": isExtensionConnected(),
105
+ })
106
+ })
107
+
108
+ if *promptFlag != "" {
109
+ go runPromptFromCLI(*promptFlag, *timeoutFlag, *onceFlag)
110
+ }
111
+
112
+ log.Printf("ChatGPT Agent listening on http://%s", cfg.ListenAddr)
113
+ log.Println("Reload the ChatGPT Browser Bridge extension if it is not connected.")
114
+ log.Println("Endpoints: GET/POST /api/chat, POST /api/chat/bulk, POST /api/chat/edit, GET /api/sniffs, GET /health")
115
+ if err := http.ListenAndServe(cfg.ListenAddr, nil); err != nil {
116
+ log.Fatal(err)
117
+ }
118
+ }
119
+
120
+ func runPromptFromCLI(prompt string, timeout time.Duration, once bool) {
121
+ log.Println("Waiting for extension API bridge connection...")
122
+ if !waitForExtension(timeout) {
123
+ log.Println("Extension did not connect before timeout")
124
+ if once {
125
+ os.Exit(1)
126
+ }
127
+ return
128
+ }
129
+
130
+ log.Printf("Sending prompt: %s", prompt)
131
+ text, _, err := sendChat(prompt)
132
+ if err != nil {
133
+ log.Printf("ChatGPT request failed: %v", err)
134
+ if once {
135
+ os.Exit(1)
136
+ }
137
+ return
138
+ }
139
+
140
+ fmt.Println(text)
141
+ if once {
142
+ os.Exit(0)
143
+ }
144
+ }
chatgpt-free-api/api_bridge.go ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "log"
5
+ "os"
6
+ "regexp"
7
+ "strings"
8
+ "time"
9
+ )
10
+
11
+ var fileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`)
12
+
13
+ func sendChat(prompt string) (string, string, error) {
14
+ return sendChatWithConversation(prompt, "", cfg.DefaultModel, cfg.DefaultThinkingEffort)
15
+ }
16
+
17
+ func sendChatWithConversation(prompt, conversationID, model, thinkingEffort string) (string, string, error) {
18
+ return sendChatWithConversationAndAttachments(prompt, conversationID, model, thinkingEffort, nil)
19
+ }
20
+
21
+ func sendChatWithConversationAndAttachments(prompt, conversationID, model, thinkingEffort string, attachments []FileAttachment) (string, string, error) {
22
+ var response, rawText, newConvID string
23
+ var err error
24
+
25
+ // ─── Try 1: Direct Go HTTP call using synced cookies (fastest, no extension needed) ───
26
+ if HasCookies() && len(attachments) == 0 {
27
+ log.Println("[chat] Attempting direct API call using synced cookies...")
28
+ response, rawText, newConvID, err = SendChatDirect(prompt, conversationID, model, thinkingEffort)
29
+ if err == nil {
30
+ log.Println("[chat] βœ… Direct API call succeeded!")
31
+ return processResponse(response, rawText, newConvID, prompt, attachments)
32
+ }
33
+ log.Printf("[chat] ⚠️ Direct API call failed: %v β€” falling back to extension", err)
34
+ }
35
+
36
+ // ─── Try 2: Extension full_conversation method (browser-based) ───
37
+ log.Println("[chat] Using extension full_conversation method...")
38
+ response, rawText, newConvID, err = sendChatViaFullConversation(prompt, conversationID, model, thinkingEffort, attachments...)
39
+ if err != nil {
40
+ return "", "", err
41
+ }
42
+
43
+ return processResponse(response, rawText, newConvID, prompt, attachments)
44
+ }
45
+
46
+ // processResponse handles post-processing: auto-download images, poll for async images
47
+ func processResponse(response, rawText, newConvID, prompt string, attachments []FileAttachment) (string, string, error) {
48
+ // Auto-download any files found in the rawText or response
49
+ var rawFileIDs []string
50
+ rawFileIDs = append(rawFileIDs, fileIDRegexp.FindAllString(rawText, -1)...)
51
+ rawFileIDs = append(rawFileIDs, fileIDRegexp.FindAllString(response, -1)...)
52
+
53
+ // Dedup and filter file IDs (excluding any attachment IDs we uploaded)
54
+ uploadedIDs := make(map[string]bool)
55
+ for _, att := range attachments {
56
+ uploadedIDs[att.ID] = true
57
+ }
58
+ seen := make(map[string]bool)
59
+ var fileIDs []string
60
+ for _, id := range rawFileIDs {
61
+ if !seen[id] && !uploadedIDs[id] {
62
+ seen[id] = true
63
+ fileIDs = append(fileIDs, id)
64
+ }
65
+ }
66
+
67
+ log.Printf("[chat] Found %d new file ID(s) to download: %v", len(fileIDs), fileIDs)
68
+
69
+ if len(fileIDs) > 0 {
70
+ for _, id := range fileIDs {
71
+ registerFilePrompt(id, prompt)
72
+ name := getPromptFilename(prompt, id)
73
+ log.Printf("[auto-download] Detected image %s (%s) in response, downloading...", id, name)
74
+
75
+ // Retry download up to 10 times because the image might still be generating/saving on OpenAI backend
76
+ var data []byte
77
+ var err error
78
+ for attempt := 1; attempt <= 10; attempt++ {
79
+ data, err = downloadChatGPTFile(id)
80
+ if err == nil {
81
+ break
82
+ }
83
+ log.Printf("[auto-download] Image %s not ready yet, retrying in 2 seconds... (attempt %d/10)", id, attempt)
84
+ time.Sleep(2 * time.Second)
85
+ }
86
+
87
+ if err == nil {
88
+ _ = os.MkdirAll("output", 0755)
89
+ localPath := "output/" + name + ".png"
90
+ err = os.WriteFile(localPath, data, 0644)
91
+ if err != nil {
92
+ log.Printf("[auto-download] Error saving file %s: %v", localPath, err)
93
+ } else {
94
+ log.Printf("[auto-download] Successfully saved image to %s", localPath)
95
+ }
96
+ } else {
97
+ log.Printf("[auto-download] Error downloading image %s: %v", id, err)
98
+ }
99
+ }
100
+ }
101
+
102
+ shouldPoll := newConvID != "" && (strings.Contains(response, "Processing image") ||
103
+ strings.Contains(response, "creating images") ||
104
+ strings.Contains(response, "generating your image") ||
105
+ len(attachments) > 0 ||
106
+ isPromptForImage(prompt))
107
+
108
+ if shouldPoll {
109
+ log.Printf("[chat] detected async image generation/edit in conversation %s, starting poll...", newConvID)
110
+ var excludeIDs []string
111
+ for _, att := range attachments {
112
+ excludeIDs = append(excludeIDs, att.ID)
113
+ }
114
+ polledResponse, pollErr := pollForImage(prompt, newConvID, 150*time.Second, excludeIDs...)
115
+ if pollErr == nil {
116
+ return polledResponse, newConvID, nil
117
+ }
118
+ log.Printf("[chat] image poll failed: %v, returning original response", pollErr)
119
+ }
120
+ return response, newConvID, nil
121
+ }
122
+
123
+ func isPromptForImage(prompt string) bool {
124
+ p := strings.ToLower(prompt)
125
+ keywords := []string{
126
+ "generate", "create", "draw", "make", "edit", "add", "remove",
127
+ "change", "modify", "paint", "cartoon", "3d", "render",
128
+ "picture", "photo", "illustration", "dall-e", "dalle",
129
+ }
130
+ for _, kw := range keywords {
131
+ if strings.Contains(p, kw) {
132
+ return true
133
+ }
134
+ }
135
+ return false
136
+ }
chatgpt-free-api/chatgpt.go ADDED
@@ -0,0 +1,1009 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/base64"
5
+ "encoding/json"
6
+ "fmt"
7
+ "log"
8
+ "net/url"
9
+ "os"
10
+ "path/filepath"
11
+ "regexp"
12
+ "strings"
13
+ "sync"
14
+ "time"
15
+
16
+ "github.com/google/uuid"
17
+ )
18
+
19
+ // FileAttachment represents an uploaded file to attach to a conversation
20
+ type FileAttachment struct {
21
+ ID string `json:"id"`
22
+ Name string `json:"name"`
23
+ Size int64 `json:"size"`
24
+ MimeType string `json:"mime_type"`
25
+ Width int `json:"width,omitempty"`
26
+ Height int `json:"height,omitempty"`
27
+ }
28
+
29
+ var (
30
+ fileIDToPromptMu sync.RWMutex
31
+ fileIDToPrompt = make(map[string]string)
32
+ )
33
+
34
+ func registerFilePrompt(fileID, prompt string) {
35
+ fileIDToPromptMu.Lock()
36
+ fileIDToPrompt[fileID] = prompt
37
+ fileIDToPromptMu.Unlock()
38
+ }
39
+
40
+ func getFilePrompt(fileID string) string {
41
+ fileIDToPromptMu.RLock()
42
+ defer fileIDToPromptMu.RUnlock()
43
+ return fileIDToPrompt[fileID]
44
+ }
45
+
46
+ // ─── Browser-context API proxy ──────────────────────────────
47
+
48
+ type apiCallParams struct {
49
+ URL string
50
+ Method string
51
+ Headers map[string]string
52
+ Body any
53
+ ResponseType string
54
+ }
55
+
56
+ type apiCallResult struct {
57
+ OK bool `json:"ok"`
58
+ Status int `json:"status"`
59
+ Headers map[string]string `json:"headers"`
60
+ Body string `json:"body"`
61
+ ConversationID string `json:"conversation_id,omitempty"`
62
+ FinalURL string `json:"finalUrl"`
63
+ Error string `json:"error"`
64
+ IsBase64 bool `json:"isBase64,omitempty"`
65
+ RawText string `json:"rawText,omitempty"`
66
+ }
67
+
68
+ func callChatGPTAPI(p apiCallParams, timeout time.Duration) (*apiCallResult, error) {
69
+ if !waitForExtension(cfg.ExtensionWait()) {
70
+ return nil, fmt.Errorf("extension not connected")
71
+ }
72
+ extMu.Lock()
73
+ conn := extConn
74
+ extMu.Unlock()
75
+ if conn == nil {
76
+ return nil, fmt.Errorf("extension not connected")
77
+ }
78
+
79
+ id := uuid.NewString()
80
+ ch := make(chan WSMessage, 1)
81
+ pendingMu.Lock()
82
+ pending[id] = ch
83
+ pendingMu.Unlock()
84
+ defer func() {
85
+ pendingMu.Lock()
86
+ delete(pending, id)
87
+ pendingMu.Unlock()
88
+ }()
89
+
90
+ // Route internal methods directly
91
+ wsMethod := "api_request"
92
+ if strings.HasPrefix(p.URL, "__internal__/") {
93
+ switch {
94
+ case strings.Contains(p.URL, "solve_pow"):
95
+ wsMethod = "solve_pow"
96
+ case strings.Contains(p.URL, "solve_turnstile"):
97
+ wsMethod = "solve_turnstile"
98
+ }
99
+ }
100
+
101
+ msg := WSMessage{
102
+ ID: id,
103
+ Method: wsMethod,
104
+ Params: map[string]any{
105
+ "url": p.URL,
106
+ "method": p.Method,
107
+ "headers": p.Headers,
108
+ "body": p.Body,
109
+ "responseType": p.ResponseType,
110
+ },
111
+ }
112
+
113
+ extMu.Lock()
114
+ err := conn.WriteJSON(msg)
115
+ extMu.Unlock()
116
+ if err != nil {
117
+ return nil, err
118
+ }
119
+
120
+ select {
121
+ case resp := <-ch:
122
+ if resp.Error != "" {
123
+ return nil, fmt.Errorf("%s", resp.Error)
124
+ }
125
+ raw, _ := json.Marshal(resp.Result)
126
+ var r apiCallResult
127
+ if err := json.Unmarshal(raw, &r); err != nil {
128
+ return nil, fmt.Errorf("decode api result: %w", err)
129
+ }
130
+ return &r, nil
131
+ case <-time.After(cfg.ChatTimeout()):
132
+ return nil, fmt.Errorf("timeout waiting for extension api_request")
133
+ }
134
+ }
135
+
136
+ // ─── ChatGPT internal API client ─────────────────────────────
137
+
138
+ type chatGPTRequirements struct {
139
+ Token string `json:"token"`
140
+ Proofofwork struct {
141
+ Required bool `json:"required"`
142
+ Seed string `json:"seed"`
143
+ Difficulty string `json:"difficulty"`
144
+ } `json:"proofofwork"`
145
+ Turnstile struct {
146
+ Required bool `json:"required"`
147
+ } `json:"turnstile"`
148
+ }
149
+
150
+ func getSessionToken() (string, error) {
151
+ r, err := callChatGPTAPI(apiCallParams{
152
+ URL: "https://chatgpt.com/api/auth/session",
153
+ Method: "GET",
154
+ }, cfg.APITimeout())
155
+ if err != nil {
156
+ return "", err
157
+ }
158
+ if r.Status != 200 {
159
+ return "", fmt.Errorf("session http %d: %s", r.Status, snippet(r.Body, 200))
160
+ }
161
+ var s struct {
162
+ AccessToken string `json:"accessToken"`
163
+ }
164
+ if err := json.Unmarshal([]byte(r.Body), &s); err != nil {
165
+ return "", fmt.Errorf("session decode: %w", err)
166
+ }
167
+ if s.AccessToken == "" {
168
+ return "", fmt.Errorf("no access token (not logged in to chatgpt.com?)")
169
+ }
170
+ return s.AccessToken, nil
171
+ }
172
+
173
+ func getChatRequirements(token string) (*chatGPTRequirements, error) {
174
+ r, err := callChatGPTAPI(apiCallParams{
175
+ URL: "https://chatgpt.com/backend-api/sentinel/chat-requirements",
176
+ Method: "POST",
177
+ Headers: map[string]string{
178
+ "Authorization": "Bearer " + token,
179
+ },
180
+ Body: map[string]any{"p": ""},
181
+ }, cfg.APITimeout())
182
+ if err != nil {
183
+ return nil, err
184
+ }
185
+ if r.Status != 200 {
186
+ return nil, fmt.Errorf("requirements http %d: %s", r.Status, snippet(r.Body, 200))
187
+ }
188
+ var cr chatGPTRequirements
189
+ if err := json.Unmarshal([]byte(r.Body), &cr); err != nil {
190
+ return nil, fmt.Errorf("requirements decode: %w", err)
191
+ }
192
+ return &cr, nil
193
+ }
194
+
195
+ func solveTurnstile() (string, error) {
196
+ // Delegate to extension β€” it executes in chatgpt.com tab where turnstile widget exists
197
+ r, err := callChatGPTAPI(apiCallParams{
198
+ URL: "__internal__/solve_turnstile",
199
+ Method: "POST",
200
+ }, cfg.APITimeout())
201
+ if err != nil {
202
+ return "", err
203
+ }
204
+ if r.Error != "" {
205
+ return "", fmt.Errorf("%s", r.Error)
206
+ }
207
+ var result struct {
208
+ Token string `json:"token"`
209
+ }
210
+ if err := json.Unmarshal([]byte(r.Body), &result); err != nil {
211
+ return strings.TrimSpace(r.Body), nil
212
+ }
213
+ return result.Token, nil
214
+ }
215
+
216
+ func solveProofOfWork(seed, difficulty string) (string, error) {
217
+ // Delegate PoW to extension which runs it in browser JS context
218
+ r, err := callChatGPTAPI(apiCallParams{
219
+ URL: "__internal__/solve_pow",
220
+ Method: "POST",
221
+ Body: map[string]any{"seed": seed, "difficulty": difficulty},
222
+ }, cfg.ChatTimeout())
223
+ if err != nil {
224
+ return "", fmt.Errorf("pow solve: %w", err)
225
+ }
226
+ if r.Error != "" {
227
+ return "", fmt.Errorf("pow solve error: %s", r.Error)
228
+ }
229
+ var result struct {
230
+ Token string `json:"token"`
231
+ }
232
+ if err := json.Unmarshal([]byte(r.Body), &result); err != nil {
233
+ // Body itself might be the token
234
+ return strings.TrimSpace(r.Body), nil
235
+ }
236
+ if result.Token != "" {
237
+ return result.Token, nil
238
+ }
239
+ return strings.TrimSpace(r.Body), nil
240
+ }
241
+
242
+ // sendChatViaFullConversation uses extension's full_conversation method.
243
+ // This does EVERYTHING inside the browser tab context (session, requirements, PoW, turnstile, conversation)
244
+ // which avoids 403 issues because all tokens are generated natively.
245
+ func sendChatViaFullConversation(prompt, conversationID, model, thinkingEffort string, attachments ...FileAttachment) (string, string, string, error) {
246
+ log.Println("[chat-full] sending full conversation via extension...")
247
+ if !waitForExtension(cfg.ExtensionWait()) {
248
+ return "", "", "", fmt.Errorf("extension not connected")
249
+ }
250
+ extMu.Lock()
251
+ conn := extConn
252
+ extMu.Unlock()
253
+ if conn == nil {
254
+ return "", "", "", fmt.Errorf("extension not connected")
255
+ }
256
+
257
+ id := uuid.NewString()
258
+ ch := make(chan WSMessage, 1)
259
+ pendingMu.Lock()
260
+ pending[id] = ch
261
+ pendingMu.Unlock()
262
+ defer func() {
263
+ pendingMu.Lock()
264
+ delete(pending, id)
265
+ pendingMu.Unlock()
266
+ }()
267
+
268
+ params := map[string]any{
269
+ "prompt": prompt,
270
+ "model": model,
271
+ "conversation_id": conversationID,
272
+ "thinking_effort": thinkingEffort,
273
+ }
274
+ if len(attachments) > 0 {
275
+ params["attachments"] = attachments
276
+ }
277
+
278
+ msg := WSMessage{
279
+ ID: id,
280
+ Method: "full_conversation",
281
+ Params: params,
282
+ }
283
+
284
+ extMu.Lock()
285
+ err := conn.WriteJSON(msg)
286
+ extMu.Unlock()
287
+ if err != nil {
288
+ return "", "", "", err
289
+ }
290
+
291
+ select {
292
+ case resp := <-ch:
293
+ if resp.Error != "" {
294
+ return "", "", "", fmt.Errorf("%s", resp.Error)
295
+ }
296
+ raw, _ := json.Marshal(resp.Result)
297
+ var r apiCallResult
298
+ if err := json.Unmarshal(raw, &r); err != nil {
299
+ return "", "", "", fmt.Errorf("decode result: %w", err)
300
+ }
301
+ if r.Body == "" {
302
+ return "", "", "", fmt.Errorf("empty response from full_conversation")
303
+ }
304
+ log.Printf("[chat-full] got response (%d chars), conversation_id: %s", len(r.Body), r.ConversationID)
305
+ return r.Body, r.RawText, r.ConversationID, nil
306
+ case <-time.After(cfg.ChatTimeout()):
307
+ return "", "", "", fmt.Errorf("timeout waiting for full_conversation")
308
+ }
309
+ }
310
+
311
+ func sendChatViaAPI(prompt string) (string, error) {
312
+ log.Println("[chat] getting session token...")
313
+ token, err := getSessionToken()
314
+ if err != nil {
315
+ return "", fmt.Errorf("session: %w", err)
316
+ }
317
+ log.Println("[chat] getting chat requirements...")
318
+ cr, err := getChatRequirements(token)
319
+ if err != nil {
320
+ return "", fmt.Errorf("requirements: %w", err)
321
+ }
322
+
323
+ headers := map[string]string{
324
+ "Authorization": "Bearer " + token,
325
+ "openai-sentinel-chat-requirements-token": cr.Token,
326
+ "Accept": "text/event-stream",
327
+ }
328
+
329
+ if cr.Proofofwork.Required {
330
+ log.Printf("[chat] solving proof-of-work (seed=%s, diff=%s)", cr.Proofofwork.Seed, cr.Proofofwork.Difficulty)
331
+ powToken, err := solveProofOfWork(cr.Proofofwork.Seed, cr.Proofofwork.Difficulty)
332
+ if err != nil {
333
+ return "", fmt.Errorf("proof-of-work: %w", err)
334
+ }
335
+ headers["openai-sentinel-proof-token"] = powToken
336
+ }
337
+ if cr.Turnstile.Required {
338
+ log.Println("[chat] solving turnstile...")
339
+ tsToken, err := solveTurnstile()
340
+ if err != nil {
341
+ log.Printf("[chat] turnstile failed: %v, attempting without it", err)
342
+ } else if tsToken != "" {
343
+ headers["openai-sentinel-turnstile-token"] = tsToken
344
+ }
345
+ }
346
+
347
+ msgID := uuid.NewString()
348
+ parentID := uuid.NewString()
349
+ body := map[string]any{
350
+ "action": "next",
351
+ "messages": []any{
352
+ map[string]any{
353
+ "id": msgID,
354
+ "author": map[string]any{"role": "user"},
355
+ "content": map[string]any{"content_type": "text", "parts": []string{prompt}},
356
+ "metadata": map[string]any{},
357
+ "create_time": float64(time.Now().Unix()),
358
+ },
359
+ },
360
+ "parent_message_id": parentID,
361
+ "model": cfg.DefaultModel,
362
+ "timezone_offset_min": cfg.TimezoneOffsetMin,
363
+ "history_and_training_disabled": false,
364
+ "force_paragen": false,
365
+ "force_rate_limit": false,
366
+ "websocket_request_id": uuid.NewString(),
367
+ "conversation_mode": map[string]any{"kind": "primary_assistant"},
368
+ "suggestions": []any{},
369
+ }
370
+
371
+ log.Println("[chat] sending conversation request...")
372
+ r, err := callChatGPTAPI(apiCallParams{
373
+ URL: "https://chatgpt.com/backend-api/conversation",
374
+ Method: "POST",
375
+ Headers: headers,
376
+ Body: body,
377
+ }, cfg.ChatTimeout())
378
+ if err != nil {
379
+ return "", err
380
+ }
381
+ if r.Status != 200 {
382
+ return "", fmt.Errorf("conversation http %d: %s", r.Status, snippet(r.Body, 400))
383
+ }
384
+ out := parseSSEFinal(r.Body)
385
+ if out == "" {
386
+ return "", fmt.Errorf("empty assistant response (raw len=%d): %s", len(r.Body), snippet(r.Body, 200))
387
+ }
388
+ log.Printf("[chat] got response (%d chars)", len(out))
389
+ return out, nil
390
+ }
391
+
392
+ func parseSSEFinal(raw string) string {
393
+ var snapshot string
394
+ var delta strings.Builder
395
+ fileIDs := make(map[string]bool)
396
+ var currentContentType string
397
+
398
+ var processDelta func(ev map[string]any)
399
+ processDelta = func(ev map[string]any) {
400
+ vVal, hasV := ev["v"]
401
+ if !hasV {
402
+ return
403
+ }
404
+ path, hasPath := ev["p"].(string)
405
+ op, _ := ev["o"].(string)
406
+
407
+ if hasPath && strings.Contains(path, "/message/content/parts/0") {
408
+ if op == "replace" {
409
+ if s, ok := vVal.(string); ok {
410
+ delta.Reset()
411
+ delta.WriteString(s)
412
+ }
413
+ } else if op == "" || op == "append" {
414
+ if s, ok := vVal.(string); ok {
415
+ delta.WriteString(s)
416
+ }
417
+ }
418
+ } else if !hasPath {
419
+ if currentContentType == "text" || currentContentType == "multimodal_text" || currentContentType == "" {
420
+ if s, ok := vVal.(string); ok {
421
+ delta.WriteString(s)
422
+ }
423
+ }
424
+ } else if op == "patch" {
425
+ if subList, ok := vVal.([]any); ok {
426
+ for _, subVal := range subList {
427
+ if subMap, ok := subVal.(map[string]any); ok {
428
+ processDelta(subMap)
429
+ }
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ for _, line := range strings.Split(raw, "\n") {
436
+ line = strings.TrimSpace(line)
437
+ if !strings.HasPrefix(line, "data:") {
438
+ continue
439
+ }
440
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
441
+ if payload == "" || payload == "[DONE]" {
442
+ continue
443
+ }
444
+ var ev map[string]any
445
+ if err := json.Unmarshal([]byte(payload), &ev); err != nil {
446
+ continue
447
+ }
448
+ if vVal, ok := ev["v"].(map[string]any); ok {
449
+ if msgObj, ok := vVal["message"].(map[string]any); ok {
450
+ if contentObj, ok := msgObj["content"].(map[string]any); ok {
451
+ if ct, ok := contentObj["content_type"].(string); ok {
452
+ currentContentType = ct
453
+ }
454
+ }
455
+ }
456
+ } else if msgObj, ok := ev["message"].(map[string]any); ok {
457
+ if contentObj, ok := msgObj["content"].(map[string]any); ok {
458
+ if ct, ok := contentObj["content_type"].(string); ok {
459
+ currentContentType = ct
460
+ }
461
+ }
462
+ }
463
+ if s := extractParts0(ev, fileIDs); s != "" {
464
+ snapshot = s
465
+ }
466
+ if v, ok := ev["v"]; ok {
467
+ if vm, ok := v.(map[string]any); ok {
468
+ if s := extractParts0(vm, fileIDs); s != "" {
469
+ snapshot = s
470
+ }
471
+ }
472
+ }
473
+ processDelta(ev)
474
+ }
475
+ out := snapshot
476
+ deltaStr := delta.String()
477
+ if delta.Len() > 0 {
478
+ isJSONBlock := strings.HasPrefix(deltaStr, `{"`) || strings.HasPrefix(deltaStr, `":"`)
479
+ if !isJSONBlock {
480
+ if delta.Len() > len(snapshot) {
481
+ out = deltaStr
482
+ } else if !strings.Contains(out, deltaStr) {
483
+ out += deltaStr
484
+ }
485
+ }
486
+ }
487
+ if len(fileIDs) > 0 {
488
+ var imageMarkdown strings.Builder
489
+ imageMarkdown.WriteString("\n\n")
490
+ for fileID := range fileIDs {
491
+ imageMarkdown.WriteString(fmt.Sprintf("![Generated Image](http://%s/api/download?file_id=%s)\n", cfg.ListenAddr, fileID))
492
+ }
493
+ out += imageMarkdown.String()
494
+ }
495
+ return out;
496
+ }
497
+
498
+ func extractParts0(ev map[string]any, fileIDs map[string]bool) string {
499
+ msg, ok := ev["message"].(map[string]any)
500
+ if !ok {
501
+ return ""
502
+ }
503
+ content, ok := msg["content"].(map[string]any)
504
+ if !ok {
505
+ return ""
506
+ }
507
+ parts, ok := content["parts"].([]any)
508
+ if !ok || len(parts) == 0 {
509
+ return ""
510
+ }
511
+ for _, part := range parts {
512
+ if partMap, ok := part.(map[string]any); ok {
513
+ if partMap["content_type"] == "image_asset_pointer" {
514
+ if assetPtr, ok := partMap["asset_pointer"].(string); ok {
515
+ if strings.HasPrefix(assetPtr, "file-service://") {
516
+ fID := strings.TrimPrefix(assetPtr, "file-service://")
517
+ fileIDs[fID] = true
518
+ } else if strings.HasPrefix(assetPtr, "sediment://") {
519
+ fID := strings.TrimPrefix(assetPtr, "sediment://")
520
+ fileIDs[fID] = true
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ s, _ := parts[0].(string)
527
+ return s
528
+ }
529
+
530
+ func snippet(s string, n int) string {
531
+ s = strings.ReplaceAll(s, "\n", " ")
532
+ if len(s) > n {
533
+ s = s[:n] + "..."
534
+ }
535
+ return s
536
+ }
537
+
538
+ // downloadChatGPTFile fetches the signed download URL from chatgpt.com backend API,
539
+ // requests it via the browser bridge (forcing base64 response), and decodes it back to raw bytes.
540
+ func downloadChatGPTFile(fileID string) ([]byte, error) {
541
+ log.Printf("[download] requesting download url for file %s...", fileID)
542
+ token, err := getSessionToken()
543
+ if err != nil {
544
+ return nil, fmt.Errorf("session token: %w", err)
545
+ }
546
+
547
+ downloadURL := fmt.Sprintf("https://chatgpt.com/backend-api/files/%s/download", fileID)
548
+ r, err := callChatGPTAPI(apiCallParams{
549
+ URL: downloadURL,
550
+ Method: "GET",
551
+ Headers: map[string]string{
552
+ "Authorization": "Bearer " + token,
553
+ },
554
+ }, cfg.APITimeout())
555
+ if err != nil {
556
+ return nil, fmt.Errorf("file download api call: %w", err)
557
+ }
558
+ if r.Status != 200 {
559
+ return nil, fmt.Errorf("file download http %d: %s", r.Status, snippet(r.Body, 200))
560
+ }
561
+
562
+ var dlInfo struct {
563
+ Status string `json:"status"`
564
+ DownloadURL string `json:"download_url"`
565
+ }
566
+ if err := json.Unmarshal([]byte(r.Body), &dlInfo); err != nil {
567
+ return nil, fmt.Errorf("decode file download info: %w", err)
568
+ }
569
+ if dlInfo.DownloadURL == "" {
570
+ return nil, fmt.Errorf("empty download url returned: %s", r.Body)
571
+ }
572
+
573
+ log.Printf("[download] downloading binary from signed url: %s", snippet(dlInfo.DownloadURL, 100))
574
+ rBin, err := callChatGPTAPI(apiCallParams{
575
+ URL: dlInfo.DownloadURL,
576
+ Method: "GET",
577
+ ResponseType: "base64",
578
+ }, cfg.ChatTimeout())
579
+ if err != nil {
580
+ return nil, fmt.Errorf("fetch binary: %w", err)
581
+ }
582
+ if rBin.Status != 200 {
583
+ return nil, fmt.Errorf("fetch binary http %d: %s", rBin.Status, snippet(rBin.Body, 200))
584
+ }
585
+
586
+ if rBin.IsBase64 {
587
+ data, err := base64.StdEncoding.DecodeString(rBin.Body)
588
+ if err != nil {
589
+ return nil, fmt.Errorf("decode base64 response: %w", err)
590
+ }
591
+ return data, nil
592
+ }
593
+
594
+ return []byte(rBin.Body), nil
595
+ }
596
+
597
+ type conversationResponse struct {
598
+ CurrentNode string `json:"current_node"`
599
+ Mapping map[string]struct {
600
+ Message *struct {
601
+ ID string `json:"id"`
602
+ Author struct {
603
+ Role string `json:"role"`
604
+ } `json:"author"`
605
+ Status string `json:"status"`
606
+ Content *struct {
607
+ ContentType string `json:"content_type"`
608
+ Parts []any `json:"parts"`
609
+ } `json:"content"`
610
+ } `json:"message"`
611
+ } `json:"mapping"`
612
+ }
613
+
614
+ func extractImageFileIDs(data []byte) []string {
615
+ var resp conversationResponse
616
+ var ids []string
617
+ if err := json.Unmarshal(data, &resp); err != nil {
618
+ return nil
619
+ }
620
+ for _, node := range resp.Mapping {
621
+ if node.Message == nil || node.Message.Content == nil {
622
+ continue
623
+ }
624
+ if node.Message.Content.ContentType == "multimodal_text" {
625
+ for _, part := range node.Message.Content.Parts {
626
+ if partMap, ok := part.(map[string]any); ok {
627
+ if partMap["content_type"] == "image_asset_pointer" {
628
+ if assetPtr, ok := partMap["asset_pointer"].(string); ok {
629
+ var id string
630
+ if strings.HasPrefix(assetPtr, "file-service://") {
631
+ id = strings.TrimPrefix(assetPtr, "file-service://")
632
+ } else if strings.HasPrefix(assetPtr, "sediment://") {
633
+ id = strings.TrimPrefix(assetPtr, "sediment://")
634
+ }
635
+ if id != "" {
636
+ ids = append(ids, id)
637
+ }
638
+ }
639
+ }
640
+ }
641
+ }
642
+ }
643
+ }
644
+ if len(ids) == 0 {
645
+ var regex = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`)
646
+ matches := regex.FindAllString(string(data), -1)
647
+ seen := make(map[string]bool)
648
+ for _, m := range matches {
649
+ if !seen[m] {
650
+ seen[m] = true
651
+ ids = append(ids, m)
652
+ }
653
+ }
654
+ }
655
+ return ids
656
+ }
657
+
658
+ var sanitizeRegexp = regexp.MustCompile(`[^a-zA-Z0-9\s-_]`)
659
+ var spaceRegexp = regexp.MustCompile(`\s+`)
660
+
661
+ func getPromptFilename(prompt, fileID string) string {
662
+ // 1. Lowercase
663
+ s := strings.ToLower(prompt)
664
+
665
+ // 2. Remove common command/generation prefixes
666
+ prefixes := []string{
667
+ "generate an image:",
668
+ "generate a image:",
669
+ "generate image:",
670
+ "create an image:",
671
+ "create a image:",
672
+ "create image:",
673
+ "draw an image:",
674
+ "draw a image:",
675
+ "draw image:",
676
+ "make an image:",
677
+ "make a image:",
678
+ "make image:",
679
+ "generate a drawing of",
680
+ "generate an image of",
681
+ "generate drawing of",
682
+ "generate image of",
683
+ "generate a",
684
+ "generate an",
685
+ "generate",
686
+ "create an image of",
687
+ "create a drawing of",
688
+ "create image of",
689
+ "create drawing of",
690
+ "create a",
691
+ "create",
692
+ "draw a",
693
+ "draw",
694
+ }
695
+ for _, prefix := range prefixes {
696
+ if strings.HasPrefix(s, prefix) {
697
+ s = strings.TrimPrefix(s, prefix)
698
+ break
699
+ }
700
+ }
701
+ s = strings.TrimSpace(s)
702
+
703
+ // 2b. Strip leading articles (a, an, the) for cleaner names
704
+ articles := []string{"a ", "an ", "the "}
705
+ for _, art := range articles {
706
+ if strings.HasPrefix(s, art) {
707
+ s = strings.TrimPrefix(s, art)
708
+ break
709
+ }
710
+ }
711
+ s = strings.TrimSpace(s)
712
+
713
+ // 3. Remove non-alphanumeric characters
714
+ s = sanitizeRegexp.ReplaceAllString(s, "")
715
+
716
+ // 4. Replace spaces with underscores
717
+ s = spaceRegexp.ReplaceAllString(s, "_")
718
+
719
+ // 5. Keep only first 3 words for a clean short name
720
+ parts := strings.SplitN(s, "_", 4)
721
+ if len(parts) > 3 {
722
+ parts = parts[:3]
723
+ }
724
+ s = strings.Join(parts, "_")
725
+
726
+ if s == "" {
727
+ s = "image"
728
+ }
729
+
730
+ // 6. Append short file suffix for uniqueness (last 8 characters of ID)
731
+ suffix := fileID
732
+ if len(suffix) > 8 {
733
+ suffix = suffix[len(suffix)-8:]
734
+ }
735
+
736
+ return fmt.Sprintf("%s_%s", s, suffix)
737
+ }
738
+
739
+ func pollForImage(prompt, conversationID string, timeout time.Duration, excludeIDs ...string) (string, error) {
740
+ deadline := time.Now().Add(timeout)
741
+
742
+ excludeMap := make(map[string]bool)
743
+ for _, id := range excludeIDs {
744
+ excludeMap[id] = true
745
+ }
746
+
747
+ // Track all generated image IDs we've found and processed
748
+ foundImages := make(map[string]bool)
749
+ var generatedIDs []string
750
+
751
+ for time.Now().Before(deadline) {
752
+ token, err := getSessionToken()
753
+ if err != nil {
754
+ time.Sleep(1 * time.Second)
755
+ continue
756
+ }
757
+ res, err := callChatGPTAPI(apiCallParams{
758
+ URL: "https://chatgpt.com/backend-api/conversation/" + conversationID,
759
+ Method: "GET",
760
+ Headers: map[string]string{
761
+ "Authorization": "Bearer " + token,
762
+ },
763
+ }, cfg.APITimeout())
764
+ if err != nil {
765
+ log.Printf("[poll] ❌ callChatGPTAPI failed: %v", err)
766
+ time.Sleep(1 * time.Second)
767
+ continue
768
+ }
769
+ log.Printf("[poll] Polled conversation %s, status=%d, bodyLen=%d", conversationID, res.Status, len(res.Body))
770
+ if res.Status == 200 {
771
+ _ = os.WriteFile("debug_poll.json", []byte(res.Body), 0644)
772
+
773
+ var pollResp conversationResponse
774
+ if err := json.Unmarshal([]byte(res.Body), &pollResp); err == nil {
775
+ ids := extractImageFileIDs([]byte(res.Body))
776
+
777
+ // Find any new generated image IDs we haven't seen yet
778
+ var newIDs []string
779
+ for _, id := range ids {
780
+ if !excludeMap[id] && !foundImages[id] {
781
+ foundImages[id] = true
782
+ newIDs = append(newIDs, id)
783
+ generatedIDs = append(generatedIDs, id)
784
+ }
785
+ }
786
+
787
+ // Download any new images immediately
788
+ for _, id := range newIDs {
789
+ registerFilePrompt(id, prompt)
790
+ name := getPromptFilename(prompt, id)
791
+ log.Printf("[auto-download] Auto-downloading generated image %s (%s)...", id, name)
792
+
793
+ var data []byte
794
+ var dlErr error
795
+ // Try to download with retries (in case the file metadata is created but binary isn't fully ready yet on OpenAI backend)
796
+ for attempt := 1; attempt <= 5; attempt++ {
797
+ data, dlErr = downloadChatGPTFile(id)
798
+ if dlErr == nil {
799
+ break
800
+ }
801
+ time.Sleep(1 * time.Second)
802
+ }
803
+ if dlErr == nil {
804
+ _ = os.MkdirAll("output", 0755)
805
+ localPath := "output/" + name + ".png"
806
+ dlErr = os.WriteFile(localPath, data, 0644)
807
+ if dlErr != nil {
808
+ log.Printf("[auto-download] Error saving file %s locally: %v", localPath, dlErr)
809
+ } else {
810
+ log.Printf("[auto-download] Successfully saved image to %s", localPath)
811
+ }
812
+ } else {
813
+ log.Printf("[auto-download] Error downloading image %s: %v", id, dlErr)
814
+ }
815
+ }
816
+
817
+ // Break early if we found new generated images
818
+ if len(generatedIDs) > 0 {
819
+ log.Printf("[poll] Found generated images: %v. Stopping poll.", generatedIDs)
820
+ break
821
+ }
822
+
823
+ // Check if the conversation leaf is finished and not generating
824
+ if leaf, exists := pollResp.Mapping[pollResp.CurrentNode]; exists && leaf.Message != nil {
825
+ role := leaf.Message.Author.Role
826
+ status := leaf.Message.Status
827
+
828
+ isFinishedAssistant := role == "assistant" && status == "finished_successfully"
829
+ isFinishedToolWithImage := role == "tool" && status == "finished_successfully" &&
830
+ leaf.Message.Content != nil && hasImagePointer(leaf.Message.Content.Parts)
831
+
832
+ if isFinishedAssistant || isFinishedToolWithImage {
833
+ log.Printf("[poll] Conversation leaf is finished (%s, %s). Stopping poll.", role, status)
834
+ break
835
+ }
836
+ }
837
+ }
838
+ }
839
+ time.Sleep(1500 * time.Millisecond)
840
+ }
841
+
842
+ // Compile and return the markdown response for all generated images
843
+ if len(generatedIDs) > 0 {
844
+ var markdown strings.Builder
845
+ markdown.WriteString("Here is your generated image:\n\n")
846
+ for _, id := range generatedIDs {
847
+ markdown.WriteString(fmt.Sprintf("![Generated Image](http://127.0.0.1:9225/api/download?file_id=%s&prompt=%s)\n", id, url.QueryEscape(prompt)))
848
+ }
849
+ return markdown.String(), nil
850
+ }
851
+
852
+ return "", fmt.Errorf("timeout waiting for image generation in conversation %s", conversationID)
853
+ }
854
+
855
+ func hasImagePointer(parts []any) bool {
856
+ for _, part := range parts {
857
+ if partMap, ok := part.(map[string]any); ok {
858
+ if partMap["content_type"] == "image_asset_pointer" {
859
+ return true
860
+ }
861
+ }
862
+ }
863
+ return false
864
+ }
865
+
866
+ // uploadFileToChatGPT uploads a local file to ChatGPT via the backend API and returns a FileAttachment.
867
+ // It uses the extension's api_request method to call /backend-api/files with proper auth.
868
+ func uploadFileToChatGPT(filePath string) (*FileAttachment, error) {
869
+ data, err := os.ReadFile(filePath)
870
+ if err != nil {
871
+ return nil, fmt.Errorf("read file: %w", err)
872
+ }
873
+
874
+ fileName := filepath.Base(filePath)
875
+ mimeType := "image/png"
876
+ ext := strings.ToLower(filepath.Ext(fileName))
877
+ switch ext {
878
+ case ".jpg", ".jpeg":
879
+ mimeType = "image/jpeg"
880
+ case ".webp":
881
+ mimeType = "image/webp"
882
+ case ".gif":
883
+ mimeType = "image/gif"
884
+ }
885
+
886
+ fileSize := int64(len(data))
887
+
888
+ log.Printf("[upload] Uploading file %s (%d bytes, %s) to ChatGPT...", fileName, fileSize, mimeType)
889
+
890
+ // Step 1: Create file upload via backend API
891
+ token, err := getSessionToken()
892
+ if err != nil {
893
+ return nil, fmt.Errorf("get session token: %w", err)
894
+ }
895
+
896
+ createBody := map[string]any{
897
+ "file_name": fileName,
898
+ "file_size": fileSize,
899
+ "use_case": "multimodal",
900
+ }
901
+ bodyJSON, _ := json.Marshal(createBody)
902
+ res, err := callChatGPTAPI(apiCallParams{
903
+ URL: "https://chatgpt.com/backend-api/files",
904
+ Method: "POST",
905
+ Headers: map[string]string{
906
+ "Authorization": "Bearer " + token,
907
+ "Content-Type": "application/json",
908
+ },
909
+ Body: string(bodyJSON),
910
+ }, cfg.APITimeout())
911
+ if err != nil {
912
+ return nil, fmt.Errorf("create file: %w", err)
913
+ }
914
+ if res.Status != 200 {
915
+ return nil, fmt.Errorf("create file status %d: %s", res.Status, res.Body[:min(len(res.Body), 500)])
916
+ }
917
+
918
+ var createResp struct {
919
+ FileID string `json:"file_id"`
920
+ UploadURL string `json:"upload_url"`
921
+ Status string `json:"status"`
922
+ }
923
+ if err := json.Unmarshal([]byte(res.Body), &createResp); err != nil {
924
+ return nil, fmt.Errorf("parse create response: %w", err)
925
+ }
926
+
927
+ log.Printf("[upload] File created with ID: %s, upload URL: %s", createResp.FileID, createResp.UploadURL[:min(len(createResp.UploadURL), 80)])
928
+
929
+ // Step 2: Upload file data to the upload URL via extension (browser context for proper auth)
930
+ b64Data := base64.StdEncoding.EncodeToString(data)
931
+ uploadRes, err := callChatGPTAPI(apiCallParams{
932
+ URL: createResp.UploadURL,
933
+ Method: "PUT",
934
+ Headers: map[string]string{
935
+ "Content-Type": mimeType,
936
+ "X-Ms-Blob-Type": "BlockBlob",
937
+ "X-Ms-Version": "2020-04-08",
938
+ },
939
+ Body: b64Data,
940
+ }, cfg.APITimeout())
941
+ if err != nil {
942
+ return nil, fmt.Errorf("upload file data: %w", err)
943
+ }
944
+ if uploadRes.Status >= 300 {
945
+ return nil, fmt.Errorf("upload file data status %d: %s", uploadRes.Status, uploadRes.Body[:min(len(uploadRes.Body), 200)])
946
+ }
947
+
948
+ log.Printf("[upload] File data uploaded successfully, marking as uploaded...")
949
+
950
+ // Step 3: Mark file as uploaded
951
+ markBody := map[string]any{}
952
+ markJSON, _ := json.Marshal(markBody)
953
+ markRes, err := callChatGPTAPI(apiCallParams{
954
+ URL: fmt.Sprintf("https://chatgpt.com/backend-api/files/%s/uploaded", createResp.FileID),
955
+ Method: "POST",
956
+ Headers: map[string]string{
957
+ "Authorization": "Bearer " + token,
958
+ "Content-Type": "application/json",
959
+ },
960
+ Body: string(markJSON),
961
+ }, cfg.APITimeout())
962
+ if err != nil {
963
+ log.Printf("[upload] Warning: mark uploaded failed: %v", err)
964
+ } else {
965
+ log.Printf("[upload] Mark uploaded response: status=%d", markRes.Status)
966
+ }
967
+
968
+ // Step 4: Wait for processing
969
+ for i := 0; i < 10; i++ {
970
+ time.Sleep(1 * time.Second)
971
+ checkRes, err := callChatGPTAPI(apiCallParams{
972
+ URL: fmt.Sprintf("https://chatgpt.com/backend-api/files/%s", createResp.FileID),
973
+ Method: "GET",
974
+ Headers: map[string]string{
975
+ "Authorization": "Bearer " + token,
976
+ },
977
+ }, cfg.APITimeout())
978
+ if err != nil {
979
+ continue
980
+ }
981
+ var fileStatus struct {
982
+ Status string `json:"status"`
983
+ FileID string `json:"file_id"`
984
+ }
985
+ if err := json.Unmarshal([]byte(checkRes.Body), &fileStatus); err == nil {
986
+ log.Printf("[upload] File %s status: %s", createResp.FileID, fileStatus.Status)
987
+ if fileStatus.Status == "success" || fileStatus.Status == "ready" {
988
+ break
989
+ }
990
+ }
991
+ }
992
+
993
+ log.Printf("[upload] File upload complete: %s (%s)", createResp.FileID, fileName)
994
+
995
+ return &FileAttachment{
996
+ ID: createResp.FileID,
997
+ Name: fileName,
998
+ Size: fileSize,
999
+ MimeType: mimeType,
1000
+ }, nil
1001
+ }
1002
+
1003
+ func min(a, b int) int {
1004
+ if a < b {
1005
+ return a
1006
+ }
1007
+ return b
1008
+ }
1009
+
chatgpt-free-api/config.go ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "log"
6
+ "os"
7
+ "path/filepath"
8
+ "time"
9
+ )
10
+
11
+ // AppConfig holds all configurable values for the agent.
12
+ type AppConfig struct {
13
+ ListenAddr string `json:"listen_addr"`
14
+ DefaultModel string `json:"default_model"`
15
+ DefaultThinkingEffort string `json:"default_thinking_effort"`
16
+ TimeoutSeconds int `json:"timeout_seconds"`
17
+ ExtensionWaitSeconds int `json:"extension_wait_seconds"`
18
+ ChatTimeoutSeconds int `json:"chat_timeout_seconds"`
19
+ APITimeoutSeconds int `json:"api_timeout_seconds"`
20
+ MaxSniffs int `json:"max_sniffs"`
21
+ BulkDelaySeconds int `json:"bulk_delay_seconds"`
22
+ BrowserWakeCooldownSecs int `json:"browser_wake_cooldown_seconds"`
23
+ TimezoneOffsetMin int `json:"timezone_offset_min"`
24
+ RawSSEOutputPath string `json:"raw_sse_output_path"`
25
+ }
26
+
27
+ // Convenience duration helpers
28
+ func (c *AppConfig) Timeout() time.Duration {
29
+ return time.Duration(c.TimeoutSeconds) * time.Second
30
+ }
31
+
32
+ func (c *AppConfig) ExtensionWait() time.Duration {
33
+ return time.Duration(c.ExtensionWaitSeconds) * time.Second
34
+ }
35
+
36
+ func (c *AppConfig) ChatTimeout() time.Duration {
37
+ return time.Duration(c.ChatTimeoutSeconds) * time.Second
38
+ }
39
+
40
+ func (c *AppConfig) APITimeout() time.Duration {
41
+ return time.Duration(c.APITimeoutSeconds) * time.Second
42
+ }
43
+
44
+ func (c *AppConfig) BrowserWakeCooldown() time.Duration {
45
+ return time.Duration(c.BrowserWakeCooldownSecs) * time.Second
46
+ }
47
+
48
+ // cfg is the global config instance used throughout the app.
49
+ var cfg = defaultConfig()
50
+
51
+ func defaultConfig() AppConfig {
52
+ return AppConfig{
53
+ ListenAddr: "127.0.0.1:9224",
54
+ DefaultModel: "auto",
55
+ DefaultThinkingEffort: "",
56
+ TimeoutSeconds: 300,
57
+ ExtensionWaitSeconds: 10,
58
+ ChatTimeoutSeconds: 240,
59
+ APITimeoutSeconds: 30,
60
+ MaxSniffs: 250,
61
+ BulkDelaySeconds: 2,
62
+ BrowserWakeCooldownSecs: 60,
63
+ TimezoneOffsetMin: -330,
64
+ RawSSEOutputPath: "",
65
+ }
66
+ }
67
+
68
+ // loadConfig reads config.json from the same directory as the executable.
69
+ // If the file doesn't exist, defaults are used silently.
70
+ func loadConfig() {
71
+ // Try config.json next to the binary first, then in CWD
72
+ paths := []string{}
73
+
74
+ if exe, err := os.Executable(); err == nil {
75
+ paths = append(paths, filepath.Join(filepath.Dir(exe), "config.json"))
76
+ }
77
+ paths = append(paths, "config.json")
78
+
79
+ var data []byte
80
+ var loadedPath string
81
+ for _, p := range paths {
82
+ d, err := os.ReadFile(p)
83
+ if err == nil {
84
+ data = d
85
+ loadedPath = p
86
+ break
87
+ }
88
+ }
89
+
90
+ if data == nil {
91
+ log.Println("[config] No config.json found, using defaults")
92
+ return
93
+ }
94
+
95
+ if err := json.Unmarshal(data, &cfg); err != nil {
96
+ log.Printf("[config] Error parsing %s: %v β€” using defaults", loadedPath, err)
97
+ cfg = defaultConfig()
98
+ return
99
+ }
100
+
101
+ log.Printf("[config] Loaded from %s", loadedPath)
102
+ }
chatgpt-free-api/config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "listen_addr": "127.0.0.1:9225",
3
+ "default_model": "gpt-5-5",
4
+ "default_thinking_effort": "",
5
+ "timeout_seconds": 300,
6
+ "extension_wait_seconds": 10,
7
+ "chat_timeout_seconds": 240,
8
+ "api_timeout_seconds": 30,
9
+ "max_sniffs": 250,
10
+ "bulk_delay_seconds": 2,
11
+ "browser_wake_cooldown_seconds": 60,
12
+ "timezone_offset_min": -330,
13
+ "raw_sse_output_path": "/Users/akashyadav/.gemini/antigravity-ide/scratch/raw_sse.txt"
14
+ }
chatgpt-free-api/cookies.go ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/base64"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "log"
9
+ "net/http"
10
+ "os"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/google/uuid"
16
+ )
17
+
18
+ // CookieObject represents a browser cookie from the extension
19
+ type CookieObject struct {
20
+ Domain string `json:"domain"`
21
+ ExpirationDate float64 `json:"expirationDate,omitempty"`
22
+ HostOnly bool `json:"hostOnly,omitempty"`
23
+ HttpOnly bool `json:"httpOnly,omitempty"`
24
+ Name string `json:"name"`
25
+ Path string `json:"path"`
26
+ SameSite string `json:"sameSite,omitempty"`
27
+ Secure bool `json:"secure,omitempty"`
28
+ Session bool `json:"session,omitempty"`
29
+ StoreId string `json:"storeId,omitempty"`
30
+ Value string `json:"value"`
31
+ }
32
+
33
+ type CookiePayloadMessage struct {
34
+ Type string `json:"type"`
35
+ Cookies []CookieObject `json:"cookies"`
36
+ }
37
+
38
+ const CookiesFile = "cookies.json"
39
+
40
+ var (
41
+ cachedCookies []CookieObject
42
+ cachedCookiesMu sync.RWMutex
43
+ lastCookieSync time.Time
44
+ )
45
+
46
+ // GetCachedCookieHeader returns a cookie header string for HTTP requests to chatgpt.com
47
+ func GetCachedCookieHeader() string {
48
+ cachedCookiesMu.RLock()
49
+ defer cachedCookiesMu.RUnlock()
50
+ var parts []string
51
+ for _, ck := range cachedCookies {
52
+ parts = append(parts, fmt.Sprintf("%s=%s", ck.Name, ck.Value))
53
+ }
54
+ return strings.Join(parts, "; ")
55
+ }
56
+
57
+ // GetCookieValue returns the value of a specific cookie by name
58
+ func GetCookieValue(name string) string {
59
+ cachedCookiesMu.RLock()
60
+ defer cachedCookiesMu.RUnlock()
61
+ for _, ck := range cachedCookies {
62
+ if ck.Name == name {
63
+ return ck.Value
64
+ }
65
+ }
66
+ return ""
67
+ }
68
+
69
+ // HasCookies returns true if we have synced cookies
70
+ func HasCookies() bool {
71
+ cachedCookiesMu.RLock()
72
+ defer cachedCookiesMu.RUnlock()
73
+ return len(cachedCookies) > 0
74
+ }
75
+
76
+ // GetLastCookieSync returns the time of the last cookie sync
77
+ func GetLastCookieSync() time.Time {
78
+ cachedCookiesMu.RLock()
79
+ defer cachedCookiesMu.RUnlock()
80
+ return lastCookieSync
81
+ }
82
+
83
+ // LoadCookiesFromFile loads cookies from cookies.json at startup
84
+ func LoadCookiesFromFile() {
85
+ data, err := os.ReadFile(CookiesFile)
86
+ if err != nil {
87
+ log.Printf("[cookies] No existing cookies.json found: %v", err)
88
+ return
89
+ }
90
+ var cookies []CookieObject
91
+ if err := json.Unmarshal(data, &cookies); err != nil {
92
+ log.Printf("[cookies] Error parsing cookies.json: %v", err)
93
+ return
94
+ }
95
+ cachedCookiesMu.Lock()
96
+ cachedCookies = cookies
97
+ lastCookieSync = time.Now()
98
+ cachedCookiesMu.Unlock()
99
+ log.Printf("[cookies] Loaded %d cookies from disk", len(cookies))
100
+ }
101
+
102
+ // HandleCookiePayload processes cookies received from the extension
103
+ func HandleCookiePayload(cookies []CookieObject) {
104
+ log.Printf("πŸͺ Received %d cookies from extension", len(cookies))
105
+
106
+ // Save to cookies.json
107
+ data, err := json.MarshalIndent(cookies, "", " ")
108
+ if err != nil {
109
+ log.Printf("❌ Failed to marshal cookies: %v", err)
110
+ return
111
+ }
112
+
113
+ if err := os.WriteFile(CookiesFile, data, 0644); err != nil {
114
+ log.Printf("❌ Failed to save cookies.json: %v", err)
115
+ return
116
+ }
117
+
118
+ // Update in-memory cache
119
+ cachedCookiesMu.Lock()
120
+ cachedCookies = cookies
121
+ lastCookieSync = time.Now()
122
+ cachedCookiesMu.Unlock()
123
+
124
+ log.Printf("πŸͺ Cached %d cookies for direct API calls", len(cookies))
125
+ }
126
+
127
+ // ─── Direct HTTP client using cookies ───────────────────────────
128
+
129
+ // DirectHTTPResponse holds the response from a direct HTTP request
130
+ type DirectHTTPResponse struct {
131
+ Status int `json:"status"`
132
+ Body string `json:"body"`
133
+ Headers map[string]string `json:"headers"`
134
+ }
135
+
136
+ // DirectHTTPRequest makes an HTTP request to chatgpt.com using synced cookies
137
+ func DirectHTTPRequest(method, urlStr string, headers map[string]string, body string) (*DirectHTTPResponse, error) {
138
+ if !HasCookies() {
139
+ return nil, fmt.Errorf("no cookies available β€” extension has not synced yet")
140
+ }
141
+
142
+ var bodyReader io.Reader
143
+ if body != "" {
144
+ bodyReader = strings.NewReader(body)
145
+ }
146
+
147
+ req, err := http.NewRequest(method, urlStr, bodyReader)
148
+ if err != nil {
149
+ return nil, fmt.Errorf("create request: %w", err)
150
+ }
151
+
152
+ // Set cookies
153
+ req.Header.Set("Cookie", GetCachedCookieHeader())
154
+
155
+ // Set default headers for ChatGPT
156
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
157
+ req.Header.Set("Accept", "*/*")
158
+ req.Header.Set("Accept-Language", "en-US,en;q=0.9")
159
+ req.Header.Set("Origin", "https://chatgpt.com")
160
+ req.Header.Set("Referer", "https://chatgpt.com/")
161
+ req.Header.Set("Sec-Fetch-Dest", "empty")
162
+ req.Header.Set("Sec-Fetch-Mode", "cors")
163
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
164
+ req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="146", "Google Chrome";v="146"`)
165
+ req.Header.Set("sec-ch-ua-mobile", "?0")
166
+ req.Header.Set("sec-ch-ua-platform", `"macOS"`)
167
+
168
+ // Set oai-did from cookies
169
+ oaiDid := GetCookieValue("oai-did")
170
+ if oaiDid != "" {
171
+ req.Header.Set("OAI-Device-Id", oaiDid)
172
+ }
173
+ req.Header.Set("OAI-Language", "en-US")
174
+
175
+ // Override with custom headers
176
+ for k, v := range headers {
177
+ req.Header.Set(k, v)
178
+ }
179
+
180
+ client := &http.Client{
181
+ Timeout: 120 * time.Second,
182
+ }
183
+
184
+ log.Printf("[direct-http] Requesting %s %s...", method, urlStr)
185
+ resp, err := client.Do(req)
186
+ if err != nil {
187
+ log.Printf("[direct-http] ❌ Connection error: %v", err)
188
+ return nil, fmt.Errorf("http request: %w", err)
189
+ }
190
+ defer resp.Body.Close()
191
+
192
+ respBody, err := io.ReadAll(resp.Body)
193
+ if err != nil {
194
+ log.Printf("[direct-http] ❌ Read response error: %v", err)
195
+ return nil, fmt.Errorf("read response: %w", err)
196
+ }
197
+
198
+ log.Printf("[direct-http] Response status: %d (body length: %d bytes)", resp.StatusCode, len(respBody))
199
+
200
+ respHeaders := make(map[string]string)
201
+ for k, v := range resp.Header {
202
+ if len(v) > 0 {
203
+ respHeaders[k] = v[0]
204
+ }
205
+ }
206
+
207
+ return &DirectHTTPResponse{
208
+ Status: resp.StatusCode,
209
+ Body: string(respBody),
210
+ Headers: respHeaders,
211
+ }, nil
212
+ }
213
+
214
+ // ─── Direct ChatGPT API Functions ───────────────────────────────
215
+
216
+ // GetSessionTokenDirect gets the session token directly using cookies (no extension needed)
217
+ func GetSessionTokenDirect() (string, error) {
218
+ resp, err := DirectHTTPRequest("GET", "https://chatgpt.com/api/auth/session", nil, "")
219
+ if err != nil {
220
+ return "", fmt.Errorf("session request: %w", err)
221
+ }
222
+ if resp.Status != 200 {
223
+ return "", fmt.Errorf("session http %d: %s", resp.Status, snippet(resp.Body, 200))
224
+ }
225
+ var s struct {
226
+ AccessToken string `json:"accessToken"`
227
+ }
228
+ if err := json.Unmarshal([]byte(resp.Body), &s); err != nil {
229
+ return "", fmt.Errorf("session decode: %w", err)
230
+ }
231
+ if s.AccessToken == "" {
232
+ return "", fmt.Errorf("no access token (not logged in)")
233
+ }
234
+ return s.AccessToken, nil
235
+ }
236
+
237
+ // GetChatRequirementsDirect gets chat requirements directly using cookies
238
+ func GetChatRequirementsDirect(token string) (*chatGPTRequirements, error) {
239
+ resp, err := DirectHTTPRequest("POST", "https://chatgpt.com/backend-api/sentinel/chat-requirements",
240
+ map[string]string{
241
+ "Authorization": "Bearer " + token,
242
+ "Content-Type": "application/json",
243
+ },
244
+ `{"p":""}`,
245
+ )
246
+ if err != nil {
247
+ return nil, err
248
+ }
249
+ if resp.Status != 200 {
250
+ return nil, fmt.Errorf("requirements http %d: %s", resp.Status, snippet(resp.Body, 200))
251
+ }
252
+ var cr chatGPTRequirements
253
+ if err := json.Unmarshal([]byte(resp.Body), &cr); err != nil {
254
+ return nil, fmt.Errorf("requirements decode: %w", err)
255
+ }
256
+ return &cr, nil
257
+ }
258
+
259
+ // SendChatDirect sends a conversation request directly to ChatGPT using cookies (no extension needed)
260
+ // Returns: response text, rawSSE, conversation_id, error
261
+ func SendChatDirect(prompt, conversationID, model, thinkingEffort string) (string, string, string, error) {
262
+ if !HasCookies() {
263
+ return "", "", "", fmt.Errorf("no cookies β€” extension has not synced")
264
+ }
265
+
266
+ log.Println("[direct] Getting session token...")
267
+ token, err := GetSessionTokenDirect()
268
+ if err != nil {
269
+ log.Printf("[direct] ❌ Session token fetch failed: %v", err)
270
+ return "", "", "", fmt.Errorf("session: %w", err)
271
+ }
272
+
273
+ log.Println("[direct] Getting chat requirements...")
274
+ cr, err := GetChatRequirementsDirect(token)
275
+ if err != nil {
276
+ log.Printf("[direct] ❌ Chat requirements fetch failed: %v", err)
277
+ return "", "", "", fmt.Errorf("requirements: %w", err)
278
+ }
279
+
280
+ headers := map[string]string{
281
+ "Authorization": "Bearer " + token,
282
+ "Content-Type": "application/json",
283
+ "Accept": "text/event-stream",
284
+ "openai-sentinel-chat-requirements-token": cr.Token,
285
+ }
286
+
287
+ // PoW solving
288
+ if cr.Proofofwork.Required {
289
+ log.Printf("[direct] Solving proof-of-work (seed=%s, diff=%s)", cr.Proofofwork.Seed, cr.Proofofwork.Difficulty)
290
+ powToken := SolveFNVPow(cr.Proofofwork.Seed, cr.Proofofwork.Difficulty)
291
+ if powToken != "" {
292
+ headers["openai-sentinel-proof-token"] = powToken
293
+ } else {
294
+ log.Println("[direct] PoW solve failed, proceeding without it")
295
+ }
296
+ }
297
+
298
+ // Turnstile β€” can't solve from Go, skip
299
+ if cr.Turnstile.Required {
300
+ log.Println("[direct] ⚠️ Turnstile required β€” cannot solve from Go, falling back to extension")
301
+ return "", "", "", fmt.Errorf("TURNSTILE_REQUIRED")
302
+ }
303
+
304
+ // Resolve parent message ID
305
+ parentID := "client-created-root"
306
+ if conversationID != "" {
307
+ convResp, err := DirectHTTPRequest("GET",
308
+ "https://chatgpt.com/backend-api/conversation/"+conversationID,
309
+ map[string]string{"Authorization": "Bearer " + token},
310
+ "",
311
+ )
312
+ if err == nil && convResp.Status == 200 {
313
+ var convData struct {
314
+ CurrentNode string `json:"current_node"`
315
+ }
316
+ if json.Unmarshal([]byte(convResp.Body), &convData) == nil && convData.CurrentNode != "" {
317
+ parentID = convData.CurrentNode
318
+ }
319
+ }
320
+ }
321
+
322
+ // Build conversation body
323
+ actualModel := model
324
+ var resolvedThinkingEffort string
325
+ if thinkingEffort != "" {
326
+ resolvedThinkingEffort = thinkingEffort
327
+ }
328
+ if strings.Contains(model, "thinking") {
329
+ if strings.Contains(model, "-extended") {
330
+ resolvedThinkingEffort = "extended"
331
+ actualModel = strings.Replace(model, "-extended", "", 1)
332
+ } else if strings.Contains(model, "-standard") {
333
+ resolvedThinkingEffort = "standard"
334
+ actualModel = strings.Replace(model, "-standard", "", 1)
335
+ } else if resolvedThinkingEffort == "" {
336
+ resolvedThinkingEffort = "standard"
337
+ }
338
+ }
339
+
340
+ convBody := map[string]any{
341
+ "action": "next",
342
+ "messages": []any{
343
+ map[string]any{
344
+ "id": uuid.NewString(),
345
+ "author": map[string]any{"role": "user"},
346
+ "content": map[string]any{
347
+ "content_type": "text",
348
+ "parts": []string{prompt},
349
+ },
350
+ "metadata": map[string]any{},
351
+ "create_time": float64(time.Now().Unix()),
352
+ },
353
+ },
354
+ "parent_message_id": parentID,
355
+ "model": actualModel,
356
+ "timezone_offset_min": cfg.TimezoneOffsetMin,
357
+ "timezone": "Asia/Kolkata",
358
+ "history_and_training_disabled": false,
359
+ "fork_from_shared_post": false,
360
+ "force_paragen": false,
361
+ "force_rate_limit": false,
362
+ "conversation_mode": map[string]any{"kind": "primary_assistant"},
363
+ "enable_message_followups": true,
364
+ "system_hints": []any{},
365
+ "supports_buffering": true,
366
+ "supported_encodings": []string{"v1"},
367
+ "paragen_cot_summary_display_override": "allow",
368
+ "force_parallel_switch": "auto",
369
+ "websocket_request_id": uuid.NewString(),
370
+ "client_contextual_info": map[string]any{
371
+ "is_dark_mode": false,
372
+ "time_since_loaded": 0,
373
+ "page_height": 800,
374
+ "page_width": 1200,
375
+ "pixel_ratio": 1,
376
+ "screen_height": 1080,
377
+ "screen_width": 1920,
378
+ },
379
+ }
380
+ if conversationID != "" {
381
+ convBody["conversation_id"] = conversationID
382
+ }
383
+ if resolvedThinkingEffort != "" {
384
+ convBody["thinking_effort"] = resolvedThinkingEffort
385
+ }
386
+
387
+ bodyJSON, _ := json.Marshal(convBody)
388
+
389
+ log.Println("[direct] Sending conversation request...")
390
+ resp, err := DirectHTTPRequest("POST", "https://chatgpt.com/backend-api/conversation", headers, string(bodyJSON))
391
+ if err != nil {
392
+ log.Printf("[direct] ❌ Conversation request failed: %v", err)
393
+ return "", "", "", fmt.Errorf("conversation request: %w", err)
394
+ }
395
+ if resp.Status != 200 {
396
+ log.Printf("[direct] ❌ Conversation HTTP %d: %s", resp.Status, snippet(resp.Body, 400))
397
+ return "", "", "", fmt.Errorf("conversation http %d: %s", resp.Status, snippet(resp.Body, 400))
398
+ }
399
+
400
+ // Parse SSE response
401
+ text := parseSSEFinal(resp.Body)
402
+ if text == "" {
403
+ return "", resp.Body, "", fmt.Errorf("empty assistant response (raw len=%d)", len(resp.Body))
404
+ }
405
+
406
+ // Extract conversation_id from SSE
407
+ newConvID := extractConversationID(resp.Body)
408
+
409
+ // Save raw SSE if configured
410
+ if cfg.RawSSEOutputPath != "" {
411
+ _ = os.WriteFile(cfg.RawSSEOutputPath, []byte(resp.Body), 0644)
412
+ }
413
+
414
+ log.Printf("[direct] Got response (%d chars), conversation_id: %s", len(text), newConvID)
415
+ return text, resp.Body, newConvID, nil
416
+ }
417
+
418
+ // extractConversationID pulls conversation_id from SSE stream
419
+ func extractConversationID(raw string) string {
420
+ for _, line := range strings.Split(raw, "\n") {
421
+ line = strings.TrimSpace(line)
422
+ if !strings.HasPrefix(line, "data:") {
423
+ continue
424
+ }
425
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
426
+ if payload == "" || payload == "[DONE]" {
427
+ continue
428
+ }
429
+ var ev map[string]any
430
+ if err := json.Unmarshal([]byte(payload), &ev); err != nil {
431
+ continue
432
+ }
433
+ if cid, ok := ev["conversation_id"].(string); ok && cid != "" {
434
+ return cid
435
+ }
436
+ if v, ok := ev["v"].(map[string]any); ok {
437
+ if cid, ok := v["conversation_id"].(string); ok && cid != "" {
438
+ return cid
439
+ }
440
+ }
441
+ }
442
+ return ""
443
+ }
444
+
445
+ // ─── FNV PoW Solver ─────────────────────────────────────────────
446
+
447
+ // SolveFNVPow solves ChatGPT's FNV-based proof of work natively in Go
448
+ func SolveFNVPow(seed, difficulty string) string {
449
+ for nonce := 0; nonce < 500000; nonce++ {
450
+ // Build a config array matching the browser's format
451
+ config := fmt.Sprintf(`[3000,"%s",4294705152,%d,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",null,"","en-US","en-US",%d,"hardwareConcurrencyβˆ’8","location","self",0,"%s","",8,%d]`,
452
+ time.Now().String(), nonce, nonce, uuid.NewString(), time.Now().UnixMilli())
453
+
454
+ encoded := base64.StdEncoding.EncodeToString([]byte(config))
455
+ input := seed + encoded
456
+
457
+ hash := fnvHash(input)
458
+ if len(hash) >= len(difficulty) && hash[:len(difficulty)] <= difficulty {
459
+ return "gAAAAAB" + encoded + "~S"
460
+ }
461
+ }
462
+ return ""
463
+ }
464
+
465
+ func fnvHash(input string) string {
466
+ h := uint32(2166136261)
467
+ for i := 0; i < len(input); i++ {
468
+ h ^= uint32(input[i])
469
+ h *= 16777619
470
+ }
471
+ h ^= h >> 16
472
+ h *= 2246822507
473
+ h ^= h >> 13
474
+ h *= 3266489909
475
+ h ^= h >> 16
476
+ return fmt.Sprintf("%08x", h)
477
+ }
chatgpt-free-api/cookies_test.go ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestCookiesLifecycle(t *testing.T) {
11
+ // 1. Prepare dummy cookies
12
+ dummyCookies := []CookieObject{
13
+ {
14
+ Domain: ".chatgpt.com",
15
+ Name: "oai-did",
16
+ Value: "test-device-id-123",
17
+ },
18
+ {
19
+ Domain: ".chatgpt.com",
20
+ Name: "__Secure-next-auth.session-token",
21
+ Value: "test-session-token-abc",
22
+ },
23
+ }
24
+
25
+ // 2. Write dummy cookies to test file path (temporarily override CookiesFile or use the function)
26
+ // Since CookiesFile is a constant ("cookies.json"), we should back up existing cookies.json if it exists,
27
+ // write our dummy data, run the test, and restore it.
28
+ const testFile = "cookies.json"
29
+ var backupData []byte
30
+ backupExists := false
31
+
32
+ if _, err := os.Stat(testFile); err == nil {
33
+ backupData, err = os.ReadFile(testFile)
34
+ if err == nil {
35
+ backupExists = true
36
+ }
37
+ }
38
+
39
+ // Clean up after test
40
+ defer func() {
41
+ if backupExists {
42
+ _ = os.WriteFile(testFile, backupData, 0644)
43
+ } else {
44
+ _ = os.Remove(testFile)
45
+ }
46
+ }()
47
+
48
+ // Write test cookies
49
+ data, err := json.Marshal(dummyCookies)
50
+ if err != nil {
51
+ t.Fatalf("Failed to marshal dummy cookies: %v", err)
52
+ }
53
+ if err := os.WriteFile(testFile, data, 0644); err != nil {
54
+ t.Fatalf("Failed to write test cookies.json: %v", err)
55
+ }
56
+
57
+ // 3. Test LoadCookiesFromFile
58
+ LoadCookiesFromFile()
59
+
60
+ if !HasCookies() {
61
+ t.Errorf("Expected HasCookies() to be true, got false")
62
+ }
63
+
64
+ // 4. Test GetCookieValue
65
+ oaiDid := GetCookieValue("oai-did")
66
+ if oaiDid != "test-device-id-123" {
67
+ t.Errorf("Expected oai-did to be 'test-device-id-123', got '%s'", oaiDid)
68
+ }
69
+
70
+ // 5. Test GetCachedCookieHeader
71
+ header := GetCachedCookieHeader()
72
+ if !strings.Contains(header, "oai-did=test-device-id-123") {
73
+ t.Errorf("Expected header to contain 'oai-did=test-device-id-123', got '%s'", header)
74
+ }
75
+ if !strings.Contains(header, "__Secure-next-auth.session-token=test-session-token-abc") {
76
+ t.Errorf("Expected header to contain session token, got '%s'", header)
77
+ }
78
+
79
+ // 6. Test HandleCookiePayload directly
80
+ newDummyCookies := []CookieObject{
81
+ {
82
+ Domain: ".chatgpt.com",
83
+ Name: "oai-did",
84
+ Value: "updated-device-id",
85
+ },
86
+ }
87
+ HandleCookiePayload(newDummyCookies)
88
+
89
+ updatedOaiDid := GetCookieValue("oai-did")
90
+ if updatedOaiDid != "updated-device-id" {
91
+ t.Errorf("Expected updated oai-did to be 'updated-device-id', got '%s'", updatedOaiDid)
92
+ }
93
+ }
94
+
95
+ func TestSolveFNVPow(t *testing.T) {
96
+ // Verify that the solver functions and can find a solution for a very easy difficulty
97
+ // Or at least it doesn't crash.
98
+ // Difficulty is a hex string prefix. Let's pass a very high/easy difficulty threshold
99
+ // to make sure it finishes quickly. "ffff" means any hash <= "ffff" (which is almost all of them since it's 8 hex chars max).
100
+ // Let's use "9" as difficulty which is easy enough to solve in a few nonces.
101
+ token := SolveFNVPow("0.abcdef123456", "9")
102
+ t.Logf("Solved PoW token: %s", token)
103
+ // It should either solve it or exit. Let's make sure it doesn't crash.
104
+ }
chatgpt-free-api/extension.go ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "log"
6
+ "os"
7
+ "os/exec"
8
+ "runtime"
9
+ "sync"
10
+ "time"
11
+
12
+ "github.com/gorilla/websocket"
13
+ )
14
+
15
+ type WSMessage struct {
16
+ Type string `json:"type,omitempty"`
17
+ Method string `json:"method,omitempty"`
18
+ ID string `json:"id,omitempty"`
19
+ Params map[string]any `json:"params,omitempty"`
20
+ Result any `json:"result,omitempty"`
21
+ Error string `json:"error,omitempty"`
22
+ }
23
+
24
+ var (
25
+ extConn *websocket.Conn
26
+ extMu sync.Mutex
27
+ reloadTriggered bool
28
+ pendingMu sync.Mutex
29
+ pending = map[string]chan WSMessage{}
30
+ extInfo map[string]any
31
+ lastBrowserOpenMu sync.Mutex
32
+ lastBrowserOpen time.Time
33
+ )
34
+
35
+ func isExtensionConnected() bool {
36
+ extMu.Lock()
37
+ defer extMu.Unlock()
38
+ return extConn != nil
39
+ }
40
+
41
+ func getExtensionInfo() map[string]any {
42
+ extMu.Lock()
43
+ defer extMu.Unlock()
44
+ if extInfo == nil {
45
+ return nil
46
+ }
47
+ out := map[string]any{}
48
+ for k, v := range extInfo {
49
+ out[k] = v
50
+ }
51
+ return out
52
+ }
53
+
54
+ func openBrowser(url string) error {
55
+ var cmd *exec.Cmd
56
+ switch runtime.GOOS {
57
+ case "windows":
58
+ cmd = exec.Command("cmd", "/c", "start", url)
59
+ case "darwin":
60
+ cmd = exec.Command("open", url)
61
+ default: // "linux", "freebsd", etc.
62
+ cmd = exec.Command("xdg-open", url)
63
+ }
64
+ return cmd.Start()
65
+ }
66
+
67
+ func wakeUpExtension() {
68
+ log.Println("[agent] Extension not connected. Waiting for background connection...")
69
+ }
70
+
71
+ func waitForExtension(timeout time.Duration) bool {
72
+ if isExtensionConnected() {
73
+ return true
74
+ }
75
+ wakeUpExtension()
76
+
77
+ deadline := time.Now().Add(timeout)
78
+ for time.Now().Before(deadline) {
79
+ if isExtensionConnected() {
80
+ return true
81
+ }
82
+ time.Sleep(250 * time.Millisecond)
83
+ }
84
+ return false
85
+ }
86
+
87
+ func handleExtensionMessage(msg WSMessage) {
88
+ if msg.Result != nil {
89
+ if m, ok := msg.Result.(map[string]any); ok {
90
+ if raw, ok := m["rawText"].(string); ok {
91
+ if cfg.RawSSEOutputPath == "" {
92
+ return
93
+ }
94
+ err := os.WriteFile(cfg.RawSSEOutputPath, []byte(raw), 0644)
95
+ if err != nil {
96
+ log.Printf("ERROR WRITING RAW SSE: %v", err)
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ if msg.ID == "" {
103
+ if msg.Type == "extension_ready" {
104
+ extMu.Lock()
105
+ extInfo = msg.Params
106
+ extMu.Unlock()
107
+ log.Printf("[ws] extension ready: %v", msg.Params)
108
+ return
109
+ }
110
+ if msg.Type == "cookies_payload" {
111
+ // Extract cookies from params
112
+ if cookiesRaw, ok := msg.Params["cookies"]; ok {
113
+ raw, _ := json.Marshal(cookiesRaw)
114
+ var cookies []CookieObject
115
+ if err := json.Unmarshal(raw, &cookies); err == nil && len(cookies) > 0 {
116
+ HandleCookiePayload(cookies)
117
+ } else {
118
+ log.Printf("[ws] Failed to parse cookies_payload: %v", err)
119
+ }
120
+ }
121
+ return
122
+ }
123
+ if msg.Type == "sniffed_chat_request" {
124
+ sniff := sniffFromMessage(msg)
125
+ addSniff(sniff)
126
+ log.Printf("πŸ” SNIFFED[%s/%s]: %s %s status=%d", sniff.Source, sniff.Phase, sniff.Method, sniff.URL, sniff.Status)
127
+ if len(sniff.Headers) > 0 {
128
+ log.Printf(" Headers:")
129
+ for k, v := range sniff.Headers {
130
+ log.Printf(" %s: %v", k, v)
131
+ }
132
+ }
133
+ if sniff.Payload != "" {
134
+ log.Printf(" Payload: %s", sniff.Payload)
135
+ }
136
+ if sniff.Response != "" {
137
+ log.Printf(" Response: %s", sniff.Response)
138
+ }
139
+ if sniff.Error != "" {
140
+ log.Printf(" Error: %s", sniff.Error)
141
+ }
142
+ }
143
+ return
144
+ }
145
+
146
+ pendingMu.Lock()
147
+ ch := pending[msg.ID]
148
+ pendingMu.Unlock()
149
+ if ch != nil {
150
+ if msg.Error != "" {
151
+ log.Printf("[ws] response id=%s error=%s", msg.ID[:8], msg.Error)
152
+ } else {
153
+ log.Printf("[ws] response id=%s ok", msg.ID[:8])
154
+ }
155
+ ch <- msg
156
+ return
157
+ }
158
+
159
+ b, _ := json.MarshalIndent(msg, "", " ")
160
+ log.Printf("[ws] unmatched message: %s", b)
161
+ }
chatgpt-free-api/go.mod ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ module chatgpt-agent
2
+
3
+ go 1.26
4
+
5
+ require (
6
+ github.com/google/uuid v1.6.0
7
+ github.com/gorilla/websocket v1.5.3
8
+ )
chatgpt-free-api/go.sum ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
2
+ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
3
+ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
4
+ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
chatgpt-free-api/gpt-extension/background.js ADDED
@@ -0,0 +1,1030 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * GPT Agent Sync β€” Background Service Worker
3
+ * Auto-syncs ChatGPT cookies to Go backend and handles background API/PoW fallbacks.
4
+ */
5
+
6
+ const AGENT_WS_URL = 'ws://127.0.0.1:9225';
7
+ const CHATGPT_URL = 'https://chatgpt.com/';
8
+ const CHATGPT_TAB_URLS = ['https://chatgpt.com/*', 'https://chat.openai.com/*'];
9
+
10
+ let ws = null;
11
+ let lastCookieSyncTime = null;
12
+ let hasCookieSynced = false;
13
+ let cookieSyncDebounceTimeout = null;
14
+ let isRefreshingSession = false;
15
+ let extensionState = 'off';
16
+
17
+ // ChatGPT cookies to sync
18
+ const CHATGPT_COOKIE_DOMAINS = ['.chatgpt.com', 'chatgpt.com', '.chat.openai.com', 'chat.openai.com'];
19
+ const CHATGPT_COOKIE_NAMES = new Set([
20
+ '__Secure-next-auth.session-token',
21
+ '__Host-next-auth.csrf-token',
22
+ '__Secure-next-auth.callback-url',
23
+ 'oai-did',
24
+ '_puid',
25
+ '__cf_bm',
26
+ 'cf_clearance',
27
+ '_cfuvid',
28
+ 'oai-sc',
29
+ 'oai-hlib',
30
+ '__cflb',
31
+ 'intercom-id-dgkjq2bp',
32
+ 'intercom-device-id-dgkjq2bp',
33
+ 'intercom-session-dgkjq2bp',
34
+ ]);
35
+
36
+ // Initialize alarms
37
+ chrome.runtime.onInstalled.addListener(init);
38
+ chrome.runtime.onStartup.addListener(init);
39
+
40
+ chrome.alarms.onAlarm.addListener(async (alarm) => {
41
+ if (alarm.name === 'reconnect') connectToBackend();
42
+ if (alarm.name === 'keepAlive') keepAlive();
43
+ if (alarm.name === 'sessionKeepAlive') {
44
+ console.log('[ChatGPT Sync] Running periodic session keep-alive refresh...');
45
+ ensureChatGPTTabAndSync(true);
46
+ }
47
+ });
48
+
49
+ async function init() {
50
+ connectToBackend();
51
+ // Keep-alive ping every 24 seconds (no cookies payload)
52
+ chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 });
53
+ // Proactively refresh ChatGPT session tab every 15 minutes to rotate cookies
54
+ chrome.alarms.create('sessionKeepAlive', { periodInMinutes: 15 });
55
+
56
+ const data = await chrome.storage.local.get(['lastCookieSyncTime']);
57
+ if (data.lastCookieSyncTime) lastCookieSyncTime = data.lastCookieSyncTime;
58
+ setState('off');
59
+ }
60
+
61
+ // Badge and State Management
62
+ function setState(newState) {
63
+ extensionState = newState;
64
+ const badges = { idle: '●', running: 'β–Ά', off: 'β—‹' };
65
+ const colors = { idle: '#10b981', running: '#f59e0b', off: '#ef4444' };
66
+
67
+ chrome.action.setBadgeText({ text: badges[extensionState] || '' });
68
+ chrome.action.setBadgeBackgroundColor({ color: colors[extensionState] || '#000' });
69
+
70
+ // Notify popup if it's open
71
+ chrome.runtime.sendMessage({ type: 'COOKIE_SYNC_UPDATE' }).catch(() => {});
72
+ }
73
+
74
+ // WebSocket Connection Management
75
+ function connectToBackend() {
76
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
77
+ return;
78
+ }
79
+
80
+ console.log('[ChatGPT Sync] Connecting to local backend at:', AGENT_WS_URL);
81
+ hasCookieSynced = false;
82
+ setState('off');
83
+
84
+ try {
85
+ ws = new WebSocket(AGENT_WS_URL);
86
+ } catch (e) {
87
+ console.error('[ChatGPT Sync] WS Connection Error:', e);
88
+ scheduleReconnect();
89
+ return;
90
+ }
91
+
92
+ ws.onopen = () => {
93
+ console.log('[ChatGPT Sync] Connected to Go Backend!');
94
+ chrome.alarms.clear('reconnect');
95
+ setState('idle');
96
+ performCookieSync();
97
+ };
98
+
99
+ ws.onmessage = async (event) => {
100
+ try {
101
+ const msg = JSON.parse(event.data);
102
+ if (msg.method === 'api_request') {
103
+ await handleApiRequest(msg);
104
+ } else if (msg.method === 'full_conversation') {
105
+ await handleFullConversation(msg);
106
+ } else if (msg.method === 'open_chatgpt') {
107
+ chrome.tabs.create({ url: CHATGPT_URL });
108
+ } else if (msg.method === 'solve_pow') {
109
+ await handleSolvePow(msg);
110
+ } else if (msg.method === 'solve_turnstile') {
111
+ await handleSolveTurnstile(msg);
112
+ } else if (msg.method === 'trigger_sync') {
113
+ console.log('[ChatGPT Sync] Backend requested fresh cookies. Activating session refresh...');
114
+ ensureChatGPTTabAndSync(false);
115
+ } else if (msg.method === 'reload_extension') {
116
+ chrome.runtime.reload();
117
+ }
118
+ } catch (e) {
119
+ console.error('[ChatGPT Sync] Error handling message:', e);
120
+ }
121
+ };
122
+
123
+ ws.onclose = () => {
124
+ console.log('[ChatGPT Sync] Connection closed. Reconnecting...');
125
+ setState('off');
126
+ scheduleReconnect();
127
+ };
128
+
129
+ ws.onerror = (err) => {
130
+ console.error('[ChatGPT Sync] WebSocket Error:', err);
131
+ setState('off');
132
+ };
133
+ }
134
+
135
+ function scheduleReconnect() {
136
+ chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s
137
+ }
138
+
139
+ function keepAlive() {
140
+ if (ws?.readyState === WebSocket.OPEN) {
141
+ ws.send(JSON.stringify({ type: 'ping' }));
142
+ } else {
143
+ connectToBackend();
144
+ }
145
+ }
146
+
147
+ function sendToAgent(msg) {
148
+ if (ws?.readyState === WebSocket.OPEN) {
149
+ ws.send(JSON.stringify(msg));
150
+ }
151
+ }
152
+
153
+ // Cookie Sync Logic
154
+ function performCookieSync() {
155
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
156
+
157
+ chrome.cookies.getAll({}, (cookies) => {
158
+ const chatgptCookies = cookies.filter(c => {
159
+ return CHATGPT_COOKIE_DOMAINS.some(d => c.domain === d || c.domain.endsWith(d));
160
+ });
161
+
162
+ const formatted = chatgptCookies.map(c => {
163
+ let exp = c.expirationDate;
164
+ if (!exp || c.session) {
165
+ exp = Math.floor(Date.now() / 1000) + 31536000; // Default 1 year
166
+ }
167
+ let sameSite = c.sameSite || 'unspecified';
168
+ if (sameSite === 'no_restriction') sameSite = 'none';
169
+
170
+ return {
171
+ domain: c.domain,
172
+ expirationDate: exp,
173
+ hostOnly: c.hostOnly,
174
+ httpOnly: c.httpOnly,
175
+ name: c.name,
176
+ path: c.path,
177
+ sameSite: sameSite,
178
+ secure: c.secure,
179
+ session: c.session,
180
+ storeId: c.storeId || '0',
181
+ value: c.value
182
+ };
183
+ });
184
+
185
+ console.log(`[ChatGPT Sync] Syncing ${formatted.length} cookies to backend`);
186
+ ws.send(JSON.stringify({
187
+ type: 'cookies_payload',
188
+ params: {
189
+ cookies: formatted
190
+ }
191
+ }));
192
+
193
+ hasCookieSynced = true;
194
+ lastCookieSyncTime = Date.now();
195
+ chrome.storage.local.set({ lastCookieSyncTime });
196
+ setState('idle');
197
+ });
198
+ }
199
+
200
+ // Proactive refresh mechanism: opens or reloads ChatGPT tab to rotate cookies
201
+ async function ensureChatGPTTabAndSync(quietMode = false) {
202
+ if (isRefreshingSession) return;
203
+ isRefreshingSession = true;
204
+ setState('running');
205
+
206
+ try {
207
+ const tabs = await chrome.tabs.query({ url: CHATGPT_TAB_URLS });
208
+ if (tabs.length > 0) {
209
+ console.log('[ChatGPT Sync] ChatGPT tab exists. Reloading to rotate cookies...');
210
+ await chrome.tabs.reload(tabs[0].id);
211
+ } else {
212
+ console.log('[ChatGPT Sync] No ChatGPT tab found. Launching background session...');
213
+ await chrome.tabs.create({ url: CHATGPT_URL, active: false });
214
+ }
215
+
216
+ setTimeout(() => {
217
+ if (isRefreshingSession) {
218
+ isRefreshingSession = false;
219
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
220
+ }
221
+ }, 15000);
222
+ } catch (e) {
223
+ console.error('[ChatGPT Sync] Session refresh error:', e);
224
+ isRefreshingSession = false;
225
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
226
+ performCookieSync();
227
+ }
228
+ }
229
+
230
+ // Tab updates to trigger cookie sync on complete load
231
+ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
232
+ const isGptTab = tab.url && CHATGPT_TAB_URLS.some(pattern => {
233
+ const regex = new RegExp(pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'));
234
+ return regex.test(tab.url);
235
+ });
236
+ if (changeInfo.status === 'complete' && isGptTab) {
237
+ console.log('[ChatGPT Sync] ChatGPT tab loaded completely. Performing cookie sync...');
238
+ performCookieSync();
239
+ isRefreshingSession = false;
240
+ setState('idle');
241
+ }
242
+ });
243
+
244
+ // Real-Time Cookie Changed Listener
245
+ chrome.cookies.onChanged.addListener((changeInfo) => {
246
+ const cookie = changeInfo.cookie;
247
+ const isTarget = CHATGPT_COOKIE_DOMAINS.some(d => cookie.domain === d || cookie.domain.endsWith(d));
248
+
249
+ if (isTarget && CHATGPT_COOKIE_NAMES.has(cookie.name)) {
250
+ if (changeInfo.removed) return;
251
+
252
+ console.log(`[ChatGPT Sync] Real-time cookie updated: ${cookie.name}. Scheduling sync...`);
253
+ if (cookieSyncDebounceTimeout) clearTimeout(cookieSyncDebounceTimeout);
254
+ cookieSyncDebounceTimeout = setTimeout(() => {
255
+ console.log('[ChatGPT Sync] Running debounced real-time cookie sync...');
256
+ performCookieSync();
257
+ }, 1500);
258
+ }
259
+ });
260
+
261
+ // Communication with Popup
262
+ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
263
+ if (msg.type === 'GET_STATUS') {
264
+ sendResponse({
265
+ connected: ws && ws.readyState === WebSocket.OPEN,
266
+ lastSyncTime: lastCookieSyncTime,
267
+ hasSyncedOnce: hasCookieSynced,
268
+ state: extensionState
269
+ });
270
+ }
271
+ if (msg.type === 'FORCE_SYNC') {
272
+ ensureChatGPTTabAndSync(false);
273
+ sendResponse({ ok: true });
274
+ }
275
+ return true;
276
+ });
277
+
278
+ // Metadata Helpers
279
+ async function getOaiDeviceId() {
280
+ try {
281
+ const cookie = await chrome.cookies.get({ url: 'https://chatgpt.com', name: 'oai-did' });
282
+ return cookie ? cookie.value : '';
283
+ } catch (e) {
284
+ return '';
285
+ }
286
+ }
287
+
288
+ let cachedBuildNumber = null;
289
+ let cachedBuildId = null;
290
+ let lastCacheTime = 0;
291
+
292
+ async function getClientMetadata() {
293
+ const now = Date.now();
294
+ if (cachedBuildNumber && cachedBuildId && (now - lastCacheTime < 3600000)) {
295
+ return { buildNumber: cachedBuildNumber, buildId: cachedBuildId };
296
+ }
297
+ try {
298
+ const resp = await fetch('https://chatgpt.com/', { credentials: 'omit' });
299
+ if (resp.ok) {
300
+ const text = await resp.text();
301
+ const buildNumberMatch = text.match(/meta name="build-number" content="([^"]+)"/);
302
+ const buildIdMatch = text.match(/"buildId":"([^"]+)"/);
303
+ if (buildNumberMatch) cachedBuildNumber = buildNumberMatch[1];
304
+ if (buildIdMatch) cachedBuildId = buildIdMatch[1];
305
+ lastCacheTime = now;
306
+ }
307
+ } catch (e) {
308
+ console.error('[ChatGPT Sync] Metadata fetch error:', e);
309
+ }
310
+ return {
311
+ buildNumber: cachedBuildNumber || 'main-build-latest',
312
+ buildId: cachedBuildId || 'latest'
313
+ };
314
+ }
315
+
316
+ async function getNativeHeaders() {
317
+ const headers = {};
318
+ const oaiDid = await getOaiDeviceId();
319
+ if (oaiDid) headers['OAI-Device-Id'] = oaiDid;
320
+ headers['OAI-Language'] = self.navigator.language || 'en-US';
321
+
322
+ const meta = await getClientMetadata();
323
+ if (meta.buildNumber) headers['OAI-Client-Build-Number'] = meta.buildNumber;
324
+ if (meta.buildId) headers['OAI-Client-Version'] = meta.buildId;
325
+ return headers;
326
+ }
327
+
328
+ // Background API request handlers
329
+ async function handleApiRequest(msg) {
330
+ const id = msg.id || `api-${Date.now()}`;
331
+ const params = msg.params || {};
332
+ const { url, method = 'GET', headers = {}, body = null, returnHeaders = true, responseType = null } = params;
333
+
334
+ // Allow internal methods
335
+ if (url && url.startsWith('__internal__')) {
336
+ if (url.includes('solve_pow')) {
337
+ await handleSolvePow(msg);
338
+ return;
339
+ }
340
+ if (url.includes('solve_turnstile')) {
341
+ await handleSolveTurnstile(msg);
342
+ return;
343
+ }
344
+ sendToAgent({ id, error: 'UNKNOWN_INTERNAL_METHOD' });
345
+ return;
346
+ }
347
+
348
+ if (!url || !/^https:\/\/(chatgpt\.com|chat\.openai\.com|[a-z0-9]+\.oaiusercontent\.com)\//.test(url)) {
349
+ sendToAgent({ id, error: 'INVALID_URL: ' + (url || 'empty') });
350
+ return;
351
+ }
352
+
353
+ setState('running');
354
+
355
+ try {
356
+ const tab = await getChatGPTTab();
357
+ await waitForTabReady(tab.id);
358
+ const [exec] = await chrome.scripting.executeScript({
359
+ target: { tabId: tab.id },
360
+ world: 'MAIN',
361
+ args: [{ url, method, headers, body, returnHeaders, responseType }],
362
+ func: async ({ url, method, headers, body, returnHeaders, responseType }) => {
363
+ try {
364
+ // Build headers β€” merge provided headers with native OAI headers
365
+ const nativeHeaders = {};
366
+ // Get OAI device ID from cookie
367
+ const cookies = document.cookie.split(';').map(c => c.trim());
368
+ const oaiDid = cookies.find(c => c.startsWith('oai-did='));
369
+ if (oaiDid) nativeHeaders['OAI-Device-Id'] = oaiDid.split('=')[1];
370
+ // Add standard OAI headers that ChatGPT frontend sends
371
+ nativeHeaders['OAI-Language'] = navigator.language || 'en-US';
372
+ // Try to get build number from page meta or global
373
+ try {
374
+ const buildMeta = document.querySelector('meta[name="build-number"]');
375
+ if (buildMeta) nativeHeaders['OAI-Client-Build-Number'] = buildMeta.content;
376
+ if (window.__NEXT_DATA__?.buildId) nativeHeaders['OAI-Client-Version'] = window.__NEXT_DATA__.buildId;
377
+ } catch(e) {}
378
+
379
+ const reqHeaders = { ...nativeHeaders, ...headers };
380
+ if (url.includes('/estuary/')) {
381
+ for (const key of Object.keys(reqHeaders)) {
382
+ if (key.toLowerCase().startsWith('oai-') || key.toLowerCase() === 'authorization') {
383
+ delete reqHeaders[key];
384
+ }
385
+ }
386
+ }
387
+ // Strip browser auth headers for external URLs (Azure blob storage)
388
+ if (url.includes('.oaiusercontent.com/')) {
389
+ for (const key of Object.keys(reqHeaders)) {
390
+ if (key.toLowerCase().startsWith('oai-') || key.toLowerCase() === 'authorization') {
391
+ delete reqHeaders[key];
392
+ }
393
+ }
394
+ }
395
+ const init = { method, headers: reqHeaders, credentials: url.includes('.oaiusercontent.com/') ? 'omit' : 'include' };
396
+ if (body !== null && body !== undefined && method !== 'GET' && method !== 'HEAD') {
397
+ // For binary blob uploads (Azure), convert base64 to Uint8Array
398
+ if (url.includes('.oaiusercontent.com/') && typeof body === 'string' && body.length > 1000) {
399
+ const binaryStr = atob(body);
400
+ const bytes = new Uint8Array(binaryStr.length);
401
+ for (let i = 0; i < binaryStr.length; i++) {
402
+ bytes[i] = binaryStr.charCodeAt(i);
403
+ }
404
+ init.body = bytes.buffer;
405
+ } else {
406
+ init.body = typeof body === 'string' ? body : JSON.stringify(body);
407
+ }
408
+ if (typeof body !== 'string' && !Object.keys(init.headers).some((k) => k.toLowerCase() === 'content-type')) {
409
+ init.headers['Content-Type'] = 'application/json';
410
+ }
411
+ }
412
+ const resp = await fetch(url, init);
413
+ const contentType = resp.headers.get('content-type') || '';
414
+ let respBody;
415
+ let isBase64 = false;
416
+
417
+ if (
418
+ responseType === 'base64' ||
419
+ contentType.startsWith('image/') ||
420
+ contentType.startsWith('audio/') ||
421
+ contentType.startsWith('video/') ||
422
+ contentType.startsWith('application/octet-stream')
423
+ ) {
424
+ const blob = await resp.blob();
425
+ respBody = await new Promise((resolve, reject) => {
426
+ const reader = new FileReader();
427
+ reader.onloadend = () => {
428
+ const parts = reader.result.split(',');
429
+ resolve(parts[1] || parts[0]);
430
+ };
431
+ reader.onerror = reject;
432
+ reader.readAsDataURL(blob);
433
+ });
434
+ isBase64 = true;
435
+ } else {
436
+ respBody = await resp.text();
437
+ }
438
+
439
+ const respHeaders = {};
440
+ if (returnHeaders) {
441
+ resp.headers.forEach((v, k) => { respHeaders[k] = v; });
442
+ }
443
+ return { ok: true, status: resp.status, headers: respHeaders, body: respBody, isBase64, finalUrl: resp.url };
444
+ } catch (e) {
445
+ return { ok: false, error: String(e && e.message || e) };
446
+ }
447
+ },
448
+ });
449
+ const result = exec?.result;
450
+ if (!result || result.ok === false) {
451
+ sendToAgent({ id, error: result?.error || 'FETCH_FAILED' });
452
+ return;
453
+ }
454
+ sendToAgent({ id, result });
455
+ } catch (e) {
456
+ sendToAgent({ id, error: e.message || 'API_REQUEST_FAILED' });
457
+ } finally {
458
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
459
+ }
460
+ }
461
+
462
+ // PoW Solvers
463
+ async function handleSolvePow(msg) {
464
+ const id = msg.id || `pow-${Date.now()}`;
465
+ const params = msg.params || {};
466
+ const { seed, difficulty } = params.body || params;
467
+ if (!seed || !difficulty) {
468
+ sendToAgent({ id, error: 'MISSING_SEED_OR_DIFFICULTY' });
469
+ return;
470
+ }
471
+ setState('running');
472
+ try {
473
+ const token = await solveShaPow(seed, difficulty);
474
+ sendToAgent({ id, result: { ok: true, status: 200, body: JSON.stringify({ token }), headers: {} } });
475
+ } catch (e) {
476
+ sendToAgent({ id, error: e.message || 'POW_SOLVE_FAILED' });
477
+ } finally {
478
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
479
+ }
480
+ }
481
+
482
+ async function handleSolveTurnstile(msg) {
483
+ const id = msg.id || `turnstile-${Date.now()}`;
484
+ sendToAgent({ id, error: 'TURNSTILE_SOLVE_NOT_SUPPORTED_IN_BACKGROUND' });
485
+ }
486
+
487
+ async function solveShaPow(seed, diff) {
488
+ const diffNum = parseInt(diff, 16) || parseInt(diff);
489
+ const prefix = '0'.repeat(Math.ceil(Math.log2(diffNum + 1) / 4));
490
+ const encoder = new TextEncoder();
491
+ for (let nonce = 0; nonce < 1000000; nonce++) {
492
+ const input = `${seed}${nonce}`;
493
+ const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(input));
494
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
495
+ const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
496
+ if (hashHex.startsWith(prefix)) {
497
+ return `gAAAAAB${btoa(input)}`;
498
+ }
499
+ }
500
+ return `gAAAAAB${btoa(seed + '0')}`;
501
+ }
502
+
503
+ async function getChatGPTTab() {
504
+ const tabs = await chrome.tabs.query({ url: CHATGPT_TAB_URLS });
505
+ if (tabs.length > 0) return tabs[0];
506
+ return await chrome.tabs.create({ url: CHATGPT_URL, active: false });
507
+ }
508
+
509
+ async function handleFullConversation(msg) {
510
+ const id = msg.id || `conv-${Date.now()}`;
511
+ const params = msg.params || {};
512
+ const { prompt, model = 'auto', conversation_id, thinking_effort, attachments } = params;
513
+ if (!prompt) {
514
+ sendToAgent({ id, error: 'MISSING_PROMPT' });
515
+ return;
516
+ }
517
+ setState('running');
518
+ try {
519
+ const tab = await getChatGPTTab();
520
+ const targetUrl = conversation_id ? `https://chatgpt.com/c/${conversation_id}` : CHATGPT_URL;
521
+ const isTargetConv = !!conversation_id;
522
+ const isTabConv = tab.url && tab.url.includes('/c/');
523
+ let needsNavigation = false;
524
+ if (isTargetConv) {
525
+ if (!tab.url || !tab.url.startsWith(targetUrl)) {
526
+ needsNavigation = true;
527
+ }
528
+ } else {
529
+ if (!tab.url || isTabConv || !tab.url.startsWith(CHATGPT_URL)) {
530
+ needsNavigation = true;
531
+ }
532
+ }
533
+
534
+ if (needsNavigation) {
535
+ console.log(`[ChatGPT Bridge] Navigating tab ${tab.id} to ${targetUrl}`);
536
+ await chrome.tabs.update(tab.id, { url: targetUrl });
537
+ await waitForTabReady(tab.id);
538
+ await sleep(2000); // Allow time for conversation DOM to load historical message IDs
539
+ } else {
540
+ await waitForTabReady(tab.id);
541
+ }
542
+ await sleep(250);
543
+ const [exec] = await chrome.scripting.executeScript({
544
+ target: { tabId: tab.id },
545
+ world: 'MAIN',
546
+ args: [{ prompt, model, conversation_id, attachments }],
547
+ func: async ({ prompt, model, conversation_id, attachments }) => {
548
+ const previousBridgeFlag = window.__CHATGPT_BRIDGE_ACTIVE__;
549
+ window.__CHATGPT_BRIDGE_ACTIVE__ = true;
550
+ try {
551
+ // Step 1: Get access token from session
552
+ const sessResp = await fetch('/api/auth/session', { credentials: 'include' });
553
+ if (!sessResp.ok) return { ok: false, error: `session_http_${sessResp.status}` };
554
+ const sessData = await sessResp.json();
555
+ const accessToken = sessData.accessToken;
556
+ if (!accessToken) return { ok: false, error: 'NO_ACCESS_TOKEN' };
557
+
558
+ // Build native headers (Device-Id, Language, Build version)
559
+ const nativeHeaders = {};
560
+ const cookies = document.cookie.split(';').map(c => c.trim());
561
+ const oaiDid = cookies.find(c => c.startsWith('oai-did='));
562
+ if (oaiDid) nativeHeaders['OAI-Device-Id'] = oaiDid.split('=')[1];
563
+ nativeHeaders['OAI-Language'] = navigator.language || 'en-US';
564
+ try {
565
+ const buildMeta = document.querySelector('meta[name="build-number"]');
566
+ if (buildMeta) nativeHeaders['OAI-Client-Build-Number'] = buildMeta.content;
567
+ if (window.__NEXT_DATA__?.buildId) nativeHeaders['OAI-Client-Version'] = window.__NEXT_DATA__.buildId;
568
+ } catch(e) {}
569
+
570
+ const encodeConfig = (cfg) => btoa(unescape(encodeURIComponent(JSON.stringify(cfg))));
571
+
572
+ // Step 2: Build browser config for requirements/PoW tokens
573
+ const buildConfig = () => {
574
+ const d = new Date();
575
+ const dateStr = d.toString();
576
+ const getUserMedia = navigator.webkitGetUserMedia || navigator.getUserMedia;
577
+ const mediaSig = getUserMedia ? `webkitGetUserMediaβˆ’${String(getUserMedia)}` : `hardwareConcurrencyβˆ’${navigator.hardwareConcurrency}`;
578
+
579
+ return [
580
+ screen.width + screen.height,
581
+ dateStr,
582
+ 4294705152,
583
+ 0,
584
+ navigator.userAgent,
585
+ null,
586
+ window.__NEXT_DATA__?.buildId || "",
587
+ navigator.language || "en-US",
588
+ (navigator.languages || [navigator.language]).join(','),
589
+ 0,
590
+ mediaSig,
591
+ 'location',
592
+ 'self',
593
+ performance.now() * 1000,
594
+ crypto.randomUUID(),
595
+ '',
596
+ navigator.hardwareConcurrency,
597
+ Date.now() - performance.now(),
598
+ ];
599
+ };
600
+
601
+ const generatePToken = () => {
602
+ const cfg = buildConfig();
603
+ const started = Date.now();
604
+ cfg[3] = 1;
605
+ cfg[9] = Date.now() - started;
606
+ return { token: 'gAAAAAC' + encodeConfig(cfg), config: cfg };
607
+ };
608
+
609
+ const generated = generatePToken();
610
+ const pToken = generated.token;
611
+
612
+ // Step 3: Get chat requirements
613
+ const reqHeaders = {
614
+ 'Authorization': `Bearer ${accessToken}`,
615
+ 'Content-Type': 'application/json',
616
+ ...nativeHeaders,
617
+ };
618
+ const reqResp = await fetch('/backend-api/sentinel/chat-requirements', {
619
+ method: 'POST',
620
+ headers: reqHeaders,
621
+ credentials: 'include',
622
+ body: JSON.stringify({ p: pToken }),
623
+ });
624
+ if (!reqResp.ok) return { ok: false, error: `requirements_http_${reqResp.status}` };
625
+ const reqData = await reqResp.json();
626
+ const chatToken = reqData.token;
627
+
628
+ // Step 4: Solve PoW if required
629
+ let proofToken = null;
630
+ if (reqData.proofofwork?.required) {
631
+ const seed = reqData.proofofwork.seed;
632
+ const difficulty = reqData.proofofwork.difficulty;
633
+ const fnvHash = (input) => {
634
+ let h = 2166136261 >>> 0;
635
+ for (let i = 0; i < input.length; i++) {
636
+ h ^= input.charCodeAt(i);
637
+ h = Math.imul(h, 16777619) >>> 0;
638
+ }
639
+ h ^= h >>> 16;
640
+ h = Math.imul(h, 2246822507) >>> 0;
641
+ h ^= h >>> 13;
642
+ h = Math.imul(h, 3266489909) >>> 0;
643
+ h ^= h >>> 16;
644
+ return (h >>> 0).toString(16).padStart(8, '0');
645
+ };
646
+ const powStarted = Date.now();
647
+ const config = generated.config.slice();
648
+ for (let nonce = 0; nonce < 500000; nonce++) {
649
+ config[3] = nonce;
650
+ config[9] = Date.now() - powStarted;
651
+ const encoded = encodeConfig(config);
652
+ if (fnvHash(seed + encoded).slice(0, difficulty.length) <= difficulty) {
653
+ proofToken = 'gAAAAAB' + encoded + '~S';
654
+ break;
655
+ }
656
+ }
657
+ if (!proofToken) {
658
+ return { ok: false, error: 'POW_SOLVE_FAILED' };
659
+ }
660
+ }
661
+
662
+ // Step 5: Solve turnstile if required
663
+ let turnstileToken = null;
664
+ if (reqData.turnstile?.required) {
665
+ if (typeof turnstile !== 'undefined') {
666
+ try { turnstileToken = turnstile.getResponse(); } catch(e) {}
667
+ }
668
+ if (!turnstileToken) {
669
+ const el = document.querySelector('[name="cf-turnstile-response"]');
670
+ if (el) turnstileToken = el.value;
671
+ }
672
+ // If still no token, try to render a new turnstile
673
+ if (!turnstileToken && typeof turnstile !== 'undefined' && reqData.turnstile.dx) {
674
+ try {
675
+ const container = document.createElement('div');
676
+ container.style.display = 'none';
677
+ document.body.appendChild(container);
678
+ await new Promise((resolve) => {
679
+ turnstile.render(container, {
680
+ sitekey: reqData.turnstile.sitekey || '0x4AAAAAAAx1CyDNL8zOEPe7',
681
+ callback: (token) => { turnstileToken = token; resolve(); },
682
+ 'error-callback': () => resolve(),
683
+ timeout: 10000,
684
+ });
685
+ setTimeout(resolve, 15000);
686
+ });
687
+ container.remove();
688
+ } catch(e) {}
689
+ }
690
+ }
691
+
692
+ // Resolve target conversation ID
693
+ let resolvedConversationId = conversation_id || null;
694
+
695
+ // Resolve parent message ID
696
+ let parentId = 'client-created-root';
697
+ if (resolvedConversationId) {
698
+ try {
699
+ const convUrl = `/backend-api/conversation/${resolvedConversationId}`;
700
+ const convDetailResp = await fetch(convUrl, {
701
+ headers: {
702
+ 'Authorization': `Bearer ${accessToken}`,
703
+ ...nativeHeaders,
704
+ },
705
+ credentials: 'include',
706
+ });
707
+ if (convDetailResp.ok) {
708
+ const convData = await convDetailResp.json();
709
+ if (convData.current_node) {
710
+ parentId = convData.current_node;
711
+ console.log(`[ChatGPT Bridge] Resolved parent message ID from API: ${parentId}`);
712
+ }
713
+ }
714
+ } catch (e) {
715
+ console.error('[ChatGPT Bridge] Failed to fetch conversation detail:', e);
716
+ }
717
+
718
+ if (parentId === 'client-created-root') {
719
+ const msgEls = Array.from(document.querySelectorAll('[data-message-id]'));
720
+ if (msgEls.length > 0) {
721
+ const lastId = msgEls[msgEls.length - 1].getAttribute('data-message-id');
722
+ if (lastId) {
723
+ parentId = lastId;
724
+ console.log(`[ChatGPT Bridge] Resolved parent message ID from DOM: ${parentId}`);
725
+ }
726
+ }
727
+ }
728
+ }
729
+
730
+ return {
731
+ ok: true,
732
+ accessToken,
733
+ nativeHeaders,
734
+ chatToken,
735
+ proofToken,
736
+ turnstileToken,
737
+ parentId,
738
+ resolvedConversationId
739
+ };
740
+ } catch (e) {
741
+ return { ok: false, error: e.message || 'UNKNOWN_ERROR' };
742
+ } finally {
743
+ window.__CHATGPT_BRIDGE_ACTIVE__ = previousBridgeFlag;
744
+ }
745
+ },
746
+ });
747
+ const prep = exec?.result;
748
+ if (!prep || !prep.ok) {
749
+ sendToAgent({ id, error: prep?.error || 'CONVERSATION_PREPARATION_FAILED', result: prep });
750
+ return;
751
+ }
752
+
753
+ const echoStart = Math.max(1000, Math.floor(performance.now()));
754
+ const echoEnd = echoStart + 1000 + Math.floor(Math.random() * 500);
755
+ const convHeaders = {
756
+ 'Authorization': `Bearer ${prep.accessToken}`,
757
+ 'Content-Type': 'application/json',
758
+ 'Accept': 'text/event-stream',
759
+ 'openai-sentinel-chat-requirements-token': prep.chatToken,
760
+ 'oai-echo-logs': `0,${echoStart},1,${echoEnd}`,
761
+ ...prep.nativeHeaders,
762
+ };
763
+ if (prep.proofToken) convHeaders['openai-sentinel-proof-token'] = prep.proofToken;
764
+ if (prep.turnstileToken) convHeaders['openai-sentinel-turnstile-token'] = prep.turnstileToken;
765
+
766
+ let actualModel = model;
767
+ let resolvedThinkingEffort = thinking_effort || null;
768
+ if (model && model.includes('thinking')) {
769
+ if (model.includes('-extended')) {
770
+ resolvedThinkingEffort = 'extended';
771
+ actualModel = model.replace('-extended', '');
772
+ } else if (model.includes('-standard')) {
773
+ resolvedThinkingEffort = 'standard';
774
+ actualModel = model.replace('-standard', '');
775
+ } else if (!resolvedThinkingEffort) {
776
+ resolvedThinkingEffort = 'standard';
777
+ }
778
+ }
779
+
780
+ const commonConversationFields = {
781
+ action: 'next',
782
+ model: actualModel,
783
+ timezone_offset_min: new Date().getTimezoneOffset(),
784
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
785
+ history_and_training_disabled: false,
786
+ fork_from_shared_post: false,
787
+ force_paragen: false,
788
+ force_rate_limit: false,
789
+ conversation_mode: { kind: 'primary_assistant' },
790
+ enable_message_followups: true,
791
+ system_hints: [],
792
+ supports_buffering: true,
793
+ supported_encodings: ['v1'],
794
+ paragen_cot_summary_display_override: 'allow',
795
+ force_parallel_switch: 'auto',
796
+ };
797
+ if (resolvedThinkingEffort) {
798
+ commonConversationFields.thinking_effort = resolvedThinkingEffort;
799
+ }
800
+
801
+ const prepareBody = {
802
+ ...commonConversationFields,
803
+ parent_message_id: prep.parentId,
804
+ };
805
+ if (prep.resolvedConversationId) {
806
+ prepareBody.conversation_id = prep.resolvedConversationId;
807
+ }
808
+
809
+ // Build message content - support multimodal (text + image attachments)
810
+ const messageParts = [prompt];
811
+ const messageAttachments = [];
812
+ if (attachments && attachments.length > 0) {
813
+ for (const att of attachments) {
814
+ messageAttachments.push({
815
+ id: att.id,
816
+ name: att.name || 'image.png',
817
+ size: att.size || 0,
818
+ mime_type: att.mime_type || 'image/png',
819
+ width: att.width || null,
820
+ height: att.height || null,
821
+ });
822
+ }
823
+ }
824
+
825
+ const messageContent = attachments && attachments.length > 0
826
+ ? { content_type: 'multimodal_text', parts: messageParts }
827
+ : { content_type: 'text', parts: [prompt] };
828
+
829
+ const userMessage = {
830
+ id: crypto.randomUUID(),
831
+ author: { role: 'user' },
832
+ content: messageContent,
833
+ metadata: {
834
+ selected_github_repos: [],
835
+ selected_all_github_repos: false,
836
+ serialization_metadata: { custom_symbol_offsets: [] },
837
+ },
838
+ create_time: Math.round(Date.now()) / 1000,
839
+ };
840
+ if (messageAttachments.length > 0) {
841
+ userMessage.metadata.attachments = messageAttachments;
842
+ }
843
+
844
+ const convBody = {
845
+ ...commonConversationFields,
846
+ messages: [userMessage],
847
+ parent_message_id: prep.parentId,
848
+ websocket_request_id: crypto.randomUUID(),
849
+ client_contextual_info: {
850
+ is_dark_mode: false,
851
+ time_since_loaded: 0,
852
+ page_height: 800,
853
+ page_width: 1200,
854
+ pixel_ratio: 1,
855
+ screen_height: 1080,
856
+ screen_width: 1920,
857
+ },
858
+ };
859
+ if (prep.resolvedConversationId) {
860
+ convBody.conversation_id = prep.resolvedConversationId;
861
+ }
862
+
863
+ const prepareHeaders = {
864
+ ...convHeaders,
865
+ 'Accept': 'application/json',
866
+ 'x-conduit-token': 'no-token',
867
+ };
868
+ const prepareResp = await fetch('https://chatgpt.com/backend-api/f/conversation/prepare', {
869
+ method: 'POST',
870
+ headers: prepareHeaders,
871
+ credentials: 'include',
872
+ body: JSON.stringify(prepareBody),
873
+ });
874
+ if (!prepareResp.ok) {
875
+ const errText = await prepareResp.text();
876
+ sendToAgent({ id, error: `prepare_http_${prepareResp.status}: ${errText.slice(0, 500)}` });
877
+ return;
878
+ }
879
+ const prepareText = await prepareResp.text();
880
+ let conduitToken = 'no-token';
881
+ try {
882
+ const prepared = JSON.parse(prepareText);
883
+ conduitToken = prepared.conduit_token || prepared.conduitToken || prepared.token || conduitToken;
884
+ } catch(e) {}
885
+
886
+ const finalHeaders = {
887
+ ...convHeaders,
888
+ 'x-conduit-token': conduitToken,
889
+ };
890
+ const convResp = await fetch('https://chatgpt.com/backend-api/f/conversation', {
891
+ method: 'POST',
892
+ headers: finalHeaders,
893
+ credentials: 'include',
894
+ body: JSON.stringify(convBody),
895
+ });
896
+
897
+ if (!convResp.ok) {
898
+ const errText = await convResp.text();
899
+ sendToAgent({ id, error: `conversation_http_${convResp.status}: ${errText.slice(0, 500)}` });
900
+ return;
901
+ }
902
+
903
+ const text = await convResp.text();
904
+ let convId = '';
905
+ const parseSSEFinal = (raw) => {
906
+ let snapshot = '';
907
+ let delta = '';
908
+ let fileIds = new Set();
909
+ let currentContentType = '';
910
+ let currentRole = '';
911
+ const extractParts0 = (obj) => {
912
+ const role = obj?.message?.author?.role;
913
+ if (role !== 'assistant' && role !== 'tool') return '';
914
+ const parts = obj?.message?.content?.parts;
915
+ if (!Array.isArray(parts)) return '';
916
+ for (const part of parts) {
917
+ if (part && typeof part === 'object' && part.content_type === 'image_asset_pointer' && part.asset_pointer) {
918
+ const match = part.asset_pointer.match(/(?:file-service|sediment):\/\/(file[_-][\w-]+)/);
919
+ if (match && match[1]) {
920
+ const fileId = match[1];
921
+ const isAttachment = attachments && attachments.some(att => att.id === fileId);
922
+ if (!isAttachment) {
923
+ fileIds.add(fileId);
924
+ }
925
+ }
926
+ }
927
+ }
928
+ return typeof parts[0] === 'string' ? parts[0] : '';
929
+ };
930
+
931
+ const processDeltaObj = (ev) => {
932
+ if (
933
+ typeof ev.p === 'string' &&
934
+ ev.p.includes('/message/content/parts/0')
935
+ ) {
936
+ if (ev.o === 'replace' && typeof ev.v === 'string') {
937
+ delta = ev.v;
938
+ } else if ((ev.o === undefined || ev.o === 'append') && typeof ev.v === 'string') {
939
+ delta += ev.v;
940
+ }
941
+ } else if (ev.p === undefined && typeof ev.v === 'string') {
942
+ if (currentContentType === 'text' || currentContentType === 'multimodal_text' || currentContentType === '') {
943
+ delta += ev.v;
944
+ }
945
+ } else if (ev.o === 'patch' && Array.isArray(ev.v)) {
946
+ for (const sub of ev.v) {
947
+ processDeltaObj(sub);
948
+ }
949
+ }
950
+ };
951
+
952
+ const lines = raw.split('\n');
953
+ for (const line of lines) {
954
+ const trimmed = line.trim();
955
+ if (!trimmed.startsWith('data:')) continue;
956
+ const data = trimmed.slice(5).trim();
957
+ if (!data || data === '[DONE]') continue;
958
+ try {
959
+ const parsed = JSON.parse(data);
960
+ if (parsed.conversation_id) {
961
+ convId = parsed.conversation_id;
962
+ }
963
+ if (parsed.v && typeof parsed.v === 'object') {
964
+ if (parsed.v.conversation_id) {
965
+ convId = parsed.v.conversation_id;
966
+ }
967
+ if (parsed.v.message) {
968
+ currentContentType = parsed.v.message.content?.content_type || '';
969
+ currentRole = parsed.v.message.author?.role || '';
970
+ }
971
+ } else if (parsed.message) {
972
+ currentContentType = parsed.message.content?.content_type || '';
973
+ currentRole = parsed.message.author?.role || '';
974
+ }
975
+ const direct = extractParts0(parsed);
976
+ if (direct) snapshot = direct;
977
+ if (parsed.v && typeof parsed.v === 'object') {
978
+ const nested = extractParts0(parsed.v);
979
+ if (nested) snapshot = nested;
980
+ }
981
+ processDeltaObj(parsed);
982
+ } catch(e) {}
983
+ }
984
+ let out = snapshot;
985
+ if (delta) {
986
+ if (delta.length > snapshot.length) {
987
+ out = delta;
988
+ } else if (snapshot && !snapshot.includes(delta)) {
989
+ out = snapshot + delta;
990
+ }
991
+ }
992
+ if (fileIds.size > 0) {
993
+ let imageMarkdown = '\n\n';
994
+ for (const fileId of fileIds) {
995
+ imageMarkdown += `![Generated Image](http://127.0.0.1:9225/api/download?file_id=${fileId}&prompt=${encodeURIComponent(prompt)})\n`;
996
+ }
997
+ out += imageMarkdown;
998
+ }
999
+ return out;
1000
+ };
1001
+ const assistantText = parseSSEFinal(text);
1002
+ if (!assistantText) {
1003
+ sendToAgent({
1004
+ id,
1005
+ error: `EMPTY_ASSISTANT_RESPONSE raw_len=${text.length} raw=${text.replace(/\s+/g, ' ').slice(0, 500)}`,
1006
+ result: { rawText: text }
1007
+ });
1008
+ return;
1009
+ }
1010
+
1011
+ sendToAgent({ id, result: { ok: true, status: 200, body: assistantText, rawText: text, conversation_id: convId || prep.resolvedConversationId, headers: {} } });
1012
+ } catch (e) {
1013
+ sendToAgent({ id, error: e.message || 'FULL_CONVERSATION_FAILED' });
1014
+ } finally {
1015
+ setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off');
1016
+ }
1017
+ }
1018
+
1019
+ async function waitForTabReady(tabId, timeout = 30000) {
1020
+ const start = Date.now();
1021
+ while (Date.now() - start < timeout) {
1022
+ const tab = await chrome.tabs.get(tabId);
1023
+ if (tab.status === 'complete') return;
1024
+ await sleep(300);
1025
+ }
1026
+ }
1027
+
1028
+ function sleep(ms) {
1029
+ return new Promise((resolve) => setTimeout(resolve, ms));
1030
+ }
chatgpt-free-api/gpt-extension/icon128.png ADDED
chatgpt-free-api/gpt-extension/icon16.png ADDED
chatgpt-free-api/gpt-extension/icon48.png ADDED
chatgpt-free-api/gpt-extension/icon_large.png ADDED
chatgpt-free-api/gpt-extension/manifest.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Gpt Agent Api",
4
+ "version": "1.0.0",
5
+ "description": "Auto-syncs ChatGPT cookies to your local ChatGPT Free API server",
6
+ "icons": {
7
+ "16": "icon16.png",
8
+ "48": "icon48.png",
9
+ "128": "icon128.png"
10
+ },
11
+ "permissions": ["cookies", "storage", "alarms", "tabs", "scripting"],
12
+ "host_permissions": [
13
+ "https://chatgpt.com/*",
14
+ "https://chat.openai.com/*"
15
+ ],
16
+ "background": {
17
+ "service_worker": "background.js"
18
+ },
19
+ "action": {
20
+ "default_popup": "popup.html",
21
+ "default_title": "GPT Agent Sync",
22
+ "default_icon": {
23
+ "16": "icon16.png",
24
+ "48": "icon48.png",
25
+ "128": "icon128.png"
26
+ }
27
+ }
28
+ }
chatgpt-free-api/gpt-extension/popup.html ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>
6
+ :root {
7
+ --primary: #10a37f;
8
+ --secondary: #1a7f64;
9
+ --bg: #0b0f19;
10
+ --card: rgba(255, 255, 255, 0.05);
11
+ --text: #ffffff;
12
+ --text-muted: #a0aec0;
13
+ --success: #10b981;
14
+ --danger: #ef4444;
15
+ }
16
+
17
+ body {
18
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
19
+ background: linear-gradient(135deg, #0b0f19 0%, #111827 100%);
20
+ color: var(--text);
21
+ width: 320px;
22
+ margin: 0;
23
+ padding: 16px;
24
+ box-sizing: border-box;
25
+ overflow: hidden;
26
+ }
27
+
28
+ .container {
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: 16px;
32
+ }
33
+
34
+ .header {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 10px;
38
+ }
39
+
40
+ .logo-glow {
41
+ width: 24px;
42
+ height: 24px;
43
+ background: linear-gradient(45deg, var(--primary), var(--secondary));
44
+ border-radius: 50%;
45
+ box-shadow: 0 0 12px var(--primary);
46
+ }
47
+
48
+ h2 {
49
+ margin: 0;
50
+ font-size: 18px;
51
+ font-weight: 700;
52
+ letter-spacing: -0.5px;
53
+ background: linear-gradient(90deg, #ffffff, var(--text-muted));
54
+ -webkit-background-clip: text;
55
+ -webkit-text-fill-color: transparent;
56
+ }
57
+
58
+ .status-card {
59
+ background: var(--card);
60
+ border: 1px solid rgba(255, 255, 255, 0.08);
61
+ border-radius: 12px;
62
+ padding: 14px;
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 8px;
66
+ backdrop-filter: blur(10px);
67
+ }
68
+
69
+ .status-row {
70
+ display: flex;
71
+ justify-content: space-between;
72
+ align-items: center;
73
+ }
74
+
75
+ .status-label {
76
+ color: var(--text-muted);
77
+ font-size: 13px;
78
+ }
79
+
80
+ .status-value {
81
+ font-weight: 600;
82
+ font-size: 13px;
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 6px;
86
+ }
87
+
88
+ .badge {
89
+ display: inline-block;
90
+ width: 8px;
91
+ height: 8px;
92
+ border-radius: 50%;
93
+ }
94
+
95
+ .badge.connected {
96
+ background-color: var(--success);
97
+ box-shadow: 0 0 8px var(--success);
98
+ }
99
+
100
+ .badge.disconnected {
101
+ background-color: var(--danger);
102
+ box-shadow: 0 0 8px var(--danger);
103
+ }
104
+
105
+ .badge.running {
106
+ background-color: #f59e0b;
107
+ box-shadow: 0 0 8px #f59e0b;
108
+ }
109
+
110
+ .btn-sync {
111
+ background: linear-gradient(90deg, var(--primary) 0%, var(--secondary) 100%);
112
+ color: #ffffff;
113
+ border: none;
114
+ border-radius: 8px;
115
+ padding: 10px 16px;
116
+ font-weight: 600;
117
+ font-size: 14px;
118
+ cursor: pointer;
119
+ transition: all 0.2s ease;
120
+ box-shadow: 0 4px 15px rgba(16, 163, 127, 0.3);
121
+ }
122
+
123
+ .btn-sync:hover {
124
+ transform: translateY(-1px);
125
+ box-shadow: 0 6px 20px rgba(16, 163, 127, 0.55);
126
+ }
127
+
128
+ .btn-sync:active {
129
+ transform: translateY(1px);
130
+ }
131
+ </style>
132
+ </head>
133
+ <body>
134
+ <div class="container">
135
+ <div class="header">
136
+ <div class="logo-glow"></div>
137
+ <h2>Gpt Agent Api</h2>
138
+ </div>
139
+
140
+ <div class="status-card">
141
+ <div class="status-row">
142
+ <span class="status-label">Server Connection</span>
143
+ <span class="status-value" id="conn-status">
144
+ <span class="badge disconnected" id="status-badge"></span>
145
+ <span id="status-text">Disconnected</span>
146
+ </span>
147
+ </div>
148
+ <div class="status-row">
149
+ <span class="status-label">Last Sync</span>
150
+ <span class="status-value" id="last-sync" style="color: var(--text-muted);">Never</span>
151
+ </div>
152
+ </div>
153
+
154
+ <button class="btn-sync" id="sync-btn">Force Sync Cookies</button>
155
+ </div>
156
+ <script src="popup.js"></script>
157
+ </body>
158
+ </html>
chatgpt-free-api/gpt-extension/popup.js ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ const statusBadge = document.getElementById('status-badge');
3
+ const statusText = document.getElementById('status-text');
4
+ const lastSync = document.getElementById('last-sync');
5
+ const syncBtn = document.getElementById('sync-btn');
6
+
7
+ function updateUI() {
8
+ chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (response) => {
9
+ if (chrome.runtime.lastError) return;
10
+ if (!response) return;
11
+
12
+ if (response.state === 'running') {
13
+ statusBadge.className = 'badge running';
14
+ statusText.innerText = 'Active';
15
+ statusText.style.color = '#f59e0b';
16
+ } else if (response.connected) {
17
+ statusBadge.className = 'badge connected';
18
+ statusText.innerText = 'Connected';
19
+ statusText.style.color = '#10b981';
20
+ } else {
21
+ statusBadge.className = 'badge disconnected';
22
+ statusText.innerText = 'Disconnected';
23
+ statusText.style.color = '#ef4444';
24
+ }
25
+
26
+ if (response.lastSyncTime) {
27
+ const date = new Date(response.lastSyncTime);
28
+ lastSync.innerText = date.toLocaleTimeString();
29
+ } else {
30
+ lastSync.innerText = 'Never';
31
+ }
32
+ });
33
+ }
34
+
35
+ // Initial update
36
+ updateUI();
37
+
38
+ // Listen for real-time updates from background worker
39
+ chrome.runtime.onMessage.addListener((msg) => {
40
+ if (msg.type === 'COOKIE_SYNC_UPDATE') {
41
+ updateUI();
42
+ }
43
+ });
44
+
45
+ syncBtn.addEventListener('click', () => {
46
+ syncBtn.disabled = true;
47
+ syncBtn.innerText = 'Syncing...';
48
+ chrome.runtime.sendMessage({ type: 'FORCE_SYNC' }, () => {
49
+ setTimeout(() => {
50
+ syncBtn.disabled = false;
51
+ syncBtn.innerText = 'Force Sync Cookies';
52
+ updateUI();
53
+ }, 800);
54
+ });
55
+ });
56
+ });
chatgpt-free-api/handlers.go ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "log"
8
+ "net/http"
9
+ "os"
10
+ "path/filepath"
11
+ "regexp"
12
+ "time"
13
+
14
+ "github.com/google/uuid"
15
+ "github.com/gorilla/websocket"
16
+ )
17
+
18
+ type ChatRequest struct {
19
+ Prompt string `json:"prompt"`
20
+ WaitForResponse bool `json:"wait_for_response"`
21
+ ConversationID string `json:"conversation_id"`
22
+ Model string `json:"model"`
23
+ ThinkingEffort string `json:"thinking_effort"`
24
+ }
25
+
26
+ type BulkChatRequest struct {
27
+ Prompts []string `json:"prompts"`
28
+ WaitForResponse bool `json:"wait_for_response"`
29
+ DelaySeconds int `json:"delay_seconds"`
30
+ }
31
+
32
+ type ChatResult struct {
33
+ Prompt string `json:"prompt"`
34
+ Response string `json:"response,omitempty"`
35
+ Error string `json:"error,omitempty"`
36
+ }
37
+
38
+ type OpenAIChatMessage struct {
39
+ Role string `json:"role"`
40
+ Content string `json:"content"`
41
+ }
42
+
43
+ type OpenAIChatCompletionRequest struct {
44
+ Model string `json:"model"`
45
+ Messages []OpenAIChatMessage `json:"messages"`
46
+ Stream bool `json:"stream"`
47
+ }
48
+
49
+ type OpenAIChoice struct {
50
+ Index int `json:"index"`
51
+ Message OpenAIChatMessage `json:"message"`
52
+ FinishReason string `json:"finish_reason"`
53
+ }
54
+
55
+ type OpenAIChatCompletionResponse struct {
56
+ ID string `json:"id"`
57
+ Object string `json:"object"`
58
+ Created int64 `json:"created"`
59
+ Model string `json:"model"`
60
+ Choices []OpenAIChoice `json:"choices"`
61
+ }
62
+
63
+ var testUpgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
64
+
65
+ func handleWS(w http.ResponseWriter, r *http.Request) {
66
+ conn, err := testUpgrader.Upgrade(w, r, nil)
67
+ if err != nil {
68
+ log.Printf("WS upgrade error: %v", err)
69
+ return
70
+ }
71
+
72
+ extMu.Lock()
73
+ extConn = conn
74
+ if !reloadTriggered {
75
+ reloadTriggered = true
76
+ log.Println("[ws] Sending reload_extension request to sync extension files from disk...")
77
+ _ = conn.WriteJSON(WSMessage{
78
+ Method: "reload_extension",
79
+ })
80
+ }
81
+ extMu.Unlock()
82
+ log.Println("[ws] Extension connected from", r.RemoteAddr)
83
+
84
+ defer func() {
85
+ extMu.Lock()
86
+ if extConn == conn {
87
+ extConn = nil
88
+ }
89
+ extMu.Unlock()
90
+ conn.Close()
91
+ log.Println("Extension disconnected")
92
+
93
+ // Fail all pending channels
94
+ pendingMu.Lock()
95
+ for id, ch := range pending {
96
+ ch <- WSMessage{
97
+ ID: id,
98
+ Error: "extension disconnected",
99
+ }
100
+ }
101
+ pending = map[string]chan WSMessage{}
102
+ pendingMu.Unlock()
103
+ }()
104
+
105
+ for {
106
+ var msg WSMessage
107
+ if err := conn.ReadJSON(&msg); err != nil {
108
+ log.Printf("[ws] read closed: %v", err)
109
+ return
110
+ }
111
+ if msg.Type != "" {
112
+ log.Printf("[ws] event: type=%s", msg.Type)
113
+ }
114
+ handleExtensionMessage(msg)
115
+ }
116
+ }
117
+
118
+ func handleHealth(w http.ResponseWriter, _ *http.Request) {
119
+ writeJSON(w, map[string]any{"ok": true, "mode": "chatgpt-api-bridge", "extensionConnected": isExtensionConnected(), "extension": getExtensionInfo()})
120
+ }
121
+
122
+ func handleCallback(w http.ResponseWriter, r *http.Request) {
123
+ if r.Method != http.MethodPost {
124
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
125
+ return
126
+ }
127
+ var msg WSMessage
128
+ if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
129
+ http.Error(w, err.Error(), http.StatusBadRequest)
130
+ return
131
+ }
132
+ handleExtensionMessage(msg)
133
+ writeJSON(w, map[string]any{"ok": true})
134
+ }
135
+
136
+ func handleChat(w http.ResponseWriter, r *http.Request) {
137
+ req := ChatRequest{WaitForResponse: true}
138
+ if r.Method == http.MethodGet {
139
+ req.Prompt = r.URL.Query().Get("prompt")
140
+ req.ConversationID = r.URL.Query().Get("conversation_id")
141
+ req.Model = r.URL.Query().Get("model")
142
+ req.ThinkingEffort = r.URL.Query().Get("thinking_effort")
143
+ } else if r.Method == http.MethodPost {
144
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
145
+ http.Error(w, err.Error(), http.StatusBadRequest)
146
+ return
147
+ }
148
+ } else {
149
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
150
+ return
151
+ }
152
+ if req.Prompt == "" {
153
+ http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest)
154
+ return
155
+ }
156
+ if req.Model == "" {
157
+ req.Model = cfg.DefaultModel
158
+ }
159
+
160
+ if req.ConversationID == "new" {
161
+ req.ConversationID = ""
162
+ clearActiveConversationID()
163
+ }
164
+
165
+ log.Printf("[chat] sending prompt via API mode (model=%s, thinking_effort=%s, conversation_id=%s): %s", req.Model, req.ThinkingEffort, req.ConversationID, snippet(req.Prompt, 50))
166
+ response, newConvID, err := sendChatWithConversation(req.Prompt, req.ConversationID, req.Model, req.ThinkingEffort)
167
+ if err != nil {
168
+ writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
169
+ return
170
+ }
171
+
172
+ if newConvID != "" {
173
+ setActiveConversationID(newConvID)
174
+ }
175
+
176
+ // Scan the response for download links to include in the JSON
177
+ var images []string
178
+ var fileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`)
179
+ matches := fileIDRegexp.FindAllString(response, -1)
180
+ if len(matches) > 0 {
181
+ seen := make(map[string]bool)
182
+ for _, id := range matches {
183
+ if !seen[id] {
184
+ seen[id] = true
185
+ name := getPromptFilename(req.Prompt, id)
186
+ images = append(images, "output/"+name+".png")
187
+ }
188
+ }
189
+ }
190
+
191
+ writeJSON(w, map[string]any{
192
+ "ok": true,
193
+ "response": response,
194
+ "conversation_id": newConvID,
195
+ "images": images,
196
+ })
197
+ }
198
+
199
+ func handleChatBulk(w http.ResponseWriter, r *http.Request) {
200
+ if r.Method != http.MethodPost {
201
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
202
+ return
203
+ }
204
+ req := BulkChatRequest{WaitForResponse: true, DelaySeconds: cfg.BulkDelaySeconds}
205
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
206
+ http.Error(w, err.Error(), http.StatusBadRequest)
207
+ return
208
+ }
209
+ if len(req.Prompts) == 0 {
210
+ http.Error(w, `{"error":"prompts required"}`, http.StatusBadRequest)
211
+ return
212
+ }
213
+ results := make([]ChatResult, 0, len(req.Prompts))
214
+ for i, prompt := range req.Prompts {
215
+ text, _, err := sendChat(prompt)
216
+ item := ChatResult{Prompt: prompt, Response: text}
217
+ if err != nil {
218
+ item.Error = err.Error()
219
+ }
220
+ results = append(results, item)
221
+ if i < len(req.Prompts)-1 && req.DelaySeconds > 0 {
222
+ time.Sleep(time.Duration(req.DelaySeconds) * time.Second)
223
+ }
224
+ }
225
+ writeJSON(w, map[string]any{"ok": true, "count": len(results), "results": results})
226
+ }
227
+
228
+ func handleChatEdit(w http.ResponseWriter, r *http.Request) {
229
+ if r.Method != http.MethodPost {
230
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
231
+ return
232
+ }
233
+
234
+ // Parse multipart form (max 32MB)
235
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
236
+ http.Error(w, `{"error":"invalid multipart form: `+err.Error()+`"}`, http.StatusBadRequest)
237
+ return
238
+ }
239
+
240
+ prompt := r.FormValue("prompt")
241
+ if prompt == "" {
242
+ http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest)
243
+ return
244
+ }
245
+
246
+ conversationID := r.FormValue("conversation_id")
247
+ model := r.FormValue("model")
248
+ if model == "" {
249
+ model = cfg.DefaultModel
250
+ }
251
+
252
+ if conversationID == "new" {
253
+ conversationID = ""
254
+ clearActiveConversationID()
255
+ }
256
+
257
+ var attachments []FileAttachment
258
+
259
+ // Handle image upload if present
260
+ file, header, err := r.FormFile("image")
261
+ if err == nil {
262
+ defer file.Close()
263
+
264
+ // Save to temp file
265
+ _ = os.MkdirAll("output/tmp", 0755)
266
+ defer os.Remove("output/tmp")
267
+ tmpPath := filepath.Join("output", "tmp", header.Filename)
268
+ out, err := os.Create(tmpPath)
269
+ if err != nil {
270
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": "save temp file: " + err.Error()})
271
+ return
272
+ }
273
+ if _, err := io.Copy(out, file); err != nil {
274
+ out.Close()
275
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": "copy file: " + err.Error()})
276
+ return
277
+ }
278
+ out.Close()
279
+ defer os.Remove(tmpPath)
280
+
281
+ log.Printf("[chat-edit] Uploading image %s to ChatGPT...", header.Filename)
282
+ att, err := uploadFileToChatGPT(tmpPath)
283
+ if err != nil {
284
+ writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": "upload image: " + err.Error()})
285
+ return
286
+ }
287
+ attachments = append(attachments, *att)
288
+ log.Printf("[chat-edit] Image uploaded: %s (file_id: %s)", header.Filename, att.ID)
289
+ }
290
+
291
+ log.Printf("[chat-edit] sending prompt with %d attachment(s): %s", len(attachments), snippet(prompt, 50))
292
+
293
+ response, newConvID, err := sendChatWithConversationAndAttachments(prompt, conversationID, model, cfg.DefaultThinkingEffort, attachments)
294
+ if err != nil {
295
+ writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
296
+ return
297
+ }
298
+
299
+ if newConvID != "" {
300
+ setActiveConversationID(newConvID)
301
+ }
302
+
303
+ // Scan response for image downloads
304
+ var images []string
305
+ var editFileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`)
306
+ matches := editFileIDRegexp.FindAllString(response, -1)
307
+ if len(matches) > 0 {
308
+ uploadedIDs := make(map[string]bool)
309
+ for _, att := range attachments {
310
+ uploadedIDs[att.ID] = true
311
+ }
312
+ seen := make(map[string]bool)
313
+ for _, id := range matches {
314
+ if !seen[id] && !uploadedIDs[id] {
315
+ seen[id] = true
316
+ name := getPromptFilename(prompt, id)
317
+ images = append(images, "output/"+name+".png")
318
+ }
319
+ }
320
+ }
321
+
322
+ writeJSON(w, map[string]any{
323
+ "ok": true,
324
+ "response": response,
325
+ "conversation_id": newConvID,
326
+ "images": images,
327
+ })
328
+ }
329
+
330
+ func handleSniffs(w http.ResponseWriter, r *http.Request) {
331
+ switch r.Method {
332
+ case http.MethodGet:
333
+ sniffMu.Lock()
334
+ out := append([]SniffedRequest(nil), sniffs...)
335
+ sniffMu.Unlock()
336
+ writeJSON(w, map[string]any{"ok": true, "count": len(out), "sniffs": out})
337
+ case http.MethodDelete:
338
+ sniffMu.Lock()
339
+ sniffs = nil
340
+ sniffMu.Unlock()
341
+ writeJSON(w, map[string]any{"ok": true, "cleared": true})
342
+ default:
343
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
344
+ }
345
+ }
346
+
347
+ func handleOpenAIChat(w http.ResponseWriter, r *http.Request) {
348
+ if r.Method != http.MethodPost {
349
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
350
+ return
351
+ }
352
+
353
+ var req OpenAIChatCompletionRequest
354
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
355
+ http.Error(w, err.Error(), http.StatusBadRequest)
356
+ return
357
+ }
358
+
359
+ if len(req.Messages) == 0 {
360
+ http.Error(w, `{"error":{"message":"messages array is empty"}}`, http.StatusBadRequest)
361
+ return
362
+ }
363
+
364
+ // Extract prompt from messages (last user message)
365
+ var prompt string
366
+ for i := len(req.Messages) - 1; i >= 0; i-- {
367
+ if req.Messages[i].Role == "user" {
368
+ prompt = req.Messages[i].Content
369
+ break
370
+ }
371
+ }
372
+ if prompt == "" {
373
+ prompt = req.Messages[len(req.Messages)-1].Content
374
+ }
375
+
376
+ model := req.Model
377
+ if model == "" {
378
+ model = cfg.DefaultModel
379
+ }
380
+
381
+ conversationID := getActiveConversationID()
382
+
383
+ log.Printf("[openai-chat] received OpenAI request (model=%s, conversation_id=%s), sending prompt: %s", model, conversationID, snippet(prompt, 50))
384
+ response, newConvID, err := sendChatWithConversation(prompt, conversationID, model, cfg.DefaultThinkingEffort)
385
+ if err != nil {
386
+ writeJSONStatus(w, http.StatusBadGateway, map[string]any{
387
+ "error": map[string]any{
388
+ "message": err.Error(),
389
+ "type": "api_error",
390
+ },
391
+ })
392
+ return
393
+ }
394
+
395
+ if newConvID != "" {
396
+ setActiveConversationID(newConvID)
397
+ }
398
+
399
+ resp := OpenAIChatCompletionResponse{
400
+ ID: "chatcmpl-" + uuid.NewString()[:12],
401
+ Object: "chat.completion",
402
+ Created: time.Now().Unix(),
403
+ Model: model,
404
+ Choices: []OpenAIChoice{
405
+ {
406
+ Index: 0,
407
+ Message: OpenAIChatMessage{
408
+ Role: "assistant",
409
+ Content: response,
410
+ },
411
+ FinishReason: "stop",
412
+ },
413
+ },
414
+ }
415
+
416
+ writeJSON(w, resp)
417
+ }
418
+
419
+ func handleDownload(w http.ResponseWriter, r *http.Request) {
420
+ fileID := r.URL.Query().Get("file_id")
421
+ if fileID == "" {
422
+ http.Error(w, `{"error":"file_id required"}`, http.StatusBadRequest)
423
+ return
424
+ }
425
+
426
+ // 1. Determine local path based on prompt mapping
427
+ prompt := getFilePrompt(fileID)
428
+ // Also check if prompt is passed in query string as backup
429
+ if prompt == "" {
430
+ prompt = r.URL.Query().Get("prompt")
431
+ }
432
+
433
+ var name string
434
+ var localPath string
435
+ if prompt != "" {
436
+ name = getPromptFilename(prompt, fileID)
437
+ localPath = fmt.Sprintf("output/%s.png", name)
438
+ } else {
439
+ localPath = fmt.Sprintf("output/%s.png", fileID)
440
+ }
441
+
442
+ var data []byte
443
+ var err error
444
+
445
+ // 2. Check if file already exists locally
446
+ if _, err = os.Stat(localPath); err == nil {
447
+ log.Printf("[download] File %s already exists locally, serving from disk", localPath)
448
+ data, err = os.ReadFile(localPath)
449
+ if err != nil {
450
+ log.Printf("[download] Error reading local file %s: %v, will redownload", localPath, err)
451
+ data = nil
452
+ }
453
+ }
454
+
455
+ // 3. If not found or failed to read, download it
456
+ if len(data) == 0 {
457
+ data, err = downloadChatGPTFile(fileID)
458
+ if err != nil {
459
+ log.Printf("[download] error downloading file %s: %v", fileID, err)
460
+ writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
461
+ return
462
+ }
463
+
464
+ // Save file locally to output folder
465
+ _ = os.MkdirAll("output", 0755)
466
+ err = os.WriteFile(localPath, data, 0644)
467
+ if err != nil {
468
+ log.Printf("[download] error saving file locally to %s: %v", localPath, err)
469
+ } else {
470
+ log.Printf("[download] file saved successfully to %s", localPath)
471
+ }
472
+ }
473
+
474
+ w.Header().Set("Content-Type", "image/png")
475
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
476
+
477
+ headerFilename := fileID + ".png"
478
+ if name != "" {
479
+ headerFilename = name + ".png"
480
+ }
481
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", headerFilename))
482
+ w.Write(data)
483
+ }
chatgpt-free-api/helpers.go ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "strings"
7
+ )
8
+
9
+ func intFromAny(v any) int {
10
+ switch x := v.(type) {
11
+ case int:
12
+ return x
13
+ case float64:
14
+ return int(x)
15
+ case json.Number:
16
+ i, _ := x.Int64()
17
+ return int(i)
18
+ default:
19
+ return 0
20
+ }
21
+ }
22
+
23
+ func boolFromAny(v any) (bool, bool) {
24
+ switch x := v.(type) {
25
+ case bool:
26
+ return x, true
27
+ default:
28
+ return false, false
29
+ }
30
+ }
31
+
32
+ func redactHeaderValue(key string, value any) any {
33
+ switch strings.ToLower(key) {
34
+ case "authorization", "openai-sentinel-chat-requirements-token", "openai-sentinel-proof-token", "openai-sentinel-turnstile-token":
35
+ return "[redacted]"
36
+ default:
37
+ return value
38
+ }
39
+ }
40
+
41
+ func writeJSON(w http.ResponseWriter, v any) {
42
+ writeJSONStatus(w, http.StatusOK, v)
43
+ }
44
+
45
+ func writeJSONStatus(w http.ResponseWriter, status int, v any) {
46
+ w.Header().Set("Content-Type", "application/json")
47
+ w.WriteHeader(status)
48
+ json.NewEncoder(w).Encode(v)
49
+ }
chatgpt-free-api/main.go ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ package main
2
+
3
+ func main() {
4
+ Start()
5
+ }
chatgpt-free-api/sniff.go ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "sync"
5
+ "time"
6
+ )
7
+
8
+ type SniffedRequest struct {
9
+ Time string `json:"time"`
10
+ Source string `json:"source,omitempty"`
11
+ Phase string `json:"phase,omitempty"`
12
+ Method string `json:"method,omitempty"`
13
+ URL string `json:"url,omitempty"`
14
+ Status int `json:"status,omitempty"`
15
+ OK *bool `json:"ok,omitempty"`
16
+ Headers map[string]any `json:"headers,omitempty"`
17
+ Payload string `json:"payload,omitempty"`
18
+ Response string `json:"response,omitempty"`
19
+ Error string `json:"error,omitempty"`
20
+ }
21
+
22
+ var (
23
+ sniffMu sync.Mutex
24
+ sniffs []SniffedRequest
25
+ )
26
+
27
+ func sniffFromMessage(msg WSMessage) SniffedRequest {
28
+ source, _ := msg.Params["source"].(string)
29
+ phase, _ := msg.Params["phase"].(string)
30
+ url, _ := msg.Params["url"].(string)
31
+ payload, _ := msg.Params["payload"].(string)
32
+ response, _ := msg.Params["response"].(string)
33
+ errText, _ := msg.Params["error"].(string)
34
+ status := intFromAny(msg.Params["status"])
35
+ headers := map[string]any{}
36
+ if headersMap, ok := msg.Params["headers"].(map[string]any); ok {
37
+ for k, v := range headersMap {
38
+ headers[k] = redactHeaderValue(k, v)
39
+ }
40
+ }
41
+ var okPtr *bool
42
+ if okVal, ok := boolFromAny(msg.Params["ok"]); ok {
43
+ okPtr = &okVal
44
+ }
45
+ if source == "" {
46
+ source = "unknown"
47
+ }
48
+ if phase == "" {
49
+ phase = "request"
50
+ }
51
+ return SniffedRequest{
52
+ Time: time.Now().Format(time.RFC3339),
53
+ Source: source,
54
+ Phase: phase,
55
+ Method: msg.Method,
56
+ URL: url,
57
+ Status: status,
58
+ OK: okPtr,
59
+ Headers: headers,
60
+ Payload: payload,
61
+ Response: response,
62
+ Error: errText,
63
+ }
64
+ }
65
+
66
+ func addSniff(sniff SniffedRequest) {
67
+ sniffMu.Lock()
68
+ defer sniffMu.Unlock()
69
+ sniffs = append([]SniffedRequest{sniff}, sniffs...)
70
+ if len(sniffs) > cfg.MaxSniffs {
71
+ sniffs = sniffs[:cfg.MaxSniffs]
72
+ }
73
+ }
chatgpt-free-api/test.sh ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Unset proxy variables for local communication
4
+ unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy
5
+
6
+ # Colors for output
7
+ GREEN='\033[0;32m'
8
+ RED='\033[0;31m'
9
+ YELLOW='\033[0;33m'
10
+ BLUE='\033[0;34m'
11
+ NC='\033[0m' # No Color
12
+
13
+ echo -e "${BLUE}==================================================${NC}"
14
+ echo -e "${BLUE} ChatGPT Free API Integration Test ${NC}"
15
+ echo -e "${BLUE}==================================================${NC}"
16
+
17
+ # Check if agent is running on port 9225
18
+ echo "πŸ” Checking if agent is running on port 9225..."
19
+ if ! curl -s --connect-timeout 2 http://127.0.0.1:9225/health >/dev/null; then
20
+ echo -e "${RED}❌ Error: Agent is not running on port 9225!${NC}"
21
+ echo -e "πŸ’‘ Please start the server separately first by running: ${YELLOW}./agent${NC} or ${YELLOW}go run main.go${NC}"
22
+ exit 1
23
+ fi
24
+ echo -e "${GREEN}βœ… Agent detected on port 9225! Running tests...${NC}"
25
+
26
+ # Test 1: Check cookie status
27
+ echo -e "\n${BLUE}[Test 1] Checking cookie status...${NC}"
28
+ STATUS_RESP=$(curl -s http://127.0.0.1:9225/api/cookies/status)
29
+ echo "Response: $STATUS_RESP"
30
+ if [[ "$STATUS_RESP" == *"has_cookies\":true"* ]]; then
31
+ TEST1_STATUS="${GREEN}PASSED${NC}"
32
+ else
33
+ TEST1_STATUS="${RED}FAILED (No cookies sync)${NC}"
34
+ fi
35
+
36
+ # Test 2: Send basic chat test
37
+ echo -e "\n${BLUE}[Test 2] Sending basic text chat prompt...${NC}"
38
+ CHAT_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \
39
+ -H "Content-Type: application/json" \
40
+ -d '{"prompt": "Tell me a short programmer joke."}')
41
+ echo "Response: $CHAT_RESP"
42
+
43
+ CONV_ID=$(echo "$CHAT_RESP" | grep -o '"conversation_id":"[^"]*' | cut -d'"' -f4)
44
+ if [ -n "$CONV_ID" ]; then
45
+ TEST2_STATUS="${GREEN}PASSED${NC}"
46
+ else
47
+ TEST2_STATUS="${RED}FAILED (No response or conversation ID)${NC}"
48
+ fi
49
+
50
+ # Test 3: Conversation thread continuity
51
+ if [ -n "$CONV_ID" ]; then
52
+ echo -e "\n${BLUE}[Test 3] Testing thread continuity...${NC}"
53
+ THREAD_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \
54
+ -H "Content-Type: application/json" \
55
+ -d "{\"prompt\": \"Explain that joke.\", \"conversation_id\": \"$CONV_ID\"}")
56
+ echo "Response: $THREAD_RESP"
57
+ if [[ "$THREAD_RESP" == *"ok\":true"* ]]; then
58
+ TEST3_STATUS="${GREEN}PASSED${NC}"
59
+ else
60
+ TEST3_STATUS="${RED}FAILED (Thread reply failed)${NC}"
61
+ fi
62
+ else
63
+ TEST3_STATUS="${YELLOW}SKIPPED (No conversation ID)${NC}"
64
+ fi
65
+
66
+ # Test 4: Bulk chat request
67
+ echo -e "\n${BLUE}[Test 4] Testing bulk chat request...${NC}"
68
+ BULK_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat/bulk \
69
+ -H "Content-Type: application/json" \
70
+ -d '{"prompts": ["What is 2+2?", "What is Go?"], "wait_for_response": true}')
71
+ echo "Response: $BULK_RESP"
72
+ if [[ "$BULK_RESP" == *"ok\":true"* ]]; then
73
+ TEST4_STATUS="${GREEN}PASSED${NC}"
74
+ else
75
+ TEST4_STATUS="${RED}FAILED${NC}"
76
+ fi
77
+
78
+ # Test 5: Image generation (GPT-2)
79
+ echo -e "\n${BLUE}[Test 5] Testing GPT-2 image generation (Cute baby dragon)...${NC}"
80
+ echo "⏳ Polling for GPT-2 generation, this will take 15-35 seconds..."
81
+ GEN_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \
82
+ -H "Content-Type: application/json" \
83
+ -d '{"prompt": "Create a cute 3D cartoon baby dragon sitting on a small chest of gold. Directly generate the image now without asking any questions or confirmation."}')
84
+ echo "Response: $GEN_RESP"
85
+
86
+ # Extract generated image path
87
+ GEN_IMAGE_PATH=$(echo "$GEN_RESP" | grep -o '"images":\["[^"]*' | cut -d'"' -f4)
88
+ if [ -n "$GEN_IMAGE_PATH" ] && [ -f "$GEN_IMAGE_PATH" ]; then
89
+ TEST5_STATUS="${GREEN}PASSED ($GEN_IMAGE_PATH)${NC}"
90
+ else
91
+ TEST5_STATUS="${RED}FAILED (Image file not generated/saved)${NC}"
92
+ fi
93
+
94
+ # Test 6: Image editing (GPT-2 Edit/Vision)
95
+ if [ -n "$GEN_IMAGE_PATH" ] && [ -f "$GEN_IMAGE_PATH" ]; then
96
+ echo -e "\n${BLUE}[Test 6] Testing GPT-2 image editing (Adding wizard hat)...${NC}"
97
+ echo "⏳ Uploading source image and polling for edit, this will take 15-35 seconds..."
98
+ EDIT_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat/edit \
99
+ -F "prompt=Add a small glowing wizard hat to the head of this dragon." \
100
+ -F "image=@$GEN_IMAGE_PATH")
101
+ echo "Response: $EDIT_RESP"
102
+
103
+ EDIT_IMAGE_PATH=$(echo "$EDIT_RESP" | grep -o '"images":\["[^"]*' | cut -d'"' -f4)
104
+ if [ -n "$EDIT_IMAGE_PATH" ] && [ -f "$EDIT_IMAGE_PATH" ]; then
105
+ TEST6_STATUS="${GREEN}PASSED ($EDIT_IMAGE_PATH)${NC}"
106
+ else
107
+ TEST6_STATUS="${RED}FAILED (Edited image file not generated/saved)${NC}"
108
+ fi
109
+ else
110
+ TEST6_STATUS="${YELLOW}SKIPPED (No source image generated in Test 5)${NC}"
111
+ fi
112
+
113
+ echo -e "\n${BLUE}==================================================${NC}"
114
+ echo -e "${BLUE} Test Summary ${NC}"
115
+ echo -e "${BLUE}==================================================${NC}"
116
+ echo -e "1. Cookie Status: $TEST1_STATUS"
117
+ echo -e "2. Basic Chat: $TEST2_STATUS"
118
+ echo -e "3. Thread Continuity: $TEST3_STATUS"
119
+ echo -e "4. Bulk Chat: $TEST4_STATUS"
120
+ echo -e "5. GPT-2 Gen: $TEST5_STATUS"
121
+ echo -e "6. GPT-2 Image Editing: $TEST6_STATUS"
122
+ echo -e "${BLUE}==================================================${NC}"
123
+
124
+ echo -e "\n${GREEN}βœ… Tests completed!${NC}\n"
flow-agent/.gitignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.mp4
2
+ *.avi
3
+ *.mov
4
+ __pycache__/
5
+ *.pyc
6
+ .env
7
+ venv/
8
+ .DS_Store
9
+ sniffed.json
10
+ sniffed_all.json
11
+ media_ids.json
12
+ output/
13
+ chunks/
14
+ *.mp4
15
+ __pycache__/
flow-agent/README.md ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div align="center">
2
+
3
+ # ⚑ Flow Agent
4
+
5
+ ### Generate AI videos & images via HTTP/HTTPS API Server or CLI β€” no API key, no limits.
6
+
7
+ **Omni Flash** for cinematic video generation Β· **Nano Banana 2** for unlimited image creation
8
+ FastAPI Integration Β· Auto watermark removal Β· Reference-based editing Β· Zero setup.
9
+
10
+ ---
11
+
12
+ 🎬 `T2V` `V2V` `I2V` β€” Video generation with auto watermark clean *(uses credits)*
13
+ πŸ–ΌοΈ `T2I` `I2I` β€” Unlimited image generation with reference support *(no credits needed)*
14
+ πŸ”‘ Uses your Google account via Chrome extension β€” **no API key required**
15
+ 🌐 FastAPI HTTP/HTTPS Server β€” **perfect for n8n & external automation**
16
+
17
+ </div>
18
+
19
+
20
+ ## βœ… Features & Status
21
+
22
+ | Feature | What it does | Time | Status |
23
+ |---------|-------------|------|--------|
24
+ | **T2V** | Generate video from text prompt | ~44s | βœ… Working |
25
+ | **T2I** | Generate image from text prompt | ~10-30s | βœ… Working |
26
+ | **V2V** | Edit/restyle existing video | ~3min | βœ… Working |
27
+ | **I2I** | Edit image with reference | ~10-30s | βœ… Working |
28
+ | **I2V** | Animate a still image into video | ~44s | βœ… Working |
29
+ | **FL** | First + Last frame video control | ~44s | βœ… Working |
30
+ | **R2V** | Reference-based video generation | ~44s | βœ… Working |
31
+ | **Upload** | Upload video/image to Flow | ~12s | βœ… Working |
32
+ | **Watermark Remove** | Auto-remove Gemini watermark (~1s) | ~1s | βœ… Auto |
33
+ | **Auto-Retry** | Auto-open/refresh Flow tab for token | auto | βœ… Built-in |
34
+ | **API Sniffer** | Discover new endpoints/payloads | - | βœ… Working |
35
+
36
+ ---
37
+
38
+ ## πŸ“‹ Prerequisites
39
+
40
+ | Requirement | Details |
41
+ |-------------|---------|
42
+ | **Python** | 3.9 or higher |
43
+ | **Chrome** | Latest version |
44
+ | **Google Account** | Logged into Flow |
45
+ | **ffmpeg** | Only for V2V merge (optional) |
46
+
47
+ ---
48
+
49
+ ## πŸ› οΈ Installation (Step by Step)
50
+
51
+ ### Step 1: Clone the repo
52
+
53
+ ```bash
54
+ git clone https://github.com/kodelyx/flow-agent.git
55
+ cd flow-agent
56
+ ```
57
+
58
+ ### Step 2: Install Python dependencies
59
+
60
+ ```bash
61
+ pip install -r requirements.txt
62
+ ```
63
+
64
+ This installs `websockets`, `opencv-python-headless`, and `numpy`.
65
+
66
+ ### Step 3: Install the Chrome Extension
67
+
68
+ 1. Open Chrome browser
69
+ 2. Go to `chrome://extensions` in the address bar
70
+ 3. Toggle **"Developer mode"** ON (top-right corner)
71
+ 4. Click **"Load unpacked"**
72
+ 5. Select the `extension/` folder from this repo
73
+ 6. You should see the **Flow Agent** extension appear
74
+
75
+ ### Step 4: Open Google Flow
76
+
77
+ 1. Open [labs.google/fx/tools/flow](https://labs.google/fx/tools/flow) in Chrome
78
+ 2. Make sure you're **logged into your Google account**
79
+ 3. The extension icon should show a **green badge** = connected
80
+ 4. The extension auto-opens this tab when needed
81
+
82
+ > ⚠️ The Flow tab auto-opens when you run a command. No manual tab management needed!
83
+
84
+ ---
85
+
86
+ ## πŸš€ Usage
87
+
88
+ ### Text β†’ Video (T2V)
89
+
90
+ Generate a new video from a text description.
91
+
92
+ ```bash
93
+ # Basic (portrait 9:16, 10 seconds)
94
+ python -m cli.generate "A samurai drawing his katana on a cliff at golden sunset"
95
+
96
+ # Landscape mode (16:9)
97
+ python -m cli.generate "Eagle soaring over snowy mountains" --aspect landscape
98
+
99
+ # Custom output file
100
+ python -m cli.generate "A dragon breathing fire" -o dragon.mp4
101
+
102
+ # Shorter duration (4/6/8/10 seconds)
103
+ python -m cli.generate "Dog playing in the park" --duration 6
104
+
105
+ # Generate multiple variations
106
+ python -m cli.generate "Cyberpunk city at night" --count 4
107
+
108
+ # I2V β€” animate a still image
109
+ python -m cli.generate "Character comes alive" --start photo.png
110
+
111
+ # FL β€” First + Last frame (controlled transition)
112
+ python -m cli.generate "Person walks forward" --start start.png --end end.png
113
+
114
+ # R2V β€” Reference images (character consistency)
115
+ python -m cli.generate "Character in new scene" --ref char1.png char2.png
116
+ ```
117
+
118
+ **CLI Options:**
119
+ | Flag | Short | Default | Description |
120
+ |------|-------|---------|-------------|
121
+ | `--output` | `-o` | `omni_output.mp4` | Output filename |
122
+ | `--aspect` | `-a` | `portrait` | `portrait` or `landscape` |
123
+ | `--duration` | `-d` | `10` | `4`, `6`, `8`, or `10` seconds |
124
+ | `--count` | `-c` | `1` | Generate 1-4 videos |
125
+ | `--edit` | `-e` | - | Pass media_id for V2V edit mode |
126
+ | `--start` | `-s` | - | Start frame image (I2V / FL mode) |
127
+ | `--end` | | - | End frame image (use with --start for FL) |
128
+ | `--ref` | `-r` | - | Reference image(s) for R2V mode |
129
+ | `--no-clean` | | - | Skip auto watermark removal |
130
+
131
+ ---
132
+
133
+ ### Text β†’ Image (T2I)
134
+
135
+ Generate images from a text description.
136
+
137
+ ```bash
138
+ # Basic (portrait 9:16)
139
+ python -m cli.image "A dragon breathing fire in a cyberpunk city"
140
+
141
+ # Landscape
142
+ python -m cli.image "Mountain sunset" --aspect landscape -o sunset.png
143
+
144
+ # Square (for logos, icons)
145
+ python -m cli.image "Minimal logo design" --aspect square
146
+
147
+ # Generate 4 variations
148
+ python -m cli.image "Abstract art" --count 4
149
+
150
+ # I2I: Edit with reference image
151
+ python -m cli.image "Make it anime style" --ref original.png -o anime.png
152
+ ```
153
+
154
+ **CLI Options:**
155
+ | Flag | Short | Default | Description |
156
+ |------|-------|---------|-------------|
157
+ | `--output` | `-o` | `output/image.png` | Output filename |
158
+ | `--aspect` | `-a` | `portrait` | `portrait`, `landscape`, `square`, `4x3`, `3x4` |
159
+ | `--count` | `-c` | `1` | Generate 1-4 variations |
160
+ | `--ref` | `-r` | - | Reference image(s) for I2I |
161
+
162
+ ### Upload Video
163
+
164
+ Upload a local video to Google Flow. Returns a `media_id` needed for V2V editing.
165
+
166
+ ```bash
167
+ # Single video
168
+ python -m cli.upload my_video.mp4
169
+
170
+ # Batch upload all .mp4 in a folder
171
+ python -m cli.upload chunks/ --batch
172
+ ```
173
+
174
+ The `media_id` is **automatically saved** to `media-id.js`:
175
+ ```
176
+ my_video.mp4 : 49f7d936-01e3-41ad-917a-2f9bb6ead00b
177
+ ```
178
+
179
+ ---
180
+
181
+ ### Video β†’ Video Edit (V2V)
182
+
183
+ Apply style changes to an uploaded video (e.g., convert to anime).
184
+
185
+ ```bash
186
+ # Step 1: Upload your video
187
+ python -m cli.upload my_video.mp4
188
+ # β†’ media_id saved to media-id.js
189
+
190
+ # Step 2: Edit with style prompt
191
+ python -m cli.edit "Transform into vibrant anime style, Studio Ghibli aesthetic" \
192
+ --media-id 49f7d936-01e3-41ad-917a-2f9bb6ead00b \
193
+ --video-file my_video.mp4 \
194
+ --output output_anime/ \
195
+ --merge
196
+
197
+ # Without local video file (specify duration manually)
198
+ python -m cli.edit "Make it look cyberpunk neon" \
199
+ -m MEDIA_ID \
200
+ --total-seconds 30 \
201
+ -o output_cyber/
202
+ ```
203
+
204
+ **How it works:** Long videos are automatically split into 10s segments, each processed in parallel, then merged with ffmpeg.
205
+
206
+ **CLI Options:**
207
+ | Flag | Short | Required | Description |
208
+ |------|-------|----------|-------------|
209
+ | `--media-id` | `-m` | Yes | Flow media ID (from upload) |
210
+ | `--video-file` | `-v` | No | Local file (auto-detects duration/fps) |
211
+ | `--total-seconds` | `-t` | No | Duration if no local file |
212
+ | `--output` | `-o` | No | Output directory (default: `output/`) |
213
+ | `--aspect` | `-a` | No | `portrait` or `landscape` |
214
+ | `--merge` | | No | Merge segments with ffmpeg |
215
+
216
+ ---
217
+
218
+ ### Image β†’ Video (I2V)
219
+
220
+ Animate a still image into a video. Requires Python scripting (no CLI yet):
221
+
222
+ ```python
223
+ import asyncio
224
+ from omniflash import (
225
+ ExtensionBridge, upload_image, generate_video_i2v,
226
+ poll_status, download_video, ASPECTS, DEFAULT_PROJECT,
227
+ )
228
+
229
+ async def main():
230
+ # Connect to extension
231
+ bridge = ExtensionBridge()
232
+ await bridge.start()
233
+ await bridge.wait_for_extension(30)
234
+
235
+ # Upload your image
236
+ img_id = await upload_image(bridge, "my_image.png")
237
+ print(f"Image uploaded: {img_id}")
238
+
239
+ # Generate video from image
240
+ media_ids = await generate_video_i2v(
241
+ bridge,
242
+ prompt="The character comes alive, dramatic movement, cinematic",
243
+ aspect=ASPECTS['portrait'], # or ASPECTS['landscape']
244
+ project_id=DEFAULT_PROJECT,
245
+ image_media_id=img_id,
246
+ duration=8, # 4, 6, 8, or 10 seconds
247
+ )
248
+
249
+ # Wait for video to finish
250
+ if media_ids:
251
+ await poll_status(bridge, media_ids[0], DEFAULT_PROJECT)
252
+ await download_video(bridge, media_ids[0], "output_i2v.mp4")
253
+
254
+ await bridge.close()
255
+
256
+ asyncio.run(main())
257
+ ```
258
+
259
+ ---
260
+
261
+ ### API Sniffer
262
+
263
+ Capture all API requests made by the Flow UI. Useful for discovering new endpoints or debugging.
264
+
265
+ ```bash
266
+ # Start sniffer, then use Flow UI normally
267
+ python -m cli.sniff
268
+
269
+ # Save captured requests to file
270
+ python -m cli.sniff --save sniffed.json
271
+ ```
272
+
273
+ See [SNIFFING.md](SNIFFING.md) for the full API discovery guide.
274
+
275
+ ---
276
+
277
+ ## 🌐 HTTP/HTTPS API Server (for n8n & external integrations)
278
+
279
+ A FastAPI-based API server is included to trigger all Flow Agent features remotely (e.g., from n8n HTTP Request nodes, custom webhooks, or automation workflows).
280
+
281
+ ### Start the API Server
282
+
283
+ Run the server from the project directory:
284
+ ```bash
285
+ # Standard HTTP (defaults to port 8000)
286
+ venv/bin/python -m cli.api --host 0.0.0.0 --port 8000
287
+
288
+ # Optional HTTPS (auto-generates self-signed SSL certificates)
289
+ venv/bin/python -m cli.api --host 0.0.0.0 --port 8443 --ssl
290
+ ```
291
+
292
+ ### Core API Endpoints
293
+
294
+ | Method | Endpoint | Description |
295
+ |--------|----------|-------------|
296
+ | **GET** | `/health` | Check Extension Bridge health and token status. |
297
+ | **POST** | `/generate/video` | T2V, I2V, FL, R2V video generation. Supports query `?download=true` to stream the binary `.mp4` file. |
298
+ | **POST** | `/generate/image` | T2I, I2I image generation. Supports `?download=true` to stream the binary `.png` file. |
299
+ | **POST** | `/upload/image` | Upload an image to Flow (via file upload or local file path). |
300
+ | **POST** | `/upload/video` | Upload a video to Flow (via file upload or local file path). |
301
+ | **POST** | `/edit/video` | V2V video editing (restyling segment duration). |
302
+ | **GET** | `/download/{filename}` | Download generated image/video files from the `output/` folder. |
303
+
304
+ ### Verify with Integration Tests
305
+
306
+ A comprehensive integration test script is provided to verify all endpoints:
307
+ ```bash
308
+ venv/bin/python test_api.py
309
+ ```
310
+
311
+ See [error.md](error.md) for detailed troubleshooting instructions if you encounter port conflicts or proxy network issues.
312
+
313
+ ---
314
+
315
+ ## 🐍 Python API (for developers)
316
+
317
+ Use the `omniflash` package directly in your own scripts:
318
+
319
+ ```python
320
+ from omniflash import (
321
+ ExtensionBridge, # WebSocket bridge to Chrome extension
322
+ generate_video, # T2V: text β†’ video
323
+ edit_video, # V2V: video β†’ video
324
+ upload_image, # Upload image β†’ get media_id
325
+ generate_video_i2v, # I2V: image β†’ video
326
+ poll_status, # Poll until video is ready
327
+ download_video, # Download finished video
328
+ ASPECTS, # {'portrait': '...', 'landscape': '...'}
329
+ DEFAULT_PROJECT, # Your default project ID
330
+ )
331
+ from omniflash.upload import upload_video # Upload video file
332
+ from omniflash import media_store # Read/write media-id.js
333
+
334
+ # media_store examples:
335
+ media_store.save("video.mp4", "uuid-here") # Save entry
336
+ mid = media_store.get("video.mp4") # Get media_id
337
+ all_entries = media_store.read_entries() # Get all entries
338
+ ```
339
+
340
+ > **Backward compatible:** `from omni import ExtensionBridge, generate_video, ...` still works.
341
+
342
+ ---
343
+
344
+ ## πŸ“ Project Structure
345
+
346
+ ```
347
+ flow-agent/
348
+ β”œβ”€β”€ omniflash/ # Core Python package
349
+ β”‚ β”œβ”€β”€ __init__.py # Public API exports
350
+ β”‚ β”œβ”€β”€ bridge.py # ExtensionBridge (WS + HTTP + auto-retry)
351
+ β”‚ β”œβ”€β”€ config.py # All config hardcoded here
352
+ β”‚ β”œβ”€β”€ media_store.py # media-id.js read/write
353
+ β”‚ β”œβ”€β”€ upload.py # Video upload (GCS resumable)
354
+ β”‚ β”œβ”€β”€ watermark.py # Auto watermark removal (embedded assets)
355
+ β”‚ └── generators/ # API functions
356
+ β”‚ β”œβ”€β”€ common.py # poll_status, download_video
357
+ β”‚ β”œβ”€β”€ t2v.py # Text β†’ Video
358
+ β”‚ β”œβ”€β”€ t2i.py # Text β†’ Image + I2I
359
+ β”‚ β”œβ”€β”€ v2v.py # Video β†’ Video (edit)
360
+ β”‚ └── i2v.py # Image β†’ Video + upload_image
361
+ β”œβ”€β”€ cli/ # CLI entry points
362
+ β”‚ β”œβ”€β”€ generate.py # python -m cli.generate
363
+ β”‚ β”œβ”€β”€ image.py # python -m cli.image
364
+ β”‚ β”œβ”€β”€ upload.py # python -m cli.upload
365
+ β”‚ β”œβ”€β”€ edit.py # python -m cli.edit
366
+ β”‚ └── sniff.py # python -m cli.sniff
367
+ β”œβ”€β”€ extension/ # Chrome extension
368
+ β”‚ β”œβ”€β”€ manifest.json # Extension manifest
369
+ β”‚ β”œβ”€β”€ background.js # WS client, API proxy
370
+ β”‚ β”œβ”€β”€ content.js # Page ↔ background bridge
371
+ β”‚ └── injected.js # Fetch interceptor, reCAPTCHA
372
+ β”œβ”€β”€ omni.py # Backward-compatible wrapper
373
+ β”œβ”€β”€ .gitignore # Git ignore rules
374
+ β”œβ”€β”€ media-id.js # Auto-updated filename β†’ media_id
375
+ β”œβ”€β”€ requirements.txt # Python dependencies
376
+ β”œβ”€β”€ SNIFFING.md # API discovery guide
377
+ └── README.md
378
+ ```
379
+
380
+ ---
381
+
382
+ ## βš™οΈ How It Works
383
+
384
+ ```
385
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
386
+ β”‚ Your Terminal / Python Script β”‚
387
+ β”‚ python -m cli.generate "..." β”‚
388
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
389
+ β”‚ import omniflash
390
+ β–Ό
391
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
392
+ β”‚ omniflash package β”‚
393
+ β”‚ ExtensionBridge (WS + HTTP) β”‚
394
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
395
+ β”‚ WebSocket (:9222)
396
+ β”‚ HTTP callback (:8100)
397
+ β–Ό
398
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
399
+ β”‚ Chrome Extension (Flow Agent) β”‚
400
+ β”‚ Auth token + reCAPTCHA solving β”‚
401
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
402
+ β”‚ HTTPS (browser cookies)
403
+ β–Ό
404
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
405
+ β”‚ Google Omni API (aisandbox) β”‚
406
+ β”‚ Video generation / editing β”‚
407
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
408
+ ```
409
+
410
+ 1. Python starts a WebSocket server + HTTP callback server
411
+ 2. Chrome extension auto-connects and provides authentication
412
+ 3. Script sends API requests through the extension
413
+ 4. Extension solves reCAPTCHA and proxies with browser cookies
414
+ 5. Script polls for completion, then downloads the result
415
+
416
+ ---
417
+
418
+ ## 🎯 Models & Endpoints
419
+
420
+ | Model | Key | Duration | Type |
421
+ |-------|-----|----------|------|
422
+ | Omni Flash T2V 4s | `abra_t2v_4s` | 4 sec | Text β†’ Video |
423
+ | Omni Flash T2V 6s | `abra_t2v_6s` | 6 sec | Text β†’ Video |
424
+ | Omni Flash T2V 8s | `abra_t2v_8s` | 8 sec | Text β†’ Video |
425
+ | Omni Flash T2V 10s | `abra_t2v_10s` | 10 sec | Text β†’ Video |
426
+ | Omni Flash Edit | `abra_edit` | 10 sec | Video β†’ Video |
427
+
428
+ | Endpoint | Path |
429
+ |----------|------|
430
+ | T2V | `/v1/video:batchAsyncGenerateVideoText` |
431
+ | I2V | `/v1/video:batchAsyncGenerateVideoStartImage` |
432
+ | V2V Edit | `/v1/video:batchAsyncGenerateVideoEditVideo` |
433
+ | Upload Image | `/v1/flow/uploadImage` |
434
+ | Poll Status | `/v1/video:batchCheckAsyncVideoGenerationStatus` |
435
+ | Get Media | `/v1/video/media/{media_id}` |
436
+
437
+ ---
438
+
439
+ ## πŸ”§ Troubleshooting
440
+
441
+ | Problem | Solution |
442
+ |---------|----------|
443
+ | Extension not connecting | Make sure Flow tab is open and you're logged in |
444
+ | `Address already in use` | Another script is using port 9222/8100. Kill it first |
445
+ | `TIMEOUT` error | Extension may have disconnected. Reload Flow tab |
446
+ | `reCAPTCHA failed` | Reload Flow tab, wait a few seconds, try again |
447
+ | `No media in response` | Check your prompt. Some prompts get blocked |
448
+ | `curl failed` | Upload too large or network issue. Retry |
449
+ | Video quality poor | Use longer duration (10s) and detailed prompts |
450
+ | V2V merge fails | Install ffmpeg: `brew install ffmpeg` |
451
+
452
+ ---
453
+
454
+ ## ⚠️ Important Notes
455
+
456
+ - **Flow tab auto-opens** β€” no manual tab management needed
457
+ - Uses your Google account's **free Flow credits** (check remaining in Flow UI)
458
+ - Extension auto-reconnects and auto-retries (3 attempts)
459
+ - **Watermark auto-removed** on every generated video (~1s)
460
+ - `media-id.js` auto-updates on every upload (video or image)
461
+ - All generated videos save to the `output/` directory by default
462
+ - Old `from omni import ...` syntax still works (backward compatible)
463
+
464
+ ---
465
+
466
+ ## πŸ“„ License
467
+
468
+ MIT
flow-agent/SNIFFING.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸ” API Sniffing Guide
2
+
3
+ How to discover new Google Flow API endpoints using the Chrome extension's built-in request sniffer.
4
+
5
+ ## How It Works
6
+
7
+ ```
8
+ Flow UI (browser)
9
+ ↓ user clicks "Generate" / "Upload" etc.
10
+ ↓
11
+ fetch() call to aisandbox-pa.googleapis.com
12
+ ↓ intercepted by injected.js (monkey-patched fetch)
13
+ ↓
14
+ postMessage β†’ content.js β†’ background.js
15
+ ↓
16
+ HTTP POST β†’ http://127.0.0.1:8100/api/ext/callback
17
+ ↓
18
+ Your sniff server logs the URL + payload
19
+ ```
20
+
21
+ The extension's `injected.js` monkey-patches `window.fetch` to intercept ALL outgoing requests to `aisandbox-pa.googleapis.com`. Every request's URL, method, and body are forwarded to your local server.
22
+
23
+ ## Quick Start
24
+
25
+ ### 1. Start the Sniff Server
26
+
27
+ ```python
28
+ python sniff.py
29
+ ```
30
+
31
+ This starts:
32
+ - WebSocket server on `ws://127.0.0.1:9222` (extension connects here)
33
+ - HTTP server on `http://127.0.0.1:8100` (receives sniffed data)
34
+
35
+ ### 2. Open Flow UI
36
+
37
+ Go to [labs.google/fx/tools/flow](https://labs.google/fx/tools/flow) in Chrome.
38
+ The extension will auto-connect to your sniff server.
39
+
40
+ ### 3. Perform the Action
41
+
42
+ Do whatever you want to discover the API for:
43
+ - Upload an image/video
44
+ - Generate a video
45
+ - Change settings
46
+ - Click any button
47
+
48
+ ### 4. Read the Logs
49
+
50
+ The sniff server prints every intercepted request:
51
+ ```
52
+ πŸ” SNIFFED: https://aisandbox-pa.googleapis.com/v1/video:batchAsyncGenerateVideoText
53
+ Method: POST
54
+ Payload: {"mediaGenerationContext":{"batchId":"..."},"clientContext":{...},"requests":[...]}
55
+ ```
56
+
57
+ ## Sniff Server Code
58
+
59
+ Save this as `sniff.py`:
60
+
61
+ ```python
62
+ #!/usr/bin/env python3
63
+ """Sniff server β€” captures all Flow UI API requests."""
64
+
65
+ import asyncio, json, websockets, logging
66
+ from http.server import HTTPServer, BaseHTTPRequestHandler
67
+ import threading
68
+
69
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%H:%M:%S')
70
+ log = logging.getLogger('sniff')
71
+
72
+ # Store all sniffed requests
73
+ all_requests = []
74
+
75
+ class Handler(BaseHTTPRequestHandler):
76
+ def do_POST(self):
77
+ length = int(self.headers.get('Content-Length', 0))
78
+ body = json.loads(self.rfile.read(length)) if length else {}
79
+
80
+ if body.get('type') == 'sniffed_video_request':
81
+ url = body.get('url', '')
82
+ method = body.get('method', '?')
83
+ payload = body.get('payload', '')
84
+
85
+ log.info('πŸ” %s %s', method, url)
86
+ if payload:
87
+ log.info(' %s', str(payload)[:1000])
88
+
89
+ all_requests.append({
90
+ 'url': url,
91
+ 'method': method,
92
+ 'payload': payload,
93
+ 'timestamp': body.get('timestamp'),
94
+ })
95
+
96
+ self.send_response(200)
97
+ self.send_header('Content-Type', 'application/json')
98
+ self.send_header('Access-Control-Allow-Origin', '*')
99
+ self.end_headers()
100
+ self.wfile.write(b'{"ok":true}')
101
+
102
+ def do_OPTIONS(self):
103
+ self.send_response(200)
104
+ self.send_header('Access-Control-Allow-Origin', '*')
105
+ self.send_header('Access-Control-Allow-Methods', 'POST')
106
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
107
+ self.end_headers()
108
+
109
+ def log_message(self, *a): pass
110
+
111
+ # Start HTTP server
112
+ srv = HTTPServer(('127.0.0.1', 8100), Handler)
113
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
114
+ log.info('HTTP callback on :8100')
115
+
116
+ async def on_connect(ws):
117
+ log.info('βœ… Extension connected!')
118
+ async for raw in ws:
119
+ data = json.loads(raw)
120
+ if data.get('type') == 'token_captured':
121
+ log.info('πŸ”‘ Token captured')
122
+
123
+ async def main():
124
+ async with websockets.serve(on_connect, '127.0.0.1', 9222):
125
+ log.info('⚑ WS server on :9222')
126
+ log.info('πŸ‘‰ Open Flow UI and perform any action...')
127
+ await asyncio.Future()
128
+
129
+ asyncio.run(main())
130
+ ```
131
+
132
+ ## What Gets Captured
133
+
134
+ | Action in Flow UI | API Endpoint |
135
+ |-------------------|-------------|
136
+ | Generate video (T2V) | `/v1/video:batchAsyncGenerateVideoText` |
137
+ | Generate video (I2V) | `/v1/video:batchAsyncGenerateVideoStartImage` |
138
+ | Generate video (Edit) | `/v1/video:batchAsyncGenerateVideoEditVideo` |
139
+ | Poll video status | `/v1/video:batchCheckAsyncVideoGenerationStatus` |
140
+ | Upload image | `/v1/flow/uploadImage` |
141
+ | Generate image | `/v1/projects/{id}/flowMedia:batchGenerateImages` |
142
+ | Get credits | `/v1/credits` |
143
+ | Get media | `/v1/media/{media_id}` |
144
+ | Upscale video | `/v1/video:batchAsyncGenerateVideoUpsampleVideo` |
145
+
146
+ ## How to Find New Endpoints
147
+
148
+ ### Example: Finding Video Upload
149
+
150
+ 1. Start `sniff.py`
151
+ 2. Open Flow UI
152
+ 3. Drag & drop a video file into Flow
153
+ 4. Check logs β€” you'll see the upload URL and payload format
154
+ 5. Add the new endpoint to `models.json`
155
+
156
+ ### Example: Finding Model Keys
157
+
158
+ 1. Start `sniff.py`
159
+ 2. Open Flow UI
160
+ 3. Select different model (e.g., Omni Flash 4s)
161
+ 4. Click Generate
162
+ 5. Check logs β€” look for `videoModelKey` in the payload
163
+
164
+ ```json
165
+ "requests": [{
166
+ "videoModelKey": "abra_t2v_4s", ← this is what you need
167
+ ...
168
+ }]
169
+ ```
170
+
171
+ ## Architecture
172
+
173
+ ```
174
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
175
+ β”‚ injected.js (MAIN world) β”‚
176
+ β”‚ - Monkey-patches window.fetch β”‚
177
+ β”‚ - Captures URL + body of every request β”‚
178
+ β”‚ - Posts to content.js via postMessage β”‚
179
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
180
+ β”‚ postMessage
181
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
182
+ β”‚ content.js (ISOLATED world) β”‚
183
+ β”‚ - Listens for __FLOWKIT_SNIFF__ messages β”‚
184
+ β”‚ - Forwards to background.js β”‚
185
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
186
+ β”‚ chrome.runtime.sendMessage
187
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
188
+ β”‚ background.js (Service Worker) β”‚
189
+ β”‚ - Receives SNIFFED_AISANDBOX_REQUEST β”‚
190
+ β”‚ - POSTs to http://127.0.0.1:8100/callback β”‚
191
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
192
+ β”‚ HTTP POST
193
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
194
+ β”‚ sniff.py (Your server) β”‚
195
+ β”‚ - Logs every request β”‚
196
+ β”‚ - Saves URL + method + payload β”‚
197
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
198
+ ```
199
+
200
+ ## Tips
201
+
202
+ - **Filter by keyword**: Modify the sniff server to only log URLs containing specific words (e.g., `upload`, `video`, `generate`)
203
+ - **Save to file**: Add `json.dump(all_requests, open('sniffed.json', 'w'))` to save all captured requests
204
+ - **Compare payloads**: Run the same action with different settings and diff the payloads to find which fields control what
205
+ - **Telemetry noise**: Ignore URLs containing `batchLog`, `fetchUserRecommendations`, `frontendEvents` β€” these are analytics, not API calls
flow-agent/cli/api.py ADDED
@@ -0,0 +1,617 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """FastAPI Server for Flow Agent β€” expose CLI functionality via HTTP/HTTPS.
3
+
4
+ Allows n8n and other remote systems to trigger video/image generation and upload assets.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ import uuid
10
+ import time
11
+ import shutil
12
+ import base64
13
+ import logging
14
+ import asyncio
15
+ from typing import List, Optional
16
+ from contextlib import asynccontextmanager
17
+
18
+ # Add parent dir to sys.path so omniflash can be imported
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+ from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Query
22
+ from fastapi.responses import FileResponse, JSONResponse
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from pydantic import BaseModel, Field
25
+
26
+ from omniflash import (
27
+ ExtensionBridge, generate_video, edit_video,
28
+ poll_status, download_video, ASPECTS, DEFAULT_PROJECT,
29
+ )
30
+ from omniflash.generators.i2v import upload_image, generate_video_i2v, generate_video_fl, generate_video_r2v
31
+ from omniflash.generators.t2i import generate_image, download_image, IMAGE_ASPECTS
32
+ from omniflash.upload import upload_video
33
+
34
+ # Setup logging
35
+ log = logging.getLogger("omniflash.api")
36
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
37
+
38
+ # Ensure required directories exist
39
+ ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
40
+ OUTPUT_DIR = os.path.join(ROOT_DIR, "output")
41
+ TEMP_DIR = os.path.join(OUTPUT_DIR, ".temp")
42
+
43
+ def ensure_temp_dir():
44
+ os.makedirs(TEMP_DIR, exist_ok=True)
45
+
46
+ def cleanup_temp_dir():
47
+ try:
48
+ if os.path.exists(TEMP_DIR) and not os.listdir(TEMP_DIR):
49
+ os.rmdir(TEMP_DIR)
50
+ except Exception:
51
+ pass
52
+
53
+ # Global ExtensionBridge instance
54
+ bridge: Optional[ExtensionBridge] = None
55
+
56
+ @asynccontextmanager
57
+ async def lifespan(app: FastAPI):
58
+ global bridge
59
+ log.info("πŸš€ Starting Flow Agent Extension Bridge...")
60
+ bridge = ExtensionBridge()
61
+ await bridge.start()
62
+
63
+ # Run extension connection in background so the API server starts immediately
64
+ asyncio.create_task(bridge.wait_for_extension(timeout=30))
65
+
66
+ yield
67
+
68
+ log.info("πŸ”Œ Closing Flow Agent Extension Bridge...")
69
+ if bridge:
70
+ await bridge.close()
71
+ cleanup_temp_dir()
72
+
73
+ app = FastAPI(
74
+ title="Flow Agent API",
75
+ description="API Server to trigger Google Flow AI video and image generation",
76
+ version="1.0.0",
77
+ lifespan=lifespan
78
+ )
79
+
80
+ # Enable CORS for convenience
81
+ app.add_middleware(
82
+ CORSMiddleware,
83
+ allow_origins=["*"],
84
+ allow_credentials=True,
85
+ allow_methods=["*"],
86
+ allow_headers=["*"],
87
+ )
88
+
89
+ # Helper function to check/reconnect the bridge
90
+ async def get_active_bridge() -> ExtensionBridge:
91
+ global bridge
92
+ if not bridge:
93
+ raise HTTPException(status_code=503, detail="Extension bridge is not initialized")
94
+
95
+ # Try a quick health check
96
+ is_healthy = await bridge.health_check()
97
+ if not is_healthy:
98
+ log.info("πŸ”„ Bridge health check failed. Re-waiting for extension connection...")
99
+ # Attempt to reconnect / grab flowKey
100
+ connected = await bridge.wait_for_extension(timeout=10, max_retries=1)
101
+ if not connected:
102
+ raise HTTPException(
103
+ status_code=503,
104
+ detail="Google Flow extension is not connected or unauthorized. Make sure Google Flow tab is open in Chrome."
105
+ )
106
+ return bridge
107
+
108
+ # Helper to process image inputs (local path, media_id, or base64 data)
109
+ async def resolve_image_input(active_bridge: ExtensionBridge, path_or_id_or_b64: str, project_id: str) -> str:
110
+ if not path_or_id_or_b64:
111
+ return ""
112
+
113
+ # Case 1: Base64 data (e.g. data:image/png;base64,... or raw base64)
114
+ if path_or_id_or_b64.startswith("data:") or len(path_or_id_or_b64) > 500:
115
+ try:
116
+ if "," in path_or_id_or_b64:
117
+ base64_data = path_or_id_or_b64.split(",", 1)[1]
118
+ else:
119
+ base64_data = path_or_id_or_b64
120
+
121
+ img_bytes = base64.b64decode(base64_data)
122
+ temp_filename = f"b64_{uuid.uuid4().hex}.png"
123
+ ensure_temp_dir()
124
+ temp_path = os.path.join(TEMP_DIR, temp_filename)
125
+ with open(temp_path, "wb") as f:
126
+ f.write(img_bytes)
127
+
128
+ mid = await upload_image(active_bridge, temp_path, project_id)
129
+ try:
130
+ os.remove(temp_path)
131
+ except OSError:
132
+ pass
133
+ cleanup_temp_dir()
134
+
135
+ if not mid:
136
+ raise HTTPException(status_code=400, detail="Failed to upload base64 image reference")
137
+ return mid
138
+ except Exception as e:
139
+ raise HTTPException(status_code=400, detail=f"Failed parsing base64 image: {str(e)}")
140
+
141
+ # Case 2: Local file path
142
+ if os.path.exists(path_or_id_or_b64):
143
+ mid = await upload_image(active_bridge, path_or_id_or_b64, project_id)
144
+ if not mid:
145
+ raise HTTPException(status_code=400, detail=f"Failed to upload local image path: {path_or_id_or_b64}")
146
+ return mid
147
+
148
+ # Case 3: Already a Media ID (UUID or similar format)
149
+ return path_or_id_or_b64
150
+
151
+
152
+ # Request Models
153
+ class VideoGenerationRequest(BaseModel):
154
+ prompt: str = Field(..., description="Text prompt for video generation")
155
+ aspect: str = Field("portrait", description="Aspect ratio: 'portrait' or 'landscape'")
156
+ duration: int = Field(10, description="Duration in seconds: 4, 6, 8, or 10")
157
+ count: int = Field(1, description="Number of variations (1-4)")
158
+ project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID")
159
+ start: Optional[str] = Field(None, description="Start frame image (file path, media_id, or base64)")
160
+ end: Optional[str] = Field(None, description="End frame image (use with start for FL mode)")
161
+ ref: Optional[List[str]] = Field(None, description="Reference image(s) (file path, media_id, or base64)")
162
+ edit: Optional[str] = Field(None, description="Flow video media_id for video editing (V2V)")
163
+ no_clean: bool = Field(False, description="Skip watermark removal")
164
+
165
+
166
+ class ImageGenerationRequest(BaseModel):
167
+ prompt: str = Field(..., description="Text prompt for image generation")
168
+ aspect: str = Field("portrait", description="Aspect ratio: 'portrait', 'landscape', 'square', '4x3', '3x4'")
169
+ count: int = Field(1, description="Number of variations (1-4)")
170
+ ref: Optional[List[str]] = Field(None, description="Reference image(s) (file path, media_id, or base64)")
171
+ project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID")
172
+
173
+
174
+ class VideoEditRequest(BaseModel):
175
+ prompt: str = Field(..., description="Restyle/edit text prompt")
176
+ video_media_id: str = Field(..., description="Original video media_id")
177
+ aspect: str = Field("portrait", description="Aspect ratio: 'portrait' or 'landscape'")
178
+ fps: int = Field(24, description="FPS of source video")
179
+ duration: int = Field(10, description="Duration of segment to edit")
180
+ start_frame: int = Field(0, description="Start frame index")
181
+ end_frame: Optional[int] = Field(None, description="End frame index")
182
+ project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID")
183
+ download: bool = Field(False, description="Directly download binary video stream")
184
+
185
+
186
+ # API Routes
187
+
188
+ @app.get("/health")
189
+ async def health():
190
+ """Check API server connection and Chrome extension authorization."""
191
+ global bridge
192
+ if not bridge:
193
+ return {"status": "starting", "extension_connected": False, "has_flow_key": False}
194
+
195
+ is_healthy = await bridge.health_check()
196
+ return {
197
+ "status": "healthy" if is_healthy else "disconnected_or_unauthorized",
198
+ "extension_connected": bridge._ws is not None,
199
+ "has_flow_key": bridge._flow_key is not None
200
+ }
201
+
202
+
203
+ @app.post("/upload/image")
204
+ async def api_upload_image(
205
+ file: Optional[UploadFile] = File(None),
206
+ path: Optional[str] = Form(None),
207
+ project_id: str = Form(DEFAULT_PROJECT)
208
+ ):
209
+ """Upload an image to Google Flow. Accepts multipart file upload or local file path."""
210
+ active_bridge = await get_active_bridge()
211
+
212
+ temp_path = None
213
+ if file:
214
+ temp_filename = f"upload_{uuid.uuid4().hex}_{file.filename}"
215
+ ensure_temp_dir()
216
+ temp_path = os.path.join(TEMP_DIR, temp_filename)
217
+ with open(temp_path, "wb") as f:
218
+ shutil.copyfileobj(file.file, f)
219
+ upload_path = temp_path
220
+ elif path:
221
+ if not os.path.exists(path):
222
+ raise HTTPException(status_code=404, detail=f"Local file not found: {path}")
223
+ upload_path = path
224
+ else:
225
+ raise HTTPException(status_code=400, detail="Must provide 'file' (multipart) or 'path' (form parameter)")
226
+
227
+ try:
228
+ media_id = await upload_image(active_bridge, upload_path, project_id)
229
+ if not media_id:
230
+ raise HTTPException(status_code=500, detail="Flow image upload failed")
231
+ return {"success": True, "media_id": media_id}
232
+ finally:
233
+ if temp_path and os.path.exists(temp_path):
234
+ try:
235
+ os.remove(temp_path)
236
+ except OSError:
237
+ pass
238
+ cleanup_temp_dir()
239
+
240
+
241
+ @app.post("/upload/video")
242
+ async def api_upload_video(
243
+ file: Optional[UploadFile] = File(None),
244
+ path: Optional[str] = Form(None),
245
+ project_id: str = Form(DEFAULT_PROJECT)
246
+ ):
247
+ """Upload a video to Google Flow. Accepts multipart file upload or local file path."""
248
+ active_bridge = await get_active_bridge()
249
+
250
+ temp_path = None
251
+ if file:
252
+ temp_filename = f"upload_{uuid.uuid4().hex}_{file.filename}"
253
+ ensure_temp_dir()
254
+ temp_path = os.path.join(TEMP_DIR, temp_filename)
255
+ with open(temp_path, "wb") as f:
256
+ shutil.copyfileobj(file.file, f)
257
+ upload_path = temp_path
258
+ elif path:
259
+ if not os.path.exists(path):
260
+ raise HTTPException(status_code=404, detail=f"Local file not found: {path}")
261
+ upload_path = path
262
+ else:
263
+ raise HTTPException(status_code=400, detail="Must provide 'file' (multipart) or 'path' (form parameter)")
264
+
265
+ try:
266
+ result = await upload_video(upload_path, project_id, active_bridge)
267
+ media_id = result.get("mediaId") or result.get("name") or result.get("id")
268
+ if not media_id and isinstance(result.get("media"), dict):
269
+ media_id = result["media"].get("name") or result["media"].get("mediaId")
270
+
271
+ if not media_id:
272
+ raise HTTPException(status_code=500, detail=f"Flow video upload failed: {result}")
273
+ return {"success": True, "media_id": media_id, "data": result}
274
+ finally:
275
+ if temp_path and os.path.exists(temp_path):
276
+ try:
277
+ os.remove(temp_path)
278
+ except OSError:
279
+ pass
280
+ cleanup_temp_dir()
281
+
282
+
283
+ @app.post("/generate/video")
284
+ async def api_generate_video(req: VideoGenerationRequest, download: bool = Query(False)):
285
+ """Generate or edit video via text prompt and optional references (T2V, I2V, FL, R2V, V2V)."""
286
+ active_bridge = await get_active_bridge()
287
+ aspect = ASPECTS.get(req.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT")
288
+
289
+ # 1. Resolve starting image (I2V / FL)
290
+ start_id = None
291
+ if req.start:
292
+ start_id = await resolve_image_input(active_bridge, req.start, req.project_id)
293
+
294
+ # 2. Resolve end image (FL)
295
+ end_id = None
296
+ if req.end:
297
+ end_id = await resolve_image_input(active_bridge, req.end, req.project_id)
298
+
299
+ # 3. Resolve reference images (R2V)
300
+ ref_ids = []
301
+ if req.ref:
302
+ for r in req.ref:
303
+ mid = await resolve_image_input(active_bridge, r, req.project_id)
304
+ if mid:
305
+ ref_ids.append(mid)
306
+
307
+ # 4. Trigger generation
308
+ media_ids = None
309
+ if start_id and end_id:
310
+ media_ids = await generate_video_fl(
311
+ active_bridge, req.prompt, aspect, req.project_id,
312
+ start_image_id=start_id, end_image_id=end_id, duration=req.duration
313
+ )
314
+ elif start_id:
315
+ media_ids = await generate_video_i2v(
316
+ active_bridge, req.prompt, aspect, req.project_id,
317
+ image_media_id=start_id, duration=req.duration
318
+ )
319
+ elif ref_ids:
320
+ media_ids = await generate_video_r2v(
321
+ active_bridge, req.prompt, aspect, req.project_id,
322
+ ref_media_ids=ref_ids, duration=req.duration
323
+ )
324
+ elif req.edit:
325
+ media_ids = await edit_video(
326
+ active_bridge, req.prompt, aspect, req.project_id,
327
+ video_media_id=req.edit, duration=req.duration
328
+ )
329
+ else:
330
+ media_ids = await generate_video(
331
+ active_bridge, req.prompt, aspect, req.project_id,
332
+ duration=req.duration, count=req.count
333
+ )
334
+
335
+ if not media_ids:
336
+ raise HTTPException(status_code=500, detail="Failed to initiate video generation")
337
+
338
+ outputs = []
339
+ timestamp = int(time.time())
340
+
341
+ # 5. Poll and Download
342
+ for i, media_id in enumerate(media_ids):
343
+ log.info(f"Polling video [{i+1}/{len(media_ids)}] ID: {media_id}")
344
+ if not await poll_status(active_bridge, media_id, req.project_id):
345
+ log.error(f"Polling failed for media ID: {media_id}")
346
+ continue
347
+
348
+ unique_id = uuid.uuid4().hex[:6]
349
+ filename = f"omni_{timestamp}_{unique_id}_{i+1}.mp4"
350
+ out_path = os.path.join(OUTPUT_DIR, filename)
351
+ ensure_temp_dir()
352
+ temp_path = os.path.join(TEMP_DIR, filename)
353
+
354
+ if await download_video(active_bridge, media_id, temp_path):
355
+ if not req.no_clean:
356
+ try:
357
+ from omniflash.watermark import remove_watermark_video
358
+ remove_watermark_video(temp_path, out_path)
359
+ try:
360
+ os.remove(temp_path)
361
+ except OSError:
362
+ pass
363
+ except Exception as e:
364
+ log.warning(f"Watermark removal failed: {e}. Fallback to raw video.")
365
+ os.replace(temp_path, out_path)
366
+ else:
367
+ os.replace(temp_path, out_path)
368
+
369
+ outputs.append({
370
+ "media_id": media_id,
371
+ "filename": filename,
372
+ "local_path": out_path,
373
+ "download_url": f"/download/{filename}"
374
+ })
375
+
376
+ cleanup_temp_dir()
377
+ if not outputs:
378
+ raise HTTPException(status_code=500, detail="Failed to download generated video(s)")
379
+
380
+ # Return binary directly if requested and single file
381
+ if download and len(outputs) == 1:
382
+ return FileResponse(
383
+ path=outputs[0]["local_path"],
384
+ filename=outputs[0]["filename"],
385
+ media_type="video/mp4"
386
+ )
387
+
388
+ return {
389
+ "success": True,
390
+ "outputs": outputs
391
+ }
392
+
393
+
394
+ @app.post("/generate/image")
395
+ async def api_generate_image(req: ImageGenerationRequest, download: bool = Query(False)):
396
+ """Generate image using text prompt and optional reference images (T2I, I2I)."""
397
+ active_bridge = await get_active_bridge()
398
+ aspect = req.aspect
399
+
400
+ # Resolve reference images if any
401
+ ref_ids = []
402
+ if req.ref:
403
+ for r in req.ref:
404
+ mid = await resolve_image_input(active_bridge, r, req.project_id)
405
+ if mid:
406
+ ref_ids.append(mid)
407
+
408
+ results = await generate_image(
409
+ active_bridge, req.prompt, aspect, req.project_id,
410
+ count=req.count, ref_media_ids=ref_ids or None
411
+ )
412
+
413
+ if not results:
414
+ raise HTTPException(status_code=500, detail="Failed to generate image")
415
+
416
+ outputs = []
417
+ timestamp = int(time.time())
418
+
419
+ for i, r in enumerate(results):
420
+ url = r.get("image_url")
421
+ media_id = r.get("media_id")
422
+ if not url:
423
+ continue
424
+
425
+ unique_id = uuid.uuid4().hex[:6]
426
+ filename = f"img_{timestamp}_{unique_id}_{i+1}.png"
427
+ out_path = os.path.join(OUTPUT_DIR, filename)
428
+
429
+ download_success = await download_image(active_bridge, url, out_path)
430
+
431
+ outputs.append({
432
+ "media_id": media_id,
433
+ "filename": filename,
434
+ "local_path": out_path if download_success else None,
435
+ "download_url": f"/download/{filename}" if download_success else None,
436
+ "remote_url": url,
437
+ "downloaded": download_success
438
+ })
439
+
440
+ # Return binary directly if requested, single image, and it was successfully downloaded
441
+ if download and len(outputs) == 1 and outputs[0]["downloaded"]:
442
+ return FileResponse(
443
+ path=outputs[0]["local_path"],
444
+ filename=outputs[0]["filename"],
445
+ media_type="image/png"
446
+ )
447
+
448
+ return {
449
+ "success": True,
450
+ "outputs": outputs
451
+ }
452
+
453
+
454
+ @app.post("/edit/video")
455
+ async def api_edit_video(req: VideoEditRequest):
456
+ """Submit V2V edit request."""
457
+ active_bridge = await get_active_bridge()
458
+ aspect = ASPECTS.get(req.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT")
459
+
460
+ media_ids = await edit_video(
461
+ active_bridge, req.prompt, aspect, req.project_id,
462
+ video_media_id=req.video_media_id, fps=req.fps,
463
+ duration=req.duration, start_frame=req.start_frame,
464
+ end_frame=req.end_frame
465
+ )
466
+
467
+ if not media_ids:
468
+ raise HTTPException(status_code=500, detail="Failed to submit V2V edit request")
469
+
470
+ outputs = []
471
+ timestamp = int(time.time())
472
+
473
+ for i, media_id in enumerate(media_ids):
474
+ log.info(f"Polling edited video [{i+1}/{len(media_ids)}] ID: {media_id}")
475
+ if not await poll_status(active_bridge, media_id, req.project_id):
476
+ continue
477
+
478
+ unique_id = uuid.uuid4().hex[:6]
479
+ filename = f"edit_{timestamp}_{unique_id}_{i+1}.mp4"
480
+ out_path = os.path.join(OUTPUT_DIR, filename)
481
+ ensure_temp_dir()
482
+ temp_path = os.path.join(TEMP_DIR, filename)
483
+
484
+ if await download_video(active_bridge, media_id, temp_path):
485
+ # V2V edited segments might also have watermarks
486
+ try:
487
+ from omniflash.watermark import remove_watermark_video
488
+ remove_watermark_video(temp_path, out_path)
489
+ try:
490
+ os.remove(temp_path)
491
+ except OSError:
492
+ pass
493
+ except Exception as e:
494
+ log.warning(f"Watermark removal failed: {e}. Fallback to raw video.")
495
+ os.replace(temp_path, out_path)
496
+
497
+ outputs.append({
498
+ "media_id": media_id,
499
+ "filename": filename,
500
+ "local_path": out_path,
501
+ "download_url": f"/download/{filename}"
502
+ })
503
+
504
+ cleanup_temp_dir()
505
+ if not outputs:
506
+ raise HTTPException(status_code=500, detail="Failed to download edited video(s)")
507
+
508
+ if req.download and len(outputs) == 1:
509
+ return FileResponse(
510
+ path=outputs[0]["local_path"],
511
+ filename=outputs[0]["filename"],
512
+ media_type="video/mp4"
513
+ )
514
+
515
+ return {
516
+ "success": True,
517
+ "outputs": outputs
518
+ }
519
+
520
+
521
+ @app.get("/download/{filename}")
522
+ async def api_download_file(filename: str):
523
+ """Download generated assets from output folder."""
524
+ file_path = os.path.join(OUTPUT_DIR, filename)
525
+ if not os.path.exists(file_path):
526
+ raise HTTPException(status_code=404, detail="Requested file not found")
527
+
528
+ # Standardize content types
529
+ media_type = "application/octet-stream"
530
+ if filename.endswith(".mp4"):
531
+ media_type = "video/mp4"
532
+ elif filename.endswith(".png"):
533
+ media_type = "image/png"
534
+ elif filename.endswith(".jpg") or filename.endswith(".jpeg"):
535
+ media_type = "image/jpeg"
536
+
537
+ return FileResponse(path=file_path, filename=filename, media_type=media_type)
538
+
539
+
540
+ if __name__ == "__main__":
541
+ import argparse
542
+ parser = argparse.ArgumentParser(description="Flow Agent API Server")
543
+ parser.add_argument("--host", default="127.0.0.1", help="Host address")
544
+ parser.add_argument("--port", type=int, default=8000, help="Port to run on")
545
+ parser.add_argument("--ssl", action="store_true", help="Enable self-signed SSL certificate")
546
+ parser.add_argument("--ssl-certfile", help="SSL certificate file path")
547
+ parser.add_argument("--ssl-keyfile", help="SSL private key file path")
548
+ args = parser.parse_args()
549
+
550
+ ssl_keyfile = args.ssl_keyfile
551
+ ssl_certfile = args.ssl_certfile
552
+
553
+ if args.ssl and not (ssl_keyfile and ssl_certfile):
554
+ try:
555
+ from cryptography import x509
556
+ from cryptography.x509.oid import NameOID
557
+ from cryptography.hazmat.primitives import hashes
558
+ from cryptography.hazmat.primitives.asymmetric import rsa
559
+ from cryptography.hazmat.primitives import serialization
560
+ import datetime
561
+
562
+ # Generate RSA key
563
+ key = rsa.generate_private_key(
564
+ public_exponent=65537,
565
+ key_size=2048,
566
+ )
567
+
568
+ # Create self-signed cert info
569
+ subject = issuer = x509.Name([
570
+ x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"),
571
+ ])
572
+ cert = x509.CertificateBuilder().subject_name(
573
+ subject
574
+ ).issuer_name(
575
+ issuer
576
+ ).public_key(
577
+ key.public_key()
578
+ ).serial_number(
579
+ x509.random_serial_number()
580
+ ).not_valid_before(
581
+ datetime.datetime.utcnow()
582
+ ).not_valid_after(
583
+ datetime.datetime.utcnow() + datetime.timedelta(days=365)
584
+ ).add_extension(
585
+ x509.SubjectAlternativeName([x509.DNSName(u"localhost")]),
586
+ critical=False,
587
+ ).sign(key, hashes.SHA256())
588
+
589
+ ssl_dir = os.path.join(OUTPUT_DIR, ".ssl")
590
+ os.makedirs(ssl_dir, exist_ok=True)
591
+ ssl_keyfile = os.path.join(ssl_dir, "key.pem")
592
+ ssl_certfile = os.path.join(ssl_dir, "cert.pem")
593
+
594
+ with open(ssl_keyfile, "wb") as f:
595
+ f.write(key.private_bytes(
596
+ encoding=serialization.Encoding.PEM,
597
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
598
+ encryption_algorithm=serialization.NoEncryption(),
599
+ ))
600
+ with open(ssl_certfile, "wb") as f:
601
+ f.write(cert.public_bytes(serialization.Encoding.PEM))
602
+
603
+ log.info(f"πŸ”’ Generated temporary self-signed SSL certificate in {ssl_dir}")
604
+ except ImportError:
605
+ log.warning("⚠️ cryptography package not found. Cannot auto-generate self-signed SSL cert.")
606
+ log.warning(" Please install it: pip install cryptography")
607
+ log.warning(" Falling back to standard HTTP.")
608
+ args.ssl = False
609
+
610
+ import uvicorn
611
+ uvicorn.run(
612
+ "cli.api:app",
613
+ host=args.host,
614
+ port=args.port,
615
+ ssl_keyfile=ssl_keyfile if args.ssl or (args.ssl_keyfile and args.ssl_keyfile) else None,
616
+ ssl_certfile=ssl_certfile if args.ssl or (args.ssl_keyfile and args.ssl_keyfile) else None,
617
+ )
flow-agent/cli/edit.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """CLI β€” Full video editor (V2V with segmentation + merge).
3
+
4
+ Usage:
5
+ python -m cli.edit "Make it anime style" -m MEDIA_ID -v video.mp4
6
+ python -m cli.edit "Cyberpunk neon" -m MEDIA_ID --total-seconds 45 -o output/
7
+ """
8
+
9
+ import argparse
10
+ import asyncio
11
+ import logging
12
+ import os
13
+ import subprocess
14
+ import sys
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
17
+
18
+ from omniflash import ExtensionBridge, poll_status, download_video, ASPECTS, DEFAULT_PROJECT
19
+ from omniflash.generators.v2v import edit_video
20
+ from omniflash.generators.common import build_client_context, build_generation_context
21
+ from omniflash.config import ENDPOINTS, CLIENT_CTX, FPS, SEGMENT_DURATION
22
+
23
+ import random
24
+
25
+ log = logging.getLogger("cli.edit")
26
+
27
+
28
+ def get_video_duration(video_path):
29
+ """Get video duration in seconds using ffprobe."""
30
+ try:
31
+ r = subprocess.run(
32
+ ["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
33
+ "-of", "csv=p=0", video_path],
34
+ capture_output=True, text=True
35
+ )
36
+ return float(r.stdout.strip())
37
+ except Exception:
38
+ try:
39
+ size = os.path.getsize(video_path)
40
+ return max(10, size / (1024 * 1024) * 3)
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ def get_video_fps(video_path):
46
+ """Get video FPS using ffprobe."""
47
+ try:
48
+ r = subprocess.run(
49
+ ["ffprobe", "-v", "quiet", "-select_streams", "v:0",
50
+ "-show_entries", "stream=r_frame_rate",
51
+ "-of", "csv=p=0", video_path],
52
+ capture_output=True, text=True
53
+ )
54
+ fps_str = r.stdout.strip()
55
+ if "/" in fps_str:
56
+ num, den = fps_str.split("/")
57
+ return float(num) / float(den)
58
+ return float(fps_str)
59
+ except Exception:
60
+ return FPS
61
+
62
+
63
+ async def edit_segment(bridge, prompt, aspect, project_id, media_id,
64
+ start_frame, end_frame, segment_num, output_dir):
65
+ """Edit a single segment and download."""
66
+ body = {
67
+ "mediaGenerationContext": build_generation_context("BLOCK_SILENCED_VIDEOS"),
68
+ "clientContext": build_client_context(project_id),
69
+ "requests": [{
70
+ "aspectRatio": aspect,
71
+ "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}},
72
+ "videoModelKey": "abra_edit",
73
+ "seed": random.randint(1, 9999),
74
+ "metadata": {},
75
+ "videoInput": {
76
+ "mediaId": media_id,
77
+ "startFrameIndex": start_frame,
78
+ "endFrameIndex": end_frame,
79
+ },
80
+ }],
81
+ }
82
+
83
+ start_sec = start_frame / FPS
84
+ end_sec = end_frame / FPS
85
+ log.info("βœ‚οΈ Segment %d: %.0fs-%.0fs (frames %d-%d)",
86
+ segment_num, start_sec, end_sec, start_frame, end_frame)
87
+
88
+ result = await bridge.api_request(ENDPOINTS["generate_edit"], body)
89
+
90
+ status = result.get("status", 0)
91
+ if status != 200:
92
+ err = result.get("data", {})
93
+ if isinstance(err, dict):
94
+ err = err.get("error", {}).get("message", result.get("error", "Unknown"))
95
+ log.error("❌ Segment %d failed (%s): %s", segment_num, status, err)
96
+ return None
97
+
98
+ data = result.get("data", {})
99
+ media_list = data.get("media", [])
100
+ if not media_list:
101
+ log.error("❌ No media for segment %d", segment_num)
102
+ return None
103
+
104
+ result_media_id = media_list[0].get("name")
105
+ credits = data.get("remainingCredits", "?")
106
+ log.info("βœ… Segment %d submitted! media_id=%s, credits=%s",
107
+ segment_num, result_media_id[:12], credits)
108
+
109
+ if not await poll_status(bridge, result_media_id, project_id):
110
+ return None
111
+
112
+ out_path = os.path.join(output_dir, f"segment_{segment_num:03d}.mp4")
113
+ temp_dir = os.path.join(output_dir, ".temp")
114
+ os.makedirs(temp_dir, exist_ok=True)
115
+ temp_path = os.path.join(temp_dir, f"segment_{segment_num:03d}.mp4")
116
+
117
+ if await download_video(bridge, result_media_id, temp_path):
118
+ # Auto-remove watermark
119
+ try:
120
+ from omniflash.watermark import remove_watermark_video
121
+ remove_watermark_video(temp_path, out_path, show_progress=False)
122
+ os.remove(temp_path)
123
+ except Exception as e:
124
+ os.replace(temp_path, out_path)
125
+ log.warning("⚠️ Watermark removal failed for segment %d: %s", segment_num, e)
126
+ # Cleanup empty .temp dir
127
+ try:
128
+ os.rmdir(temp_dir)
129
+ except OSError:
130
+ pass
131
+ return out_path
132
+ return None
133
+
134
+
135
+ async def run(args):
136
+ aspect = ASPECTS.get(args.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT")
137
+
138
+ total_seconds = args.total_seconds
139
+ fps = FPS
140
+
141
+ if args.video_file and os.path.exists(args.video_file):
142
+ if not total_seconds:
143
+ total_seconds = get_video_duration(args.video_file)
144
+ log.info("πŸ“Ή Video: %s (%.1fs)", args.video_file, total_seconds or 0)
145
+ fps = get_video_fps(args.video_file)
146
+ log.info("πŸ“Ή FPS: %.1f", fps)
147
+
148
+ if not total_seconds:
149
+ log.error("❌ Can't determine video duration. Use --total-seconds")
150
+ return
151
+
152
+ os.makedirs(args.output, exist_ok=True)
153
+
154
+ # Calculate segments
155
+ segments = []
156
+ current = 0
157
+ seg_num = 1
158
+ while current < total_seconds:
159
+ start_frame = int(current * fps)
160
+ end_frame = int(min(current + SEGMENT_DURATION, total_seconds) * fps)
161
+ if end_frame <= start_frame:
162
+ break
163
+ segments.append((seg_num, start_frame, end_frame))
164
+ current += SEGMENT_DURATION
165
+ seg_num += 1
166
+
167
+ log.info("πŸ“‹ Total: %.1fs β†’ %d segments of %ds each",
168
+ total_seconds, len(segments), SEGMENT_DURATION)
169
+ log.info("─" * 50)
170
+
171
+ bridge = ExtensionBridge()
172
+ await bridge.start()
173
+ if not await bridge.wait_for_extension(timeout=30):
174
+ return
175
+
176
+ # Process segments (max 5 concurrent)
177
+ semaphore = asyncio.Semaphore(5)
178
+ results = [None] * len(segments)
179
+
180
+ async def process_segment(idx, seg_num, start_frame, end_frame):
181
+ async with semaphore:
182
+ out = await edit_segment(
183
+ bridge, args.prompt, aspect, args.project_id,
184
+ args.media_id, start_frame, end_frame, seg_num, args.output
185
+ )
186
+ results[idx] = out
187
+
188
+ tasks = [
189
+ asyncio.create_task(process_segment(idx, sn, sf, ef))
190
+ for idx, (sn, sf, ef) in enumerate(segments)
191
+ ]
192
+ await asyncio.gather(*tasks)
193
+ await bridge.close()
194
+
195
+ saved = [r for r in results if r]
196
+
197
+ log.info("─" * 50)
198
+ log.info("πŸŽ‰ Done! %d/%d segments saved to %s/", len(saved), len(segments), args.output)
199
+ for f in saved:
200
+ log.info(" βœ… %s", os.path.basename(f))
201
+
202
+ # Merge with ffmpeg
203
+ if len(saved) > 1 and args.merge:
204
+ merge_path = os.path.join(args.output, "merged_output.mp4")
205
+ log.info("πŸ”— Merging %d segments...", len(saved))
206
+ try:
207
+ concat_file = os.path.join(args.output, "concat.txt")
208
+ with open(concat_file, "w") as f:
209
+ for s in saved:
210
+ f.write(f"file '{os.path.abspath(s)}'\n")
211
+ subprocess.run([
212
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0",
213
+ "-i", concat_file, "-c", "copy", merge_path
214
+ ], capture_output=True)
215
+ os.remove(concat_file)
216
+ # Auto-remove watermark from merged output
217
+ try:
218
+ from omniflash.watermark import remove_watermark_video
219
+ clean_path = remove_watermark_video(merge_path, show_progress=False)
220
+ os.replace(clean_path, merge_path)
221
+ except Exception:
222
+ pass
223
+ log.info("βœ… Merged: %s", merge_path)
224
+ except Exception as e:
225
+ log.warning("⚠️ Merge failed (ffmpeg needed): %s", e)
226
+
227
+
228
+ def main():
229
+ parser = argparse.ArgumentParser(description="Omni Flash β€” Full Video Editor")
230
+ parser.add_argument("prompt", help="Edit prompt")
231
+ parser.add_argument("--media-id", "-m", required=True, help="Flow media ID")
232
+ parser.add_argument("--video-file", "-v", help="Local video (for duration/fps)")
233
+ parser.add_argument("--total-seconds", "-t", type=float)
234
+ parser.add_argument("--output", "-o", default="output", help="Output directory")
235
+ parser.add_argument("--aspect", "-a", choices=["portrait", "landscape"], default="portrait")
236
+ parser.add_argument("--merge", action="store_true")
237
+ parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT)
238
+ args = parser.parse_args()
239
+ asyncio.run(run(args))
240
+
241
+
242
+ if __name__ == "__main__":
243
+ main()
flow-agent/cli/generate.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """CLI β€” Generate video from text prompt (T2V) or edit existing video (V2V).
3
+
4
+ Usage:
5
+ python -m cli.generate "A dragon breathing fire"
6
+ python -m cli.generate "A dragon breathing fire" --aspect landscape -o dragon.mp4
7
+ python -m cli.generate "Make it anime" --edit MEDIA_ID
8
+ """
9
+
10
+ import argparse
11
+ import asyncio
12
+ import os
13
+ import sys
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ from omniflash import (
18
+ ExtensionBridge, generate_video, edit_video,
19
+ poll_status, download_video, ASPECTS, DEFAULT_PROJECT,
20
+ )
21
+ from omniflash.generators.i2v import upload_image, generate_video_i2v, generate_video_fl, generate_video_r2v
22
+
23
+
24
+ async def run(args):
25
+ aspect = ASPECTS.get(args.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT")
26
+
27
+ bridge = ExtensionBridge()
28
+ await bridge.start()
29
+
30
+ if not await bridge.wait_for_extension(timeout=30):
31
+ return
32
+
33
+ # Auto-upload local image files
34
+ async def resolve_image(path_or_id):
35
+ if os.path.exists(path_or_id):
36
+ mid = await upload_image(bridge, path_or_id)
37
+ if mid:
38
+ print(f"πŸ“€ Uploaded: {path_or_id} β†’ {mid[:12]}...")
39
+ return mid
40
+ return path_or_id
41
+
42
+ if args.start and args.end:
43
+ # First+Last frame mode
44
+ start_id = await resolve_image(args.start)
45
+ end_id = await resolve_image(args.end)
46
+ if not start_id or not end_id:
47
+ await bridge.close()
48
+ return
49
+ media_ids = await generate_video_fl(bridge, args.prompt, aspect, args.project_id,
50
+ start_image_id=start_id, end_image_id=end_id,
51
+ duration=args.duration)
52
+ elif args.start:
53
+ # I2V mode (start image only)
54
+ start_id = await resolve_image(args.start)
55
+ if not start_id:
56
+ await bridge.close()
57
+ return
58
+ media_ids = await generate_video_i2v(bridge, args.prompt, aspect, args.project_id,
59
+ image_media_id=start_id, duration=args.duration)
60
+ elif args.ref:
61
+ # Reference images mode
62
+ ref_ids = []
63
+ for r in args.ref:
64
+ mid = await resolve_image(r)
65
+ if mid:
66
+ ref_ids.append(mid)
67
+ if not ref_ids:
68
+ await bridge.close()
69
+ return
70
+ media_ids = await generate_video_r2v(bridge, args.prompt, aspect, args.project_id,
71
+ ref_media_ids=ref_ids, duration=args.duration)
72
+ elif args.edit:
73
+ media_ids = await edit_video(bridge, args.prompt, aspect, args.project_id,
74
+ video_media_id=args.edit, duration=args.duration)
75
+ else:
76
+ media_ids = await generate_video(bridge, args.prompt, aspect, args.project_id,
77
+ duration=args.duration, count=args.count)
78
+
79
+ if not media_ids:
80
+ await bridge.close()
81
+ return
82
+
83
+ for i, media_id in enumerate(media_ids):
84
+ label = f"[{i+1}/{len(media_ids)}] " if len(media_ids) > 1 else ""
85
+ print(f"{label}Polling {media_id[:12]}...")
86
+ if not await poll_status(bridge, media_id, args.project_id):
87
+ continue
88
+
89
+ if len(media_ids) == 1:
90
+ out_path = args.output
91
+ else:
92
+ base, ext = os.path.splitext(args.output)
93
+ out_path = f"{base}_{i+1}{ext}"
94
+
95
+ # Setup temp dir for download
96
+ out_dir = os.path.dirname(out_path) or "."
97
+ temp_dir = os.path.join(out_dir, ".temp")
98
+ os.makedirs(temp_dir, exist_ok=True)
99
+ temp_path = os.path.join(temp_dir, os.path.basename(out_path))
100
+
101
+ if await download_video(bridge, media_id, temp_path):
102
+ # Auto-remove watermark unless --no-clean
103
+ if not args.no_clean:
104
+ try:
105
+ from omniflash.watermark import remove_watermark_video
106
+ remove_watermark_video(temp_path, out_path)
107
+ os.remove(temp_path)
108
+ print(f"🧹 Watermark removed!")
109
+ except Exception as e:
110
+ # Fallback: move temp to output as-is
111
+ os.replace(temp_path, out_path)
112
+ print(f"⚠️ Watermark removal failed: {e}")
113
+ else:
114
+ os.replace(temp_path, out_path)
115
+
116
+ # Cleanup empty .temp dir
117
+ try:
118
+ os.rmdir(temp_dir)
119
+ except OSError:
120
+ pass
121
+
122
+ print(f"πŸŽ‰ Done! {out_path}")
123
+
124
+ await bridge.close()
125
+
126
+
127
+ def main():
128
+ parser = argparse.ArgumentParser(description="Omni Flash β€” Video Generator")
129
+ parser.add_argument("prompt", help="Text prompt for video")
130
+ parser.add_argument("--output", "-o", default="omni_output.mp4", help="Output file")
131
+ parser.add_argument("--aspect", "-a", choices=["portrait", "landscape"], default="portrait")
132
+ parser.add_argument("--duration", "-d", type=int, choices=[4, 6, 8, 10], default=10)
133
+ parser.add_argument("--count", "-c", type=int, choices=[1, 2, 3, 4], default=1)
134
+ parser.add_argument("--edit", "-e", metavar="MEDIA_ID",
135
+ help="Edit existing video (V2V mode)")
136
+ parser.add_argument("--start", "-s", metavar="IMAGE",
137
+ help="Start frame image (file path or media_id)")
138
+ parser.add_argument("--end", metavar="IMAGE",
139
+ help="End frame image (use with --start for FL mode)")
140
+ parser.add_argument("--ref", "-r", nargs="+", metavar="IMAGE",
141
+ help="Reference images for R2V mode")
142
+ parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT)
143
+ parser.add_argument("--no-clean", action="store_true",
144
+ help="Skip automatic watermark removal")
145
+ args = parser.parse_args()
146
+ asyncio.run(run(args))
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
flow-agent/cli/image.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """CLI β€” Generate image from text prompt (T2I).
3
+
4
+ Usage:
5
+ python -m cli.image "A cat wearing sunglasses on a beach"
6
+ python -m cli.image "Dragon in cyberpunk city" --aspect landscape --count 4
7
+ python -m cli.image "Logo design" --aspect square -o logo.png
8
+ """
9
+
10
+ import argparse
11
+ import asyncio
12
+ import os
13
+ import sys
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ from omniflash import ExtensionBridge, DEFAULT_PROJECT
18
+ from omniflash.generators.t2i import generate_image, download_image, IMAGE_ASPECTS
19
+
20
+
21
+ async def run(args):
22
+ aspect = args.aspect
23
+
24
+ bridge = ExtensionBridge()
25
+ await bridge.start()
26
+
27
+ if not await bridge.wait_for_extension(timeout=30):
28
+ return
29
+
30
+ # Handle ref images: auto-upload local files
31
+ ref_ids = []
32
+ if args.ref:
33
+ from omniflash.generators.i2v import upload_image
34
+ for ref in args.ref:
35
+ if os.path.exists(ref):
36
+ print(f"πŸ“€ Uploading reference: {ref}")
37
+ mid = await upload_image(bridge, ref)
38
+ if mid:
39
+ ref_ids.append(mid)
40
+ print(f" media_id={mid[:12]}...")
41
+ else:
42
+ # Assume it's already a media_id
43
+ ref_ids.append(ref)
44
+
45
+ results = await generate_image(
46
+ bridge, args.prompt, aspect, args.project_id,
47
+ count=args.count, ref_media_ids=ref_ids or None
48
+ )
49
+
50
+ if not results:
51
+ await bridge.close()
52
+ return
53
+
54
+ os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
55
+
56
+ for i, r in enumerate(results):
57
+ if not r.get("image_url"):
58
+ print(f"⚠️ Image {i+1}: no URL")
59
+ continue
60
+
61
+ if len(results) == 1:
62
+ out_path = args.output
63
+ else:
64
+ base, ext = os.path.splitext(args.output)
65
+ out_path = f"{base}_{i+1}{ext}"
66
+
67
+ if await download_image(bridge, r["image_url"], out_path):
68
+ print(f"πŸŽ‰ Done! {out_path}")
69
+
70
+ await bridge.close()
71
+
72
+
73
+ def main():
74
+ parser = argparse.ArgumentParser(description="Flow Agent β€” Image Generator")
75
+ parser.add_argument("prompt", help="Text prompt for image")
76
+ parser.add_argument("--output", "-o", default="output/image.png", help="Output file")
77
+ parser.add_argument("--aspect", "-a",
78
+ choices=list(IMAGE_ASPECTS.keys()),
79
+ default="portrait",
80
+ help="Aspect ratio")
81
+ parser.add_argument("--count", "-c", type=int, choices=[1, 2, 3, 4], default=1,
82
+ help="Generate 1-4 variations")
83
+ parser.add_argument("--ref", "-r", nargs="+", metavar="IMAGE",
84
+ help="Reference image(s): file path or media_id")
85
+ parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT)
86
+ args = parser.parse_args()
87
+ asyncio.run(run(args))
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
flow-agent/cli/sniff.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """CLI β€” API request sniffer.
3
+
4
+ Captures all Flow UI API requests for endpoint discovery.
5
+
6
+ Usage:
7
+ python -m cli.sniff
8
+ python -m cli.sniff --save sniffed.json
9
+ """
10
+
11
+ import asyncio
12
+ import argparse
13
+ import json
14
+ import logging
15
+ import os
16
+ import sys
17
+ from http.server import HTTPServer, BaseHTTPRequestHandler
18
+ import threading
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+
22
+ for _pkg in ["websockets"]:
23
+ try:
24
+ __import__(_pkg)
25
+ except ImportError:
26
+ os.system(f"{sys.executable} -m pip install {_pkg} -q")
27
+
28
+ import websockets
29
+
30
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
31
+ log = logging.getLogger("cli.sniff")
32
+
33
+ all_requests = []
34
+
35
+ IGNORE = {"batchLog", "frontendEvents", "fetchUserRecommendations", "flowAgent/applets",
36
+ "savedSharedApplets", "models/statuses"}
37
+
38
+
39
+ def make_handler(save_file):
40
+ class Handler(BaseHTTPRequestHandler):
41
+ def do_POST(self):
42
+ length = int(self.headers.get("Content-Length", 0))
43
+ body = json.loads(self.rfile.read(length)) if length else {}
44
+
45
+ if body.get("type") == "sniffed_video_request":
46
+ url = body.get("url", "")
47
+ if not any(n in url for n in IGNORE):
48
+ method = body.get("method", "?")
49
+ payload = body.get("payload", "")
50
+
51
+ log.info("─" * 60)
52
+ log.info("πŸ” %s %s", method, url.split("?")[0])
53
+ if payload and payload != "(empty)":
54
+ try:
55
+ parsed = json.loads(payload)
56
+ log.info(" %s", json.dumps(parsed, indent=2)[:2000])
57
+ except (json.JSONDecodeError, TypeError):
58
+ log.info(" %s", str(payload)[:1000])
59
+
60
+ entry = {
61
+ "url": url,
62
+ "method": method,
63
+ "payload": payload,
64
+ "timestamp": body.get("timestamp"),
65
+ }
66
+ all_requests.append(entry)
67
+
68
+ if save_file:
69
+ with open(save_file, "w") as f:
70
+ json.dump(all_requests, f, indent=2)
71
+
72
+ self.send_response(200)
73
+ self.send_header("Content-Type", "application/json")
74
+ self.send_header("Access-Control-Allow-Origin", "*")
75
+ self.end_headers()
76
+ self.wfile.write(b'{"ok":true}')
77
+
78
+ def do_OPTIONS(self):
79
+ self.send_response(200)
80
+ self.send_header("Access-Control-Allow-Origin", "*")
81
+ self.send_header("Access-Control-Allow-Methods", "POST")
82
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
83
+ self.end_headers()
84
+
85
+ def log_message(self, *a):
86
+ pass
87
+
88
+ return Handler
89
+
90
+
91
+ async def on_connect(ws):
92
+ log.info("βœ… Extension connected!")
93
+ async for raw in ws:
94
+ data = json.loads(raw)
95
+ if data.get("type") == "token_captured":
96
+ log.info("πŸ”‘ Token captured")
97
+
98
+
99
+ async def run(args):
100
+ Handler = make_handler(args.save)
101
+ srv = HTTPServer(("127.0.0.1", args.port), Handler)
102
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
103
+
104
+ log.info("⚑ WS server on ws://127.0.0.1:%d", args.ws_port)
105
+ log.info("⚑ HTTP callback on http://127.0.0.1:%d", args.port)
106
+ if args.save:
107
+ log.info("πŸ’Ύ Saving to: %s", args.save)
108
+ log.info("πŸ‘‰ Open Flow UI and perform any action...")
109
+ log.info("─" * 60)
110
+
111
+ async with websockets.serve(on_connect, "127.0.0.1", args.ws_port):
112
+ await asyncio.Future()
113
+
114
+
115
+ def main():
116
+ parser = argparse.ArgumentParser(description="Flow API Sniffer")
117
+ parser.add_argument("--save", "-s", help="Save to JSON file")
118
+ parser.add_argument("--port", type=int, default=8100)
119
+ parser.add_argument("--ws-port", type=int, default=9222)
120
+ args = parser.parse_args()
121
+ asyncio.run(run(args))
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
flow-agent/cli/upload.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """CLI β€” Upload video or batch upload directory.
3
+
4
+ Usage:
5
+ python -m cli.upload video.mp4
6
+ python -m cli.upload chunks/ --batch
7
+ """
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import sys
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16
+
17
+ from omniflash.upload import upload_video
18
+
19
+
20
+ async def run(args):
21
+ if args.batch or os.path.isdir(args.path):
22
+ # Batch upload all mp4 in directory
23
+ directory = args.path
24
+ chunks = sorted([f for f in os.listdir(directory) if f.endswith(".mp4")])
25
+ if not chunks:
26
+ print(f"❌ No .mp4 files found in {directory}")
27
+ return
28
+ print(f"πŸ“ Found {len(chunks)} videos in {directory}")
29
+ for i, chunk in enumerate(chunks, 1):
30
+ path = os.path.join(directory, chunk)
31
+ print(f"\n{'─' * 50}")
32
+ print(f"[{i}/{len(chunks)}] {chunk}")
33
+ try:
34
+ await upload_video(path, args.project_id)
35
+ except Exception as e:
36
+ print(f"❌ {chunk} failed: {e}")
37
+ else:
38
+ # Single file upload
39
+ result = await upload_video(args.path, args.project_id)
40
+ print(json.dumps(result, indent=2))
41
+
42
+
43
+ def main():
44
+ parser = argparse.ArgumentParser(description="Omni Flash β€” Upload Video")
45
+ parser.add_argument("path", help="Video file or directory of videos")
46
+ parser.add_argument("--batch", "-b", action="store_true",
47
+ help="Batch upload all .mp4 in directory")
48
+ parser.add_argument("--project-id", "-p",
49
+ default="ff92d5cc-8a03-41d2-b59e-e0774d17bcf6")
50
+ args = parser.parse_args()
51
+ asyncio.run(run(args))
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
flow-agent/error.md ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸ› οΈ Flow Agent API Troubleshooting Guide (error.md)
2
+
3
+ This document contains standard error scenarios and how to resolve them quickly.
4
+
5
+ ---
6
+
7
+ ## 1. Error: `OSError: [Errno 48] Address already in use`
8
+ * **Symptom**: The server fails to start and exits with: `OSError: [Errno 48] Address already in use` (typically for port `8000`, `8100`, or `9222`).
9
+ * **Cause**: Another instance of the API server or a background flow-agent process is already running and occupying the port.
10
+ * **Resolution**:
11
+ Run the following commands in your terminal to find and kill the process:
12
+ ```bash
13
+ # Clear API server port (8000)
14
+ kill -9 $(lsof -t -i:8000) 2>/dev/null || true
15
+
16
+ # Clear Extension bridge HTTP callback port (8100)
17
+ kill -9 $(lsof -t -i:8100) 2>/dev/null || true
18
+
19
+ # Clear Extension bridge WebSocket port (9222)
20
+ kill -9 $(lsof -t -i:9222) 2>/dev/null || true
21
+ ```
22
+
23
+ ---
24
+
25
+ ## 2. Error: `Internal Server Error (500) - RuntimeError: curl failed`
26
+ * **Symptom**: Step `Uploading Video to Flow` fails with Status Code `500` and the server console prints: `RuntimeError: curl failed: ...`
27
+ * **Cause**: Your terminal session has sandbox/proxy environment variables active (`http_proxy`, `https_proxy`, `HTTP_PROXY`, or `HTTPS_PROXY` might be set by the AI agent sandbox). This causes `curl` to route all Google Cloud Storage uploads through a proxy that blocks the connection.
28
+ * **Resolution**:
29
+ Clear the proxy variables in your terminal window before starting the server and running the test script:
30
+ ```bash
31
+ # 1. Unset the proxy variables
32
+ unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
33
+
34
+ # 2. Restart the API server in this terminal
35
+ venv/bin/python -m cli.api --port 8000
36
+ ```
37
+ *(Make sure to also run `unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY` in your testing/client terminal window as well).*
38
+
39
+ ---
40
+
41
+ ## 3. Error: `Google Flow extension is not connected or unauthorized`
42
+ * **Symptom**: `/health` returns `has_flow_key: false` and generation calls return: `"Google Flow extension is not connected or unauthorized. Make sure Google Flow tab is open in Chrome."`
43
+ * **Cause**: The Extension Bridge WS is connected, but the Chrome extension is unable to capture the auth token (`flowKey`) because Google Flow is either not open, has gone idle, or your Google account has logged out.
44
+ * **Resolution**:
45
+ 1. Open Chrome.
46
+ 2. Make sure you are logged into your Google account at **[labs.google/fx/tools/flow](https://labs.google/fx/tools/flow)**.
47
+ 3. Reload the page. The extension icon in your extension bar should show a green indicator.
48
+ 4. Once logged in, the extension will automatically push the token to the server and heal the state.
49
+
50
+ ---
51
+
52
+ ## 4. Error: `Failed (0): TIMEOUT`
53
+ * **Symptom**: Request fails after 90 seconds with `TIMEOUT`.
54
+ * **Cause**: Google Flow took too long to respond, or there is an active **reCAPTCHA challenge** popped up on your Chrome browser that requires manual verification.
55
+ * **Resolution**:
56
+ 1. Open Chrome and inspect the Google Flow tab.
57
+ 2. If a reCAPTCHA prompt is present, solve it.
58
+ 3. Reload the tab to refresh the connection, wait 5 seconds, and try your request again.
59
+
60
+ ---
61
+
62
+ ## 5. Error: `zsh: no such file or directory: venv/bin/python`
63
+ * **Symptom**: Running server or test script returns: `zsh: no such file or directory: venv/bin/python` or similar file errors.
64
+ * **Cause**: The command is run from the parent workspace folder (`N8N-Agent`) instead of the cloned `flow-agent` project folder where the virtual environment (`venv`) resides.
65
+ * **Resolution**:
66
+ Always change your directory to the `flow-agent` folder before running commands, or use the absolute paths:
67
+ ```bash
68
+ # Go to the correct directory first
69
+ cd /path/to/flow-agent
70
+
71
+ # Then run the command
72
+ venv/bin/python -m cli.api --port 8000
73
+ ```
74
+
flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1 ADDED
Binary file (835 Bytes). View file
 
flow-agent/extension/background.js ADDED
@@ -0,0 +1,879 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Flow Agent β€” Chrome Extension Background Service Worker
3
+ *
4
+ * Connects to local Python agent via WebSocket (agent runs WS server).
5
+ * Captures bearer token, solves reCAPTCHA, proxies API calls through browser.
6
+ */
7
+
8
+ const AGENT_WS_URL = 'ws://127.0.0.1:9222';
9
+ // NOTE: This is a browser-restricted public API key β€” safe to ship in extension bundles.
10
+ const API_KEY = 'AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY';
11
+
12
+ let ws = null;
13
+ let flowKey = null;
14
+ let callbackSecret = null; // Auth secret for HTTP callback, received from server on WS connect
15
+ let state = 'off'; // off | idle | running
16
+ let manualDisconnect = false;
17
+ let metrics = {
18
+ tokenCapturedAt: null,
19
+ requestCount: 0, // captcha-consuming requests only (gen image/video/upscale)
20
+ successCount: 0,
21
+ failedCount: 0,
22
+ lastError: null,
23
+ };
24
+
25
+ // ─── URL β†’ Log Type Classifier ─────────────────────────────
26
+
27
+ // Visible log types β€” only these appear in the request log
28
+ const _VISIBLE_TYPES = new Set(['GEN_IMG', 'GEN_VID', 'GEN_VID_REF', 'UPSCALE', 'TRACKING', 'URL_REFRESH']);
29
+
30
+ function _classifyApiUrl(url) {
31
+ if (url.includes('uploadImage')) return 'UPLOAD';
32
+ if (url.includes('batchGenerateImages')) return 'GEN_IMG';
33
+ if (url.includes('UpsampleVideo')) return 'UPSCALE';
34
+ if (url.includes('ReferenceImages')) return 'GEN_VID_REF';
35
+ if (url.includes('batchAsyncGenerateVideo')) return 'GEN_VID';
36
+ if (url.includes('batchCheckAsync')) return 'POLL';
37
+ if (url.includes('upsampleImage')) return 'UPS_IMG';
38
+ if (url.includes('/media/')) return 'MEDIA';
39
+ if (url.includes('/credits')) return 'CREDITS';
40
+ return 'API';
41
+ }
42
+
43
+ // ─── Request Log ────────────────────────────────────────────
44
+
45
+ let requestLog = [];
46
+
47
+ function addRequestLog(entry) {
48
+ requestLog.unshift(entry);
49
+ if (requestLog.length > 100) requestLog.pop();
50
+ broadcastRequestLog();
51
+ }
52
+
53
+ function updateRequestLog(id, updates) {
54
+ const entry = requestLog.find((e) => e.id === id);
55
+ if (entry) Object.assign(entry, updates);
56
+ broadcastRequestLog();
57
+ }
58
+
59
+ function broadcastRequestLog() {
60
+ chrome.runtime.sendMessage({ type: 'REQUEST_LOG_UPDATE', log: requestLog }).catch(() => {});
61
+ }
62
+
63
+ // ─── Startup ────────────────────────────────────────────────
64
+
65
+ chrome.runtime.onInstalled.addListener(init);
66
+ chrome.runtime.onStartup.addListener(init);
67
+ chrome.alarms.onAlarm.addListener(async (alarm) => {
68
+ if (alarm.name === 'reconnect') connectToAgent();
69
+ if (alarm.name === 'keepAlive') keepAlive();
70
+ if (alarm.name === 'token-refresh') {
71
+ await captureTokenFromFlowTab();
72
+ }
73
+ });
74
+
75
+ async function init() {
76
+ const data = await chrome.storage.local.get(['flowKey', 'metrics', 'callbackSecret']);
77
+ if (data.flowKey) flowKey = data.flowKey;
78
+ if (data.metrics) Object.assign(metrics, data.metrics);
79
+ if (data.callbackSecret) callbackSecret = data.callbackSecret;
80
+ connectToAgent();
81
+ chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 });
82
+ }
83
+
84
+ // ─── Token Capture ──────────────────────────────────────────
85
+
86
+ chrome.webRequest.onBeforeSendHeaders.addListener(
87
+ (details) => {
88
+ if (!details?.requestHeaders?.length) return;
89
+ const authHeader = details.requestHeaders.find(
90
+ (h) => h.name?.toLowerCase() === 'authorization',
91
+ );
92
+ const value = authHeader?.value || '';
93
+ if (!value.startsWith('Bearer ya29.')) return;
94
+
95
+ const token = value.replace(/^Bearer\s+/i, '').trim();
96
+ if (!token) return;
97
+
98
+ // Always update β€” even if same token string, refresh the timestamp
99
+ flowKey = token;
100
+ metrics.tokenCapturedAt = Date.now();
101
+ chrome.storage.local.set({ flowKey, metrics });
102
+ console.log('[Flow Agent] Bearer token captured');
103
+
104
+ // Notify agent
105
+ if (ws?.readyState === WebSocket.OPEN) {
106
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
107
+ }
108
+ },
109
+ { urls: ['https://aisandbox-pa.googleapis.com/*', 'https://labs.google/*'] },
110
+ ['requestHeaders', 'extraHeaders'],
111
+ );
112
+
113
+ let _openingFlowTab = false;
114
+
115
+ async function captureTokenFromFlowTab() {
116
+ const tabs = await chrome.tabs.query({
117
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
118
+ });
119
+ if (!tabs.length) {
120
+ if (_openingFlowTab) {
121
+ console.log('[Flow Agent] Flow tab already opening, skipping');
122
+ return;
123
+ }
124
+ _openingFlowTab = true;
125
+ try {
126
+ console.log('[Flow Agent] No Flow tab found β€” opening one in background');
127
+ await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: false });
128
+ await sleep(3000);
129
+ const retryTabs = await chrome.tabs.query({
130
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
131
+ });
132
+ if (!retryTabs.length) {
133
+ console.log('[Flow Agent] Flow tab not ready yet after open');
134
+ return;
135
+ }
136
+ await chrome.scripting.executeScript({
137
+ target: { tabId: retryTabs[0].id },
138
+ files: ['content.js'],
139
+ });
140
+ console.log('[Flow Agent] Token refresh triggered on newly opened Flow tab');
141
+ } catch (e) {
142
+ console.error('[Flow Agent] Token refresh failed after opening tab:', e);
143
+ } finally {
144
+ _openingFlowTab = false;
145
+ }
146
+ return;
147
+ }
148
+ try {
149
+ await chrome.scripting.executeScript({
150
+ target: { tabId: tabs[0].id },
151
+ files: ['content.js'],
152
+ });
153
+ console.log('[Flow Agent] Token refresh triggered on Flow tab');
154
+ } catch (e) {
155
+ console.error('[Flow Agent] Token refresh failed:', e);
156
+ }
157
+ }
158
+
159
+ // ─── WebSocket to Agent ─────────────────────────────────────
160
+
161
+ function connectToAgent() {
162
+ if (manualDisconnect) return;
163
+ if (ws?.readyState === WebSocket.CONNECTING) return;
164
+ if (ws?.readyState === WebSocket.OPEN) return;
165
+
166
+ try {
167
+ ws = new WebSocket(AGENT_WS_URL);
168
+ } catch (e) {
169
+ console.error('[Flow Agent] WS connect error:', e);
170
+ scheduleReconnect();
171
+ return;
172
+ }
173
+
174
+ ws.onopen = () => {
175
+ console.log('[Flow Agent] Connected to agent');
176
+ chrome.alarms.clear('reconnect');
177
+ setState('idle');
178
+
179
+ // Token refresh alarm β€” 45 min gives buffer before ~60 min expiry
180
+ chrome.alarms.create('token-refresh', { periodInMinutes: 45 });
181
+
182
+ // Send current state + resend token if we have one
183
+ ws.send(JSON.stringify({
184
+ type: 'extension_ready',
185
+ flowKeyPresent: !!flowKey,
186
+ tokenAge: flowKey && metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null,
187
+ }));
188
+ if (flowKey) {
189
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
190
+ }
191
+ };
192
+
193
+ ws.onmessage = async ({ data }) => {
194
+ try {
195
+ const msg = JSON.parse(data);
196
+
197
+ if (msg.method === 'api_request') {
198
+ await handleApiRequest(msg);
199
+ } else if (msg.method === 'trpc_request') {
200
+ await handleTrpcRequest(msg);
201
+ } else if (msg.method === 'upload_video') {
202
+ await handleUploadVideo(msg);
203
+ } else if (msg.method === 'solve_captcha') {
204
+ await handleSolveCaptcha(msg);
205
+ } else if (msg.method === 'get_status') {
206
+ sendToAgent({
207
+ id: msg.id,
208
+ result: {
209
+ state,
210
+ flowKeyPresent: !!flowKey,
211
+ manualDisconnect,
212
+ tokenAge: metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null,
213
+ metrics,
214
+ },
215
+ });
216
+ } else if (msg.method === 'open_flow_tab') {
217
+ // Python bridge asks us to open/focus a Flow tab
218
+ console.log('[Flow Agent] Agent requested: open Flow tab');
219
+ const tabs = await chrome.tabs.query({
220
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
221
+ });
222
+ if (tabs.length) {
223
+ // Tab exists β€” refresh it to trigger fresh API calls β†’ token capture
224
+ await chrome.tabs.reload(tabs[0].id);
225
+ console.log('[Flow Agent] Refreshed existing Flow tab');
226
+ } else {
227
+ // No tab β€” open one (active so it loads properly)
228
+ await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: true });
229
+ console.log('[Flow Agent] Opened new Flow tab');
230
+ }
231
+ // Wait for page to load and make API calls that trigger token capture
232
+ await sleep(5000);
233
+ // If token was captured by webRequest during page load, send it
234
+ if (flowKey && ws?.readyState === WebSocket.OPEN) {
235
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
236
+ console.log('[Flow Agent] Sent stored token after tab open');
237
+ } else {
238
+ // Try reading from storage as fallback
239
+ const data = await chrome.storage.local.get(['flowKey']);
240
+ if (data.flowKey) {
241
+ flowKey = data.flowKey;
242
+ if (ws?.readyState === WebSocket.OPEN) {
243
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
244
+ console.log('[Flow Agent] Sent token from storage after tab open');
245
+ }
246
+ }
247
+ }
248
+ } else if (msg.method === 'refresh_flow_tab') {
249
+ // Python bridge asks us to refresh token
250
+ console.log('[Flow Agent] Agent requested: refresh token');
251
+ await captureTokenFromFlowTab();
252
+ await sleep(3000);
253
+ // Actively send token if we have one
254
+ if (flowKey && ws?.readyState === WebSocket.OPEN) {
255
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
256
+ console.log('[Flow Agent] Sent token after refresh');
257
+ } else {
258
+ const data = await chrome.storage.local.get(['flowKey']);
259
+ if (data.flowKey) {
260
+ flowKey = data.flowKey;
261
+ if (ws?.readyState === WebSocket.OPEN) {
262
+ ws.send(JSON.stringify({ type: 'token_captured', flowKey }));
263
+ console.log('[Flow Agent] Sent token from storage after refresh');
264
+ }
265
+ }
266
+ }
267
+ } else if (msg.type === 'callback_secret') {
268
+ callbackSecret = msg.secret;
269
+ chrome.storage.local.set({ callbackSecret: msg.secret });
270
+ console.log('[Flow Agent] Received callback secret');
271
+ } else if (msg.type === 'pong') {
272
+ // keepalive response
273
+ }
274
+ } catch (e) {
275
+ console.error('[Flow Agent] Message error:', e);
276
+ }
277
+ };
278
+
279
+ ws.onclose = () => {
280
+ setState('off');
281
+ chrome.alarms.clear('token-refresh');
282
+ if (!manualDisconnect) scheduleReconnect();
283
+ };
284
+
285
+ ws.onerror = (e) => {
286
+ console.error('[Flow Agent] WS error:', e);
287
+ metrics.lastError = 'WS_ERROR';
288
+ chrome.storage.local.set({ metrics });
289
+ };
290
+ }
291
+
292
+ function scheduleReconnect() {
293
+ chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s
294
+ }
295
+
296
+ function keepAlive() {
297
+ if (ws?.readyState === WebSocket.OPEN) {
298
+ ws.send(JSON.stringify({ type: 'ping' }));
299
+ } else {
300
+ connectToAgent();
301
+ }
302
+ }
303
+
304
+ function sendToAgent(msg) {
305
+ // API responses (with msg.id) go via HTTP β€” immune to WS disconnect
306
+ if (msg.id) {
307
+ fetch('http://127.0.0.1:8100/api/ext/callback', {
308
+ method: 'POST',
309
+ headers: { 'Content-Type': 'application/json' },
310
+ body: JSON.stringify(msg),
311
+ }).catch(() => {
312
+ // HTTP failed β€” fallback to WS
313
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
314
+ });
315
+ return;
316
+ }
317
+ // Non-response messages (ping, status) or no secret yet β€” use WS
318
+ if (ws?.readyState === WebSocket.OPEN) {
319
+ ws.send(JSON.stringify(msg));
320
+ }
321
+ }
322
+
323
+ // ─── reCAPTCHA Solving ──────────────────────────────────────
324
+
325
+ async function requestCaptchaFromTab(tabId, requestId, pageAction) {
326
+ try {
327
+ return await chrome.tabs.sendMessage(tabId, {
328
+ type: 'GET_CAPTCHA',
329
+ requestId,
330
+ pageAction,
331
+ });
332
+ } catch (error) {
333
+ const msg = error?.message || '';
334
+ const shouldInject =
335
+ msg.includes('Receiving end does not exist') ||
336
+ msg.includes('Could not establish connection');
337
+ if (!shouldInject) throw error;
338
+
339
+ // Inject content script and retry
340
+ await chrome.scripting.executeScript({
341
+ target: { tabId },
342
+ files: ['content.js'],
343
+ });
344
+ await sleep(200);
345
+ return await chrome.tabs.sendMessage(tabId, {
346
+ type: 'GET_CAPTCHA',
347
+ requestId,
348
+ pageAction,
349
+ });
350
+ }
351
+ }
352
+
353
+ async function solveCaptcha(requestId, captchaAction) {
354
+ const tabs = await chrome.tabs.query({
355
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
356
+ });
357
+
358
+ if (!tabs.length) {
359
+ // Auto-open Flow tab and wait briefly before returning error
360
+ try {
361
+ await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: false });
362
+ await sleep(3000);
363
+ // Retry tab query after opening
364
+ const retryTabs = await chrome.tabs.query({
365
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
366
+ });
367
+ if (!retryTabs.length) return { error: 'NO_FLOW_TAB' };
368
+ const resp = await Promise.race([
369
+ requestCaptchaFromTab(retryTabs[0].id, requestId, captchaAction),
370
+ new Promise((_, rej) => setTimeout(() => rej(new Error('CAPTCHA_TIMEOUT')), 30000)),
371
+ ]);
372
+ return resp;
373
+ } catch (e) {
374
+ return { error: e.message || 'NO_FLOW_TAB' };
375
+ }
376
+ }
377
+
378
+ try {
379
+ const resp = await Promise.race([
380
+ requestCaptchaFromTab(tabs[0].id, requestId, captchaAction),
381
+ new Promise((_, rej) => setTimeout(() => rej(new Error('CAPTCHA_TIMEOUT')), 30000)),
382
+ ]);
383
+ return resp;
384
+ } catch (e) {
385
+ return { error: e.message };
386
+ }
387
+ }
388
+
389
+ async function handleSolveCaptcha(msg) {
390
+ const { id, params } = msg;
391
+ const result = await solveCaptcha(id, params?.captchaAction || 'VIDEO_GENERATION');
392
+
393
+ // Standalone captcha solve counts as captcha-consuming
394
+ metrics.requestCount++;
395
+ if (result?.token) {
396
+ metrics.successCount++;
397
+ } else {
398
+ metrics.failedCount++;
399
+ metrics.lastError = result?.error || 'NO_TOKEN';
400
+ }
401
+ chrome.storage.local.set({ metrics });
402
+
403
+ sendToAgent({ id, result });
404
+ }
405
+
406
+ // ─── API Request Proxy ──────────────────────────────────────
407
+
408
+ async function handleTrpcRequest(msg) {
409
+ const { id, params } = msg;
410
+ const { url, method = 'POST', headers = {}, body } = params;
411
+
412
+ if (!url || !url.startsWith('https://labs.google/')) {
413
+ sendToAgent({ id, error: 'INVALID_TRPC_URL' });
414
+ return;
415
+ }
416
+
417
+ setState('running');
418
+ // TRPC calls don't consume captcha β€” don't count in metrics
419
+
420
+ const logId = id;
421
+ const logType = url.includes('createProject') ? 'CREATE_PROJECT' : 'TRPC';
422
+ // TRPC calls are silent β€” don't show in request log
423
+
424
+ const fetchHeaders = { 'Content-Type': 'application/json', ...headers };
425
+ if (flowKey) {
426
+ fetchHeaders['authorization'] = `Bearer ${flowKey}`;
427
+ }
428
+
429
+ try {
430
+ const resp = await fetch(url, {
431
+ method,
432
+ headers: fetchHeaders,
433
+ body: body ? JSON.stringify(body) : undefined,
434
+ credentials: 'include',
435
+ });
436
+ const data = await resp.json();
437
+ chrome.storage.local.set({ metrics });
438
+ updateRequestLog(logId, { status: 'success' });
439
+ sendToAgent({ id, status: resp.status, data });
440
+ } catch (e) {
441
+ console.error('[Flow Agent] tRPC request failed:', e);
442
+ chrome.storage.local.set({ metrics });
443
+ updateRequestLog(logId, { status: 'failed', error: e.message || 'TRPC_FETCH_FAILED' });
444
+ sendToAgent({ id, error: e.message || 'TRPC_FETCH_FAILED' });
445
+ } finally {
446
+ setState('idle');
447
+ }
448
+ }
449
+
450
+
451
+ async function handleUploadVideo(msg) {
452
+ const { id, params } = msg;
453
+ const { videoBase64, projectId, videoSize } = params;
454
+
455
+ try {
456
+ const tabs = await chrome.tabs.query({ url: '*://labs.google/*' });
457
+ if (!tabs.length) {
458
+ sendToAgent({ id, error: 'NO_FLOW_TAB' });
459
+ return;
460
+ }
461
+
462
+ const size = videoSize || (videoBase64 ? Math.floor(videoBase64.length * 3 / 4) : 0);
463
+
464
+ // Get session URL via page context XHR (needs session cookies)
465
+ const startResults = await chrome.scripting.executeScript({
466
+ target: { tabId: tabs[0].id },
467
+ world: 'MAIN',
468
+ func: (projId, sz) => {
469
+ return new Promise((resolve) => {
470
+ const xhr = new XMLHttpRequest();
471
+ xhr.open('POST', '/fx/api/upload-video?action=start');
472
+ xhr.setRequestHeader('X-Upload-Project-Id', projId);
473
+ xhr.setRequestHeader('X-Upload-Content-Type', 'video/mp4');
474
+ xhr.setRequestHeader('X-Upload-Content-Length', sz.toString());
475
+ xhr.withCredentials = true;
476
+ xhr.onload = () => {
477
+ let data;
478
+ try { data = JSON.parse(xhr.responseText); } catch { data = {}; }
479
+ resolve({
480
+ sessionUrl: data.sessionUrl || xhr.getResponseHeader('X-Upload-Session-Url') || '',
481
+ status: xhr.status,
482
+ });
483
+ };
484
+ xhr.onerror = () => resolve({ error: 'POST_FAILED' });
485
+ xhr.send();
486
+ });
487
+ },
488
+ args: [projectId, size],
489
+ });
490
+
491
+ const step1 = startResults?.[0]?.result;
492
+ if (!step1 || step1.error || !step1.sessionUrl) {
493
+ sendToAgent({ id, error: step1?.error || 'NO_SESSION_URL' });
494
+ return;
495
+ }
496
+
497
+ // Return sessionUrl + token β€” caller handles PUT
498
+ sendToAgent({
499
+ id,
500
+ result: {
501
+ sessionUrl: step1.sessionUrl,
502
+ token: flowKey || '',
503
+ },
504
+ });
505
+ } catch (e) {
506
+ sendToAgent({ id, error: `UPLOAD_ERROR: ${e.message}` });
507
+ }
508
+ }
509
+
510
+ async function handleApiRequest(msg) {
511
+ const { id, params } = msg;
512
+ const { url, method, headers, body, captchaAction } = params;
513
+
514
+ if (!url) {
515
+ sendToAgent({ id, error: 'MISSING_URL' });
516
+ return;
517
+ }
518
+
519
+ if (!url.startsWith('https://aisandbox-pa.googleapis.com/')) {
520
+ sendToAgent({ id, error: 'INVALID_URL' });
521
+ return;
522
+ }
523
+
524
+ setState('running');
525
+ const hasCaptcha = !!captchaAction;
526
+ if (hasCaptcha) metrics.requestCount++;
527
+
528
+ const logId = id;
529
+ const logType = _classifyApiUrl(url);
530
+ if (_VISIBLE_TYPES.has(logType)) {
531
+ const payloadSummary = body ? JSON.stringify(body).slice(0, 200) : null;
532
+ addRequestLog({ id: logId, type: logType, time: new Date().toISOString(), status: 'processing', error: null, outputUrl: null, url, payloadSummary });
533
+ }
534
+
535
+ try {
536
+ // Step 1: Solve captcha if needed
537
+ let captchaToken = null;
538
+ if (captchaAction) {
539
+ const captchaResult = await solveCaptcha(id, captchaAction);
540
+ captchaToken = captchaResult?.token || null;
541
+ if (!captchaToken) {
542
+ // Cannot proceed without captcha β€” API will 403
543
+ const err = captchaResult?.error || 'CAPTCHA_FAILED';
544
+ console.error(`[Flow Agent] Captcha failed for ${captchaAction}: ${err}`);
545
+ sendToAgent({ id, status: 403, error: `CAPTCHA_FAILED: ${err}` });
546
+ if (hasCaptcha) { metrics.failedCount++; metrics.lastError = `CAPTCHA_FAILED: ${err}`; }
547
+ chrome.storage.local.set({ metrics });
548
+ updateRequestLog(logId, { status: 'failed', error: `CAPTCHA_FAILED: ${err}` });
549
+ setState('idle');
550
+ return;
551
+ }
552
+ }
553
+
554
+ // Step 2: Inject captcha token into body
555
+ let finalBody = body;
556
+ if (captchaToken && finalBody) {
557
+ finalBody = JSON.parse(JSON.stringify(finalBody)); // deep clone
558
+ if (finalBody.clientContext?.recaptchaContext) {
559
+ finalBody.clientContext.recaptchaContext.token = captchaToken;
560
+ }
561
+ if (finalBody.requests && Array.isArray(finalBody.requests)) {
562
+ for (const req of finalBody.requests) {
563
+ if (req.clientContext?.recaptchaContext) {
564
+ req.clientContext.recaptchaContext.token = captchaToken;
565
+ }
566
+ }
567
+ }
568
+ }
569
+
570
+ // Step 3: Use flowKey for auth
571
+ const activeFlowKey = flowKey;
572
+ if (!activeFlowKey) {
573
+ sendToAgent({ id, status: 503, error: 'NO_FLOW_KEY' });
574
+ if (hasCaptcha) { metrics.failedCount++; metrics.lastError = 'NO_FLOW_KEY'; }
575
+ chrome.storage.local.set({ metrics });
576
+ updateRequestLog(logId, { status: 'failed', error: 'NO_FLOW_KEY' });
577
+ setState('idle');
578
+ return;
579
+ }
580
+
581
+ const fetchHeaders = { ...(headers || {}) };
582
+ fetchHeaders['authorization'] = `Bearer ${activeFlowKey}`;
583
+
584
+ // Step 4: Make the API call from browser context
585
+ const response = await fetch(url, {
586
+ method: method || 'POST',
587
+ headers: fetchHeaders,
588
+ credentials: 'include',
589
+ body: method === 'GET' ? undefined : JSON.stringify(finalBody),
590
+ });
591
+
592
+ let responseData;
593
+ const responseText = await response.text();
594
+ try {
595
+ responseData = JSON.parse(responseText);
596
+ } catch {
597
+ responseData = responseText;
598
+ }
599
+
600
+ sendToAgent({
601
+ id,
602
+ status: response.status,
603
+ data: responseData,
604
+ });
605
+
606
+ const responseSummary = responseText ? responseText.slice(0, 300) : null;
607
+ if (response.ok) {
608
+ if (hasCaptcha) { metrics.successCount++; metrics.lastError = null; }
609
+ updateRequestLog(logId, { status: 'success', httpStatus: response.status, responseSummary });
610
+ } else {
611
+ if (hasCaptcha) { metrics.failedCount++; metrics.lastError = `API_${response.status}`; }
612
+ updateRequestLog(logId, { status: 'failed', error: `API_${response.status}`, httpStatus: response.status, responseSummary });
613
+ }
614
+ } catch (e) {
615
+ sendToAgent({
616
+ id,
617
+ status: 500,
618
+ error: e.message || 'API_REQUEST_FAILED',
619
+ });
620
+ if (hasCaptcha) { metrics.failedCount++; metrics.lastError = e.message; }
621
+ updateRequestLog(logId, { status: 'failed', error: e.message || 'API_REQUEST_FAILED' });
622
+ }
623
+
624
+ chrome.storage.local.set({ metrics });
625
+ setState('idle');
626
+ }
627
+
628
+ // ─── State & Popup ──────────────────────────────────────────
629
+
630
+ function setState(newState) {
631
+ state = newState;
632
+ const badges = { idle: '●', running: 'β–Ά', off: 'β—‹' };
633
+ const colors = { idle: '#22c55e', running: '#f59e0b', off: '#6b7280' };
634
+ chrome.action.setBadgeText({ text: badges[state] || '' });
635
+ chrome.action.setBadgeBackgroundColor({ color: colors[state] || '#000' });
636
+ broadcastStatus();
637
+ }
638
+
639
+ function broadcastStatus() {
640
+ chrome.runtime.sendMessage({ type: 'STATUS_PUSH' }).catch(() => {});
641
+ }
642
+
643
+ chrome.runtime.onMessage.addListener((msg, _, reply) => {
644
+ if (msg.type === 'STATUS') {
645
+ reply({
646
+ connected: ws?.readyState === WebSocket.OPEN,
647
+ agentConnected: ws?.readyState === WebSocket.OPEN,
648
+ flowKeyPresent: !!flowKey,
649
+ manualDisconnect,
650
+ tokenAge: metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null,
651
+ metrics: {
652
+ requestCount: metrics.requestCount,
653
+ successCount: metrics.successCount,
654
+ failedCount: metrics.failedCount,
655
+ lastError: metrics.lastError,
656
+ },
657
+ state,
658
+ });
659
+ }
660
+
661
+ if (msg.type === 'DISCONNECT') {
662
+ manualDisconnect = true;
663
+ if (ws) ws.close();
664
+ reply({ ok: true });
665
+ return true;
666
+ }
667
+
668
+ if (msg.type === 'RECONNECT') {
669
+ manualDisconnect = false;
670
+ connectToAgent();
671
+ reply({ ok: true });
672
+ return true;
673
+ }
674
+
675
+ if (msg.type === 'REQUEST_LOG') {
676
+ reply({ log: requestLog });
677
+ return true;
678
+ }
679
+
680
+ if (msg.type === 'OPEN_FLOW_TAB') {
681
+ chrome.tabs.query({
682
+ url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'],
683
+ }).then((tabs) => {
684
+ if (tabs.length) {
685
+ chrome.tabs.update(tabs[0].id, { active: true });
686
+ reply({ ok: true, tabId: tabs[0].id });
687
+ } else {
688
+ chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow' })
689
+ .then((tab) => reply({ ok: true, tabId: tab.id }))
690
+ .catch((e) => reply({ error: e.message }));
691
+ }
692
+ }).catch((e) => reply({ error: e.message }));
693
+ return true;
694
+ }
695
+
696
+ if (msg.type === 'REFRESH_TOKEN') {
697
+ captureTokenFromFlowTab()
698
+ .then(() => reply({ ok: true }))
699
+ .catch((e) => reply({ error: e.message }));
700
+ return true;
701
+ }
702
+
703
+ if (msg.type === 'TEST_CAPTCHA') {
704
+ solveCaptcha(`test-${Date.now()}`, msg.pageAction || 'IMAGE_GENERATION')
705
+ .then((r) => reply(r))
706
+ .catch((e) => reply({ error: e.message }));
707
+ return true;
708
+ }
709
+
710
+ if (msg.type === 'TRPC_MEDIA_URLS') {
711
+ handleTrpcMediaUrls(msg.trpcUrl, msg.body);
712
+ reply({ ok: true });
713
+ return true;
714
+ }
715
+
716
+ if (msg.type === 'SNIFFED_AISANDBOX_REQUEST') {
717
+ console.log('[Flow Agent] SNIFFED aisandbox request:', msg.url);
718
+ fetch('http://127.0.0.1:8100/api/ext/callback', {
719
+ method: 'POST',
720
+ headers: { 'Content-Type': 'application/json' },
721
+ body: JSON.stringify({
722
+ type: 'sniffed_video_request',
723
+ url: msg.url,
724
+ method: msg.method,
725
+ payload: msg.payload,
726
+ timestamp: msg.timestamp,
727
+ }),
728
+ }).catch((e) => console.error('[Flow Agent] Failed to forward sniffed request:', e));
729
+ reply({ ok: true });
730
+ return true;
731
+ }
732
+
733
+ return true;
734
+ });
735
+
736
+ // ─── TRPC Media URL Extractor ──────────────────────────────
737
+
738
+ function handleTrpcMediaUrls(trpcUrl, bodyText) {
739
+ try {
740
+ // Extract all fresh GCS signed URLs
741
+ const urlRegex = /https:\/\/storage\.googleapis\.com\/ai-sandbox-videofx\/(?:image|video)\/[0-9a-f-]{36}\?[^"'\s]+/g;
742
+ const matches = bodyText.match(urlRegex) || [];
743
+ if (!matches.length) return;
744
+
745
+ // Deduplicate and parse
746
+ const urlMap = {};
747
+ for (const rawUrl of matches) {
748
+ // Unescape JSON-escaped URLs
749
+ const url = rawUrl.replace(/\\u0026/g, '&').replace(/\\/g, '');
750
+ const mediaMatch = url.match(/\/(image|video)\/([0-9a-f-]{36})\?/);
751
+ if (mediaMatch) {
752
+ const [, mediaType, mediaId] = mediaMatch;
753
+ // Keep last occurrence (freshest)
754
+ urlMap[mediaId] = { mediaType, url, mediaId };
755
+ }
756
+ }
757
+
758
+ const entries = Object.values(urlMap);
759
+ if (!entries.length) return;
760
+
761
+ console.log(`[Flow Agent] Captured ${entries.length} fresh media URLs from TRPC`);
762
+ // URL refresh is silent β€” don't show in request log
763
+
764
+ // Forward to agent for DB update
765
+ if (ws?.readyState === WebSocket.OPEN) {
766
+ ws.send(JSON.stringify({
767
+ type: 'media_urls_refresh',
768
+ urls: entries,
769
+ }));
770
+ }
771
+ } catch (e) {
772
+ console.error('[Flow Agent] Failed to extract TRPC media URLs:', e);
773
+ }
774
+ }
775
+
776
+ function sleep(ms) {
777
+ return new Promise((r) => setTimeout(r, ms));
778
+ }
779
+
780
+ // ─── Human-like Telemetry ──────────────────────────────────
781
+ // Periodically send tracking events to Google's analytics endpoints
782
+ // to mimic normal browser behavior.
783
+
784
+ const _UA = navigator.userAgent;
785
+ let _telemetrySessionId = `;${Date.now()}`;
786
+
787
+ function _rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
788
+
789
+ function _buildBatchLogPayload() {
790
+ const events = [];
791
+ const types = ['FLOW_IMAGE_LATENCY', 'FLOW_VIDEO_LATENCY'];
792
+ const count = _rand(1, 3);
793
+ for (let i = 0; i < count; i++) {
794
+ events.push({
795
+ event: types[_rand(0, types.length - 1)],
796
+ eventProperties: [
797
+ { key: 'CURRENT_TIME_MS', doubleValue: Date.now() },
798
+ { key: 'DURATION_MS', doubleValue: _rand(150, 800) },
799
+ { key: 'USER_AGENT', stringValue: _UA },
800
+ { key: 'IS_DESKTOP', booleanValue: true },
801
+ ],
802
+ eventMetadata: { sessionId: _telemetrySessionId },
803
+ eventTime: new Date().toISOString(),
804
+ });
805
+ }
806
+ return { appEvents: events };
807
+ }
808
+
809
+ function _buildFrontendEventsPayload() {
810
+ const eventTypes = [
811
+ 'FLOW_IMAGE_LATENCY', 'FLOW_VIDEO_LATENCY', 'GRID_SCROLL_DEPTH',
812
+ 'FLOW_PROJECT_OPEN', 'FLOW_SCENE_VIEW',
813
+ ];
814
+ const count = _rand(1, 4);
815
+ const events = [];
816
+ for (let i = 0; i < count; i++) {
817
+ const et = eventTypes[_rand(0, eventTypes.length - 1)];
818
+ const params = {
819
+ USER_AGENT: { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: _UA },
820
+ IS_DESKTOP: { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: 'true' },
821
+ };
822
+ if (et.includes('LATENCY')) {
823
+ params.CURRENT_TIME_MS = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: String(Date.now()) };
824
+ params.DURATION_MS = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: String(_rand(100, 600)) };
825
+ }
826
+ if (et === 'GRID_SCROLL_DEPTH') {
827
+ params.MEDIA_GENERATION_PAYGATE_TIER = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: 'PAYGATE_TIER_TWO' };
828
+ }
829
+ events.push({
830
+ eventType: et,
831
+ metadata: {
832
+ sessionId: _telemetrySessionId,
833
+ createTime: new Date().toISOString(),
834
+ additionalParams: params,
835
+ },
836
+ });
837
+ }
838
+ return { events };
839
+ }
840
+
841
+ async function sendTelemetry() {
842
+ if (!flowKey || state === 'off') return;
843
+
844
+ const headers = {
845
+ 'Content-Type': 'text/plain;charset=UTF-8',
846
+ 'authorization': `Bearer ${flowKey}`,
847
+ };
848
+
849
+ // Telemetry is silent β€” don't show in request log
850
+ try {
851
+ if (Math.random() < 0.5) {
852
+ await fetch(`https://aisandbox-pa.googleapis.com/v1:batchLog`, {
853
+ method: 'POST', headers, credentials: 'include',
854
+ body: JSON.stringify(_buildBatchLogPayload()),
855
+ });
856
+ } else {
857
+ await fetch(`https://aisandbox-pa.googleapis.com/v1/flow:batchLogFrontendEvents`, {
858
+ method: 'POST', headers, credentials: 'include',
859
+ body: JSON.stringify(_buildFrontendEventsPayload()),
860
+ });
861
+ }
862
+ } catch {}
863
+ }
864
+
865
+ // Send telemetry at random intervals (45-120s) to look organic
866
+ function scheduleTelemetry() {
867
+ const delay = _rand(45, 120) * 1000;
868
+ setTimeout(async () => {
869
+ await sendTelemetry();
870
+ scheduleTelemetry(); // reschedule with new random interval
871
+ }, delay);
872
+ }
873
+
874
+ // Refresh session ID every ~30min like a real user
875
+ setInterval(() => { _telemetrySessionId = `;${Date.now()}`; }, _rand(25, 35) * 60 * 1000);
876
+
877
+ scheduleTelemetry();
878
+
879
+ console.log('[Flow Agent] Extension loaded');
flow-agent/extension/content.js ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Content script β€” bridge between background.js and injected.js
3
+ * Injects injected.js into MAIN world to access window.grecaptcha
4
+ */
5
+ (function () {
6
+ const s = document.createElement('script');
7
+ s.src = chrome.runtime.getURL('injected.js');
8
+ s.onload = () => s.remove();
9
+ (document.head || document.documentElement).appendChild(s);
10
+ })();
11
+
12
+ chrome.runtime.onMessage.addListener((msg, _, reply) => {
13
+ if (msg.type !== 'GET_CAPTCHA') return;
14
+
15
+ const { requestId, pageAction } = msg;
16
+
17
+ const handler = (e) => {
18
+ if (e.detail?.requestId === requestId) {
19
+ window.removeEventListener('CAPTCHA_RESULT', handler);
20
+ clearTimeout(timer);
21
+ reply({ token: e.detail.token, error: e.detail.error });
22
+ }
23
+ };
24
+
25
+ const timer = setTimeout(() => {
26
+ window.removeEventListener('CAPTCHA_RESULT', handler);
27
+ reply({ error: 'CONTENT_TIMEOUT' });
28
+ }, 25000);
29
+
30
+ window.addEventListener('CAPTCHA_RESULT', handler);
31
+
32
+ window.dispatchEvent(new CustomEvent('GET_CAPTCHA', {
33
+ detail: { requestId, pageAction },
34
+ }));
35
+
36
+ return true; // keep channel open for async reply
37
+ });
38
+
39
+ // ─── TRPC Media URL Monitor ─────────────────────────────────
40
+ // Forward intercepted TRPC responses with media URLs to background.js
41
+ window.addEventListener('TRPC_MEDIA_URLS', (e) => {
42
+ const { url, body } = e.detail || {};
43
+ if (!body) return;
44
+ chrome.runtime.sendMessage({
45
+ type: 'TRPC_MEDIA_URLS',
46
+ trpcUrl: url,
47
+ body,
48
+ }).catch(() => {});
49
+ });
50
+
51
+ // ─── Aisandbox Request Sniffer (via postMessage from MAIN world) ──
52
+ window.addEventListener('message', (e) => {
53
+ if (e.data?.type !== '__FLOWKIT_SNIFF__') return;
54
+ const { url, body, method } = e.data;
55
+ if (!url) return;
56
+ chrome.runtime.sendMessage({
57
+ type: 'SNIFFED_AISANDBOX_REQUEST',
58
+ url,
59
+ method,
60
+ payload: body,
61
+ timestamp: Date.now(),
62
+ }).catch(() => {});
63
+ });
64
+
65
+ // ─── Video Upload Relay ─────────────────────────────────────
66
+ chrome.runtime.onMessage.addListener((msg, _, reply) => {
67
+ if (msg.type !== 'UPLOAD_VIDEO') return;
68
+
69
+ const { requestId, videoBase64, projectId } = msg;
70
+
71
+ const handler = (e) => {
72
+ if (e.detail?.requestId === requestId) {
73
+ window.removeEventListener('UPLOAD_VIDEO_RESULT', handler);
74
+ clearTimeout(timer);
75
+ reply(e.detail);
76
+ }
77
+ };
78
+
79
+ const timer = setTimeout(() => {
80
+ window.removeEventListener('UPLOAD_VIDEO_RESULT', handler);
81
+ reply({ error: 'UPLOAD_TIMEOUT' });
82
+ }, 120000); // 2 min timeout for large uploads
83
+
84
+ window.addEventListener('UPLOAD_VIDEO_RESULT', handler);
85
+
86
+ window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO', {
87
+ detail: { requestId, videoBase64, projectId },
88
+ }));
89
+
90
+ return true; // keep channel open for async reply
91
+ });
flow-agent/extension/icon128.png ADDED
flow-agent/extension/icon16.png ADDED
flow-agent/extension/icon48.png ADDED
flow-agent/extension/injected.js ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Injected into MAIN world on labs.google β€” has access to window.grecaptcha
3
+ * Also intercepts TRPC fetch responses to capture fresh signed media URLs.
4
+ */
5
+ const SITE_KEY = '6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV';
6
+
7
+ // ─── XHR Interceptor (for file uploads) ─────────────────────
8
+ const _xhrOpen = XMLHttpRequest.prototype.open;
9
+ const _xhrSend = XMLHttpRequest.prototype.send;
10
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
11
+ this.__sniffUrl = url;
12
+ this.__sniffMethod = method;
13
+ return _xhrOpen.call(this, method, url, ...rest);
14
+ };
15
+ XMLHttpRequest.prototype.send = function (body) {
16
+ try {
17
+ const url = this.__sniffUrl || '';
18
+ if (url.includes('googleapis.com') || url.includes('labs.google') || url.includes('storage.google')) {
19
+ window.postMessage({
20
+ type: '__FLOWKIT_SNIFF__',
21
+ url,
22
+ body: typeof body === 'string' ? body : `(binary ${body?.size || body?.byteLength || '?'} bytes)`,
23
+ method: this.__sniffMethod || 'POST',
24
+ }, '*');
25
+ }
26
+ } catch {}
27
+ return _xhrSend.call(this, body);
28
+ };
29
+
30
+ // ─── TRPC Response Monitor ─────────────────────────────────
31
+ // Monkey-patch fetch to intercept TRPC responses containing media URLs.
32
+ // Fresh signed GCS URLs are extracted and forwarded to the agent.
33
+
34
+ const _originalFetch = window.fetch;
35
+ window.fetch = async function (...args) {
36
+ try {
37
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
38
+
39
+ // ─── SNIFF ALL outgoing requests (catch upload) ─────────
40
+ {
41
+ let bodyText = '';
42
+ if (args[1]?.body) {
43
+ const b = args[1].body;
44
+ if (typeof b === 'string') bodyText = b.length > 5000 ? b.slice(0, 200) + `...(${b.length} chars)` : b;
45
+ else if (b instanceof FormData) bodyText = `(FormData: ${[...b.keys()].join(', ')})`;
46
+ else if (b instanceof Blob) bodyText = `(Blob ${b.size} bytes, type=${b.type})`;
47
+ else if (b instanceof ArrayBuffer) bodyText = `(ArrayBuffer ${b.byteLength} bytes)`;
48
+ else if (b instanceof ReadableStream) bodyText = '(ReadableStream)';
49
+ else bodyText = JSON.stringify(b)?.slice(0, 2000) || '(unknown)';
50
+ }
51
+ window.postMessage({
52
+ type: '__FLOWKIT_SNIFF__',
53
+ url, body: bodyText, method: args[1]?.method || 'GET',
54
+ }, '*');
55
+ }
56
+ } catch {}
57
+
58
+ const response = await _originalFetch.apply(this, args);
59
+ try {
60
+ const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
61
+ // Only intercept TRPC calls on labs.google that return project/flow data
62
+ if (url.includes('/fx/api/trpc/') && response.ok) {
63
+ const clone = response.clone();
64
+ clone.text().then(text => {
65
+ if (text.includes('storage.googleapis.com/ai-sandbox-videofx/')) {
66
+ window.dispatchEvent(new CustomEvent('TRPC_MEDIA_URLS', {
67
+ detail: { url, body: text },
68
+ }));
69
+ }
70
+ }).catch(() => {});
71
+ }
72
+ } catch {}
73
+ return response;
74
+ };
75
+
76
+
77
+ window.addEventListener('GET_CAPTCHA', async ({ detail }) => {
78
+ const { requestId, pageAction } = detail;
79
+ try {
80
+ await waitForGrecaptcha();
81
+ const token = await window.grecaptcha.enterprise.execute(SITE_KEY, {
82
+ action: pageAction,
83
+ });
84
+ window.dispatchEvent(new CustomEvent('CAPTCHA_RESULT', {
85
+ detail: { requestId, token },
86
+ }));
87
+ } catch (e) {
88
+ window.dispatchEvent(new CustomEvent('CAPTCHA_RESULT', {
89
+ detail: { requestId, error: e.message },
90
+ }));
91
+ }
92
+ });
93
+
94
+ function waitForGrecaptcha(timeout = 10000) {
95
+ return new Promise((resolve, reject) => {
96
+ const start = Date.now();
97
+ const check = () => {
98
+ if (window.grecaptcha?.enterprise?.execute) return resolve();
99
+ if (Date.now() - start > timeout) return reject(new Error('grecaptcha not available'));
100
+ setTimeout(check, 200);
101
+ };
102
+ check();
103
+ });
104
+ }
105
+
106
+ // ─── Video Upload Handler ───────────────────────────────────
107
+ window.addEventListener('UPLOAD_VIDEO', async ({ detail }) => {
108
+ const { requestId, videoBase64, projectId } = detail;
109
+ try {
110
+ // Convert base64 to Blob
111
+ const byteChars = atob(videoBase64);
112
+ const byteArray = new Uint8Array(byteChars.length);
113
+ for (let i = 0; i < byteChars.length; i++) {
114
+ byteArray[i] = byteChars.charCodeAt(i);
115
+ }
116
+ const blob = new Blob([byteArray], { type: 'video/mp4' });
117
+
118
+ // Step 1: POST start β€” get session URL
119
+ const startResp = await _originalFetch('/fx/api/upload-video?action=start', {
120
+ method: 'POST',
121
+ credentials: 'include',
122
+ headers: {
123
+ 'X-Upload-Project-Id': projectId || '',
124
+ 'X-Upload-Content-Type': 'video/mp4',
125
+ 'X-Upload-Content-Length': blob.size.toString(),
126
+ },
127
+ });
128
+ const sessionUrl = startResp.headers.get('X-Upload-Session-Url') || '';
129
+ const startData = await startResp.json().catch(() => ({}));
130
+ // sessionUrl may be in header OR in response body
131
+ const finalSessionUrl = sessionUrl || startData.sessionUrl || '';
132
+ startData._sessionUrl = finalSessionUrl;
133
+ startData._status = startResp.status;
134
+
135
+ if (!finalSessionUrl) {
136
+ window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', {
137
+ detail: { requestId, error: 'NO_SESSION_URL', startData },
138
+ }));
139
+ return;
140
+ }
141
+
142
+ // Step 2: PUT directly to GCS session URL with resumable upload headers
143
+ const uploadResp = await _originalFetch(finalSessionUrl, {
144
+ method: 'PUT',
145
+ body: blob,
146
+ headers: {
147
+ 'Content-Type': 'video/mp4',
148
+ 'X-Goog-Upload-Command': 'upload, finalize',
149
+ 'X-Goog-Upload-Offset': '0',
150
+ },
151
+ });
152
+ const uploadData = await uploadResp.json().catch(() => ({}));
153
+ uploadData._status = uploadResp.status;
154
+
155
+ window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', {
156
+ detail: { requestId, startData, uploadData, status: uploadResp.status },
157
+ }));
158
+ } catch (e) {
159
+ window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', {
160
+ detail: { requestId, error: e.message },
161
+ }));
162
+ }
163
+ });
flow-agent/extension/manifest.json ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "Flow Agent",
4
+ "version": "1.0.0",
5
+ "description": "Automate Google Flow β€” T2V, V2V, I2V video + T2I, I2I unlimited image generation from terminal",
6
+ "icons": {
7
+ "16": "icon16.png",
8
+ "48": "icon48.png",
9
+ "128": "icon128.png"
10
+ },
11
+ "permissions": ["storage", "alarms", "tabs", "webRequest", "scripting", "declarativeNetRequest", "sidePanel"],
12
+ "host_permissions": [
13
+ "https://labs.google/*",
14
+ "https://aisandbox-pa.googleapis.com/*",
15
+ "https://aisandbox-pa.sandbox.googleapis.com/*",
16
+ "https://storage.googleapis.com/*",
17
+ "http://127.0.0.1:8100/*"
18
+ ],
19
+ "background": {
20
+ "service_worker": "background.js"
21
+ },
22
+ "content_scripts": [
23
+ {
24
+ "matches": [
25
+ "https://labs.google/fx/tools/flow*",
26
+ "https://labs.google/fx/*/tools/flow*"
27
+ ],
28
+ "js": ["content.js"],
29
+ "run_at": "document_start"
30
+ }
31
+ ],
32
+ "web_accessible_resources": [
33
+ {
34
+ "resources": ["injected.js"],
35
+ "matches": ["https://labs.google/*"]
36
+ }
37
+ ],
38
+ "declarative_net_request": {
39
+ "rule_resources": [
40
+ {
41
+ "id": "referer_rules",
42
+ "enabled": true,
43
+ "path": "rules.json"
44
+ }
45
+ ]
46
+ },
47
+ "side_panel": {
48
+ "default_path": "side_panel.html"
49
+ },
50
+ "action": {
51
+ "default_popup": "popup.html",
52
+ "default_title": "Flow Agent",
53
+ "default_icon": {
54
+ "16": "icon16.png",
55
+ "48": "icon48.png",
56
+ "128": "icon128.png"
57
+ }
58
+ }
59
+ }
flow-agent/extension/popup.html ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <title>Flow Agent</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
8
+ <style>
9
+ *,
10
+ *::before,
11
+ *::after {
12
+ box-sizing: border-box;
13
+ margin: 0;
14
+ padding: 0;
15
+ }
16
+
17
+ :root {
18
+ --bg: #08090e;
19
+ --surface: #0e1018;
20
+ --card: #141622;
21
+ --card-hover: #1a1d2e;
22
+ --border: #1e2235;
23
+ --border-glow: #2a3050;
24
+ --accent: #6366f1;
25
+ --accent-soft: #818cf8;
26
+ --accent-bg: rgba(99, 102, 241, 0.08);
27
+ --green: #10b981;
28
+ --red: #f43f5e;
29
+ --yellow: #f59e0b;
30
+ --cyan: #22d3ee;
31
+ --text: #e8eaf0;
32
+ --text-dim: #9ca3af;
33
+ --muted: #6b7280;
34
+ --font: 'Inter', -apple-system, sans-serif;
35
+ --mono: 'SF Mono', 'Fira Code', monospace;
36
+ --radius: 8px;
37
+ }
38
+
39
+ html,
40
+ body {
41
+ width: 360px;
42
+ min-height: 200px;
43
+ background: var(--bg);
44
+ color: var(--text);
45
+ font-family: var(--font);
46
+ font-size: 12px;
47
+ line-height: 1.5;
48
+ -webkit-font-smoothing: antialiased;
49
+ }
50
+
51
+ header {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 10px;
55
+ padding: 12px 14px 10px;
56
+ border-bottom: 1px solid var(--border);
57
+ background: linear-gradient(180deg, rgba(99, 102, 241, 0.06) 0%, var(--surface) 100%);
58
+ position: relative;
59
+ }
60
+
61
+ header::after {
62
+ content: '';
63
+ position: absolute;
64
+ bottom: -1px;
65
+ left: 14px;
66
+ right: 14px;
67
+ height: 1px;
68
+ background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.25), transparent);
69
+ }
70
+
71
+ .logo {
72
+ width: 24px;
73
+ height: 24px;
74
+ border-radius: 6px;
75
+ background: linear-gradient(135deg, var(--accent), #a855f7);
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ font-weight: 800;
80
+ font-size: 12px;
81
+ color: #fff;
82
+ box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3);
83
+ }
84
+
85
+ .header-title {
86
+ flex: 1;
87
+ font-size: 13px;
88
+ font-weight: 700;
89
+ background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
90
+ -webkit-background-clip: text;
91
+ -webkit-text-fill-color: transparent;
92
+ }
93
+
94
+ #btn-panel {
95
+ padding: 5px 12px;
96
+ font-family: var(--font);
97
+ font-size: 10px;
98
+ font-weight: 600;
99
+ letter-spacing: 0.02em;
100
+ border: 1px solid var(--border);
101
+ border-radius: 6px;
102
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
103
+ color: #fff;
104
+ cursor: pointer;
105
+ transition: all 0.2s;
106
+ box-shadow: 0 2px 6px rgba(99, 102, 241, 0.2);
107
+ }
108
+
109
+ #btn-panel:hover {
110
+ background: linear-gradient(135deg, #818cf8, #8b5cf6);
111
+ box-shadow: 0 3px 10px rgba(99, 102, 241, 0.3);
112
+ transform: translateY(-1px);
113
+ }
114
+
115
+ .log-header {
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: space-between;
119
+ padding: 8px 14px 6px;
120
+ font-size: 9px;
121
+ font-weight: 600;
122
+ text-transform: uppercase;
123
+ letter-spacing: 0.1em;
124
+ color: var(--muted);
125
+ background: var(--surface);
126
+ border-bottom: 1px solid var(--border);
127
+ }
128
+
129
+ #log-count {
130
+ background: var(--accent-bg);
131
+ border: 1px solid rgba(99, 102, 241, 0.15);
132
+ border-radius: 10px;
133
+ padding: 1px 7px;
134
+ font-size: 10px;
135
+ font-weight: 700;
136
+ color: var(--accent-soft);
137
+ }
138
+
139
+ #log-list {
140
+ max-height: 440px;
141
+ overflow-y: auto;
142
+ scrollbar-width: thin;
143
+ scrollbar-color: var(--border) transparent;
144
+ }
145
+
146
+ #log-list::-webkit-scrollbar {
147
+ width: 4px;
148
+ }
149
+
150
+ #log-list::-webkit-scrollbar-track {
151
+ background: transparent;
152
+ }
153
+
154
+ #log-list::-webkit-scrollbar-thumb {
155
+ background: var(--border);
156
+ border-radius: 2px;
157
+ }
158
+
159
+ .log-empty {
160
+ padding: 28px 14px;
161
+ text-align: center;
162
+ color: var(--muted);
163
+ font-size: 11px;
164
+ }
165
+
166
+ .log-empty::before {
167
+ content: 'πŸ“‹';
168
+ display: block;
169
+ font-size: 20px;
170
+ margin-bottom: 6px;
171
+ opacity: 0.4;
172
+ }
173
+
174
+ .entry {
175
+ border-bottom: 1px solid rgba(30, 34, 53, 0.6);
176
+ }
177
+
178
+ .entry-row {
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 6px;
182
+ padding: 7px 14px;
183
+ cursor: pointer;
184
+ transition: background 0.15s;
185
+ user-select: none;
186
+ }
187
+
188
+ .entry-row:hover {
189
+ background: var(--accent-bg);
190
+ }
191
+
192
+ .entry-id {
193
+ font-family: var(--mono);
194
+ font-size: 9px;
195
+ color: var(--muted);
196
+ min-width: 54px;
197
+ flex-shrink: 0;
198
+ }
199
+
200
+ .entry-type {
201
+ flex: 1;
202
+ font-size: 11px;
203
+ font-weight: 700;
204
+ color: var(--cyan);
205
+ letter-spacing: 0.02em;
206
+ overflow: hidden;
207
+ text-overflow: ellipsis;
208
+ white-space: nowrap;
209
+ }
210
+
211
+ .entry-time {
212
+ font-family: var(--mono);
213
+ font-size: 9px;
214
+ color: var(--text-dim);
215
+ flex-shrink: 0;
216
+ font-variant-numeric: tabular-nums;
217
+ }
218
+
219
+ .badge {
220
+ display: inline-flex;
221
+ align-items: center;
222
+ gap: 2px;
223
+ padding: 2px 6px;
224
+ border-radius: 4px;
225
+ font-size: 9px;
226
+ font-weight: 700;
227
+ flex-shrink: 0;
228
+ }
229
+
230
+ .badge-ok {
231
+ background: rgba(16, 185, 129, 0.1);
232
+ color: var(--green);
233
+ }
234
+
235
+ .badge-fail {
236
+ background: rgba(244, 63, 94, 0.1);
237
+ color: var(--red);
238
+ }
239
+
240
+ .badge-proc {
241
+ background: rgba(245, 158, 11, 0.1);
242
+ color: var(--yellow);
243
+ }
244
+
245
+ .expand-icon {
246
+ font-size: 9px;
247
+ color: var(--muted);
248
+ flex-shrink: 0;
249
+ transition: transform 0.2s ease;
250
+ }
251
+
252
+ .entry.open .expand-icon {
253
+ transform: rotate(90deg);
254
+ }
255
+
256
+ .entry-details {
257
+ display: none;
258
+ padding: 0 14px 10px;
259
+ background: var(--surface);
260
+ }
261
+
262
+ .entry.open .entry-details {
263
+ display: block;
264
+ }
265
+
266
+ .detail-section {
267
+ margin-top: 6px;
268
+ }
269
+
270
+ .detail-label {
271
+ font-size: 9px;
272
+ font-weight: 600;
273
+ text-transform: uppercase;
274
+ letter-spacing: 0.08em;
275
+ color: var(--muted);
276
+ margin-bottom: 2px;
277
+ }
278
+
279
+ .detail-value {
280
+ font-family: var(--mono);
281
+ font-size: 9px;
282
+ color: var(--text);
283
+ background: var(--card);
284
+ border: 1px solid var(--border);
285
+ border-radius: 4px;
286
+ padding: 5px 8px;
287
+ word-break: break-all;
288
+ white-space: pre-wrap;
289
+ max-height: 80px;
290
+ overflow-y: auto;
291
+ }
292
+
293
+ .detail-value.url {
294
+ color: var(--accent-soft);
295
+ white-space: nowrap;
296
+ overflow: hidden;
297
+ text-overflow: ellipsis;
298
+ max-height: none;
299
+ }
300
+
301
+ .detail-error {
302
+ color: var(--red);
303
+ }
304
+
305
+ .footer {
306
+ padding: 10px 14px;
307
+ border-top: 1px solid var(--border);
308
+ background: var(--surface);
309
+ font-size: 10px;
310
+ color: var(--muted);
311
+ text-align: center;
312
+ }
313
+ </style>
314
+ </head>
315
+
316
+ <body>
317
+ <header>
318
+ <img src="icon48.png" style="width:24px;height:24px;border-radius:6px;box-shadow:0 2px 6px rgba(99,102,241,0.3)">
319
+ <div class="header-title">Flow Agent</div>
320
+ <button id="btn-panel">Side Panel</button>
321
+ </header>
322
+
323
+ <div class="log-header">
324
+ Recent Requests
325
+ <span id="log-count">0</span>
326
+ </div>
327
+
328
+ <div id="log-list">
329
+ <div class="log-empty">No requests yet</div>
330
+ </div>
331
+
332
+ <div class="footer">Click a row to expand details</div>
333
+
334
+ <script src="popup.js"></script>
335
+ </body>
336
+
337
+ </html>
flow-agent/extension/popup.js ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const TYPE_LABELS = {
2
+ GENERATE_IMAGE: 'GEN IMAGE',
3
+ REGENERATE_IMAGE: 'REGEN IMAGE',
4
+ EDIT_IMAGE: 'EDIT IMAGE',
5
+ GENERATE_CHARACTER_IMAGE: 'GEN REF',
6
+ REGENERATE_CHARACTER_IMAGE: 'REGEN REF',
7
+ EDIT_CHARACTER_IMAGE: 'EDIT REF',
8
+ GENERATE_VIDEO: 'GEN VIDEO',
9
+ GENERATE_VIDEO_REFS: 'GEN VIDEO FROM REFS',
10
+ UPSCALE_VIDEO: 'UPSCALE VIDEO',
11
+ GEN_IMG: 'GEN IMAGE',
12
+ GEN_VID: 'GEN VIDEO',
13
+ GEN_VID_REF: 'GEN VIDEO FROM REFS',
14
+ UPSCALE: 'UPSCALE VIDEO',
15
+ TRACKING: 'TRACKING',
16
+ URL_REFRESH: 'URL REFRESH',
17
+ };
18
+
19
+ function formatType(type) {
20
+ if (!type) return 'β€”';
21
+ return TYPE_LABELS[type] || type.slice(0, 12).toUpperCase();
22
+ }
23
+
24
+ function formatTime(iso) {
25
+ if (!iso) return 'β€”';
26
+ try {
27
+ const d = new Date(iso);
28
+ const hh = String(d.getHours()).padStart(2, '0');
29
+ const mm = String(d.getMinutes()).padStart(2, '0');
30
+ const ss = String(d.getSeconds()).padStart(2, '0');
31
+ return `${hh}:${mm}:${ss}`;
32
+ } catch {
33
+ return 'β€”';
34
+ }
35
+ }
36
+
37
+ function escHtml(str) {
38
+ return String(str)
39
+ .replace(/&/g, '&amp;')
40
+ .replace(/</g, '&lt;')
41
+ .replace(/>/g, '&gt;')
42
+ .replace(/"/g, '&quot;');
43
+ }
44
+
45
+ function badgeHtml(status) {
46
+ if (status === 'COMPLETED' || status === 'success') {
47
+ return '<span class="badge badge-ok">&#10003; done</span>';
48
+ } else if (status === 'FAILED' || status === 'failed' || (typeof status === 'number' && status >= 400)) {
49
+ return '<span class="badge badge-fail">&#10007; fail</span>';
50
+ } else if (status === 'PROCESSING') {
51
+ return '<span class="badge badge-proc">&#9203; gen...</span>';
52
+ } else {
53
+ return '<span class="badge badge-proc">&#9203; sent</span>';
54
+ }
55
+ }
56
+
57
+ function renderLog(entries) {
58
+ const list = document.getElementById('log-list');
59
+ const countEl = document.getElementById('log-count');
60
+
61
+ if (!entries || entries.length === 0) {
62
+ list.innerHTML = '<div class="log-empty">No requests yet</div>';
63
+ countEl.textContent = '0';
64
+ return;
65
+ }
66
+
67
+ countEl.textContent = entries.length;
68
+
69
+ list.innerHTML = entries.map((entry, i) => {
70
+ const shortId = entry.id ? String(entry.id).slice(0, 8) : 'β€”';
71
+ const type = formatType(entry.type || entry.method);
72
+ const time = formatTime(entry.time || entry.timestamp);
73
+ const status = entry.status || 'pending';
74
+ const error = entry.error || '';
75
+
76
+ const urlDisplay = entry.url
77
+ ? `<div class="detail-section">
78
+ <div class="detail-label">URL</div>
79
+ <div class="detail-value url" title="${escHtml(entry.url)}">${escHtml(entry.url)}</div>
80
+ </div>`
81
+ : '';
82
+
83
+ const payloadDisplay = entry.payloadSummary
84
+ ? `<div class="detail-section">
85
+ <div class="detail-label">Payload</div>
86
+ <div class="detail-value">${escHtml(entry.payloadSummary)}</div>
87
+ </div>`
88
+ : '';
89
+
90
+ const responseDisplay = entry.responseSummary
91
+ ? `<div class="detail-section">
92
+ <div class="detail-label">Response${entry.httpStatus ? ` (${entry.httpStatus})` : ''}</div>
93
+ <div class="detail-value">${escHtml(entry.responseSummary)}</div>
94
+ </div>`
95
+ : '';
96
+
97
+ const errorDisplay = error
98
+ ? `<div class="detail-section">
99
+ <div class="detail-label">Error</div>
100
+ <div class="detail-value detail-error">${escHtml(error)}</div>
101
+ </div>`
102
+ : '';
103
+
104
+ const hasDetails = entry.url || entry.payloadSummary || entry.responseSummary || error;
105
+
106
+ return `<div class="entry" data-idx="${i}">
107
+ <div class="entry-row">
108
+ <span class="entry-id">${escHtml(shortId)}</span>
109
+ <span class="entry-type">${escHtml(type)}</span>
110
+ <span class="entry-time">${escHtml(time)}</span>
111
+ ${badgeHtml(status)}
112
+ ${hasDetails ? '<span class="expand-icon">&#9654;</span>' : '<span class="expand-icon" style="visibility:hidden">&#9654;</span>'}
113
+ </div>
114
+ ${hasDetails ? `<div class="entry-details">${urlDisplay}${payloadDisplay}${responseDisplay}${errorDisplay}</div>` : ''}
115
+ </div>`;
116
+ }).join('');
117
+
118
+ // Toggle expand on row click
119
+ list.querySelectorAll('.entry-row').forEach((row) => {
120
+ row.addEventListener('click', () => {
121
+ const entry = row.closest('.entry');
122
+ if (entry.querySelector('.entry-details')) {
123
+ entry.classList.toggle('open');
124
+ }
125
+ });
126
+ });
127
+ }
128
+
129
+ document.getElementById('btn-panel').addEventListener('click', () => {
130
+ chrome.windows.getCurrent((win) => {
131
+ chrome.sidePanel.open({ windowId: win.id });
132
+ });
133
+ });
134
+
135
+ chrome.runtime.sendMessage({ type: 'REQUEST_LOG' }, (data) => {
136
+ if (chrome.runtime.lastError) return;
137
+ if (data && data.log) renderLog(data.log);
138
+ });
flow-agent/extension/rules.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": 1,
4
+ "priority": 1,
5
+ "action": {
6
+ "type": "modifyHeaders",
7
+ "requestHeaders": [
8
+ {
9
+ "header": "Referer",
10
+ "operation": "set",
11
+ "value": "https://labs.google/"
12
+ },
13
+ {
14
+ "header": "Origin",
15
+ "operation": "set",
16
+ "value": "https://labs.google"
17
+ }
18
+ ]
19
+ },
20
+ "condition": {
21
+ "urlFilter": "aisandbox-pa.googleapis.com",
22
+ "resourceTypes": ["xmlhttprequest"]
23
+ }
24
+ }
25
+ ]
flow-agent/extension/side_panel.html ADDED
@@ -0,0 +1,840 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <title>Flow Agent</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
8
+ <style>
9
+ *,
10
+ *::before,
11
+ *::after {
12
+ box-sizing: border-box;
13
+ margin: 0;
14
+ padding: 0;
15
+ }
16
+
17
+ :root {
18
+ --bg: #08090e;
19
+ --surface: #0e1018;
20
+ --card: #141622;
21
+ --card-hover: #1a1d2e;
22
+ --border: #1e2235;
23
+ --border-glow: #2a3050;
24
+ --accent: #6366f1;
25
+ --accent-soft: #818cf8;
26
+ --accent-bg: rgba(99, 102, 241, 0.08);
27
+ --accent-glow: rgba(99, 102, 241, 0.25);
28
+ --green: #10b981;
29
+ --green-bg: rgba(16, 185, 129, 0.1);
30
+ --red: #f43f5e;
31
+ --red-bg: rgba(244, 63, 94, 0.1);
32
+ --yellow: #f59e0b;
33
+ --yellow-bg: rgba(245, 158, 11, 0.1);
34
+ --cyan: #22d3ee;
35
+ --text: #e8eaf0;
36
+ --text-dim: #9ca3af;
37
+ --muted: #6b7280;
38
+ --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
39
+ --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
40
+ --radius: 10px;
41
+ --radius-sm: 6px;
42
+ }
43
+
44
+ html,
45
+ body {
46
+ height: 100%;
47
+ margin: 0;
48
+ }
49
+
50
+ body {
51
+ width: 100%;
52
+ min-width: 280px;
53
+ background: var(--bg);
54
+ color: var(--text);
55
+ font-family: var(--font);
56
+ font-size: 13px;
57
+ line-height: 1.5;
58
+ display: flex;
59
+ flex-direction: column;
60
+ -webkit-font-smoothing: antialiased;
61
+ }
62
+
63
+ /* ── Header ─────────────────────────────────── */
64
+ header {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 10px;
68
+ padding: 14px 16px 12px;
69
+ background: linear-gradient(180deg, rgba(99, 102, 241, 0.06) 0%, var(--surface) 100%);
70
+ border-bottom: 1px solid var(--border);
71
+ position: relative;
72
+ }
73
+
74
+ header::after {
75
+ content: '';
76
+ position: absolute;
77
+ bottom: -1px;
78
+ left: 16px;
79
+ right: 16px;
80
+ height: 1px;
81
+ background: linear-gradient(90deg, transparent, var(--accent-glow), transparent);
82
+ }
83
+
84
+ .logo {
85
+ width: 28px;
86
+ height: 28px;
87
+ border-radius: 8px;
88
+ background: linear-gradient(135deg, var(--accent), #a855f7);
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ font-weight: 800;
93
+ font-size: 14px;
94
+ color: #fff;
95
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
96
+ flex-shrink: 0;
97
+ }
98
+
99
+ .header-info {
100
+ flex: 1;
101
+ }
102
+
103
+ .header-title {
104
+ font-size: 14px;
105
+ font-weight: 700;
106
+ letter-spacing: -0.01em;
107
+ background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
108
+ -webkit-background-clip: text;
109
+ -webkit-text-fill-color: transparent;
110
+ }
111
+
112
+ .header-sub {
113
+ font-size: 10px;
114
+ color: var(--muted);
115
+ letter-spacing: 0.03em;
116
+ }
117
+
118
+ /* Connection indicator */
119
+ #conn-dot {
120
+ width: 10px;
121
+ height: 10px;
122
+ border-radius: 50%;
123
+ background: var(--red);
124
+ transition: all 0.4s ease;
125
+ flex-shrink: 0;
126
+ position: relative;
127
+ }
128
+
129
+ #conn-dot.on {
130
+ background: var(--green);
131
+ box-shadow: 0 0 8px 2px rgba(16, 185, 129, 0.4);
132
+ animation: pulse-glow 2.5s ease-in-out infinite;
133
+ }
134
+
135
+ @keyframes pulse-glow {
136
+
137
+ 0%,
138
+ 100% {
139
+ box-shadow: 0 0 6px 1px rgba(16, 185, 129, 0.3);
140
+ }
141
+
142
+ 50% {
143
+ box-shadow: 0 0 14px 4px rgba(16, 185, 129, 0.15);
144
+ }
145
+ }
146
+
147
+ /* Toggle */
148
+ .toggle-wrap {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 7px;
152
+ flex-shrink: 0;
153
+ }
154
+
155
+ .toggle-label {
156
+ font-size: 10px;
157
+ font-weight: 600;
158
+ color: var(--muted);
159
+ letter-spacing: 0.08em;
160
+ text-transform: uppercase;
161
+ }
162
+
163
+ .toggle {
164
+ position: relative;
165
+ width: 36px;
166
+ height: 20px;
167
+ cursor: pointer;
168
+ }
169
+
170
+ .toggle input {
171
+ opacity: 0;
172
+ width: 0;
173
+ height: 0;
174
+ }
175
+
176
+ .toggle-track {
177
+ position: absolute;
178
+ inset: 0;
179
+ background: var(--border);
180
+ border-radius: 10px;
181
+ transition: background 0.3s ease;
182
+ }
183
+
184
+ .toggle-thumb {
185
+ position: absolute;
186
+ top: 3px;
187
+ left: 3px;
188
+ width: 14px;
189
+ height: 14px;
190
+ border-radius: 50%;
191
+ background: var(--muted);
192
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
193
+ }
194
+
195
+ .toggle input:checked~.toggle-track {
196
+ background: var(--accent);
197
+ box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
198
+ }
199
+
200
+ .toggle input:checked~.toggle-thumb {
201
+ transform: translateX(16px);
202
+ background: #fff;
203
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
204
+ }
205
+
206
+ /* ── Metrics ─────────────────────────────────── */
207
+ .metrics {
208
+ display: grid;
209
+ grid-template-columns: repeat(3, 1fr);
210
+ gap: 8px;
211
+ padding: 12px 16px;
212
+ background: var(--surface);
213
+ border-bottom: 1px solid var(--border);
214
+ }
215
+
216
+ .metric-card {
217
+ background: var(--card);
218
+ border: 1px solid var(--border);
219
+ border-radius: var(--radius-sm);
220
+ padding: 12px 10px 10px;
221
+ text-align: center;
222
+ transition: all 0.2s ease;
223
+ }
224
+
225
+ .metric-card:hover {
226
+ border-color: var(--border-glow);
227
+ background: var(--card-hover);
228
+ }
229
+
230
+ .metric-value {
231
+ font-size: 26px;
232
+ font-weight: 800;
233
+ line-height: 1;
234
+ letter-spacing: -0.03em;
235
+ color: var(--text);
236
+ margin-bottom: 4px;
237
+ font-variant-numeric: tabular-nums;
238
+ }
239
+
240
+ .metric-value.green {
241
+ color: var(--green);
242
+ }
243
+
244
+ .metric-value.red {
245
+ color: var(--red);
246
+ }
247
+
248
+ .metric-label {
249
+ font-size: 9px;
250
+ font-weight: 600;
251
+ text-transform: uppercase;
252
+ letter-spacing: 0.12em;
253
+ color: var(--muted);
254
+ }
255
+
256
+ /* ── State bar ─────────────────────────────────── */
257
+ .state-bar {
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 8px;
261
+ padding: 8px 16px;
262
+ background: var(--surface);
263
+ border-bottom: 1px solid var(--border);
264
+ font-size: 11px;
265
+ color: var(--muted);
266
+ font-weight: 500;
267
+ }
268
+
269
+ .state-label {
270
+ font-size: 9px;
271
+ font-weight: 600;
272
+ letter-spacing: 0.1em;
273
+ text-transform: uppercase;
274
+ color: var(--muted);
275
+ }
276
+
277
+ #state-badge {
278
+ padding: 2px 8px;
279
+ border-radius: 4px;
280
+ font-size: 9px;
281
+ font-weight: 700;
282
+ text-transform: uppercase;
283
+ letter-spacing: 0.1em;
284
+ background: var(--border);
285
+ color: var(--muted);
286
+ }
287
+
288
+ #state-badge.idle {
289
+ background: var(--green-bg);
290
+ color: var(--green);
291
+ }
292
+
293
+ #state-badge.running {
294
+ background: var(--yellow-bg);
295
+ color: var(--yellow);
296
+ }
297
+
298
+ #state-badge.off {
299
+ background: var(--border);
300
+ color: var(--muted);
301
+ }
302
+
303
+ #token-status {
304
+ margin-left: auto;
305
+ font-size: 10px;
306
+ font-weight: 600;
307
+ display: flex;
308
+ align-items: center;
309
+ gap: 4px;
310
+ }
311
+
312
+ #token-status::before {
313
+ content: '';
314
+ width: 6px;
315
+ height: 6px;
316
+ border-radius: 50%;
317
+ display: inline-block;
318
+ }
319
+
320
+ #token-status.ok {
321
+ color: var(--green);
322
+ }
323
+
324
+ #token-status.ok::before {
325
+ background: var(--green);
326
+ }
327
+
328
+ #token-status.bad {
329
+ color: var(--red);
330
+ }
331
+
332
+ #token-status.bad::before {
333
+ background: var(--red);
334
+ }
335
+
336
+ #token-status.warn {
337
+ color: var(--yellow);
338
+ }
339
+
340
+ #token-status.warn::before {
341
+ background: var(--yellow);
342
+ }
343
+
344
+ /* ── Log section ─────────────────────────────── */
345
+ .log-section {
346
+ flex: 1;
347
+ display: flex;
348
+ flex-direction: column;
349
+ min-height: 0;
350
+ }
351
+
352
+ .log-header {
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: space-between;
356
+ padding: 10px 16px 8px;
357
+ font-size: 10px;
358
+ font-weight: 600;
359
+ text-transform: uppercase;
360
+ letter-spacing: 0.1em;
361
+ color: var(--muted);
362
+ background: var(--surface);
363
+ border-bottom: 1px solid var(--border);
364
+ }
365
+
366
+ #log-count {
367
+ background: var(--accent-bg);
368
+ border: 1px solid rgba(99, 102, 241, 0.15);
369
+ border-radius: 10px;
370
+ padding: 1px 8px;
371
+ font-size: 10px;
372
+ font-weight: 700;
373
+ color: var(--accent-soft);
374
+ }
375
+
376
+ .log-table-wrap {
377
+ flex: 1;
378
+ overflow-y: auto;
379
+ scrollbar-width: thin;
380
+ scrollbar-color: var(--border) transparent;
381
+ }
382
+
383
+ .log-table-wrap::-webkit-scrollbar {
384
+ width: 5px;
385
+ }
386
+
387
+ .log-table-wrap::-webkit-scrollbar-track {
388
+ background: transparent;
389
+ }
390
+
391
+ .log-table-wrap::-webkit-scrollbar-thumb {
392
+ background: var(--border);
393
+ border-radius: 3px;
394
+ }
395
+
396
+ .log-table-wrap::-webkit-scrollbar-thumb:hover {
397
+ background: var(--border-glow);
398
+ }
399
+
400
+ table {
401
+ width: 100%;
402
+ border-collapse: collapse;
403
+ }
404
+
405
+ thead th {
406
+ position: sticky;
407
+ top: 0;
408
+ padding: 7px 12px;
409
+ text-align: left;
410
+ font-size: 9px;
411
+ font-weight: 700;
412
+ text-transform: uppercase;
413
+ letter-spacing: 0.1em;
414
+ color: var(--muted);
415
+ background: var(--surface);
416
+ border-bottom: 1px solid var(--border);
417
+ z-index: 1;
418
+ }
419
+
420
+ thead th:first-child {
421
+ width: 60px;
422
+ }
423
+
424
+ thead th:last-child {
425
+ width: 36%;
426
+ }
427
+
428
+ tbody tr {
429
+ border-bottom: 1px solid rgba(30, 34, 53, 0.6);
430
+ transition: all 0.15s ease;
431
+ }
432
+
433
+ tbody tr:hover {
434
+ background: var(--accent-bg);
435
+ }
436
+
437
+ tbody td {
438
+ padding: 7px 12px;
439
+ vertical-align: middle;
440
+ white-space: nowrap;
441
+ }
442
+
443
+ .td-id {
444
+ font-family: var(--mono);
445
+ font-size: 10px;
446
+ color: var(--accent-soft);
447
+ font-variant-numeric: tabular-nums;
448
+ cursor: pointer;
449
+ transition: color 0.15s;
450
+ }
451
+
452
+ .td-id:hover {
453
+ color: var(--accent);
454
+ text-decoration: underline;
455
+ }
456
+
457
+ .td-type {
458
+ font-family: var(--font);
459
+ font-size: 11px;
460
+ font-weight: 700;
461
+ letter-spacing: 0.02em;
462
+ color: var(--cyan);
463
+ }
464
+
465
+ .td-time {
466
+ font-family: var(--mono);
467
+ font-size: 10px;
468
+ color: var(--text-dim);
469
+ font-variant-numeric: tabular-nums;
470
+ }
471
+
472
+ /* Status badges */
473
+ .badge {
474
+ display: inline-flex;
475
+ align-items: center;
476
+ gap: 3px;
477
+ padding: 2px 8px;
478
+ border-radius: 4px;
479
+ font-size: 9px;
480
+ font-weight: 700;
481
+ letter-spacing: 0.03em;
482
+ }
483
+
484
+ .badge-ok {
485
+ background: var(--green-bg);
486
+ color: var(--green);
487
+ }
488
+
489
+ .badge-fail {
490
+ background: var(--red-bg);
491
+ color: var(--red);
492
+ }
493
+
494
+ .badge-proc {
495
+ background: var(--yellow-bg);
496
+ color: var(--yellow);
497
+ }
498
+
499
+ .td-error {
500
+ max-width: 120px;
501
+ overflow: hidden;
502
+ text-overflow: ellipsis;
503
+ font-size: 10px;
504
+ color: var(--red);
505
+ cursor: default;
506
+ }
507
+
508
+ .td-error.empty {
509
+ color: var(--muted);
510
+ }
511
+
512
+ .log-empty {
513
+ padding: 32px 16px;
514
+ text-align: center;
515
+ color: var(--muted);
516
+ font-size: 12px;
517
+ letter-spacing: 0.02em;
518
+ }
519
+
520
+ .log-empty::before {
521
+ content: 'πŸ“‹';
522
+ display: block;
523
+ font-size: 24px;
524
+ margin-bottom: 8px;
525
+ opacity: 0.4;
526
+ }
527
+
528
+ /* ── Detail overlay ─────────────────────────── */
529
+ .detail-overlay {
530
+ display: none;
531
+ position: fixed;
532
+ inset: 0;
533
+ background: rgba(0, 0, 0, 0.7);
534
+ backdrop-filter: blur(4px);
535
+ z-index: 100;
536
+ }
537
+
538
+ .detail-overlay.open {
539
+ display: flex;
540
+ align-items: center;
541
+ justify-content: center;
542
+ animation: fadeIn 0.2s ease;
543
+ }
544
+
545
+ @keyframes fadeIn {
546
+ from {
547
+ opacity: 0;
548
+ }
549
+
550
+ to {
551
+ opacity: 1;
552
+ }
553
+ }
554
+
555
+ .detail-panel {
556
+ width: 92%;
557
+ max-height: 80vh;
558
+ background: var(--card);
559
+ border: 1px solid var(--border-glow);
560
+ border-radius: var(--radius);
561
+ overflow: hidden;
562
+ display: flex;
563
+ flex-direction: column;
564
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
565
+ }
566
+
567
+ .detail-header {
568
+ display: flex;
569
+ align-items: center;
570
+ justify-content: space-between;
571
+ padding: 12px 16px;
572
+ background: var(--surface);
573
+ border-bottom: 1px solid var(--border);
574
+ font-size: 12px;
575
+ font-weight: 700;
576
+ color: var(--accent-soft);
577
+ }
578
+
579
+ .detail-close {
580
+ background: none;
581
+ border: 1px solid var(--border);
582
+ border-radius: 6px;
583
+ color: var(--muted);
584
+ cursor: pointer;
585
+ font-size: 14px;
586
+ width: 28px;
587
+ height: 28px;
588
+ display: flex;
589
+ align-items: center;
590
+ justify-content: center;
591
+ transition: all 0.15s;
592
+ }
593
+
594
+ .detail-close:hover {
595
+ color: var(--text);
596
+ background: var(--card-hover);
597
+ border-color: var(--border-glow);
598
+ }
599
+
600
+ .detail-body {
601
+ padding: 14px 16px;
602
+ overflow-y: auto;
603
+ font-size: 11px;
604
+ line-height: 1.6;
605
+ max-height: 65vh;
606
+ }
607
+
608
+ .detail-row {
609
+ display: flex;
610
+ gap: 10px;
611
+ padding: 6px 0;
612
+ border-bottom: 1px solid rgba(30, 34, 53, 0.4);
613
+ }
614
+
615
+ .detail-label {
616
+ width: 90px;
617
+ flex-shrink: 0;
618
+ color: var(--muted);
619
+ font-weight: 600;
620
+ text-transform: uppercase;
621
+ font-size: 9px;
622
+ letter-spacing: 0.06em;
623
+ }
624
+
625
+ .detail-value {
626
+ flex: 1;
627
+ word-break: break-all;
628
+ color: var(--text);
629
+ font-family: var(--mono);
630
+ font-size: 10px;
631
+ }
632
+
633
+ .detail-value.error {
634
+ color: var(--red);
635
+ }
636
+
637
+ .detail-value.ok {
638
+ color: var(--green);
639
+ }
640
+
641
+ /* ── Action buttons ─────────────────────────── */
642
+ .actions {
643
+ display: flex;
644
+ gap: 8px;
645
+ padding: 12px 16px;
646
+ border-top: 1px solid var(--border);
647
+ background: var(--surface);
648
+ }
649
+
650
+ .btn {
651
+ flex: 1;
652
+ padding: 9px 12px;
653
+ font-family: var(--font);
654
+ font-size: 11px;
655
+ font-weight: 600;
656
+ letter-spacing: 0.02em;
657
+ border: 1px solid var(--border);
658
+ border-radius: var(--radius-sm);
659
+ background: var(--card);
660
+ color: var(--text-dim);
661
+ cursor: pointer;
662
+ transition: all 0.2s ease;
663
+ }
664
+
665
+ .btn:hover {
666
+ background: var(--card-hover);
667
+ border-color: var(--border-glow);
668
+ color: var(--text);
669
+ transform: translateY(-1px);
670
+ }
671
+
672
+ .btn:active {
673
+ transform: translateY(0);
674
+ opacity: 0.8;
675
+ }
676
+
677
+ .btn-primary {
678
+ background: linear-gradient(135deg, var(--accent), #7c3aed);
679
+ border-color: transparent;
680
+ color: #fff;
681
+ box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
682
+ }
683
+
684
+ .btn-primary:hover {
685
+ background: linear-gradient(135deg, #818cf8, #8b5cf6);
686
+ border-color: transparent;
687
+ color: #fff;
688
+ box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35);
689
+ }
690
+
691
+ /* ── Signature ──────────────────────────────── */
692
+ .signature {
693
+ padding: 10px 16px;
694
+ text-align: center;
695
+ font-size: 10px;
696
+ letter-spacing: 0.04em;
697
+ color: var(--muted);
698
+ background: var(--bg);
699
+ border-top: 1px solid var(--border);
700
+ }
701
+
702
+ .signature a {
703
+ color: var(--accent-soft);
704
+ text-decoration: none;
705
+ font-weight: 500;
706
+ transition: color 0.15s;
707
+ }
708
+
709
+ .signature a:hover {
710
+ color: var(--text);
711
+ }
712
+
713
+ /* ── Animations ──────────────────────────────── */
714
+ @keyframes slideUp {
715
+ from {
716
+ opacity: 0;
717
+ transform: translateY(6px);
718
+ }
719
+
720
+ to {
721
+ opacity: 1;
722
+ transform: translateY(0);
723
+ }
724
+ }
725
+
726
+ .metric-card {
727
+ animation: slideUp 0.3s ease forwards;
728
+ }
729
+
730
+ .metric-card:nth-child(2) {
731
+ animation-delay: 0.05s;
732
+ }
733
+
734
+ .metric-card:nth-child(3) {
735
+ animation-delay: 0.1s;
736
+ }
737
+ </style>
738
+ </head>
739
+
740
+ <body>
741
+
742
+ <!-- Header -->
743
+ <header>
744
+ <img src="icon48.png" class="logo-img"
745
+ style="width:28px;height:28px;border-radius:8px;box-shadow:0 2px 8px rgba(99,102,241,0.3)">
746
+ <div class="header-info">
747
+ <div class="header-title">Flow Agent</div>
748
+ <div class="header-sub">AI Video Automation Agent</div>
749
+ </div>
750
+ <div id="conn-dot"></div>
751
+ <div class="toggle-wrap">
752
+ <span class="toggle-label" id="toggle-label">OFF</span>
753
+ <label class="toggle">
754
+ <input type="checkbox" id="main-toggle">
755
+ <span class="toggle-track"></span>
756
+ <span class="toggle-thumb"></span>
757
+ </label>
758
+ </div>
759
+ </header>
760
+
761
+ <!-- Metrics -->
762
+ <div class="metrics">
763
+ <div class="metric-card">
764
+ <div class="metric-value" id="m-total">0</div>
765
+ <div class="metric-label">Total</div>
766
+ </div>
767
+ <div class="metric-card">
768
+ <div class="metric-value green" id="m-success">0</div>
769
+ <div class="metric-label">Success</div>
770
+ </div>
771
+ <div class="metric-card">
772
+ <div class="metric-value red" id="m-failed">0</div>
773
+ <div class="metric-label">Failed</div>
774
+ </div>
775
+ </div>
776
+
777
+ <!-- State bar -->
778
+ <div class="state-bar">
779
+ <span class="state-label">State</span>
780
+ <span id="state-badge" class="off">off</span>
781
+ <span id="token-status" class="bad">no token</span>
782
+ </div>
783
+
784
+ <!-- Request log -->
785
+ <div class="log-section">
786
+ <div class="log-header">
787
+ Request Log
788
+ <span id="log-count">0</span>
789
+ </div>
790
+
791
+ <div class="log-table-wrap">
792
+ <table>
793
+ <thead>
794
+ <tr>
795
+ <th>ID</th>
796
+ <th>Type</th>
797
+ <th>Time</th>
798
+ <th>Status</th>
799
+ <th>Error</th>
800
+ </tr>
801
+ </thead>
802
+ <tbody id="log-body">
803
+ <tr>
804
+ <td colspan="5" class="log-empty">No requests yet</td>
805
+ </tr>
806
+ </tbody>
807
+ </table>
808
+ </div>
809
+ </div>
810
+
811
+ <!-- Actions -->
812
+ <div class="actions">
813
+ <button class="btn btn-primary" id="btn-flow">Open Flow Tab</button>
814
+ <button class="btn" id="btn-token">Refresh Token</button>
815
+ </div>
816
+
817
+ <!-- Signature -->
818
+ <div class="signature">
819
+ <a href="https://github.com/kodelyx/flow-agent" target="_blank">
820
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" style="vertical-align:-2px;margin-right:3px">
821
+ <path
822
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z" />
823
+ </svg>Flow Agent</a> Β· built by <a href="https://kodelyx.com" target="_blank">kodelyx</a>
824
+ </div>
825
+
826
+ <!-- Detail overlay -->
827
+ <div class="detail-overlay" id="detail-overlay">
828
+ <div class="detail-panel">
829
+ <div class="detail-header">
830
+ <span id="detail-title">Request Detail</span>
831
+ <button class="detail-close" id="detail-close">&times;</button>
832
+ </div>
833
+ <div class="detail-body" id="detail-body"></div>
834
+ </div>
835
+ </div>
836
+
837
+ <script src="side_panel.js"></script>
838
+ </body>
839
+
840
+ </html>
flow-agent/extension/side_panel.js ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Flow Agent β€” Side Panel
3
+ * Displays live connection status, metrics, and request log.
4
+ */
5
+
6
+ // ── Type label map ───────────────────────────────────────────
7
+
8
+ const TYPE_LABELS = {
9
+ // Worker request types
10
+ GENERATE_IMAGE: 'GEN IMAGE',
11
+ REGENERATE_IMAGE: 'REGEN IMAGE',
12
+ EDIT_IMAGE: 'EDIT IMAGE',
13
+ GENERATE_CHARACTER_IMAGE: 'GEN REF',
14
+ REGENERATE_CHARACTER_IMAGE: 'REGEN REF',
15
+ EDIT_CHARACTER_IMAGE: 'EDIT REF',
16
+ GENERATE_VIDEO: 'GEN VIDEO',
17
+ GENERATE_VIDEO_REFS: 'GEN VIDEO FROM REFS',
18
+ UPSCALE_VIDEO: 'UPSCALE VIDEO',
19
+ // Captcha action types
20
+ IMAGE_GENERATION: 'GEN IMAGE',
21
+ VIDEO_GENERATION: 'GEN VIDEO',
22
+ // Extension-classified API types
23
+ GEN_IMG: 'GEN IMAGE',
24
+ GEN_VID: 'GEN VIDEO',
25
+ GEN_VID_REF: 'GEN VIDEO FROM REFS',
26
+ UPSCALE: 'UPSCALE VIDEO',
27
+ UPS_IMG: 'UPSCALE IMAGE',
28
+ POLL: 'CHECK GEN VIDEO',
29
+ CREDITS: 'CHECK CREDIT',
30
+ CREATE_PROJECT: 'CREATE PROJECT',
31
+ UPLOAD: 'UPLOAD IMAGE',
32
+ MEDIA: 'READ MEDIA',
33
+ TRACKING: 'GOOGLE FLOW TRACK',
34
+ URL_REFRESH: 'URL REFRESH',
35
+ TRPC: 'TRPC',
36
+ API: 'API',
37
+ };
38
+
39
+ function formatType(type) {
40
+ if (!type) return 'β€”';
41
+ return TYPE_LABELS[type] || type.slice(0, 5).toUpperCase();
42
+ }
43
+
44
+ // ── Time formatting ──────────────────────────────────────────
45
+
46
+ function formatTime(iso) {
47
+ if (!iso) return 'β€”';
48
+ try {
49
+ const d = new Date(iso);
50
+ const hh = String(d.getHours()).padStart(2, '0');
51
+ const mm = String(d.getMinutes()).padStart(2, '0');
52
+ const ss = String(d.getSeconds()).padStart(2, '0');
53
+ return `${hh}:${mm}:${ss}`;
54
+ } catch {
55
+ return 'β€”';
56
+ }
57
+ }
58
+
59
+ // ── Status update ────────────────────────────────────────────
60
+
61
+ function updateStatus(data) {
62
+ if (!data) return;
63
+
64
+ // Connection dot
65
+ const dot = document.getElementById('conn-dot');
66
+ const connected = data.agentConnected;
67
+ dot.className = connected ? 'on' : '';
68
+
69
+ // Toggle state
70
+ const toggle = document.getElementById('main-toggle');
71
+ const toggleLabel = document.getElementById('toggle-label');
72
+ const isOn = data.state !== 'off';
73
+ toggle.checked = isOn;
74
+ toggleLabel.textContent = isOn ? 'ON' : 'OFF';
75
+
76
+ // State badge
77
+ const stateBadge = document.getElementById('state-badge');
78
+ const st = data.state || 'off';
79
+ stateBadge.textContent = st;
80
+ stateBadge.className = st; // idle | running | off
81
+
82
+ // Token status
83
+ const tokenEl = document.getElementById('token-status');
84
+ if (data.flowKeyPresent) {
85
+ const ageMs = data.tokenAge || 0;
86
+ const ageMin = Math.round(ageMs / 60000);
87
+ if (ageMs > 3600000) {
88
+ tokenEl.textContent = `token expired β€” open Flow to refresh`;
89
+ tokenEl.className = 'warn';
90
+ } else {
91
+ tokenEl.textContent = `token synced ${ageMin}m`;
92
+ tokenEl.className = 'ok';
93
+ }
94
+ // Auto-refresh when token age > 55 min and connected
95
+ if (ageMs > 3300000 && data.agentConnected) {
96
+ chrome.runtime.sendMessage({ type: 'REFRESH_TOKEN' });
97
+ }
98
+ } else {
99
+ tokenEl.textContent = 'no token';
100
+ tokenEl.className = 'bad';
101
+ }
102
+
103
+ // Metrics
104
+ const m = data.metrics || {};
105
+ document.getElementById('m-total').textContent = m.requestCount || 0;
106
+ document.getElementById('m-success').textContent = m.successCount || 0;
107
+ document.getElementById('m-failed').textContent = m.failedCount || 0;
108
+ }
109
+
110
+ // ── Request log ──────────────────────────────────────────────
111
+
112
+ function updateRequestLog(entries) {
113
+ const tbody = document.getElementById('log-body');
114
+ const countEl = document.getElementById('log-count');
115
+
116
+ if (!entries || entries.length === 0) {
117
+ tbody.innerHTML = '<tr><td colspan="5" class="log-empty">No requests yet</td></tr>';
118
+ countEl.textContent = '0';
119
+ return;
120
+ }
121
+
122
+ countEl.textContent = entries.length;
123
+ _logEntries = entries;
124
+
125
+ // Render newest first (entries already sorted DESC by background.js)
126
+ const rows = entries.map((entry) => {
127
+ const shortId = entry.id ? String(entry.id).slice(0, 8) : 'β€”';
128
+ const type = formatType(entry.type || entry.method);
129
+ const time = formatTime(entry.time || entry.timestamp || entry.createdAt);
130
+ const status = entry.status || entry.state || 'pending';
131
+ const error = entry.error || '';
132
+
133
+ let badgeHtml;
134
+ if (status === 'COMPLETED' || status === 'success') {
135
+ badgeHtml = '<span class="badge badge-ok">&#10003; done</span>';
136
+ } else if (status === 'FAILED' || status === 'failed' || (typeof status === 'number' && status >= 400)) {
137
+ badgeHtml = '<span class="badge badge-fail">&#10007; fail</span>';
138
+ } else if (status === 'PROCESSING') {
139
+ badgeHtml = '<span class="badge badge-proc">&#9203; gen...</span>';
140
+ } else if (status === 200 || status === 'processing') {
141
+ badgeHtml = '<span class="badge badge-proc">&#9203; sent</span>';
142
+ } else {
143
+ badgeHtml = '<span class="badge badge-proc">&#9203; sent</span>';
144
+ }
145
+
146
+ const errorDisplay = error
147
+ ? `<td class="td-error" title="${escHtml(error)}">${escHtml(truncate(error, 28))}</td>`
148
+ : `<td class="td-error empty">β€”</td>`;
149
+
150
+ return `<tr>
151
+ <td class="td-id" data-request-id="${escHtml(entry.id || '')}">${escHtml(shortId)}</td>
152
+ <td class="td-type">${escHtml(type)}</td>
153
+ <td class="td-time">${escHtml(time)}</td>
154
+ <td>${badgeHtml}</td>
155
+ ${errorDisplay}
156
+ </tr>`;
157
+ });
158
+
159
+ tbody.innerHTML = rows.join('');
160
+
161
+ // Attach click handlers to ID cells
162
+ tbody.querySelectorAll('.td-id[data-request-id]').forEach(td => {
163
+ td.addEventListener('click', () => {
164
+ const reqId = td.getAttribute('data-request-id');
165
+ if (reqId) showRequestDetail(reqId);
166
+ });
167
+ });
168
+ }
169
+
170
+ function escHtml(str) {
171
+ return String(str)
172
+ .replace(/&/g, '&amp;')
173
+ .replace(/</g, '&lt;')
174
+ .replace(/>/g, '&gt;')
175
+ .replace(/"/g, '&quot;');
176
+ }
177
+
178
+ function truncate(str, len) {
179
+ if (!str || str.length <= len) return str;
180
+ return str.slice(0, len) + '…';
181
+ }
182
+
183
+ // ── Request detail modal ────────────────────────────────────
184
+
185
+ let _logEntries = [];
186
+
187
+ function showRequestDetail(reqId) {
188
+ const entry = _logEntries.find(e => e.id === reqId);
189
+ if (!entry) return;
190
+
191
+ const overlay = document.getElementById('detail-overlay');
192
+ const title = document.getElementById('detail-title');
193
+ const body = document.getElementById('detail-body');
194
+
195
+ title.textContent = `Request ${String(reqId).slice(0, 12)}`;
196
+
197
+ const fields = [
198
+ ['ID', entry.id],
199
+ ['Type', formatType(entry.type || entry.method)],
200
+ ['Time', formatTime(entry.time || entry.timestamp || entry.createdAt)],
201
+ ['Status', entry.status || entry.state || 'pending'],
202
+ ['HTTP', entry.httpStatus || 'β€”'],
203
+ ['URL', entry.url || 'β€”'],
204
+ ['Payload', entry.payloadSummary || 'β€”'],
205
+ ['Response', entry.responseSummary || 'β€”'],
206
+ ['Error', entry.error || 'β€”'],
207
+ ];
208
+
209
+ body.innerHTML = fields.map(([label, value]) => {
210
+ let cls = 'detail-value';
211
+ if (label === 'Error' && value && value !== 'β€”') cls += ' error';
212
+ if (label === 'Status' && (value === 'COMPLETED' || value === 'success')) cls += ' ok';
213
+ return `<div class="detail-row">
214
+ <div class="detail-label">${escHtml(label)}</div>
215
+ <div class="${cls}">${escHtml(String(value || 'β€”'))}</div>
216
+ </div>`;
217
+ }).join('');
218
+
219
+ overlay.classList.add('open');
220
+ }
221
+
222
+ document.getElementById('detail-close').addEventListener('click', () => {
223
+ document.getElementById('detail-overlay').classList.remove('open');
224
+ });
225
+
226
+ document.getElementById('detail-overlay').addEventListener('click', (e) => {
227
+ if (e.target === e.currentTarget) {
228
+ e.currentTarget.classList.remove('open');
229
+ }
230
+ });
231
+
232
+ // ── Initial data fetch ───────────────────────────────────────
233
+
234
+ function fetchStatus() {
235
+ chrome.runtime.sendMessage({ type: 'STATUS' }, (data) => {
236
+ if (chrome.runtime.lastError) return;
237
+ updateStatus(data);
238
+ });
239
+ }
240
+
241
+ function fetchLog() {
242
+ chrome.runtime.sendMessage({ type: 'REQUEST_LOG' }, (data) => {
243
+ if (chrome.runtime.lastError) return;
244
+ if (data && data.log) updateRequestLog(data.log);
245
+ });
246
+ }
247
+
248
+ // ── Message listener (push updates) ─────────────────────────
249
+
250
+ chrome.runtime.onMessage.addListener((msg) => {
251
+ if (msg.type === 'STATUS_PUSH') {
252
+ fetchStatus();
253
+ }
254
+ if (msg.type === 'REQUEST_LOG_UPDATE') {
255
+ if (msg.log) updateRequestLog(msg.log);
256
+ }
257
+ });
258
+
259
+ // ── Toggle (connect / disconnect) ───────────────────────────
260
+
261
+ document.getElementById('main-toggle').addEventListener('change', (e) => {
262
+ const msgType = e.target.checked ? 'RECONNECT' : 'DISCONNECT';
263
+ chrome.runtime.sendMessage({ type: msgType }, () => {
264
+ if (chrome.runtime.lastError) return;
265
+ setTimeout(fetchStatus, 400);
266
+ });
267
+ });
268
+
269
+ // ── Action buttons ───────────────────────────────────────────
270
+
271
+ document.getElementById('btn-flow').addEventListener('click', () => {
272
+ chrome.runtime.sendMessage({ type: 'OPEN_FLOW_TAB' }, () => {
273
+ if (chrome.runtime.lastError) return;
274
+ });
275
+ });
276
+
277
+ document.getElementById('btn-token').addEventListener('click', () => {
278
+ const btn = document.getElementById('btn-token');
279
+ btn.textContent = 'Opening...';
280
+ btn.disabled = true;
281
+ chrome.runtime.sendMessage({ type: 'REFRESH_TOKEN' }, () => {
282
+ if (chrome.runtime.lastError) { /* ignore */ }
283
+ btn.textContent = 'Refresh Token';
284
+ btn.disabled = false;
285
+ });
286
+ });
287
+
288
+ // ── Init ─────────────────────────────────────────────────────
289
+
290
+ document.addEventListener('DOMContentLoaded', () => {
291
+ fetchStatus();
292
+ fetchLog();
293
+ });
flow-agent/media-id.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ cat_beach.png : cc312ed8-e13f-4b89-a25d-4d45786daf15
2
+ dragon_end.png : 9f7ee671-62cf-4380-8896-38a1cc0ce943
3
+ dragon_start.png : 76690529-192b-42ee-b6d5-ebc003ec7467
4
+ end_frame.png : dfa0816c-dfc5-41bd-bbbf-0ef865a377de
5
+ start_frame.png : 41f43cdb-293f-4e41-8bb2-664fd269619b
6
+ upload_08eb0364414643f9bede2b4e139375d9_test_t2i.png : a821e119-ebdb-419d-8991-f80ecfa63637
7
+ upload_125349fc756b4e6f8ead3b2a9d6aa036_test_i2v.mp4 : 84e699af-72ba-45db-9a0e-ea60cda3420b
8
+ upload_55a0bcc69fa74bf4a85790a23ba0e92a_test_t2i.png : e6dcaab9-266f-4dd1-bf04-63bf8cd5a27b
9
+ upload_6fb7eab112a04007b7468ce3b4a7c5d9_test_t2i.png : 0e61aa50-fc25-4c58-95ff-ea2587e66265
10
+ upload_c0e77a6605844f5d93182e9fc6aaaa5e_test_i2v.mp4 : 9f598bab-5f73-403d-b679-7cd223f42523
11
+ upload_cd9aed3690464e06953eb74c5b254619_test_t2i.png : a2ef1c23-c2bb-42a9-8cd2-03264a754623
12
+ upload_ce5bb751ddeb461c9392c617af058186_test_i2v.mp4 : 8eb1f60d-0f6a-44b3-835d-8f268c565818