refactor(gemini-web): Move provider logic to its own package

The Gemini Web API client logic has been relocated from `internal/client/gemini-web` to a new, more specific `internal/provider/gemini-web` package. This refactoring improves code organization and modularity by better isolating provider-specific implementations.

As a result of this move, the `GeminiWebState` struct and its methods have been exported (capitalized) to make them accessible from the executor. All call sites have been updated to use the new package path and the exported identifiers.
This commit is contained in:
hkfires
2025-09-24 10:24:12 +08:00
parent a2c5fdaf66
commit e9707c2f9e
14 changed files with 117 additions and 84 deletions

View File

@@ -0,0 +1,178 @@
package geminiwebapi
import (
"bytes"
"encoding/json"
"fmt"
"math"
"regexp"
"strings"
"time"
"unicode/utf8"
)
var (
reGoogle = regexp.MustCompile("(\\()?\\[`([^`]+?)`\\]\\(https://www\\.google\\.com/search\\?q=[^)]*\\)(\\))?")
reColonNum = regexp.MustCompile(`([^:]+:\d+)`)
reInline = regexp.MustCompile("`(\\[[^\\]]+\\]\\([^\\)]+\\))`")
)
func unescapeGeminiText(s string) string {
if s == "" {
return s
}
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "\\<", "<")
s = strings.ReplaceAll(s, "\\_", "_")
s = strings.ReplaceAll(s, "\\>", ">")
return s
}
func postProcessModelText(text string) string {
text = reGoogle.ReplaceAllStringFunc(text, func(m string) string {
subs := reGoogle.FindStringSubmatch(m)
if len(subs) < 4 {
return m
}
outerOpen := subs[1]
display := subs[2]
target := display
if loc := reColonNum.FindString(display); loc != "" {
target = loc
}
newSeg := "[`" + display + "`](" + target + ")"
if outerOpen != "" {
return "(" + newSeg + ")"
}
return newSeg
})
text = reInline.ReplaceAllString(text, "$1")
return text
}
func estimateTokens(s string) int {
if s == "" {
return 0
}
rc := float64(utf8.RuneCountInString(s))
if rc <= 0 {
return 0
}
est := int(math.Ceil(rc / 4.0))
if est < 0 {
return 0
}
return est
}
// ConvertOutputToGemini converts simplified ModelOutput to Gemini API-like JSON.
// promptText is used only to estimate usage tokens to populate usage fields.
func ConvertOutputToGemini(output *ModelOutput, modelName string, promptText string) ([]byte, error) {
if output == nil || len(output.Candidates) == 0 {
return nil, fmt.Errorf("empty output")
}
parts := make([]map[string]any, 0, 2)
var thoughtsText string
if output.Candidates[0].Thoughts != nil {
if t := strings.TrimSpace(*output.Candidates[0].Thoughts); t != "" {
thoughtsText = unescapeGeminiText(t)
parts = append(parts, map[string]any{
"text": thoughtsText,
"thought": true,
})
}
}
visible := unescapeGeminiText(output.Candidates[0].Text)
finalText := postProcessModelText(visible)
if finalText != "" {
parts = append(parts, map[string]any{"text": finalText})
}
if imgs := output.Candidates[0].GeneratedImages; len(imgs) > 0 {
for _, gi := range imgs {
if mime, data, err := FetchGeneratedImageData(gi); err == nil && data != "" {
parts = append(parts, map[string]any{
"inlineData": map[string]any{
"mimeType": mime,
"data": data,
},
})
}
}
}
promptTokens := estimateTokens(promptText)
completionTokens := estimateTokens(finalText)
thoughtsTokens := 0
if thoughtsText != "" {
thoughtsTokens = estimateTokens(thoughtsText)
}
totalTokens := promptTokens + completionTokens
now := time.Now()
resp := map[string]any{
"candidates": []any{
map[string]any{
"content": map[string]any{
"parts": parts,
"role": "model",
},
"finishReason": "stop",
"index": 0,
},
},
"createTime": now.Format(time.RFC3339Nano),
"responseId": fmt.Sprintf("gemini-web-%d", now.UnixNano()),
"modelVersion": modelName,
"usageMetadata": map[string]any{
"promptTokenCount": promptTokens,
"candidatesTokenCount": completionTokens,
"thoughtsTokenCount": thoughtsTokens,
"totalTokenCount": totalTokens,
},
}
b, err := json.Marshal(resp)
if err != nil {
return nil, fmt.Errorf("failed to marshal gemini response: %w", err)
}
return ensureColonSpacing(b), nil
}
// ensureColonSpacing inserts a single space after JSON key-value colons while
// leaving string content untouched. This matches the relaxed formatting used by
// Gemini responses and keeps downstream text-processing tools compatible with
// the proxy output.
func ensureColonSpacing(b []byte) []byte {
if len(b) == 0 {
return b
}
var out bytes.Buffer
out.Grow(len(b) + len(b)/8)
inString := false
escaped := false
for i := 0; i < len(b); i++ {
ch := b[i]
out.WriteByte(ch)
if escaped {
escaped = false
continue
}
switch ch {
case '\\':
escaped = true
case '"':
inString = !inString
case ':':
if !inString && i+1 < len(b) {
next := b[i+1]
if next != ' ' && next != '\n' && next != '\r' && next != '\t' {
out.WriteByte(' ')
}
}
}
}
return out.Bytes()
}