Compare commits

...

30 Commits

Author SHA1 Message Date
CJACK.
b1f8d6192f Merge pull request #105 from CJackHwang/codex/fix-issues-found-in-review
Merge pull request #104 from CJackHwang/codex/revert-to-commit-efb484b

Restore tool-call parsing and repair logic; remove accidental split files
2026-03-20 02:38:35 +08:00
CJACK.
acfb3b225d Split toolcall input parsing to satisfy line gate 2026-03-20 02:37:23 +08:00
CJACK.
99a6164000 Fix path corruption when parsing tool call JSON strings 2026-03-20 02:31:37 +08:00
CJACK.
e49d9d33e2 Merge pull request #104 from CJackHwang/codex/revert-to-commit-efb484b
Restore tool-call parsing and repair logic; remove accidental split files
2026-03-20 02:17:52 +08:00
CJACK.
184a3d1e4e Sync Node tool-call parsing with aggressive fenced/mixed policy 2026-03-20 02:16:37 +08:00
CJACK.
c4ec14f49a Fix refactor line gate for toolcalls_parse 2026-03-20 02:12:34 +08:00
CJACK.
fb5fc0e885 Default to aggressive tool-call interception in mixed/fenced text 2026-03-20 02:03:46 +08:00
CJACK.
20b603666d Allow standalone parser to detect mixed prose tool JSON 2026-03-20 02:03:32 +08:00
CJACK.
4d549b7102 Revert "Merge branch 'dev' into codex/fix-issues-found-in-review"
This reverts commit 33b0d1d144, reversing
changes made to efb484ba4f.
2026-03-20 01:38:11 +08:00
CJACK.
33b0d1d144 Merge branch 'dev' into codex/fix-issues-found-in-review 2026-03-20 01:23:00 +08:00
CJACK.
efb484ba4f Merge pull request #103 from CJackHwang/codex/fix-threshold-issue-and-audit-pr
fix: unblock PR #101 line gate and improve PoW/token retry handling
2026-03-20 01:16:46 +08:00
CJACK.
145501d4a5 fix(tool-sieve): allow mixed prose + tool json interception 2026-03-20 01:15:32 +08:00
CJACK.
2d5103997b fix(tool-sieve): keep mixed prose tool json in strict text mode 2026-03-20 01:15:15 +08:00
CJACK.
52e7e7aae8 fix: unblock line gate and harden pow token recovery 2026-03-20 00:50:05 +08:00
CJACK.
5b5a4000d7 Merge pull request #99 from CJackHwang/codex/refactor-toolcalls_parse.go-for-line-limits
Codex-generated pull request
2026-03-19 21:06:45 +08:00
CJACK.
2bbf603148 fix: address PR #97 review findings 2026-03-18 00:52:24 +08:00
CJACK.
d14b8a0664 Stabilize tool-call parsing and pass refactor gate 2026-03-18 00:45:28 +08:00
CJACK.
f16e0b579e Merge pull request #92 from valkryhx/main
fix(toolcall): fix deepseek function calling bug and add json repair
2026-03-18 00:15:47 +08:00
huangxun
cf569f4749 docs: add testing documentation for tool call debugging
- Add targeted test commands to TESTING.md for debugging tool call issues
- Add quick test commands reference in README.md
- Document specific test cases for DeepSeek tool call parsing
2026-03-17 16:41:16 +08:00
huangxun
c9c59f2490 refactor(toolcall): enhance tool call extraction with multiple keywords and safety limits
- Add support for multiple keywords: tool_calls, function.name:, [tool_call_history]
- Add OOM protection with search limits in extractToolCallObjects
- Add max scan length limit in extractJSONObject to prevent OOM on unclosed objects
- Update tool_sieve to handle more tool call patterns
- Add loose JSON repair in parseToolCallPayload for better error recovery

This improves DeepSeek tool call parsing robustness.
2026-03-17 16:28:27 +08:00
huangxun
16216cc2ca fix(toolcalls): support nested objects in missing array brackets repair
- Upgrade missingArrayBracketsPattern regex to support single-level nested {} objects
- This fixes DeepSeek's list hallucination where tool call JSON objects contain nested fields like {"input": {"q": "value"}}
- Add comprehensive test cases covering 2-5 nested objects, mixed nested/primitive fields, and real DeepSeek 8-queen output patterns
- Add RepairLooseJSON function to repair unquoted keys and missing array brackets

Fixes: DeepSeek tool call parsing with nested JSON objects
2026-03-17 16:24:16 +08:00
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
huangxun
7318d1f4a8 fix(toolcall): fix deepseek function calling bug and add json repair
- Fix: Expand stream sieve keywords to support function.name: and [TOOL_CALL_HISTORY]

- Fix: Add repairInvalidJSONBackslashes to handle unescaped backslashes in Windows paths

- Sync: Update JS stream sieve to match Go implementation

- Test: Add unit tests for backslash repair and deepseek format parsing

- Tool: Move repair json test tool to tests/repair_json_tool.go
2026-03-13 13:47:40 +08:00
55 changed files with 2641 additions and 1167 deletions

View File

@@ -476,6 +476,23 @@ go run ./cmd/ds2api-tests \
npm ci --prefix webui && npm run build --prefix webui npm ci --prefix webui && npm run build --prefix webui
``` ```
## 测试
详细测试指南请参阅 [TESTING.md](TESTING.md)。
### 快速测试命令
```bash
# 运行所有单元测试
go test ./...
# 运行 tool calls 相关测试(调试工具调用问题)
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 运行端到端测试
./tests/scripts/run-live.sh
```
## Release 自动构建GitHub Actions ## Release 自动构建GitHub Actions
工作流文件:`.github/workflows/release-artifacts.yml` 工作流文件:`.github/workflows/release-artifacts.yml`

View File

@@ -173,6 +173,50 @@ rg "<trace_id>" artifacts/testsuite/<run_id>/server.log
go test ./... go test ./...
``` ```
### 运行特定模块的单元测试
```bash
# 运行 tool calls 相关测试(推荐用于调试 tool call 解析问题)
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 运行单个测试用例
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/
# 运行 format 相关测试
go test -v ./internal/format/...
# 运行 adapter 相关测试
go test -v ./internal/adapter/openai/...
```
### 调试 Tool Call 问题 | Debugging Tool Call Issues
当遇到 DeepSeek 工具调用解析问题时,可以使用以下方法:
```bash
# 1. 运行 tool calls 相关的所有测试
go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/
# 2. 查看测试输出中的详细调试信息
go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ 2>&1
# 3. 检查具体测试用例的修复效果
# 测试用例位于 internal/util/toolcalls_test.go包含
# - TestParseToolCallsWithDeepSeekHallucination: DeepSeek 典型幻觉输出
# - TestRepairLooseJSONWithNestedObjects: 嵌套对象的方括号修复
# - TestParseToolCallsWithMixedWindowsPaths: Windows 路径处理
```
### 运行 Node.js 测试
```bash
# 运行 Node 测试
node --test tests/node/stream-tool-sieve.test.js
# 或使用脚本
./tests/scripts/run-unit-node.sh
```
### 跑端到端测试(跳过 preflight ### 跑端到端测试(跳过 preflight
```bash ```bash

View File

@@ -358,7 +358,7 @@ func TestHandleClaudeStreamRealtimeToolSafetyAcrossStructuredFormats(t *testing.
} }
} }
func TestHandleClaudeStreamRealtimeDoesNotStopOnUnclosedFencedToolExample(t *testing.T) { func TestHandleClaudeStreamRealtimePromotesUnclosedFencedToolExample(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeClaudeSSEHTTPResponse( resp := makeClaudeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"Here is an example:\\n```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"Bash\\\",\\\"input\\\":{\\\"command\\\":\\\"pwd\\\"}}]}\"}", "data: {\"p\":\"response/content\",\"v\":\"Here is an example:\\n```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"Bash\\\",\\\"input\\\":{\\\"command\\\":\\\"pwd\\\"}}]}\"}",
@@ -371,22 +371,27 @@ func TestHandleClaudeStreamRealtimeDoesNotStopOnUnclosedFencedToolExample(t *tes
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"}) h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "show example only"}}, false, false, []string{"Bash"})
frames := parseClaudeFrames(t, rec.Body.String()) frames := parseClaudeFrames(t, rec.Body.String())
foundToolUse := false
for _, f := range findClaudeFrames(frames, "content_block_start") { for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any) contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" { if contentBlock["type"] == "tool_use" {
t.Fatalf("unexpected tool_use for fenced example, body=%s", rec.Body.String()) foundToolUse = true
}
}
foundEndTurn := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "end_turn" {
foundEndTurn = true
break break
} }
} }
if !foundEndTurn { if !foundToolUse {
t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String()) t.Fatalf("expected tool_use for fenced example, body=%s", rec.Body.String())
}
foundToolStop := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "tool_use" {
foundToolStop = true
break
}
}
if !foundToolStop {
t.Fatalf("expected stop_reason=tool_use, body=%s", rec.Body.String())
} }
} }

View File

@@ -98,11 +98,11 @@ func (s *chatStreamRuntime) sendDone() {
func (s *chatStreamRuntime) finalize(finishReason string) { func (s *chatStreamRuntime) finalize(finishReason string) {
finalThinking := s.thinking.String() finalThinking := s.thinking.String()
finalText := s.text.String() finalText := s.text.String()
detected := util.ParseStandaloneToolCalls(finalText, s.toolNames) detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames)
if len(detected) > 0 && !s.toolCallsDoneEmitted { if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted {
finishReason = "tool_calls" finishReason = "tool_calls"
delta := map[string]any{ delta := map[string]any{
"tool_calls": formatFinalStreamToolCallsWithStableIDs(detected, s.streamToolCallIDs), "tool_calls": formatFinalStreamToolCallsWithStableIDs(detected.Calls, s.streamToolCallIDs),
} }
if !s.firstChunkSent { if !s.firstChunkSent {
delta["role"] = "assistant" delta["role"] = "assistant"
@@ -158,7 +158,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) {
} }
} }
if len(detected) > 0 || s.toolCallsEmitted { if len(detected.Calls) > 0 || s.toolCallsEmitted {
finishReason = "tool_calls" finishReason = "tool_calls"
} }
s.sendChunk(openaifmt.BuildChatStreamChunk( s.sendChunk(openaifmt.BuildChatStreamChunk(

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,25 @@ 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 != "" {
deleteCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
err := h.DS.DeleteAllSessionsForToken(deleteCtx, 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,7 +53,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh
if len(toolSchemas) == 0 { if len(toolSchemas) == 0 {
return messages, names return messages, names
} }
toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON format (no other text):\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON. The response must start with { and end with }.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block." toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY a JSON code block like this:\n```json\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n```\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n```json\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n```\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON code block. The response must start with ```json and end with ```.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets."
if policy.Mode == util.ToolChoiceRequired { if policy.Mode == util.ToolChoiceRequired {
toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list." toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list."
} }

View File

@@ -211,7 +211,7 @@ func TestHandleNonStreamUnknownToolNotIntercepted(t *testing.T) {
} }
} }
func TestHandleNonStreamEmbeddedToolCallExampleRemainsText(t *testing.T) { func TestHandleNonStreamEmbeddedToolCallExamplePromotesToolCall(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeSSEHTTPResponse( resp := makeSSEHTTPResponse(
`data: {"p":"response/content","v":"下面是示例:"}`, `data: {"p":"response/content","v":"下面是示例:"}`,
@@ -229,20 +229,21 @@ func TestHandleNonStreamEmbeddedToolCallExampleRemainsText(t *testing.T) {
out := decodeJSONBody(t, rec.Body.String()) out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any) choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any) choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "stop" { if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"]) t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
} }
msg, _ := choice["message"].(map[string]any) msg, _ := choice["message"].(map[string]any)
if _, ok := msg["tool_calls"]; ok { toolCalls, _ := msg["tool_calls"].([]any)
t.Fatalf("did not expect tool_calls field for embedded example: %#v", msg["tool_calls"]) if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for embedded example: %#v", msg["tool_calls"])
} }
content, _ := msg["content"].(string) content, _ := msg["content"].(string)
if !strings.Contains(content, "下面是示例:") || !strings.Contains(content, "请勿执行。") || !strings.Contains(content, `"tool_calls"`) { if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected embedded example to remain plain text, got %#v", content) t.Fatalf("expected raw tool_calls json stripped from content, got %#v", content)
} }
} }
func TestHandleNonStreamFencedToolCallExampleNotIntercepted(t *testing.T) { func TestHandleNonStreamFencedToolCallExamplePromotesToolCall(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeSSEHTTPResponse( resp := makeSSEHTTPResponse(
"data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}", "data: {\"p\":\"response/content\",\"v\":\"```json\\n{\\\"tool_calls\\\":[{\\\"name\\\":\\\"search\\\",\\\"input\\\":{\\\"q\\\":\\\"go\\\"}}]}\\n```\"}",
@@ -258,16 +259,17 @@ func TestHandleNonStreamFencedToolCallExampleNotIntercepted(t *testing.T) {
out := decodeJSONBody(t, rec.Body.String()) out := decodeJSONBody(t, rec.Body.String())
choices, _ := out["choices"].([]any) choices, _ := out["choices"].([]any)
choice, _ := choices[0].(map[string]any) choice, _ := choices[0].(map[string]any)
if choice["finish_reason"] != "stop" { if choice["finish_reason"] != "tool_calls" {
t.Fatalf("expected finish_reason=stop, got %#v", choice["finish_reason"]) t.Fatalf("expected finish_reason=tool_calls, got %#v", choice["finish_reason"])
} }
msg, _ := choice["message"].(map[string]any) msg, _ := choice["message"].(map[string]any)
if _, ok := msg["tool_calls"]; ok { toolCalls, _ := msg["tool_calls"].([]any)
t.Fatalf("did not expect tool_calls field for fenced example: %#v", msg["tool_calls"]) if len(toolCalls) != 1 {
t.Fatalf("expected one tool_call field for fenced example: %#v", msg["tool_calls"])
} }
content, _ := msg["content"].(string) content, _ := msg["content"].(string)
if !strings.Contains(content, "```json") || !strings.Contains(content, `"tool_calls"`) { if strings.Contains(content, `"tool_calls"`) {
t.Fatalf("expected fenced tool example to pass through as text, got %q", content) t.Fatalf("expected raw tool_calls json stripped from content, got %q", content)
} }
} }
@@ -615,7 +617,7 @@ func TestHandleStreamToolCallWithSameChunkTrailingTextRemainsText(t *testing.T)
} }
} }
func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) { func TestHandleStreamFencedToolCallSnippetPromotesToolCall(t *testing.T) {
h := &Handler{} h := &Handler{}
resp := makeSSEHTTPResponse( resp := makeSSEHTTPResponse(
fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "下面是调用示例:\n```json\n"), fmt.Sprintf(`data: {"p":"response/content","v":%q}`, "下面是调用示例:\n```json\n"),
@@ -631,8 +633,8 @@ func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) {
if !done { if !done {
t.Fatalf("expected [DONE], body=%s", rec.Body.String()) t.Fatalf("expected [DONE], body=%s", rec.Body.String())
} }
if streamHasToolCallsDelta(frames) { if !streamHasToolCallsDelta(frames) {
t.Fatalf("did not expect tool_calls delta for fenced snippet, body=%s", rec.Body.String()) t.Fatalf("expected tool_calls delta for fenced snippet, body=%s", rec.Body.String())
} }
content := strings.Builder{} content := strings.Builder{}
for _, frame := range frames { for _, frame := range frames {
@@ -646,11 +648,11 @@ func TestHandleStreamFencedToolCallSnippetRemainsText(t *testing.T) {
} }
} }
got := content.String() got := content.String()
if !strings.Contains(got, "```json") || !strings.Contains(strings.ToLower(got), "tool_calls") { if strings.Contains(strings.ToLower(got), "tool_calls") {
t.Fatalf("expected fenced tool snippet in content, got=%q", got) t.Fatalf("expected raw fenced tool_calls snippet stripped from content, got=%q", got)
} }
if streamFinishReason(frames) != "stop" { if streamFinishReason(frames) != "tool_calls" {
t.Fatalf("expected finish_reason=stop, body=%s", rec.Body.String()) t.Fatalf("expected finish_reason=tool_calls, body=%s", rec.Body.String())
} }
} }

View File

@@ -297,7 +297,7 @@ func TestHandleResponsesStreamOutputTextDeltaCarriesItemIndexes(t *testing.T) {
} }
} }
func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *testing.T) { func TestHandleResponsesStreamThinkingAndMixedToolExampleEmitsFunctionCall(t *testing.T) {
h := &Handler{} h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@@ -333,6 +333,7 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te
responseObj, _ := completedPayload["response"].(map[string]any) responseObj, _ := completedPayload["response"].(map[string]any)
output, _ := responseObj["output"].([]any) output, _ := responseObj["output"].([]any)
hasMessage := false hasMessage := false
hasFunctionCall := false
for _, item := range output { for _, item := range output {
m, _ := item.(map[string]any) m, _ := item.(map[string]any)
if m == nil { if m == nil {
@@ -342,12 +343,15 @@ func TestHandleResponsesStreamThinkingAndMixedToolExampleRemainMessageOnly(t *te
hasMessage = true hasMessage = true
} }
if asString(m["type"]) == "function_call" { if asString(m["type"]) == "function_call" {
t.Fatalf("did not expect function_call output for mixed prose tool example, output=%#v", output) hasFunctionCall = true
} }
} }
if !hasMessage { if !hasMessage {
t.Fatalf("expected message output for mixed prose tool example, output=%#v", output) t.Fatalf("expected message output for mixed prose tool example, output=%#v", output)
} }
if !hasFunctionCall {
t.Fatalf("expected function_call output for mixed prose tool example, output=%#v", output)
}
} }
func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) { func TestHandleResponsesStreamToolChoiceNoneRejectsFunctionCall(t *testing.T) {

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") {
@@ -167,15 +171,15 @@ func TestResponsesNonStreamMixedProseToolPayloadHandlerPath(t *testing.T) {
t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String()) t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String())
} }
outputText, _ := out["output_text"].(string) outputText, _ := out["output_text"].(string)
if outputText == "" { if outputText != "" {
t.Fatalf("expected output_text preserved for mixed prose payload") t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText)
} }
output, _ := out["output"].([]any) output, _ := out["output"].([]any)
if len(output) != 1 { if len(output) != 1 {
t.Fatalf("expected one output item, got %#v", output) t.Fatalf("expected one output item, got %#v", output)
} }
first, _ := output[0].(map[string]any) first, _ := output[0].(map[string]any)
if first["type"] != "message" { if first["type"] != "function_call" {
t.Fatalf("expected message output item, got %#v", output) t.Fatalf("expected function_call output item, got %#v", output)
} }
} }

View File

@@ -167,22 +167,22 @@ func findToolSegmentStart(s string) int {
return -1 return -1
} }
lower := strings.ToLower(s) lower := strings.ToLower(s)
offset := 0 keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
for { bestKeyIdx := -1
keyRel := strings.Index(lower[offset:], "tool_calls") for _, kw := range keywords {
if keyRel < 0 { idx := strings.Index(lower, kw)
return -1 if idx >= 0 && (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx
} }
keyIdx := offset + keyRel
start := strings.LastIndex(s[:keyIdx], "{")
if start < 0 {
start = keyIdx
}
if !insideCodeFence(s[:start]) {
return start
}
offset = keyIdx + len("tool_calls")
} }
if bestKeyIdx < 0 {
return -1
}
start := strings.LastIndex(s[:bestKeyIdx], "{")
if start < 0 {
start = bestKeyIdx
}
return start
} }
func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) { func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix string, calls []util.ParsedToolCall, suffix string, ready bool) {
@@ -191,13 +191,22 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
return "", nil, "", false return "", nil, "", false
} }
lower := strings.ToLower(captured) lower := strings.ToLower(captured)
keyIdx := strings.Index(lower, "tool_calls")
keyIdx := -1
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
for _, kw := range keywords {
idx := strings.Index(lower, kw)
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
keyIdx = idx
}
}
if keyIdx < 0 { if keyIdx < 0 {
return "", nil, "", false return "", nil, "", false
} }
start := strings.LastIndex(captured[:keyIdx], "{") start := strings.LastIndex(captured[:keyIdx], "{")
if start < 0 { if start < 0 {
return "", nil, "", false start = keyIdx
} }
obj, end, ok := extractJSONObjectFrom(captured, start) obj, end, ok := extractJSONObjectFrom(captured, start)
if !ok { if !ok {
@@ -205,9 +214,6 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
} }
prefixPart := captured[:start] prefixPart := captured[:start]
suffixPart := captured[end:] suffixPart := captured[end:]
if insideCodeFence(state.recentTextTail + prefixPart) {
return captured, nil, "", true
}
parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames) parsed := util.ParseStandaloneToolCallsDetailed(obj, toolNames)
if len(parsed.Calls) == 0 { if len(parsed.Calls) == 0 {
if parsed.SawToolCallSyntax && parsed.RejectedByPolicy { if parsed.SawToolCallSyntax && parsed.RejectedByPolicy {
@@ -215,6 +221,9 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
// consume it to avoid leaking raw tool_calls JSON to user content. // consume it to avoid leaking raw tool_calls JSON to user content.
return prefixPart, nil, suffixPart, true return prefixPart, nil, suffixPart, true
} }
// If it has obvious keywords but failed to parse even after loose repair,
// we still might want to intercept it if it looks like an attempt at tool call.
// For now, keep the original logic but rely on loose JSON repair.
return captured, nil, "", true return captured, nil, "", true
} }
return prefixPart, parsed.Calls, suffixPart, true return prefixPart, parsed.Calls, suffixPart, true

View File

@@ -19,9 +19,6 @@ func buildIncrementalToolDeltas(state *toolStreamSieveState) []toolCallDelta {
if start < 0 { if start < 0 {
return nil return nil
} }
if insideCodeFence(state.recentTextTail + captured[:start]) {
return nil
}
certainSingle, hasMultiple := classifyToolCallsIncrementalSafety(captured, keyIdx) certainSingle, hasMultiple := classifyToolCallsIncrementalSafety(captured, keyIdx)
if hasMultiple { if hasMultiple {
state.disableDeltas = true state.disableDeltas = true

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,49 @@ 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 {
// token 可能过期,尝试重新登录并重试一次
newToken, loginErr := h.DS.Login(r.Context(), acc)
if loginErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
if retryErr := h.DS.DeleteAllSessionsForToken(r.Context(), token); retryErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}

View File

@@ -1,21 +1,28 @@
package admin package admin
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"ds2api/internal/auth" "ds2api/internal/auth"
"ds2api/internal/config" "ds2api/internal/config"
"ds2api/internal/deepseek"
) )
type testingDSMock struct { type testingDSMock struct {
loginCalls int loginCalls int
createSessionCalls int createSessionCalls int
getPowCalls int getPowCalls int
callCompletionCalls int callCompletionCalls int
deleteAllSessionsCalls int
deleteAllSessionsError error
deleteAllSessionsErrorOnce bool
} }
func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) { func (m *testingDSMock) Login(_ context.Context, _ config.Account) (string, error) {
@@ -38,6 +45,22 @@ 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 {
m.deleteAllSessionsCalls++
if m.deleteAllSessionsError != nil {
err := m.deleteAllSessionsError
if m.deleteAllSessionsErrorOnce {
m.deleteAllSessionsError = nil
}
return err
}
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()
@@ -74,3 +97,38 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Fatalf("expected test status ok, got %q", updated.TestStatus) t.Fatalf("expected test status ok, got %q", updated.TestStatus)
} }
} }
func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"expired-token"}]}`)
store := config.LoadStore()
ds := &testingDSMock{deleteAllSessionsError: errors.New("token expired"), deleteAllSessionsErrorOnce: true}
h := &Handler{Store: store, DS: ds}
req := httptest.NewRequest(http.MethodPost, "/delete-all", bytes.NewBufferString(`{"identifier":"batch@example.com"}`))
rec := httptest.NewRecorder()
h.deleteAllSessions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if ok, _ := resp["success"].(bool); !ok {
t.Fatalf("expected success response, got %#v", resp)
}
if ds.loginCalls != 1 {
t.Fatalf("expected relogin once, got %d", ds.loginCalls)
}
if ds.deleteAllSessionsCalls != 2 {
t.Fatalf("expected delete called twice, got %d", ds.deleteAllSessionsCalls)
}
updated, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected account")
}
if updated.Token != "new-token" {
t.Fatalf("expected refreshed token persisted, got %q", updated.Token)
}
}

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,8 +62,8 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
attempts++ attempts++
continue continue
} }
code := intFrom(resp["code"]) code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 { if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any) data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any) bizData, _ := data["biz_data"].(map[string]any)
sessionID, _ := bizData["id"].(string) sessionID, _ := bizData["id"].(string)
@@ -71,10 +71,9 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
return sessionID, nil return sessionID, nil
} }
} }
msg, _ := resp["msg"].(string) config.Logger.Warn("[create_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
config.Logger.Warn("[create_session] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
if a.UseConfigToken { if a.UseConfigToken {
if isTokenInvalid(status, code, msg) && !refreshed { if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) { if c.Auth.RefreshToken(ctx, a) {
refreshed = true refreshed = true
continue continue
@@ -96,6 +95,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
maxAttempts = c.maxRetries maxAttempts = c.maxRetries
} }
attempts := 0 attempts := 0
refreshed := false
for attempts < maxAttempts { for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken) headers := c.authHeaders(a.DeepSeekToken)
resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"}) resp, status, err := c.postJSONWithStatus(ctx, c.regular, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
@@ -104,8 +104,8 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
attempts++ attempts++
continue continue
} }
code := intFrom(resp["code"]) code, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 { if status == http.StatusOK && code == 0 && bizCode == 0 {
data, _ := resp["data"].(map[string]any) data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any) bizData, _ := data["biz_data"].(map[string]any)
challenge, _ := bizData["challenge"].(map[string]any) challenge, _ := bizData["challenge"].(map[string]any)
@@ -116,15 +116,16 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
} }
return BuildPowHeader(challenge, answer) return BuildPowHeader(challenge, answer)
} }
msg, _ := resp["msg"].(string) config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "msg", msg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
if a.UseConfigToken { if a.UseConfigToken {
if isTokenInvalid(status, code, msg) { if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !refreshed {
if c.Auth.RefreshToken(ctx, a) { if c.Auth.RefreshToken(ctx, a) {
refreshed = true
continue continue
} }
} }
if c.Auth.SwitchAccount(ctx, a) { if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++ attempts++
continue continue
} }
@@ -143,15 +144,34 @@ func (c *Client) authHeaders(token string) map[string]string {
return headers return headers
} }
func isTokenInvalid(status int, code int, msg string) bool { func isTokenInvalid(status int, code int, bizCode int, msg string, bizMsg string) bool {
msg = strings.ToLower(msg) msg = strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
if status == http.StatusUnauthorized || status == http.StatusForbidden { if status == http.StatusUnauthorized || status == http.StatusForbidden {
return true return true
} }
if code == 40001 || code == 40002 || code == 40003 { if code == 40001 || code == 40002 || code == 40003 || bizCode == 40001 || bizCode == 40002 || bizCode == 40003 {
return true return true
} }
return strings.Contains(msg, "token") || strings.Contains(msg, "unauthorized") return strings.Contains(msg, "token") ||
strings.Contains(msg, "unauthorized") ||
strings.Contains(msg, "expired") ||
strings.Contains(msg, "not login") ||
strings.Contains(msg, "login required") ||
strings.Contains(msg, "invalid jwt")
}
func extractResponseStatus(resp map[string]any) (code int, bizCode int, msg string, bizMsg string) {
code = intFrom(resp["code"])
msg, _ = resp["msg"].(string)
data, _ := resp["data"].(map[string]any)
bizCode = intFrom(data["biz_code"])
bizMsg, _ = data["biz_msg"].(string)
if strings.TrimSpace(bizMsg) == "" {
if bizData, ok := data["biz_data"].(map[string]any); ok {
bizMsg, _ = bizData["msg"].(string)
}
}
return code, bizCode, msg, bizMsg
} }
func normalizeMobileForLogin(raw string) (mobile string, areaCode any) { func normalizeMobileForLogin(raw string) (mobile string, areaCode any) {

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,256 @@
package deepseek
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"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, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 && bizCode == 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
}
stats.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[get_session_count] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "account", a.AccountID)
if a.UseConfigToken {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !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, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status != http.StatusOK || code != 0 || bizCode != 0 {
if strings.TrimSpace(bizMsg) != "" {
msg = bizMsg
}
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,155 @@
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, bizCode, msg, bizMsg := extractResponseStatus(resp)
if status == http.StatusOK && code == 0 && bizCode == 0 {
result.Success = true
return result, nil
}
result.ErrorMessage = fmt.Sprintf("status=%d, code=%d, msg=%s", status, code, msg)
config.Logger.Warn("[delete_session] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "session_id", sessionID)
if a.UseConfigToken {
if isTokenInvalid(status, code, bizCode, msg, bizMsg) && !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

@@ -8,15 +8,15 @@ import (
) )
func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
detected := util.ParseStandaloneToolCalls(finalText, toolNames) detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
finishReason := "stop" finishReason := "stop"
messageObj := map[string]any{"role": "assistant", "content": finalText} messageObj := map[string]any{"role": "assistant", "content": finalText}
if strings.TrimSpace(finalThinking) != "" { if strings.TrimSpace(finalThinking) != "" {
messageObj["reasoning_content"] = finalThinking messageObj["reasoning_content"] = finalThinking
} }
if len(detected) > 0 { if len(detected.Calls) > 0 {
finishReason = "tool_calls" finishReason = "tool_calls"
messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected) messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected.Calls)
messageObj["content"] = nil messageObj["content"] = nil
} }

View File

@@ -13,12 +13,12 @@ import (
func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any {
// Strict mode: only standalone, structured tool-call payloads are treated // Strict mode: only standalone, structured tool-call payloads are treated
// as executable tool calls. // as executable tool calls.
detected := util.ParseStandaloneToolCalls(finalText, toolNames) detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames)
exposedOutputText := finalText exposedOutputText := finalText
output := make([]any, 0, 2) output := make([]any, 0, 2)
if len(detected) > 0 { if len(detected.Calls) > 0 {
exposedOutputText = "" exposedOutputText = ""
output = append(output, toResponsesFunctionCallItems(detected)...) output = append(output, toResponsesFunctionCallItems(detected.Calls)...)
} else { } else {
content := make([]any, 0, 2) content := make([]any, 0, 2)
if finalThinking != "" { if finalThinking != "" {

View File

@@ -45,7 +45,7 @@ func TestBuildResponseObjectToolCallsFollowChatShape(t *testing.T) {
} }
} }
func TestBuildResponseObjectTreatsMixedProseToolPayloadAsText(t *testing.T) { func TestBuildResponseObjectPromotesMixedProseToolPayloadToFunctionCall(t *testing.T) {
obj := BuildResponseObject( obj := BuildResponseObject(
"resp_test", "resp_test",
"gpt-4o", "gpt-4o",
@@ -56,20 +56,20 @@ func TestBuildResponseObjectTreatsMixedProseToolPayloadAsText(t *testing.T) {
) )
outputText, _ := obj["output_text"].(string) outputText, _ := obj["output_text"].(string)
if outputText == "" { if outputText != "" {
t.Fatalf("expected output_text preserved for mixed prose payload") t.Fatalf("expected output_text hidden for mixed prose tool payload, got %q", outputText)
} }
output, _ := obj["output"].([]any) output, _ := obj["output"].([]any)
if len(output) != 1 { if len(output) != 1 {
t.Fatalf("expected one message output item, got %#v", obj["output"]) t.Fatalf("expected one function_call output item, got %#v", obj["output"])
} }
first, _ := output[0].(map[string]any) first, _ := output[0].(map[string]any)
if first["type"] != "message" { if first["type"] != "function_call" {
t.Fatalf("expected message output type, got %#v", first["type"]) t.Fatalf("expected function_call output type, got %#v", first["type"])
} }
} }
func TestBuildResponseObjectFencedToolPayloadRemainsText(t *testing.T) { func TestBuildResponseObjectPromotesFencedToolPayloadToFunctionCall(t *testing.T) {
obj := BuildResponseObject( obj := BuildResponseObject(
"resp_test", "resp_test",
"gpt-4o", "gpt-4o",
@@ -80,16 +80,16 @@ func TestBuildResponseObjectFencedToolPayloadRemainsText(t *testing.T) {
) )
outputText, _ := obj["output_text"].(string) outputText, _ := obj["output_text"].(string)
if outputText == "" { if outputText != "" {
t.Fatalf("expected output_text preserved for fenced example") t.Fatalf("expected output_text hidden for fenced tool payload, got %q", outputText)
} }
output, _ := obj["output"].([]any) output, _ := obj["output"].([]any)
if len(output) != 1 { if len(output) != 1 {
t.Fatalf("expected one message output item, got %#v", obj["output"]) t.Fatalf("expected one function_call output item, got %#v", obj["output"])
} }
first, _ := output[0].(map[string]any) first, _ := output[0].(map[string]any)
if first["type"] != "message" { if first["type"] != "function_call" {
t.Fatalf("expected message output type, got %#v", first["type"]) t.Fatalf("expected function_call output type, got %#v", first["type"])
} }
} }

View File

@@ -2,10 +2,8 @@
const { const {
toStringSafe, toStringSafe,
looksLikeToolExampleContext,
} = require('./state'); } = require('./state');
const { const {
stripFencedCodeBlocks,
buildToolCallCandidates, buildToolCallCandidates,
parseToolCallsPayload, parseToolCallsPayload,
parseMarkupToolCalls, parseMarkupToolCalls,
@@ -38,16 +36,13 @@ function parseToolCalls(text, toolNames) {
function parseToolCallsDetailed(text, toolNames) { function parseToolCallsDetailed(text, toolNames) {
const result = emptyParseResult(); const result = emptyParseResult();
if (!toStringSafe(text)) { const normalized = toStringSafe(text);
if (!normalized) {
return result; return result;
} }
const sanitized = stripFencedCodeBlocks(text); result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized);
if (!toStringSafe(sanitized)) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(sanitized);
const candidates = buildToolCallCandidates(sanitized); const candidates = buildToolCallCandidates(normalized);
let parsed = []; let parsed = [];
for (const c of candidates) { for (const c of candidates) {
parsed = parseToolCallsPayload(c); parsed = parseToolCallsPayload(c);
@@ -63,9 +58,9 @@ function parseToolCallsDetailed(text, toolNames) {
} }
} }
if (parsed.length === 0) { if (parsed.length === 0) {
parsed = parseMarkupToolCalls(sanitized); parsed = parseMarkupToolCalls(normalized);
if (parsed.length === 0) { if (parsed.length === 0) {
parsed = parseTextKVToolCalls(sanitized); parsed = parseTextKVToolCalls(normalized);
if (parsed.length === 0) { if (parsed.length === 0) {
return result; return result;
} }
@@ -90,22 +85,29 @@ function parseStandaloneToolCallsDetailed(text, toolNames) {
if (!trimmed) { if (!trimmed) {
return result; return result;
} }
if (trimmed.includes('```')) {
return result;
}
if (looksLikeToolExampleContext(trimmed)) {
return result;
}
result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed); result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed);
let parsed = parseToolCallsPayload(trimmed); const candidates = buildToolCallCandidates(trimmed);
let parsed = [];
for (const c of candidates) {
parsed = parseToolCallsPayload(c);
if (parsed.length === 0) {
parsed = parseMarkupToolCalls(c);
}
if (parsed.length === 0) {
parsed = parseTextKVToolCalls(c);
}
if (parsed.length > 0) {
break;
}
}
if (parsed.length === 0) { if (parsed.length === 0) {
parsed = parseMarkupToolCalls(trimmed); parsed = parseMarkupToolCalls(trimmed);
} if (parsed.length === 0) {
if (parsed.length === 0) { parsed = parseTextKVToolCalls(trimmed);
parsed = parseTextKVToolCalls(trimmed); if (parsed.length === 0) {
} return result;
if (parsed.length === 0) { }
return result; }
} }
result.sawToolCallSyntax = true; result.sawToolCallSyntax = true;

View File

@@ -46,6 +46,9 @@ function processToolSieveChunk(state, chunk, toolNames) {
if (Array.isArray(consumed.calls) && consumed.calls.length > 0) { if (Array.isArray(consumed.calls) && consumed.calls.length > 0) {
state.pendingToolRaw = captured; state.pendingToolRaw = captured;
state.pendingToolCalls = consumed.calls; state.pendingToolCalls = consumed.calls;
if (consumed.suffix) {
state.pending = consumed.suffix + state.pending;
}
continue; continue;
} }
if (consumed.prefix) { if (consumed.prefix) {
@@ -165,19 +168,34 @@ function findToolSegmentStart(s) {
return -1; return -1;
} }
const lower = s.toLowerCase(); const lower = s.toLowerCase();
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
let offset = 0; let offset = 0;
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
while (true) { while (true) {
const keyIdx = lower.indexOf('tool_calls', offset); let bestKeyIdx = -1;
if (keyIdx < 0) { let matchedKeyword = '';
for (const kw of keywords) {
const idx = lower.indexOf(kw, offset);
if (idx >= 0) {
if (bestKeyIdx < 0 || idx < bestKeyIdx) {
bestKeyIdx = idx;
matchedKeyword = kw;
}
}
}
if (bestKeyIdx < 0) {
return -1; return -1;
} }
const keyIdx = bestKeyIdx;
const start = s.slice(0, keyIdx).lastIndexOf('{'); const start = s.slice(0, keyIdx).lastIndexOf('{');
const candidateStart = start >= 0 ? start : keyIdx; const candidateStart = start >= 0 ? start : keyIdx;
if (!insideCodeFence(s.slice(0, candidateStart))) { if (!insideCodeFence(s.slice(0, candidateStart))) {
return candidateStart; return candidateStart;
} }
offset = keyIdx + 'tool_calls'.length; offset = keyIdx + matchedKeyword.length;
} }
} }
@@ -187,20 +205,28 @@ function consumeToolCapture(state, toolNames) {
return { ready: false, prefix: '', calls: [], suffix: '' }; return { ready: false, prefix: '', calls: [], suffix: '' };
} }
const lower = captured.toLowerCase(); const lower = captured.toLowerCase();
const keyIdx = lower.indexOf('tool_calls');
let keyIdx = -1;
const keywords = ['tool_calls', 'function.name:', '[tool_call_history]'];
for (const kw of keywords) {
const idx = lower.indexOf(kw);
if (idx >= 0 && (keyIdx < 0 || idx < keyIdx)) {
keyIdx = idx;
}
}
if (keyIdx < 0) { if (keyIdx < 0) {
return { ready: false, prefix: '', calls: [], suffix: '' }; return { ready: false, prefix: '', calls: [], suffix: '' };
} }
const start = captured.slice(0, keyIdx).lastIndexOf('{'); const start = captured.slice(0, keyIdx).lastIndexOf('{');
if (start < 0) { const actualStart = start >= 0 ? start : keyIdx;
return { ready: false, prefix: '', calls: [], suffix: '' };
} const obj = extractJSONObjectFrom(captured, actualStart);
const obj = extractJSONObjectFrom(captured, start);
if (!obj.ok) { if (!obj.ok) {
return { ready: false, prefix: '', calls: [], suffix: '' }; return { ready: false, prefix: '', calls: [], suffix: '' };
} }
const prefixPart = captured.slice(0, start); const prefixPart = captured.slice(0, actualStart);
const suffixPart = captured.slice(obj.end); const suffixPart = captured.slice(obj.end);
if (insideCodeFence((state.recentTextTail || '') + prefixPart)) { if (insideCodeFence((state.recentTextTail || '') + prefixPart)) {
@@ -212,16 +238,7 @@ function consumeToolCapture(state, toolNames) {
}; };
} }
if ((state.recentTextTail || '').trim() !== '' || prefixPart.trim() !== '' || suffixPart.trim() !== '') { const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames);
return {
ready: true,
prefix: captured,
calls: [],
suffix: '',
};
}
const parsed = parseStandaloneToolCallsDetailed(captured.slice(start, obj.end), toolNames);
if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) { if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) {
if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) { if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) {
return { return {

View File

@@ -20,7 +20,7 @@ func buildToolCallCandidates(text string) []string {
} }
} }
// best-effort extraction around "tool_calls" key in mixed text payloads. // best-effort extraction around tool call keywords in mixed text payloads.
candidates = append(candidates, extractToolCallObjects(trimmed)...) candidates = append(candidates, extractToolCallObjects(trimmed)...)
// best-effort object slice: from first '{' to last '}' // best-effort object slice: from first '{' to last '}'
@@ -57,25 +57,65 @@ func extractToolCallObjects(text string) []string {
lower := strings.ToLower(text) lower := strings.ToLower(text)
out := []string{} out := []string{}
offset := 0 offset := 0
keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"}
for { for {
idx := strings.Index(lower[offset:], "tool_calls") bestIdx := -1
if idx < 0 { matchedKeyword := ""
for _, kw := range keywords {
idx := strings.Index(lower[offset:], kw)
if idx >= 0 {
absIdx := offset + idx
if bestIdx < 0 || absIdx < bestIdx {
bestIdx = absIdx
matchedKeyword = kw
}
}
}
if bestIdx < 0 {
break break
} }
idx += offset
start := strings.LastIndex(text[:idx], "{") idx := bestIdx
for start >= 0 { // Avoid backtracking too far to prevent OOM on malicious or very long strings
searchLimit := idx - 2000
if searchLimit < offset {
searchLimit = offset
}
start := strings.LastIndex(text[searchLimit:idx], "{")
if start >= 0 {
start += searchLimit
}
if start < 0 {
offset = idx + len(matchedKeyword)
continue
}
foundObj := false
for start >= searchLimit {
candidate, end, ok := extractJSONObject(text, start) candidate, end, ok := extractJSONObject(text, start)
if ok { if ok {
// Move forward to avoid repeatedly matching the same object. // Move forward to avoid repeatedly matching the same object.
offset = end offset = end
out = append(out, strings.TrimSpace(candidate)) out = append(out, strings.TrimSpace(candidate))
foundObj = true
break break
} }
start = strings.LastIndex(text[:start], "{") // Try previous '{'
if start > searchLimit {
prevStart := strings.LastIndex(text[searchLimit:start], "{")
if prevStart >= 0 {
start = searchLimit + prevStart
continue
}
}
break
} }
if start < 0 {
offset = idx + len("tool_calls") if !foundObj {
offset = idx + len(matchedKeyword)
} }
} }
return out return out
@@ -88,7 +128,12 @@ func extractJSONObject(text string, start int) (string, int, bool) {
depth := 0 depth := 0
quote := byte(0) quote := byte(0)
escaped := false escaped := false
for i := start; i < len(text); i++ { // Limit scan length to avoid OOM on unclosed objects
maxLen := start + 50000
if maxLen > len(text) {
maxLen = len(text)
}
for i := start; i < maxLen; i++ {
ch := text[i] ch := text[i]
if quote != 0 { if quote != 0 {
if escaped { if escaped {

View File

@@ -0,0 +1,106 @@
package util
import (
"encoding/json"
"strings"
"unicode"
)
func parseToolCallInput(v any) map[string]any {
switch x := v.(type) {
case nil:
return map[string]any{}
case map[string]any:
return x
case string:
raw := strings.TrimSpace(x)
if raw == "" {
return map[string]any{}
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil {
repairPathLikeControlChars(parsed)
return parsed
}
// Try to repair invalid backslashes (common in Windows paths output by models)
repaired := repairInvalidJSONBackslashes(raw)
if repaired != raw {
if err := json.Unmarshal([]byte(repaired), &parsed); err == nil && parsed != nil {
return parsed
}
}
// Try to repair loose JSON in string argument as well
repairedLoose := RepairLooseJSON(raw)
if repairedLoose != raw {
if err := json.Unmarshal([]byte(repairedLoose), &parsed); err == nil && parsed != nil {
return parsed
}
}
return map[string]any{"_raw": raw}
default:
b, err := json.Marshal(x)
if err != nil {
return map[string]any{}
}
var parsed map[string]any
if err := json.Unmarshal(b, &parsed); err == nil && parsed != nil {
return parsed
}
return map[string]any{}
}
}
func repairPathLikeControlChars(m map[string]any) {
for k, v := range m {
switch vv := v.(type) {
case map[string]any:
repairPathLikeControlChars(vv)
case []any:
for _, item := range vv {
if child, ok := item.(map[string]any); ok {
repairPathLikeControlChars(child)
}
}
case string:
if isPathLikeKey(k) && containsControlRune(vv) {
m[k] = escapeControlRunes(vv)
}
}
}
}
func isPathLikeKey(key string) bool {
k := strings.ToLower(strings.TrimSpace(key))
return strings.Contains(k, "path") || strings.Contains(k, "file")
}
func containsControlRune(s string) bool {
for _, r := range s {
if unicode.IsControl(r) {
return true
}
}
return false
}
func escapeControlRunes(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
for _, r := range s {
switch r {
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -0,0 +1,79 @@
package util
import (
"regexp"
"strings"
)
func repairInvalidJSONBackslashes(s string) string {
if !strings.Contains(s, "\\") {
return s
}
var out strings.Builder
out.Grow(len(s) + 10)
runes := []rune(s)
for i := 0; i < len(runes); i++ {
if runes[i] == '\\' {
if i+1 < len(runes) {
next := runes[i+1]
switch next {
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
out.WriteRune('\\')
out.WriteRune(next)
i++
continue
case 'u':
if i+5 < len(runes) {
isHex := true
for j := 1; j <= 4; j++ {
r := runes[i+1+j]
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
isHex = false
break
}
}
if isHex {
out.WriteRune('\\')
out.WriteRune('u')
for j := 1; j <= 4; j++ {
out.WriteRune(runes[i+1+j])
}
i += 5
continue
}
}
}
}
// Not a valid escape sequence, double it
out.WriteString("\\\\")
} else {
out.WriteRune(runes[i])
}
}
return out.String()
}
var unquotedKeyPattern = regexp.MustCompile(`([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`)
// missingArrayBracketsPattern identifies a sequence of two or more JSON objects separated by commas
// that immediately follow a colon, which indicates a missing array bracket `[` `]`.
// E.g., "key": {"a": 1}, {"b": 2} -> "key": [{"a": 1}, {"b": 2}]
// NOTE: The pattern uses (?:[^{}]|\{[^{}]*\})* to support single-level nested {} objects,
// which handles cases like {"content": "x", "input": {"q": "y"}}
var missingArrayBracketsPattern = regexp.MustCompile(`(:\s*)(\{(?:[^{}]|\{[^{}]*\})*\}(?:\s*,\s*\{(?:[^{}]|\{[^{}]*\})*\})+)`)
func RepairLooseJSON(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
// 1. Replace unquoted keys: {key: -> {"key":
s = unquotedKeyPattern.ReplaceAllString(s, `$1"$2":`)
// 2. Heuristic: Fix missing array brackets for list of objects
// e.g., : {obj1}, {obj2} -> : [{obj1}, {obj2}]
// This specifically addresses DeepSeek's "list hallucination"
s = missingArrayBracketsPattern.ReplaceAllString(s, `$1[$2]`)
return s
}

View File

@@ -16,7 +16,6 @@ type ToolCallParseResult struct {
RejectedByPolicy bool RejectedByPolicy bool
RejectedToolNames []string RejectedToolNames []string
} }
func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall { func ParseToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseToolCallsDetailed(text, availableToolNames).Calls return ParseToolCallsDetailed(text, availableToolNames).Calls
} }
@@ -26,10 +25,6 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
if strings.TrimSpace(text) == "" { if strings.TrimSpace(text) == "" {
return result return result
} }
text = stripFencedCodeBlocks(text)
if strings.TrimSpace(text) == "" {
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(text) result.SawToolCallSyntax = looksLikeToolCallSyntax(text)
candidates := buildToolCallCandidates(text) candidates := buildToolCallCandidates(text)
@@ -68,7 +63,6 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0 result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result return result
} }
func ParseStandaloneToolCalls(text string, availableToolNames []string) []ParsedToolCall { func ParseStandaloneToolCalls(text string, availableToolNames []string) []ParsedToolCall {
return ParseStandaloneToolCallsDetailed(text, availableToolNames).Calls return ParseStandaloneToolCallsDetailed(text, availableToolNames).Calls
} }
@@ -79,17 +73,15 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
if trimmed == "" { if trimmed == "" {
return result return result
} }
if looksLikeToolExampleContext(trimmed) {
return result
}
result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed) result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed)
candidates := []string{trimmed} candidates := buildToolCallCandidates(trimmed)
var parsed []ParsedToolCall
for _, candidate := range candidates { for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate) candidate = strings.TrimSpace(candidate)
if candidate == "" { if candidate == "" {
continue continue
} }
parsed := parseToolCallsPayload(candidate) parsed = parseToolCallsPayload(candidate)
if len(parsed) == 0 { if len(parsed) == 0 {
parsed = parseXMLToolCalls(candidate) parsed = parseXMLToolCalls(candidate)
} }
@@ -100,14 +92,23 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string)
parsed = parseTextKVToolCalls(candidate) parsed = parseTextKVToolCalls(candidate)
} }
if len(parsed) > 0 { if len(parsed) > 0 {
result.SawToolCallSyntax = true break
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result
} }
} }
if len(parsed) == 0 {
parsed = parseXMLToolCalls(trimmed)
if len(parsed) == 0 {
parsed = parseTextKVToolCalls(trimmed)
if len(parsed) == 0 {
return result
}
}
}
result.SawToolCallSyntax = true
calls, rejectedNames := filterToolCallsDetailed(parsed, availableToolNames)
result.Calls = calls
result.RejectedToolNames = rejectedNames
result.RejectedByPolicy = len(rejectedNames) > 0 && len(calls) == 0
return result return result
} }
@@ -171,7 +172,13 @@ func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCan
func parseToolCallsPayload(payload string) []ParsedToolCall { func parseToolCallsPayload(payload string) []ParsedToolCall {
var decoded any var decoded any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil { if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
return nil // Try to repair backslashes first! Because LLMs often mix these two problems.
repaired := repairInvalidJSONBackslashes(payload)
// Try loose repair on top of that
repaired = RepairLooseJSON(repaired)
if err := json.Unmarshal([]byte(repaired), &decoded); err != nil {
return nil
}
} }
switch v := decoded.(type) { switch v := decoded.(type) {
case map[string]any: case map[string]any:
@@ -248,32 +255,3 @@ func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
Input: parseToolCallInput(inputRaw), Input: parseToolCallInput(inputRaw),
}, true }, true
} }
func parseToolCallInput(v any) map[string]any {
switch x := v.(type) {
case nil:
return map[string]any{}
case map[string]any:
return x
case string:
raw := strings.TrimSpace(x)
if raw == "" {
return map[string]any{}
}
var parsed map[string]any
if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil {
return parsed
}
return map[string]any{"_raw": raw}
default:
b, err := json.Marshal(x)
if err != nil {
return map[string]any{}
}
var parsed map[string]any
if err := json.Unmarshal(b, &parsed); err == nil && parsed != nil {
return parsed
}
return map[string]any{}
}
}

View File

@@ -1,6 +1,9 @@
package util package util
import "testing" import (
"strings"
"testing"
)
func TestParseToolCalls(t *testing.T) { func TestParseToolCalls(t *testing.T) {
text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix` text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix`
@@ -19,8 +22,8 @@ func TestParseToolCalls(t *testing.T) {
func TestParseToolCallsFromFencedJSON(t *testing.T) { func TestParseToolCallsFromFencedJSON(t *testing.T) {
text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```" text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```"
calls := ParseToolCalls(text, []string{"search"}) calls := ParseToolCalls(text, []string{"search"})
if len(calls) != 0 { if len(calls) != 1 {
t.Fatalf("expected fenced tool_call example to be ignored, got %#v", calls) t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
} }
} }
@@ -96,10 +99,10 @@ func TestFormatOpenAIToolCalls(t *testing.T) {
} }
} }
func TestParseStandaloneToolCallsOnlyMatchesStandalonePayload(t *testing.T) { func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) {
mixed := `这里是示例:{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` mixed := `这里是示例:{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 0 { if calls := ParseStandaloneToolCalls(mixed, []string{"search"}); len(calls) != 1 {
t.Fatalf("expected standalone parser to ignore mixed prose, got %#v", calls) t.Fatalf("expected standalone parser to parse mixed prose payload, got %#v", calls)
} }
standalone := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}` standalone := `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`
@@ -109,10 +112,10 @@ func TestParseStandaloneToolCallsOnlyMatchesStandalonePayload(t *testing.T) {
} }
} }
func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) { func TestParseStandaloneToolCallsParsesFencedCodeBlock(t *testing.T) {
fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```" fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```"
if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 0 { if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 1 {
t.Fatalf("expected fenced tool_call example to be ignored, got %#v", calls) t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls)
} }
} }
@@ -279,3 +282,238 @@ func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) {
t.Fatalf("expected mismatched tags to be rejected, got %#v", calls) t.Fatalf("expected mismatched tags to be rejected, got %#v", calls)
} }
} }
func TestRepairInvalidJSONBackslashes(t *testing.T) {
tests := []struct {
input string
expected string
}{
{`{"path": "C:\Users\name"}`, `{"path": "C:\\Users\name"}`},
{`{"cmd": "cd D:\git_codes"}`, `{"cmd": "cd D:\\git_codes"}`},
{`{"text": "line1\nline2"}`, `{"text": "line1\nline2"}`},
{`{"path": "D:\\back\\slash"}`, `{"path": "D:\\back\\slash"}`},
{`{"unicode": "\u2705"}`, `{"unicode": "\u2705"}`},
{`{"invalid_u": "\u123"}`, `{"invalid_u": "\\u123"}`},
}
for _, tt := range tests {
got := repairInvalidJSONBackslashes(tt.input)
if got != tt.expected {
t.Errorf("repairInvalidJSONBackslashes(%s) = %s; want %s", tt.input, got, tt.expected)
}
}
}
func TestRepairLooseJSON(t *testing.T) {
tests := []struct {
input string
expected string
}{
{`{tool_calls: [{"name": "search", "input": {"q": "go"}}]}`, `{"tool_calls": [{"name": "search", "input": {"q": "go"}}]}`},
{`{name: "search", input: {q: "go"}}`, `{"name": "search", "input": {"q": "go"}}`},
}
for _, tt := range tests {
got := RepairLooseJSON(tt.input)
if got != tt.expected {
t.Errorf("RepairLooseJSON(%s) = %s; want %s", tt.input, got, tt.expected)
}
}
}
func TestParseToolCallsWithUnquotedKeys(t *testing.T) {
text := `这里是列表:{tool_calls: [{"name": "todowrite", "input": {"todos": "test"}}]}`
availableTools := []string{"todowrite"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(parsed))
}
if parsed[0].Name != "todowrite" {
t.Errorf("expected tool todowrite, got %s", parsed[0].Name)
}
}
func TestParseToolCallsWithInvalidBackslashes(t *testing.T) {
// DeepSeek sometimes outputs Windows paths with single backslashes in JSON strings
// Note: using raw string to simulate what AI actually sends in the stream
text := `好的,执行以下命令:{"name": "execute_command", "input": "{\"command\": \"cd D:\git_codes && dir\"}"}`
availableTools := []string{"execute_command"}
parsed := ParseToolCalls(text, availableTools)
// If standard JSON fails, buildToolCallCandidates should still extract the object,
// and parseToolCallsPayload should repair it.
if len(parsed) != 1 {
// If it still fails, let's see why
candidates := buildToolCallCandidates(text)
t.Logf("Candidates: %v", candidates)
t.Fatalf("expected 1 tool call, got %d", len(parsed))
}
cmd, ok := parsed[0].Input["command"].(string)
if !ok {
t.Fatalf("expected command string in input, got %v", parsed[0].Input)
}
expected := "cd D:\\git_codes && dir"
if cmd != expected {
t.Errorf("expected command %q, got %q", expected, cmd)
}
}
func TestParseToolCallsWithDeepSeekHallucination(t *testing.T) {
// 模拟 DeepSeek 典型的幻觉输出:未加引号的键名 + 包含 Windows 路径的嵌套 JSON 字符串 + 漏掉列表的方括号
text := `检测到实施意图——实现经典算法。需在misc/目录创建Python文件。
关键约束:
1. Windows UTF-8编码处理
2. 必须用绝对路径导入
3. 禁止write覆盖已有文件misc/目录允许创建新文件)
将任务分解并委托:
- 研究8皇后算法模式并行探索
- 实现带可视化输出的解决方案unspecified-high
先创建todo列表追踪步骤。
{tool_calls: [{"name": "todowrite", "input": {"todos": {"content": "研究8皇后问题算法模式回溯法和输出格式", "status": "pending", "priority": "high"}, {"content": "在misc/目录创建8皇后Python脚本包含完整解决方案和可视化输出", "status": "pending", "priority": "high"}, {"content": "验证脚本正确性(运行测试)", "status": "pending", "priority": "medium"}}}]}`
availableTools := []string{"todowrite"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
cands := buildToolCallCandidates(text)
for i, c := range cands {
t.Logf("CAND %d: %s", i, c)
repaired := RepairLooseJSON(c)
t.Logf(" REPAIRED: %s", repaired)
}
t.Fatalf("expected 1 tool call, got %d. Candidates: %v", len(parsed), buildToolCallCandidates(text))
}
if parsed[0].Name != "todowrite" {
t.Errorf("expected tool name 'todowrite', got %q", parsed[0].Name)
}
todos, ok := parsed[0].Input["todos"].([]any)
if !ok {
t.Fatalf("expected 'todos' to be parsed as a list, got %T: %#v", parsed[0].Input["todos"], parsed[0].Input["todos"])
}
if len(todos) != 3 {
t.Errorf("expected 3 todo items, got %d", len(todos))
}
}
func TestParseToolCallsWithMixedWindowsPaths(t *testing.T) {
// 更复杂的案例:嵌套 JSON 字符串中的反斜杠未转义
text := `关键约束: 1. Windows UTF-8编码处理 2. 必须用绝对路径导入 D:\git_codes\ds2api\misc
{tool_calls: [{"name": "write_file", "input": "{\"path\": \"D:\\git_codes\\ds2api\\misc\\queens.py\", \"content\": \"print('hello')\"}"}]}`
availableTools := []string{"write_file"}
parsed := ParseToolCalls(text, availableTools)
if len(parsed) != 1 {
t.Fatalf("expected 1 tool call from mixed text with paths, got %d", len(parsed))
}
path, _ := parsed[0].Input["path"].(string)
// 在解析后的 Go map 中,反斜杠应该被还原
if !strings.Contains(path, "D:\\git_codes") && !strings.Contains(path, "D:/git_codes") {
t.Errorf("expected path to contain Windows style separators, got %q", path)
}
}
func TestParseToolCallInputRepairsControlCharsInPath(t *testing.T) {
in := `{"path":"D:\tmp\new\readme.txt","content":"line1\nline2"}`
parsed := parseToolCallInput(in)
path, ok := parsed["path"].(string)
if !ok {
t.Fatalf("expected path string in parsed input, got %#v", parsed["path"])
}
if path != `D:\tmp\new\readme.txt` {
t.Fatalf("expected repaired windows path, got %q", path)
}
content, ok := parsed["content"].(string)
if !ok {
t.Fatalf("expected content string in parsed input, got %#v", parsed["content"])
}
if content != "line1\nline2" {
t.Fatalf("expected non-path field to keep decoded escapes, got %q", content)
}
}
func TestRepairLooseJSONWithNestedObjects(t *testing.T) {
// 测试嵌套对象的修复DeepSeek 幻觉输出,每个元素内部包含嵌套 {}
// 注意:正则只支持单层嵌套,不支持更深层次的嵌套
tests := []struct {
name string
input string
expected string
}{
// 1. 单层嵌套对象(核心修复目标)
{
name: "单层嵌套 - 2个元素",
input: `"todos": {"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}`,
expected: `"todos": [{"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}]`,
},
// 2. 3个单层嵌套对象
{
name: "3个单层嵌套对象",
input: `"items": {"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}`,
expected: `"items": [{"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}]`,
},
// 3. 混合嵌套:有些字段是对象,有些是原始值
{
name: "混合嵌套 - 对象和原始值混合",
input: `"items": {"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}`,
expected: `"items": [{"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}]`,
},
// 4. 4个嵌套对象边界测试
{
name: "4个嵌套对象",
input: `"todos": {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}`,
expected: `"todos": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]`,
},
// 5. DeepSeek 典型幻觉:无空格逗号分隔
{
name: "无空格逗号分隔",
input: `"results": {"name": "a"}, {"name": "b"}, {"name": "c"}`,
expected: `"results": [{"name": "a"}, {"name": "b"}, {"name": "c"}]`,
},
// 6. 嵌套数组(数组在对象内,不是深层嵌套)
{
name: "对象内包含数组",
input: `"data": {"items": [1,2,3]}, {"items": [4,5,6]}`,
expected: `"data": [{"items": [1,2,3]}, {"items": [4,5,6]}]`,
},
// 7. 真实的 DeepSeek 8皇后问题输出
{
name: "DeepSeek 8皇后真实输出",
input: `"todos": {"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}`,
expected: `"todos": [{"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}]`,
},
// 8. 简单无嵌套对象(回归测试)
{
name: "简单无嵌套对象",
input: `"items": {"a": 1}, {"b": 2}`,
expected: `"items": [{"a": 1}, {"b": 2}]`,
},
// 9. 更复杂的单层嵌套
{
name: "复杂单层嵌套",
input: `"functions": {"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}`,
expected: `"functions": [{"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}]`,
},
// 10. 5个嵌套对象
{
name: "5个嵌套对象",
input: `"tasks": {"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}`,
expected: `"tasks": [{"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}]`,
},
}
for _, tt := range tests {
got := RepairLooseJSON(tt.input)
if got != tt.expected {
t.Errorf("[%s] RepairLooseJSON with nested objects:\n input: %s\n got: %s\n expected: %s", tt.name, tt.input, got, tt.expected)
}
}
}

View File

@@ -409,8 +409,8 @@ func TestParseToolCallsWithFunctionWrapper(t *testing.T) {
func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) { func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) {
fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this." fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this."
calls := ParseStandaloneToolCalls(fenced, []string{"search"}) calls := ParseStandaloneToolCalls(fenced, []string{"search"})
if len(calls) != 0 { if len(calls) != 1 {
t.Fatalf("expected fenced code block ignored, got %d calls", len(calls)) t.Fatalf("expected fenced code block to be parsed, got %d calls", len(calls))
} }
} }

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

@@ -1,6 +1,13 @@
{ {
"calls": [], "calls": [
"sawToolCallSyntax": false, {
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": false, "rejectedByPolicy": false,
"rejectedToolNames": [] "rejectedToolNames": []
} }

View File

@@ -1,6 +1,13 @@
{ {
"calls": [], "calls": [
"sawToolCallSyntax": false, {
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true,
"rejectedByPolicy": false, "rejectedByPolicy": false,
"rejectedToolNames": [] "rejectedToolNames": []
} }

View File

@@ -1,6 +1,13 @@
{ {
"calls": [], "calls": [
{
"name": "read_file",
"input": {
"path": "README.MD"
}
}
],
"sawToolCallSyntax": true, "sawToolCallSyntax": true,
"rejectedByPolicy": false, "rejectedByPolicy": false,
"rejectedToolNames": [] "rejectedToolNames": []
} }

View File

@@ -91,7 +91,9 @@ test('parseToolCalls supports fenced json and function.arguments string payload'
'```', '```',
].join('\n'); ].join('\n');
const calls = parseToolCalls(text, ['read_file']); const calls = parseToolCalls(text, ['read_file']);
assert.equal(calls.length, 0); assert.equal(calls.length, 1);
assert.equal(calls[0].name, 'read_file');
assert.equal(calls[0].input.path, 'README.md');
}); });
test('parseToolCalls parses text-kv fallback payload', () => { test('parseToolCalls parses text-kv fallback payload', () => {
@@ -122,19 +124,19 @@ test('parseToolCalls parses multiple text-kv fallback payloads', () => {
assert.equal(calls[1].name, 'bash'); assert.equal(calls[1].name, 'bash');
}); });
test('parseStandaloneToolCalls only matches standalone payload and ignores mixed prose', () => { test('parseStandaloneToolCalls parses mixed prose payload', () => {
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"}}]}';
const mixedCalls = parseStandaloneToolCalls(mixed, ['read_file']); const mixedCalls = parseStandaloneToolCalls(mixed, ['read_file']);
const standaloneCalls = parseStandaloneToolCalls(standalone, ['read_file']); const standaloneCalls = parseStandaloneToolCalls(standalone, ['read_file']);
assert.equal(mixedCalls.length, 0); assert.equal(mixedCalls.length, 1);
assert.equal(standaloneCalls.length, 1); assert.equal(standaloneCalls.length, 1);
}); });
test('parseStandaloneToolCalls ignores fenced code block tool_call examples', () => { test('parseStandaloneToolCalls parses fenced code block tool_call payload', () => {
const fenced = ['```json', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}', '```'].join('\n'); const fenced = ['```json', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}', '```'].join('\n');
const calls = parseStandaloneToolCalls(fenced, ['read_file']); const calls = parseStandaloneToolCalls(fenced, ['read_file']);
assert.equal(calls.length, 0); assert.equal(calls.length, 1);
}); });
@@ -259,28 +261,28 @@ test('sieve emits final tool_calls for split arguments payload without increment
assert.deepEqual(finalCalls[0].input, { path: 'README.MD', mode: 'head' }); assert.deepEqual(finalCalls[0].input, { path: 'README.MD', mode: 'head' });
}); });
test('sieve keeps tool json as text when leading prose exists (strict mode)', () => { test('sieve still emits tool_calls when leading prose exists before tool json', () => {
const events = runSieve( const events = runSieve(
['我将调用工具。', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'], ['我将调用工具。', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'],
['read_file'], ['read_file'],
); );
const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0)); const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0));
const leakedText = collectText(events); const leakedText = collectText(events);
assert.equal(hasTool, false); assert.equal(hasTool, true);
assert.equal(leakedText.includes('我将调用工具。'), true); assert.equal(leakedText.includes('我将调用工具。'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
}); });
test('sieve keeps same-chunk trailing prose payload as text in strict mode', () => { test('sieve emits tool_calls and keeps trailing prose when payload and prose share a chunk', () => {
const events = runSieve( const events = runSieve(
['{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}然后继续解释。'], ['{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}然后继续解释。'],
['read_file'], ['read_file'],
); );
const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0)); const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0));
const leakedText = collectText(events); const leakedText = collectText(events);
assert.equal(hasTool, false); assert.equal(hasTool, true);
assert.equal(leakedText.includes('然后继续解释。'), true); assert.equal(leakedText.includes('然后继续解释。'), true);
assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
}); });
test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => { test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => {

77
tests/repair_json_tool.go Normal file
View File

@@ -0,0 +1,77 @@
package main
import (
"fmt"
"strings"
)
func repairInvalidJSONBackslashes(s string) string {
if !strings.Contains(s, "\\") {
return s
}
var out strings.Builder
out.Grow(len(s) + 10)
runes := []rune(s)
for i := 0; i < len(runes); i++ {
if runes[i] == '\\' {
if i+1 < len(runes) {
next := runes[i+1]
switch next {
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
out.WriteRune('\\')
out.WriteRune(next)
i++
continue
case 'u':
if i+5 < len(runes) {
isHex := true
for j := 1; j <= 4; j++ {
r := runes[i+1+j]
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
isHex = false
break
}
}
if isHex {
out.WriteRune('\\')
out.WriteRune('u')
for j := 1; j <= 4; j++ {
out.WriteRune(runes[i+1+j])
}
i += 5
continue
}
}
}
}
// Not a valid escape sequence, double it
out.WriteString("\\\\")
} else {
out.WriteRune(runes[i])
}
}
return out.String()
}
func main() {
tests := []struct {
input string
expected string
}{
{`{"path": "C:\Users\name"}`, `{"path": "C:\\Users\\name"}`},
{`{"cmd": "cd D:\git_codes"}`, `{"cmd": "cd D:\\git_codes"}`},
{`{"text": "line1\nline2"}`, `{"text": "line1\nline2"}`},
{`{"path": "D:\\back\\slash"}`, `{"path": "D:\\back\\slash"}`},
{`{"unicode": "\u2705"}`, `{"unicode": "\u2705"}`},
{`{"invalid_u": "\u123"}`, `{"invalid_u": "\\u123"}`},
}
for _, tt := range tests {
got := repairInvalidJSONBackslashes(tt.input)
if got != tt.expected {
fmt.Printf("FAIL: input=%s\n got=%s\n exp=%s\n", tt.input, got, tt.expected)
} else {
fmt.Printf("PASS: input=%s\n", tt.input)
}
}
}

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

@@ -1,121 +1,127 @@
import { useI18n } from '../../i18n' import { useI18n } from '../../i18n'
import { useAccountsData } from './useAccountsData' import { useAccountsData } from './useAccountsData'
import { useAccountActions } from './useAccountActions' import { useAccountActions } from './useAccountActions'
import QueueCards from './QueueCards' import QueueCards from './QueueCards'
import ApiKeysPanel from './ApiKeysPanel' import ApiKeysPanel from './ApiKeysPanel'
import AccountsTable from './AccountsTable' import AccountsTable from './AccountsTable'
import AddKeyModal from './AddKeyModal' import AddKeyModal from './AddKeyModal'
import AddAccountModal from './AddAccountModal' import AddAccountModal from './AddAccountModal'
export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) { export default function AccountManagerContainer({ config, onRefresh, onMessage, authFetch }) {
const { t } = useI18n() const { t } = useI18n()
const apiFetch = authFetch || fetch const apiFetch = authFetch || fetch
const { const {
queueStatus, queueStatus,
keysExpanded, keysExpanded,
setKeysExpanded, setKeysExpanded,
accounts, accounts,
page, page,
pageSize, pageSize,
totalPages, totalPages,
totalAccounts, totalAccounts,
loadingAccounts, loadingAccounts,
fetchAccounts, fetchAccounts,
changePageSize, changePageSize,
resolveAccountIdentifier, resolveAccountIdentifier,
searchQuery, searchQuery,
handleSearchChange, handleSearchChange,
} = useAccountsData({ apiFetch }) } = useAccountsData({ apiFetch })
const { const {
showAddKey, showAddKey,
setShowAddKey, setShowAddKey,
showAddAccount, showAddAccount,
setShowAddAccount, setShowAddAccount,
newKey, newKey,
setNewKey, setNewKey,
copiedKey, copiedKey,
setCopiedKey, setCopiedKey,
newAccount, newAccount,
setNewAccount, setNewAccount,
loading, loading,
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
addKey, sessionCounts,
deleteKey, deletingSessions,
addAccount, addKey,
deleteAccount, deleteKey,
testAccount, addAccount,
testAllAccounts, deleteAccount,
} = useAccountActions({ testAccount,
apiFetch, testAllAccounts,
t, deleteAllSessions,
onMessage, } = useAccountActions({
onRefresh, apiFetch,
config, t,
fetchAccounts, onMessage,
resolveAccountIdentifier, onRefresh,
}) config,
fetchAccounts,
return ( resolveAccountIdentifier,
<div className="space-y-6"> })
<QueueCards queueStatus={queueStatus} t={t} />
return (
<ApiKeysPanel <div className="space-y-6">
t={t} <QueueCards queueStatus={queueStatus} t={t} />
config={config}
keysExpanded={keysExpanded} <ApiKeysPanel
setKeysExpanded={setKeysExpanded} t={t}
setShowAddKey={setShowAddKey} config={config}
copiedKey={copiedKey} keysExpanded={keysExpanded}
setCopiedKey={setCopiedKey} setKeysExpanded={setKeysExpanded}
onDeleteKey={deleteKey} setShowAddKey={setShowAddKey}
/> copiedKey={copiedKey}
setCopiedKey={setCopiedKey}
<AccountsTable onDeleteKey={deleteKey}
t={t} />
accounts={accounts}
loadingAccounts={loadingAccounts} <AccountsTable
testing={testing} t={t}
testingAll={testingAll} accounts={accounts}
batchProgress={batchProgress} loadingAccounts={loadingAccounts}
totalAccounts={totalAccounts} testing={testing}
page={page} testingAll={testingAll}
pageSize={pageSize} batchProgress={batchProgress}
totalPages={totalPages} sessionCounts={sessionCounts}
resolveAccountIdentifier={resolveAccountIdentifier} deletingSessions={deletingSessions}
onTestAll={testAllAccounts} totalAccounts={totalAccounts}
onShowAddAccount={() => setShowAddAccount(true)} page={page}
onTestAccount={testAccount} pageSize={pageSize}
onDeleteAccount={deleteAccount} totalPages={totalPages}
onPrevPage={() => fetchAccounts(page - 1)} resolveAccountIdentifier={resolveAccountIdentifier}
onNextPage={() => fetchAccounts(page + 1)} onTestAll={testAllAccounts}
onPageSizeChange={changePageSize} onShowAddAccount={() => setShowAddAccount(true)}
searchQuery={searchQuery} onTestAccount={testAccount}
onSearchChange={handleSearchChange} onDeleteAccount={deleteAccount}
/> onDeleteAllSessions={deleteAllSessions}
onPrevPage={() => fetchAccounts(page - 1)}
<AddKeyModal onNextPage={() => fetchAccounts(page + 1)}
show={showAddKey} onPageSizeChange={changePageSize}
t={t} searchQuery={searchQuery}
newKey={newKey} onSearchChange={handleSearchChange}
setNewKey={setNewKey} />
loading={loading}
onClose={() => setShowAddKey(false)} <AddKeyModal
onAdd={addKey} show={showAddKey}
/> t={t}
newKey={newKey}
<AddAccountModal setNewKey={setNewKey}
show={showAddAccount} loading={loading}
t={t} onClose={() => setShowAddKey(false)}
newAccount={newAccount} onAdd={addKey}
setNewAccount={setNewAccount} />
loading={loading}
onClose={() => setShowAddAccount(false)} <AddAccountModal
onAdd={addAccount} show={showAddAccount}
/> t={t}
</div> newAccount={newAccount}
) setNewAccount={setNewAccount}
} loading={loading}
onClose={() => setShowAddAccount(false)}
onAdd={addAccount}
/>
</div>
)
}

View File

@@ -1,191 +1,213 @@
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({
t, t,
accounts, accounts,
loadingAccounts, loadingAccounts,
testing, testing,
testingAll, testingAll,
batchProgress, batchProgress,
totalAccounts, sessionCounts,
page, deletingSessions,
pageSize, totalAccounts,
totalPages, page,
resolveAccountIdentifier, pageSize,
onTestAll, totalPages,
onShowAddAccount, resolveAccountIdentifier,
onTestAccount, onTestAll,
onDeleteAccount, onShowAddAccount,
onPrevPage, onTestAccount,
onNextPage, onDeleteAccount,
onPageSizeChange, onDeleteAllSessions,
searchQuery, onPrevPage,
onSearchChange, onNextPage,
}) { onPageSizeChange,
const [copiedId, setCopiedId] = useState(null) searchQuery,
onSearchChange,
const copyId = (id) => { }) {
navigator.clipboard.writeText(id).then(() => { const [copiedId, setCopiedId] = useState(null)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 1500) const copyId = (id) => {
}) navigator.clipboard.writeText(id).then(() => {
} setCopiedId(id)
return ( setTimeout(() => setCopiedId(null), 1500)
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm"> })
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4"> }
<div> return (
<h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2> <div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p> <div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
</div> <div>
<div className="flex flex-wrap gap-2"> <h2 className="text-lg font-semibold">{t('accountManager.accountsTitle')}</h2>
<input <p className="text-sm text-muted-foreground">{t('accountManager.accountsDesc')}</p>
type="text" </div>
value={searchQuery} <div className="flex flex-wrap gap-2">
onChange={e => onSearchChange(e.target.value)} <input
placeholder={t('accountManager.searchPlaceholder')} type="text"
className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground" value={searchQuery}
/> onChange={e => onSearchChange(e.target.value)}
<button placeholder={t('accountManager.searchPlaceholder')}
onClick={onTestAll} className="px-3 py-1.5 text-sm bg-muted border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
disabled={testingAll || totalAccounts === 0} />
className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50" <button
> onClick={onTestAll}
{testingAll ? <span className="animate-spin mr-2"></span> : <Play className="w-3 h-3 mr-2" />} disabled={testingAll || totalAccounts === 0}
{t('accountManager.testAll')} className="flex items-center px-3 py-2 bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary/80 transition-colors text-xs font-medium border border-border disabled:opacity-50"
</button> >
<button {testingAll ? <span className="animate-spin mr-2"></span> : <Play className="w-3 h-3 mr-2" />}
onClick={onShowAddAccount} {t('accountManager.testAll')}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm" </button>
> <button
<Plus className="w-4 h-4" /> onClick={onShowAddAccount}
{t('accountManager.addAccount')} className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium text-sm shadow-sm"
</button> >
</div> <Plus className="w-4 h-4" />
</div> {t('accountManager.addAccount')}
</button>
{testingAll && batchProgress.total > 0 && ( </div>
<div className="p-4 border-b border-border bg-muted/30"> </div>
<div className="flex items-center justify-between text-sm mb-2">
<span className="font-medium">{t('accountManager.testingAllAccounts')}</span> {testingAll && batchProgress.total > 0 && (
<span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span> <div className="p-4 border-b border-border bg-muted/30">
</div> <div className="flex items-center justify-between text-sm mb-2">
<div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4"> <span className="font-medium">{t('accountManager.testingAllAccounts')}</span>
<div <span className="text-muted-foreground">{batchProgress.current} / {batchProgress.total}</span>
className="bg-primary h-full transition-all duration-300" </div>
style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }} <div className="w-full bg-muted rounded-full h-2 overflow-hidden mb-4">
/> <div
</div> className="bg-primary h-full transition-all duration-300"
{batchProgress.results.length > 0 && ( style={{ width: `${(batchProgress.current / batchProgress.total) * 100}%` }}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar"> />
{batchProgress.results.map((r, i) => ( </div>
<div key={i} className={clsx( {batchProgress.results.length > 0 && (
"text-xs px-2 py-1 rounded border truncate", <div className="grid grid-cols-2 md:grid-cols-4 gap-2 max-h-32 overflow-y-auto custom-scrollbar">
r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive" {batchProgress.results.map((r, i) => (
)}> <div key={i} className={clsx(
{r.success ? '✓' : '✗'} {r.id} "text-xs px-2 py-1 rounded border truncate",
</div> r.success ? "bg-emerald-500/10 border-emerald-500/20 text-emerald-500" : "bg-destructive/10 border-destructive/20 text-destructive"
))} )}>
</div> {r.success ? '✓' : '✗'} {r.id}
)} </div>
</div> ))}
)} </div>
)}
<div className="divide-y divide-border"> </div>
{loadingAccounts ? ( )}
<div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
) : accounts.length > 0 ? ( <div className="divide-y divide-border">
accounts.map((acc, i) => { {loadingAccounts ? (
const id = resolveAccountIdentifier(acc) <div className="p-8 text-center text-muted-foreground">{t('actions.loading')}</div>
return ( ) : accounts.length > 0 ? (
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors"> accounts.map((acc, i) => {
<div className="flex items-center gap-3 min-w-0"> const id = resolveAccountIdentifier(acc)
<div className={clsx( return (
"w-2 h-2 rounded-full shrink-0", <div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" : <div className="flex items-center gap-3 min-w-0">
(acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" : <div className={clsx(
"bg-amber-500" "w-2 h-2 rounded-full shrink-0",
)} /> acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
<div className="min-w-0"> (acc.test_status === 'ok' || acc.has_token) ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
<div "bg-amber-500"
className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group" )} />
onClick={() => copyId(id)} <div className="min-w-0">
> <div
<span className="truncate">{id || '-'}</span> className="font-medium truncate flex items-center gap-1.5 cursor-pointer hover:text-primary transition-colors group"
{copiedId === id onClick={() => copyId(id)}
? <Check className="w-3 h-3 text-emerald-500 shrink-0" /> >
: <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" /> <span className="truncate">{id || '-'}</span>
} {copiedId === id
</div> ? <Check className="w-3 h-3 text-emerald-500 shrink-0" />
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5"> : <Copy className="w-3 h-3 opacity-0 group-hover:opacity-50 shrink-0 transition-opacity" />
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span> }
{acc.token_preview && ( </div>
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]"> <div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
{acc.token_preview} <span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')}</span>
</span> {acc.token_preview && (
)} <span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
</div> {acc.token_preview}
</div> </span>
</div> )}
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0"> {sessionCounts && sessionCounts[id] !== undefined && (
<button <span className="font-mono bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded text-[10px]">
onClick={() => onTestAccount(id)} {t('accountManager.sessionCount', { count: sessionCounts[id] })}
disabled={testing[id]} </span>
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50" )}
> {sessionCounts && sessionCounts[id] !== undefined && sessionCounts[id] > 0 && (
{testing[id] ? t('actions.testing') : t('actions.test')} <button
</button> onClick={() => onDeleteAllSessions(id)}
<button disabled={deletingSessions && deletingSessions[id]}
onClick={() => onDeleteAccount(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"
className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors" title={t('accountManager.deleteAllSessions')}
> >
<Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" /> {deletingSessions && deletingSessions[id] ? (
</button> <span className="animate-spin"></span>
</div> ) : (
</div> <FolderX className="w-3 h-3" />
) )}
}) </button>
) : ( )}
<div className="p-8 text-center text-muted-foreground">{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}</div> </div>
)} </div>
</div> </div>
<div className="flex items-center gap-2 self-start lg:self-auto ml-5 lg:ml-0">
{totalPages > 1 && ( <button
<div className="p-4 border-t border-border flex items-center justify-between"> onClick={() => onTestAccount(id)}
<div className="flex items-center gap-3"> disabled={testing[id]}
<div className="text-sm text-muted-foreground"> className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
{t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} >
</div> {testing[id] ? t('actions.testing') : t('actions.test')}
<select </button>
value={pageSize} <button
onChange={e => onPageSizeChange(Number(e.target.value))} onClick={() => onDeleteAccount(id)}
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground" className="p-1 lg:p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
> >
{[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => ( <Trash2 className="w-3.5 h-3.5 lg:w-4 lg:h-4" />
<option key={s} value={s}>{s}</option> </button>
))} </div>
</select> </div>
</div> )
<div className="flex items-center gap-2"> })
<button ) : (
onClick={onPrevPage} <div className="p-8 text-center text-muted-foreground">{searchQuery ? t('accountManager.searchNoResults') : t('accountManager.noAccounts')}</div>
disabled={page <= 1 || loadingAccounts} )}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed" </div>
>
<ChevronLeft className="w-4 h-4" /> {totalPages > 1 && (
</button> <div className="p-4 border-t border-border flex items-center justify-between">
<span className="text-sm font-medium px-2">{page} / {totalPages}</span> <div className="flex items-center gap-3">
<button <div className="text-sm text-muted-foreground">
onClick={onNextPage} {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })}
disabled={page >= totalPages || loadingAccounts} </div>
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed" <select
> value={pageSize}
<ChevronRight className="w-4 h-4" /> onChange={e => onPageSizeChange(Number(e.target.value))}
</button> className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
</div> >
</div> {[10, 20, 50, 100, 500, 1000, 2000, 5000].map(s => (
)} <option key={s} value={s}>{s}</option>
</div> ))}
) </select>
} </div>
<div className="flex items-center gap-2">
<button
onClick={onPrevPage}
disabled={page <= 1 || loadingAccounts}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium px-2">{page} / {totalPages}</span>
<button
onClick={onNextPage}
disabled={page >= totalPages || loadingAccounts}
className="p-2 border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</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

@@ -1,297 +1,305 @@
{ {
"language": { "language": {
"label": "Language", "label": "Language",
"english": "English", "english": "English",
"chinese": "中文" "chinese": "中文"
}, },
"nav": { "nav": {
"accounts": { "accounts": {
"label": "Account Management", "label": "Account Management",
"desc": "Manage the DeepSeek account pool" "desc": "Manage the DeepSeek account pool"
}, },
"test": { "test": {
"label": "API Test", "label": "API Test",
"desc": "Test API connectivity and responses" "desc": "Test API connectivity and responses"
}, },
"import": { "import": {
"label": "Batch Import", "label": "Batch Import",
"desc": "Bulk import account configuration" "desc": "Bulk import account configuration"
}, },
"vercel": { "vercel": {
"label": "Vercel Sync", "label": "Vercel Sync",
"desc": "Sync configuration to Vercel" "desc": "Sync configuration to Vercel"
}, },
"settings": { "settings": {
"label": "Settings", "label": "Settings",
"desc": "Edit runtime and security settings online" "desc": "Edit runtime and security settings online"
} }
}, },
"sidebar": { "sidebar": {
"onlineAdminConsole": "Online Admin Console", "onlineAdminConsole": "Online Admin Console",
"systemStatus": "System Status", "systemStatus": "System Status",
"statusOnline": "Online", "statusOnline": "Online",
"accounts": "Accounts", "accounts": "Accounts",
"keys": "Keys", "keys": "Keys",
"signOut": "Sign out" "signOut": "Sign out"
}, },
"auth": { "auth": {
"expired": "Authentication expired. Please sign in again.", "expired": "Authentication expired. Please sign in again.",
"checking": "Checking authentication status..." "checking": "Checking authentication status..."
}, },
"errors": { "errors": {
"fetchConfig": "Failed to fetch configuration: {error}" "fetchConfig": "Failed to fetch configuration: {error}"
}, },
"actions": { "actions": {
"cancel": "Cancel", "cancel": "Cancel",
"add": "Add", "add": "Add",
"delete": "Delete", "delete": "Delete",
"copy": "Copy", "copy": "Copy",
"generate": "Generate", "generate": "Generate",
"test": "Test", "test": "Test",
"testing": "Testing...", "testing": "Testing...",
"loading": "Loading..." "loading": "Loading..."
}, },
"messages": { "messages": {
"deleted": "Deleted successfully", "deleted": "Deleted successfully",
"deleteFailed": "Delete failed", "deleteFailed": "Delete failed",
"failedToAdd": "Failed to add", "failedToAdd": "Failed to add",
"networkError": "Network error.", "networkError": "Network error.",
"requestFailed": "Request failed.", "requestFailed": "Request failed.",
"generationStopped": "Generation stopped.", "generationStopped": "Generation stopped.",
"invalidJson": "Invalid JSON format.", "invalidJson": "Invalid JSON format.",
"importFailed": "Import failed.", "importFailed": "Import failed.",
"copyFailed": "Copy failed." "copyFailed": "Copy failed."
}, },
"landing": { "landing": {
"adminConsole": "Admin Console", "adminConsole": "Admin Console",
"apiStatus": "API Status", "apiStatus": "API Status",
"features": { "features": {
"compatibility": { "compatibility": {
"title": "Full Compatibility", "title": "Full Compatibility",
"desc": "OpenAI & Claude format support" "desc": "OpenAI & Claude format support"
}, },
"loadBalancing": { "loadBalancing": {
"title": "Load Balancing", "title": "Load Balancing",
"desc": "Smart rotation with stable throughput" "desc": "Smart rotation with stable throughput"
}, },
"reasoning": { "reasoning": {
"title": "Deep Reasoning", "title": "Deep Reasoning",
"desc": "Expose reasoning traces when enabled" "desc": "Expose reasoning traces when enabled"
}, },
"search": { "search": {
"title": "Web Search", "title": "Web Search",
"desc": "Integrated native web search" "desc": "Integrated native web search"
} }
} }
}, },
"accountManager": { "accountManager": {
"addKeySuccess": "API key added successfully.", "addKeySuccess": "API key added successfully.",
"addAccountSuccess": "Account added successfully.", "addAccountSuccess": "Account added successfully.",
"requiredFields": "Password and email/mobile are required.", "requiredFields": "Password and email/mobile are required.",
"deleteKeyConfirm": "Are you sure you want to delete this API key?", "deleteKeyConfirm": "Are you sure you want to delete this API key?",
"deleteAccountConfirm": "Are you sure you want to delete this account?", "deleteAccountConfirm": "Are you sure you want to delete this account?",
"invalidIdentifier": "Invalid account identifier. Operation aborted.", "invalidIdentifier": "Invalid account identifier. Operation aborted.",
"testAllConfirm": "Test API connectivity for all accounts?", "testAllConfirm": "Test API connectivity for all accounts?",
"testAllCompleted": "Completed: {success}/{total} available", "testAllCompleted": "Completed: {success}/{total} available",
"testFailed": "Test failed: {error}", "testFailed": "Test failed: {error}",
"available": "Available", "available": "Available",
"inUse": "In use", "inUse": "In use",
"totalPool": "Total pool", "totalPool": "Total pool",
"accountsUnit": "accounts", "accountsUnit": "accounts",
"threadsUnit": "threads", "threadsUnit": "threads",
"apiKeysTitle": "API Keys", "apiKeysTitle": "API Keys",
"apiKeysDesc": "Manage the API access key pool", "apiKeysDesc": "Manage the API access key pool",
"addKey": "Add key", "addKey": "Add key",
"copied": "Copied", "copied": "Copied",
"copyKeyTitle": "Copy key", "copyKeyTitle": "Copy key",
"deleteKeyTitle": "Delete key", "deleteKeyTitle": "Delete key",
"noApiKeys": "No API keys found.", "noApiKeys": "No API keys found.",
"accountsTitle": "DeepSeek Accounts", "accountsTitle": "DeepSeek Accounts",
"accountsDesc": "Manage the DeepSeek account pool", "accountsDesc": "Manage the DeepSeek account pool",
"testAll": "Test all", "testAll": "Test all",
"addAccount": "Add account", "addAccount": "Add account",
"testingAllAccounts": "Testing all accounts...", "testingAllAccounts": "Testing all accounts...",
"sessionActive": "Session active", "sessionActive": "Session active",
"reauthRequired": "Re-auth required", "reauthRequired": "Re-auth required",
"testStatusFailed": "Last test failed", "testStatusFailed": "Last test failed",
"noAccounts": "No accounts found.", "noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key", "modalAddKeyTitle": "Add API key",
"newKeyLabel": "New key value", "newKeyLabel": "New key value",
"newKeyPlaceholder": "Enter a custom API key", "newKeyPlaceholder": "Enter a custom API key",
"generate": "Generate", "generate": "Generate",
"generateHint": "Click Generate to create a random key.", "generateHint": "Click Generate to create a random key.",
"addKeyLoading": "Adding...", "addKeyLoading": "Adding...",
"addKeyAction": "Add key", "addKeyAction": "Add key",
"modalAddAccountTitle": "Add DeepSeek account", "modalAddAccountTitle": "Add DeepSeek account",
"emailOptional": "Email (optional)", "emailOptional": "Email (optional)",
"mobileOptional": "Mobile (optional)", "mobileOptional": "Mobile (optional)",
"passwordLabel": "Password", "passwordLabel": "Password",
"passwordPlaceholder": "Account password", "passwordPlaceholder": "Account password",
"addAccountLoading": "Adding...", "addAccountLoading": "Adding...",
"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}",
"apiTester": { "deleteAllSessions": "Delete all sessions",
"defaultMessage": "Hello, please introduce yourself in one sentence.", "deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
"models": { "deleteAllSessionsSuccess": "Successfully deleted all sessions"
"chat": "Non-reasoning model", },
"reasoner": "Reasoning model", "apiTester": {
"chatSearch": "Non-reasoning model (with search)", "defaultMessage": "Hello, please introduce yourself in one sentence.",
"reasonerSearch": "Reasoning model (with search)" "models": {
}, "chat": "Non-reasoning model",
"missingApiKey": "Please provide an API key.", "reasoner": "Reasoning model",
"requestFailed": "Request failed.", "chatSearch": "Non-reasoning model (with search)",
"networkError": "Network error: {error}", "reasonerSearch": "Reasoning model (with search)"
"testSuccess": "{account}: Test successful ({time}ms)", },
"config": "Configuration", "missingApiKey": "Please provide an API key.",
"modelLabel": "Model", "requestFailed": "Request failed.",
"streamMode": "Streaming", "networkError": "Network error: {error}",
"accountSelector": "Account", "testSuccess": "{account}: Test successful ({time}ms)",
"autoRandom": "🤖 Auto / Random", "config": "Configuration",
"apiKeyOptional": "API Key (optional)", "modelLabel": "Model",
"apiKeyDefault": "Default: ...{suffix}", "streamMode": "Streaming",
"apiKeyPlaceholder": "Enter a custom key", "accountSelector": "Account",
"modeManaged": "Managed key mode (uses account pool).", "autoRandom": "🤖 Auto / Random",
"modeDirect": "Direct token mode (requires a valid DeepSeek token).", "apiKeyOptional": "API Key (optional)",
"statusError": "Error", "apiKeyDefault": "Default: ...{suffix}",
"reasoningTrace": "Reasoning Trace", "apiKeyPlaceholder": "Enter a custom key",
"generating": "Generating response...", "modeManaged": "Managed key mode (uses account pool).",
"enterMessage": "Enter a message...", "modeDirect": "Direct token mode (requires a valid DeepSeek token).",
"adminConsoleLabel": "DeepSeek admin console" "statusError": "Error",
}, "reasoningTrace": "Reasoning Trace",
"batchImport": { "generating": "Generating response...",
"templates": { "enterMessage": "Enter a message...",
"full": { "adminConsoleLabel": "DeepSeek admin console"
"name": "Full configuration template", },
"desc": "Includes keys, accounts, and model mapping" "batchImport": {
}, "templates": {
"emailOnly": { "full": {
"name": "Email-only accounts", "name": "Full configuration template",
"desc": "Batch import accounts using email login" "desc": "Includes keys, accounts, and model mapping"
}, },
"mobileOnly": { "emailOnly": {
"name": "Mobile-only accounts", "name": "Email-only accounts",
"desc": "Batch import accounts using mobile login" "desc": "Batch import accounts using email login"
}, },
"keysOnly": { "mobileOnly": {
"name": "API keys only", "name": "Mobile-only accounts",
"desc": "Add API access keys only" "desc": "Batch import accounts using mobile login"
} },
}, "keysOnly": {
"enterJson": "Please provide JSON configuration content.", "name": "API keys only",
"importSuccess": "Import successful: {keys} keys, {accounts} accounts", "desc": "Add API access keys only"
"templateLoaded": "Template loaded: {name}", }
"currentConfigLoaded": "Current configuration loaded.", },
"fetchConfigFailed": "Failed to fetch configuration.", "enterJson": "Please provide JSON configuration content.",
"copySuccess": "Base64 configuration copied to clipboard.", "importSuccess": "Import successful: {keys} keys, {accounts} accounts",
"quickTemplates": "Quick Templates", "templateLoaded": "Template loaded: {name}",
"dataExport": "Data Export", "currentConfigLoaded": "Current configuration loaded.",
"dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.", "fetchConfigFailed": "Failed to fetch configuration.",
"copyBase64": "Copy Base64 config", "copySuccess": "Base64 configuration copied to clipboard.",
"copied": "Copied", "quickTemplates": "Quick Templates",
"variableName": "Variable name", "dataExport": "Data Export",
"jsonEditor": "JSON Editor", "dataExportDesc": "Copy the Base64-encoded configuration for Vercel environment variables.",
"loadCurrentConfig": "Load current config", "copyBase64": "Copy Base64 config",
"applyConfig": "Apply config", "copied": "Copied",
"importing": "Importing...", "variableName": "Variable name",
"importComplete": "Import complete", "jsonEditor": "JSON Editor",
"importSummary": "Imported {keys} API keys and updated {accounts} accounts." "loadCurrentConfig": "Load current config",
}, "applyConfig": "Apply config",
"settings": { "importing": "Importing...",
"loadFailed": "Failed to load settings.", "importComplete": "Import complete",
"nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).", "importSummary": "Imported {keys} API keys and updated {accounts} accounts."
"save": "Save settings", },
"saving": "Saving...", "settings": {
"saveSuccess": "Settings saved and hot reloaded.", "loadFailed": "Failed to load settings.",
"saveFailed": "Failed to save settings.", "nonJsonResponse": "Unexpected non-JSON response from server (status: {status}).",
"securityTitle": "Security", "save": "Save settings",
"jwtExpireHours": "JWT expiry (hours)", "saving": "Saving...",
"newPassword": "New admin password", "saveSuccess": "Settings saved and hot reloaded.",
"newPasswordPlaceholder": "Enter new password (min 4 chars)", "saveFailed": "Failed to save settings.",
"updatePassword": "Update password", "securityTitle": "Security",
"updating": "Updating...", "jwtExpireHours": "JWT expiry (hours)",
"passwordTooShort": "Password must be at least 4 characters.", "newPassword": "New admin password",
"passwordUpdated": "Password updated. Please sign in again.", "newPasswordPlaceholder": "Enter new password (min 4 chars)",
"passwordUpdateFailed": "Failed to update password.", "updatePassword": "Update password",
"runtimeTitle": "Concurrency & Queue", "updating": "Updating...",
"accountMaxInflight": "Per-account max inflight", "passwordTooShort": "Password must be at least 4 characters.",
"accountMaxQueue": "Account max queue size", "passwordUpdated": "Password updated. Please sign in again.",
"globalMaxInflight": "Global max inflight", "passwordUpdateFailed": "Failed to update password.",
"behaviorTitle": "Behavior", "runtimeTitle": "Concurrency & Queue",
"toolcallMode": "Toolcall mode", "accountMaxInflight": "Per-account max inflight",
"earlyEmitConfidence": "Early emit confidence", "accountMaxQueue": "Account max queue size",
"responsesTTL": "Responses store TTL (seconds)", "globalMaxInflight": "Global max inflight",
"embeddingsProvider": "Embeddings provider", "behaviorTitle": "Behavior",
"modelTitle": "Model mapping", "toolcallMode": "Toolcall mode",
"claudeMapping": "Claude mapping (JSON)", "earlyEmitConfidence": "Early emit confidence",
"modelAliases": "Model aliases (JSON)", "responsesTTL": "Responses store TTL (seconds)",
"backupTitle": "Backup & Restore", "embeddingsProvider": "Embeddings provider",
"loadExport": "Load current export", "modelTitle": "Model mapping",
"importModeMerge": "Merge import (default)", "claudeMapping": "Claude mapping (JSON)",
"importModeReplace": "Replace all import", "modelAliases": "Model aliases (JSON)",
"importNow": "Import now", "autoDeleteTitle": "Auto Delete Sessions",
"importing": "Importing...", "autoDeleteDesc": "When enabled, all sessions will be automatically deleted after each request completes.",
"importPlaceholder": "Paste config JSON to import", "autoDeleteSessions": "Auto delete sessions",
"importEmpty": "Please input import JSON.", "autoDeleteWarning": "Warning: Enabling this will delete all session history after each request. Use with caution.",
"importInvalidJson": "Import JSON is invalid.", "backupTitle": "Backup & Restore",
"importFailed": "Import failed.", "loadExport": "Load current export",
"importSuccess": "Config imported (mode: {mode}).", "importModeMerge": "Merge import (default)",
"exportFailed": "Export failed.", "importModeReplace": "Replace all import",
"exportLoaded": "Current export loaded.", "importNow": "Import now",
"exportJson": "Export JSON", "importing": "Importing...",
"invalidJsonField": "{field} is not a valid JSON object.", "importPlaceholder": "Paste config JSON to import",
"defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.", "importEmpty": "Please input import JSON.",
"vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.", "importInvalidJson": "Import JSON is invalid.",
"autoFetchPaused": "Auto loading paused after {count} failures: {error}", "importFailed": "Import failed.",
"retryLoad": "Retry now" "importSuccess": "Config imported (mode: {mode}).",
}, "exportFailed": "Export failed.",
"login": { "exportLoaded": "Current export loaded.",
"welcome": "Welcome back", "exportJson": "Export JSON",
"subtitle": "Enter your admin key to continue", "invalidJsonField": "{field} is not a valid JSON object.",
"adminKeyLabel": "Admin key", "defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.",
"adminKeyPlaceholder": "Enter your admin key...", "vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy.",
"rememberSession": "Remember this session", "autoFetchPaused": "Auto loading paused after {count} failures: {error}",
"signIn": "Sign in", "retryLoad": "Retry now"
"secureConnection": "Secure connection", },
"adminPortal": "DS2API admin portal", "login": {
"signInFailed": "Sign-in failed.", "welcome": "Welcome back",
"networkError": "Network error: {error}" "subtitle": "Enter your admin key to continue",
}, "adminKeyLabel": "Admin key",
"vercel": { "adminKeyPlaceholder": "Enter your admin key...",
"tokenRequired": "Vercel access token is required.", "rememberSession": "Remember this session",
"projectRequired": "Project ID is required.", "signIn": "Sign in",
"syncFailed": "Sync failed.", "secureConnection": "Secure connection",
"networkError": "Network error.", "adminPortal": "DS2API admin portal",
"title": "Vercel Deployment", "signInFailed": "Sign-in failed.",
"description": "Sync the current keys and accounts directly to Vercel environment variables.", "networkError": "Network error: {error}"
"tokenLabel": "Vercel Access Token", },
"getToken": "Get token", "vercel": {
"tokenPlaceholderPreconfig": "Using preconfigured token", "tokenRequired": "Vercel access token is required.",
"tokenPlaceholder": "Enter Vercel access token", "projectRequired": "Project ID is required.",
"projectIdLabel": "Project ID", "syncFailed": "Sync failed.",
"projectIdHint": "Find it in Project Settings → General.", "networkError": "Network error.",
"teamIdLabel": "Team ID", "title": "Vercel Deployment",
"optional": "optional", "description": "Sync the current keys and accounts directly to Vercel environment variables.",
"syncing": "Syncing...", "tokenLabel": "Vercel Access Token",
"syncRedeploy": "Sync & redeploy", "getToken": "Get token",
"redeployHint": "This triggers a Vercel redeploy and usually takes 3060 seconds.", "tokenPlaceholderPreconfig": "Using preconfigured token",
"syncSucceeded": "Sync succeeded", "tokenPlaceholder": "Enter Vercel access token",
"syncFailedLabel": "Sync failed", "projectIdLabel": "Project ID",
"openDeployment": "Open deployment", "projectIdHint": "Find it in Project Settings → General.",
"statusSynced": "Synced", "teamIdLabel": "Team ID",
"statusNotSynced": "Not synced", "optional": "optional",
"statusNeverSynced": "Never synced", "syncing": "Syncing...",
"lastSyncTime": "Last sync: {time}", "syncRedeploy": "Sync & redeploy",
"pollPaused": "Status polling paused after {count} failures.", "redeployHint": "This triggers a Vercel redeploy and usually takes 3060 seconds.",
"manualRefresh": "Refresh manually", "syncSucceeded": "Sync succeeded",
"howItWorks": "How it works", "syncFailedLabel": "Sync failed",
"steps": { "openDeployment": "Open deployment",
"one": "The current configuration (keys and accounts) is exported as JSON.", "statusSynced": "Synced",
"two": "The JSON is Base64-encoded for safe formatting.", "statusNotSynced": "Not synced",
"three": "Update the env var in Vercel:", "statusNeverSynced": "Never synced",
"four": "Trigger a redeploy to apply the updated environment variables." "lastSyncTime": "Last sync: {time}",
} "pollPaused": "Status polling paused after {count} failures.",
} "manualRefresh": "Refresh manually",
} "howItWorks": "How it works",
"steps": {
"one": "The current configuration (keys and accounts) is exported as JSON.",
"two": "The JSON is Base64-encoded for safe formatting.",
"three": "Update the env var in Vercel:",
"four": "Trigger a redeploy to apply the updated environment variables."
}
}
}

View File

@@ -1,297 +1,305 @@
{ {
"language": { "language": {
"label": "语言", "label": "语言",
"english": "English", "english": "English",
"chinese": "中文" "chinese": "中文"
}, },
"nav": { "nav": {
"accounts": { "accounts": {
"label": "账号管理", "label": "账号管理",
"desc": "管理 DeepSeek 账号池" "desc": "管理 DeepSeek 账号池"
}, },
"test": { "test": {
"label": "API 测试", "label": "API 测试",
"desc": "测试 API 连接与响应" "desc": "测试 API 连接与响应"
}, },
"import": { "import": {
"label": "批量导入", "label": "批量导入",
"desc": "批量导入账号配置" "desc": "批量导入账号配置"
}, },
"vercel": { "vercel": {
"label": "Vercel 同步", "label": "Vercel 同步",
"desc": "同步配置到 Vercel" "desc": "同步配置到 Vercel"
}, },
"settings": { "settings": {
"label": "设置中心", "label": "设置中心",
"desc": "在线修改系统设置与配置" "desc": "在线修改系统设置与配置"
} }
}, },
"sidebar": { "sidebar": {
"onlineAdminConsole": "在线管理面板", "onlineAdminConsole": "在线管理面板",
"systemStatus": "系统状态", "systemStatus": "系统状态",
"statusOnline": "在线", "statusOnline": "在线",
"accounts": "账号", "accounts": "账号",
"keys": "密钥", "keys": "密钥",
"signOut": "退出登录" "signOut": "退出登录"
}, },
"auth": { "auth": {
"expired": "认证已过期,请重新登录", "expired": "认证已过期,请重新登录",
"checking": "正在检查登录状态..." "checking": "正在检查登录状态..."
}, },
"errors": { "errors": {
"fetchConfig": "获取配置失败: {error}" "fetchConfig": "获取配置失败: {error}"
}, },
"actions": { "actions": {
"cancel": "取消", "cancel": "取消",
"add": "添加", "add": "添加",
"delete": "删除", "delete": "删除",
"copy": "复制", "copy": "复制",
"generate": "生成", "generate": "生成",
"test": "测试", "test": "测试",
"testing": "正在测试...", "testing": "正在测试...",
"loading": "加载中..." "loading": "加载中..."
}, },
"messages": { "messages": {
"deleted": "删除成功", "deleted": "删除成功",
"deleteFailed": "删除失败", "deleteFailed": "删除失败",
"failedToAdd": "添加失败", "failedToAdd": "添加失败",
"networkError": "网络错误", "networkError": "网络错误",
"requestFailed": "请求失败", "requestFailed": "请求失败",
"generationStopped": "已停止生成", "generationStopped": "已停止生成",
"invalidJson": "无效的 JSON 格式", "invalidJson": "无效的 JSON 格式",
"importFailed": "导入失败", "importFailed": "导入失败",
"copyFailed": "复制失败" "copyFailed": "复制失败"
}, },
"landing": { "landing": {
"adminConsole": "管理面板", "adminConsole": "管理面板",
"apiStatus": "API 状态", "apiStatus": "API 状态",
"features": { "features": {
"compatibility": { "compatibility": {
"title": "全面兼容", "title": "全面兼容",
"desc": "适配 OpenAI 与 Claude 格式" "desc": "适配 OpenAI 与 Claude 格式"
}, },
"loadBalancing": { "loadBalancing": {
"title": "负载均衡", "title": "负载均衡",
"desc": "智能轮询,稳定高效" "desc": "智能轮询,稳定高效"
}, },
"reasoning": { "reasoning": {
"title": "深度思考", "title": "深度思考",
"desc": "支持推理过程输出" "desc": "支持推理过程输出"
}, },
"search": { "search": {
"title": "联网搜索", "title": "联网搜索",
"desc": "集成原生网页搜索能力" "desc": "集成原生网页搜索能力"
} }
} }
}, },
"accountManager": { "accountManager": {
"addKeySuccess": "API 密钥添加成功", "addKeySuccess": "API 密钥添加成功",
"addAccountSuccess": "账号添加成功", "addAccountSuccess": "账号添加成功",
"requiredFields": "需要填写密码以及邮箱或手机号", "requiredFields": "需要填写密码以及邮箱或手机号",
"deleteKeyConfirm": "确定要删除此 API 密钥吗?", "deleteKeyConfirm": "确定要删除此 API 密钥吗?",
"deleteAccountConfirm": "确定要删除此账号吗?", "deleteAccountConfirm": "确定要删除此账号吗?",
"invalidIdentifier": "账号标识无效,无法执行操作", "invalidIdentifier": "账号标识无效,无法执行操作",
"testAllConfirm": "测试所有账号的 API 连通性?", "testAllConfirm": "测试所有账号的 API 连通性?",
"testAllCompleted": "完成:{success}/{total} 可用", "testAllCompleted": "完成:{success}/{total} 可用",
"testFailed": "测试失败: {error}", "testFailed": "测试失败: {error}",
"available": "可用", "available": "可用",
"inUse": "正在使用", "inUse": "正在使用",
"totalPool": "账号池总数", "totalPool": "账号池总数",
"accountsUnit": "个账号", "accountsUnit": "个账号",
"threadsUnit": "线程", "threadsUnit": "线程",
"apiKeysTitle": "API 密钥", "apiKeysTitle": "API 密钥",
"apiKeysDesc": "管理 API 访问密钥池", "apiKeysDesc": "管理 API 访问密钥池",
"addKey": "添加密钥", "addKey": "添加密钥",
"copied": "已复制", "copied": "已复制",
"copyKeyTitle": "复制密钥", "copyKeyTitle": "复制密钥",
"deleteKeyTitle": "删除密钥", "deleteKeyTitle": "删除密钥",
"noApiKeys": "未找到 API 密钥", "noApiKeys": "未找到 API 密钥",
"accountsTitle": "DeepSeek 账号", "accountsTitle": "DeepSeek 账号",
"accountsDesc": "管理 DeepSeek 账号池", "accountsDesc": "管理 DeepSeek 账号池",
"testAll": "测试全部", "testAll": "测试全部",
"addAccount": "添加账号", "addAccount": "添加账号",
"testingAllAccounts": "正在测试所有账号...", "testingAllAccounts": "正在测试所有账号...",
"sessionActive": "已建立会话", "sessionActive": "已建立会话",
"reauthRequired": "需重新登录", "reauthRequired": "需重新登录",
"testStatusFailed": "上次测试失败", "testStatusFailed": "上次测试失败",
"noAccounts": "未找到任何账号", "noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥", "modalAddKeyTitle": "添加 API 密钥",
"newKeyLabel": "新密钥值", "newKeyLabel": "新密钥值",
"newKeyPlaceholder": "输入自定义 API 密钥", "newKeyPlaceholder": "输入自定义 API 密钥",
"generate": "生成", "generate": "生成",
"generateHint": "点击「生成」自动创建随机密钥", "generateHint": "点击「生成」自动创建随机密钥",
"addKeyLoading": "添加中...", "addKeyLoading": "添加中...",
"addKeyAction": "添加密钥", "addKeyAction": "添加密钥",
"modalAddAccountTitle": "添加 DeepSeek 账号", "modalAddAccountTitle": "添加 DeepSeek 账号",
"emailOptional": "邮箱 (可选)", "emailOptional": "邮箱 (可选)",
"mobileOptional": "手机号 (可选)", "mobileOptional": "手机号 (可选)",
"passwordLabel": "密码", "passwordLabel": "密码",
"passwordPlaceholder": "账号密码", "passwordPlaceholder": "账号密码",
"addAccountLoading": "添加中...", "addAccountLoading": "添加中...",
"addAccountAction": "添加账号", "addAccountAction": "添加账号",
"pageInfo": "第 {current}/{total} 页,共 {count} 个账号", "pageInfo": "第 {current}/{total} 页,共 {count} 个账号",
"searchPlaceholder": "搜索账号...", "searchPlaceholder": "搜索账号...",
"searchNoResults": "未找到匹配的账号" "searchNoResults": "未找到匹配的账号",
}, "sessionCount": "会话: {count}",
"apiTester": { "deleteAllSessions": "删除所有会话",
"defaultMessage": "你好,请用一句话介绍你自己。", "deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
"models": { "deleteAllSessionsSuccess": "删除成功"
"chat": "非思考模型", },
"reasoner": "思考模型", "apiTester": {
"chatSearch": "非思考模型 (带搜索)", "defaultMessage": "你好,请用一句话介绍你自己。",
"reasonerSearch": "思考模型 (带搜索)" "models": {
}, "chat": "非思考模型",
"missingApiKey": "请提供 API 密钥", "reasoner": "思考模型",
"requestFailed": "请求失败", "chatSearch": "非思考模型 (带搜索)",
"networkError": "网络错误: {error}", "reasonerSearch": "思考模型 (带搜索)"
"testSuccess": "{account}: 测试成功 ({time}ms)", },
"config": "配置", "missingApiKey": "请提供 API 密钥",
"modelLabel": "模型", "requestFailed": "请求失败",
"streamMode": "流式模式", "networkError": "网络错误: {error}",
"accountSelector": "选择账号", "testSuccess": "{account}: 测试成功 ({time}ms)",
"autoRandom": "🤖 自动 / 随机", "config": "配置",
"apiKeyOptional": "API 密钥 (可选)", "modelLabel": "模型",
"apiKeyDefault": "默认: ...{suffix}", "streamMode": "流式模式",
"apiKeyPlaceholder": "输入自定义密钥", "accountSelector": "选择账号",
"modeManaged": "当前使用托管 key 模式(会走账号池)。", "autoRandom": "🤖 自动 / 随机",
"modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token", "apiKeyOptional": "API 密钥 (可选)",
"statusError": "错误", "apiKeyDefault": "默认: ...{suffix}",
"reasoningTrace": "思维链过程", "apiKeyPlaceholder": "输入自定义密钥",
"generating": "正在生成响应...", "modeManaged": "当前使用托管 key 模式(会走账号池)。",
"enterMessage": "输入消息...", "modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token",
"adminConsoleLabel": "DeepSeek 管理员界面" "statusError": "错误",
}, "reasoningTrace": "思维链过程",
"batchImport": { "generating": "正在生成响应...",
"templates": { "enterMessage": "输入消息...",
"full": { "adminConsoleLabel": "DeepSeek 管理员界面"
"name": "全量配置模板", },
"desc": "包含密钥、账号及模型映射" "batchImport": {
}, "templates": {
"emailOnly": { "full": {
"name": "仅邮箱账号", "name": "全量配置模板",
"desc": "批量导入邮箱格式账号" "desc": "包含密钥、账号及模型映射"
}, },
"mobileOnly": { "emailOnly": {
"name": "仅手机号账号", "name": "仅邮箱账号",
"desc": "批量导入手机号格式账号" "desc": "批量导入邮箱格式账号"
}, },
"keysOnly": { "mobileOnly": {
"name": "仅 API 密钥", "name": "仅手机号账号",
"desc": "仅添加 API 访问密钥" "desc": "批量导入手机号格式账号"
} },
}, "keysOnly": {
"enterJson": "请输入 JSON 配置内容", "name": "仅 API 密钥",
"importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号", "desc": "仅添加 API 访问密钥"
"templateLoaded": "已加载模板: {name}", }
"currentConfigLoaded": "当前配置已加载", },
"fetchConfigFailed": "获取配置失败", "enterJson": "请输入 JSON 配置内容",
"copySuccess": "Base64 配置已复制到剪贴板", "importSuccess": "导入成功: {keys} 个密钥, {accounts} 个账号",
"quickTemplates": "快速模板", "templateLoaded": "已加载模板: {name}",
"dataExport": "数据导出", "currentConfigLoaded": "当前配置已加载",
"dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。", "fetchConfigFailed": "获取配置失败",
"copyBase64": "复制 Base64 配置", "copySuccess": "Base64 配置已复制到剪贴板",
"copied": "已复制", "quickTemplates": "快速模板",
"variableName": "变量名", "dataExport": "数据导出",
"jsonEditor": "JSON 编辑器", "dataExportDesc": "获取配置的 Base64 字符串,用于 Vercel 环境变量。",
"loadCurrentConfig": "加载当前配置", "copyBase64": "复制 Base64 配置",
"applyConfig": "应用配置", "copied": "已复制",
"importing": "正在导入...", "variableName": "变量名",
"importComplete": "导入操作已完成", "jsonEditor": "JSON 编辑器",
"importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。" "loadCurrentConfig": "加载当前配置",
}, "applyConfig": "应用配置",
"settings": { "importing": "正在导入...",
"loadFailed": "加载设置失败", "importComplete": "导入操作已完成",
"nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status}", "importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。"
"save": "保存设置", },
"saving": "保存中...", "settings": {
"saveSuccess": "设置已保存并热更新生效", "loadFailed": "加载设置失败",
"saveFailed": "保存设置失败", "nonJsonResponse": "服务端返回了非 JSON 响应(状态码:{status}",
"securityTitle": "安全设置", "save": "保存设置",
"jwtExpireHours": "JWT 有效期(小时)", "saving": "保存中...",
"newPassword": "面板新密码", "saveSuccess": "设置已保存并热更新生效",
"newPasswordPlaceholder": "输入新密码(至少 4 位)", "saveFailed": "保存设置失败",
"updatePassword": "修改密码", "securityTitle": "安全设置",
"updating": "更新中...", "jwtExpireHours": "JWT 有效期(小时)",
"passwordTooShort": "新密码至少 4 位", "newPassword": "面板新密码",
"passwordUpdated": "密码已更新,需重新登录", "newPasswordPlaceholder": "输入新密码(至少 4 位)",
"passwordUpdateFailed": "密码更新失败", "updatePassword": "修改密码",
"runtimeTitle": "并发与队列", "updating": "更新中...",
"accountMaxInflight": "每账号并发上限", "passwordTooShort": "新密码至少 4 位",
"accountMaxQueue": "账号等待队列上限", "passwordUpdated": "密码已更新,需重新登录",
"globalMaxInflight": "全局并发上限", "passwordUpdateFailed": "密码更新失败",
"behaviorTitle": "行为设置", "runtimeTitle": "并发与队列",
"toolcallMode": "Toolcall 模式", "accountMaxInflight": "每账号并发上限",
"earlyEmitConfidence": "早发置信度", "accountMaxQueue": "账号等待队列上限",
"responsesTTL": "Responses 缓存 TTL", "globalMaxInflight": "全局并发上限",
"embeddingsProvider": "Embeddings Provider", "behaviorTitle": "行为设置",
"modelTitle": "模型映射", "toolcallMode": "Toolcall 模式",
"claudeMapping": "Claude 映射JSON", "earlyEmitConfidence": "早发置信度",
"modelAliases": "模型别名JSON", "responsesTTL": "Responses 缓存 TTL",
"backupTitle": "备份与恢复", "embeddingsProvider": "Embeddings Provider",
"loadExport": "加载当前导出", "modelTitle": "模型映射",
"importModeMerge": "合并导入(默认", "claudeMapping": "Claude 映射JSON",
"importModeReplace": "全量覆盖导入", "modelAliases": "模型别名JSON",
"importNow": "立即导入", "autoDeleteTitle": "自动删除会话",
"importing": "导入中...", "autoDeleteDesc": "开启后,每次请求完成后会自动删除该账号的所有会话记录。",
"importPlaceholder": "粘贴要导入的 JSON 配置", "autoDeleteSessions": "自动删除会话",
"importEmpty": "请先输入导入 JSON", "autoDeleteWarning": "开启此功能后,每次请求完成都会删除该账号的所有历史会话,请谨慎使用。",
"importInvalidJson": "导入 JSON 格式无效", "backupTitle": "备份与恢复",
"importFailed": "导入失败", "loadExport": "加载当前导出",
"importSuccess": "配置导入成功(模式:{mode}", "importModeMerge": "合并导入(默认",
"exportFailed": "导出失败", "importModeReplace": "全量覆盖导入",
"exportLoaded": "已加载当前配置导出", "importNow": "立即导入",
"exportJson": "导出 JSON", "importing": "导入中...",
"invalidJsonField": "{field} 不是有效 JSON 对象", "importPlaceholder": "粘贴要导入的 JSON 配置",
"defaultPasswordWarning": "当前使用默认密码 admin请尽快在此修改。", "importEmpty": "请先输入导入 JSON",
"vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。", "importInvalidJson": "导入 JSON 格式无效",
"autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error}", "importFailed": "导入失败",
"retryLoad": "立即重试" "importSuccess": "配置导入成功(模式:{mode}",
}, "exportFailed": "导出失败",
"login": { "exportLoaded": "已加载当前配置导出",
"welcome": "欢迎回来", "exportJson": "导出 JSON",
"subtitle": "请输入管理员密钥以继续", "invalidJsonField": "{field} 不是有效 JSON 对象",
"adminKeyLabel": "管理员密钥", "defaultPasswordWarning": "当前使用默认密码 admin请尽快在此修改。",
"adminKeyPlaceholder": "输入您的管理员密钥...", "vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。",
"rememberSession": "记住登录状态", "autoFetchPaused": "自动加载已暂停:连续失败 {count} 次({error}",
"signIn": "登录", "retryLoad": "立即重试"
"secureConnection": "安全连接", },
"adminPortal": "DS2API 管理员门户", "login": {
"signInFailed": "登录失败", "welcome": "欢迎回来",
"networkError": "网络错误: {error}" "subtitle": "请输入管理员密钥以继续",
}, "adminKeyLabel": "管理员密钥",
"vercel": { "adminKeyPlaceholder": "输入您的管理员密钥...",
"tokenRequired": "需要 Vercel 访问令牌", "rememberSession": "记住登录状态",
"projectRequired": "需要项目 ID", "signIn": "登录",
"syncFailed": "同步失败", "secureConnection": "安全连接",
"networkError": "网络错误", "adminPortal": "DS2API 管理员门户",
"title": "Vercel 部署", "signInFailed": "登录失败",
"description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。", "networkError": "网络错误: {error}"
"tokenLabel": "Vercel 访问令牌", },
"getToken": "获取令牌", "vercel": {
"tokenPlaceholderPreconfig": "正在使用预配置的令牌", "tokenRequired": "需要 Vercel 访问令牌",
"tokenPlaceholder": "输入 Vercel 访问令牌", "projectRequired": "需要项目 ID",
"projectIdLabel": "项目 ID", "syncFailed": "同步失败",
"projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到", "networkError": "网络错误",
"teamIdLabel": "团队 ID", "title": "Vercel 部署",
"optional": "可选", "description": "将当前密钥和账号配置直接同步到 Vercel 环境变量中。",
"syncing": "正在同步...", "tokenLabel": "Vercel 访问令牌",
"syncRedeploy": "同步并重新部署", "getToken": "获取令牌",
"redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。", "tokenPlaceholderPreconfig": "正在使用预配置的令牌",
"syncSucceeded": "同步成功", "tokenPlaceholder": "输入 Vercel 访问令牌",
"syncFailedLabel": "同步失败", "projectIdLabel": "项目 ID",
"openDeployment": "访问部署地址", "projectIdHint": "可在项目设置 (Project Settings) → 常规 (General) 中找到",
"statusSynced": "已同步", "teamIdLabel": "团队 ID",
"statusNotSynced": "未同步", "optional": "可选",
"statusNeverSynced": "从未同步", "syncing": "正在同步...",
"lastSyncTime": "上次同步: {time}", "syncRedeploy": "同步并重新部署",
"pollPaused": "状态轮询已暂停:连续失败 {count} 次。", "redeployHint": "这将触发 Vercel 的重新部署,大约需要 30-60 秒。",
"manualRefresh": "手动刷新", "syncSucceeded": "同步成功",
"howItWorks": "工作原理", "syncFailedLabel": "同步失败",
"steps": { "openDeployment": "访问部署地址",
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。", "statusSynced": "已同步",
"two": "JSON 被编码为 Base64 以确保格式兼容性。", "statusNotSynced": "未同步",
"three": "更新 Vercel 项目中的环境变量:", "statusNeverSynced": "从未同步",
"four": "触发重新部署以应用新的环境变量。" "lastSyncTime": "上次同步: {time}",
} "pollPaused": "状态轮询已暂停:连续失败 {count} 次。",
} "manualRefresh": "手动刷新",
} "howItWorks": "工作原理",
"steps": {
"one": "当前配置 (密钥和账号) 被导出为 JSON 字符串。",
"two": "JSON 被编码为 Base64 以确保格式兼容性。",
"three": "更新 Vercel 项目中的环境变量:",
"four": "触发重新部署以应用新的环境变量。"
}
}
}