From 255feb2e650f8667f24dd796ca5ad17cebc94d1a Mon Sep 17 00:00:00 2001 From: BigUncle Date: Wed, 25 Feb 2026 18:03:25 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(claude):=20=E4=BF=AE=E5=A4=8D=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=B0=83=E7=94=A8=E5=85=BC=E5=AE=B9=E4=B8=8E=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Claude 工具定义兼容 input_schema 与 function.parameters - tool_calls 解析增加 thinking 回退与大小写无关工具名匹配 - 补充 claude/util 相关回归测试 --- .../adapter/claude/handler_stream_test.go | 26 +++++++++++ internal/adapter/claude/handler_util_test.go | 43 +++++++++++++++++++ internal/adapter/claude/handler_utils.go | 37 ++++++++++++++-- .../adapter/claude/stream_runtime_finalize.go | 3 ++ internal/format/claude/render.go | 3 ++ internal/format/claude/render_test.go | 29 +++++++++++++ internal/util/toolcalls_parse.go | 20 ++++++++- internal/util/toolcalls_test.go | 11 +++++ 8 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 internal/format/claude/render_test.go diff --git a/internal/adapter/claude/handler_stream_test.go b/internal/adapter/claude/handler_stream_test.go index 701c8d7..42358aa 100644 --- a/internal/adapter/claude/handler_stream_test.go +++ b/internal/adapter/claude/handler_stream_test.go @@ -183,6 +183,32 @@ func TestHandleClaudeStreamRealtimeToolSafety(t *testing.T) { } } +func TestHandleClaudeStreamRealtimeToolDetectionFromThinkingFallback(t *testing.T) { + h := &Handler{} + resp := makeClaudeSSEHTTPResponse( + `data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`, + `data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"}) + + frames := parseClaudeFrames(t, rec.Body.String()) + foundToolUse := false + for _, f := range findClaudeFrames(frames, "content_block_start") { + contentBlock, _ := f.Payload["content_block"].(map[string]any) + if contentBlock["type"] == "tool_use" && contentBlock["name"] == "search" { + foundToolUse = true + break + } + } + if !foundToolUse { + t.Fatalf("expected tool_use block from thinking fallback, body=%s", rec.Body.String()) + } +} + func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) { h := &Handler{} resp := makeClaudeSSEHTTPResponse( diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index ae75d8e..f5b0ad5 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -141,6 +141,34 @@ func TestBuildClaudeToolPromptMultipleTools(t *testing.T) { } } +func TestBuildClaudeToolPromptSupportsOpenAIStyleFunctionTool(t *testing.T) { + tools := []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + "description": "Search via function tool", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "q": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + prompt := buildClaudeToolPrompt(tools) + if !containsStr(prompt, "Tool: search") { + t.Fatalf("expected OpenAI-style function tool name in prompt, got: %q", prompt) + } + if !containsStr(prompt, "Search via function tool") { + t.Fatalf("expected OpenAI-style function tool description in prompt, got: %q", prompt) + } + if !containsStr(prompt, "\"q\"") { + t.Fatalf("expected parameters schema serialized in prompt, got: %q", prompt) + } +} + func TestBuildClaudeToolPromptSkipsNonMap(t *testing.T) { tools := []any{"not a map"} prompt := buildClaudeToolPrompt(tools) @@ -237,6 +265,21 @@ func TestExtractClaudeToolNamesNil(t *testing.T) { } } +func TestExtractClaudeToolNamesSupportsOpenAIStyleFunctionTool(t *testing.T) { + tools := []any{ + map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "search", + }, + }, + } + names := extractClaudeToolNames(tools) + if len(names) != 1 || names[0] != "search" { + t.Fatalf("expected [search], got %v", names) + } +} + // ─── toMessageMaps ─────────────────────────────────────────────────── func TestToMessageMapsNormal(t *testing.T) { diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index df4c6b2..3728ffb 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -46,9 +46,8 @@ func buildClaudeToolPrompt(tools []any) string { if !ok { continue } - name, _ := m["name"].(string) - desc, _ := m["description"].(string) - schema, _ := json.Marshal(m["input_schema"]) + name, desc, schemaObj := extractClaudeToolMeta(m) + schema, _ := json.Marshal(schemaObj) parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema)) } parts = append(parts, @@ -98,13 +97,43 @@ func extractClaudeToolNames(tools []any) []string { if !ok { continue } - if name, ok := m["name"].(string); ok && name != "" { + name, _, _ := extractClaudeToolMeta(m) + if name != "" { out = append(out, name) } } return out } +func extractClaudeToolMeta(m map[string]any) (string, string, any) { + name, _ := m["name"].(string) + desc, _ := m["description"].(string) + schemaObj := m["input_schema"] + if schemaObj == nil { + schemaObj = m["parameters"] + } + + if fn, ok := m["function"].(map[string]any); ok { + if strings.TrimSpace(name) == "" { + name, _ = fn["name"].(string) + } + if strings.TrimSpace(desc) == "" { + desc, _ = fn["description"].(string) + } + if schemaObj == nil { + if v, ok := fn["input_schema"]; ok { + schemaObj = v + } + } + if schemaObj == nil { + if v, ok := fn["parameters"]; ok { + schemaObj = v + } + } + } + return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj +} + func toMessageMaps(v any) []map[string]any { arr, ok := v.([]any) if !ok { diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/adapter/claude/stream_runtime_finalize.go index f957ba1..12d9510 100644 --- a/internal/adapter/claude/stream_runtime_finalize.go +++ b/internal/adapter/claude/stream_runtime_finalize.go @@ -46,6 +46,9 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { if s.bufferToolContent { detected := util.ParseToolCalls(finalText, s.toolNames) + if len(detected) == 0 && finalThinking != "" { + detected = util.ParseToolCalls(finalThinking, s.toolNames) + } if len(detected) > 0 { stopReason = "tool_use" for i, tc := range detected { diff --git a/internal/format/claude/render.go b/internal/format/claude/render.go index fdba055..4675398 100644 --- a/internal/format/claude/render.go +++ b/internal/format/claude/render.go @@ -9,6 +9,9 @@ import ( func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any { detected := util.ParseToolCalls(finalText, toolNames) + if len(detected) == 0 && finalThinking != "" { + detected = util.ParseToolCalls(finalThinking, toolNames) + } content := make([]map[string]any, 0, 4) if finalThinking != "" { content = append(content, map[string]any{"type": "thinking", "thinking": finalThinking}) diff --git a/internal/format/claude/render_test.go b/internal/format/claude/render_test.go new file mode 100644 index 0000000..389eaee --- /dev/null +++ b/internal/format/claude/render_test.go @@ -0,0 +1,29 @@ +package claude + +import "testing" + +func TestBuildMessageResponseDetectsToolCallsFromThinkingFallback(t *testing.T) { + resp := BuildMessageResponse( + "msg_1", + "claude-sonnet-4-5", + []any{map[string]any{"role": "user", "content": "hi"}}, + `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`, + "", + []string{"search"}, + ) + + if resp["stop_reason"] != "tool_use" { + t.Fatalf("expected stop_reason=tool_use, got=%#v", resp["stop_reason"]) + } + content, _ := resp["content"].([]map[string]any) + if len(content) < 2 { + t.Fatalf("expected thinking + tool_use content blocks, got=%#v", resp["content"]) + } + last := content[len(content)-1] + if last["type"] != "tool_use" { + t.Fatalf("expected last content block tool_use, got=%#v", last["type"]) + } + if last["name"] != "search" { + t.Fatalf("expected tool name search, got=%#v", last["name"]) + } +} diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 5b386c2..fdace8e 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -89,8 +89,17 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []string) ([]ParsedToolCall, []string) { allowed := map[string]struct{}{} + allowedCanonical := map[string]string{} for _, name := range availableToolNames { - allowed[name] = struct{}{} + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + allowed[trimmed] = struct{}{} + lower := strings.ToLower(trimmed) + if _, exists := allowedCanonical[lower]; !exists { + allowedCanonical[lower] = trimmed + } } if len(allowed) == 0 { rejectedSet := map[string]struct{}{} @@ -112,10 +121,17 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin if tc.Name == "" { continue } - if _, ok := allowed[tc.Name]; !ok { + matchedName := "" + if _, ok := allowed[tc.Name]; ok { + matchedName = tc.Name + } else if canonical, ok := allowedCanonical[strings.ToLower(tc.Name)]; ok { + matchedName = canonical + } + if matchedName == "" { rejectedSet[tc.Name] = struct{}{} continue } + tc.Name = matchedName if tc.Input == nil { tc.Input = map[string]any{} } diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index 0e823c0..1287102 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -46,6 +46,17 @@ func TestParseToolCallsRejectsUnknownToolName(t *testing.T) { } } +func TestParseToolCallsAllowsCaseInsensitiveToolNameAndCanonicalizes(t *testing.T) { + text := `{"tool_calls":[{"name":"Bash","input":{"command":"ls -al"}}]}` + calls := ParseToolCalls(text, []string{"bash"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %#v", calls) + } + if calls[0].Name != "bash" { + t.Fatalf("expected canonical tool name bash, got %q", calls[0].Name) + } +} + func TestParseToolCallsDetailedMarksPolicyRejection(t *testing.T) { text := `{"tool_calls":[{"name":"unknown","input":{}}]}` res := ParseToolCallsDetailed(text, []string{"search"}) From d3b5493d2e205b33e9601702e7b8fad33e8032d1 Mon Sep 17 00:00:00 2001 From: BigUncle Date: Thu, 26 Feb 2026 00:41:39 +0800 Subject: [PATCH 2/5] fix(claude): guard thinking tool-call fallback when final text exists - only parse tool_calls from thinking when finalText is empty - apply the same guard in stream runtime finalizer - add regression tests for non-stream and stream paths --- .../adapter/claude/handler_stream_test.go | 34 +++++++++++++++++++ .../adapter/claude/stream_runtime_finalize.go | 2 +- internal/format/claude/render.go | 2 +- internal/format/claude/render_test.go | 33 ++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/internal/adapter/claude/handler_stream_test.go b/internal/adapter/claude/handler_stream_test.go index 42358aa..ebce879 100644 --- a/internal/adapter/claude/handler_stream_test.go +++ b/internal/adapter/claude/handler_stream_test.go @@ -209,6 +209,40 @@ func TestHandleClaudeStreamRealtimeToolDetectionFromThinkingFallback(t *testing. } } +func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) { + h := &Handler{} + resp := makeClaudeSSEHTTPResponse( + `data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`, + `data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`, + `data: {"p":"response/content","v":"normal answer"}`, + `data: [DONE]`, + ) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + + h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"}) + + frames := parseClaudeFrames(t, rec.Body.String()) + for _, f := range findClaudeFrames(frames, "content_block_start") { + contentBlock, _ := f.Payload["content_block"].(map[string]any) + if contentBlock["type"] == "tool_use" { + t.Fatalf("unexpected tool_use block when final text exists, body=%s", rec.Body.String()) + } + } + + 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 + } + } + if !foundEndTurn { + t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String()) + } +} + func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) { h := &Handler{} resp := makeClaudeSSEHTTPResponse( diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/adapter/claude/stream_runtime_finalize.go index 12d9510..18a9e2d 100644 --- a/internal/adapter/claude/stream_runtime_finalize.go +++ b/internal/adapter/claude/stream_runtime_finalize.go @@ -46,7 +46,7 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { if s.bufferToolContent { detected := util.ParseToolCalls(finalText, s.toolNames) - if len(detected) == 0 && finalThinking != "" { + if len(detected) == 0 && finalText == "" && finalThinking != "" { detected = util.ParseToolCalls(finalThinking, s.toolNames) } if len(detected) > 0 { diff --git a/internal/format/claude/render.go b/internal/format/claude/render.go index 4675398..d1a486b 100644 --- a/internal/format/claude/render.go +++ b/internal/format/claude/render.go @@ -9,7 +9,7 @@ import ( func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any { detected := util.ParseToolCalls(finalText, toolNames) - if len(detected) == 0 && finalThinking != "" { + if len(detected) == 0 && finalText == "" && finalThinking != "" { detected = util.ParseToolCalls(finalThinking, toolNames) } content := make([]map[string]any, 0, 4) diff --git a/internal/format/claude/render_test.go b/internal/format/claude/render_test.go index 389eaee..38668d4 100644 --- a/internal/format/claude/render_test.go +++ b/internal/format/claude/render_test.go @@ -27,3 +27,36 @@ func TestBuildMessageResponseDetectsToolCallsFromThinkingFallback(t *testing.T) t.Fatalf("expected tool name search, got=%#v", last["name"]) } } + +func TestBuildMessageResponseSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) { + resp := BuildMessageResponse( + "msg_1", + "claude-sonnet-4-5", + []any{map[string]any{"role": "user", "content": "hi"}}, + `{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`, + "normal answer", + []string{"search"}, + ) + + if resp["stop_reason"] != "end_turn" { + t.Fatalf("expected stop_reason=end_turn, got=%#v", resp["stop_reason"]) + } + + content, _ := resp["content"].([]map[string]any) + foundText := false + foundTool := false + for _, block := range content { + if block["type"] == "text" && block["text"] == "normal answer" { + foundText = true + } + if block["type"] == "tool_use" { + foundTool = true + } + } + if !foundText { + t.Fatalf("expected text block with finalText, got=%#v", resp["content"]) + } + if foundTool { + t.Fatalf("unexpected tool_use block when finalText exists, got=%#v", resp["content"]) + } +} From 3f09d60cdc48308a643a847c350eae888a68dde0 Mon Sep 17 00:00:00 2001 From: AYANGarch Date: Thu, 26 Feb 2026 22:54:50 +0800 Subject: [PATCH 3/5] feat(zeabur): add one-click deploy template --- DEPLOY.en.md | 12 +++++++++++ DEPLOY.md | 12 +++++++++++ README.MD | 7 ++++++ README.en.md | 7 ++++++ zeabur.yaml | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 98 insertions(+) create mode 100644 zeabur.yaml diff --git a/DEPLOY.en.md b/DEPLOY.en.md index 2ff3250..127696a 100644 --- a/DEPLOY.en.md +++ b/DEPLOY.en.md @@ -175,6 +175,18 @@ If container logs look normal but the admin panel is unreachable, check these fi 1. **Port alignment**: when `PORT` is not `5001`, use the same port in your URL (for example `http://localhost:8080/admin`). 2. **WebUI assets in dev compose**: `docker-compose.dev.yml` runs `go run` in a dev image and does not auto-install Node.js inside the container; if `static/admin` is missing in your repo, `/admin` will return 404. Build once on host: `./scripts/build-webui.sh`. +### 2.7 Zeabur One-Click (Dockerfile) + +This repo includes a `zeabur.yaml` template for one-click deployment on Zeabur: + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) + +Notes: + +- **Port**: DS2API listens on `5001` by default; the template sets `PORT=5001`. +- **Persistent config**: the template mounts `/data` and sets `DS2API_CONFIG_PATH=/data/config.json`. After importing config in Admin UI, it will be written and persisted to this path. +- **First login**: after deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions (recommended: rotate to a strong secret after first login). + --- ## 3. Vercel Deployment diff --git a/DEPLOY.md b/DEPLOY.md index d7d74d3..2df4238 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -175,6 +175,18 @@ healthcheck: 1. **端口是否一致**:`PORT` 改成非 `5001` 时,访问地址也要改成对应端口(如 `http://localhost:8080/admin`)。 2. **开发 compose 的 WebUI 静态文件**:`docker-compose.dev.yml` 使用 `go run` 开发镜像,不会在容器内自动安装 Node.js;若仓库里没有 `static/admin`,`/admin` 会返回 404。可先在宿主机构建一次:`./scripts/build-webui.sh`。 +### 2.7 Zeabur 一键部署(Dockerfile) + +仓库提供 `zeabur.yaml` 模板,可在 Zeabur 上一键部署: + +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) + +部署要点: + +- **端口**:服务默认监听 `5001`,模板会固定设置 `PORT=5001`。 +- **配置持久化**:模板挂载卷 `/data`,并设置 `DS2API_CONFIG_PATH=/data/config.json`;在管理台导入配置后,会写入并持久化到该路径。 +- **首次登录**:部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录(建议首次登录后自行更换为强密码)。 + --- ## 三、Vercel 部署 diff --git a/README.MD b/README.MD index b8c3be0..43d1270 100644 --- a/README.MD +++ b/README.MD @@ -5,6 +5,7 @@ ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) [![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.md) +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) 语言 / Language: [中文](README.MD) | [English](README.en.md) @@ -162,6 +163,12 @@ docker-compose logs -f 更新镜像:`docker-compose up -d --build` +#### Zeabur 一键部署(Dockerfile) + +1. 点击上方 “Deploy on Zeabur” 按钮,一键部署。 +2. 部署完成后访问 `/admin`,使用 Zeabur 环境变量/模板指引中的 `DS2API_ADMIN_KEY` 登录。 +3. 在管理台导入/编辑配置(会写入并持久化到 `/data/config.json`)。 + ### 方式三:Vercel 部署 1. Fork 仓库到自己的 GitHub diff --git a/README.en.md b/README.en.md index 4e872ad..c955ef3 100644 --- a/README.en.md +++ b/README.en.md @@ -5,6 +5,7 @@ ![Forks](https://img.shields.io/github/forks/CJackHwang/ds2api.svg) [![Release](https://img.shields.io/github/v/release/CJackHwang/ds2api?display_name=tag)](https://github.com/CJackHwang/ds2api/releases) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](DEPLOY.en.md) +[![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/L4CFHP) Language: [中文](README.MD) | [English](README.en.md) @@ -162,6 +163,12 @@ docker-compose logs -f Rebuild after updates: `docker-compose up -d --build` +#### Zeabur One-Click (Dockerfile) + +1. Click the “Deploy on Zeabur” button above to deploy. +2. After deployment, open `/admin` and login with `DS2API_ADMIN_KEY` shown in Zeabur env/template instructions. +3. Import / edit config in Admin UI (it will be written and persisted to `/data/config.json`). + ### Option 3: Vercel 1. Fork this repo to your GitHub account diff --git a/zeabur.yaml b/zeabur.yaml new file mode 100644 index 0000000..8d36cb4 --- /dev/null +++ b/zeabur.yaml @@ -0,0 +1,60 @@ +# yaml-language-server: $schema=https://schema.zeabur.app/template.json +apiVersion: zeabur.com/v1 +kind: Template +metadata: + name: DS2API +spec: + description: DeepSeek Web 对话转 OpenAI/Claude/Gemini 兼容 API(Go 实现,含 WebUI) + tags: + - DeepSeek + - API + - Go + readme: |- + # DS2API (Zeabur) + + ## After deployment + - Admin panel: `/admin` + - Health check: `/healthz` + - Config is persisted at `/data/config.json` (mounted volume) + + ## First-time setup + 1. Open your service URL, then visit `/admin` + 2. Login with `DS2API_ADMIN_KEY` (shown in Zeabur env/instructions) + 3. Import / edit config in Admin UI (saved to `/data/config.json`) + + services: + - name: ds2api + template: GIT + spec: + source: + source: GITHUB + repo: 1139136822 + branch: main + rootDirectory: / + ports: + - id: web + port: 5001 + type: HTTP + volumes: + - id: data + dir: /data + env: + PORT: + default: "5001" + LOG_LEVEL: + default: "INFO" + DS2API_ADMIN_KEY: + default: ${PASSWORD} + expose: true + DS2API_CONFIG_PATH: + default: /data/config.json + instructions: + - title: Admin panel + content: Visit `/admin` on your service URL. + - title: DS2API admin key + content: ${DS2API_ADMIN_KEY} + healthCheck: + type: HTTP + port: web + http: + path: /healthz From f60a3ea501e47036cbca9344ac5d46abb0ec4bac Mon Sep 17 00:00:00 2001 From: AYANGarch Date: Thu, 26 Feb 2026 23:18:57 +0800 Subject: [PATCH 4/5] docs(readme): add ds2api whale icon --- README.MD | 4 +++ README.en.md | 4 +++ assets/ds2api-icon.svg | 63 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 assets/ds2api-icon.svg diff --git a/README.MD b/README.MD index 43d1270..9b77d09 100644 --- a/README.MD +++ b/README.MD @@ -1,3 +1,7 @@ +

+ DS2API icon +

+ # DS2API [![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) diff --git a/README.en.md b/README.en.md index c955ef3..add018b 100644 --- a/README.en.md +++ b/README.en.md @@ -1,3 +1,7 @@ +

+ DS2API icon +

+ # DS2API [![License](https://img.shields.io/github/license/CJackHwang/ds2api.svg)](LICENSE) diff --git a/assets/ds2api-icon.svg b/assets/ds2api-icon.svg new file mode 100644 index 0000000..faf8eb3 --- /dev/null +++ b/assets/ds2api-icon.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From f6f6a651fdb1a75f6dd49418b092bd0e9cfb5962 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 20:58:18 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=E8=B4=A6=E5=8F=B7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=8A=B6=E6=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E3=80=81?= =?UTF-8?q?=E5=88=86=E9=A1=B5=E9=80=89=E6=8B=A9=E5=99=A8=E3=80=81=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E8=B4=A6=E5=8F=B7=E5=90=8D=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Account 结构加 TestStatus 字段,测试后写入 config.json - listAccounts 接口返回 test_status,前端根据结果显示红/绿/黄状态点 - 分页选择器支持 10/20/50/100/500/1000/2000/5000 - 点击账号名自动复制到剪贴板,hover 显示复制图标,复制后显示绿色对勾 --- internal/admin/deps.go | 1 + internal/admin/handler_accounts_crud.go | 1 + internal/admin/handler_accounts_testing.go | 10 ++++- internal/config/config.go | 9 ++-- internal/config/store.go | 12 +++++ .../account/AccountManagerContainer.jsx | 4 ++ webui/src/features/account/AccountsTable.jsx | 45 ++++++++++++++++--- webui/src/features/account/useAccountsData.js | 13 ++++-- webui/src/locales/en.json | 1 + webui/src/locales/zh.json | 1 + 10 files changed, 83 insertions(+), 14 deletions(-) diff --git a/internal/admin/deps.go b/internal/admin/deps.go index e92c37b..7debcf0 100644 --- a/internal/admin/deps.go +++ b/internal/admin/deps.go @@ -16,6 +16,7 @@ type ConfigStore interface { Accounts() []config.Account FindAccount(identifier string) (config.Account, bool) UpdateAccountToken(identifier, token string) error + UpdateAccountTestStatus(identifier, status string) error Update(mutator func(*config.Config) error) error ExportJSONAndBase64() (string, string, error) IsEnvBacked() bool diff --git a/internal/admin/handler_accounts_crud.go b/internal/admin/handler_accounts_crud.go index daaa434..a0d64df 100644 --- a/internal/admin/handler_accounts_crud.go +++ b/internal/admin/handler_accounts_crud.go @@ -56,6 +56,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) { "has_password": acc.Password != "", "has_token": token != "", "token_preview": preview, + "test_status": acc.TestStatus, }) } writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages}) diff --git a/internal/admin/handler_accounts_testing.go b/internal/admin/handler_accounts_testing.go index 2bd7706..7a7430d 100644 --- a/internal/admin/handler_accounts_testing.go +++ b/internal/admin/handler_accounts_testing.go @@ -88,7 +88,15 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any { start := time.Now() - result := map[string]any{"account": acc.Identifier(), "success": false, "response_time": 0, "message": "", "model": model} + identifier := acc.Identifier() + result := map[string]any{"account": identifier, "success": false, "response_time": 0, "message": "", "model": model} + defer func() { + status := "failed" + if ok, _ := result["success"].(bool); ok { + status = "ok" + } + _ = h.Store.UpdateAccountTestStatus(identifier, status) + }() token := strings.TrimSpace(acc.Token) if token == "" { newToken, err := h.DS.Login(ctx, acc) diff --git a/internal/config/config.go b/internal/config/config.go index 4b281a2..b8d59d6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,10 +18,11 @@ type Config struct { } type Account struct { - Email string `json:"email,omitempty"` - Mobile string `json:"mobile,omitempty"` - Password string `json:"password,omitempty"` - Token string `json:"token,omitempty"` + Email string `json:"email,omitempty"` + Mobile string `json:"mobile,omitempty"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` + TestStatus string `json:"test_status,omitempty"` } type CompatConfig struct { diff --git a/internal/config/store.go b/internal/config/store.go index 2e6fcaf..7a09cdc 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -97,6 +97,18 @@ func (s *Store) FindAccount(identifier string) (Account, bool) { return Account{}, false } +func (s *Store) UpdateAccountTestStatus(identifier, status string) error { + identifier = strings.TrimSpace(identifier) + s.mu.Lock() + defer s.mu.Unlock() + idx, ok := s.findAccountIndexLocked(identifier) + if !ok { + return errors.New("account not found") + } + s.cfg.Accounts[idx].TestStatus = status + return s.saveLocked() +} + func (s *Store) UpdateAccountToken(identifier, token string) error { identifier = strings.TrimSpace(identifier) s.mu.Lock() diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index f5f9324..558739d 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -17,10 +17,12 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, setKeysExpanded, accounts, page, + pageSize, totalPages, totalAccounts, loadingAccounts, fetchAccounts, + changePageSize, resolveAccountIdentifier, } = useAccountsData({ apiFetch }) @@ -79,6 +81,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, batchProgress={batchProgress} totalAccounts={totalAccounts} page={page} + pageSize={pageSize} totalPages={totalPages} resolveAccountIdentifier={resolveAccountIdentifier} onTestAll={testAllAccounts} @@ -87,6 +90,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, onDeleteAccount={deleteAccount} onPrevPage={() => fetchAccounts(page - 1)} onNextPage={() => fetchAccounts(page + 1)} + onPageSizeChange={changePageSize} /> { + navigator.clipboard.writeText(id).then(() => { + setCopiedId(id) + setTimeout(() => setCopiedId(null), 1500) + }) + } return (
@@ -83,12 +94,23 @@ export default function AccountsTable({
-
{id || '-'}
+
copyId(id)} + > + {id || '-'} + {copiedId === id + ? + : + } +
- {acc.has_token ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')} + {acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : (acc.test_status === 'ok' || acc.has_token) ? t('accountManager.sessionActive') : t('accountManager.reauthRequired')} {acc.token_preview && ( {acc.token_preview} @@ -122,8 +144,19 @@ export default function AccountsTable({ {totalPages > 1 && (
-
- {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} +
+
+ {t('accountManager.pageInfo', { current: page, total: totalPages, count: totalAccounts })} +
+