Files
CLIProxyAPI/internal/client/gemini-web/persistence.go
hkfires 56b2dabcca refactor(auth): Introduce generic cookie snapshot manager
This commit introduces a generic `cookies.Manager` to centralize the logic for handling cookie snapshots, which was previously duplicated across the Gemini and PaLM clients. This refactoring eliminates code duplication and improves maintainability.

The new `cookies.Manager[T]` in `internal/auth/cookies` orchestrates the lifecycle of cookie data between a temporary snapshot file and the main token file. It provides `Apply`, `Persist`, and `Flush` methods to manage this process.

Key changes:
- A generic `Manager` is created in `internal/auth/cookies`, usable for any token storage type.
- A `Hooks` struct allows for customizable behavior, such as custom merging strategies for different token types.
- Duplicated snapshot handling code has been removed from the `gemini-web` and `palm` persistence packages.
- The `GeminiWebClient` and `PaLMClient` have been updated to use the new `cookies.Manager`.
- The `auth_gemini` and `auth_palm` CLI commands now leverage the client's `Flush` method, simplifying the command logic.
- Cookie snapshot utility functions have been moved from `internal/util/files.go` to a new `internal/util/cookies.go` for better organization.
2025-09-19 11:46:09 +08:00

268 lines
7.9 KiB
Go

package geminiwebapi
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// StoredMessage represents a single message in a conversation record.
type StoredMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Name string `json:"name,omitempty"`
}
// ConversationRecord stores a full conversation with its metadata for persistence.
type ConversationRecord struct {
Model string `json:"model"`
ClientID string `json:"client_id"`
Metadata []string `json:"metadata,omitempty"`
Messages []StoredMessage `json:"messages"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Sha256Hex computes the SHA256 hash of a string and returns its hex representation.
func Sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// RoleText represents a turn in a conversation with a role and text content.
type RoleText struct {
Role string
Text string
}
func ToStoredMessages(msgs []RoleText) []StoredMessage {
out := make([]StoredMessage, 0, len(msgs))
for _, m := range msgs {
out = append(out, StoredMessage{
Role: m.Role,
Content: m.Text,
})
}
return out
}
func HashMessage(m StoredMessage) string {
s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
return Sha256Hex(s)
}
func HashConversation(clientID, model string, msgs []StoredMessage) string {
var b strings.Builder
b.WriteString(clientID)
b.WriteString("|")
b.WriteString(model)
for _, m := range msgs {
b.WriteString("|")
b.WriteString(HashMessage(m))
}
return Sha256Hex(b.String())
}
// ConvStorePath returns the path for account-level metadata persistence based on token file path.
func ConvStorePath(tokenFilePath string) string {
wd, err := os.Getwd()
if err != nil || wd == "" {
wd = "."
}
convDir := filepath.Join(wd, "conv")
base := strings.TrimSuffix(filepath.Base(tokenFilePath), filepath.Ext(tokenFilePath))
return filepath.Join(convDir, base+".conv.json")
}
// ConvDataPath returns the path for full conversation persistence based on token file path.
func ConvDataPath(tokenFilePath string) string {
wd, err := os.Getwd()
if err != nil || wd == "" {
wd = "."
}
convDir := filepath.Join(wd, "conv")
base := strings.TrimSuffix(filepath.Base(tokenFilePath), filepath.Ext(tokenFilePath))
return filepath.Join(convDir, base+".data.json")
}
// LoadConvStore reads the account-level metadata store from disk.
func LoadConvStore(path string) (map[string][]string, error) {
b, err := os.ReadFile(path)
if err != nil {
// Missing file is not an error; return empty map
return map[string][]string{}, nil
}
var tmp map[string][]string
if err := json.Unmarshal(b, &tmp); err != nil {
return nil, err
}
if tmp == nil {
tmp = map[string][]string{}
}
return tmp, nil
}
// SaveConvStore writes the account-level metadata store to disk atomically.
func SaveConvStore(path string, data map[string][]string) error {
if data == nil {
data = map[string][]string{}
}
payload, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// AccountMetaKey builds the key for account-level metadata map.
func AccountMetaKey(email, modelName string) string {
return fmt.Sprintf("account-meta|%s|%s", email, modelName)
}
// LoadConvData reads the full conversation data and index from disk.
func LoadConvData(path string) (map[string]ConversationRecord, map[string]string, error) {
b, err := os.ReadFile(path)
if err != nil {
// Missing file is not an error; return empty sets
return map[string]ConversationRecord{}, map[string]string{}, nil
}
var wrapper struct {
Items map[string]ConversationRecord `json:"items"`
Index map[string]string `json:"index"`
}
if err := json.Unmarshal(b, &wrapper); err != nil {
return nil, nil, err
}
if wrapper.Items == nil {
wrapper.Items = map[string]ConversationRecord{}
}
if wrapper.Index == nil {
wrapper.Index = map[string]string{}
}
return wrapper.Items, wrapper.Index, nil
}
// SaveConvData writes the full conversation data and index to disk atomically.
func SaveConvData(path string, items map[string]ConversationRecord, index map[string]string) error {
if items == nil {
items = map[string]ConversationRecord{}
}
if index == nil {
index = map[string]string{}
}
wrapper := struct {
Items map[string]ConversationRecord `json:"items"`
Index map[string]string `json:"index"`
}{Items: items, Index: index}
payload, err := json.MarshalIndent(wrapper, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// BuildConversationRecord constructs a ConversationRecord from history and the latest output.
// Returns false when output is empty or has no candidates.
func BuildConversationRecord(model, clientID string, history []RoleText, output *ModelOutput, metadata []string) (ConversationRecord, bool) {
if output == nil || len(output.Candidates) == 0 {
return ConversationRecord{}, false
}
text := ""
if t := output.Candidates[0].Text; t != "" {
text = RemoveThinkTags(t)
}
final := append([]RoleText{}, history...)
final = append(final, RoleText{Role: "assistant", Text: text})
rec := ConversationRecord{
Model: model,
ClientID: clientID,
Metadata: metadata,
Messages: ToStoredMessages(final),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return rec, true
}
// FindByMessageListIn looks up a conversation record by hashed message list.
// It attempts both the stable client ID and a legacy email-based ID.
func FindByMessageListIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) {
stored := ToStoredMessages(msgs)
stableHash := HashConversation(stableClientID, model, stored)
fallbackHash := HashConversation(email, model, stored)
// Try stable hash via index indirection first
if key, ok := index["hash:"+stableHash]; ok {
if rec, ok2 := items[key]; ok2 {
return rec, true
}
}
if rec, ok := items[stableHash]; ok {
return rec, true
}
// Fallback to legacy hash (email-based)
if key, ok := index["hash:"+fallbackHash]; ok {
if rec, ok2 := items[key]; ok2 {
return rec, true
}
}
if rec, ok := items[fallbackHash]; ok {
return rec, true
}
return ConversationRecord{}, false
}
// FindConversationIn tries exact then sanitized assistant messages.
func FindConversationIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) (ConversationRecord, bool) {
if len(msgs) == 0 {
return ConversationRecord{}, false
}
if rec, ok := FindByMessageListIn(items, index, stableClientID, email, model, msgs); ok {
return rec, true
}
if rec, ok := FindByMessageListIn(items, index, stableClientID, email, model, SanitizeAssistantMessages(msgs)); ok {
return rec, true
}
return ConversationRecord{}, false
}
// FindReusableSessionIn returns reusable metadata and the remaining message suffix.
func FindReusableSessionIn(items map[string]ConversationRecord, index map[string]string, stableClientID, email, model string, msgs []RoleText) ([]string, []RoleText) {
if len(msgs) < 2 {
return nil, nil
}
searchEnd := len(msgs)
for searchEnd >= 2 {
sub := msgs[:searchEnd]
tail := sub[len(sub)-1]
if strings.EqualFold(tail.Role, "assistant") || strings.EqualFold(tail.Role, "system") {
if rec, ok := FindConversationIn(items, index, stableClientID, email, model, sub); ok {
remain := msgs[searchEnd:]
return rec.Metadata, remain
}
}
searchEnd--
}
return nil, nil
}