diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0300c56..0000000 --- a/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# ── Stage 1: 編譯 ───────────────────────────────────────────── -FROM golang:1.25-alpine AS builder - -WORKDIR /build - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o cursor-api-proxy . - -# ── Stage 2: 執行環境 ────────────────────────────────────────── -FROM alpine:3.21 - -RUN apk add --no-cache ca-certificates tzdata - -WORKDIR /app - -COPY --from=builder /build/cursor-api-proxy . - -EXPOSE 8766 - -ENTRYPOINT ["./cursor-api-proxy"] diff --git a/Makefile b/Makefile index f0eb2c7..152f528 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,12 @@ ANTHROPIC_BASE_HOST ?= $(HOST) TLS_CERT ?= TLS_KEY ?= +# ── Gemini Web Provider ─────────────────────── +PROVIDER ?= cursor +GEMINI_ACCOUNT_DIR ?= +GEMINI_BROWSER_VISIBLE ?= false +GEMINI_MAX_SESSIONS ?= 3 + # ── 記錄 ────────────────────────────────────── SESSIONS_LOG ?= @@ -84,6 +90,12 @@ env: @printf 'CURSOR_BRIDGE_TLS_CERT=%s\n' "$(TLS_CERT)" >> $(ENV_FILE) @printf 'CURSOR_BRIDGE_TLS_KEY=%s\n' "$(TLS_KEY)" >> $(ENV_FILE) @printf 'CURSOR_BRIDGE_SESSIONS_LOG=%s\n' "$(SESSIONS_LOG)" >> $(ENV_FILE) + @printf '# ── Provider 設定 ───────────────────────────\n' >> $(ENV_FILE) + @printf 'CURSOR_BRIDGE_PROVIDER=%s\n' "$(PROVIDER)" >> $(ENV_FILE) + @printf '# Gemini Web Provider 設定(當 PROVIDER=gemini-web 時使用)\n' >> $(ENV_FILE) + @printf 'GEMINI_ACCOUNT_DIR=%s\n' "$(GEMINI_ACCOUNT_DIR)" >> $(ENV_FILE) + @printf 'GEMINI_BROWSER_VISIBLE=%s\n' "$(GEMINI_BROWSER_VISIBLE)" >> $(ENV_FILE) + @printf 'GEMINI_MAX_SESSIONS=%s\n' "$(GEMINI_MAX_SESSIONS)" >> $(ENV_FILE) @echo "已產生 $(ENV_FILE)" ## 編譯二進位檔 @@ -102,18 +114,18 @@ run: build clean: rm -f cursor-api-proxy $(ENV_FILE) -## 設定 OpenCode 使用此代理(更新 opencode.json 的 cursor provider) +## 設定 OpenCode 使用此代理(更新 opencode.json 的 cursor 與 gemini-web provider) opencode: build @if [ ! -f "$(OPENCODE_CONFIG)" ]; then \ echo "找不到 $(OPENCODE_CONFIG),建立新設定檔"; \ mkdir -p $$(dirname "$(OPENCODE_CONFIG)"); \ - printf '{\n "model": "$(OPENCODE_MODEL)",\n "small_model": "$(OPENCODE_SMALL_MODEL)",\n "provider": {\n "cursor": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Cursor Agent",\n "options": {\n "baseURL": "http://$(HOST):$(PORT)/v1",\n "apiKey": "unused"\n },\n "models": { "auto": { "name": "Cursor Auto" } }\n }\n }\n}\n' > "$(OPENCODE_CONFIG)"; \ - echo "已建立 $(OPENCODE_CONFIG)"; \ + printf '{\n "model": "$(OPENCODE_MODEL)",\n "small_model": "$(OPENCODE_SMALL_MODEL)",\n "provider": {\n "cursor": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Cursor Agent",\n "options": {\n "baseURL": "http://$(HOST):$(PORT)/v1",\n "apiKey": "unused"\n },\n "models": { "auto": { "name": "Cursor Auto" } }\n },\n "gemini-web": {\n "npm": "@ai-sdk/openai-compatible",\n "name": "Gemini Web",\n "options": {\n "baseURL": "http://$(HOST):$(PORT)/v1",\n "apiKey": "unused"\n },\n "models": {\n "gemini-2.0-flash": { "name": "Gemini 2.0 Flash" },\n "gemini-2.5-pro": { "name": "Gemini 2.5 Pro" },\n "gemini-2.5-pro-thinking": { "name": "Gemini 2.5 Pro Thinking" }\n }\n }\n }\n}\n' > "$(OPENCODE_CONFIG)"; \ + echo "已建立 $(OPENCODE_CONFIG)(包含 cursor 與 gemini-web provider)"; \ elif [ -n "$(API_KEY)" ]; then \ - jq --arg model "$(OPENCODE_MODEL)" --arg small "$(OPENCODE_SMALL_MODEL)" --arg base "http://$(HOST):$(PORT)/v1" --arg key "$(API_KEY)" '.model = $$model | .small_model = $$small | .provider.cursor.options.baseURL = $$base | .provider.cursor.options.apiKey = $$key' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \ + jq --arg model "$(OPENCODE_MODEL)" --arg small "$(OPENCODE_SMALL_MODEL)" --arg base "http://$(HOST):$(PORT)/v1" --arg key "$(API_KEY)" '.model = $$model | .small_model = $$small | .provider.cursor.options.baseURL = $$base | .provider.cursor.options.apiKey = $$key | .provider["gemini-web"].options.baseURL = $$base | .provider["gemini-web"].options.apiKey = $$key' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \ echo "已更新 $(OPENCODE_CONFIG)(model=$(OPENCODE_MODEL), small_model=$(OPENCODE_SMALL_MODEL), baseURL → http://$(HOST):$(PORT)/v1,apiKey 已設定)"; \ else \ - jq --arg model "$(OPENCODE_MODEL)" --arg small "$(OPENCODE_SMALL_MODEL)" --arg base "http://$(HOST):$(PORT)/v1" '.model = $$model | .small_model = $$small | .provider.cursor.options.baseURL = $$base' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \ + jq --arg model "$(OPENCODE_MODEL)" --arg small "$(OPENCODE_SMALL_MODEL)" --arg base "http://$(HOST):$(PORT)/v1" '.model = $$model | .small_model = $$small | .provider.cursor.options.baseURL = $$base | .provider["gemini-web"].options.baseURL = $$base' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \ echo "已更新 $(OPENCODE_CONFIG)(model=$(OPENCODE_MODEL), small_model=$(OPENCODE_SMALL_MODEL), baseURL → http://$(HOST):$(PORT)/v1)"; \ fi @@ -126,8 +138,8 @@ opencode-models: opencode MODELS=$$(curl -s http://$(HOST):$(PORT)/v1/models | jq '[.data[].id]'); \ kill $$PID 2>/dev/null; wait $$PID 2>/dev/null; \ if [ -n "$$MODELS" ] && [ "$$MODELS" != "null" ]; then \ - jq --argjson ids "$$MODELS" 'reduce $ids[] as $id (.; .provider.cursor.models[$id] = { name: $id })' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \ - echo "已同步模型列表到 $(OPENCODE_CONFIG)"; \ + jq --argjson ids "$$MODELS" 'reduce $ids[] as $id (.; .provider.cursor.models[$id] = { name: $id } | .provider["gemini-web"].models[$id] = { name: $id })' "$(OPENCODE_CONFIG)" > "$(OPENCODE_CONFIG).tmp" && mv "$(OPENCODE_CONFIG).tmp" "$(OPENCODE_CONFIG)"; \ + echo "已同步模型列表到 $(OPENCODE_CONFIG)(cursor 與 gemini-web)"; \ else \ echo "無法取得模型列表,請確認代理已啟動"; \ fi @@ -287,8 +299,21 @@ help: @echo " make docker-restart 重新建置並啟動容器" @echo " make docker-shell 進入容器 shell(除錯用)" @echo "" + @echo "Provider 設定範例:" + @echo " make env PROVIDER=cursor # 使用 Cursor(預設)" + @echo " make env PROVIDER=gemini-web # 使用 Gemini Web" + @echo " make env PROVIDER=gemini-web GEMINI_ACCOUNT_DIR=/path/to/sessions" + @echo " make env PROVIDER=gemini-web GEMINI_BROWSER_VISIBLE=true" + @echo " make opencode # 設定 OpenCode(含 cursor 與 gemini-web provider)" + @echo "" @echo "覆寫範例:" @echo " make env PORT=9000 API_KEY=mysecret TIMEOUT_MS=60000" @echo " make pm2-claude-code PORT=8765 API_KEY=mykey" @echo " make pm2-opencode PORT=8765" @echo " make claude-settings PORT=8766 ANTHROPIC_BASE_HOST=localhost ANTHROPIC_DEFAULT_OPUS_MODEL=claude-4.6-opus-high" + @echo "" + @echo "使用 Gemini Web Provider:" + @echo " 1. make env PROVIDER=gemini-web" + @echo " 2. gemini-login my-session # 登入並儲存 session" + @echo " 3. make run # 啟動代理" + @echo " 4. 在 OpenCode 設定 model: gemini/gemini-2.5-pro" diff --git a/cmd/gemini-login/main.go b/cmd/gemini-login/main.go new file mode 100644 index 0000000..7a0d7f7 --- /dev/null +++ b/cmd/gemini-login/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "cursor-api-proxy/internal/config" + "cursor-api-proxy/internal/env" + "cursor-api-proxy/internal/providers/geminiweb" + "fmt" + "os" + "strings" +) + +func main() { + accountName := "" + visible := false + + // 解析命令列參數 + for i := 1; i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "--visible" || arg == "-v" { + visible = true + } else if arg == "--help" || arg == "-h" { + printHelp() + os.Exit(0) + } else if !strings.HasPrefix(arg, "-") { + accountName = arg + } + } + + e := env.OsEnvToMap() + loaded := env.LoadEnvConfig(e, "") + cfg := config.LoadBridgeConfig(e, "") + + cfg.GeminiAccountDir = loaded.GeminiAccountDir + // 命令列參數優先於環境變數 + cfg.GeminiBrowserVisible = visible || loaded.GeminiBrowserVisible + + fmt.Printf("Session 儲存位置: %s\n", cfg.GeminiAccountDir) + fmt.Printf("瀏覽器可見: %v\n", cfg.GeminiBrowserVisible) + + if err := geminiweb.RunLogin(cfg, accountName); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func printHelp() { + fmt.Println("使用方法: gemini-login [options] [session-name]") + fmt.Println("") + fmt.Println("選項:") + fmt.Println(" --visible, -v 顯示瀏覽器視窗(預設隱藏)") + fmt.Println(" --help, -h 顯示此說明") + fmt.Println("") + fmt.Println("環境變數:") + fmt.Println(" GEMINI_ACCOUNT_DIR Session 儲存目錄(預設: ~/.cursor-api-proxy/gemini-accounts)") + fmt.Println(" GEMINI_BROWSER_VISIBLE 是否顯示瀏覽器(true/false,預設: false)") + fmt.Println("") + fmt.Println("範例:") + fmt.Println(" gemini-login my-session") + fmt.Println(" gemini-login --visible my-session") + fmt.Println(" GEMINI_BROWSER_VISIBLE=true gemini-login") +} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index a7355fc..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,27 +0,0 @@ -services: - cursor-api-proxy: - build: - context: . - dockerfile: Dockerfile - image: cursor-api-proxy:latest - container_name: cursor-api-proxy - restart: unless-stopped - env_file: - - .env - ports: - - "${CURSOR_BRIDGE_PORT:-8766}:${CURSOR_BRIDGE_PORT:-8766}" - environment: - - CURSOR_BRIDGE_HOST=0.0.0.0 - volumes: - # Cursor CLI 二進位檔(從宿主機掛載,唯讀) - - ${CURSOR_AGENT_HOST_BIN:-/usr/local/bin/agent}:/usr/local/bin/agent:ro - # 帳號設定目錄(持久化帳號資料) - - ${CURSOR_ACCOUNTS_DIR:-~/.cursor-api-proxy}:/root/.cursor-api-proxy - # 工作區(選用,掛載你想讓 agent 存取的專案目錄) - - ${WORKSPACE_DIR:-/tmp/workspace}:/workspace - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:${CURSOR_BRIDGE_PORT:-8766}/health"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s diff --git a/go.mod b/go.mod index c231a41..341a00d 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,20 @@ require ( ) require ( + github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v3 v3.0.5 // indirect + github.com/go-rod/rod v0.116.2 // indirect + github.com/go-stack/stack v1.8.1 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/playwright-community/playwright-go v0.5700.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/ysmood/fetchup v0.2.3 // indirect + github.com/ysmood/goob v0.4.0 // indirect + github.com/ysmood/got v0.40.0 // indirect + github.com/ysmood/gson v0.7.3 // indirect + github.com/ysmood/leakless v0.9.0 // indirect golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 8411195..77d212a 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,15 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= +github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= +github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -10,17 +20,73 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U= +github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= diff --git a/internal/apitypes/types.go b/internal/apitypes/types.go new file mode 100644 index 0000000..abdcfcd --- /dev/null +++ b/internal/apitypes/types.go @@ -0,0 +1,40 @@ +package apitypes + +type Message struct { + Role string + Content string +} + +type Tool struct { + Type string + Function ToolFunction +} + +type ToolFunction struct { + Name string + Description string + Parameters interface{} +} + +type ToolCall struct { + ID string + Name string + Arguments string +} + +type StreamChunk struct { + Type ChunkType + Text string + Thinking string + ToolCall *ToolCall + Done bool +} + +type ChunkType int + +const ( + ChunkText ChunkType = iota + ChunkThinking + ChunkToolCall + ChunkDone +) diff --git a/internal/config/config.go b/internal/config/config.go index b217919..bf7ea2e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,50 +5,58 @@ import ( ) type BridgeConfig struct { - AgentBin string - Host string - Port int - RequiredKey string - DefaultModel string - Mode string - Force bool - ApproveMcps bool - StrictModel bool - Workspace string - TimeoutMs int - TLSCertPath string - TLSKeyPath string - SessionsLogPath string - ChatOnlyWorkspace bool - Verbose bool - MaxMode bool - ConfigDirs []string - MultiPort bool - WinCmdlineMax int + AgentBin string + Host string + Port int + RequiredKey string + DefaultModel string + Mode string + Provider string + Force bool + ApproveMcps bool + StrictModel bool + Workspace string + TimeoutMs int + TLSCertPath string + TLSKeyPath string + SessionsLogPath string + ChatOnlyWorkspace bool + Verbose bool + MaxMode bool + ConfigDirs []string + MultiPort bool + WinCmdlineMax int + GeminiAccountDir string + GeminiBrowserVisible bool + GeminiMaxSessions int } func LoadBridgeConfig(e env.EnvSource, cwd string) BridgeConfig { loaded := env.LoadEnvConfig(e, cwd) return BridgeConfig{ - AgentBin: loaded.AgentBin, - Host: loaded.Host, - Port: loaded.Port, - RequiredKey: loaded.RequiredKey, - DefaultModel: loaded.DefaultModel, - Mode: "ask", - Force: loaded.Force, - ApproveMcps: loaded.ApproveMcps, - StrictModel: loaded.StrictModel, - Workspace: loaded.Workspace, - TimeoutMs: loaded.TimeoutMs, - TLSCertPath: loaded.TLSCertPath, - TLSKeyPath: loaded.TLSKeyPath, - SessionsLogPath: loaded.SessionsLogPath, - ChatOnlyWorkspace: loaded.ChatOnlyWorkspace, - Verbose: loaded.Verbose, - MaxMode: loaded.MaxMode, - ConfigDirs: loaded.ConfigDirs, - MultiPort: loaded.MultiPort, - WinCmdlineMax: loaded.WinCmdlineMax, + AgentBin: loaded.AgentBin, + Host: loaded.Host, + Port: loaded.Port, + RequiredKey: loaded.RequiredKey, + DefaultModel: loaded.DefaultModel, + Mode: "ask", + Provider: loaded.Provider, + Force: loaded.Force, + ApproveMcps: loaded.ApproveMcps, + StrictModel: loaded.StrictModel, + Workspace: loaded.Workspace, + TimeoutMs: loaded.TimeoutMs, + TLSCertPath: loaded.TLSCertPath, + TLSKeyPath: loaded.TLSKeyPath, + SessionsLogPath: loaded.SessionsLogPath, + ChatOnlyWorkspace: loaded.ChatOnlyWorkspace, + Verbose: loaded.Verbose, + MaxMode: loaded.MaxMode, + ConfigDirs: loaded.ConfigDirs, + MultiPort: loaded.MultiPort, + WinCmdlineMax: loaded.WinCmdlineMax, + GeminiAccountDir: loaded.GeminiAccountDir, + GeminiBrowserVisible: loaded.GeminiBrowserVisible, + GeminiMaxSessions: loaded.GeminiMaxSessions, } } diff --git a/internal/env/env.go b/internal/env/env.go index 239e3d8..45dc3be 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -12,28 +12,32 @@ import ( type EnvSource map[string]string type LoadedEnv struct { - AgentBin string - AgentNode string - AgentScript string - CommandShell string - Host string - Port int - RequiredKey string - DefaultModel string - Force bool - ApproveMcps bool - StrictModel bool - Workspace string - TimeoutMs int - TLSCertPath string - TLSKeyPath string - SessionsLogPath string - ChatOnlyWorkspace bool - Verbose bool - MaxMode bool - ConfigDirs []string - MultiPort bool - WinCmdlineMax int + AgentBin string + AgentNode string + AgentScript string + CommandShell string + Host string + Port int + RequiredKey string + DefaultModel string + Provider string + Force bool + ApproveMcps bool + StrictModel bool + Workspace string + TimeoutMs int + TLSCertPath string + TLSKeyPath string + SessionsLogPath string + ChatOnlyWorkspace bool + Verbose bool + MaxMode bool + ConfigDirs []string + MultiPort bool + WinCmdlineMax int + GeminiAccountDir string + GeminiBrowserVisible bool + GeminiMaxSessions int } type AgentCommand struct { @@ -256,29 +260,40 @@ func LoadEnvConfig(e EnvSource, cwd string) LoadedEnv { workspace = cwd } + geminiAccountDir := getEnvVal(e, []string{"GEMINI_ACCOUNT_DIR"}) + if geminiAccountDir == "" { + geminiAccountDir = filepath.Join(home, ".cursor-api-proxy", "gemini-accounts") + } else { + geminiAccountDir = resolveAbs(geminiAccountDir, cwd) + } + return LoadedEnv{ - AgentBin: agentBin, - AgentNode: getEnvVal(e, []string{"CURSOR_AGENT_NODE"}), - AgentScript: getEnvVal(e, []string{"CURSOR_AGENT_SCRIPT"}), - CommandShell: commandShell, - Host: host, - Port: port, - RequiredKey: getEnvVal(e, []string{"CURSOR_BRIDGE_API_KEY"}), - DefaultModel: normalizeModelId(getEnvVal(e, []string{"CURSOR_BRIDGE_DEFAULT_MODEL"})), - Force: envBool(e, []string{"CURSOR_BRIDGE_FORCE"}, false), - ApproveMcps: envBool(e, []string{"CURSOR_BRIDGE_APPROVE_MCPS"}, false), - StrictModel: envBool(e, []string{"CURSOR_BRIDGE_STRICT_MODEL"}, true), - Workspace: workspace, - TimeoutMs: envInt(e, []string{"CURSOR_BRIDGE_TIMEOUT_MS"}, 300000), - TLSCertPath: resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_TLS_CERT"}), cwd), - TLSKeyPath: resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_TLS_KEY"}), cwd), - SessionsLogPath: sessionsLogPath, - ChatOnlyWorkspace: envBool(e, []string{"CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE"}, true), - Verbose: envBool(e, []string{"CURSOR_BRIDGE_VERBOSE"}, false), - MaxMode: envBool(e, []string{"CURSOR_BRIDGE_MAX_MODE"}, false), - ConfigDirs: configDirs, - MultiPort: envBool(e, []string{"CURSOR_BRIDGE_MULTI_PORT"}, false), - WinCmdlineMax: winMax, + AgentBin: agentBin, + AgentNode: getEnvVal(e, []string{"CURSOR_AGENT_NODE"}), + AgentScript: getEnvVal(e, []string{"CURSOR_AGENT_SCRIPT"}), + CommandShell: commandShell, + Host: host, + Port: port, + RequiredKey: getEnvVal(e, []string{"CURSOR_BRIDGE_API_KEY"}), + DefaultModel: normalizeModelId(getEnvVal(e, []string{"CURSOR_BRIDGE_DEFAULT_MODEL"})), + Provider: getEnvVal(e, []string{"CURSOR_BRIDGE_PROVIDER"}), + Force: envBool(e, []string{"CURSOR_BRIDGE_FORCE"}, false), + ApproveMcps: envBool(e, []string{"CURSOR_BRIDGE_APPROVE_MCPS"}, false), + StrictModel: envBool(e, []string{"CURSOR_BRIDGE_STRICT_MODEL"}, true), + Workspace: workspace, + TimeoutMs: envInt(e, []string{"CURSOR_BRIDGE_TIMEOUT_MS"}, 300000), + TLSCertPath: resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_TLS_CERT"}), cwd), + TLSKeyPath: resolveAbs(getEnvVal(e, []string{"CURSOR_BRIDGE_TLS_KEY"}), cwd), + SessionsLogPath: sessionsLogPath, + ChatOnlyWorkspace: envBool(e, []string{"CURSOR_BRIDGE_CHAT_ONLY_WORKSPACE"}, true), + Verbose: envBool(e, []string{"CURSOR_BRIDGE_VERBOSE"}, false), + MaxMode: envBool(e, []string{"CURSOR_BRIDGE_MAX_MODE"}, false), + ConfigDirs: configDirs, + MultiPort: envBool(e, []string{"CURSOR_BRIDGE_MULTI_PORT"}, false), + WinCmdlineMax: winMax, + GeminiAccountDir: geminiAccountDir, + GeminiBrowserVisible: envBool(e, []string{"GEMINI_BROWSER_VISIBLE"}, false), + GeminiMaxSessions: envInt(e, []string{"GEMINI_MAX_SESSIONS"}, 3), } } diff --git a/internal/handlers/gemini_handler.go b/internal/handlers/gemini_handler.go new file mode 100644 index 0000000..04d7065 --- /dev/null +++ b/internal/handlers/gemini_handler.go @@ -0,0 +1,203 @@ +package handlers + +import ( + "context" + "cursor-api-proxy/internal/apitypes" + "cursor-api-proxy/internal/config" + "cursor-api-proxy/internal/httputil" + "cursor-api-proxy/internal/logger" + "cursor-api-proxy/internal/providers/geminiweb" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" +) + +func HandleGeminiChatCompletions(w http.ResponseWriter, r *http.Request, cfg config.BridgeConfig, rawBody, method, pathname, remoteAddress string) { + _ = context.Background() // 確保 context 被使用 + var bodyMap map[string]interface{} + if err := json.Unmarshal([]byte(rawBody), &bodyMap); err != nil { + httputil.WriteJSON(w, 400, map[string]interface{}{ + "error": map[string]string{"message": "invalid JSON body", "code": "bad_request"}, + }, nil) + return + } + + rawModel, _ := bodyMap["model"].(string) + if rawModel == "" { + rawModel = "gemini-2.0-flash" + } + + var messages []interface{} + if m, ok := bodyMap["messages"].([]interface{}); ok { + messages = m + } + + isStream := false + if s, ok := bodyMap["stream"].(bool); ok { + isStream = s + } + + // 轉換 messages 為 apitypes.Message + var apiMessages []apitypes.Message + for _, m := range messages { + if msgMap, ok := m.(map[string]interface{}); ok { + role, _ := msgMap["role"].(string) + content := "" + if c, ok := msgMap["content"].(string); ok { + content = c + } + apiMessages = append(apiMessages, apitypes.Message{ + Role: role, + Content: content, + }) + } + } + + logger.LogRequestStart(method, pathname, rawModel, cfg.TimeoutMs, isStream) + start := time.Now().UnixMilli() + + // 創建 Gemini provider (使用 Playwright) + provider, provErr := geminiweb.NewPlaywrightProvider(cfg) + if provErr != nil { + logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, -1, provErr.Error()) + httputil.WriteJSON(w, 500, map[string]interface{}{ + "error": map[string]string{"message": provErr.Error(), "code": "provider_error"}, + }, nil) + return + } + + if isStream { + httputil.WriteSSEHeaders(w, nil) + flusher, _ := w.(http.Flusher) + + id := "chatcmpl_" + uuid.New().String() + created := time.Now().Unix() + var accumulated string + + err := provider.Generate(r.Context(), rawModel, apiMessages, nil, func(chunk apitypes.StreamChunk) { + if chunk.Type == apitypes.ChunkText { + accumulated += chunk.Text + respChunk := map[string]interface{}{ + "id": id, + "object": "chat.completion.chunk", + "created": created, + "model": rawModel, + "choices": []map[string]interface{}{ + { + "index": 0, + "delta": map[string]string{"content": chunk.Text}, + "finish_reason": nil, + }, + }, + } + data, _ := json.Marshal(respChunk) + fmt.Fprintf(w, "data: %s\n\n", data) + if flusher != nil { + flusher.Flush() + } + } else if chunk.Type == apitypes.ChunkThinking { + respChunk := map[string]interface{}{ + "id": id, + "object": "chat.completion.chunk", + "created": created, + "model": rawModel, + "choices": []map[string]interface{}{ + { + "index": 0, + "delta": map[string]interface{}{"reasoning_content": chunk.Thinking}, + "finish_reason": nil, + }, + }, + } + data, _ := json.Marshal(respChunk) + fmt.Fprintf(w, "data: %s\n\n", data) + if flusher != nil { + flusher.Flush() + } + } else if chunk.Type == apitypes.ChunkDone { + stopChunk := map[string]interface{}{ + "id": id, + "object": "chat.completion.chunk", + "created": created, + "model": rawModel, + "choices": []map[string]interface{}{ + { + "index": 0, + "delta": map[string]interface{}{}, + "finish_reason": "stop", + }, + }, + } + data, _ := json.Marshal(stopChunk) + fmt.Fprintf(w, "data: %s\n\n", data) + fmt.Fprintf(w, "data: [DONE]\n\n") + if flusher != nil { + flusher.Flush() + } + } + }) + + latencyMs := time.Now().UnixMilli() - start + if err != nil { + logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, -1, err.Error()) + logger.LogRequestDone(method, pathname, rawModel, latencyMs, -1) + return + } + + logger.LogTrafficResponse(cfg.Verbose, rawModel, accumulated, true) + logger.LogRequestDone(method, pathname, rawModel, latencyMs, 0) + return + } + + // 非串流模式 + var resultText string + var resultThinking string + + err := provider.Generate(r.Context(), rawModel, apiMessages, nil, func(chunk apitypes.StreamChunk) { + if chunk.Type == apitypes.ChunkText { + resultText += chunk.Text + } else if chunk.Type == apitypes.ChunkThinking { + resultThinking += chunk.Thinking + } + }) + + latencyMs := time.Now().UnixMilli() - start + + if err != nil { + logger.LogAgentError(cfg.SessionsLogPath, method, pathname, remoteAddress, -1, err.Error()) + logger.LogRequestDone(method, pathname, rawModel, latencyMs, -1) + httputil.WriteJSON(w, 500, map[string]interface{}{ + "error": map[string]string{"message": err.Error(), "code": "gemini_error"}, + }, nil) + return + } + + logger.LogTrafficResponse(cfg.Verbose, rawModel, resultText, false) + logger.LogRequestDone(method, pathname, rawModel, latencyMs, 0) + + id := "chatcmpl_" + uuid.New().String() + created := time.Now().Unix() + + resp := map[string]interface{}{ + "id": id, + "object": "chat.completion", + "created": created, + "model": rawModel, + "choices": []map[string]interface{}{ + { + "index": 0, + "message": map[string]interface{}{ + "role": "assistant", + "content": resultText, + }, + "finish_reason": "stop", + }, + }, + "usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + + httputil.WriteJSON(w, 200, resp, nil) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index c658e92..db18a0a 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -71,18 +71,29 @@ func LogDebug(format string, args ...interface{}) { } func LogServerStart(version, scheme, host string, port int, cfg config.BridgeConfig) { + provider := cfg.Provider + if provider == "" { + provider = "cursor" + } fmt.Printf("\n%s%s╔══════════════════════════════════════════╗%s\n", cBold, cBCyan, cReset) fmt.Printf("%s%s cursor-api-proxy %sv%s%s%s%s ready%s\n", cBold, cBCyan, cReset, cBold, cWhite, version, cBCyan, cReset) fmt.Printf("%s%s╚══════════════════════════════════════════╝%s\n\n", cBold, cBCyan, cReset) url := fmt.Sprintf("%s://%s:%d", scheme, host, port) fmt.Printf(" %s●%s listening %s%s%s\n", cBGreen, cReset, cBold, url, cReset) + fmt.Printf(" %s▸%s provider %s%s%s\n", cCyan, cReset, cBold, provider, cReset) fmt.Printf(" %s▸%s agent %s%s%s\n", cCyan, cReset, cDim, cfg.AgentBin, cReset) fmt.Printf(" %s▸%s workspace %s%s%s\n", cCyan, cReset, cDim, cfg.Workspace, cReset) fmt.Printf(" %s▸%s model %s%s%s\n", cCyan, cReset, cDim, cfg.DefaultModel, cReset) fmt.Printf(" %s▸%s mode %s%s%s\n", cCyan, cReset, cDim, cfg.Mode, cReset) fmt.Printf(" %s▸%s timeout %s%d ms%s\n", cCyan, cReset, cDim, cfg.TimeoutMs, cReset) + // 顯示 Gemini Web Provider 相關設定 + if provider == "gemini-web" { + fmt.Printf(" %s▸%s gemini-dir %s%s%s\n", cCyan, cReset, cDim, cfg.GeminiAccountDir, cReset) + fmt.Printf(" %s▸%s max-sess %s%d%s\n", cCyan, cReset, cDim, cfg.GeminiMaxSessions, cReset) + } + flags := []string{} if cfg.Force { flags = append(flags, "force") diff --git a/internal/providers/cursor/provider.go b/internal/providers/cursor/provider.go new file mode 100644 index 0000000..e30b3c7 --- /dev/null +++ b/internal/providers/cursor/provider.go @@ -0,0 +1,27 @@ +package cursor + +import ( + "context" + "cursor-api-proxy/internal/apitypes" + "cursor-api-proxy/internal/config" +) + +type Provider struct { + cfg config.BridgeConfig +} + +func NewProvider(cfg config.BridgeConfig) *Provider { + return &Provider{cfg: cfg} +} + +func (p *Provider) Name() string { + return "cursor" +} + +func (p *Provider) Close() error { + return nil +} + +func (p *Provider) Generate(ctx context.Context, model string, messages []apitypes.Message, tools []apitypes.Tool, cb func(apitypes.StreamChunk)) error { + return nil +} diff --git a/internal/providers/factory.go b/internal/providers/factory.go new file mode 100644 index 0000000..63cf767 --- /dev/null +++ b/internal/providers/factory.go @@ -0,0 +1,32 @@ +package providers + +import ( + "context" + "cursor-api-proxy/internal/apitypes" + "cursor-api-proxy/internal/config" + "cursor-api-proxy/internal/providers/cursor" + "cursor-api-proxy/internal/providers/geminiweb" + "fmt" +) + +type Provider interface { + Name() string + Close() error + Generate(ctx context.Context, model string, messages []apitypes.Message, tools []apitypes.Tool, cb func(apitypes.StreamChunk)) error +} + +func NewProvider(cfg config.BridgeConfig) (Provider, error) { + providerType := cfg.Provider + if providerType == "" { + providerType = "cursor" + } + + switch providerType { + case "cursor": + return cursor.NewProvider(cfg), nil + case "gemini-web": + return geminiweb.NewPlaywrightProvider(cfg) + default: + return nil, fmt.Errorf("unknown provider: %s", providerType) + } +} diff --git a/internal/providers/geminiweb/browser.go b/internal/providers/geminiweb/browser.go new file mode 100644 index 0000000..a94894e --- /dev/null +++ b/internal/providers/geminiweb/browser.go @@ -0,0 +1,125 @@ +package geminiweb + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/proto" +) + +type Browser struct { + browser *rod.Browser + visible bool +} + +func NewBrowser(visible bool) (*Browser, error) { + l := launcher.New() + if visible { + l = l.Headless(false) + } else { + l = l.Headless(true) + } + + url, err := l.Launch() + if err != nil { + return nil, fmt.Errorf("failed to launch browser: %w", err) + } + + b := rod.New().ControlURL(url) + if err := b.Connect(); err != nil { + return nil, fmt.Errorf("failed to connect browser: %w", err) + } + + return &Browser{browser: b, visible: visible}, nil +} + +func (b *Browser) Close() error { + if b.browser != nil { + return b.browser.Close() + } + return nil +} + +func (b *Browser) NewPage() (*rod.Page, error) { + return b.browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) +} + +type Cookie struct { + Name string `json:"name"` + Value string `json:"value"` + Domain string `json:"domain"` + Path string `json:"path"` + Expires float64 `json:"expires"` + HTTPOnly bool `json:"httpOnly"` + Secure bool `json:"secure"` +} + +func LoadCookiesFromFile(cookieFile string) ([]Cookie, error) { + data, err := os.ReadFile(cookieFile) + if err != nil { + return nil, fmt.Errorf("failed to read cookies: %w", err) + } + + var cookies []Cookie + if err := json.Unmarshal(data, &cookies); err != nil { + return nil, fmt.Errorf("failed to parse cookies: %w", err) + } + + return cookies, nil +} + +func SaveCookiesToFile(cookies []Cookie, cookieFile string) error { + data, err := json.MarshalIndent(cookies, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal cookies: %w", err) + } + + dir := filepath.Dir(cookieFile) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create cookie dir: %w", err) + } + + if err := os.WriteFile(cookieFile, data, 0644); err != nil { + return fmt.Errorf("failed to write cookies: %w", err) + } + + return nil +} + +func SetCookiesOnPage(page *rod.Page, cookies []Cookie) error { + var protoCookies []*proto.NetworkCookieParam + for _, c := range cookies { + p := &proto.NetworkCookieParam{ + Name: c.Name, + Value: c.Value, + Domain: c.Domain, + Path: c.Path, + HTTPOnly: c.HTTPOnly, + Secure: c.Secure, + } + if c.Expires > 0 { + exp := proto.TimeSinceEpoch(c.Expires) + p.Expires = exp + } + protoCookies = append(protoCookies, p) + } + return page.SetCookies(protoCookies) +} + +func WaitForElement(page *rod.Page, selector string, timeout time.Duration) (*rod.Element, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return page.Context(ctx).Element(selector) +} + +func WaitForElements(page *rod.Page, selector string, timeout time.Duration) (rod.Elements, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return page.Context(ctx).Elements(selector) +} diff --git a/internal/providers/geminiweb/browser_manager.go b/internal/providers/geminiweb/browser_manager.go new file mode 100644 index 0000000..3cff6f7 --- /dev/null +++ b/internal/providers/geminiweb/browser_manager.go @@ -0,0 +1,173 @@ +package geminiweb + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/proto" +) + +// BrowserManager 管理瀏覽器實例的生命週期 +type BrowserManager struct { + mu sync.Mutex + browser *rod.Browser + userDataDir string + page *rod.Page + visible bool + isRunning bool + currentModel string +} + +var ( + globalManager *BrowserManager + globalMu sync.Mutex +) + +// GetBrowserManager 獲取全域瀏覽器管理器(單例) +func GetBrowserManager(userDataDir string, visible bool) (*BrowserManager, error) { + globalMu.Lock() + defer globalMu.Unlock() + + if globalManager != nil { + return globalManager, nil + } + + manager, err := NewBrowserManager(userDataDir, visible) + if err != nil { + return nil, err + } + + globalManager = manager + return globalManager, nil +} + +// NewBrowserManager 建立新的瀏覽器管理器 +func NewBrowserManager(userDataDir string, visible bool) (*BrowserManager, error) { + cleanLockFiles(userDataDir) + + if err := os.MkdirAll(userDataDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create user data dir: %w", err) + } + + return &BrowserManager{ + userDataDir: userDataDir, + visible: visible, + }, nil +} + +// cleanLockFiles 清理 Chrome 的殘留鎖檔案 +func cleanLockFiles(userDataDir string) { + lockFiles := []string{ + "SingletonLock", + "SingletonCookie", + "SingletonSocket", + "Default/SingletonLock", + "Default/SingletonCookie", + "Default/SingletonSocket", + } + + for _, file := range lockFiles { + path := filepath.Join(userDataDir, file) + os.Remove(path) + } +} + +// Launch 啟動瀏覽器(如果尚未啟動) +func (m *BrowserManager) Launch() error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.isRunning && m.browser != nil { + return nil + } + + l := launcher.New() + + if m.visible { + l = l.Headless(false) + } else { + l = l.Headless(true) + } + + l = l.UserDataDir(m.userDataDir) + + url, err := l.Launch() + if err != nil { + return fmt.Errorf("failed to launch browser: %w", err) + } + + b := rod.New().ControlURL(url) + if err := b.Connect(); err != nil { + return fmt.Errorf("failed to connect browser: %w", err) + } + + m.browser = b + + page, err := b.Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + _ = b.Close() + return fmt.Errorf("failed to create page: %w", err) + } + + m.page = page + m.isRunning = true + + return nil +} + +// GetPage 獲取頁面 +func (m *BrowserManager) GetPage() (*rod.Page, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.isRunning || m.browser == nil { + return nil, fmt.Errorf("browser not running") + } + + return m.page, nil +} + +// Close 關閉瀏覽器 +func (m *BrowserManager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + if !m.isRunning { + return nil + } + + var err error + if m.browser != nil { + err = m.browser.Close() + m.browser = nil + } + + m.page = nil + m.isRunning = false + return err +} + +// IsRunning 檢查瀏覽器是否正在運行 +func (m *BrowserManager) IsRunning() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.isRunning +} + +// SetCurrentModel 設定當前模型 +func (m *BrowserManager) SetCurrentModel(model string) { + m.mu.Lock() + defer m.mu.Unlock() + m.currentModel = model +} + +// GetCurrentModel 獲取當前模型 +func (m *BrowserManager) GetCurrentModel() string { + m.mu.Lock() + defer m.mu.Unlock() + return m.currentModel +} diff --git a/internal/providers/geminiweb/page.go b/internal/providers/geminiweb/page.go new file mode 100644 index 0000000..7365bd3 --- /dev/null +++ b/internal/providers/geminiweb/page.go @@ -0,0 +1,250 @@ +package geminiweb + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/go-rod/rod" +) + +const geminiURL = "https://gemini.google.com/app" + +// 輸入框選擇器(依優先順序) +var inputSelectors = []string{ + ".ProseMirror", + "rich-textarea", + "div[role='textbox'][contenteditable='true']", + "div[contenteditable='true']", + "textarea", +} + +// NavigateToGemini 導航到 Gemini +func NavigateToGemini(page *rod.Page) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := page.Context(ctx).Navigate(geminiURL); err != nil { + return fmt.Errorf("failed to navigate: %w", err) + } + return page.Context(ctx).WaitLoad() +} + +// IsLoggedIn 檢查是否已登入 +func IsLoggedIn(page *rod.Page) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + for _, sel := range inputSelectors { + if _, err := page.Context(ctx).Element(sel); err == nil { + return true + } + } + return false +} + +// SelectModel 選擇模型(可選) +func SelectModel(page *rod.Page, model string) error { + fmt.Printf("[GeminiWeb] Model selection skipped (using current model)\n") + return nil +} + +// TypeInput 在輸入框中輸入文字 +func TypeInput(page *rod.Page, text string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + fmt.Println("[GeminiWeb] Looking for input field...") + + // 1. 嘗試所有選擇器 + var inputEl *rod.Element + var err error + + for _, sel := range inputSelectors { + fmt.Printf(" Trying: %s\n", sel) + inputEl, err = page.Context(ctx).Element(sel) + if err == nil { + fmt.Printf(" ✓ Found with: %s\n", sel) + break + } + } + + if err != nil { + // 2. Fallback: 嘗試等待頁面載入完成後重試 + fmt.Println("[GeminiWeb] Waiting for page to fully load...") + time.Sleep(3 * time.Second) + + for _, sel := range inputSelectors { + fmt.Printf(" Retrying: %s\n", sel) + inputEl, err = page.Context(ctx).Element(sel) + if err == nil { + fmt.Printf(" ✓ Found with: %s\n", sel) + break + } + } + } + + if err != nil { + // 3. Debug: 印出頁面標題和 URL + info, _ := page.Info() + fmt.Printf("[GeminiWeb] DEBUG: URL=%s Title=%s\n", info.URL, info.Title) + + // 4. Fallback: 嘗試更通用的選擇器 + fmt.Println("[GeminiWeb] Trying generic selectors...") + genericSelectors := []string{ + "div[contenteditable]", + "[contenteditable]", + "textarea", + "input[type='text']", + } + + for _, sel := range genericSelectors { + fmt.Printf(" Trying generic: %s\n", sel) + inputEl, err = page.Context(ctx).Element(sel) + if err == nil { + fmt.Printf(" ✓ Found with: %s\n", sel) + break + } + } + } + + if err != nil { + info, _ := page.Info() + return fmt.Errorf("input field not found after trying all selectors (URL=%s)", info.URL) + } + + // 2. Focus 輸入框 + fmt.Printf("[GeminiWeb] Focusing input field...\n") + if err := inputEl.Focus(); err != nil { + return fmt.Errorf("failed to focus input: %w", err) + } + + time.Sleep(500 * time.Millisecond) + + // 3. 使用 Input 方法 + fmt.Printf("[GeminiWeb] Typing %d chars...\n", len(text)) + if err := inputEl.Input(text); err != nil { + return fmt.Errorf("failed to input text: %w", err) + } + + time.Sleep(200 * time.Millisecond) + + fmt.Println("[GeminiWeb] Input complete") + return nil +} + +// ClickSend 發送訊息 +func ClickSend(page *rod.Page) error { + // 方法 1: 按 Enter + if err := page.Keyboard.Press('\r'); err != nil { + return fmt.Errorf("failed to press Enter: %w", err) + } + + time.Sleep(200 * time.Millisecond) + return nil +} + +// WaitForReady 等待頁面空閒 +func WaitForReady(page *rod.Page) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + fmt.Println("[GeminiWeb] Checking if page is ready...") + + for { + select { + case <-ctx.Done(): + fmt.Println("[GeminiWeb] Page ready check timeout, proceeding anyway") + return nil + default: + time.Sleep(500 * time.Millisecond) + + // 檢查是否有停止按鈕 + hasStopBtn := false + stopBtns, _ := page.Elements("button[aria-label*='Stop'], button[aria-label*='停止']") + for _, btn := range stopBtns { + visible, _ := btn.Visible() + if visible { + hasStopBtn = true + break + } + } + + if !hasStopBtn { + fmt.Println("[GeminiWeb] Page is ready") + return nil + } + } + } +} + +// ExtractResponse 提取回應文字 +func ExtractResponse(page *rod.Page) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + var lastText string + lastUpdate := time.Now() + + for { + select { + case <-ctx.Done(): + if lastText != "" { + return lastText, nil + } + return "", fmt.Errorf("response timeout") + default: + time.Sleep(500 * time.Millisecond) + + // 尋找回應文字 + for _, sel := range responseSelectors { + elements, err := page.Elements(sel) + if err != nil || len(elements) == 0 { + continue + } + + // 取得最後一個元素的文字 + lastEl := elements[len(elements)-1] + text, err := lastEl.Text() + if err != nil { + continue + } + + text = strings.TrimSpace(text) + if text != "" && text != lastText && len(text) > len(lastText) { + lastText = text + lastUpdate = time.Now() + fmt.Printf("[GeminiWeb] Response length: %d\n", len(text)) + } + } + + // 檢查是否已完成(2 秒內沒有新內容) + if time.Since(lastUpdate) > 2*time.Second && lastText != "" { + // 最後檢查一次是否還有停止按鈕 + hasStopBtn := false + stopBtns, _ := page.Elements("button[aria-label*='Stop'], button[aria-label*='停止']") + for _, btn := range stopBtns { + visible, _ := btn.Visible() + if visible { + hasStopBtn = true + break + } + } + + if !hasStopBtn { + return lastText, nil + } + } + } + } +} + +// 默認的回應選擇器 +var responseSelectors = []string{ + ".model-response-text", + ".message-content", + ".markdown", + ".prose", + "model-response", +} diff --git a/internal/providers/geminiweb/playwright_provider.go b/internal/providers/geminiweb/playwright_provider.go new file mode 100644 index 0000000..ced9dfa --- /dev/null +++ b/internal/providers/geminiweb/playwright_provider.go @@ -0,0 +1,641 @@ +package geminiweb + +import ( + "context" + "cursor-api-proxy/internal/apitypes" + "cursor-api-proxy/internal/config" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/playwright-community/playwright-go" +) + +// PlaywrightProvider 使用 Playwright 的 Gemini Provider +type PlaywrightProvider struct { + cfg config.BridgeConfig + pw *playwright.Playwright + browser playwright.Browser + context playwright.BrowserContext + page playwright.Page + mu sync.Mutex + userDataDir string +} + +var ( + playwrightInstance *playwright.Playwright + playwrightOnce sync.Once + playwrightErr error +) + +// NewPlaywrightProvider 建立新的 Playwright Provider +func NewPlaywrightProvider(cfg config.BridgeConfig) (*PlaywrightProvider, error) { + // 確保 Playwright 已初始化(單例) + playwrightOnce.Do(func() { + playwrightInstance, playwrightErr = playwright.Run() + if playwrightErr != nil { + playwrightErr = fmt.Errorf("failed to run playwright: %w", playwrightErr) + } + }) + + if playwrightErr != nil { + return nil, playwrightErr + } + + // 清理 Chrome 鎖檔案 + userDataDir := filepath.Join(cfg.GeminiAccountDir, "default-session") + cleanLockFiles(userDataDir) + + // 確保目錄存在 + if err := os.MkdirAll(userDataDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create user data dir: %w", err) + } + + return &PlaywrightProvider{ + cfg: cfg, + pw: playwrightInstance, + userDataDir: userDataDir, + }, nil +} + +// getName 返回 Provider 名稱 +func (p *PlaywrightProvider) Name() string { + return "gemini-web" +} + +// launchIfNeeded 如果需要則啟動瀏覽器 +func (p *PlaywrightProvider) launchIfNeeded() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.context != nil && p.page != nil { + return nil + } + + fmt.Println("[GeminiWeb] Launching Chromium...") + + // 使用 LaunchPersistentContext(自動保存 session) + context, err := p.pw.Chromium.LaunchPersistentContext(p.userDataDir, + playwright.BrowserTypeLaunchPersistentContextOptions{ + Headless: playwright.Bool(!p.cfg.GeminiBrowserVisible), + Args: []string{ + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-extensions", + "--disable-plugins", + "--disable-sync", + }, + }) + if err != nil { + return fmt.Errorf("failed to launch persistent context: %w", err) + } + + p.context = context + + // 取得或建立頁面 + pages := context.Pages() + if len(pages) > 0 { + p.page = pages[0] + } else { + page, err := context.NewPage() + if err != nil { + _ = context.Close() + return fmt.Errorf("failed to create page: %w", err) + } + p.page = page + } + + fmt.Println("[GeminiWeb] Browser launched") + return nil +} + +// Generate 生成回應 +func (p *PlaywrightProvider) Generate(ctx context.Context, model string, messages []apitypes.Message, tools []apitypes.Tool, cb func(apitypes.StreamChunk)) (err error) { + // 確保在返回錯誤時保存診斷 + defer func() { + if err != nil { + fmt.Println("[GeminiWeb] Error occurred, saving diagnostics...") + _ = p.saveDiagnostics() + } + }() + + fmt.Printf("[GeminiWeb] Starting generation with model: %s\n", model) + + // 1. 確保瀏覽器已啟動 + if err := p.launchIfNeeded(); err != nil { + return fmt.Errorf("failed to launch browser: %w", err) + } + + // 2. 導航到 Gemini(如果需要) + currentURL := p.page.URL() + if !strings.Contains(currentURL, "gemini.google.com") { + fmt.Println("[GeminiWeb] Navigating to Gemini...") + if _, err := p.page.Goto("https://gemini.google.com/app", playwright.PageGotoOptions{ + WaitUntil: playwright.WaitUntilStateDomcontentloaded, + Timeout: playwright.Float(60000), + }); err != nil { + return fmt.Errorf("failed to navigate: %w", err) + } + // 額外等待 JavaScript 載入 + fmt.Println("[GeminiWeb] Waiting for page to initialize...") + time.Sleep(3 * time.Second) + } + + // 3. 調試模式:等待用戶確認 + if p.cfg.GeminiBrowserVisible { + fmt.Println("\n" + strings.Repeat("=", 70)) + fmt.Println("🔍 調試模式:瀏覽器已開啟") + fmt.Println("請檢查瀏覽器畫面,然後按 ENTER 繼續...") + fmt.Println("如果有問題,請查看: /tmp/gemini-debug.*") + fmt.Println(strings.Repeat("=", 70)) + + var input string + fmt.Scanln(&input) + } + + // 4. 等待頁面完全載入(project-golem 策略) + fmt.Println("[GeminiWeb] Waiting for page to be ready...") + if err := p.waitForPageReady(); err != nil { + fmt.Printf("[GeminiWeb] Warning: %v\n", err) + + // 額外調試:輸出頁面 HTML 結構 + if p.cfg.GeminiBrowserVisible { + html, _ := p.page.Content() + debugPath := "/tmp/gemini-debug.html" + if err := os.WriteFile(debugPath, []byte(html), 0644); err == nil { + fmt.Printf("[GeminiWeb] HTML saved to: %s\n", debugPath) + } + } + } + + // 4. 檢查登入狀態 + fmt.Println("[GeminiWeb] Checking login status...") + loggedIn := p.isLoggedIn() + if !loggedIn { + fmt.Println("[GeminiWeb] Not logged in, continuing anyway") + if p.cfg.GeminiBrowserVisible { + fmt.Println("\n========================================") + fmt.Println("Browser is open. You can:") + fmt.Println("1. Log in to Gemini now") + fmt.Println("2. Continue without login") + fmt.Println("========================================\n") + } + } else { + fmt.Println("[GeminiWeb] ✓ Logged in") + } + + // 5. 選擇模型(如果支援) + if err := p.selectModel(model); err != nil { + fmt.Printf("[GeminiWeb] Warning: model selection failed: %v\n", err) + } + + // 6. 建構提示詞 + prompt := buildPromptFromMessagesPlaywright(messages) + fmt.Printf("[GeminiWeb] Typing prompt (%d chars)...\n", len(prompt)) + + // 7. 輸入文字(使用 Playwright 的 Auto-wait) + if err := p.typeInput(prompt); err != nil { + return fmt.Errorf("failed to type: %w", err) + } + + // 7. 發送訊息 + fmt.Println("[GeminiWeb] Sending message...") + if err := p.sendMessage(); err != nil { + return fmt.Errorf("failed to send: %w", err) + } + + // 8. 提取回應 + fmt.Println("[GeminiWeb] Waiting for response...") + response, err := p.extractResponse() + if err != nil { + return fmt.Errorf("failed to extract response: %w", err) + } + + // 9. 回調 + cb(apitypes.StreamChunk{Type: apitypes.ChunkText, Text: response}) + cb(apitypes.StreamChunk{Type: apitypes.ChunkDone, Done: true}) + + fmt.Printf("[GeminiWeb] Response complete (%d chars)\n", len(response)) + return nil +} + +// Close 關閉 Provider +func (p *PlaywrightProvider) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.context != nil { + if err := p.context.Close(); err != nil { + return err + } + p.context = nil + p.page = nil + } + return nil +} + +// saveDiagnostics 保存診斷信息 +func (p *PlaywrightProvider) saveDiagnostics() error { + if p.page == nil { + return fmt.Errorf("no page available") + } + + // 截圖 + screenshotPath := "/tmp/gemini-debug.png" + if _, err := p.page.Screenshot(playwright.PageScreenshotOptions{ + Path: playwright.String(screenshotPath), + }); err == nil { + fmt.Printf("[GeminiWeb] Screenshot saved: %s\n", screenshotPath) + } + + // HTML + htmlPath := "/tmp/gemini-debug.html" + if html, err := p.page.Content(); err == nil { + if err := os.WriteFile(htmlPath, []byte(html), 0644); err == nil { + fmt.Printf("[GeminiWeb] HTML saved: %s\n", htmlPath) + } + } + + // 輸出頁面信息 + url := p.page.URL() + title, _ := p.page.Title() + fmt.Printf("[GeminiWeb] Diagnostics: URL=%s, Title=%s\n", url, title) + + return nil +} + +// waitForPageReady 等待頁面完全就緒(project-golem 策略) +func (p *PlaywrightProvider) waitForPageReady() error { + fmt.Println("[GeminiWeb] Checking for ready state...") + + // 1. 等待停止按鈕消失(如果存在) + _, _ = p.page.WaitForSelector("button[aria-label*='Stop'], button[aria-label*='停止']", playwright.PageWaitForSelectorOptions{ + State: playwright.WaitForSelectorStateDetached, + Timeout: playwright.Float(5000), + }) + + // 2. 嘗試多種等待策略 + inputSelectors := []string{ + ".ql-editor.ql-blank", + ".ql-editor", + "div[contenteditable='true'][role='textbox']", + "div[contenteditable='true']", + ".ProseMirror", + "rich-textarea", + "textarea", + } + + // 策略 A: 等待任一輸入框出現 + for i, sel := range inputSelectors { + fmt.Printf(" [%d/%d] Waiting for: %s\n", i+1, len(inputSelectors), sel) + locator := p.page.Locator(sel) + if err := locator.WaitFor(playwright.LocatorWaitForOptions{ + Timeout: playwright.Float(5000), + State: playwright.WaitForSelectorStateVisible, + }); err == nil { + fmt.Printf(" ✓ Input field found: %s\n", sel) + return nil + } + } + + // 策略 B: 等待頁面完全載入 + fmt.Println("[GeminiWeb] Waiting for page load...") + time.Sleep(3 * time.Second) + + // 策略 C: 使用 JavaScript 檢查 + fmt.Println("[GeminiWeb] Checking with JavaScript...") + result, err := p.page.Evaluate(` + () => { + // 檢查所有可能的輸入元素 + const selectors = [ + '.ql-editor.ql-blank', + '.ql-editor', + 'div[contenteditable="true"][role="textbox"]', + 'div[contenteditable="true"]', + '.ProseMirror', + 'rich-textarea', + 'textarea' + ]; + + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) { + return { + found: true, + selector: sel, + tagName: el.tagName, + className: el.className, + visible: el.offsetParent !== null + }; + } + } + + return { found: false }; + } + `) + + if err == nil { + if m, ok := result.(map[string]interface{}); ok { + if found, _ := m["found"].(bool); found { + sel, _ := m["selector"].(string) + fmt.Printf(" ✓ JavaScript found: %s\n", sel) + return nil + } + } + } + + // 策略 D: 調試模式 - 輸出頁面結構 + if p.cfg.GeminiBrowserVisible { + fmt.Println("[GeminiWeb].dump: Page structure analysis") + _, _ = p.page.Evaluate(` + () => { + const allElements = document.querySelectorAll('*'); + const inputLike = []; + for (const el of allElements) { + if (el.contentEditable === 'true' || + el.role === 'textbox' || + el.tagName === 'TEXTAREA' || + el.tagName === 'INPUT') { + inputLike.push({ + tag: el.tagName, + class: el.className, + id: el.id, + role: el.role, + contentEditable: el.contentEditable + }); + } + } + console.log('Input-like elements:', inputLike); + } + `) + } + + return fmt.Errorf("no input field found after all strategies") +} + +// isLoggedIn 檢查是否已登入 +func (p *PlaywrightProvider) isLoggedIn() bool { + // 嘗試找輸入框(登入狀態的主要特徵) + selectors := []string{ + ".ProseMirror", + "rich-textarea", + "div[role='textbox']", + "div[contenteditable='true']", + "textarea", + } + + for _, sel := range selectors { + locator := p.page.Locator(sel) + if count, _ := locator.Count(); count > 0 { + return true + } + } + return false +} + +// typeInput 輸入文字(使用 Playwright 的 Auto-wait) +func (p *PlaywrightProvider) typeInput(text string) error { + fmt.Println("[GeminiWeb] Looking for input field...") + + selectors := []string{ + ".ql-editor.ql-blank", + ".ql-editor", + "div[contenteditable='true'][role='textbox']", + "div[contenteditable='true']", + ".ProseMirror", + "rich-textarea", + "textarea", + } + + var inputLocator playwright.Locator + var found bool + + for _, sel := range selectors { + fmt.Printf(" Trying: %s\n", sel) + locator := p.page.Locator(sel) + if err := locator.WaitFor(playwright.LocatorWaitForOptions{ + Timeout: playwright.Float(3000), + }); err == nil { + inputLocator = locator + found = true + fmt.Printf(" ✓ Found with: %s\n", sel) + break + } + } + + if !found { + // 錯誤會被 Generate 的 defer 捕獲並保存診斷 + url := p.page.URL() + title, _ := p.page.Title() + return fmt.Errorf("input field not found (URL=%s, Title=%s). Diagnostics will be saved to /tmp/", url, title) + } + + // Focus 並填充(Playwright 自動等待) + fmt.Printf("[GeminiWeb] Typing %d chars...\n", len(text)) + if err := inputLocator.Fill(text); err != nil { + return fmt.Errorf("failed to fill: %w", err) + } + + fmt.Println("[GeminiWeb] Input complete") + return nil +} + +// sendMessage 發送訊息 +func (p *PlaywrightProvider) sendMessage() error { + // 方法 1: 按 Enter(最可靠) + if err := p.page.Keyboard().Press("Enter"); err != nil { + return fmt.Errorf("failed to press Enter: %w", err) + } + + time.Sleep(200 * time.Millisecond) + + // 方法 2: 嘗試點擊發送按鈕(補強) + _, _ = p.page.Evaluate(` + () => { + const keywords = ['發送', 'Send', '傳送']; + const buttons = Array.from(document.querySelectorAll('button, [role="button"]')); + + for (const btn of buttons) { + const text = (btn.innerText || btn.textContent || '').trim(); + const label = (btn.getAttribute('aria-label') || '').trim(); + + // 跳過停止按鈕 + if (['停止', 'Stop', '中斷'].includes(text) || label.toLowerCase().includes('stop')) { + continue; + } + + if (keywords.some(kw => text.includes(kw) || label.includes(kw))) { + btn.click(); + return true; + } + } + return false; + } + `) + + return nil +} + +// extractResponse 提取回應 +func (p *PlaywrightProvider) extractResponse() (string, error) { + var lastText string + var stableCount int + lastUpdate := time.Now() + timeout := 120 * time.Second + startTime := time.Now() + + for time.Since(startTime) < timeout { + time.Sleep(500 * time.Millisecond) + + // 使用 JavaScript 提取回應文字(更精確) + result, err := p.page.Evaluate(` + () => { + // 尋找所有可能的回應容器 + const selectors = [ + 'model-response', + '.model-response', + 'message-content', + '.message-content' + ]; + + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) { + // 嘗試找markdown內容 + const markdown = el.querySelector('.markdown, .prose, [class*="markdown"]'); + if (markdown && markdown.innerText.trim()) { + let text = markdown.innerText.trim(); + // 移除常見的標籤前綴 + text = text.replace(/^Gemini said\s*\n*/i, '').replace(/^Gemini\s*[::]\s*\n*/i, '').trim(); + return { text: text, source: sel + ' .markdown' }; + } + + // 嘗試找純文字內容(排除標籤) + let textContent = el.innerText.trim(); + if (textContent) { + // 移除常見的標籤前綴 + textContent = textContent.replace(/^Gemini said\s*\n*/i, '').replace(/^Gemini\s*[::]\s*\n*/i, '').trim(); + return { text: textContent, source: sel }; + } + } + } + + return { text: '', source: 'none' }; + } + `) + + if err == nil { + if m, ok := result.(map[string]interface{}); ok { + text, _ := m["text"].(string) + text = strings.TrimSpace(text) + + if text != "" && len(text) > len(lastText) { + lastText = text + lastUpdate = time.Now() + stableCount = 0 + fmt.Printf("[GeminiWeb] Response: %d chars\n", len(text)) + } + } + } + + // 檢查是否完成(需要連續 3 次穩定) + if time.Since(lastUpdate) > 500*time.Millisecond && lastText != "" { + stableCount++ + if stableCount >= 3 { + // 最終檢查:停止按鈕是否還存在 + stopBtn := p.page.Locator("button[aria-label*='Stop'], button[aria-label*='停止'], button[data-test-id='stop-button']") + count, _ := stopBtn.Count() + + if count == 0 { + fmt.Println("[GeminiWeb] ✓ Response complete") + return lastText, nil + } + } + } + } + + if lastText != "" { + fmt.Println("[GeminiWeb] ✓ Response complete (timeout)") + return lastText, nil + } + return "", fmt.Errorf("response timeout") +} + +// selectModel 選擇 Gemini 模型 +// Gemini Web 只有三種模型:fast, thinking, pro +func (p *PlaywrightProvider) selectModel(model string) error { + // 映射模型名稱到 Gemini Web 的模型選擇器 + modelMap := map[string]string{ + "fast": "Fast", + "thinking": "Thinking", + "pro": "Pro", + "gemini-fast": "Fast", + "gemini-thinking": "Thinking", + "gemini-pro": "Pro", + "gemini-2.0-fast": "Fast", + "gemini-2.0-flash": "Fast", // 相容舊名稱 + "gemini-2.5-pro": "Pro", + "gemini-2.5-pro-thinking": "Thinking", + } + + // 從完整模型名稱中提取類型 + modelType := "" + modelLower := strings.ToLower(model) + for key, value := range modelMap { + if strings.Contains(modelLower, strings.ToLower(key)) || modelLower == strings.ToLower(key) { + modelType = value + break + } + } + + if modelType == "" { + // 默認使用 Fast + fmt.Printf("[GeminiWeb] Unknown model '%s', defaulting to Fast\n", model) + return nil + } + + fmt.Printf("[GeminiWeb] Selecting model: %s\n", modelType) + + // 點擊模型選擇器 + modelSelector := p.page.Locator("button[aria-label*='Model'], button[aria-label*='模型'], [data-test-id='model-selector']") + if count, _ := modelSelector.Count(); count > 0 { + if err := modelSelector.First().Click(); err != nil { + fmt.Printf("[GeminiWeb] Warning: could not click model selector: %v\n", err) + } else { + time.Sleep(500 * time.Millisecond) + + // 選擇對應的模型選項 + optionSelector := p.page.Locator(fmt.Sprintf("button:has-text('%s'), [role='menuitem']:has-text('%s')", modelType, modelType)) + if count, _ := optionSelector.Count(); count > 0 { + if err := optionSelector.First().Click(); err != nil { + fmt.Printf("[GeminiWeb] Warning: could not select model: %v\n", err) + } else { + fmt.Printf("[GeminiWeb] ✓ Model selected: %s\n", modelType) + time.Sleep(500 * time.Millisecond) + } + } + } + } + + return nil +} + +// buildPromptFromMessages 從訊息列表建構提示詞 +func buildPromptFromMessagesPlaywright(messages []apitypes.Message) string { + var prompt string + for _, m := range messages { + switch m.Role { + case "system": + prompt += "System: " + m.Content + "\n\n" + case "user": + prompt += m.Content + "\n\n" + case "assistant": + prompt += "Assistant: " + m.Content + "\n\n" + } + } + return prompt +} diff --git a/internal/providers/geminiweb/pool.go b/internal/providers/geminiweb/pool.go new file mode 100644 index 0000000..88d4f89 --- /dev/null +++ b/internal/providers/geminiweb/pool.go @@ -0,0 +1,169 @@ +package geminiweb + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +type GeminiSession struct { + Name string `json:"name"` + CookieFile string `json:"cookie_file"` + LastUsed int64 `json:"last_used"` + ActiveCount int `json:"active_count"` + RateLimitEnd int64 `json:"rate_limit_end"` +} + +type SessionPool struct { + mu sync.Mutex + sessions []*GeminiSession + dir string + maxCount int +} + +func NewSessionPool(dir string, maxSessions int) (*SessionPool, error) { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create session dir: %w", err) + } + + sessions, err := loadSessions(dir) + if err != nil { + return nil, fmt.Errorf("failed to load sessions: %w", err) + } + + return &SessionPool{ + sessions: sessions, + dir: dir, + maxCount: maxSessions, + }, nil +} + +func loadSessions(dir string) ([]*GeminiSession, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var sessions []*GeminiSession + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + metaPath := filepath.Join(dir, name, "session.json") + data, err := os.ReadFile(metaPath) + if err != nil { + continue + } + + var s GeminiSession + if err := json.Unmarshal(data, &s); err != nil { + continue + } + sessions = append(sessions, &s) + } + + return sessions, nil +} + +func (p *SessionPool) Count() int { + p.mu.Lock() + defer p.mu.Unlock() + return len(p.sessions) +} + +func (p *SessionPool) GetAvailable() *GeminiSession { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now().UnixMilli() + + var available []*GeminiSession + for _, s := range p.sessions { + if s.RateLimitEnd < now { + available = append(available, s) + } + } + + if len(available) == 0 { + return nil + } + + var best *GeminiSession + for _, s := range available { + if best == nil || s.ActiveCount < best.ActiveCount { + best = s + } else if s.ActiveCount == best.ActiveCount && s.LastUsed < best.LastUsed { + best = s + } + } + + return best +} + +func (p *SessionPool) StartSession(s *GeminiSession) { + p.mu.Lock() + defer p.mu.Unlock() + s.ActiveCount++ + s.LastUsed = time.Now().UnixMilli() + p.saveSession(s) +} + +func (p *SessionPool) EndSession(s *GeminiSession) { + p.mu.Lock() + defer p.mu.Unlock() + if s.ActiveCount > 0 { + s.ActiveCount-- + } + p.saveSession(s) +} + +func (p *SessionPool) RateLimitSession(s *GeminiSession, durationMs int64) { + p.mu.Lock() + defer p.mu.Unlock() + s.RateLimitEnd = time.Now().UnixMilli() + durationMs + p.saveSession(s) +} + +func (p *SessionPool) saveSession(s *GeminiSession) { + metaPath := filepath.Join(p.dir, s.Name, "session.json") + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return + } + _ = os.WriteFile(metaPath, data, 0644) +} + +func (p *SessionPool) CreateSession(name string) (*GeminiSession, error) { + p.mu.Lock() + defer p.mu.Unlock() + + sessionDir := filepath.Join(p.dir, name) + if err := os.MkdirAll(sessionDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create session dir: %w", err) + } + + s := &GeminiSession{ + Name: name, + CookieFile: filepath.Join(sessionDir, "cookies.json"), + LastUsed: time.Now().UnixMilli(), + } + + p.sessions = append(p.sessions, s) + p.saveSession(s) + + return s, nil +} + +func (p *SessionPool) GetSessionNames() []string { + p.mu.Lock() + defer p.mu.Unlock() + names := make([]string, len(p.sessions)) + for i, s := range p.sessions { + names[i] = s.Name + } + return names +} diff --git a/internal/providers/geminiweb/provider.go b/internal/providers/geminiweb/provider.go new file mode 100644 index 0000000..257f57e --- /dev/null +++ b/internal/providers/geminiweb/provider.go @@ -0,0 +1,196 @@ +package geminiweb + +import ( + "context" + "cursor-api-proxy/internal/apitypes" + "cursor-api-proxy/internal/config" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// Provider 使用持久化瀏覽器管理器 +type Provider struct { + cfg config.BridgeConfig + managerOnce sync.Once + manager *BrowserManager + managerErr error +} + +// NewProvider 建立新的 Provider +func NewProvider(cfg config.BridgeConfig) *Provider { + return &Provider{cfg: cfg} +} + +// getName 返回 Provider 名稱 +func (p *Provider) Name() string { + return "gemini-web" +} + +// Close 關閉瀏覽器 +func (p *Provider) Close() error { + if p.manager != nil { + return p.manager.Close() + } + return nil +} + +// getManager 獲取或初始化瀏覽器管理器(單例) +func (p *Provider) getManager() (*BrowserManager, error) { + p.managerOnce.Do(func() { + sessionDir := p.getSessionDir() + p.manager, p.managerErr = GetBrowserManager(sessionDir, p.cfg.GeminiBrowserVisible) + }) + return p.manager, p.managerErr +} + +// getSessionDir 獲取 session 目錄 +func (p *Provider) getSessionDir() string { + // 使用單一 session 目錄(簡化設計) + return filepath.Join(p.cfg.GeminiAccountDir, "default-session") +} + +// Generate 生成回應 +func (p *Provider) Generate(ctx context.Context, model string, messages []apitypes.Message, tools []apitypes.Tool, cb func(apitypes.StreamChunk)) error { + fmt.Printf("[GeminiWeb] Starting generation with model: %s\n", model) + + // 1. 獲取瀏覽器管理器 + manager, err := p.getManager() + if err != nil { + return fmt.Errorf("failed to get browser manager: %w", err) + } + + // 2. 啟動瀏覽器(如果尚未啟動) + if !manager.IsRunning() { + fmt.Printf("[GeminiWeb] Launching browser...\n") + if err := manager.Launch(); err != nil { + return fmt.Errorf("failed to launch browser: %w", err) + } + } + + // 3. 獲取頁面 + page, err := manager.GetPage() + if err != nil { + return fmt.Errorf("failed to get page: %w", err) + } + + // 4. 檢查當前 URL,如果不是 Gemini 則導航 + currentURL, _ := page.Info() + if !strings.Contains(currentURL.URL, "gemini.google.com") { + fmt.Printf("[GeminiWeb] Navigating to Gemini...\n") + if err := NavigateToGemini(page); err != nil { + return fmt.Errorf("failed to navigate: %w", err) + } + time.Sleep(2 * time.Second) + } + + // 5. 檢查登入狀態 + fmt.Printf("[GeminiWeb] Checking login status...\n") + if !IsLoggedIn(page) { + fmt.Printf("[GeminiWeb] Not logged in, continuing anyway\n") + + if p.cfg.GeminiBrowserVisible { + fmt.Println("\n========================================") + fmt.Println("Browser is open. You can:") + fmt.Println("1. Log in to Gemini now") + fmt.Println("2. Continue without login") + fmt.Println("========================================\n") + } + } else { + fmt.Printf("[GeminiWeb] Logged in\n") + } + + // 6. 等待頁面就緒 + if err := WaitForReady(page); err != nil { + fmt.Printf("[GeminiWeb] Warning: %v\n", err) + } + + // 7. 建構提示詞 + prompt := buildPromptFromMessages(messages) + fmt.Printf("[GeminiWeb] Typing prompt (%d chars)...\n", len(prompt)) + + // 8. 輸入文字 + if err := TypeInput(page, prompt); err != nil { + return fmt.Errorf("failed to type input: %w", err) + } + + // 9. 發送 + fmt.Printf("[GeminiWeb] Sending message...\n") + if err := ClickSend(page); err != nil { + return fmt.Errorf("failed to send: %w", err) + } + + // 10. 提取回應 + fmt.Printf("[GeminiWeb] Waiting for response...\n") + response, err := ExtractResponse(page) + if err != nil { + return fmt.Errorf("failed to extract response: %w", err) + } + + // 11. 串流回調 + cb(apitypes.StreamChunk{Type: apitypes.ChunkText, Text: response}) + cb(apitypes.StreamChunk{Type: apitypes.ChunkDone, Done: true}) + + fmt.Printf("[GeminiWeb] Response complete (%d chars)\n", len(response)) + return nil +} + +// buildPromptFromMessages 從訊息列表建構提示詞 +func buildPromptFromMessages(messages []apitypes.Message) string { + var prompt string + for _, m := range messages { + switch m.Role { + case "system": + prompt += "System: " + m.Content + "\n\n" + case "user": + prompt += m.Content + "\n\n" + case "assistant": + prompt += "Assistant: " + m.Content + "\n\n" + } + } + return prompt +} + +// RunLogin 執行登入流程(供 gemini-login 命令使用) +func RunLogin(cfg config.BridgeConfig, sessionName string) error { + if sessionName == "" { + sessionName = "default-session" + } + + sessionDir := filepath.Join(cfg.GeminiAccountDir, sessionName) + if err := os.MkdirAll(sessionDir, 0755); err != nil { + return fmt.Errorf("failed to create session dir: %w", err) + } + + fmt.Printf("Starting browser for login. Session: %s\n", sessionName) + fmt.Printf("Session directory: %s\n", sessionDir) + fmt.Println("Please log in to your Gemini account in the browser window.") + fmt.Println("Press Ctrl+C when you have completed the login...") + + manager, err := NewBrowserManager(sessionDir, true) // visible=true + if err != nil { + return fmt.Errorf("failed to create browser manager: %w", err) + } + + if err := manager.Launch(); err != nil { + return fmt.Errorf("failed to launch browser: %w", err) + } + defer manager.Close() + + page, err := manager.GetPage() + if err != nil { + return fmt.Errorf("failed to get page: %w", err) + } + + if err := NavigateToGemini(page); err != nil { + return fmt.Errorf("failed to navigate: %w", err) + } + + // 等待用戶手動登入... + // 使用 Ctrl+C 退出,瀏覽器資料會自動保存在 userDataDir + + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 58e8552..745f958 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,11 +13,11 @@ import ( ) type RouterOptions struct { - Version string - Config config.BridgeConfig - ModelCache *handlers.ModelCacheRef - LastModel *string - Pool pool.PoolHandle + Version string + Config config.BridgeConfig + ModelCache *handlers.ModelCacheRef + LastModel *string + Pool pool.PoolHandle } func NewRouter(opts RouterOptions) http.HandlerFunc { @@ -61,7 +61,16 @@ func NewRouter(opts RouterOptions) http.HandlerFunc { }, nil) return } - handlers.HandleChatCompletions(w, r, cfg, opts.Pool, opts.LastModel, raw, method, pathname, remoteAddress) + // 根據 Provider 選擇處理方式 + provider := cfg.Provider + if provider == "" { + provider = "cursor" + } + if provider == "gemini-web" { + handlers.HandleGeminiChatCompletions(w, r, cfg, raw, method, pathname, remoteAddress) + } else { + handlers.HandleChatCompletions(w, r, cfg, opts.Pool, opts.LastModel, raw, method, pathname, remoteAddress) + } case method == "POST" && pathname == "/v1/messages": raw, err := httputil.ReadBody(r) diff --git a/scripts/detect-gemini-dom.go b/scripts/detect-gemini-dom.go new file mode 100644 index 0000000..1c482a9 --- /dev/null +++ b/scripts/detect-gemini-dom.go @@ -0,0 +1,159 @@ +package main + +import ( + "cursor-api-proxy/internal/providers/geminiweb" + "fmt" + "os" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/go-rod/rod/lib/proto" +) + +func main() { + fmt.Println("Starting Gemini DOM detection...") + fmt.Println("This will open a browser and analyze the Gemini web interface.") + fmt.Println() + + // 啟動可見瀏覽器 + l := launcher.New().Headless(false) + url, err := l.Launch() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to launch browser: %v\n", err) + os.Exit(1) + } + + browser := rod.New().ControlURL(url) + if err := browser.Connect(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to connect browser: %v\n", err) + os.Exit(1) + } + defer browser.Close() + + page, err := browser.Page(proto.TargetCreateTarget{URL: "about:blank"}) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create page: %v\n", err) + os.Exit(1) + } + + // 載入 cookies(如果有) + home, _ := os.UserHomeDir() + cookieFile := home + "/.cursor-api-proxy/gemini-accounts/session-1/cookies.json" + if _, err := os.Stat(cookieFile); err == nil { + cookies, err := geminiweb.LoadCookiesFromFile(cookieFile) + if err == nil { + geminiweb.SetCookiesOnPage(page, cookies) + fmt.Println("Loaded existing cookies") + } + } + + // 導航到 Gemini + fmt.Println("Navigating to gemini.google.com...") + if err := geminiweb.NavigateToGemini(page); err != nil { + fmt.Fprintf(os.Stderr, "Failed to navigate: %v\n", err) + } + + fmt.Println() + fmt.Println("Browser is now open. Please:") + fmt.Println("1. Log in if needed") + fmt.Println("2. Wait for the chat interface to fully load") + fmt.Println("3. Look for the model selector dropdown") + fmt.Println() + fmt.Println("Press Enter to analyze the DOM...") + fmt.Scanln() + + // 分析 DOM + analyzeDOM(page) + + fmt.Println() + fmt.Println("Press Enter to close...") + fmt.Scanln() +} + +func analyzeDOM(page *rod.Page) { + fmt.Println("=== DOM Analysis ===") + fmt.Println() + + // 尋找可能的輸入框 + fmt.Println("Looking for input fields...") + selectors := []string{ + `textarea`, + `[contenteditable="true"]`, + `[role="textbox"]`, + `input[type="text"]`, + } + for _, sel := range selectors { + elements, err := page.Elements(sel) + if err == nil && len(elements) > 0 { + fmt.Printf(" Found %d elements with: %s\n", len(elements), sel) + for i, el := range elements { + tag, _ := el.Property("tagName") + class, _ := el.Attribute("class") + ariaLabel, _ := el.Attribute("aria-label") + placeholder, _ := el.Attribute("placeholder") + fmt.Printf(" [%d] tag=%s class=%s aria-label=%s placeholder=%s\n", + i, tag, class, ariaLabel, placeholder) + } + } + } + + // 尋找可能的發送按鈕 + fmt.Println() + fmt.Println("Looking for send buttons...") + buttonSelectors := []string{ + `button`, + `[role="button"]`, + `[type="submit"]`, + } + for _, sel := range buttonSelectors { + elements, err := page.Elements(sel) + if err == nil && len(elements) > 0 { + fmt.Printf(" Found %d elements with: %s\n", len(elements), sel) + for i, el := range elements { + if i >= 5 { + fmt.Printf(" ... and %d more\n", len(elements)-5) + break + } + tag, _ := el.Property("tagName") + class, _ := el.Attribute("class") + ariaLabel, _ := el.Attribute("aria-label") + text, _ := el.Text() + text = truncate(text, 30) + fmt.Printf(" [%d] tag=%s class=%s aria-label=%s text=%s\n", + i, tag, class, ariaLabel, text) + } + } + } + + // 尋找模型選擇器 + fmt.Println() + fmt.Println("Looking for model selector...") + modelSelectors := []string{ + `[aria-label*="model"]`, + `[aria-label*="Model"]`, + `button[aria-haspopup]`, + `[data-test-id*="model"]`, + `[class*="model"]`, + } + for _, sel := range modelSelectors { + elements, err := page.Elements(sel) + if err == nil && len(elements) > 0 { + fmt.Printf(" Found with: %s\n", sel) + for i, el := range elements { + tag, _ := el.Property("tagName") + class, _ := el.Attribute("class") + ariaLabel, _ := el.Attribute("aria-label") + text, _ := el.Text() + fmt.Printf(" [%d] tag=%s class=%s aria-label=%s text=%s\n", + i, tag, class, ariaLabel, truncate(text, 30)) + } + } + } +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +}