Spaces:
Runtime error
Runtime error
akashyadav758 commited on
Commit Β·
6ada2bc
1
Parent(s): 2a601f2
Add and load all three extensions in Chrome on Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- Dockerfile +8 -0
- chatgpt-free-api/.gitignore +13 -0
- chatgpt-free-api/README.md +56 -0
- chatgpt-free-api/agent.go +144 -0
- chatgpt-free-api/api_bridge.go +136 -0
- chatgpt-free-api/chatgpt.go +1009 -0
- chatgpt-free-api/config.go +102 -0
- chatgpt-free-api/config.json +14 -0
- chatgpt-free-api/cookies.go +477 -0
- chatgpt-free-api/cookies_test.go +104 -0
- chatgpt-free-api/extension.go +161 -0
- chatgpt-free-api/go.mod +8 -0
- chatgpt-free-api/go.sum +4 -0
- chatgpt-free-api/gpt-extension/background.js +1030 -0
- chatgpt-free-api/gpt-extension/icon128.png +0 -0
- chatgpt-free-api/gpt-extension/icon16.png +0 -0
- chatgpt-free-api/gpt-extension/icon48.png +0 -0
- chatgpt-free-api/gpt-extension/icon_large.png +0 -0
- chatgpt-free-api/gpt-extension/manifest.json +28 -0
- chatgpt-free-api/gpt-extension/popup.html +158 -0
- chatgpt-free-api/gpt-extension/popup.js +56 -0
- chatgpt-free-api/handlers.go +483 -0
- chatgpt-free-api/helpers.go +49 -0
- chatgpt-free-api/main.go +5 -0
- chatgpt-free-api/sniff.go +73 -0
- chatgpt-free-api/test.sh +124 -0
- flow-agent/.gitignore +15 -0
- flow-agent/README.md +468 -0
- flow-agent/SNIFFING.md +205 -0
- flow-agent/cli/api.py +617 -0
- flow-agent/cli/edit.py +243 -0
- flow-agent/cli/generate.py +150 -0
- flow-agent/cli/image.py +91 -0
- flow-agent/cli/sniff.py +125 -0
- flow-agent/cli/upload.py +55 -0
- flow-agent/error.md +74 -0
- flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1 +0 -0
- flow-agent/extension/background.js +879 -0
- flow-agent/extension/content.js +91 -0
- flow-agent/extension/icon128.png +0 -0
- flow-agent/extension/icon16.png +0 -0
- flow-agent/extension/icon48.png +0 -0
- flow-agent/extension/injected.js +163 -0
- flow-agent/extension/manifest.json +59 -0
- flow-agent/extension/popup.html +337 -0
- flow-agent/extension/popup.js +138 -0
- flow-agent/extension/rules.json +25 -0
- flow-agent/extension/side_panel.html +840 -0
- flow-agent/extension/side_panel.js +293 -0
- 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("\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("\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 += `})\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, '&')
|
| 40 |
+
.replace(/</g, '<')
|
| 41 |
+
.replace(/>/g, '>')
|
| 42 |
+
.replace(/"/g, '"');
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function badgeHtml(status) {
|
| 46 |
+
if (status === 'COMPLETED' || status === 'success') {
|
| 47 |
+
return '<span class="badge badge-ok">✓ done</span>';
|
| 48 |
+
} else if (status === 'FAILED' || status === 'failed' || (typeof status === 'number' && status >= 400)) {
|
| 49 |
+
return '<span class="badge badge-fail">✗ fail</span>';
|
| 50 |
+
} else if (status === 'PROCESSING') {
|
| 51 |
+
return '<span class="badge badge-proc">⏳ gen...</span>';
|
| 52 |
+
} else {
|
| 53 |
+
return '<span class="badge badge-proc">⏳ 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">▶</span>' : '<span class="expand-icon" style="visibility:hidden">▶</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">×</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">✓ done</span>';
|
| 136 |
+
} else if (status === 'FAILED' || status === 'failed' || (typeof status === 'number' && status >= 400)) {
|
| 137 |
+
badgeHtml = '<span class="badge badge-fail">✗ fail</span>';
|
| 138 |
+
} else if (status === 'PROCESSING') {
|
| 139 |
+
badgeHtml = '<span class="badge badge-proc">⏳ gen...</span>';
|
| 140 |
+
} else if (status === 200 || status === 'processing') {
|
| 141 |
+
badgeHtml = '<span class="badge badge-proc">⏳ sent</span>';
|
| 142 |
+
} else {
|
| 143 |
+
badgeHtml = '<span class="badge badge-proc">⏳ 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, '&')
|
| 173 |
+
.replace(/</g, '<')
|
| 174 |
+
.replace(/>/g, '>')
|
| 175 |
+
.replace(/"/g, '"');
|
| 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
|