mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
The logic for managing cookie persistence files was previously implemented directly within the `gemini-web` client's persistence layer. This approach was not reusable and led to duplicated helper functions. This commit refactors the cookie persistence mechanism by: - Renaming the concept from "sidecar" to "snapshot" for clarity. - Extracting file I/O and path manipulation logic into a new, generic `internal/util/cookie_snapshot.go` file. - Creating reusable utility functions: `WriteCookieSnapshot`, `TryReadCookieSnapshotInto`, and `RemoveCookieSnapshot`. - Updating the `gemini-web` persistence code to use these new centralized utility functions. This change improves code organization, reduces duplication, and makes the cookie snapshot functionality easier to maintain and potentially reuse across other clients.
1082 lines
36 KiB
Go
1082 lines
36 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/auth/gemini"
|
|
geminiWeb "github.com/luispater/CLIProxyAPI/v5/internal/client/gemini-web"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/config"
|
|
. "github.com/luispater/CLIProxyAPI/v5/internal/constant"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/interfaces"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/translator/translator"
|
|
"github.com/luispater/CLIProxyAPI/v5/internal/util"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
// This file wires the external-facing client for Gemini Web.
|
|
|
|
// Defaults for Gemini Web behavior that are no longer configurable via YAML.
|
|
const (
|
|
// geminiWebDefaultTimeoutSec defines the per-request HTTP timeout seconds.
|
|
geminiWebDefaultTimeoutSec = 300
|
|
// geminiWebDefaultRefreshIntervalSec defines background cookie auto-refresh interval seconds.
|
|
geminiWebDefaultRefreshIntervalSec = 540
|
|
// geminiWebDefaultPersistIntervalSec defines how often rotated cookies are persisted to disk (3 hours).
|
|
geminiWebDefaultPersistIntervalSec = 10800
|
|
)
|
|
|
|
type GeminiWebClient struct {
|
|
ClientBase
|
|
gwc *geminiWeb.GeminiClient
|
|
tokenFilePath string
|
|
convStore map[string][]string
|
|
convMutex sync.RWMutex
|
|
|
|
// JSON-based conversation persistence
|
|
convData map[string]geminiWeb.ConversationRecord
|
|
convIndex map[string]string
|
|
|
|
// restart-stable id for conversation hashing/lookup
|
|
stableClientID string
|
|
|
|
cookieRotationStarted bool
|
|
cookiePersistCancel context.CancelFunc
|
|
lastPersistedTS string
|
|
|
|
// register models once after successful auth init
|
|
modelsRegistered bool
|
|
}
|
|
|
|
func (c *GeminiWebClient) UnregisterClient() {
|
|
if c.cookiePersistCancel != nil {
|
|
c.cookiePersistCancel()
|
|
c.cookiePersistCancel = nil
|
|
}
|
|
// Flush cookie snapshot to main token file and remove snapshot
|
|
c.flushCookieSnapshotToMain()
|
|
if c.gwc != nil {
|
|
c.gwc.Close(0)
|
|
c.gwc = nil
|
|
}
|
|
c.ClientBase.UnregisterClient()
|
|
}
|
|
|
|
func NewGeminiWebClient(cfg *config.Config, ts *gemini.GeminiWebTokenStorage, tokenFilePath string) (*GeminiWebClient, error) {
|
|
jar, _ := cookiejar.New(nil)
|
|
httpClient := util.SetProxy(cfg, &http.Client{Jar: jar})
|
|
|
|
// derive a restart-stable id from tokens (sha256 of 1PSID, hex prefix)
|
|
stableSuffix := geminiWeb.Sha256Hex(ts.Secure1PSID)
|
|
if len(stableSuffix) > 16 {
|
|
stableSuffix = stableSuffix[:16]
|
|
}
|
|
idPrefix := stableSuffix
|
|
if len(idPrefix) > 8 {
|
|
idPrefix = idPrefix[:8]
|
|
}
|
|
clientID := fmt.Sprintf("gemini-web-%s-%d", idPrefix, time.Now().UnixNano())
|
|
|
|
client := &GeminiWebClient{
|
|
ClientBase: ClientBase{
|
|
RequestMutex: &sync.Mutex{},
|
|
httpClient: httpClient,
|
|
cfg: cfg,
|
|
tokenStorage: ts,
|
|
modelQuotaExceeded: make(map[string]*time.Time),
|
|
isAvailable: true,
|
|
},
|
|
tokenFilePath: tokenFilePath,
|
|
convStore: make(map[string][]string),
|
|
convData: make(map[string]geminiWeb.ConversationRecord),
|
|
convIndex: make(map[string]string),
|
|
stableClientID: "gemini-web-" + stableSuffix,
|
|
}
|
|
// Load persisted conversation stores
|
|
if store, err := geminiWeb.LoadConvStore(geminiWeb.ConvStorePath(tokenFilePath)); err == nil {
|
|
client.convStore = store
|
|
}
|
|
if items, index, err := geminiWeb.LoadConvData(geminiWeb.ConvDataPath(tokenFilePath)); err == nil {
|
|
client.convData = items
|
|
client.convIndex = index
|
|
}
|
|
|
|
client.InitializeModelRegistry(clientID)
|
|
|
|
// Prefer cookie snapshot at startup if present
|
|
if ok, err := geminiWeb.ApplyCookieSnapshotToTokenStorage(tokenFilePath, ts); err == nil && ok {
|
|
log.Debugf("Loaded Gemini Web cookie snapshot: %s", filepath.Base(util.CookieSnapshotPath(tokenFilePath)))
|
|
}
|
|
|
|
client.gwc = geminiWeb.NewGeminiClient(ts.Secure1PSID, ts.Secure1PSIDTS, cfg.ProxyURL, geminiWeb.WithAccountLabel(strings.TrimSuffix(filepath.Base(tokenFilePath), ".json")))
|
|
timeoutSec := geminiWebDefaultTimeoutSec
|
|
refreshIntervalSec := cfg.GeminiWeb.TokenRefreshSeconds
|
|
if refreshIntervalSec <= 0 {
|
|
refreshIntervalSec = geminiWebDefaultRefreshIntervalSec
|
|
}
|
|
if err := client.gwc.Init(float64(timeoutSec), false, 300, true, float64(refreshIntervalSec), false); err != nil {
|
|
log.Warnf("Gemini Web init failed for %s: %v. Will retry in background.", client.GetEmail(), err)
|
|
go client.backgroundInitRetry()
|
|
} else {
|
|
client.cookieRotationStarted = true
|
|
// Persist immediately once after successful init to capture fresh cookies
|
|
_ = client.SaveTokenToFile()
|
|
client.startCookiePersist()
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func (c *GeminiWebClient) Init() error {
|
|
ts := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
|
|
c.gwc = geminiWeb.NewGeminiClient(ts.Secure1PSID, ts.Secure1PSIDTS, c.cfg.ProxyURL, geminiWeb.WithAccountLabel(c.GetEmail()))
|
|
timeoutSec := geminiWebDefaultTimeoutSec
|
|
refreshIntervalSec := c.cfg.GeminiWeb.TokenRefreshSeconds
|
|
if refreshIntervalSec <= 0 {
|
|
refreshIntervalSec = geminiWebDefaultRefreshIntervalSec
|
|
}
|
|
if err := c.gwc.Init(float64(timeoutSec), false, 300, true, float64(refreshIntervalSec), false); err != nil {
|
|
return err
|
|
}
|
|
c.registerModelsOnce()
|
|
// Persist immediately once after successful init to capture fresh cookies
|
|
_ = c.SaveTokenToFile()
|
|
c.startCookiePersist()
|
|
return nil
|
|
}
|
|
|
|
// IsReady reports whether the underlying Gemini Web client is initialized and running.
|
|
func (c *GeminiWebClient) IsReady() bool {
|
|
return c != nil && c.gwc != nil && c.gwc.Running
|
|
}
|
|
|
|
func (c *GeminiWebClient) registerModelsOnce() {
|
|
if c.modelsRegistered {
|
|
return
|
|
}
|
|
c.RegisterModels(GEMINI, geminiWeb.GetGeminiWebAliasedModels())
|
|
c.modelsRegistered = true
|
|
}
|
|
|
|
// EnsureRegistered registers models if the client is ready and not yet registered.
|
|
// It is safe to call multiple times.
|
|
func (c *GeminiWebClient) EnsureRegistered() {
|
|
if c.IsReady() {
|
|
c.registerModelsOnce()
|
|
}
|
|
}
|
|
|
|
func (c *GeminiWebClient) Type() string { return GEMINI }
|
|
func (c *GeminiWebClient) Provider() string { return GEMINI }
|
|
func (c *GeminiWebClient) CanProvideModel(modelName string) bool {
|
|
geminiWeb.EnsureGeminiWebAliasMap()
|
|
_, ok := geminiWeb.GeminiWebAliasMap[strings.ToLower(modelName)]
|
|
return ok
|
|
}
|
|
func (c *GeminiWebClient) GetEmail() string {
|
|
base := filepath.Base(c.tokenFilePath)
|
|
return strings.TrimSuffix(base, ".json")
|
|
}
|
|
func (c *GeminiWebClient) StableClientID() string {
|
|
if c.stableClientID != "" {
|
|
return c.stableClientID
|
|
}
|
|
sum := geminiWeb.Sha256Hex(c.GetEmail())
|
|
if len(sum) > 16 {
|
|
sum = sum[:16]
|
|
}
|
|
return "gemini-web-" + sum
|
|
}
|
|
|
|
// useReusableContext reports whether JSON-based reusable conversation matching is enabled.
|
|
// Controlled by `gemini-web.context` boolean in config (true enables reuse, default true).
|
|
func (c *GeminiWebClient) useReusableContext() bool {
|
|
if c == nil || c.cfg == nil {
|
|
return true
|
|
}
|
|
return c.cfg.GeminiWeb.Context
|
|
}
|
|
|
|
// chatPrep encapsulates shared request preparation results for both stream and non-stream flows.
|
|
type chatPrep struct {
|
|
chat *geminiWeb.ChatSession
|
|
prompt string
|
|
uploaded []string
|
|
reuse bool
|
|
metaLen int
|
|
handlerType string
|
|
tagged bool
|
|
underlying string
|
|
cleaned []geminiWeb.RoleText
|
|
translatedRaw []byte
|
|
}
|
|
|
|
// prepareChat performs translation, message parsing, metadata reuse, prompt build and StartChat.
|
|
func (c *GeminiWebClient) prepareChat(ctx context.Context, modelName string, rawJSON []byte, isStream bool) (*chatPrep, *interfaces.ErrorMessage) {
|
|
res := &chatPrep{}
|
|
if handler, ok := ctx.Value("handler").(interfaces.APIHandler); ok {
|
|
res.handlerType = handler.HandlerType()
|
|
rawJSON = translator.Request(res.handlerType, c.Type(), modelName, rawJSON, isStream)
|
|
}
|
|
res.translatedRaw = rawJSON
|
|
if c.cfg.RequestLog {
|
|
if ginContext, ok := ctx.Value("gin").(*gin.Context); ok {
|
|
ginContext.Set("API_REQUEST", rawJSON)
|
|
}
|
|
}
|
|
messages, files, mimes, msgFileIdx, err := geminiWeb.ParseMessagesAndFiles(rawJSON)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: fmt.Errorf("bad request: %w", err)}
|
|
}
|
|
cleaned := geminiWeb.SanitizeAssistantMessages(messages)
|
|
res.cleaned = cleaned
|
|
res.underlying = geminiWeb.MapAliasToUnderlying(modelName)
|
|
model, err := geminiWeb.ModelFromName(res.underlying)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: err}
|
|
}
|
|
|
|
var (
|
|
meta []string
|
|
useMsgs []geminiWeb.RoleText
|
|
filesSubset [][]byte
|
|
mimesSubset []string
|
|
)
|
|
if c.useReusableContext() {
|
|
reuseMeta, remaining := c.findReusableSession(res.underlying, cleaned)
|
|
res.reuse = len(reuseMeta) > 0
|
|
if res.reuse {
|
|
meta = reuseMeta
|
|
if len(remaining) == 1 {
|
|
useMsgs = []geminiWeb.RoleText{remaining[0]}
|
|
} else {
|
|
useMsgs = remaining
|
|
}
|
|
} else {
|
|
// Fallback: only when there is clear continuation context.
|
|
// Require at least two messages and the previous turn is assistant.
|
|
if len(cleaned) >= 2 && strings.EqualFold(cleaned[len(cleaned)-2].Role, "assistant") {
|
|
// Prefer canonical (underlying) model key; fall back to alias key for backward-compatibility.
|
|
keyUnderlying := geminiWeb.AccountMetaKey(c.GetEmail(), res.underlying)
|
|
keyAlias := geminiWeb.AccountMetaKey(c.GetEmail(), modelName)
|
|
c.convMutex.RLock()
|
|
fallbackMeta := c.convStore[keyUnderlying]
|
|
if len(fallbackMeta) == 0 {
|
|
fallbackMeta = c.convStore[keyAlias]
|
|
}
|
|
c.convMutex.RUnlock()
|
|
if len(fallbackMeta) > 0 {
|
|
meta = fallbackMeta
|
|
// Only send the newest user message as continuation.
|
|
useMsgs = []geminiWeb.RoleText{cleaned[len(cleaned)-1]}
|
|
res.reuse = true
|
|
} else {
|
|
meta = nil
|
|
useMsgs = cleaned
|
|
}
|
|
} else {
|
|
// No safe continuation context detected; do not reuse metadata.
|
|
meta = nil
|
|
useMsgs = cleaned
|
|
}
|
|
}
|
|
res.tagged = geminiWeb.NeedRoleTags(useMsgs)
|
|
if res.reuse && len(useMsgs) == 1 {
|
|
res.tagged = false
|
|
}
|
|
if res.reuse && len(useMsgs) == 1 && len(messages) > 0 {
|
|
lastIdx := len(messages) - 1
|
|
if lastIdx >= 0 && lastIdx < len(msgFileIdx) {
|
|
for _, fi := range msgFileIdx[lastIdx] {
|
|
if fi >= 0 && fi < len(files) {
|
|
filesSubset = append(filesSubset, files[fi])
|
|
if fi < len(mimes) {
|
|
mimesSubset = append(mimesSubset, mimes[fi])
|
|
} else {
|
|
mimesSubset = append(mimesSubset, "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
filesSubset = files
|
|
mimesSubset = mimes
|
|
}
|
|
res.metaLen = len(meta)
|
|
} else {
|
|
// Context reuse disabled: use account-level metadata if present.
|
|
// Check both canonical model and alias for compatibility.
|
|
keyUnderlying := geminiWeb.AccountMetaKey(c.GetEmail(), res.underlying)
|
|
keyAlias := geminiWeb.AccountMetaKey(c.GetEmail(), modelName)
|
|
c.convMutex.RLock()
|
|
if v, ok := c.convStore[keyUnderlying]; ok && len(v) > 0 {
|
|
meta = v
|
|
} else {
|
|
meta = c.convStore[keyAlias]
|
|
}
|
|
c.convMutex.RUnlock()
|
|
useMsgs = cleaned
|
|
res.tagged = geminiWeb.NeedRoleTags(useMsgs)
|
|
filesSubset = files
|
|
mimesSubset = mimes
|
|
res.reuse = false
|
|
res.metaLen = len(meta)
|
|
}
|
|
|
|
uploadedFiles, upErr := geminiWeb.MaterializeInlineFiles(filesSubset, mimesSubset)
|
|
if upErr != nil {
|
|
return nil, upErr
|
|
}
|
|
res.uploaded = uploadedFiles
|
|
|
|
// XML hint follows code-mode only:
|
|
// - code-mode = true -> enable XML wrapping hint
|
|
// - code-mode = false -> disable XML wrapping hint
|
|
enableXMLHint := c.cfg != nil && c.cfg.GeminiWeb.CodeMode
|
|
useMsgs = geminiWeb.AppendXMLWrapHintIfNeeded(useMsgs, !enableXMLHint)
|
|
res.prompt = geminiWeb.BuildPrompt(useMsgs, res.tagged, res.tagged)
|
|
if strings.TrimSpace(res.prompt) == "" {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 400, Error: errors.New("bad request: empty prompt after filtering system/thought content")}
|
|
}
|
|
c.appendUpstreamRequestLog(ctx, modelName, res.tagged, true, res.prompt, len(uploadedFiles), res.reuse, res.metaLen)
|
|
gem := c.getConfiguredGem()
|
|
res.chat = c.gwc.StartChat(model, gem, meta)
|
|
return res, nil
|
|
}
|
|
|
|
func (c *GeminiWebClient) SendRawMessage(ctx context.Context, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
|
original := bytes.Clone(rawJSON)
|
|
prep, prepErr := c.prepareChat(ctx, modelName, rawJSON, false)
|
|
if prepErr != nil {
|
|
return nil, prepErr
|
|
}
|
|
defer geminiWeb.CleanupFiles(prep.uploaded)
|
|
log.Debugf("Use Gemini Web account %s for model %s", c.GetEmail(), modelName)
|
|
out, genErr := geminiWeb.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, c.cfg)
|
|
if genErr != nil {
|
|
return nil, c.handleSendError(genErr, modelName)
|
|
}
|
|
gemBytes, errMsg := c.handleSendSuccess(ctx, prep, &out, modelName)
|
|
if errMsg != nil {
|
|
return nil, errMsg
|
|
}
|
|
if translator.NeedConvert(prep.handlerType, c.Type()) {
|
|
var param any
|
|
out := translator.ResponseNonStream(prep.handlerType, c.Type(), ctx, modelName, original, prep.translatedRaw, gemBytes, ¶m)
|
|
if prep.handlerType == OPENAI && out != "" {
|
|
newID := fmt.Sprintf("chatcmpl-%x", time.Now().UnixNano())
|
|
if v := gjson.Parse(out).Get("id"); v.Exists() {
|
|
out, _ = sjson.Set(out, "id", newID)
|
|
}
|
|
}
|
|
return []byte(out), nil
|
|
}
|
|
return gemBytes, nil
|
|
}
|
|
|
|
func (c *GeminiWebClient) SendRawMessageStream(ctx context.Context, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage) {
|
|
dataChan := make(chan []byte)
|
|
errChan := make(chan *interfaces.ErrorMessage)
|
|
go func() {
|
|
defer close(dataChan)
|
|
defer close(errChan)
|
|
original := bytes.Clone(rawJSON)
|
|
prep, prepErr := c.prepareChat(ctx, modelName, rawJSON, true)
|
|
if prepErr != nil {
|
|
errChan <- prepErr
|
|
return
|
|
}
|
|
defer geminiWeb.CleanupFiles(prep.uploaded)
|
|
log.Debugf("Use Gemini Web account %s for model %s", c.GetEmail(), modelName)
|
|
out, genErr := geminiWeb.SendWithSplit(prep.chat, prep.prompt, prep.uploaded, c.cfg)
|
|
if genErr != nil {
|
|
errChan <- c.handleSendError(genErr, modelName)
|
|
return
|
|
}
|
|
gemBytes, errMsg := c.handleSendSuccess(ctx, prep, &out, modelName)
|
|
if errMsg != nil {
|
|
errChan <- errMsg
|
|
return
|
|
}
|
|
// Branch by handler type:
|
|
// - Native Gemini handler: emit at most two messages (thoughts, then others), no [DONE].
|
|
// - Translated handlers (e.g., OpenAI Responses): split first payload into two (if thoughts exist), then emit translator's [DONE].
|
|
if prep.handlerType == GEMINI {
|
|
root := gjson.ParseBytes(gemBytes)
|
|
parts := root.Get("candidates.0.content.parts")
|
|
if parts.Exists() && parts.IsArray() {
|
|
var thoughtArr, otherArr strings.Builder
|
|
thoughtCount := 0
|
|
thoughtArr.WriteByte('[')
|
|
otherArr.WriteByte('[')
|
|
firstThought := true
|
|
firstOther := true
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("thought").Bool() {
|
|
if !firstThought {
|
|
thoughtArr.WriteByte(',')
|
|
}
|
|
thoughtArr.WriteString(part.Raw)
|
|
firstThought = false
|
|
thoughtCount++
|
|
} else {
|
|
if !firstOther {
|
|
otherArr.WriteByte(',')
|
|
}
|
|
otherArr.WriteString(part.Raw)
|
|
firstOther = false
|
|
}
|
|
return true
|
|
})
|
|
thoughtArr.WriteByte(']')
|
|
otherArr.WriteByte(']')
|
|
if thoughtCount > 0 {
|
|
thoughtOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", thoughtArr.String())
|
|
// Only when the first chunk contains thoughts, set finishReason to null
|
|
thoughtOnly, _ = sjson.SetRaw(thoughtOnly, "candidates.0.finishReason", "null")
|
|
dataChan <- []byte(thoughtOnly)
|
|
}
|
|
othersOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", otherArr.String())
|
|
// Do not modify finishReason for non-thought first chunks or subsequent chunks
|
|
dataChan <- []byte(othersOnly)
|
|
return
|
|
}
|
|
// Fallback: no parts array; emit single message
|
|
// No special handling when no parts or no thoughts
|
|
dataChan <- gemBytes
|
|
return
|
|
}
|
|
|
|
// Translated handlers: when code-mode is ON, merge <think> into content and emit a single chunk; otherwise keep split.
|
|
newCtx := context.WithValue(ctx, "alt", alt)
|
|
var param any
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
combined := mergeThoughtIntoSingleContent(gemBytes)
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, combined, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
for _, l := range done {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
root := gjson.ParseBytes(gemBytes)
|
|
parts := root.Get("candidates.0.content.parts")
|
|
if parts.Exists() && parts.IsArray() {
|
|
// Non code-mode: perform pseudo streaming by splitting text into small chunks
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
chunkSize := 40
|
|
fr := strings.ToUpper(root.Get("candidates.0.finishReason").String())
|
|
units := make([][]byte, 0, 16)
|
|
units = append(units, buildPseudoUnits(gemBytes, true, chunkSize, false)...)
|
|
other := buildPseudoUnits(gemBytes, false, chunkSize, false)
|
|
if len(other) > 0 && fr != "" {
|
|
if updated, err := sjson.SetBytes(other[len(other)-1], "candidates.0.finishReason", fr); err == nil {
|
|
other[len(other)-1] = updated
|
|
}
|
|
}
|
|
units = append(units, other...)
|
|
for _, u := range units {
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, u, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// 80ms interval between pseudo chunks
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
// translator-level done signal
|
|
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
for _, l := range done {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
var thoughtArr, otherArr strings.Builder
|
|
thoughtCount := 0
|
|
thoughtArr.WriteByte('[')
|
|
otherArr.WriteByte('[')
|
|
firstThought := true
|
|
firstOther := true
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if part.Get("thought").Bool() {
|
|
if !firstThought {
|
|
thoughtArr.WriteByte(',')
|
|
}
|
|
thoughtArr.WriteString(part.Raw)
|
|
firstThought = false
|
|
thoughtCount++
|
|
} else {
|
|
if !firstOther {
|
|
otherArr.WriteByte(',')
|
|
}
|
|
otherArr.WriteString(part.Raw)
|
|
firstOther = false
|
|
}
|
|
return true
|
|
})
|
|
thoughtArr.WriteByte(']')
|
|
otherArr.WriteByte(']')
|
|
|
|
if thoughtCount > 0 {
|
|
thoughtOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", thoughtArr.String())
|
|
// Only when the first chunk contains thoughts, suppress finishReason before translation
|
|
thoughtOnly, _ = sjson.Delete(thoughtOnly, "candidates.0.finishReason")
|
|
// If CodeMode enabled, demote thought parts to content before translating
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
processed := collapseThoughtPartsToContent([]byte(thoughtOnly))
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte(thoughtOnly), ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
othersOnly, _ := sjson.SetRaw(string(gemBytes), "candidates.0.content.parts", otherArr.String())
|
|
// Do not modify finishReason if there is no thought chunk
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
processed := collapseThoughtPartsToContent([]byte(othersOnly))
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte(othersOnly), ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
for _, l := range done {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
// Fallback: no parts array; forward as a single translated payload then DONE
|
|
// If code-mode is ON, still merge to a single content block.
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
processed := mergeThoughtIntoSingleContent(gemBytes)
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, processed, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
} else {
|
|
lines := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, gemBytes, ¶m)
|
|
for _, l := range lines {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
// Apply 80ms delay between pseudo chunks in non-code mode
|
|
if !c.cfg.GeminiWeb.CodeMode {
|
|
time.Sleep(80 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
done := translator.Response(prep.handlerType, c.Type(), newCtx, modelName, original, prep.translatedRaw, []byte("[DONE]"), ¶m)
|
|
for _, l := range done {
|
|
if l != "" {
|
|
dataChan <- []byte(l)
|
|
}
|
|
}
|
|
}()
|
|
return dataChan, errChan
|
|
}
|
|
|
|
func (c *GeminiWebClient) handleSendError(genErr error, modelName string) *interfaces.ErrorMessage {
|
|
log.Errorf("failed to generate content: %v", genErr)
|
|
status := 500
|
|
var eUsage *geminiWeb.UsageLimitExceeded
|
|
var eTempBlock *geminiWeb.TemporarilyBlocked
|
|
if errors.As(genErr, &eUsage) || errors.As(genErr, &eTempBlock) {
|
|
status = 429
|
|
}
|
|
var eModelInvalid *geminiWeb.ModelInvalid
|
|
if status == 500 && errors.As(genErr, &eModelInvalid) {
|
|
status = 400
|
|
}
|
|
var eValue *geminiWeb.ValueError
|
|
if status == 500 && errors.As(genErr, &eValue) {
|
|
status = 400
|
|
}
|
|
var eTimeout *geminiWeb.TimeoutError
|
|
if status == 500 && errors.As(genErr, &eTimeout) {
|
|
status = 504
|
|
}
|
|
if status == 429 {
|
|
now := time.Now()
|
|
c.modelQuotaExceeded[modelName] = &now
|
|
c.SetModelQuotaExceeded(modelName)
|
|
}
|
|
return &interfaces.ErrorMessage{StatusCode: status, Error: genErr}
|
|
}
|
|
|
|
func (c *GeminiWebClient) handleSendSuccess(ctx context.Context, prep *chatPrep, output *geminiWeb.ModelOutput, modelName string) ([]byte, *interfaces.ErrorMessage) {
|
|
delete(c.modelQuotaExceeded, modelName)
|
|
c.ClearModelQuotaExceeded(modelName)
|
|
gemBytes, err := geminiWeb.ConvertOutputToGemini(output, modelName, prep.prompt)
|
|
if err != nil {
|
|
return nil, &interfaces.ErrorMessage{StatusCode: 500, Error: err}
|
|
}
|
|
c.AddAPIResponseData(ctx, gemBytes)
|
|
if output != nil {
|
|
metaAfter := prep.chat.Metadata()
|
|
if len(metaAfter) > 0 {
|
|
// Store under canonical (underlying) model key for stability across aliases.
|
|
key := geminiWeb.AccountMetaKey(c.GetEmail(), prep.underlying)
|
|
c.convMutex.Lock()
|
|
c.convStore[key] = metaAfter
|
|
snapshot := c.convStore
|
|
c.convMutex.Unlock()
|
|
_ = geminiWeb.SaveConvStore(geminiWeb.ConvStorePath(c.tokenFilePath), snapshot)
|
|
}
|
|
if c.useReusableContext() {
|
|
c.storeConversationJSON(prep.underlying, prep.cleaned, prep.chat.Metadata(), output)
|
|
}
|
|
}
|
|
return gemBytes, nil
|
|
}
|
|
|
|
// collapseThoughtPartsToContent flattens Gemini "thought" parts into regular text parts
|
|
// so downstream OpenAI translators emit them as `content` instead of `reasoning_content`.
|
|
// It preserves part order and keeps non-text parts intact.
|
|
func collapseThoughtPartsToContent(gemBytes []byte) []byte {
|
|
parts := gjson.GetBytes(gemBytes, "candidates.0.content.parts")
|
|
if !parts.Exists() || !parts.IsArray() {
|
|
return gemBytes
|
|
}
|
|
arr := parts.Array()
|
|
newParts := make([]json.RawMessage, 0, len(arr))
|
|
for _, part := range arr {
|
|
if t := part.Get("text"); t.Exists() {
|
|
obj, _ := json.Marshal(map[string]string{"text": t.String()})
|
|
newParts = append(newParts, obj)
|
|
} else {
|
|
newParts = append(newParts, json.RawMessage(part.Raw))
|
|
}
|
|
}
|
|
var sb strings.Builder
|
|
sb.WriteByte('[')
|
|
for i, p := range newParts {
|
|
if i > 0 {
|
|
sb.WriteByte(',')
|
|
}
|
|
sb.Write(p)
|
|
}
|
|
sb.WriteByte(']')
|
|
if updated, err := sjson.SetRawBytes(gemBytes, "candidates.0.content.parts", []byte(sb.String())); err == nil {
|
|
return updated
|
|
}
|
|
return gemBytes
|
|
}
|
|
|
|
// mergeThoughtIntoSingleContent merges all thought text and normal text into one text part.
|
|
// The output places the thought text inside <think>...</think> followed by a newline and then the normal text.
|
|
// Non-text parts are ignored for the combined output chunk.
|
|
func mergeThoughtIntoSingleContent(gemBytes []byte) []byte {
|
|
parts := gjson.GetBytes(gemBytes, "candidates.0.content.parts")
|
|
if !parts.Exists() || !parts.IsArray() {
|
|
return gemBytes
|
|
}
|
|
var thought strings.Builder
|
|
var visible strings.Builder
|
|
parts.ForEach(func(_, part gjson.Result) bool {
|
|
if t := part.Get("text"); t.Exists() {
|
|
if part.Get("thought").Bool() {
|
|
thought.WriteString(t.String())
|
|
} else {
|
|
visible.WriteString(t.String())
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
var combined strings.Builder
|
|
if thought.Len() > 0 {
|
|
combined.WriteString("<think>")
|
|
combined.WriteString(thought.String())
|
|
combined.WriteString("</think>\n\n")
|
|
}
|
|
combined.WriteString(visible.String())
|
|
|
|
// Build a single-part array
|
|
obj, _ := json.Marshal(map[string]string{"text": combined.String()})
|
|
var arr strings.Builder
|
|
arr.WriteByte('[')
|
|
arr.Write(obj)
|
|
arr.WriteByte(']')
|
|
if updated, err := sjson.SetRawBytes(gemBytes, "candidates.0.content.parts", []byte(arr.String())); err == nil {
|
|
return updated
|
|
}
|
|
return gemBytes
|
|
}
|
|
|
|
func (c *GeminiWebClient) appendUpstreamRequestLog(ctx context.Context, modelName string, useTags, explicitContext bool, prompt string, filesCount int, reuse bool, metaLen int) {
|
|
if !c.cfg.RequestLog {
|
|
return
|
|
}
|
|
ginContext, ok := ctx.Value("gin").(*gin.Context)
|
|
if !ok || ginContext == nil {
|
|
return
|
|
}
|
|
preview := geminiWeb.BuildUpstreamRequestLog(c.GetEmail(), c.useReusableContext(), useTags, explicitContext, prompt, filesCount, reuse, metaLen, c.getConfiguredGem())
|
|
if existing, exists := ginContext.Get("API_REQUEST"); exists {
|
|
if base, ok2 := existing.([]byte); ok2 {
|
|
merged := append(append([]byte{}, base...), []byte(preview)...)
|
|
ginContext.Set("API_REQUEST", merged)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *GeminiWebClient) SendRawTokenCount(ctx context.Context, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
|
est := geminiWeb.EstimateTotalTokensFromRawJSON(rawJSON)
|
|
return []byte(fmt.Sprintf(`{"totalTokens":%d}`, est)), nil
|
|
}
|
|
|
|
// SaveTokenToFile persists current cookies to a cookie snapshot via gemini-web helpers.
|
|
func (c *GeminiWebClient) SaveTokenToFile() error {
|
|
ts := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
|
|
if c.gwc != nil && c.gwc.Cookies != nil {
|
|
if v, ok := c.gwc.Cookies["__Secure-1PSID"]; ok && v != "" {
|
|
ts.Secure1PSID = v
|
|
}
|
|
if v, ok := c.gwc.Cookies["__Secure-1PSIDTS"]; ok && v != "" {
|
|
ts.Secure1PSIDTS = v
|
|
}
|
|
}
|
|
log.Debugf("Saving Gemini Web cookie snapshot to %s", filepath.Base(util.CookieSnapshotPath(c.tokenFilePath)))
|
|
return geminiWeb.SaveCookieSnapshot(c.tokenFilePath, c.gwc.Cookies)
|
|
}
|
|
|
|
// startCookiePersist periodically writes refreshed cookies into the cookie snapshot file.
|
|
func (c *GeminiWebClient) startCookiePersist() {
|
|
if c.gwc == nil {
|
|
return
|
|
}
|
|
if c.cookiePersistCancel != nil {
|
|
c.cookiePersistCancel()
|
|
c.cookiePersistCancel = nil
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.cookiePersistCancel = cancel
|
|
go func() {
|
|
// Persist cookies at the same cadence as auto-refresh when enabled,
|
|
// otherwise use a coarse default interval.
|
|
persistSec := geminiWebDefaultPersistIntervalSec
|
|
if c.gwc != nil && c.gwc.AutoRefresh {
|
|
if sec := int(c.gwc.RefreshInterval / time.Second); sec > 0 {
|
|
persistSec = sec
|
|
}
|
|
}
|
|
ticker := time.NewTicker(time.Duration(persistSec) * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if c.gwc != nil && c.gwc.Cookies != nil {
|
|
if err := c.SaveTokenToFile(); err != nil {
|
|
log.Errorf("Failed to persist cookie snapshot for %s: %v", c.GetEmail(), err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (c *GeminiWebClient) IsModelQuotaExceeded(model string) bool {
|
|
if t, ok := c.modelQuotaExceeded[model]; ok {
|
|
return time.Since(*t) <= 30*time.Minute
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *GeminiWebClient) GetUserAgent() string {
|
|
if ua := geminiWeb.HeadersGemini.Get("User-Agent"); ua != "" {
|
|
return ua
|
|
}
|
|
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
}
|
|
|
|
func (c *GeminiWebClient) GetRequestMutex() *sync.Mutex { return nil }
|
|
|
|
func (c *GeminiWebClient) RefreshTokens(ctx context.Context) error { return c.Init() }
|
|
|
|
// runeChunks splits a string into rune-safe chunks of roughly the given size.
|
|
// It preserves UTF-8 boundaries to avoid breaking characters mid-sequence.
|
|
func runeChunks(s string, size int) []string {
|
|
if size <= 0 || len(s) == 0 {
|
|
return []string{s}
|
|
}
|
|
var chunks []string
|
|
var b strings.Builder
|
|
count := 0
|
|
for _, r := range s {
|
|
b.WriteRune(r)
|
|
count++
|
|
if count >= size {
|
|
chunks = append(chunks, b.String())
|
|
b.Reset()
|
|
count = 0
|
|
}
|
|
}
|
|
if b.Len() > 0 {
|
|
chunks = append(chunks, b.String())
|
|
}
|
|
if len(chunks) == 0 {
|
|
return []string{""}
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
// splitCodeBlocks splits text by triple backtick code fences, marking code blocks.
|
|
type textBlock struct {
|
|
text string
|
|
isCode bool
|
|
}
|
|
|
|
func splitCodeBlocks(s string) []textBlock {
|
|
var blocks []textBlock
|
|
for {
|
|
start := strings.Index(s, "```")
|
|
if start == -1 {
|
|
if s != "" {
|
|
blocks = append(blocks, textBlock{text: s, isCode: false})
|
|
}
|
|
break
|
|
}
|
|
// prepend plain text before code block
|
|
if start > 0 {
|
|
blocks = append(blocks, textBlock{text: s[:start], isCode: false})
|
|
}
|
|
s = s[start+3:]
|
|
end := strings.Index(s, "```")
|
|
if end == -1 {
|
|
// unmatched fence, treat rest as code
|
|
blocks = append(blocks, textBlock{text: s, isCode: true})
|
|
break
|
|
}
|
|
code := s[:end]
|
|
blocks = append(blocks, textBlock{text: code, isCode: true})
|
|
s = s[end+3:]
|
|
}
|
|
return blocks
|
|
}
|
|
|
|
// buildPseudoUnits constructs a series of Gemini JSON payloads that each contain
|
|
// a small portion of the original response's parts. When thoughtOnly is true,
|
|
// it chunks only reasoning text; otherwise it chunks visible text and forwards
|
|
// functionCall parts as separate units. All generated units have finishReason removed.
|
|
func buildPseudoUnits(gemBytes []byte, thoughtOnly bool, chunkSize int, _ bool) [][]byte {
|
|
base := gemBytes
|
|
base, _ = sjson.DeleteBytes(base, "candidates.0.finishReason")
|
|
setParts := func(partsRaw string) []byte {
|
|
s, _ := sjson.SetRawBytes(base, "candidates.0.content.parts", []byte(partsRaw))
|
|
return s
|
|
}
|
|
parts := gjson.GetBytes(gemBytes, "candidates.0.content.parts")
|
|
var units [][]byte
|
|
|
|
if thoughtOnly {
|
|
var buf strings.Builder
|
|
parts.ForEach(func(_, p gjson.Result) bool {
|
|
if p.Get("thought").Bool() {
|
|
if t := p.Get("text"); t.Exists() {
|
|
buf.WriteString(t.String())
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if buf.Len() > 0 {
|
|
// Chunk by runes to preserve exact formatting (including newlines)
|
|
segs := runeChunks(buf.String(), chunkSize)
|
|
for _, piece := range segs {
|
|
obj := map[string]any{"text": piece, "thought": true}
|
|
arr, _ := json.Marshal([]map[string]any{obj})
|
|
units = append(units, setParts(string(arr)))
|
|
}
|
|
}
|
|
return units
|
|
}
|
|
|
|
// Non-thought: chunk visible text semantically and forward functionCall parts in order
|
|
flushText := func(sb *strings.Builder) {
|
|
if sb.Len() == 0 {
|
|
return
|
|
}
|
|
s := sb.String()
|
|
// Preserve code fences as whole blocks; otherwise chunk by runes
|
|
blocks := splitCodeBlocks(s)
|
|
for _, blk := range blocks {
|
|
if blk.isCode {
|
|
obj := map[string]any{"text": "```" + blk.text + "```"}
|
|
arr, _ := json.Marshal([]map[string]any{obj})
|
|
units = append(units, setParts(string(arr)))
|
|
continue
|
|
}
|
|
for _, piece := range runeChunks(blk.text, chunkSize) {
|
|
if piece == "" {
|
|
continue
|
|
}
|
|
obj := map[string]any{"text": piece}
|
|
arr, _ := json.Marshal([]map[string]any{obj})
|
|
units = append(units, setParts(string(arr)))
|
|
}
|
|
}
|
|
sb.Reset()
|
|
}
|
|
|
|
var textBuf strings.Builder
|
|
parts.ForEach(func(_, p gjson.Result) bool {
|
|
if p.Get("thought").Bool() {
|
|
return true
|
|
}
|
|
if fc := p.Get("functionCall"); fc.Exists() {
|
|
flushText(&textBuf)
|
|
units = append(units, setParts("["+fc.Raw+"]"))
|
|
return true
|
|
}
|
|
if t := p.Get("text"); t.Exists() {
|
|
textBuf.WriteString(t.String())
|
|
return true
|
|
}
|
|
// Unknown part: forward as its own unit
|
|
flushText(&textBuf)
|
|
units = append(units, setParts("["+p.Raw+"]"))
|
|
return true
|
|
})
|
|
flushText(&textBuf)
|
|
return units
|
|
}
|
|
|
|
func (c *GeminiWebClient) backgroundInitRetry() {
|
|
backoffs := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, 1 * time.Minute, 2 * time.Minute, 5 * time.Minute}
|
|
i := 0
|
|
for {
|
|
if err := c.Init(); err == nil {
|
|
log.Infof("Gemini Web token recovered for %s", c.GetEmail())
|
|
if !c.cookieRotationStarted {
|
|
c.cookieRotationStarted = true
|
|
}
|
|
c.startCookiePersist()
|
|
return
|
|
}
|
|
d := backoffs[i]
|
|
if i < len(backoffs)-1 {
|
|
i++
|
|
}
|
|
time.Sleep(d)
|
|
}
|
|
}
|
|
|
|
// flushCookieSnapshotToMain merges snapshot cookies into the main token file.
|
|
func (c *GeminiWebClient) flushCookieSnapshotToMain() {
|
|
if c.tokenFilePath == "" {
|
|
return
|
|
}
|
|
base := c.tokenStorage.(*gemini.GeminiWebTokenStorage)
|
|
if err := geminiWeb.FlushCookieSnapshotToMain(c.tokenFilePath, c.gwc.Cookies, base); err != nil {
|
|
log.Errorf("Failed to flush cookie snapshot to main for %s: %v", filepath.Base(c.tokenFilePath), err)
|
|
}
|
|
}
|
|
|
|
// findReusableSession and storeConversationJSON live here as client bridges; hashing/records in gemini-web
|
|
func (c *GeminiWebClient) getConfiguredGem() *geminiWeb.Gem {
|
|
if c.cfg.GeminiWeb.CodeMode {
|
|
return &geminiWeb.Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findReusableSession bridges to gemini-web conversation reuse using in-memory stores.
|
|
func (c *GeminiWebClient) findReusableSession(model string, msgs []geminiWeb.RoleText) ([]string, []geminiWeb.RoleText) {
|
|
c.convMutex.RLock()
|
|
items := c.convData
|
|
index := c.convIndex
|
|
c.convMutex.RUnlock()
|
|
return geminiWeb.FindReusableSessionIn(items, index, c.StableClientID(), c.GetEmail(), model, msgs)
|
|
}
|
|
|
|
// storeConversationJSON persists conversation records and updates in-memory indexes.
|
|
func (c *GeminiWebClient) storeConversationJSON(model string, history []geminiWeb.RoleText, metadata []string, output *geminiWeb.ModelOutput) {
|
|
rec, ok := geminiWeb.BuildConversationRecord(model, c.StableClientID(), history, output, metadata)
|
|
if !ok {
|
|
return
|
|
}
|
|
stableID := rec.ClientID
|
|
stableHash := geminiWeb.HashConversation(stableID, model, rec.Messages)
|
|
legacyID := c.GetEmail()
|
|
legacyHash := geminiWeb.HashConversation(legacyID, model, rec.Messages)
|
|
c.convMutex.Lock()
|
|
c.convData[stableHash] = rec
|
|
c.convIndex["hash:"+stableHash] = stableHash
|
|
if legacyID != stableID {
|
|
c.convIndex["hash:"+legacyHash] = stableHash
|
|
}
|
|
items := c.convData
|
|
index := c.convIndex
|
|
c.convMutex.Unlock()
|
|
_ = geminiWeb.SaveConvData(geminiWeb.ConvDataPath(c.tokenFilePath), items, index)
|
|
}
|
|
|
|
// IsAvailable returns true if the client is available for use.
|
|
func (c *GeminiWebClient) IsAvailable() bool {
|
|
return c.isAvailable
|
|
}
|
|
|
|
// SetUnavailable sets the client to unavailable.
|
|
func (c *GeminiWebClient) SetUnavailable() {
|
|
c.isAvailable = false
|
|
}
|