mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
- Renamed constants from uppercase to CamelCase for consistency. - Replaced redundant file-based auth handling logic with the new `util.CountAuthFiles` helper. - Fixed various error-handling inconsistencies and enhanced robustness in file operations. - Streamlined auth client reload logic in server and watcher components. - Applied minor code readability improvements across multiple packages.
365 lines
9.9 KiB
Go
365 lines
9.9 KiB
Go
package geminiwebapi
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
bolt "go.etcd.io/bbolt"
|
|
)
|
|
|
|
// 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+".bolt")
|
|
}
|
|
|
|
// 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+".bolt")
|
|
}
|
|
|
|
// LoadConvStore reads the account-level metadata store from disk.
|
|
func LoadConvStore(path string) (map[string][]string, error) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: time.Second})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
_ = db.Close()
|
|
}()
|
|
out := map[string][]string{}
|
|
err = db.View(func(tx *bolt.Tx) error {
|
|
b := tx.Bucket([]byte("account_meta"))
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
return b.ForEach(func(k, v []byte) error {
|
|
var arr []string
|
|
if len(v) > 0 {
|
|
if e := json.Unmarshal(v, &arr); e != nil {
|
|
// Skip malformed entries instead of failing the whole load
|
|
return nil
|
|
}
|
|
}
|
|
out[string(k)] = arr
|
|
return nil
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, 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{}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = db.Close()
|
|
}()
|
|
return db.Update(func(tx *bolt.Tx) error {
|
|
// Recreate bucket to reflect the given snapshot exactly.
|
|
if b := tx.Bucket([]byte("account_meta")); b != nil {
|
|
if err = tx.DeleteBucket([]byte("account_meta")); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
b, errCreateBucket := tx.CreateBucket([]byte("account_meta"))
|
|
if errCreateBucket != nil {
|
|
return errCreateBucket
|
|
}
|
|
for k, v := range data {
|
|
enc, e := json.Marshal(v)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if e = b.Put([]byte(k), enc); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: time.Second})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer func() {
|
|
_ = db.Close()
|
|
}()
|
|
items := map[string]ConversationRecord{}
|
|
index := map[string]string{}
|
|
err = db.View(func(tx *bolt.Tx) error {
|
|
// Load conv_items
|
|
if b := tx.Bucket([]byte("conv_items")); b != nil {
|
|
if e := b.ForEach(func(k, v []byte) error {
|
|
var rec ConversationRecord
|
|
if len(v) > 0 {
|
|
if e2 := json.Unmarshal(v, &rec); e2 != nil {
|
|
// Skip malformed
|
|
return nil
|
|
}
|
|
items[string(k)] = rec
|
|
}
|
|
return nil
|
|
}); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
// Load conv_index
|
|
if b := tx.Bucket([]byte("conv_index")); b != nil {
|
|
if e := b.ForEach(func(k, v []byte) error {
|
|
index[string(k)] = string(v)
|
|
return nil
|
|
}); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return items, 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{}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = db.Close()
|
|
}()
|
|
return db.Update(func(tx *bolt.Tx) error {
|
|
// Recreate items bucket
|
|
if b := tx.Bucket([]byte("conv_items")); b != nil {
|
|
if err = tx.DeleteBucket([]byte("conv_items")); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
bi, errCreateBucket := tx.CreateBucket([]byte("conv_items"))
|
|
if errCreateBucket != nil {
|
|
return errCreateBucket
|
|
}
|
|
for k, rec := range items {
|
|
enc, e := json.Marshal(rec)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if e = bi.Put([]byte(k), enc); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
|
|
// Recreate index bucket
|
|
if b := tx.Bucket([]byte("conv_index")); b != nil {
|
|
if err = tx.DeleteBucket([]byte("conv_index")); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
bx, errCreateBucket := tx.CreateBucket([]byte("conv_index"))
|
|
if errCreateBucket != nil {
|
|
return errCreateBucket
|
|
}
|
|
for k, v := range index {
|
|
if e := bx.Put([]byte(k), []byte(v)); e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|