Compare commits

..

17 Commits

Author SHA1 Message Date
CJACK.
de50fd3954 Merge pull request #96 from CJackHwang/codex/update-ci-line-count-limits-cihke3
ci: ignore test files in line gate and raise frontend limit to 500
2026-03-16 23:16:22 +08:00
CJACK.
7648d5f192 ci: keep entry line cap precedence over frontend cap 2026-03-16 23:06:58 +08:00
CJACK.
d35e5eab25 ci: ignore tests in line gate and raise frontend limit 2026-03-16 22:58:13 +08:00
CJACK.
90610a52ce Merge pull request #93 from latticeon/feature/session-management
feat: 添加会话管理功能
2026-03-16 22:12:00 +08:00
latticeon
f6296d506f fix: 修改批量删除会话方式
- 从逐条单个删除改为官方的批量删除接口
- 单个删除函数保留备用
2026-03-16 16:23:39 +08:00
latticeon
dfea092583 fix: 更新测试 mock 结构体以实现新增的接口方法
会话管理功能新增接口方法后,同步更新测试 mock 结构体:
- mockOpenAIConfig: 添加 AutoDeleteSessions() 方法
- streamStatusDSStub: 添加 DeleteAllSessionsForToken() 方法
- testingDSMock: 添加 DeleteAllSessionsForToken() 和 GetSessionCountForToken() 方法

同时修复 client_session_delete.go 中 fmt.Errorf 使用非常量格式字符串的编译错误,改用 errors.New()
2026-03-16 11:58:07 +08:00
latticeon
af7dc134bb fix: 修复会话管理相关问题并拆分文件
1. 修复无限循环问题
   - DeleteAllSessions/DeleteAllSessionsForToken 添加无进度检测
   - 连续 3 轮删除失败则退出循环
   - DeleteAllSessionsForToken 添加 cursor 推进逻辑

2. 修复字段语义不准确
   - TotalCount 重命名为 FirstPageCount
   - 明确该值仅统计第一页,多页账户需关注 HasMore

3. 修复 defer 执行顺序问题
   - 合并两个 defer,确保先删除会话再释放账号
   - 使用同步删除避免并发截断风险

4. 文件拆分
   - 新建 client_session_delete.go 处理会话删除
   - client_session.go 专注于会话查询
2026-03-16 01:44:21 +08:00
latticeon
2657d37f76 添加会话数量显示与清除功能
添加会话清除功能,增强安全性,避免账号被盗等情况泄露源代码
账号列表点击测试后显示账号的会话数量
设置页添加自动清除开关,每次调用后清除被调用账号的所有会话
2026-03-16 00:50:31 +08:00
CJACK.
f2674487c7 Merge pull request #90 from CJackHwang/dev
Merge pull request #89 from CJackHwang/codex/review-changes-in-pull-request-#88

Support text-kv `function.name`/`function.arguments` fallback and looser name matching
2026-03-09 21:42:28 +08:00
CJACK.
71cdcb43e8 Merge pull request #89 from CJackHwang/codex/review-changes-in-pull-request-#88
Support text-kv `function.name`/`function.arguments` fallback and looser name matching
2026-03-09 19:21:24 +08:00
CJACK.
9c46c3a874 Merge branch 'dev' into codex/review-changes-in-pull-request-#88 2026-03-09 19:20:32 +08:00
CJACK.
12d5f136d5 fix(toolcall): pass gates and align go/js multi-layer parser 2026-03-09 19:16:28 +08:00
CJACK.
00c37d8d2f Merge pull request #88 from valkryhx/main
update openai function calling 成功率高 是因为chat内容和tool内容分开保存,而ds则混合了
2026-03-09 19:04:41 +08:00
huangxun
0f1985af4a feat(util): 增加对混杂文本中 Tool Call 的 fallback 解析支持
- 引入 parseTextKVToolCalls 解析器以处理混杂文本或带历史记录套壳(如 [TOOL_CALL_HISTORY])输出的函数调用提取。
- 将其作为 JSON 和 XML 的 fallback 解析手段集成到主流程。
- 添加单元测试用例且更新相关语义说明文档。
2026-03-09 15:00:16 +08:00
huangxun
fa8affe1b7 Merge remote-tracking branch 'upstream/main' 2026-03-09 14:29:09 +08:00
valkryhx
d27e700c4f update openai function calling 成功率高 是因为chat内容和tool内容分开保存,而ds则混合了 2026-03-06 23:22:11 +08:00
valkryhx
d6bce5af93 Merge branch 'dev' 2026-03-06 22:49:56 +08:00
38 changed files with 2039 additions and 965 deletions

View File

@@ -19,7 +19,8 @@ This document defines the cross-runtime contract for `ParseToolCallsDetailed` /
- first `{` to last `}` object slice. - first `{` to last `}` object slice.
3. Parse each candidate in order: 3. Parse each candidate in order:
- JSON payload parser (`tool_calls`, list, single call object), - JSON payload parser (`tool_calls`, list, single call object),
- markup parser (`<tool_call>`, `<function_call>`, `<invoke>`; supports attributes + nested fields). - XML/Markup parser (`<tool_call>`, `<function_call>`, `<invoke>`; supports attributes + nested fields),
- Text KV fallback parser (`function.name: <name>` ... `function.arguments: {json}`).
4. Stop at first candidate that yields at least one call. 4. Stop at first candidate that yields at least one call.
## Name normalization policy ## Name normalization policy

View File

@@ -19,6 +19,7 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
} }
type ConfigReader interface { type ConfigReader interface {
@@ -28,6 +29,7 @@ type ConfigReader interface {
ToolcallEarlyEmitConfidence() string ToolcallEarlyEmitConfidence() string
ResponsesStoreTTLSeconds() int ResponsesStoreTTLSeconds() int
EmbeddingsProvider() string EmbeddingsProvider() string
AutoDeleteSessions() bool
} }
var _ AuthResolver = (*auth.Resolver)(nil) var _ AuthResolver = (*auth.Resolver)(nil)

View File

@@ -19,6 +19,7 @@ func (m mockOpenAIConfig) ToolcallMode() string { return m.toolMo
func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit } func (m mockOpenAIConfig) ToolcallEarlyEmitConfidence() string { return m.earlyEmit }
func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL } func (m mockOpenAIConfig) ResponsesStoreTTLSeconds() int { return m.responsesTTL }
func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv } func (m mockOpenAIConfig) EmbeddingsProvider() string { return m.embedProv }
func (m mockOpenAIConfig) AutoDeleteSessions() bool { return false }
func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) { func TestNormalizeOpenAIChatRequestWithConfigInterface(t *testing.T) {
cfg := mockOpenAIConfig{ cfg := mockOpenAIConfig{

View File

@@ -35,7 +35,23 @@ func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
writeOpenAIError(w, status, detail) writeOpenAIError(w, status, detail)
return return
} }
defer h.Auth.Release(a) defer func() {
// 自动删除会话(同步)
// 必须在 Release 之前同步删除,否则:
// 1. 异步删除时账号已被 Release
// 2. 新请求可能获取到同一账号并开始使用
// 3. 异步删除仍在进行,会截断新请求正在使用的会话
if h.Store.AutoDeleteSessions() && a.DeepSeekToken != "" {
err := h.DS.DeleteAllSessionsForToken(context.Background(), a.DeepSeekToken)
if err != nil {
config.Logger.Warn("[auto_delete_sessions] failed", "account", a.AccountID, "error", err)
} else {
config.Logger.Debug("[auto_delete_sessions] success", "account", a.AccountID)
}
}
h.Auth.Release(a)
}()
r = r.WithContext(auth.WithAuth(r.Context(), a)) r = r.WithContext(auth.WithAuth(r.Context(), a))
var req map[string]any var req map[string]any

View File

@@ -53,6 +53,10 @@ func (m streamStatusDSStub) CallCompletion(_ context.Context, _ *auth.RequestAut
return m.resp, nil return m.resp, nil
} }
func (m streamStatusDSStub) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func makeOpenAISSEHTTPResponse(lines ...string) *http.Response { func makeOpenAISSEHTTPResponse(lines ...string) *http.Response {
body := strings.Join(lines, "\n") body := strings.Join(lines, "\n")
if !strings.HasSuffix(body, "\n") { if !strings.HasSuffix(body, "\n") {

View File

@@ -27,6 +27,7 @@ type ConfigStore interface {
RuntimeAccountMaxInflight() int RuntimeAccountMaxInflight() int
RuntimeAccountMaxQueue(defaultSize int) int RuntimeAccountMaxQueue(defaultSize int) int
RuntimeGlobalMaxInflight(defaultSize int) int RuntimeGlobalMaxInflight(defaultSize int) int
AutoDeleteSessions() bool
} }
type PoolController interface { type PoolController interface {
@@ -40,6 +41,8 @@ type DeepSeekCaller interface {
CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error)
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error) CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
GetSessionCountForToken(ctx context.Context, token string) (*deepseek.SessionStats, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
} }
var _ ConfigStore = (*config.Store)(nil) var _ ConfigStore = (*config.Store)(nil)

View File

@@ -31,6 +31,7 @@ func RegisterRoutes(r chi.Router, h *Handler) {
pr.Get("/queue/status", h.queueStatus) pr.Get("/queue/status", h.queueStatus)
pr.Post("/accounts/test", h.testSingleAccount) pr.Post("/accounts/test", h.testSingleAccount)
pr.Post("/accounts/test-all", h.testAllAccounts) pr.Post("/accounts/test-all", h.testAllAccounts)
pr.Post("/accounts/sessions/delete-all", h.deleteAllSessions)
pr.Post("/import", h.batchImport) pr.Post("/import", h.batchImport)
pr.Post("/test", h.testAPI) pr.Post("/test", h.testAPI)
pr.Post("/vercel/sync", h.syncVercel) pr.Post("/vercel/sync", h.syncVercel)

View File

@@ -89,7 +89,7 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
start := time.Now() start := time.Now()
identifier := acc.Identifier() identifier := acc.Identifier()
result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model} result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model, "session_count": 0}
defer func() { defer func() {
status := "failed" status := "failed"
if ok, _ := result["success"].(bool); ok { if ok, _ := result["success"].(bool); ok {
@@ -124,6 +124,13 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
return result return result
} }
} }
// 获取会话数量
sessionStats, sessionErr := h.DS.GetSessionCountForToken(ctx, token)
if sessionErr == nil && sessionStats != nil {
result["session_count"] = sessionStats.FirstPageCount
}
if strings.TrimSpace(message) == "" { if strings.TrimSpace(message) == "" {
result["success"] = true result["success"] = true
result["message"] = "API 测试成功(仅会话创建)" result["message"] = "API 测试成功(仅会话创建)"
@@ -210,3 +217,39 @@ func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)}) writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
} }
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识identifier / email / mobile"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
// 获取 token
token := strings.TrimSpace(acc.Token)
if token == "" {
newToken, err := h.DS.Login(r.Context(), acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
}
// 删除所有会话
err := h.DS.DeleteAllSessionsForToken(r.Context(), token)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}

View File

@@ -9,6 +9,7 @@ import (
"ds2api/internal/auth" "ds2api/internal/auth"
"ds2api/internal/config" "ds2api/internal/config"
"ds2api/internal/deepseek"
) )
type testingDSMock struct { type testingDSMock struct {
@@ -38,6 +39,14 @@ func (m *testingDSMock) CallCompletion(_ context.Context, _ *auth.RequestAuth, _
return nil, errors.New("should not call CallCompletion in this test") return nil, errors.New("should not call CallCompletion in this test")
} }
func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) error {
return nil
}
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*deepseek.SessionStats, error) {
return &deepseek.SessionStats{Success: true}, nil
}
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) { func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`) t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore() store := config.LoadStore()

View File

@@ -7,15 +7,30 @@ import (
"ds2api/internal/config" "ds2api/internal/config"
) )
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, map[string]string, map[string]string, error) { func boolFrom(v any) bool {
if v == nil {
return false
}
switch x := v.(type) {
case bool:
return x
case string:
return strings.ToLower(strings.TrimSpace(x)) == "true"
default:
return false
}
}
func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ToolcallConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, map[string]string, map[string]string, error) {
var ( var (
adminCfg *config.AdminConfig adminCfg *config.AdminConfig
runtimeCfg *config.RuntimeConfig runtimeCfg *config.RuntimeConfig
toolcallCfg *config.ToolcallConfig toolcallCfg *config.ToolcallConfig
respCfg *config.ResponsesConfig respCfg *config.ResponsesConfig
embCfg *config.EmbeddingsConfig embCfg *config.EmbeddingsConfig
claudeMap map[string]string autoDeleteCfg *config.AutoDeleteConfig
aliasMap map[string]string claudeMap map[string]string
aliasMap map[string]string
) )
if raw, ok := req["admin"].(map[string]any); ok { if raw, ok := req["admin"].(map[string]any); ok {
@@ -23,7 +38,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["jwt_expire_hours"]; exists { if v, exists := raw["jwt_expire_hours"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 720 { if n < 1 || n > 720 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("admin.jwt_expire_hours must be between 1 and 720")
} }
cfg.JWTExpireHours = n cfg.JWTExpireHours = n
} }
@@ -35,26 +50,26 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["account_max_inflight"]; exists { if v, exists := raw["account_max_inflight"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 256 { if n < 1 || n > 256 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_inflight must be between 1 and 256")
} }
cfg.AccountMaxInflight = n cfg.AccountMaxInflight = n
} }
if v, exists := raw["account_max_queue"]; exists { if v, exists := raw["account_max_queue"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 200000 { if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.account_max_queue must be between 1 and 200000")
} }
cfg.AccountMaxQueue = n cfg.AccountMaxQueue = n
} }
if v, exists := raw["global_max_inflight"]; exists { if v, exists := raw["global_max_inflight"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 1 || n > 200000 { if n < 1 || n > 200000 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be between 1 and 200000")
} }
cfg.GlobalMaxInflight = n cfg.GlobalMaxInflight = n
} }
if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight { if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
} }
runtimeCfg = cfg runtimeCfg = cfg
} }
@@ -67,7 +82,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "feature_match", "off": case "feature_match", "off":
cfg.Mode = mode cfg.Mode = mode
default: default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.mode must be feature_match or off")
} }
} }
if v, exists := raw["early_emit_confidence"]; exists { if v, exists := raw["early_emit_confidence"]; exists {
@@ -76,7 +91,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
case "high", "low", "off": case "high", "low", "off":
cfg.EarlyEmitConfidence = level cfg.EarlyEmitConfidence = level
default: default:
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("toolcall.early_emit_confidence must be high, low or off")
} }
} }
toolcallCfg = cfg toolcallCfg = cfg
@@ -87,7 +102,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
if v, exists := raw["store_ttl_seconds"]; exists { if v, exists := raw["store_ttl_seconds"]; exists {
n := intFrom(v) n := intFrom(v)
if n < 30 || n > 86400 { if n < 30 || n > 86400 {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400") return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("responses.store_ttl_seconds must be between 30 and 86400")
} }
cfg.StoreTTLSeconds = n cfg.StoreTTLSeconds = n
} }
@@ -98,9 +113,6 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
cfg := &config.EmbeddingsConfig{} cfg := &config.EmbeddingsConfig{}
if v, exists := raw["provider"]; exists { if v, exists := raw["provider"]; exists {
p := strings.TrimSpace(fmt.Sprintf("%v", v)) p := strings.TrimSpace(fmt.Sprintf("%v", v))
if p == "" {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("embeddings.provider cannot be empty")
}
cfg.Provider = p cfg.Provider = p
} }
embCfg = cfg embCfg = cfg
@@ -130,5 +142,13 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
} }
} }
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, claudeMap, aliasMap, nil if raw, ok := req["auto_delete"].(map[string]any); ok {
cfg := &config.AutoDeleteConfig{}
if v, exists := raw["sessions"]; exists {
cfg.Sessions = boolFrom(v)
}
autoDeleteCfg = cfg
}
return adminCfg, runtimeCfg, toolcallCfg, respCfg, embCfg, autoDeleteCfg, claudeMap, aliasMap, nil
} }

View File

@@ -28,6 +28,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
"toolcall": snap.Toolcall, "toolcall": snap.Toolcall,
"responses": snap.Responses, "responses": snap.Responses,
"embeddings": snap.Embeddings, "embeddings": snap.Embeddings,
"auto_delete": snap.AutoDelete,
"claude_mapping": settingsClaudeMapping(snap), "claude_mapping": settingsClaudeMapping(snap),
"model_aliases": snap.ModelAliases, "model_aliases": snap.ModelAliases,
"env_backed": h.Store.IsEnvBacked(), "env_backed": h.Store.IsEnvBacked(),

View File

@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req) adminCfg, runtimeCfg, toolcallCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, claudeMap, aliasMap, err := parseSettingsUpdateRequest(req)
if err != nil { if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()}) writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return return
@@ -60,6 +60,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" { if embeddingsCfg != nil && strings.TrimSpace(embeddingsCfg.Provider) != "" {
c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider) c.Embeddings.Provider = strings.TrimSpace(embeddingsCfg.Provider)
} }
if autoDeleteCfg != nil {
c.AutoDelete.Sessions = autoDeleteCfg.Sessions
}
if claudeMap != nil { if claudeMap != nil {
c.ClaudeMapping = claudeMap c.ClaudeMapping = claudeMap
c.ClaudeModelMap = nil c.ClaudeModelMap = nil

View File

@@ -47,6 +47,7 @@ func (c Config) MarshalJSON() ([]byte, error) {
if strings.TrimSpace(c.Embeddings.Provider) != "" { if strings.TrimSpace(c.Embeddings.Provider) != "" {
m["embeddings"] = c.Embeddings m["embeddings"] = c.Embeddings
} }
m["auto_delete"] = c.AutoDelete
if c.VercelSyncHash != "" { if c.VercelSyncHash != "" {
m["_vercel_sync_hash"] = c.VercelSyncHash m["_vercel_sync_hash"] = c.VercelSyncHash
} }
@@ -108,6 +109,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
if err := json.Unmarshal(v, &c.Embeddings); err != nil { if err := json.Unmarshal(v, &c.Embeddings); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err) return fmt.Errorf("invalid field %q: %w", k, err)
} }
case "auto_delete":
if err := json.Unmarshal(v, &c.AutoDelete); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err)
}
case "_vercel_sync_hash": case "_vercel_sync_hash":
if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil { if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
return fmt.Errorf("invalid field %q: %w", k, err) return fmt.Errorf("invalid field %q: %w", k, err)
@@ -141,6 +146,7 @@ func (c Config) Clone() Config {
Toolcall: c.Toolcall, Toolcall: c.Toolcall,
Responses: c.Responses, Responses: c.Responses,
Embeddings: c.Embeddings, Embeddings: c.Embeddings,
AutoDelete: c.AutoDelete,
VercelSyncHash: c.VercelSyncHash, VercelSyncHash: c.VercelSyncHash,
VercelSyncTime: c.VercelSyncTime, VercelSyncTime: c.VercelSyncTime,
AdditionalFields: map[string]any{}, AdditionalFields: map[string]any{},

View File

@@ -12,7 +12,8 @@ type Config struct {
Toolcall ToolcallConfig `json:"toolcall,omitempty"` Toolcall ToolcallConfig `json:"toolcall,omitempty"`
Responses ResponsesConfig `json:"responses,omitempty"` Responses ResponsesConfig `json:"responses,omitempty"`
Embeddings EmbeddingsConfig `json:"embeddings,omitempty"` Embeddings EmbeddingsConfig `json:"embeddings,omitempty"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"` AutoDelete AutoDeleteConfig `json:"auto_delete"`
VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"` VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
AdditionalFields map[string]any `json:"-"` AdditionalFields map[string]any `json:"-"`
} }
@@ -53,3 +54,7 @@ type ResponsesConfig struct {
type EmbeddingsConfig struct { type EmbeddingsConfig struct {
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
} }
type AutoDeleteConfig struct {
Sessions bool `json:"sessions"`
}

View File

@@ -165,3 +165,9 @@ func (s *Store) RuntimeGlobalMaxInflight(defaultSize int) int {
} }
return defaultSize return defaultSize
} }
func (s *Store) AutoDeleteSessions() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.AutoDelete.Sessions
}

View File

@@ -62,3 +62,51 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, url st
} }
return out, resp.StatusCode, nil return out, resp.StatusCode, nil
} }
func (c *Client) getJSON(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, error) {
body, status, err := c.getJSONWithStatus(ctx, doer, url, headers)
if err != nil {
return nil, err
}
if status == 0 {
return nil, errors.New("request failed")
}
return body, nil
}
func (c *Client) getJSONWithStatus(ctx context.Context, doer trans.Doer, url string, headers map[string]string) (map[string]any, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, 0, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := doer.Do(req)
if err != nil {
config.Logger.Warn("[deepseek] fingerprint GET request failed, fallback to std transport", "url", url, "error", err)
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if reqErr != nil {
return nil, 0, err
}
for k, v := range headers {
req2.Header.Set(k, v)
}
resp, err = c.fallback.Do(req2)
if err != nil {
return nil, 0, err
}
}
defer resp.Body.Close()
payloadBytes, err := readResponseBody(resp)
if err != nil {
return nil, resp.StatusCode, err
}
out := map[string]any{}
if len(payloadBytes) > 0 {
if err := json.Unmarshal(payloadBytes, &out); err != nil {
config.Logger.Warn("[deepseek] json parse failed", "url", url, "status", resp.StatusCode, "content_encoding", resp.Header.Get("Content-Encoding"), "preview", preview(payloadBytes))
}
}
return out, resp.StatusCode, nil
}

View File

@@ -0,0 +1,254 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"ds2api/internal/auth"
"ds2api/internal/config"
)
// SessionInfo 会话信息
type SessionInfo struct {
ID string `json:"id"`
Title string `json:"title"`
TitleType string `json:"title_type"`
Pinned bool `json:"pinned"`
UpdatedAt float64 `json:"updated_at"`
}
// SessionStats 会话统计结果
type SessionStats struct {
AccountID string // 账号标识 (email 或 mobile)
FirstPageCount int // 第一页会话数量(当 HasMore 为 true 时,真实总数可能更大)
PinnedCount int // 置顶会话数量
HasMore bool // 是否还有更多页
Success bool // 请求是否成功
ErrorMessage string // 错误信息
}
// GetSessionCount 获取单个账号的会话数量
func (c *Client) GetSessionCount(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (*SessionStats, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
stats := &SessionStats{
AccountID: a.AccountID,
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
config.Logger.Warn("[get_session_count] request error", "error", err, "account", a.AccountID)
attempts++
continue
}
code := intFrom(resp["code"])
if status == http.StatusOK && code == 0 {
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
stats.FirstPageCount = len(chatSessions)
stats.HasMore = hasMore
stats.Success = true
// 统计置顶会话数量
for _, session := range chatSessions {
if s, ok := session.(map[string]any); ok {
if pinned, ok := s["pinned"].(bool); ok && pinned {
stats.PinnedCount++
}
}
}
return stats, nil
}
msg, _ := resp["msg"].(string)
stats.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[get_session_count] failed", "status", status, "code", code, "msg", msg, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, msg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
stats.Success = false
stats.ErrorMessage = "get session count failed after retries"
return stats, errors.New(stats.ErrorMessage)
}
// GetSessionCountForToken 直接使用 token 获取会话数量(直通模式)
func (c *Client) GetSessionCountForToken(ctx context.Context, token string) (*SessionStats, error) {
headers := c.authHeaders(token)
reqURL := DeepSeekFetchSessionURL + "?lte_cursor.pinned=false"
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
return nil, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
return nil, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
stats := &SessionStats{
FirstPageCount: len(chatSessions),
HasMore: hasMore,
Success: true,
}
// 统计置顶会话数量
for _, session := range chatSessions {
if s, ok := session.(map[string]any); ok {
if pinned, ok := s["pinned"].(bool); ok && pinned {
stats.PinnedCount++
}
}
}
return stats, nil
}
// GetSessionCountAll 获取所有账号的会话数量统计
func (c *Client) GetSessionCountAll(ctx context.Context) []*SessionStats {
accounts := c.Store.Accounts()
results := make([]*SessionStats, 0, len(accounts))
for _, acc := range accounts {
token := acc.Token
accountID := acc.Email
if accountID == "" {
accountID = acc.Mobile
}
// 如果没有 token尝试登录获取
if token == "" {
var err error
token, err = c.Login(ctx, acc)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
Success: false,
ErrorMessage: fmt.Sprintf("login failed: %v", err),
})
continue
}
}
stats, err := c.GetSessionCountForToken(ctx, token)
if err != nil {
results = append(results, &SessionStats{
AccountID: accountID,
Success: false,
ErrorMessage: err.Error(),
})
continue
}
stats.AccountID = accountID
results = append(results, stats)
}
return results
}
// FetchSessionPage 获取会话列表(支持分页)
func (c *Client) FetchSessionPage(ctx context.Context, a *auth.RequestAuth, cursor string) ([]SessionInfo, bool, error) {
headers := c.authHeaders(a.DeepSeekToken)
// 构建请求 URL
params := url.Values{}
params.Set("lte_cursor.pinned", "false")
if cursor != "" {
params.Set("lte_cursor", cursor)
}
reqURL := DeepSeekFetchSessionURL + "?" + params.Encode()
resp, status, err := c.getJSONWithStatus(ctx, c.regular, reqURL, headers)
if err != nil {
return nil, false, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
return nil, false, fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
chatSessions, _ := bizData["chat_sessions"].([]any)
hasMore, _ := bizData["has_more"].(bool)
sessions := make([]SessionInfo, 0, len(chatSessions))
for _, s := range chatSessions {
if m, ok := s.(map[string]any); ok {
session := SessionInfo{
ID: stringFromMap(m, "id"),
Title: stringFromMap(m, "title"),
TitleType: stringFromMap(m, "title_type"),
Pinned: boolFromMap(m, "pinned"),
UpdatedAt: floatFromMap(m, "updated_at"),
}
sessions = append(sessions, session)
}
}
return sessions, hasMore, nil
}
// 辅助函数
func stringFromMap(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func boolFromMap(m map[string]any, key string) bool {
if v, ok := m[key].(bool); ok {
return v
}
return false
}
func floatFromMap(m map[string]any, key string) float64 {
if v, ok := m[key].(float64); ok {
return v
}
return 0
}

View File

@@ -0,0 +1,156 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"ds2api/internal/auth"
"ds2api/internal/config"
)
// DeleteSessionResult 删除会话结果
type DeleteSessionResult struct {
SessionID string // 会话 ID
Success bool // 是否成功
ErrorMessage string // 错误信息
}
// DeleteSession 删除单个会话
func (c *Client) DeleteSession(ctx context.Context, a *auth.RequestAuth, sessionID string, maxAttempts int) (*DeleteSessionResult, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
result := &DeleteSessionResult{
SessionID: sessionID,
}
if sessionID == "" {
result.ErrorMessage = "session_id is required"
return result, errors.New(result.ErrorMessage)
}
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_session] request error", "error", err, "session_id", sessionID)
attempts++
continue
}
code := intFrom(resp["code"])
if status == http.StatusOK && code == 0 {
result.Success = true
return result, nil
}
msg, _ := resp["msg"].(string)
result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "msg", msg, "session_id", sessionID)
if a.UseConfigToken {
if isTokenInvalid(status, code, msg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
result.Success = false
result.ErrorMessage = "delete session failed after retries"
return result, errors.New(result.ErrorMessage)
}
// DeleteSessionForToken 直接使用 token 删除会话(直通模式)
func (c *Client) DeleteSessionForToken(ctx context.Context, token string, sessionID string) (*DeleteSessionResult, error) {
result := &DeleteSessionResult{
SessionID: sessionID,
}
if sessionID == "" {
result.ErrorMessage = "session_id is required"
return result, errors.New(result.ErrorMessage)
}
headers := c.authHeaders(token)
payload := map[string]any{
"chat_session_id": sessionID,
}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteSessionURL, headers, payload)
if err != nil {
result.ErrorMessage = err.Error()
return result, err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
result.ErrorMessage = fmt.Sprintf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
return result, errors.New(result.ErrorMessage)
}
result.Success = true
return result, nil
}
// DeleteAllSessions 删除所有会话(谨慎使用)
func (c *Client) DeleteAllSessions(ctx context.Context, a *auth.RequestAuth) error {
headers := c.authHeaders(a.DeepSeekToken)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions] request error", "error", err)
return err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
config.Logger.Warn("[delete_all_sessions] failed", "status", status, "code", code, "msg", msg)
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
return nil
}
// DeleteAllSessionsForToken 直接使用 token 删除所有会话(直通模式)
func (c *Client) DeleteAllSessionsForToken(ctx context.Context, token string) error {
headers := c.authHeaders(token)
payload := map[string]any{}
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekDeleteAllSessionsURL, headers, payload)
if err != nil {
config.Logger.Warn("[delete_all_sessions_for_token] request error", "error", err)
return err
}
code := intFrom(resp["code"])
if status != http.StatusOK || code != 0 {
msg, _ := resp["msg"].(string)
config.Logger.Warn("[delete_all_sessions_for_token] failed", "status", status, "code", code, "msg", msg)
return fmt.Errorf("request failed: status=%d, code=%d, msg=%s", status, code, msg)
}
return nil
}

View File

@@ -11,6 +11,9 @@ const (
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create" DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge" DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion" DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
) )
var defaultBaseHeaders = map[string]string{ var defaultBaseHeaders = map[string]string{

View File

@@ -9,6 +9,7 @@ const {
buildToolCallCandidates, buildToolCallCandidates,
parseToolCallsPayload, parseToolCallsPayload,
parseMarkupToolCalls, parseMarkupToolCalls,
parseTextKVToolCalls,
} = require('./parse_payload'); } = require('./parse_payload');
const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g; const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g;
@@ -53,13 +54,23 @@ function parseToolCallsDetailed(text, toolNames) {
if (parsed.length === 0) { if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c); parsed = parseMarkupToolCalls(c);
} }
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);
}
if (parsed.length > 0) { if (parsed.length > 0) {
result.sawToolCallSyntax = true; result.sawToolCallSyntax = true;
break; break;
} }
} }
if (parsed.length === 0) { if (parsed.length === 0) {
return result; parsed = parseMarkupToolCalls(sanitized);
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(sanitized);
if (parsed.length === 0) {
return result;
}
}
result.sawToolCallSyntax = true;
} }
const filtered = filterToolCallsDetailed(parsed, toolNames); const filtered = filterToolCallsDetailed(parsed, toolNames);
@@ -90,6 +101,9 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
if (parsed.length === 0) { if (parsed.length === 0) {
parsed = parseMarkupToolCalls(trimmed); parsed = parseMarkupToolCalls(trimmed);
} }
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(trimmed);
}
if (parsed.length === 0) { if (parsed.length === 0) {
return result; return result;
} }
@@ -207,7 +221,8 @@ function looksLikeToolCallSyntax(text) {
return lower.includes('tool_calls') return lower.includes('tool_calls')
|| lower.includes('<tool_call') || lower.includes('<tool_call')
|| lower.includes('<function_call') || lower.includes('<function_call')
|| lower.includes('<invoke'); || lower.includes('<invoke')
|| lower.includes('function.name:');
} }
module.exports = { module.exports = {

View File

@@ -18,6 +18,7 @@ const TOOL_CALL_MARKUP_ARGS_PATTERNS = [
/<(?:[a-z0-9_:-]+:)?args\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i, /<(?:[a-z0-9_:-]+:)?args\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i,
/<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i, /<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i,
]; ];
const TEXT_KV_NAME_PATTERN = /function\.name:\s*([a-zA-Z0-9_.-]+)/gi;
const { const {
toStringSafe, toStringSafe,
@@ -141,6 +142,47 @@ function parseMarkupToolCalls(text) {
return out; return out;
} }
function parseTextKVToolCalls(text) {
const raw = toStringSafe(text);
if (!raw) {
return [];
}
const out = [];
const matches = [...raw.matchAll(TEXT_KV_NAME_PATTERN)];
if (matches.length === 0) {
return out;
}
for (let i = 0; i < matches.length; i += 1) {
const match = matches[i];
const name = toStringSafe(match[1]).trim();
if (!name) {
continue;
}
const nameEnd = match.index + toStringSafe(match[0]).length;
const searchEnd = i + 1 < matches.length ? matches[i + 1].index : raw.length;
const searchArea = raw.slice(nameEnd, searchEnd);
const argIdx = searchArea.indexOf('function.arguments:');
if (argIdx < 0) {
continue;
}
const argStart = nameEnd + argIdx + 'function.arguments:'.length;
const bracePos = raw.slice(argStart, searchEnd).indexOf('{');
if (bracePos < 0) {
continue;
}
const objStart = argStart + bracePos;
const obj = extractJSONObjectFrom(raw, objStart);
if (!obj.ok) {
continue;
}
out.push({
name,
input: parseToolCallInput(raw.slice(objStart, obj.end)),
});
}
return out;
}
function parseMarkupSingleToolCall(attrs, inner) { function parseMarkupSingleToolCall(attrs, inner) {
const embedded = parseToolCallsPayload(inner); const embedded = parseToolCallsPayload(inner);
if (embedded.length > 0) { if (embedded.length > 0) {
@@ -317,4 +359,5 @@ module.exports = {
buildToolCallCandidates, buildToolCallCandidates,
parseToolCallsPayload, parseToolCallsPayload,
parseMarkupToolCalls, parseMarkupToolCalls,
parseTextKVToolCalls,
}; };

View File

@@ -0,0 +1,33 @@
package util
import (
"regexp"
"strings"
)
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
func resolveAllowedToolNameWithLooseMatch(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
if _, ok := allowed[name]; ok {
return name
}
lower := strings.ToLower(strings.TrimSpace(name))
if canonical, ok := allowedCanonical[lower]; ok {
return canonical
}
if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 {
if canonical, ok := allowedCanonical[lower[idx+1:]]; ok {
return canonical
}
}
loose := toolNameLoosePattern.ReplaceAllString(lower, "")
if loose == "" {
return ""
}
for candidateLower, canonical := range allowedCanonical {
if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose {
return canonical
}
}
return ""
}

View File

@@ -2,12 +2,9 @@ package util
import ( import (
"encoding/json" "encoding/json"
"regexp"
"strings" "strings"
) )
var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)
type ParsedToolCall struct { type ParsedToolCall struct {
Name string `json:"name"` Name string `json:"name"`
Input map[string]any `json:"input"` Input map[string]any `json:"input"`
@@ -45,6 +42,9 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
if len(tc) == 0 { if len(tc) == 0 {
tc = parseMarkupToolCalls(candidate) tc = parseMarkupToolCalls(candidate)
} }
if len(tc) == 0 {
tc = parseTextKVToolCalls(candidate)
}
if len(tc) > 0 { if len(tc) > 0 {
parsed = tc parsed = tc
result.SawToolCallSyntax = true result.SawToolCallSyntax = true
@@ -54,7 +54,10 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
if len(parsed) == 0 { if len(parsed) == 0 {
parsed = parseXMLToolCalls(text) parsed = parseXMLToolCalls(text)
if len(parsed) == 0 { if len(parsed) == 0 {
return result parsed = parseTextKVToolCalls(text)
if len(parsed) == 0 {
return result
}
} }
result.SawToolCallSyntax = true result.SawToolCallSyntax = true
} }
@@ -93,6 +96,9 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
if len(parsed) == 0 { if len(parsed) == 0 {
parsed = parseMarkupToolCalls(candidate) parsed = parseMarkupToolCalls(candidate)
} }
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(candidate)
}
if len(parsed) > 0 { if len(parsed) > 0 {
result.SawToolCallSyntax = true result.SawToolCallSyntax = true
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames) calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
@@ -159,28 +165,7 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin
} }
func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string { func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string {
if _, ok := allowed[name]; ok { return resolveAllowedToolNameWithLooseMatch(name, allowed, allowedCanonical)
return name
}
lower := strings.ToLower(strings.TrimSpace(name))
if canonical, ok := allowedCanonical[lower]; ok {
return canonical
}
if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 {
if canonical, ok := allowedCanonical[lower[idx+1:]]; ok {
return canonical
}
}
loose := toolNameLoosePattern.ReplaceAllString(lower, "")
if loose == "" {
return ""
}
for candidateLower, canonical := range allowedCanonical {
if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose {
return canonical
}
}
return ""
} }
func parseToolCallsPayload(payload string) []ParsedToolCall { func parseToolCallsPayload(payload string) []ParsedToolCall {
@@ -207,7 +192,8 @@ func looksLikeToolCallSyntax(text string) bool {
return strings.Contains(lower, "tool_calls") || return strings.Contains(lower, "tool_calls") ||
strings.Contains(lower, "<tool_call") || strings.Contains(lower, "<tool_call") ||
strings.Contains(lower, "<function_call") || strings.Contains(lower, "<function_call") ||
strings.Contains(lower, "<invoke") strings.Contains(lower, "<invoke") ||
strings.Contains(lower, "function.name:")
} }
func parseToolCallList(v any) []ParsedToolCall { func parseToolCallList(v any) []ParsedToolCall {

View File

@@ -0,0 +1,55 @@
package util
import (
"regexp"
"strings"
)
var textKVNamePattern = regexp.MustCompile(`(?is)function\.name:\s*([a-zA-Z0-9_\-.]+)`)
func parseTextKVToolCalls(text string) []ParsedToolCall {
var out []ParsedToolCall
matches := textKVNamePattern.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return nil
}
for i, match := range matches {
name := text[match[2]:match[3]]
offset := match[1]
endSearch := len(text)
if i+1 < len(matches) {
endSearch = matches[i+1][0]
}
searchArea := text[offset:endSearch]
argIdx := strings.Index(searchArea, "function.arguments:")
if argIdx < 0 {
continue
}
startIdx := offset + argIdx + len("function.arguments:")
braceIdx := strings.IndexByte(text[startIdx:endSearch], '{')
if braceIdx < 0 {
continue
}
actualStart := startIdx + braceIdx
objJson, _, ok := extractJSONObject(text, actualStart)
if !ok {
continue
}
input := parseToolCallInput(objJson)
out = append(out, ParsedToolCall{
Name: name,
Input: input,
})
}
if len(out) == 0 {
return nil
}
return out
}

View File

@@ -0,0 +1,63 @@
package util
import (
"testing"
)
func TestParseTextKVToolCalls_Basic(t *testing.T) {
text := `
[TOOL_CALL_HISTORY]
status: already_called
origin: assistant
not_user_input: true
tool_call_id: call_3fcd15235eb94f7eae3a8de5a9cfa36b
function.name: execute_command
function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}
[/TOOL_CALL_HISTORY]
Some other text thinking...
`
calls := ParseToolCalls(text, []string{"execute_command"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "execute_command" {
t.Fatalf("unexpected name: %s", calls[0].Name)
}
if calls[0].Input["command"] != "cd scripts && python check_syntax.py example.py" {
t.Fatalf("unexpected command arg: %v", calls[0].Input["command"])
}
}
func TestParseTextKVToolCalls_Multiple(t *testing.T) {
text := `
function.name: read_file
function.arguments: {
"path": "abc.txt"
}
function.name: bash
function.arguments: {"command": "ls"}
`
calls := ParseToolCalls(text, []string{"read_file", "bash"})
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if calls[0].Name != "read_file" {
t.Fatalf("unexpected 1st name: %s", calls[0].Name)
}
if calls[1].Name != "bash" {
t.Fatalf("unexpected 2nd name: %s", calls[1].Name)
}
}
func TestParseTextKVToolCalls_Standalone(t *testing.T) {
text := "function.name: read_file\nfunction.arguments: {\"path\":\"README.md\"}"
calls := ParseStandaloneToolCalls(text, []string{"read_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0].Name != "read_file" {
t.Fatalf("unexpected name: %s", calls[0].Name)
}
}

View File

@@ -0,0 +1,101 @@
# DeepSeek Function Calling 缺陷分析与 ds2api 的增强修复策略
> **相关 PR**: #74 (代码核心实现) 与 #75 (Merge to dev)
> **问题背景**: 解决因包括 DeepSeek 在内的部分模型在函数调用Function Calling/Tool Call表现不够“规范”从而导致工具调用失败的问题。
## 一、底层架构对比:为什么会产生 Function Calling 缺陷?
在探讨缺陷前,我们需要理解两种 Function Calling 的底层结构差异:
### 1. OpenAI 的原生结构化返回 (API 级分离)
在 OpenAI 的规范中,**聊天文字与工具调用是在底层的 JSON 结构中被硬性拆分的**
* 聊天废话存放在 `response.choices[0].message.content` 里。
* 工具请求存放在单独的数组 `response.choices[0].message.tool_calls` 里。
**优势:** 这种设计对客户端极其友好。客户端只需判断 `tool_calls` 是否为空,就能决定是执行代码还是渲染文字。它支持同时并发多个工具请求,且底层的生成殷勤被严格训练和约束,极少抛出语法错误的 JSON。
### 2. DeepSeek 等模型的“单文本流”机制
相比之下,部分未经深度专门微调的模型(或者在特定的通信适配层中),它们依然倾向于把一切内容打包成一个纯文本流吐出。这就是为什么它们的输出往往不仅包含了本该属于 `tool_calls` 结构里的 JSON还会像个“老实人”一样夹杂了属于 `content` 里的散文。
---
## 二、DeepSeek 在 Function Calling 上的特定缺陷表现
相比于 OpenAI 严格遵循 API 约定的原生结构DeepSeek 等开源/国产推理模型在工具调用时,经常会暴露出以下三种典型的“不守规矩”的输出行为:
### 1. 混合输出:散文文本与工具 JSON 混杂 (Mixed Prose Streams)
当应用要求模型直接返回工具请求时DeepSeek 有时候会**“忍不住想和用户搭话”**。
它常常前置一段解释性废话,中间插入工具调用的 JSON 参数,并在末尾再补上一句总结:
```text
好的,我这就帮你读取 README.md 的内容:
{"tool_calls":[{"name":"read_file","input":{"path":"README.md"}}]}
请稍等片刻,我马上把它读出来。
```
**旧版系统痛点:**
原有的代码存在**严格模式Strict Mode**校验:
```go
// 如果解析到的 JSON 块前后存在任何非空字符串,就放弃当作工具调用!
if strings.TrimSpace(state.recentTextTail) != "" || strings.TrimSpace(prefixPart) != "" ... {
return captured, nil, "", true
}
```
这直接导致上述结构被网关认定是一段“普通聊天”,直接原封不动地返回给用户,这直接干挂了后续的工具自动执行流程。
### 2. 工具名格式幻觉:擅自修改或前缀化工具名称
由于 DeepSeek 的预训练数据中有大量的代码和不同的平台结构,它在回复工具名称时,常常无法忠实于 System Prompt 中提供的纯命名(也就是 `name: "read_file"`),而是加上前缀或者拼写变形,例如:
* `{"name": "mcp.search_web"}` (自带命名空间)
* `{"name": "tools.read_file"}`
* `{"name": "search-web"}` (下划线变成了中划线)
**旧版系统痛点:**
旧版系统对于工具名的匹配几乎只有“绝对相等”的字典级比对,只要差了一个字符或加了前缀,就会由于找不到合法工具而直接失败。
### 3. Role 角色的非标准返回
在部分工具通信流的响应中,返回的内容其所属的 `role` 没有被标准化处理,可能携带意料之外的属性,或是与下游严格比对出现冲突。
---
## 二、PR #74 的代码增强修复方案
为了解决大模型这种自身的不规范行为PR #74 在系统的中间层网关联入了一个**极其包容的容错引擎**。它并不强制要求模型“改过自新”,而是主动做了以下三块增强:
### 1. 从流中分离混合内容(废除 Strict Mode
修改了 `internal/adapter/openai/tool_sieve_core.go`
取消了前后包裹文本的拦截逻辑。当系统扫描到流式结构中有完整的 `{"tool_calls":...}` 时,它会将废话和 JSON 分发到不同的事件流中:
```go
if prefix != "" {
// 将前面的“好的,帮你读文件”剥离出来作为常规文本输出
state.noteText(prefix)
events = append(events, toolStreamEvent{Content: prefix})
}
// 捕获并拦截中间的工具请求,进行背后执行
state.pendingToolCalls = calls
```
**效果:** 用户的屏幕上只能看到正常的文字交流,而后端的工具也会立刻挂载。
### 2. 多级宽容匹配引擎 (Resolve Allowed Tool Name)
`internal/util/toolcalls_parse.go` 中,新增了一个由严到松降级匹配的强大漏斗策略函数 `resolveAllowedToolName`
1. **绝对匹配**:和以前一样,`read_file` == `read_file`
2. **忽略大小写**`Read_File` 算作合法。
3. **命名空间抹除**:通过寻找最后一个 `.` 来剥离前缀,强制将 `mcp.search_web` 还原出真实的 `search_web`
4. **终极正则清洗**
引入 `var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`)`
这个正则剥离了字符串里所有的符号、空格、格式符。
将传入的 `read-file` 洗除符号成为 `readfile`,并去和系统中所有合法工具同样清洗后的版本进行比较。只要核心字母一致,即算作匹配成功。
### 3. Role 归一化 (Normalize OpenAIRoleForPrompt)
`internal/adapter/openai/responses_input_items.go` 等处,引入了特定的 `normalizeOpenAIRoleForPrompt(role)` 清洗,保证输入和传递给上游的 Role 枚举始终受控,消除了因为意外的身份字段传参崩溃。
---
## 报告总结与 tool_sieve 的本质作用
PR #74 / #75 并没有从模型本身开刀,而是基于**网关应足够健壮**的设计哲学。
**其实整个增强实现,本质上实现了一个名为 `tool_sieve` (工具筛子) 的中间层网关。**
面对 DeepSeek 这种吐出一团混合了聊天文字与 JSON 面团的“不标准”数据流,`tool_sieve` 就像一个勤劳的高精度筛子,不仅人工揉开了面团:
1. 它把散文分拣出来,塞回标准结构的 `content` 字段去展示;
2. 剥离并清洗出有瑕疵的 JSON 块,按照 OpenAI 的标准格式小心翼翼地放进 `tool_calls` 结构里去等待执行。
这意味着,即便 AI 被配置了奇怪的回复设定、加粗了强调语言,甚至是犯了标点符号拼写小失误,**只要它输出了可以拼凑成工具指令的 JSON 核心单元,整个中继层就能将其挽救,并把正确的工具结果呈现给模型和用户**。 这不仅修复了缺陷,更极大地增强了工具网关的通用性和鲁棒性。

View File

@@ -1,6 +1,8 @@
# Line gate targets for large-file decoupling refactor. # Line gate targets for large-file decoupling refactor.
# Default limit: 300 lines # Backend default limit: 300 lines
# Frontend (webui/) default limit: 500 lines
# Entry/facade limit: 120 lines (enforced in script) # Entry/facade limit: 120 lines (enforced in script)
# Test files are ignored by the gate script.
internal/config/config.go internal/config/config.go
internal/config/logger.go internal/config/logger.go

View File

@@ -2,10 +2,11 @@
## Rules ## Rules
1. Production file default upper bound: `<= 300` lines. 1. Backend production files upper bound: `<= 300` lines.
2. Entry/facade files upper bound: `<= 120` lines. 2. Frontend (`webui/`) production files upper bound: `<= 500` lines.
3. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`. 3. Entry/facade files upper bound: `<= 120` lines.
4. Test files are out of scope for this gate. 4. Scope is limited to target files in `plans/refactor-line-gate-targets.txt`.
5. Test files are out of scope for this gate.
## Command ## Command

View File

@@ -94,6 +94,34 @@ test('parseToolCalls supports fenced json and function.arguments string payload'
assert.equal(calls.length, 0); assert.equal(calls.length, 0);
}); });
test('parseToolCalls parses text-kv fallback payload', () => {
const text = [
'[TOOL_CALL_HISTORY]',
'function.name: execute_command',
'function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}',
'[/TOOL_CALL_HISTORY]',
'Some other text thinking...',
].join('\n');
const calls = parseToolCalls(text, ['execute_command']);
assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'execute_command');
assert.equal(calls[0].input.command, 'cd scripts && python check_syntax.py example.py');
});
test('parseToolCalls parses multiple text-kv fallback payloads', () => {
const text = [
'function.name: read_file',
'function.arguments: {"path":"abc.txt"}',
'',
'function.name: bash',
'function.arguments: {"command":"ls"}',
].join('\n');
const calls = parseToolCalls(text, ['read_file', 'bash']);
assert.equal(calls.length, 2);
assert.equal(calls[0].name, 'read_file');
assert.equal(calls[1].name, 'bash');
});
test('parseStandaloneToolCalls only matches standalone payload and ignores mixed prose', () => { test('parseStandaloneToolCalls only matches standalone payload and ignores mixed prose', () => {
const mixed = '这里是示例:{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]},请勿执行。'; const mixed = '这里是示例:{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]},请勿执行。';
const standalone = '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'; const standalone = '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}';

View File

@@ -5,6 +5,7 @@ ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
TARGETS_FILE="$ROOT_DIR/plans/refactor-line-gate-targets.txt" TARGETS_FILE="$ROOT_DIR/plans/refactor-line-gate-targets.txt"
DEFAULT_MAX=300 DEFAULT_MAX=300
FRONTEND_MAX=500
ENTRY_MAX=120 ENTRY_MAX=120
is_entry_file() { is_entry_file() {
@@ -22,6 +23,27 @@ is_entry_file() {
return 1 return 1
} }
is_frontend_file() {
[[ "$1" == webui/* ]]
}
is_test_file() {
local file="$1"
local base
base="$(basename "$file")"
[[ "$file" == tests/* ]] && return 0
[[ "$file" == */tests/* ]] && return 0
[[ "$file" == */__tests__/* ]] && return 0
[[ "$base" == *_test.go ]] && return 0
[[ "$base" == *.test.js ]] && return 0
[[ "$base" == *.test.jsx ]] && return 0
[[ "$base" == *.test.ts ]] && return 0
[[ "$base" == *.test.tsx ]] && return 0
return 1
}
if [[ ! -f "$TARGETS_FILE" ]]; then if [[ ! -f "$TARGETS_FILE" ]]; then
echo "missing targets file: $TARGETS_FILE" >&2 echo "missing targets file: $TARGETS_FILE" >&2
exit 1 exit 1
@@ -35,6 +57,10 @@ while IFS= read -r file; do
[[ -z "$file" ]] && continue [[ -z "$file" ]] && continue
[[ "${file:0:1}" == "#" ]] && continue [[ "${file:0:1}" == "#" ]] && continue
if is_test_file "$file"; then
continue
fi
checked=$((checked + 1)) checked=$((checked + 1))
abs="$ROOT_DIR/$file" abs="$ROOT_DIR/$file"
if [[ ! -f "$abs" ]]; then if [[ ! -f "$abs" ]]; then
@@ -47,6 +73,8 @@ while IFS= read -r file; do
limit="$DEFAULT_MAX" limit="$DEFAULT_MAX"
if is_entry_file "$file"; then if is_entry_file "$file"; then
limit="$ENTRY_MAX" limit="$ENTRY_MAX"
elif is_frontend_file "$file"; then
limit="$FRONTEND_MAX"
fi fi
if (( lines > limit )); then if (( lines > limit )); then

View File

@@ -43,12 +43,15 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
sessionCounts,
deletingSessions,
addKey, addKey,
deleteKey, deleteKey,
addAccount, addAccount,
deleteAccount, deleteAccount,
testAccount, testAccount,
testAllAccounts, testAllAccounts,
deleteAllSessions,
} = useAccountActions({ } = useAccountActions({
apiFetch, apiFetch,
t, t,
@@ -81,6 +84,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
testing={testing} testing={testing}
testingAll={testingAll} testingAll={testingAll}
batchProgress={batchProgress} batchProgress={batchProgress}
sessionCounts={sessionCounts}
deletingSessions={deletingSessions}
totalAccounts={totalAccounts} totalAccounts={totalAccounts}
page={page} page={page}
pageSize={pageSize} pageSize={pageSize}
@@ -90,6 +95,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
onShowAddAccount={() => setShowAddAccount(true)} onShowAddAccount={() => setShowAddAccount(true)}
onTestAccount={testAccount} onTestAccount={testAccount}
onDeleteAccount={deleteAccount} onDeleteAccount={deleteAccount}
onDeleteAllSessions={deleteAllSessions}
onPrevPage={() => fetchAccounts(page - 1)} onPrevPage={() => fetchAccounts(page - 1)}
onNextPage={() => fetchAccounts(page + 1)} onNextPage={() => fetchAccounts(page + 1)}
onPageSizeChange={changePageSize} onPageSizeChange={changePageSize}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2 } from 'lucide-react' import { ChevronLeft, ChevronRight, Check, Copy, Play, Plus, Trash2, FolderX } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
export default function AccountsTable({ export default function AccountsTable({
@@ -9,6 +9,8 @@ export default function AccountsTable({
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
sessionCounts,
deletingSessions,
totalAccounts, totalAccounts,
page, page,
pageSize, pageSize,
@@ -18,6 +20,7 @@ export default function AccountsTable({
onShowAddAccount, onShowAddAccount,
onTestAccount, onTestAccount,
onDeleteAccount, onDeleteAccount,
onDeleteAllSessions,
onPrevPage, onPrevPage,
onNextPage, onNextPage,
onPageSizeChange, onPageSizeChange,
@@ -125,6 +128,25 @@ export default function AccountsTable({
{acc.token_preview} {acc.token_preview}
</span> </span>
)} )}
{sessionCounts && sessionCounts[id] !== undefined && (
<span className="font-mono bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded text-[10px]">
{t('accountManager.sessionCount', { count: sessionCounts[id] })}
</span>
)}
{sessionCounts && sessionCounts[id] !== undefined && sessionCounts[id] > 0 && (
<button
onClick={() => onDeleteAllSessions(id)}
disabled={deletingSessions && deletingSessions[id]}
className="flex items-center gap-1 font-mono bg-red-500/10 text-red-500 hover:bg-red-500/20 px-1.5 py-0.5 rounded text-[10px] transition-colors disabled:opacity-50"
title={t('accountManager.deleteAllSessions')}
>
{deletingSessions && deletingSessions[id] ? (
<span className="animate-spin"></span>
) : (
<FolderX className="w-3 h-3" />
)}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,8 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const [testing, setTesting] = useState({}) const [testing, setTesting] = useState({})
const [testingAll, setTestingAll] = useState(false) const [testingAll, setTestingAll] = useState(false)
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] }) const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0, results: [] })
const [sessionCounts, setSessionCounts] = useState({})
const [deletingSessions, setDeletingSessions] = useState({})
const addKey = async () => { const addKey = async () => {
if (!newKey.trim()) return if (!newKey.trim()) return
@@ -115,6 +117,12 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
body: JSON.stringify({ identifier: accountID }), body: JSON.stringify({ identifier: accountID }),
}) })
const data = await res.json() const data = await res.json()
// 更新会话数
if (data.session_count !== undefined) {
setSessionCounts(prev => ({ ...prev, [accountID]: data.session_count }))
}
const statusMessage = data.success const statusMessage = data.success
? t('apiTester.testSuccess', { account: accountID, time: data.response_time }) ? t('apiTester.testSuccess', { account: accountID, time: data.response_time })
: `${accountID}: ${data.message}` : `${accountID}: ${data.message}`
@@ -170,6 +178,41 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
setTestingAll(false) setTestingAll(false)
} }
const deleteAllSessions = async (identifier) => {
const accountID = String(identifier || '').trim()
if (!accountID) {
onMessage('error', t('accountManager.invalidIdentifier'))
return
}
if (!confirm(t('accountManager.deleteAllSessionsConfirm'))) return
setDeletingSessions(prev => ({ ...prev, [accountID]: true }))
try {
const res = await apiFetch('/admin/accounts/sessions/delete-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: accountID }),
})
const data = await res.json()
if (data.success) {
onMessage('success', t('accountManager.deleteAllSessionsSuccess'))
// 清除会话数显示
setSessionCounts(prev => {
const newCounts = { ...prev }
delete newCounts[accountID]
return newCounts
})
} else {
onMessage('error', data.message || t('messages.requestFailed'))
}
} catch (e) {
onMessage('error', t('messages.networkError'))
} finally {
setDeletingSessions(prev => ({ ...prev, [accountID]: false }))
}
}
return { return {
showAddKey, showAddKey,
setShowAddKey, setShowAddKey,
@@ -185,11 +228,14 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
sessionCounts,
deletingSessions,
addKey, addKey,
deleteKey, deleteKey,
addAccount, addAccount,
deleteAccount, deleteAccount,
testAccount, testAccount,
testAllAccounts, testAllAccounts,
deleteAllSessions,
} }
} }

View File

@@ -0,0 +1,39 @@
import { Trash2 } from 'lucide-react'
export default function AutoDeleteSection({ t, form, setForm }) {
return (
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<div className="flex items-center gap-2">
<Trash2 className="w-4 h-4 text-muted-foreground" />
<h3 className="font-semibold">{t('settings.autoDeleteTitle')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('settings.autoDeleteDesc')}</p>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{t('settings.autoDeleteSessions')}</label>
<button
type="button"
role="switch"
aria-checked={form.auto_delete?.sessions || false}
onClick={() => setForm((prev) => ({
...prev,
auto_delete: { ...prev.auto_delete, sessions: !prev.auto_delete?.sessions },
}))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
form.auto_delete?.sessions ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
form.auto_delete?.sessions ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{form.auto_delete?.sessions && (
<p className="text-xs text-amber-500 flex items-center gap-1">
{t('settings.autoDeleteWarning')}
</p>
)}
</div>
)
}

View File

@@ -5,6 +5,7 @@ import { useSettingsForm } from './useSettingsForm'
import SecuritySection from './SecuritySection' import SecuritySection from './SecuritySection'
import RuntimeSection from './RuntimeSection' import RuntimeSection from './RuntimeSection'
import BehaviorSection from './BehaviorSection' import BehaviorSection from './BehaviorSection'
import AutoDeleteSection from './AutoDeleteSection'
import ModelSection from './ModelSection' import ModelSection from './ModelSection'
import BackupSection from './BackupSection' import BackupSection from './BackupSection'
@@ -91,6 +92,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
<BehaviorSection t={t} form={form} setForm={setForm} /> <BehaviorSection t={t} form={form} setForm={setForm} />
<AutoDeleteSection t={t} form={form} setForm={setForm} />
<ModelSection t={t} form={form} setForm={setForm} /> <ModelSection t={t} form={form} setForm={setForm} />
<BackupSection <BackupSection

View File

@@ -16,6 +16,7 @@ const DEFAULT_FORM = {
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' }, toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
responses: { store_ttl_seconds: 900 }, responses: { store_ttl_seconds: 900 },
embeddings: { provider: '' }, embeddings: { provider: '' },
auto_delete: { sessions: false },
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}', claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
model_aliases_text: '{}', model_aliases_text: '{}',
} }
@@ -55,6 +56,9 @@ function fromServerForm(data) {
embeddings: { embeddings: {
provider: data.embeddings?.provider || '', provider: data.embeddings?.provider || '',
}, },
auto_delete: {
sessions: Boolean(data.auto_delete?.sessions || false),
},
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2), claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2), model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
} }
@@ -74,6 +78,7 @@ function toServerPayload(form) {
}, },
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) }, responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
embeddings: { provider: String(form.embeddings.provider || '').trim() }, embeddings: { provider: String(form.embeddings.provider || '').trim() },
auto_delete: { sessions: Boolean(form.auto_delete?.sessions) },
} }
} }

View File

@@ -131,7 +131,11 @@
"addAccountAction": "Add account", "addAccountAction": "Add account",
"pageInfo": "Page {current}/{total}, {count} accounts total", "pageInfo": "Page {current}/{total}, {count} accounts total",
"searchPlaceholder": "Search accounts...", "searchPlaceholder": "Search accounts...",
"searchNoResults": "No accounts match your search" "searchNoResults": "No accounts match your search",
"sessionCount": "Sessions: {count}",
"deleteAllSessions": "Delete all sessions",
"deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
"deleteAllSessionsSuccess": "Successfully deleted all sessions"
}, },
"apiTester": { "apiTester": {
"defaultMessage": "Hello, please introduce yourself in one sentence.", "defaultMessage": "Hello, please introduce yourself in one sentence.",
@@ -227,6 +231,10 @@
"modelTitle": "Model mapping", "modelTitle": "Model mapping",
"claudeMapping": "Claude mapping (JSON)", "claudeMapping": "Claude mapping (JSON)",
"modelAliases": "Model aliases (JSON)", "modelAliases": "Model aliases (JSON)",
"autoDeleteTitle": "Auto Delete Sessions",
"autoDeleteDesc": "When enabled, all sessions will be automatically deleted after each request completes.",
"autoDeleteSessions": "Auto delete sessions",
"autoDeleteWarning": "Warning: Enabling this will delete all session history after each request. Use with caution.",
"backupTitle": "Backup & Restore", "backupTitle": "Backup & Restore",
"loadExport": "Load current export", "loadExport": "Load current export",
"importModeMerge": "Merge import (default)", "importModeMerge": "Merge import (default)",

View File

@@ -131,7 +131,11 @@
"addAccountAction": "添加账号", "addAccountAction": "添加账号",
"pageInfo": "第 {current}/{total} 页,共 {count} 个账号", "pageInfo": "第 {current}/{total} 页,共 {count} 个账号",
"searchPlaceholder": "搜索账号...", "searchPlaceholder": "搜索账号...",
"searchNoResults": "未找到匹配的账号" "searchNoResults": "未找到匹配的账号",
"sessionCount": "会话: {count}",
"deleteAllSessions": "删除所有会话",
"deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
"deleteAllSessionsSuccess": "删除成功"
}, },
"apiTester": { "apiTester": {
"defaultMessage": "你好,请用一句话介绍你自己。", "defaultMessage": "你好,请用一句话介绍你自己。",
@@ -227,6 +231,10 @@
"modelTitle": "模型映射", "modelTitle": "模型映射",
"claudeMapping": "Claude 映射JSON", "claudeMapping": "Claude 映射JSON",
"modelAliases": "模型别名JSON", "modelAliases": "模型别名JSON",
"autoDeleteTitle": "自动删除会话",
"autoDeleteDesc": "开启后,每次请求完成后会自动删除该账号的所有会话记录。",
"autoDeleteSessions": "自动删除会话",
"autoDeleteWarning": "开启此功能后,每次请求完成都会删除该账号的所有历史会话,请谨慎使用。",
"backupTitle": "备份与恢复", "backupTitle": "备份与恢复",
"loadExport": "加载当前导出", "loadExport": "加载当前导出",
"importModeMerge": "合并导入(默认)", "importModeMerge": "合并导入(默认)",