diff --git a/go.mod b/go.mod index c00e366..341a00d 100644 --- a/go.mod +++ b/go.mod @@ -8,10 +8,14 @@ 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 diff --git a/go.sum b/go.sum index 8f3d2b9..77d212a 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +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= @@ -12,8 +20,13 @@ 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= @@ -25,15 +38,55 @@ 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/providers/factory.go b/internal/providers/factory.go index 8fd9ea5..c55fdc3 100644 --- a/internal/providers/factory.go +++ b/internal/providers/factory.go @@ -25,7 +25,8 @@ func NewProvider(cfg config.BridgeConfig) (Provider, error) { case "cursor": return cursor.NewProvider(cfg), nil case "gemini-web": - return geminiweb.NewProvider(cfg), nil + // 使用新的 Playwright provider + return geminiweb.NewPlaywrightProvider(cfg) default: return nil, fmt.Errorf("unknown provider: %s", providerType) } diff --git a/internal/providers/geminiweb/playwright_provider.go b/internal/providers/geminiweb/playwright_provider.go new file mode 100644 index 0000000..577dcaa --- /dev/null +++ b/internal/providers/geminiweb/playwright_provider.go @@ -0,0 +1,392 @@ +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)) error { + 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.WaitUntilStateNetworkidle, + Timeout: playwright.Float(30000), + }); err != nil { + return fmt.Errorf("failed to navigate: %w", err) + } + time.Sleep(2 * time.Second) + } + + // 3. 檢查登入狀態 + 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") + } + + // 4. 等待頁面就緒 + if err := p.waitForReady(); err != nil { + fmt.Printf("[GeminiWeb] Warning: %v\n", err) + } + + // 5. 建構提示詞 + prompt := buildPromptFromMessagesPlaywright(messages) + fmt.Printf("[GeminiWeb] Typing prompt (%d chars)...\n", len(prompt)) + + // 6. 輸入文字(使用 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 +} + +// isLoggedIn 檢查是否已登入 +func (p *PlaywrightProvider) isLoggedIn() bool { + // 嘗試找輸入框(登入狀態的主要特徵) + selectors := []string{ + ".ProseMirror", + "rich-textarea", + "div[role='textbox']", + "div[contenteditable='true']", + "textarea", + } + + for _, sel := range selectors { + if _, err := p.page.WaitForSelector(sel, playwright.PageWaitForSelectorOptions{ + Timeout: playwright.Float(3000), + }); err == nil { + return true + } + } + return false +} + +// waitForReady 等待頁面就緒 +func (p *PlaywrightProvider) waitForReady() error { + fmt.Println("[GeminiWeb] Checking if page is ready...") + + // 等待停止按鈕消失(如果存在) + _, _ = p.page.WaitForSelector("button[aria-label*='Stop']", playwright.PageWaitForSelectorOptions{ + State: playwright.WaitForSelectorStateDetached, + Timeout: playwright.Float(5000), + }) + + fmt.Println("[GeminiWeb] Page is ready") + return nil +} + +// typeInput 輸入文字(使用 Playwright 的 Auto-wait) +func (p *PlaywrightProvider) typeInput(text string) error { + fmt.Println("[GeminiWeb] Looking for input field...") + + selectors := []string{ + ".ProseMirror", + "rich-textarea", + "div[role='textbox'][contenteditable='true']", + "div[contenteditable='true']", + "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 { + // 顯示 debug 信息 + url := p.page.URL() + title, _ := p.page.Title() + return fmt.Errorf("input field not found (URL=%s, Title=%s)", 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) { + selectors := []string{ + ".model-response-text", + ".message-content", + ".markdown", + ".prose", + "model-response", + } + + var lastText string + lastUpdate := time.Now() + timeout := 120 * time.Second + startTime := time.Now() + + for time.Since(startTime) < timeout { + time.Sleep(500 * time.Millisecond) + + // 嘗試所有選擇器 + for _, sel := range selectors { + locator := p.page.Locator(sel) + count, _ := locator.Count() + + if count > 0 { + // 取最後一個元素 + lastEl := locator.Last() + text, err := lastEl.TextContent() + if err != nil { + continue + } + + text = strings.TrimSpace(text) + if text != "" && 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 != "" { + // 最終檢查:停止按鈕是否還存在 + stopBtn := p.page.Locator("button[aria-label*='Stop'], button[aria-label*='停止']") + count, _ := stopBtn.Count() + + if count == 0 { + return lastText, nil + } + } + } + + if lastText != "" { + return lastText, nil + } + return "", fmt.Errorf("response timeout") +} + +// 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 +}