mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-07 08:50:22 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd3dbf2ce | ||
|
|
226df1c23a | ||
|
|
2665230a09 | ||
|
|
4f0c2b794c | ||
|
|
e756064c19 | ||
|
|
17dfb0af01 | ||
|
|
ff74f517df | ||
|
|
477a9a180f | ||
|
|
da48df06d2 |
@@ -668,6 +668,15 @@ func (h *AccountHandler) ClearError(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除错误后,同时清除 token 缓存,确保下次请求会获取最新的 token(触发刷新或从 DB 读取)
|
||||||
|
// 这解决了管理员重置账号状态后,旧的失效 token 仍在缓存中导致立即再次 401 的问题
|
||||||
|
if h.tokenCacheInvalidator != nil && account.IsOAuth() {
|
||||||
|
if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(c.Request.Context(), account); invalidateErr != nil {
|
||||||
|
// 缓存失效失败只记录日志,不影响主流程
|
||||||
|
_ = c.Error(invalidateErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response.Success(c, dto.AccountFromService(account))
|
response.Success(c, dto.AccountFromService(account))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -594,11 +592,14 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 清理 JSON Schema
|
// 清理 JSON Schema
|
||||||
params := cleanJSONSchema(inputSchema)
|
// 1. 深度清理 [undefined] 值
|
||||||
|
DeepCleanUndefined(inputSchema)
|
||||||
|
// 2. 转换为符合 Gemini v1internal 的 schema
|
||||||
|
params := CleanJSONSchema(inputSchema)
|
||||||
// 为 nil schema 提供默认值
|
// 为 nil schema 提供默认值
|
||||||
if params == nil {
|
if params == nil {
|
||||||
params = map[string]any{
|
params = map[string]any{
|
||||||
"type": "OBJECT",
|
"type": "object", // lowercase type
|
||||||
"properties": map[string]any{},
|
"properties": map[string]any{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,236 +632,3 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
|
|||||||
FunctionDeclarations: funcDecls,
|
FunctionDeclarations: funcDecls,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanJSONSchema 清理 JSON Schema,移除 Antigravity/Gemini 不支持的字段
|
|
||||||
// 参考 proxycast 的实现,确保 schema 符合 JSON Schema draft 2020-12
|
|
||||||
func cleanJSONSchema(schema map[string]any) map[string]any {
|
|
||||||
if schema == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cleaned := cleanSchemaValue(schema, "$")
|
|
||||||
result, ok := cleaned.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保有 type 字段(默认 OBJECT)
|
|
||||||
if _, hasType := result["type"]; !hasType {
|
|
||||||
result["type"] = "OBJECT"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保有 properties 字段(默认空对象)
|
|
||||||
if _, hasProps := result["properties"]; !hasProps {
|
|
||||||
result["properties"] = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证 required 中的字段都存在于 properties 中
|
|
||||||
if required, ok := result["required"].([]any); ok {
|
|
||||||
if props, ok := result["properties"].(map[string]any); ok {
|
|
||||||
validRequired := make([]any, 0, len(required))
|
|
||||||
for _, r := range required {
|
|
||||||
if reqName, ok := r.(string); ok {
|
|
||||||
if _, exists := props[reqName]; exists {
|
|
||||||
validRequired = append(validRequired, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(validRequired) > 0 {
|
|
||||||
result["required"] = validRequired
|
|
||||||
} else {
|
|
||||||
delete(result, "required")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
var schemaValidationKeys = map[string]bool{
|
|
||||||
"minLength": true,
|
|
||||||
"maxLength": true,
|
|
||||||
"pattern": true,
|
|
||||||
"minimum": true,
|
|
||||||
"maximum": true,
|
|
||||||
"exclusiveMinimum": true,
|
|
||||||
"exclusiveMaximum": true,
|
|
||||||
"multipleOf": true,
|
|
||||||
"uniqueItems": true,
|
|
||||||
"minItems": true,
|
|
||||||
"maxItems": true,
|
|
||||||
"minProperties": true,
|
|
||||||
"maxProperties": true,
|
|
||||||
"patternProperties": true,
|
|
||||||
"propertyNames": true,
|
|
||||||
"dependencies": true,
|
|
||||||
"dependentSchemas": true,
|
|
||||||
"dependentRequired": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var warnedSchemaKeys sync.Map
|
|
||||||
|
|
||||||
func schemaCleaningWarningsEnabled() bool {
|
|
||||||
// 可通过环境变量强制开关,方便排查:SUB2API_SCHEMA_CLEAN_WARN=true/false
|
|
||||||
if v := strings.TrimSpace(os.Getenv("SUB2API_SCHEMA_CLEAN_WARN")); v != "" {
|
|
||||||
switch strings.ToLower(v) {
|
|
||||||
case "1", "true", "yes", "on":
|
|
||||||
return true
|
|
||||||
case "0", "false", "no", "off":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 默认:非 release 模式下输出(debug/test)
|
|
||||||
return gin.Mode() != gin.ReleaseMode
|
|
||||||
}
|
|
||||||
|
|
||||||
func warnSchemaKeyRemovedOnce(key, path string) {
|
|
||||||
if !schemaCleaningWarningsEnabled() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !schemaValidationKeys[key] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, loaded := warnedSchemaKeys.LoadOrStore(key, struct{}{}); loaded {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("[SchemaClean] removed unsupported JSON Schema validation field key=%q path=%q", key, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// excludedSchemaKeys 不支持的 schema 字段
|
|
||||||
// 基于 Claude API (Vertex AI) 的实际支持情况
|
|
||||||
// 支持: type, description, enum, properties, required, additionalProperties, items
|
|
||||||
// 不支持: minItems, maxItems, minLength, maxLength, pattern, minimum, maximum 等验证字段
|
|
||||||
var excludedSchemaKeys = map[string]bool{
|
|
||||||
// 元 schema 字段
|
|
||||||
"$schema": true,
|
|
||||||
"$id": true,
|
|
||||||
"$ref": true,
|
|
||||||
|
|
||||||
// 字符串验证(Gemini 不支持)
|
|
||||||
"minLength": true,
|
|
||||||
"maxLength": true,
|
|
||||||
"pattern": true,
|
|
||||||
|
|
||||||
// 数字验证(Claude API 通过 Vertex AI 不支持这些字段)
|
|
||||||
"minimum": true,
|
|
||||||
"maximum": true,
|
|
||||||
"exclusiveMinimum": true,
|
|
||||||
"exclusiveMaximum": true,
|
|
||||||
"multipleOf": true,
|
|
||||||
|
|
||||||
// 数组验证(Claude API 通过 Vertex AI 不支持这些字段)
|
|
||||||
"uniqueItems": true,
|
|
||||||
"minItems": true,
|
|
||||||
"maxItems": true,
|
|
||||||
|
|
||||||
// 组合 schema(Gemini 不支持)
|
|
||||||
"oneOf": true,
|
|
||||||
"anyOf": true,
|
|
||||||
"allOf": true,
|
|
||||||
"not": true,
|
|
||||||
"if": true,
|
|
||||||
"then": true,
|
|
||||||
"else": true,
|
|
||||||
"$defs": true,
|
|
||||||
"definitions": true,
|
|
||||||
|
|
||||||
// 对象验证(仅保留 properties/required/additionalProperties)
|
|
||||||
"minProperties": true,
|
|
||||||
"maxProperties": true,
|
|
||||||
"patternProperties": true,
|
|
||||||
"propertyNames": true,
|
|
||||||
"dependencies": true,
|
|
||||||
"dependentSchemas": true,
|
|
||||||
"dependentRequired": true,
|
|
||||||
|
|
||||||
// 其他不支持的字段
|
|
||||||
"default": true,
|
|
||||||
"const": true,
|
|
||||||
"examples": true,
|
|
||||||
"deprecated": true,
|
|
||||||
"readOnly": true,
|
|
||||||
"writeOnly": true,
|
|
||||||
"contentMediaType": true,
|
|
||||||
"contentEncoding": true,
|
|
||||||
|
|
||||||
// Claude 特有字段
|
|
||||||
"strict": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanSchemaValue 递归清理 schema 值
|
|
||||||
func cleanSchemaValue(value any, path string) any {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
result := make(map[string]any)
|
|
||||||
for k, val := range v {
|
|
||||||
// 跳过不支持的字段
|
|
||||||
if excludedSchemaKeys[k] {
|
|
||||||
warnSchemaKeyRemovedOnce(k, path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理 type 字段
|
|
||||||
if k == "type" {
|
|
||||||
result[k] = cleanTypeValue(val)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理 format 字段:只保留 Gemini 支持的 format 值
|
|
||||||
if k == "format" {
|
|
||||||
if formatStr, ok := val.(string); ok {
|
|
||||||
// Gemini 只支持 date-time, date, time
|
|
||||||
if formatStr == "date-time" || formatStr == "date" || formatStr == "time" {
|
|
||||||
result[k] = val
|
|
||||||
}
|
|
||||||
// 其他 format 值直接跳过
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊处理 additionalProperties:Claude API 只支持布尔值,不支持 schema 对象
|
|
||||||
if k == "additionalProperties" {
|
|
||||||
if boolVal, ok := val.(bool); ok {
|
|
||||||
result[k] = boolVal
|
|
||||||
} else {
|
|
||||||
// 如果是 schema 对象,转换为 false(更安全的默认值)
|
|
||||||
result[k] = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归清理所有值
|
|
||||||
result[k] = cleanSchemaValue(val, path+"."+k)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
|
|
||||||
case []any:
|
|
||||||
// 递归处理数组中的每个元素
|
|
||||||
cleaned := make([]any, 0, len(v))
|
|
||||||
for i, item := range v {
|
|
||||||
cleaned = append(cleaned, cleanSchemaValue(item, fmt.Sprintf("%s[%d]", path, i)))
|
|
||||||
}
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanTypeValue 处理 type 字段,转换为大写
|
|
||||||
func cleanTypeValue(value any) any {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case string:
|
|
||||||
return strings.ToUpper(v)
|
|
||||||
case []any:
|
|
||||||
// 联合类型 ["string", "null"] -> 取第一个非 null 类型
|
|
||||||
for _, t := range v {
|
|
||||||
if ts, ok := t.(string); ok && ts != "null" {
|
|
||||||
return strings.ToUpper(ts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 如果只有 null,返回 STRING
|
|
||||||
return "STRING"
|
|
||||||
default:
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package antigravity
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +20,15 @@ func TransformGeminiToClaude(geminiResp []byte, originalModel string) ([]byte, *
|
|||||||
v1Resp.Response = directResp
|
v1Resp.Response = directResp
|
||||||
v1Resp.ResponseID = directResp.ResponseID
|
v1Resp.ResponseID = directResp.ResponseID
|
||||||
v1Resp.ModelVersion = directResp.ModelVersion
|
v1Resp.ModelVersion = directResp.ModelVersion
|
||||||
|
} else if len(v1Resp.Response.Candidates) == 0 {
|
||||||
|
// 第一次解析成功但 candidates 为空,说明是直接的 GeminiResponse 格式
|
||||||
|
var directResp GeminiResponse
|
||||||
|
if err2 := json.Unmarshal(geminiResp, &directResp); err2 != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse gemini response as direct: %w", err2)
|
||||||
|
}
|
||||||
|
v1Resp.Response = directResp
|
||||||
|
v1Resp.ResponseID = directResp.ResponseID
|
||||||
|
v1Resp.ModelVersion = directResp.ModelVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用处理器转换
|
// 使用处理器转换
|
||||||
@@ -173,16 +183,20 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) {
|
|||||||
p.trailingSignature = ""
|
p.trailingSignature = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
p.textBuilder += part.Text
|
// 非空 text 带签名 - 特殊处理:先输出 text,再输出空 thinking 块
|
||||||
|
|
||||||
// 非空 text 带签名 - 立即刷新并输出空 thinking 块
|
|
||||||
if signature != "" {
|
if signature != "" {
|
||||||
p.flushText()
|
p.contentBlocks = append(p.contentBlocks, ClaudeContentItem{
|
||||||
|
Type: "text",
|
||||||
|
Text: part.Text,
|
||||||
|
})
|
||||||
p.contentBlocks = append(p.contentBlocks, ClaudeContentItem{
|
p.contentBlocks = append(p.contentBlocks, ClaudeContentItem{
|
||||||
Type: "thinking",
|
Type: "thinking",
|
||||||
Thinking: "",
|
Thinking: "",
|
||||||
Signature: signature,
|
Signature: signature,
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// 普通 text (无签名) - 累积到 builder
|
||||||
|
p.textBuilder += part.Text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,6 +256,14 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
|
|||||||
var finishReason string
|
var finishReason string
|
||||||
if len(geminiResp.Candidates) > 0 {
|
if len(geminiResp.Candidates) > 0 {
|
||||||
finishReason = geminiResp.Candidates[0].FinishReason
|
finishReason = geminiResp.Candidates[0].FinishReason
|
||||||
|
if finishReason == "MALFORMED_FUNCTION_CALL" {
|
||||||
|
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in response for model %s", originalModel)
|
||||||
|
if geminiResp.Candidates[0].Content != nil {
|
||||||
|
if b, err := json.Marshal(geminiResp.Candidates[0].Content); err == nil {
|
||||||
|
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopReason := "end_turn"
|
stopReason := "end_turn"
|
||||||
|
|||||||
519
backend/internal/pkg/antigravity/schema_cleaner.go
Normal file
519
backend/internal/pkg/antigravity/schema_cleaner.go
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
package antigravity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CleanJSONSchema 清理 JSON Schema,移除 Antigravity/Gemini 不支持的字段
|
||||||
|
// 参考 Antigravity-Manager/src-tauri/src/proxy/common/json_schema.rs 实现
|
||||||
|
// 确保 schema 符合 JSON Schema draft 2020-12 且适配 Gemini v1internal
|
||||||
|
func CleanJSONSchema(schema map[string]any) map[string]any {
|
||||||
|
if schema == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 0. 预处理:展开 $ref (Schema Flattening)
|
||||||
|
// (Go map 是引用的,直接修改 schema)
|
||||||
|
flattenRefs(schema, extractDefs(schema))
|
||||||
|
|
||||||
|
// 递归清理
|
||||||
|
cleaned := cleanJSONSchemaRecursive(schema)
|
||||||
|
result, ok := cleaned.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDefs 提取并移除定义的 helper
|
||||||
|
func extractDefs(schema map[string]any) map[string]any {
|
||||||
|
defs := make(map[string]any)
|
||||||
|
if d, ok := schema["$defs"].(map[string]any); ok {
|
||||||
|
for k, v := range d {
|
||||||
|
defs[k] = v
|
||||||
|
}
|
||||||
|
delete(schema, "$defs")
|
||||||
|
}
|
||||||
|
if d, ok := schema["definitions"].(map[string]any); ok {
|
||||||
|
for k, v := range d {
|
||||||
|
defs[k] = v
|
||||||
|
}
|
||||||
|
delete(schema, "definitions")
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenRefs 递归展开 $ref
|
||||||
|
func flattenRefs(schema map[string]any, defs map[string]any) {
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return // 无需展开
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并替换 $ref
|
||||||
|
if ref, ok := schema["$ref"].(string); ok {
|
||||||
|
delete(schema, "$ref")
|
||||||
|
// 解析引用名 (例如 #/$defs/MyType -> MyType)
|
||||||
|
parts := strings.Split(ref, "/")
|
||||||
|
refName := parts[len(parts)-1]
|
||||||
|
|
||||||
|
if defSchema, exists := defs[refName]; exists {
|
||||||
|
if defMap, ok := defSchema.(map[string]any); ok {
|
||||||
|
// 合并定义内容 (不覆盖现有 key)
|
||||||
|
for k, v := range defMap {
|
||||||
|
if _, has := schema[k]; !has {
|
||||||
|
schema[k] = deepCopy(v) // 需深拷贝避免共享引用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 递归处理刚刚合并进来的内容
|
||||||
|
flattenRefs(schema, defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历子节点
|
||||||
|
for _, v := range schema {
|
||||||
|
if subMap, ok := v.(map[string]any); ok {
|
||||||
|
flattenRefs(subMap, defs)
|
||||||
|
} else if subArr, ok := v.([]any); ok {
|
||||||
|
for _, item := range subArr {
|
||||||
|
if itemMap, ok := item.(map[string]any); ok {
|
||||||
|
flattenRefs(itemMap, defs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepCopy 深拷贝 (简单实现,仅针对 JSON 类型)
|
||||||
|
func deepCopy(src any) any {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := src.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
dst := make(map[string]any)
|
||||||
|
for k, val := range v {
|
||||||
|
dst[k] = deepCopy(val)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
case []any:
|
||||||
|
dst := make([]any, len(v))
|
||||||
|
for i, val := range v {
|
||||||
|
dst[i] = deepCopy(val)
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
default:
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanJSONSchemaRecursive 递归核心清理逻辑
|
||||||
|
// 返回处理后的值 (通常是 input map,但可能修改内部结构)
|
||||||
|
func cleanJSONSchemaRecursive(value any) any {
|
||||||
|
schemaMap, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. [NEW] 合并 allOf
|
||||||
|
mergeAllOf(schemaMap)
|
||||||
|
|
||||||
|
// 1. [CRITICAL] 深度递归处理子项
|
||||||
|
if props, ok := schemaMap["properties"].(map[string]any); ok {
|
||||||
|
for _, v := range props {
|
||||||
|
cleanJSONSchemaRecursive(v)
|
||||||
|
}
|
||||||
|
// Go 中不需要像 Rust 那样显式处理 nullable_keys remove required,
|
||||||
|
// 因为我们在子项处理中会正确设置 type 和 description
|
||||||
|
} else if items, ok := schemaMap["items"]; ok {
|
||||||
|
// [FIX] Gemini 期望 "items" 是单个 Schema 对象(列表验证),而不是数组(元组验证)。
|
||||||
|
if itemsArr, ok := items.([]any); ok {
|
||||||
|
// 策略:将元组 [A, B] 视为 A、B 中的最佳匹配项。
|
||||||
|
best := extractBestSchemaFromUnion(itemsArr)
|
||||||
|
if best == nil {
|
||||||
|
// 回退到通用字符串
|
||||||
|
best = map[string]any{"type": "string"}
|
||||||
|
}
|
||||||
|
// 用处理后的对象替换原有数组
|
||||||
|
cleanedBest := cleanJSONSchemaRecursive(best)
|
||||||
|
schemaMap["items"] = cleanedBest
|
||||||
|
} else {
|
||||||
|
cleanJSONSchemaRecursive(items)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 遍历所有值递归
|
||||||
|
for _, v := range schemaMap {
|
||||||
|
if _, isMap := v.(map[string]any); isMap {
|
||||||
|
cleanJSONSchemaRecursive(v)
|
||||||
|
} else if arr, isArr := v.([]any); isArr {
|
||||||
|
for _, item := range arr {
|
||||||
|
cleanJSONSchemaRecursive(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. [FIX] 处理 anyOf/oneOf 联合类型: 合并属性而非直接删除
|
||||||
|
var unionArray []any
|
||||||
|
typeStr, _ := schemaMap["type"].(string)
|
||||||
|
if typeStr == "" || typeStr == "object" {
|
||||||
|
if anyOf, ok := schemaMap["anyOf"].([]any); ok {
|
||||||
|
unionArray = anyOf
|
||||||
|
} else if oneOf, ok := schemaMap["oneOf"].([]any); ok {
|
||||||
|
unionArray = oneOf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unionArray) > 0 {
|
||||||
|
if bestBranch := extractBestSchemaFromUnion(unionArray); bestBranch != nil {
|
||||||
|
if bestMap, ok := bestBranch.(map[string]any); ok {
|
||||||
|
// 合并分支内容
|
||||||
|
for k, v := range bestMap {
|
||||||
|
if k == "properties" {
|
||||||
|
targetProps, _ := schemaMap["properties"].(map[string]any)
|
||||||
|
if targetProps == nil {
|
||||||
|
targetProps = make(map[string]any)
|
||||||
|
schemaMap["properties"] = targetProps
|
||||||
|
}
|
||||||
|
if sourceProps, ok := v.(map[string]any); ok {
|
||||||
|
for pk, pv := range sourceProps {
|
||||||
|
if _, exists := targetProps[pk]; !exists {
|
||||||
|
targetProps[pk] = deepCopy(pv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if k == "required" {
|
||||||
|
targetReq, _ := schemaMap["required"].([]any)
|
||||||
|
if sourceReq, ok := v.([]any); ok {
|
||||||
|
for _, rv := range sourceReq {
|
||||||
|
// 简单的去重添加
|
||||||
|
exists := false
|
||||||
|
for _, tr := range targetReq {
|
||||||
|
if tr == rv {
|
||||||
|
exists = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
targetReq = append(targetReq, rv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schemaMap["required"] = targetReq
|
||||||
|
}
|
||||||
|
} else if _, exists := schemaMap[k]; !exists {
|
||||||
|
schemaMap[k] = deepCopy(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. [SAFETY] 检查当前对象是否为 JSON Schema 节点
|
||||||
|
looksLikeSchema := hasKey(schemaMap, "type") ||
|
||||||
|
hasKey(schemaMap, "properties") ||
|
||||||
|
hasKey(schemaMap, "items") ||
|
||||||
|
hasKey(schemaMap, "enum") ||
|
||||||
|
hasKey(schemaMap, "anyOf") ||
|
||||||
|
hasKey(schemaMap, "oneOf") ||
|
||||||
|
hasKey(schemaMap, "allOf")
|
||||||
|
|
||||||
|
if looksLikeSchema {
|
||||||
|
// 4. [ROBUST] 约束迁移
|
||||||
|
migrateConstraints(schemaMap)
|
||||||
|
|
||||||
|
// 5. [CRITICAL] 白名单过滤
|
||||||
|
allowedFields := map[string]bool{
|
||||||
|
"type": true,
|
||||||
|
"description": true,
|
||||||
|
"properties": true,
|
||||||
|
"required": true,
|
||||||
|
"items": true,
|
||||||
|
"enum": true,
|
||||||
|
"title": true,
|
||||||
|
}
|
||||||
|
for k := range schemaMap {
|
||||||
|
if !allowedFields[k] {
|
||||||
|
delete(schemaMap, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. [SAFETY] 处理空 Object
|
||||||
|
if t, _ := schemaMap["type"].(string); t == "object" {
|
||||||
|
hasProps := false
|
||||||
|
if props, ok := schemaMap["properties"].(map[string]any); ok && len(props) > 0 {
|
||||||
|
hasProps = true
|
||||||
|
}
|
||||||
|
if !hasProps {
|
||||||
|
schemaMap["properties"] = map[string]any{
|
||||||
|
"reason": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reason for calling this tool",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
schemaMap["required"] = []any{"reason"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. [SAFETY] Required 字段对齐
|
||||||
|
if props, ok := schemaMap["properties"].(map[string]any); ok {
|
||||||
|
if req, ok := schemaMap["required"].([]any); ok {
|
||||||
|
var validReq []any
|
||||||
|
for _, r := range req {
|
||||||
|
if rStr, ok := r.(string); ok {
|
||||||
|
if _, exists := props[rStr]; exists {
|
||||||
|
validReq = append(validReq, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(validReq) > 0 {
|
||||||
|
schemaMap["required"] = validReq
|
||||||
|
} else {
|
||||||
|
delete(schemaMap, "required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 处理 type 字段 (Lowercase + Nullable 提取)
|
||||||
|
isEffectivelyNullable := false
|
||||||
|
if typeVal, exists := schemaMap["type"]; exists {
|
||||||
|
var selectedType string
|
||||||
|
switch v := typeVal.(type) {
|
||||||
|
case string:
|
||||||
|
lower := strings.ToLower(v)
|
||||||
|
if lower == "null" {
|
||||||
|
isEffectivelyNullable = true
|
||||||
|
selectedType = "string" // fallback
|
||||||
|
} else {
|
||||||
|
selectedType = lower
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
// ["string", "null"]
|
||||||
|
for _, t := range v {
|
||||||
|
if ts, ok := t.(string); ok {
|
||||||
|
lower := strings.ToLower(ts)
|
||||||
|
if lower == "null" {
|
||||||
|
isEffectivelyNullable = true
|
||||||
|
} else if selectedType == "" {
|
||||||
|
selectedType = lower
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedType == "" {
|
||||||
|
selectedType = "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
schemaMap["type"] = selectedType
|
||||||
|
} else {
|
||||||
|
// 默认 object 如果有 properties (虽然上面白名单过滤可能删了 type 如果它不在... 但 type 必在 allowlist)
|
||||||
|
// 如果没有 type,但有 properties,补一个
|
||||||
|
if hasKey(schemaMap, "properties") {
|
||||||
|
schemaMap["type"] = "object"
|
||||||
|
} else {
|
||||||
|
// 默认为 string ? or object? Gemini 通常需要明确 type
|
||||||
|
schemaMap["type"] = "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEffectivelyNullable {
|
||||||
|
desc, _ := schemaMap["description"].(string)
|
||||||
|
if !strings.Contains(desc, "nullable") {
|
||||||
|
if desc != "" {
|
||||||
|
desc += " "
|
||||||
|
}
|
||||||
|
desc += "(nullable)"
|
||||||
|
schemaMap["description"] = desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Enum 值强制转字符串
|
||||||
|
if enumVals, ok := schemaMap["enum"].([]any); ok {
|
||||||
|
hasNonString := false
|
||||||
|
for i, val := range enumVals {
|
||||||
|
if _, isStr := val.(string); !isStr {
|
||||||
|
hasNonString = true
|
||||||
|
if val == nil {
|
||||||
|
enumVals[i] = "null"
|
||||||
|
} else {
|
||||||
|
enumVals[i] = fmt.Sprintf("%v", val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If we mandated string values, we must ensure type is string
|
||||||
|
if hasNonString {
|
||||||
|
schemaMap["type"] = "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schemaMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasKey(m map[string]any, k string) bool {
|
||||||
|
_, ok := m[k]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateConstraints(m map[string]any) {
|
||||||
|
constraints := []struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
}{
|
||||||
|
{"minLength", "minLen"},
|
||||||
|
{"maxLength", "maxLen"},
|
||||||
|
{"pattern", "pattern"},
|
||||||
|
{"minimum", "min"},
|
||||||
|
{"maximum", "max"},
|
||||||
|
{"multipleOf", "multipleOf"},
|
||||||
|
{"exclusiveMinimum", "exclMin"},
|
||||||
|
{"exclusiveMaximum", "exclMax"},
|
||||||
|
{"minItems", "minItems"},
|
||||||
|
{"maxItems", "maxItems"},
|
||||||
|
{"propertyNames", "propertyNames"},
|
||||||
|
{"format", "format"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var hints []string
|
||||||
|
for _, c := range constraints {
|
||||||
|
if val, ok := m[c.key]; ok && val != nil {
|
||||||
|
hints = append(hints, fmt.Sprintf("%s: %v", c.label, val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(hints) > 0 {
|
||||||
|
suffix := fmt.Sprintf(" [Constraint: %s]", strings.Join(hints, ", "))
|
||||||
|
desc, _ := m["description"].(string)
|
||||||
|
if !strings.Contains(desc, suffix) {
|
||||||
|
m["description"] = desc + suffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAllOf 合并 allOf
|
||||||
|
func mergeAllOf(m map[string]any) {
|
||||||
|
allOf, ok := m["allOf"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(m, "allOf")
|
||||||
|
|
||||||
|
mergedProps := make(map[string]any)
|
||||||
|
mergedReq := make(map[string]bool)
|
||||||
|
otherFields := make(map[string]any)
|
||||||
|
|
||||||
|
for _, sub := range allOf {
|
||||||
|
if subMap, ok := sub.(map[string]any); ok {
|
||||||
|
// Props
|
||||||
|
if props, ok := subMap["properties"].(map[string]any); ok {
|
||||||
|
for k, v := range props {
|
||||||
|
mergedProps[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Required
|
||||||
|
if reqs, ok := subMap["required"].([]any); ok {
|
||||||
|
for _, r := range reqs {
|
||||||
|
if s, ok := r.(string); ok {
|
||||||
|
mergedReq[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Others
|
||||||
|
for k, v := range subMap {
|
||||||
|
if k != "properties" && k != "required" && k != "allOf" {
|
||||||
|
if _, exists := otherFields[k]; !exists {
|
||||||
|
otherFields[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
for k, v := range otherFields {
|
||||||
|
if _, exists := m[k]; !exists {
|
||||||
|
m[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mergedProps) > 0 {
|
||||||
|
existProps, _ := m["properties"].(map[string]any)
|
||||||
|
if existProps == nil {
|
||||||
|
existProps = make(map[string]any)
|
||||||
|
m["properties"] = existProps
|
||||||
|
}
|
||||||
|
for k, v := range mergedProps {
|
||||||
|
if _, exists := existProps[k]; !exists {
|
||||||
|
existProps[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mergedReq) > 0 {
|
||||||
|
existReq, _ := m["required"].([]any)
|
||||||
|
var validReqs []any
|
||||||
|
for _, r := range existReq {
|
||||||
|
if s, ok := r.(string); ok {
|
||||||
|
validReqs = append(validReqs, s)
|
||||||
|
delete(mergedReq, s) // already exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// append new
|
||||||
|
for r := range mergedReq {
|
||||||
|
validReqs = append(validReqs, r)
|
||||||
|
}
|
||||||
|
m["required"] = validReqs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBestSchemaFromUnion 从 anyOf/oneOf 中选取最佳分支
|
||||||
|
func extractBestSchemaFromUnion(unionArray []any) any {
|
||||||
|
var bestOption any
|
||||||
|
bestScore := -1
|
||||||
|
|
||||||
|
for _, item := range unionArray {
|
||||||
|
score := scoreSchemaOption(item)
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestOption = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreSchemaOption(val any) int {
|
||||||
|
m, ok := val.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
typeStr, _ := m["type"].(string)
|
||||||
|
|
||||||
|
if hasKey(m, "properties") || typeStr == "object" {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if hasKey(m, "items") || typeStr == "array" {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if typeStr != "" && typeStr != "null" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCleanUndefined 深度清理值为 "[undefined]" 的字段
|
||||||
|
func DeepCleanUndefined(value any) {
|
||||||
|
if value == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for k, val := range v {
|
||||||
|
if s, ok := val.(string); ok && s == "[undefined]" {
|
||||||
|
delete(v, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
DeepCleanUndefined(val)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, val := range v {
|
||||||
|
DeepCleanUndefined(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,6 +103,14 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
|
|||||||
// 检查是否结束
|
// 检查是否结束
|
||||||
if len(geminiResp.Candidates) > 0 {
|
if len(geminiResp.Candidates) > 0 {
|
||||||
finishReason := geminiResp.Candidates[0].FinishReason
|
finishReason := geminiResp.Candidates[0].FinishReason
|
||||||
|
if finishReason == "MALFORMED_FUNCTION_CALL" {
|
||||||
|
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in stream for model %s", p.originalModel)
|
||||||
|
if geminiResp.Candidates[0].Content != nil {
|
||||||
|
if b, err := json.Marshal(geminiResp.Candidates[0].Content); err == nil {
|
||||||
|
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if finishReason != "" {
|
if finishReason != "" {
|
||||||
_, _ = result.Write(p.emitFinish(finishReason))
|
_, _ = result.Write(p.emitFinish(finishReason))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,20 +193,20 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
// 普通用户订阅接口不应包含 assigned_* / notes 等管理员字段。
|
// 普通用户订阅接口不应包含 assigned_* / notes 等管理员字段。
|
||||||
deps.userSubRepo.SetByUserID(1, []service.UserSubscription{
|
deps.userSubRepo.SetByUserID(1, []service.UserSubscription{
|
||||||
{
|
{
|
||||||
ID: 501,
|
ID: 501,
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
GroupID: 10,
|
GroupID: 10,
|
||||||
StartsAt: deps.now,
|
StartsAt: deps.now,
|
||||||
ExpiresAt: deps.now.Add(24 * time.Hour),
|
ExpiresAt: deps.now.Add(24 * time.Hour),
|
||||||
Status: service.SubscriptionStatusActive,
|
Status: service.SubscriptionStatusActive,
|
||||||
DailyUsageUSD: 1.23,
|
DailyUsageUSD: 1.23,
|
||||||
WeeklyUsageUSD: 2.34,
|
WeeklyUsageUSD: 2.34,
|
||||||
MonthlyUsageUSD: 3.45,
|
MonthlyUsageUSD: 3.45,
|
||||||
AssignedBy: ptr(int64(999)),
|
AssignedBy: ptr(int64(999)),
|
||||||
AssignedAt: deps.now,
|
AssignedAt: deps.now,
|
||||||
Notes: "admin-note",
|
Notes: "admin-note",
|
||||||
CreatedAt: deps.now,
|
CreatedAt: deps.now,
|
||||||
UpdatedAt: deps.now,
|
UpdatedAt: deps.now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -197,6 +197,35 @@ func (a *Account) GetCredentialAsTime(key string) *time.Time {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCredentialAsInt64 解析凭证中的 int64 字段
|
||||||
|
// 用于读取 _token_version 等内部字段
|
||||||
|
func (a *Account) GetCredentialAsInt64(key string) int64 {
|
||||||
|
if a == nil || a.Credentials == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
val, ok := a.Credentials[key]
|
||||||
|
if !ok || val == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch v := val.(type) {
|
||||||
|
case int64:
|
||||||
|
return v
|
||||||
|
case float64:
|
||||||
|
return int64(v)
|
||||||
|
case int:
|
||||||
|
return int64(v)
|
||||||
|
case json.Number:
|
||||||
|
if i, err := v.Int64(); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if i, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Account) IsTempUnschedulableEnabled() bool {
|
func (a *Account) IsTempUnschedulableEnabled() bool {
|
||||||
if a.Credentials == nil {
|
if a.Credentials == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1305,6 +1305,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理 Schema
|
||||||
|
if cleanedBody, err := cleanGeminiRequest(injectedBody); err == nil {
|
||||||
|
injectedBody = cleanedBody
|
||||||
|
log.Printf("[Antigravity] Cleaned request schema in forwarded request for account %s", account.Name)
|
||||||
|
} else {
|
||||||
|
log.Printf("[Antigravity] Failed to clean schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 包装请求
|
// 包装请求
|
||||||
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody)
|
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1705,6 +1713,19 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
|
|||||||
if u := extractGeminiUsage(parsed); u != nil {
|
if u := extractGeminiUsage(parsed); u != nil {
|
||||||
usage = u
|
usage = u
|
||||||
}
|
}
|
||||||
|
// Check for MALFORMED_FUNCTION_CALL
|
||||||
|
if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 {
|
||||||
|
if cand, ok := candidates[0].(map[string]any); ok {
|
||||||
|
if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" {
|
||||||
|
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in forward stream")
|
||||||
|
if content, ok := cand["content"]; ok {
|
||||||
|
if b, err := json.Marshal(content); err == nil {
|
||||||
|
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if firstTokenMs == nil {
|
if firstTokenMs == nil {
|
||||||
@@ -1854,6 +1875,20 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
|
|||||||
usage = u
|
usage = u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for MALFORMED_FUNCTION_CALL
|
||||||
|
if candidates, ok := parsed["candidates"].([]any); ok && len(candidates) > 0 {
|
||||||
|
if cand, ok := candidates[0].(map[string]any); ok {
|
||||||
|
if fr, ok := cand["finishReason"].(string); ok && fr == "MALFORMED_FUNCTION_CALL" {
|
||||||
|
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in forward non-stream collect")
|
||||||
|
if content, ok := cand["content"]; ok {
|
||||||
|
if b, err := json.Marshal(content); err == nil {
|
||||||
|
log.Printf("[Antigravity] Malformed content: %s", string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保留最后一个有 parts 的响应
|
// 保留最后一个有 parts 的响应
|
||||||
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
||||||
lastWithParts = parsed
|
lastWithParts = parsed
|
||||||
@@ -1950,6 +1985,58 @@ func getOrCreateGeminiParts(response map[string]any) (result map[string]any, exi
|
|||||||
return result, existingParts, setParts
|
return result, existingParts, setParts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mergeCollectedPartsToResponse 将收集的所有 parts 合并到 Gemini 响应中
|
||||||
|
// 这个函数会合并所有类型的 parts:text、thinking、functionCall、inlineData 等
|
||||||
|
// 保持原始顺序,只合并连续的普通 text parts
|
||||||
|
func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map[string]any) map[string]any {
|
||||||
|
if len(collectedParts) == 0 {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
result, _, setParts := getOrCreateGeminiParts(response)
|
||||||
|
|
||||||
|
// 合并策略:
|
||||||
|
// 1. 保持原始顺序
|
||||||
|
// 2. 连续的普通 text parts 合并为一个
|
||||||
|
// 3. thinking、functionCall、inlineData 等保持原样
|
||||||
|
var mergedParts []any
|
||||||
|
var textBuffer strings.Builder
|
||||||
|
|
||||||
|
flushTextBuffer := func() {
|
||||||
|
if textBuffer.Len() > 0 {
|
||||||
|
mergedParts = append(mergedParts, map[string]any{
|
||||||
|
"text": textBuffer.String(),
|
||||||
|
})
|
||||||
|
textBuffer.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range collectedParts {
|
||||||
|
// 检查是否是普通 text part
|
||||||
|
if text, ok := part["text"].(string); ok {
|
||||||
|
// 检查是否有 thought 标记
|
||||||
|
if thought, _ := part["thought"].(bool); thought {
|
||||||
|
// thinking part,先刷新 text buffer,然后保留原样
|
||||||
|
flushTextBuffer()
|
||||||
|
mergedParts = append(mergedParts, part)
|
||||||
|
} else {
|
||||||
|
// 普通 text,累积到 buffer
|
||||||
|
_, _ = textBuffer.WriteString(text)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非 text part(functionCall、inlineData 等),先刷新 text buffer,然后保留原样
|
||||||
|
flushTextBuffer()
|
||||||
|
mergedParts = append(mergedParts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新剩余的 text
|
||||||
|
flushTextBuffer()
|
||||||
|
|
||||||
|
setParts(mergedParts)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中
|
// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中
|
||||||
func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any {
|
func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any {
|
||||||
if len(imageParts) == 0 {
|
if len(imageParts) == 0 {
|
||||||
@@ -2133,6 +2220,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
|||||||
var firstTokenMs *int
|
var firstTokenMs *int
|
||||||
var last map[string]any
|
var last map[string]any
|
||||||
var lastWithParts map[string]any
|
var lastWithParts map[string]any
|
||||||
|
var collectedParts []map[string]any // 收集所有 parts(包括 text、thinking、functionCall、inlineData 等)
|
||||||
|
|
||||||
type scanEvent struct {
|
type scanEvent struct {
|
||||||
line string
|
line string
|
||||||
@@ -2227,9 +2315,12 @@ func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Cont
|
|||||||
|
|
||||||
last = parsed
|
last = parsed
|
||||||
|
|
||||||
// 保留最后一个有 parts 的响应
|
// 保留最后一个有 parts 的响应,并收集所有 parts
|
||||||
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
if parts := extractGeminiParts(parsed); len(parts) > 0 {
|
||||||
lastWithParts = parsed
|
lastWithParts = parsed
|
||||||
|
|
||||||
|
// 收集所有 parts(text、thinking、functionCall、inlineData 等)
|
||||||
|
collectedParts = append(collectedParts, parts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
case <-intervalCh:
|
case <-intervalCh:
|
||||||
@@ -2252,6 +2343,11 @@ returnResponse:
|
|||||||
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Empty response from upstream")
|
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Empty response from upstream")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将收集的所有 parts 合并到最终响应中
|
||||||
|
if len(collectedParts) > 0 {
|
||||||
|
finalResponse = mergeCollectedPartsToResponse(finalResponse, collectedParts)
|
||||||
|
}
|
||||||
|
|
||||||
// 序列化为 JSON(Gemini 格式)
|
// 序列化为 JSON(Gemini 格式)
|
||||||
geminiBody, err := json.Marshal(finalResponse)
|
geminiBody, err := json.Marshal(finalResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2459,3 +2555,55 @@ func isImageGenerationModel(model string) bool {
|
|||||||
modelLower == "gemini-2.5-flash-image-preview" ||
|
modelLower == "gemini-2.5-flash-image-preview" ||
|
||||||
strings.HasPrefix(modelLower, "gemini-2.5-flash-image-")
|
strings.HasPrefix(modelLower, "gemini-2.5-flash-image-")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanGeminiRequest 清理 Gemini 请求体中的 Schema
|
||||||
|
func cleanGeminiRequest(body []byte) ([]byte, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := false
|
||||||
|
|
||||||
|
// 1. 清理 Tools
|
||||||
|
if tools, ok := payload["tools"].([]any); ok && len(tools) > 0 {
|
||||||
|
for _, t := range tools {
|
||||||
|
toolMap, ok := t.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// function_declarations (snake_case) or functionDeclarations (camelCase)
|
||||||
|
var funcs []any
|
||||||
|
if f, ok := toolMap["functionDeclarations"].([]any); ok {
|
||||||
|
funcs = f
|
||||||
|
} else if f, ok := toolMap["function_declarations"].([]any); ok {
|
||||||
|
funcs = f
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(funcs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range funcs {
|
||||||
|
funcMap, ok := f.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if params, ok := funcMap["parameters"].(map[string]any); ok {
|
||||||
|
antigravity.DeepCleanUndefined(params)
|
||||||
|
cleaned := antigravity.CleanJSONSchema(params)
|
||||||
|
funcMap["parameters"] = cleaned
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !modified {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(payload)
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,14 +94,14 @@ func TestAntigravityRetryLoop_URLFallback_UsesLatestSuccess(t *testing.T) {
|
|||||||
|
|
||||||
var handleErrorCalled bool
|
var handleErrorCalled bool
|
||||||
result, err := antigravityRetryLoop(antigravityRetryLoopParams{
|
result, err := antigravityRetryLoop(antigravityRetryLoopParams{
|
||||||
prefix: "[test]",
|
prefix: "[test]",
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
account: account,
|
account: account,
|
||||||
proxyURL: "",
|
proxyURL: "",
|
||||||
accessToken: "token",
|
accessToken: "token",
|
||||||
action: "generateContent",
|
action: "generateContent",
|
||||||
body: []byte(`{"input":"test"}`),
|
body: []byte(`{"input":"test"}`),
|
||||||
quotaScope: AntigravityQuotaScopeClaude,
|
quotaScope: AntigravityQuotaScopeClaude,
|
||||||
httpUpstream: upstream,
|
httpUpstream: upstream,
|
||||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope) {
|
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, quotaScope AntigravityQuotaScope) {
|
||||||
handleErrorCalled = true
|
handleErrorCalled = true
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
|
|||||||
return "", errors.New("access_token not found in credentials")
|
return "", errors.New("access_token not found in credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil {
|
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
||||||
ttl := 30 * time.Minute
|
ttl := 30 * time.Minute
|
||||||
if expiresAt != nil {
|
if expiresAt != nil {
|
||||||
until := time.Until(*expiresAt)
|
until := time.Until(*expiresAt)
|
||||||
|
|||||||
@@ -181,8 +181,8 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
return "", errors.New("access_token not found in credentials")
|
return "", errors.New("access_token not found in credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil {
|
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
||||||
ttl := 30 * time.Minute
|
ttl := 30 * time.Minute
|
||||||
if refreshFailed {
|
if refreshFailed {
|
||||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Populate cache with TTL.
|
// 3) Populate cache with TTL(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil {
|
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
||||||
ttl := 30 * time.Minute
|
ttl := 30 * time.Minute
|
||||||
if expiresAt != nil {
|
if expiresAt != nil {
|
||||||
until := time.Until(*expiresAt)
|
until := time.Until(*expiresAt)
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
|
|||||||
return "", errors.New("access_token not found in credentials")
|
return "", errors.New("access_token not found in credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 存入缓存
|
// 3. 存入缓存(验证版本后再写入,避免异步刷新任务与请求线程的竞态条件)
|
||||||
if p.tokenCache != nil {
|
if p.tokenCache != nil && !IsTokenVersionStale(ctx, account, p.accountRepo) {
|
||||||
ttl := 30 * time.Minute
|
ttl := 30 * time.Minute
|
||||||
if refreshFailed {
|
if refreshFailed {
|
||||||
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
// 刷新失败时使用短 TTL,避免失效 token 长时间缓存导致 401 抖动
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
type TokenCacheInvalidator interface {
|
type TokenCacheInvalidator interface {
|
||||||
InvalidateToken(ctx context.Context, account *Account) error
|
InvalidateToken(ctx context.Context, account *Account) error
|
||||||
@@ -24,18 +28,85 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var cacheKey string
|
var keysToDelete []string
|
||||||
|
accountIDKey := "account:" + strconv.FormatInt(account.ID, 10)
|
||||||
|
|
||||||
switch account.Platform {
|
switch account.Platform {
|
||||||
case PlatformGemini:
|
case PlatformGemini:
|
||||||
cacheKey = GeminiTokenCacheKey(account)
|
// Gemini 可能有两种缓存键:project_id 或 account_id
|
||||||
|
// 首次获取 token 时可能没有 project_id,之后自动检测到 project_id 后会使用新 key
|
||||||
|
// 刷新时需要同时删除两种可能的 key,确保不会遗留旧缓存
|
||||||
|
keysToDelete = append(keysToDelete, GeminiTokenCacheKey(account))
|
||||||
|
keysToDelete = append(keysToDelete, "gemini:"+accountIDKey)
|
||||||
case PlatformAntigravity:
|
case PlatformAntigravity:
|
||||||
cacheKey = AntigravityTokenCacheKey(account)
|
// Antigravity 同样可能有两种缓存键
|
||||||
|
keysToDelete = append(keysToDelete, AntigravityTokenCacheKey(account))
|
||||||
|
keysToDelete = append(keysToDelete, "ag:"+accountIDKey)
|
||||||
case PlatformOpenAI:
|
case PlatformOpenAI:
|
||||||
cacheKey = OpenAITokenCacheKey(account)
|
keysToDelete = append(keysToDelete, OpenAITokenCacheKey(account))
|
||||||
case PlatformAnthropic:
|
case PlatformAnthropic:
|
||||||
cacheKey = ClaudeTokenCacheKey(account)
|
keysToDelete = append(keysToDelete, ClaudeTokenCacheKey(account))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return c.cache.DeleteAccessToken(ctx, cacheKey)
|
|
||||||
|
// 删除所有可能的缓存键(去重后)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, key := range keysToDelete {
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if err := c.cache.DeleteAccessToken(ctx, key); err != nil {
|
||||||
|
slog.Warn("token_cache_delete_failed", "key", key, "account_id", account.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTokenVersionStale 检查 account 的 token 版本是否已过时
|
||||||
|
// 用于解决异步刷新任务与请求线程的竞态条件:
|
||||||
|
// 如果刷新任务已更新 token 并删除缓存,此时请求线程的旧 account 对象不应写入缓存
|
||||||
|
//
|
||||||
|
// 返回 true 表示 token 已过时(不应缓存),false 表示可以缓存
|
||||||
|
func IsTokenVersionStale(ctx context.Context, account *Account, repo AccountRepository) bool {
|
||||||
|
if account == nil || repo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVersion := account.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
|
latestAccount, err := repo.GetByID(ctx, account.ID)
|
||||||
|
if err != nil || latestAccount == nil {
|
||||||
|
// 查询失败,默认允许缓存
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
latestVersion := latestAccount.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
|
// 情况1: 当前 account 没有版本号,但 DB 中已有版本号
|
||||||
|
// 说明异步刷新任务已更新 token,当前 account 已过时
|
||||||
|
if currentVersion == 0 && latestVersion > 0 {
|
||||||
|
slog.Debug("token_version_stale_no_current_version",
|
||||||
|
"account_id", account.ID,
|
||||||
|
"latest_version", latestVersion)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况2: 两边都没有版本号,说明从未被异步刷新过,允许缓存
|
||||||
|
if currentVersion == 0 && latestVersion == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况3: 比较版本号,如果 DB 中的版本更新,当前 account 已过时
|
||||||
|
if latestVersion > currentVersion {
|
||||||
|
slog.Debug("token_version_stale",
|
||||||
|
"account_id", account.ID,
|
||||||
|
"current_version", currentVersion,
|
||||||
|
"latest_version", latestVersion)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,27 @@ func TestCompositeTokenCacheInvalidator_Gemini(t *testing.T) {
|
|||||||
|
|
||||||
err := invalidator.InvalidateToken(context.Background(), account)
|
err := invalidator.InvalidateToken(context.Background(), account)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"gemini:project-x"}, cache.deletedKeys)
|
// 新行为:同时删除基于 project_id 和 account_id 的缓存键
|
||||||
|
// 这是为了处理:首次获取 token 时可能没有 project_id,之后自动检测到后会使用新 key
|
||||||
|
require.Equal(t, []string{"gemini:project-x", "gemini:account:10"}, cache.deletedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeTokenCacheInvalidator_GeminiWithoutProjectID(t *testing.T) {
|
||||||
|
cache := &geminiTokenCacheStub{}
|
||||||
|
invalidator := NewCompositeTokenCacheInvalidator(cache)
|
||||||
|
account := &Account{
|
||||||
|
ID: 10,
|
||||||
|
Platform: PlatformGemini,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "gemini-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := invalidator.InvalidateToken(context.Background(), account)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// 没有 project_id 时,两个 key 相同,去重后只删除一个
|
||||||
|
require.Equal(t, []string{"gemini:account:10"}, cache.deletedKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompositeTokenCacheInvalidator_Antigravity(t *testing.T) {
|
func TestCompositeTokenCacheInvalidator_Antigravity(t *testing.T) {
|
||||||
@@ -68,7 +88,26 @@ func TestCompositeTokenCacheInvalidator_Antigravity(t *testing.T) {
|
|||||||
|
|
||||||
err := invalidator.InvalidateToken(context.Background(), account)
|
err := invalidator.InvalidateToken(context.Background(), account)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, []string{"ag:ag-project"}, cache.deletedKeys)
|
// 新行为:同时删除基于 project_id 和 account_id 的缓存键
|
||||||
|
require.Equal(t, []string{"ag:ag-project", "ag:account:99"}, cache.deletedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompositeTokenCacheInvalidator_AntigravityWithoutProjectID(t *testing.T) {
|
||||||
|
cache := &geminiTokenCacheStub{}
|
||||||
|
invalidator := NewCompositeTokenCacheInvalidator(cache)
|
||||||
|
account := &Account{
|
||||||
|
ID: 99,
|
||||||
|
Platform: PlatformAntigravity,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "ag-token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := invalidator.InvalidateToken(context.Background(), account)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// 没有 project_id 时,两个 key 相同,去重后只删除一个
|
||||||
|
require.Equal(t, []string{"ag:account:99"}, cache.deletedKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompositeTokenCacheInvalidator_OpenAI(t *testing.T) {
|
func TestCompositeTokenCacheInvalidator_OpenAI(t *testing.T) {
|
||||||
@@ -233,9 +272,10 @@ func TestCompositeTokenCacheInvalidator_DeleteError(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 新行为:删除失败只记录日志,不返回错误
|
||||||
|
// 这是因为缓存失效失败不应影响主业务流程
|
||||||
err := invalidator.InvalidateToken(context.Background(), tt.account)
|
err := invalidator.InvalidateToken(context.Background(), tt.account)
|
||||||
require.Error(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, expectedErr, err)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,9 +292,12 @@ func TestCompositeTokenCacheInvalidator_AllPlatformsIntegration(t *testing.T) {
|
|||||||
{ID: 4, Platform: PlatformAnthropic, Type: AccountTypeOAuth},
|
{ID: 4, Platform: PlatformAnthropic, Type: AccountTypeOAuth},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新行为:Gemini 和 Antigravity 会同时删除基于 project_id 和 account_id 的键
|
||||||
expectedKeys := []string{
|
expectedKeys := []string{
|
||||||
"gemini:gemini-proj",
|
"gemini:gemini-proj",
|
||||||
|
"gemini:account:1",
|
||||||
"ag:ag-proj",
|
"ag:ag-proj",
|
||||||
|
"ag:account:2",
|
||||||
"openai:account:3",
|
"openai:account:3",
|
||||||
"claude:account:4",
|
"claude:account:4",
|
||||||
}
|
}
|
||||||
@@ -266,3 +309,239 @@ func TestCompositeTokenCacheInvalidator_AllPlatformsIntegration(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, expectedKeys, cache.deletedKeys)
|
require.Equal(t, expectedKeys, cache.deletedKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== GetCredentialAsInt64 测试 ==========
|
||||||
|
|
||||||
|
func TestAccount_GetCredentialAsInt64(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
credentials map[string]any
|
||||||
|
key string
|
||||||
|
expected int64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "int64_value",
|
||||||
|
credentials: map[string]any{"_token_version": int64(1737654321000)},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 1737654321000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "float64_value",
|
||||||
|
credentials: map[string]any{"_token_version": float64(1737654321000)},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 1737654321000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "int_value",
|
||||||
|
credentials: map[string]any{"_token_version": 12345},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 12345,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string_value",
|
||||||
|
credentials: map[string]any{"_token_version": "1737654321000"},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 1737654321000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string_with_spaces",
|
||||||
|
credentials: map[string]any{"_token_version": " 1737654321000 "},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 1737654321000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil_credentials",
|
||||||
|
credentials: nil,
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_key",
|
||||||
|
credentials: map[string]any{"other_key": 123},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil_value",
|
||||||
|
credentials: map[string]any{"_token_version": nil},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid_string",
|
||||||
|
credentials: map[string]any{"_token_version": "not_a_number"},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_string",
|
||||||
|
credentials: map[string]any{"_token_version": ""},
|
||||||
|
key: "_token_version",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
account := &Account{Credentials: tt.credentials}
|
||||||
|
result := account.GetCredentialAsInt64(tt.key)
|
||||||
|
require.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_GetCredentialAsInt64_NilAccount(t *testing.T) {
|
||||||
|
var account *Account
|
||||||
|
result := account.GetCredentialAsInt64("_token_version")
|
||||||
|
require.Equal(t, int64(0), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== IsTokenVersionStale 测试 ==========
|
||||||
|
|
||||||
|
func TestIsTokenVersionStale(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
account *Account
|
||||||
|
latestAccount *Account
|
||||||
|
repoErr error
|
||||||
|
expectedStale bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil_account",
|
||||||
|
account: nil,
|
||||||
|
latestAccount: nil,
|
||||||
|
expectedStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no_version_in_account_but_db_has_version",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
expectedStale: true, // 当前 account 无版本但 DB 有,说明已被异步刷新,当前已过时
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both_no_version",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{},
|
||||||
|
},
|
||||||
|
expectedStale: false, // 两边都没有版本号,说明从未被异步刷新过,允许缓存
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same_version",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
expectedStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current_version_newer",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(200)},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
expectedStale: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current_version_older_stale",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
latestAccount: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(200)},
|
||||||
|
},
|
||||||
|
expectedStale: true, // 当前版本过时
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repo_error",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
latestAccount: nil,
|
||||||
|
repoErr: errors.New("db error"),
|
||||||
|
expectedStale: false, // 查询失败,默认允许缓存
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repo_returns_nil",
|
||||||
|
account: &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
},
|
||||||
|
latestAccount: nil,
|
||||||
|
repoErr: nil,
|
||||||
|
expectedStale: false, // 查询返回 nil,默认允许缓存
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// 由于 IsTokenVersionStale 接受 AccountRepository 接口,而创建完整的 mock 很繁琐
|
||||||
|
// 这里我们直接测试函数的核心逻辑来验证行为
|
||||||
|
|
||||||
|
if tt.name == "nil_account" {
|
||||||
|
result := IsTokenVersionStale(context.Background(), nil, nil)
|
||||||
|
require.Equal(t, tt.expectedStale, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟 IsTokenVersionStale 的核心逻辑
|
||||||
|
account := tt.account
|
||||||
|
currentVersion := account.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
|
// 模拟 repo 查询
|
||||||
|
latestAccount := tt.latestAccount
|
||||||
|
if tt.repoErr != nil || latestAccount == nil {
|
||||||
|
require.Equal(t, tt.expectedStale, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
latestVersion := latestAccount.GetCredentialAsInt64("_token_version")
|
||||||
|
|
||||||
|
// 情况1: 当前 account 没有版本号,但 DB 中已有版本号
|
||||||
|
if currentVersion == 0 && latestVersion > 0 {
|
||||||
|
require.Equal(t, tt.expectedStale, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况2: 两边都没有版本号
|
||||||
|
if currentVersion == 0 && latestVersion == 0 {
|
||||||
|
require.Equal(t, tt.expectedStale, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 情况3: 比较版本号
|
||||||
|
isStale := latestVersion > currentVersion
|
||||||
|
require.Equal(t, tt.expectedStale, isStale)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTokenVersionStale_NilRepo(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
ID: 1,
|
||||||
|
Credentials: map[string]any{"_token_version": int64(100)},
|
||||||
|
}
|
||||||
|
result := IsTokenVersionStale(context.Background(), account, nil)
|
||||||
|
require.False(t, result) // nil repo,默认允许缓存
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
|
|||||||
|
|
||||||
// 如果有新凭证,先更新(即使有错误也要保存 token)
|
// 如果有新凭证,先更新(即使有错误也要保存 token)
|
||||||
if newCredentials != nil {
|
if newCredentials != nil {
|
||||||
|
// 记录刷新版本时间戳,用于解决缓存一致性问题
|
||||||
|
// TokenProvider 写入缓存前会检查此版本,如果版本已更新则跳过写入
|
||||||
|
newCredentials["_token_version"] = time.Now().UnixMilli()
|
||||||
|
|
||||||
account.Credentials = newCredentials
|
account.Credentials = newCredentials
|
||||||
if saveErr := s.accountRepo.Update(ctx, account); saveErr != nil {
|
if saveErr := s.accountRepo.Update(ctx, account); saveErr != nil {
|
||||||
return fmt.Errorf("failed to save credentials: %w", saveErr)
|
return fmt.Errorf("failed to save credentials: %w", saveErr)
|
||||||
|
|||||||
@@ -1,50 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
|
<div v-if="show && position">
|
||||||
<div class="py-1">
|
<!-- Backdrop: click anywhere outside to close -->
|
||||||
<template v-if="account">
|
<div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
|
||||||
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
<div
|
||||||
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800"
|
||||||
{{ t('admin.accounts.testConnection') }}
|
:style="{ top: position.top + 'px', left: position.left + 'px' }"
|
||||||
</button>
|
@click.stop
|
||||||
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
>
|
||||||
<Icon name="chart" size="sm" class="text-indigo-500" />
|
<div class="py-1">
|
||||||
{{ t('admin.accounts.viewStats') }}
|
<template v-if="account">
|
||||||
</button>
|
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
||||||
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
{{ t('admin.accounts.testConnection') }}
|
||||||
<Icon name="link" size="sm" />
|
|
||||||
{{ t('admin.accounts.reAuthorize') }}
|
|
||||||
</button>
|
</button>
|
||||||
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
<Icon name="refresh" size="sm" />
|
<Icon name="chart" size="sm" class="text-indigo-500" />
|
||||||
{{ t('admin.accounts.refreshToken') }}
|
{{ t('admin.accounts.viewStats') }}
|
||||||
|
</button>
|
||||||
|
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||||
|
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
|
<Icon name="link" size="sm" />
|
||||||
|
{{ t('admin.accounts.reAuthorize') }}
|
||||||
|
</button>
|
||||||
|
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
{{ t('admin.accounts.refreshToken') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||||
|
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
|
<Icon name="sync" size="sm" />
|
||||||
|
{{ t('admin.accounts.resetStatus') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||||
|
<Icon name="clock" size="sm" />
|
||||||
|
{{ t('admin.accounts.clearRateLimit') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
</div>
|
||||||
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
|
||||||
<Icon name="sync" size="sm" />
|
|
||||||
{{ t('admin.accounts.resetStatus') }}
|
|
||||||
</button>
|
|
||||||
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
|
||||||
<Icon name="clock" size="sm" />
|
|
||||||
{{ t('admin.accounts.clearRateLimit') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, watch, onUnmounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Icon } from '@/components/icons'
|
import { Icon } from '@/components/icons'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
||||||
defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
const emit = defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
|
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
|
||||||
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
|
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -279,18 +279,143 @@ interface Props {
|
|||||||
expandableActions?: boolean
|
expandableActions?: boolean
|
||||||
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||||||
rowKey?: string | ((row: any) => string | number)
|
rowKey?: string | ((row: any) => string | number)
|
||||||
|
/**
|
||||||
|
* Default sort configuration (only applied when there is no persisted sort state)
|
||||||
|
*/
|
||||||
|
defaultSortKey?: string
|
||||||
|
defaultSortOrder?: 'asc' | 'desc'
|
||||||
|
/**
|
||||||
|
* Persist sort state (key + order) to localStorage using this key.
|
||||||
|
* If provided, DataTable will load the stored sort state on mount.
|
||||||
|
*/
|
||||||
|
sortStorageKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false,
|
loading: false,
|
||||||
stickyFirstColumn: true,
|
stickyFirstColumn: true,
|
||||||
stickyActionsColumn: true,
|
stickyActionsColumn: true,
|
||||||
expandableActions: true
|
expandableActions: true,
|
||||||
|
defaultSortOrder: 'asc'
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortKey = ref<string>('')
|
const sortKey = ref<string>('')
|
||||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||||
const actionsExpanded = ref(false)
|
const actionsExpanded = ref(false)
|
||||||
|
|
||||||
|
type PersistedSortState = {
|
||||||
|
key: string
|
||||||
|
order: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
const collator = new Intl.Collator(undefined, {
|
||||||
|
numeric: true,
|
||||||
|
sensitivity: 'base'
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSortableKeys = () => {
|
||||||
|
const keys = new Set<string>()
|
||||||
|
for (const col of props.columns) {
|
||||||
|
if (col.sortable) keys.add(col.key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSortKey = (candidate: string) => {
|
||||||
|
if (!candidate) return ''
|
||||||
|
const sortableKeys = getSortableKeys()
|
||||||
|
return sortableKeys.has(candidate) ? candidate : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSortOrder = (candidate: any): 'asc' | 'desc' => {
|
||||||
|
return candidate === 'desc' ? 'desc' : 'asc'
|
||||||
|
}
|
||||||
|
|
||||||
|
const readPersistedSortState = (): PersistedSortState | null => {
|
||||||
|
if (!props.sortStorageKey) return null
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(props.sortStorageKey)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as Partial<PersistedSortState>
|
||||||
|
const key = normalizeSortKey(typeof parsed.key === 'string' ? parsed.key : '')
|
||||||
|
if (!key) return null
|
||||||
|
return { key, order: normalizeSortOrder(parsed.order) }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DataTable] Failed to read persisted sort state:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writePersistedSortState = (state: PersistedSortState) => {
|
||||||
|
if (!props.sortStorageKey) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(props.sortStorageKey, JSON.stringify(state))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[DataTable] Failed to persist sort state:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveInitialSortState = (): PersistedSortState | null => {
|
||||||
|
const persisted = readPersistedSortState()
|
||||||
|
if (persisted) return persisted
|
||||||
|
|
||||||
|
const key = normalizeSortKey(props.defaultSortKey || '')
|
||||||
|
if (!key) return null
|
||||||
|
return { key, order: normalizeSortOrder(props.defaultSortOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySortState = (state: PersistedSortState | null) => {
|
||||||
|
if (!state) return
|
||||||
|
sortKey.value = state.key
|
||||||
|
sortOrder.value = state.order
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNullishOrEmpty = (value: any) => value === null || value === undefined || value === ''
|
||||||
|
|
||||||
|
const toFiniteNumberOrNull = (value: any): number | null => {
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
||||||
|
if (typeof value === 'boolean') return value ? 1 : 0
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
const n = Number(trimmed)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSortableString = (value: any): string => {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||||
|
if (value instanceof Date) return value.toISOString()
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
} catch {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareSortValues = (a: any, b: any): number => {
|
||||||
|
const aEmpty = isNullishOrEmpty(a)
|
||||||
|
const bEmpty = isNullishOrEmpty(b)
|
||||||
|
if (aEmpty && bEmpty) return 0
|
||||||
|
if (aEmpty) return 1
|
||||||
|
if (bEmpty) return -1
|
||||||
|
|
||||||
|
const aNum = toFiniteNumberOrNull(a)
|
||||||
|
const bNum = toFiniteNumberOrNull(b)
|
||||||
|
if (aNum !== null && bNum !== null) {
|
||||||
|
if (aNum === bNum) return 0
|
||||||
|
return aNum < bNum ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const aStr = toSortableString(a)
|
||||||
|
const bStr = toSortableString(b)
|
||||||
|
const res = collator.compare(aStr, bStr)
|
||||||
|
if (res === 0) return 0
|
||||||
|
return res < 0 ? -1 : 1
|
||||||
|
}
|
||||||
const resolveRowKey = (row: any, index: number) => {
|
const resolveRowKey = (row: any, index: number) => {
|
||||||
if (typeof props.rowKey === 'function') {
|
if (typeof props.rowKey === 'function') {
|
||||||
const key = props.rowKey(row)
|
const key = props.rowKey(row)
|
||||||
@@ -334,15 +459,18 @@ const handleSort = (key: string) => {
|
|||||||
const sortedData = computed(() => {
|
const sortedData = computed(() => {
|
||||||
if (!sortKey.value || !props.data) return props.data
|
if (!sortKey.value || !props.data) return props.data
|
||||||
|
|
||||||
return [...props.data].sort((a, b) => {
|
const key = sortKey.value
|
||||||
const aVal = a[sortKey.value]
|
const order = sortOrder.value
|
||||||
const bVal = b[sortKey.value]
|
|
||||||
|
|
||||||
if (aVal === bVal) return 0
|
// Stable sort (tie-break with original index) to avoid jitter when values are equal.
|
||||||
|
return props.data
|
||||||
const comparison = aVal > bVal ? 1 : -1
|
.map((row, index) => ({ row, index }))
|
||||||
return sortOrder.value === 'asc' ? comparison : -comparison
|
.sort((a, b) => {
|
||||||
})
|
const cmp = compareSortValues(a.row?.[key], b.row?.[key])
|
||||||
|
if (cmp !== 0) return order === 'asc' ? cmp : -cmp
|
||||||
|
return a.index - b.index
|
||||||
|
})
|
||||||
|
.map(item => item.row)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasActionsColumn = computed(() => {
|
const hasActionsColumn = computed(() => {
|
||||||
@@ -396,6 +524,51 @@ const getAdaptivePaddingClass = () => {
|
|||||||
return 'px-6' // 24px (原始值)
|
return 'px-6' // 24px (原始值)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Init + keep persisted sort state consistent with current columns
|
||||||
|
const didInitSort = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const initial = resolveInitialSortState()
|
||||||
|
applySortState(initial)
|
||||||
|
didInitSort.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.columns,
|
||||||
|
() => {
|
||||||
|
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||||||
|
const normalized = normalizeSortKey(sortKey.value)
|
||||||
|
if (!sortKey.value) {
|
||||||
|
const initial = resolveInitialSortState()
|
||||||
|
applySortState(initial)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
const fallback = resolveInitialSortState()
|
||||||
|
if (fallback) {
|
||||||
|
applySortState(fallback)
|
||||||
|
} else {
|
||||||
|
sortKey.value = ''
|
||||||
|
sortOrder.value = 'asc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[sortKey, sortOrder],
|
||||||
|
([nextKey, nextOrder]) => {
|
||||||
|
if (!didInitSort.value) return
|
||||||
|
if (!props.sortStorageKey) return
|
||||||
|
const key = normalizeSortKey(nextKey)
|
||||||
|
if (!key) return
|
||||||
|
writePersistedSortState({ key, order: normalizeSortOrder(nextOrder) })
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
|
|||||||
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
|
||||||
- `data: any[]` - Array of data objects to display
|
- `data: any[]` - Array of data objects to display
|
||||||
- `loading?: boolean` - Show loading skeleton
|
- `loading?: boolean` - Show loading skeleton
|
||||||
|
- `defaultSortKey?: string` - Default sort key (only used if no persisted sort state)
|
||||||
|
- `defaultSortOrder?: 'asc' | 'desc'` - Default sort order (default: `asc`)
|
||||||
|
- `sortStorageKey?: string` - Persist sort state (key + order) to localStorage
|
||||||
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
|
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
|
||||||
|
|
||||||
**Slots:**
|
**Slots:**
|
||||||
|
|||||||
@@ -1022,6 +1022,13 @@ export default {
|
|||||||
title: 'Account Management',
|
title: 'Account Management',
|
||||||
description: 'Manage AI platform accounts and credentials',
|
description: 'Manage AI platform accounts and credentials',
|
||||||
createAccount: 'Create Account',
|
createAccount: 'Create Account',
|
||||||
|
autoRefresh: 'Auto Refresh',
|
||||||
|
enableAutoRefresh: 'Enable auto refresh',
|
||||||
|
refreshInterval5s: '5 seconds',
|
||||||
|
refreshInterval10s: '10 seconds',
|
||||||
|
refreshInterval15s: '15 seconds',
|
||||||
|
refreshInterval30s: '30 seconds',
|
||||||
|
autoRefreshCountdown: 'Auto refresh: {seconds}s',
|
||||||
syncFromCrs: 'Sync from CRS',
|
syncFromCrs: 'Sync from CRS',
|
||||||
syncFromCrsTitle: 'Sync Accounts from CRS',
|
syncFromCrsTitle: 'Sync Accounts from CRS',
|
||||||
syncFromCrsDesc:
|
syncFromCrsDesc:
|
||||||
|
|||||||
@@ -1096,6 +1096,13 @@ export default {
|
|||||||
title: '账号管理',
|
title: '账号管理',
|
||||||
description: '管理 AI 平台账号和 Cookie',
|
description: '管理 AI 平台账号和 Cookie',
|
||||||
createAccount: '添加账号',
|
createAccount: '添加账号',
|
||||||
|
autoRefresh: '自动刷新',
|
||||||
|
enableAutoRefresh: '启用自动刷新',
|
||||||
|
refreshInterval5s: '5 秒',
|
||||||
|
refreshInterval10s: '10 秒',
|
||||||
|
refreshInterval15s: '15 秒',
|
||||||
|
refreshInterval30s: '30 秒',
|
||||||
|
autoRefreshCountdown: '自动刷新:{seconds}s',
|
||||||
syncFromCrs: '从 CRS 同步',
|
syncFromCrs: '从 CRS 同步',
|
||||||
syncFromCrsTitle: '从 CRS 同步账号',
|
syncFromCrsTitle: '从 CRS 同步账号',
|
||||||
syncFromCrsDesc:
|
syncFromCrsDesc:
|
||||||
|
|||||||
@@ -17,10 +17,58 @@
|
|||||||
@create="showCreate = true"
|
@create="showCreate = true"
|
||||||
>
|
>
|
||||||
<template #after>
|
<template #after>
|
||||||
|
<!-- Auto Refresh Dropdown -->
|
||||||
|
<div class="relative" ref="autoRefreshDropdownRef">
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
showAutoRefreshDropdown = !showAutoRefreshDropdown;
|
||||||
|
showColumnDropdown = false
|
||||||
|
"
|
||||||
|
class="btn btn-secondary px-2 md:px-3"
|
||||||
|
:title="t('admin.accounts.autoRefresh')"
|
||||||
|
>
|
||||||
|
<Icon name="refresh" size="sm" :class="[autoRefreshEnabled ? 'animate-spin' : '']" />
|
||||||
|
<span class="hidden md:inline">
|
||||||
|
{{
|
||||||
|
autoRefreshEnabled
|
||||||
|
? t('admin.accounts.autoRefreshCountdown', { seconds: autoRefreshCountdown })
|
||||||
|
: t('admin.accounts.autoRefresh')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showAutoRefreshDropdown"
|
||||||
|
class="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div class="p-2">
|
||||||
|
<button
|
||||||
|
@click="setAutoRefreshEnabled(!autoRefreshEnabled)"
|
||||||
|
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span>{{ t('admin.accounts.enableAutoRefresh') }}</span>
|
||||||
|
<Icon v-if="autoRefreshEnabled" name="check" size="sm" class="text-primary-500" />
|
||||||
|
</button>
|
||||||
|
<div class="my-1 border-t border-gray-100 dark:border-gray-700"></div>
|
||||||
|
<button
|
||||||
|
v-for="sec in autoRefreshIntervals"
|
||||||
|
:key="sec"
|
||||||
|
@click="setAutoRefreshInterval(sec)"
|
||||||
|
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<span>{{ autoRefreshIntervalLabel(sec) }}</span>
|
||||||
|
<Icon v-if="autoRefreshIntervalSeconds === sec" name="check" size="sm" class="text-primary-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Column Settings Dropdown -->
|
<!-- Column Settings Dropdown -->
|
||||||
<div class="relative" ref="columnDropdownRef">
|
<div class="relative" ref="columnDropdownRef">
|
||||||
<button
|
<button
|
||||||
@click="showColumnDropdown = !showColumnDropdown"
|
@click="
|
||||||
|
showColumnDropdown = !showColumnDropdown;
|
||||||
|
showAutoRefreshDropdown = false
|
||||||
|
"
|
||||||
class="btn btn-secondary px-2 md:px-3"
|
class="btn btn-secondary px-2 md:px-3"
|
||||||
:title="t('admin.users.columnSettings')"
|
:title="t('admin.users.columnSettings')"
|
||||||
>
|
>
|
||||||
@@ -53,7 +101,15 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #table>
|
<template #table>
|
||||||
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
|
||||||
<DataTable :columns="cols" :data="accounts" :loading="loading" row-key="id">
|
<DataTable
|
||||||
|
:columns="cols"
|
||||||
|
:data="accounts"
|
||||||
|
:loading="loading"
|
||||||
|
row-key="id"
|
||||||
|
default-sort-key="name"
|
||||||
|
default-sort-order="asc"
|
||||||
|
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
|
||||||
|
>
|
||||||
<template #cell-select="{ row }">
|
<template #cell-select="{ row }">
|
||||||
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||||
</template>
|
</template>
|
||||||
@@ -161,6 +217,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useIntervalFn } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -221,6 +278,26 @@ const hiddenColumns = reactive<Set<string>>(new Set())
|
|||||||
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
|
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
|
||||||
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
|
||||||
|
|
||||||
|
// Sorting settings
|
||||||
|
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
|
||||||
|
|
||||||
|
// Auto refresh settings
|
||||||
|
const showAutoRefreshDropdown = ref(false)
|
||||||
|
const autoRefreshDropdownRef = ref<HTMLElement | null>(null)
|
||||||
|
const AUTO_REFRESH_STORAGE_KEY = 'account-auto-refresh'
|
||||||
|
const autoRefreshIntervals = [5, 10, 15, 30] as const
|
||||||
|
const autoRefreshEnabled = ref(false)
|
||||||
|
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
|
||||||
|
const autoRefreshCountdown = ref(0)
|
||||||
|
|
||||||
|
const autoRefreshIntervalLabel = (sec: number) => {
|
||||||
|
if (sec === 5) return t('admin.accounts.refreshInterval5s')
|
||||||
|
if (sec === 10) return t('admin.accounts.refreshInterval10s')
|
||||||
|
if (sec === 15) return t('admin.accounts.refreshInterval15s')
|
||||||
|
if (sec === 30) return t('admin.accounts.refreshInterval30s')
|
||||||
|
return `${sec}s`
|
||||||
|
}
|
||||||
|
|
||||||
const loadSavedColumns = () => {
|
const loadSavedColumns = () => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
|
||||||
@@ -244,6 +321,60 @@ const saveColumnsToStorage = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadSavedAutoRefresh = () => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY)
|
||||||
|
if (!saved) return
|
||||||
|
const parsed = JSON.parse(saved) as { enabled?: boolean; interval_seconds?: number }
|
||||||
|
autoRefreshEnabled.value = parsed.enabled === true
|
||||||
|
const interval = Number(parsed.interval_seconds)
|
||||||
|
if (autoRefreshIntervals.includes(interval as any)) {
|
||||||
|
autoRefreshIntervalSeconds.value = interval as any
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load saved auto refresh settings:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAutoRefreshToStorage = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
AUTO_REFRESH_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enabled: autoRefreshEnabled.value,
|
||||||
|
interval_seconds: autoRefreshIntervalSeconds.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save auto refresh settings:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
loadSavedColumns()
|
||||||
|
loadSavedAutoRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAutoRefreshEnabled = (enabled: boolean) => {
|
||||||
|
autoRefreshEnabled.value = enabled
|
||||||
|
saveAutoRefreshToStorage()
|
||||||
|
if (enabled) {
|
||||||
|
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||||
|
resumeAutoRefresh()
|
||||||
|
} else {
|
||||||
|
pauseAutoRefresh()
|
||||||
|
autoRefreshCountdown.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAutoRefreshInterval = (seconds: (typeof autoRefreshIntervals)[number]) => {
|
||||||
|
autoRefreshIntervalSeconds.value = seconds
|
||||||
|
saveAutoRefreshToStorage()
|
||||||
|
if (autoRefreshEnabled.value) {
|
||||||
|
autoRefreshCountdown.value = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleColumn = (key: string) => {
|
const toggleColumn = (key: string) => {
|
||||||
if (hiddenColumns.has(key)) {
|
if (hiddenColumns.has(key)) {
|
||||||
hiddenColumns.delete(key)
|
hiddenColumns.delete(key)
|
||||||
@@ -260,6 +391,44 @@ const { items: accounts, loading, params, pagination, load, reload, debouncedRel
|
|||||||
initialParams: { platform: '', type: '', status: '', search: '' }
|
initialParams: { platform: '', type: '', status: '', search: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isAnyModalOpen = computed(() => {
|
||||||
|
return (
|
||||||
|
showCreate.value ||
|
||||||
|
showEdit.value ||
|
||||||
|
showSync.value ||
|
||||||
|
showBulkEdit.value ||
|
||||||
|
showTempUnsched.value ||
|
||||||
|
showDeleteDialog.value ||
|
||||||
|
showReAuth.value ||
|
||||||
|
showTest.value ||
|
||||||
|
showStats.value
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
|
||||||
|
async () => {
|
||||||
|
if (!autoRefreshEnabled.value) return
|
||||||
|
if (document.hidden) return
|
||||||
|
if (loading.value) return
|
||||||
|
if (isAnyModalOpen.value) return
|
||||||
|
if (menu.show) return
|
||||||
|
|
||||||
|
if (autoRefreshCountdown.value <= 0) {
|
||||||
|
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||||
|
try {
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Auto refresh failed:', e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoRefreshCountdown.value -= 1
|
||||||
|
},
|
||||||
|
1000,
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
|
||||||
// All available columns
|
// All available columns
|
||||||
const allColumns = computed(() => {
|
const allColumns = computed(() => {
|
||||||
const c = [
|
const c = [
|
||||||
@@ -512,10 +681,12 @@ const handleClickOutside = (event: MouseEvent) => {
|
|||||||
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
|
||||||
showColumnDropdown.value = false
|
showColumnDropdown.value = false
|
||||||
}
|
}
|
||||||
|
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
|
||||||
|
showAutoRefreshDropdown.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadSavedColumns()
|
|
||||||
load()
|
load()
|
||||||
try {
|
try {
|
||||||
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
|
||||||
@@ -526,6 +697,13 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
window.addEventListener('scroll', handleScroll, true)
|
window.addEventListener('scroll', handleScroll, true)
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
|
||||||
|
if (autoRefreshEnabled.value) {
|
||||||
|
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
|
||||||
|
resumeAutoRefresh()
|
||||||
|
} else {
|
||||||
|
pauseAutoRefresh()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user