From 63d75edfbc0606f6d419d38a7c24e3a9be6df5e5 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sun, 3 May 2026 15:53:14 +0800 Subject: [PATCH 01/76] fix(image): remove paid account requirement for high-resolution image generation --- internal/protocol/conversation.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/protocol/conversation.go b/internal/protocol/conversation.go index e1d055b6a..e778e5db1 100644 --- a/internal/protocol/conversation.go +++ b/internal/protocol/conversation.go @@ -668,8 +668,7 @@ func AssistantHistoryMessages(messages []map[string]any) []string { const maxFreeGeneratePixels = 1577536 func RequiresPaidImageSize(size string) bool { - width, height, ok := imageSizeDimensions(size) - return ok && width*height > maxFreeGeneratePixels + return false } func imageSizeDimensions(size string) (int, int, bool) { From 5a4ff6b71eed63a2794541dffffe915272bc466b Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Mon, 4 May 2026 08:41:22 +0800 Subject: [PATCH 02/76] fix(image): keep local return false for RequiresPaidImageSize --- internal/protocol/conversation.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/protocol/conversation.go b/internal/protocol/conversation.go index 799609832..26aec2092 100644 --- a/internal/protocol/conversation.go +++ b/internal/protocol/conversation.go @@ -1134,9 +1134,7 @@ func NormalizeImageGenerationSize(size string) string { } func RequiresPaidImageSize(size string) bool { - size = NormalizeImageGenerationSize(size) - width, height, ok := imageSizeDimensions(size) - return ok && width*height > maxFreeGeneratePixels + return false } func imageSizeDimensions(size string) (int, int, bool) { From 8b9691b962d25990ecbdab697b650b6e600267ec Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Mon, 4 May 2026 09:17:38 +0800 Subject: [PATCH 03/76] fix(test): update test expectations for RequiresPaidImageSize returning false --- internal/protocol/api_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/protocol/api_test.go b/internal/protocol/api_test.go index 58f942df6..0442286f7 100644 --- a/internal/protocol/api_test.go +++ b/internal/protocol/api_test.go @@ -186,11 +186,11 @@ func TestRequiresPaidImageSize(t *testing.T) { {name: "free pixel budget", size: "1248x1248", want: false}, {name: "1080p square below paid budget", size: "1080x1080", want: false}, {name: "1080p tier below paid budget", size: "1080p", want: false}, - {name: "1080p widescreen above paid budget", size: "1920x1080", want: true}, - {name: "2k tier", size: "2k", want: true}, - {name: "2k", size: "2560x1440", want: true}, - {name: "4k tier", size: "4k", want: true}, - {name: "4k", size: "3840x2160", want: true}, + {name: "1080p widescreen above paid budget", size: "1920x1080", want: false}, + {name: "2k tier", size: "2k", want: false}, + {name: "2k", size: "2560x1440", want: false}, + {name: "4k tier", size: "4k", want: false}, + {name: "4k", size: "3840x2160", want: false}, } for _, tt := range tests { @@ -232,8 +232,8 @@ func TestConversationRequestNormalizesResolutionTierSize(t *testing.T) { if request.Size != "2048x2048" { t.Fatalf("Normalized() size = %q, want 2048x2048", request.Size) } - if !request.RequirePaidAccount { - t.Fatal("Normalized() RequirePaidAccount = false, want true for 2k tier") + if request.RequirePaidAccount { + t.Fatal("Normalized() RequirePaidAccount = true, want false for 2k tier") } } @@ -333,8 +333,8 @@ func TestResponseImageGenerationToolAcceptsTypedToolSlice(t *testing.T) { if request.Size != "2880x2880" { t.Fatalf("request size = %q, want 2880x2880", request.Size) } - if !request.RequirePaidAccount { - t.Fatal("RequirePaidAccount = false, want true for 2880x2880") + if request.RequirePaidAccount { + t.Fatal("RequirePaidAccount = true, want false for 2880x2880") } } From f194810363d07ce3f6dd7a3107c84fb6bca3467f Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Mon, 4 May 2026 13:43:47 +0800 Subject: [PATCH 04/76] fix(ci): add git authentication for Go module downloads in CI and release workflows --- .github/workflows/ci.yml | 3 +++ .github/workflows/release.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b84aa3e32..9be34a409 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,5 +35,8 @@ jobs: - name: Verify Go version run: go version | grep -q 'go1.26.2' + - name: Configure git for Go modules + run: git config --global url."https://${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" + - name: Test backend run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d802f2c4d..e97934d1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,6 +87,9 @@ jobs: - name: Verify Go version run: go version | grep -q 'go1.26.2' + - name: Configure git for Go modules + run: git config --global url."https://${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" + - name: Set up QEMU uses: docker/setup-qemu-action@v3 From 404557bbd5de132cce4330a86c1245bcfb874056 Mon Sep 17 00:00:00 2001 From: ZyphrZero <133507172+ZyphrZero@users.noreply.github.com> Date: Fri, 8 May 2026 13:27:37 +0800 Subject: [PATCH 05/76] feat(admin): add user pagination, image response refactor, and build optimization - Add server-side pagination for managed users list with page/pageSize params - Redesign users page UI with pagination controls and role management - Add announcement-markdown component for rich announcement rendering - Refactor image response handling with improved concurrency and error handling - Add backend and HTTP API tests for pagination and image endpoints - Set GOMEMLIMIT=1GiB in Dockerfile to prevent OOM during source builds --- CHANGELOG.md | 37 ++ Dockerfile | 8 +- internal/backend/backend_test.go | 130 ++++++ internal/backend/responses_image.go | 145 ++++-- internal/httpapi/app_test.go | 75 ++++ internal/httpapi/routes.go | 206 ++++++++- .../components/announcements-card.tsx | 10 +- web/src/app/users/page.tsx | 213 ++++++--- web/src/components/announcement-banner.tsx | 5 +- web/src/components/announcement-markdown.tsx | 414 ++++++++++++++++++ web/src/lib/api.ts | 43 +- 11 files changed, 1170 insertions(+), 116 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 web/src/components/announcement-markdown.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..ab277c5c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +## [0.1.7] - 2026-05-08 + +### 新增 + +- 图片生成链路区分官方 `gpt-image-2` 与 `codex-gpt-image-2`:官方模型走标准 `image_generation` 工具语义,Codex 别名走 Codex Responses 路由,并在发送上游前完成模型归一化。 +- Codex 图片生成请求增加 Codex TUI 请求头与模型映射,避免把合成别名直接传给上游工具模型字段。 +- 图片结果支持带会话凭据的鉴权加载与下载,在受保护图片资源下可继续预览、灯箱查看和保存。 +- 图片创作台增加账号能力感知、模型路由标签、尺寸预设展示和更明确的高分辨率提示。 +- 账号刷新接口返回逐账号刷新明细,包括成功状态、耗时、账号信息和失败汇总;管理端账号页同步展示单账号刷新状态、指标卡片和 Token 复制操作。 + +### 变更 + +- 创作并发控制从全局图片槽位改为按用户的创作单元并发限制,统一覆盖图片生成、图片编辑和图片场景聊天任务。 +- 多输出图片任务按单个输出占用创作单元,可更细粒度地展示局部进度和 `output_statuses`。 +- 高分辨率图片请求不再由本地账号类型预先拦截,是否接受由上游服务决定;前端提示也改为说明上游判定。 +- Responses 图片处理从通用 backend 逻辑中拆分为独立模块,协议归一化、SSE 解析和图片输出处理更集中。 +- 图片页、任务队列、弹出菜单和设置页输入控件做了交互整理,移除了过时的 Codex 专用提示文案。 + +### 修复 + +- 对 Responses SSE 传输中断、HTTP/2 `flow_control_error` 等短暂上游连接问题增加有限重试,降低长时间 2K 图片生成因单次断流失败的概率。 +- 修复滚动锁定时页面被额外添加外边距的问题,减少弹窗或灯箱打开时的布局抖动。 +- 改进图片任务耗时显示,任务开始处理后使用更准确的起始时间计算。 +- 扩展刷新结果中的 Token 脱敏范围,避免账号刷新明细泄漏敏感凭据。 + +### 文档 + +- 新增 `jshook/README.md` 作为 ChatGPT Web 逆向研究索引。 +- 新增 gpt-image-2 生成链路、认证 API schema、接口清单、内容类型、函数映射、内部代号和请求完成流程等研究文档。 +- 新增用于验证文本聊天和图片生成完整流程的 `jshook/scripts/` 脚本与脱敏响应样本。 +- README 的技术研究入口改为文档表格,并移除过时的图片架构说明和历史功能状态文档。 + +### 破坏性变更 + +- 移除 `CHATGPT2API_IMAGE_CONCURRENT_LIMIT` 配置项。请改用 `USER_DEFAULT_CONCURRENT_LIMIT` 控制用户默认创作并发额度,该额度现在统一作用于图片生成、图片编辑和图片场景聊天任务。 diff --git a/Dockerfile b/Dockerfile index ef86f43d0..10a98cd96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,9 +32,15 @@ COPY --from=web-build /app/internal/web/dist ./internal/web/dist ARG TARGETOS ARG TARGETARCH ARG VERSION=0.0.0-dev +# GOMEMLIMIT 是 Go 1.19+ 的软内存上限,仅在内存紧张时触发更激进的 GC, +# 不影响构建速度,仅作为防止 OOM 的安全阀 RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build -trimpath -tags=embed -ldflags="-s -w -X chatgpt2api/internal/version.Version=${VERSION}" -o /out/chatgpt2api ./cmd/chatgpt2api + GOMEMLIMIT=1GiB \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ + go build -trimpath -tags=embed \ + -ldflags="-s -w -X chatgpt2api/internal/version.Version=${VERSION}" \ + -o /out/chatgpt2api ./cmd/chatgpt2api FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS app diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go index ae9a6b029..b1f2283b9 100644 --- a/internal/backend/backend_test.go +++ b/internal/backend/backend_test.go @@ -8,12 +8,38 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) func ptrInt(value int) *int { return &value } +func newTestBackendClient(server *httptest.Server) *Client { + client := &Client{ + BaseURL: server.URL, + AccessToken: "token-1", + httpClient: server.Client(), + lookup: testAccountLookup{ + "token-1": {"chatgpt_account_id": "acct-1"}, + }, + } + client.fp = client.buildFingerprint() + client.applyBrowserFingerprint() + client.userAgent = client.fp["user-agent"] + client.deviceID = client.fp["oai-device-id"] + client.sessionID = client.fp["oai-session-id"] + return client +} + +func setOfficialImageDownloadRetryDelayForTest(delay time.Duration) func() { + previous := officialImageDownloadRetryDelay + officialImageDownloadRetryDelay = delay + return func() { + officialImageDownloadRetryDelay = previous + } +} + func TestUpstreamHTTPErrorSummarizesCloudflareChallenge(t *testing.T) { err := upstreamHTTPError("bootstrap", 403, []byte(`Enable JavaScript and cookies to continue`)) got := err.Error() @@ -353,6 +379,110 @@ func TestStreamResponsesImageDoesNotTreatQueuedAssistantNoticeAsFinalText(t *tes } } +func TestResolveOfficialImageResultsRetriesTransientDownloadURL404(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + resetOfficialImageRetryDelay := setOfficialImageDownloadRetryDelayForTest(0) + defer resetOfficialImageRetryDelay() + + downloadAttempts := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-1/attachment/file_img/download": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/attachment.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/attachment.png": + downloadAttempts++ + if downloadAttempts == 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"File link not found."}`)) + return + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + results, err := client.resolveOfficialImageResults(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + }, ResponsesImageEvent{ + ConversationID: "conv-1", + SedimentIDs: []string{"file_img"}, + }) + if err != nil { + t.Fatalf("resolveOfficialImageResults() error = %v", err) + } + if downloadAttempts != 2 { + t.Fatalf("download attempts = %d, want 2", downloadAttempts) + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one final image result", results) + } +} + +func TestResolveOfficialImageResultsFallsBackFromAttachmentToFileDownload(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + resetOfficialImageRetryDelay := setOfficialImageDownloadRetryDelayForTest(0) + defer resetOfficialImageRetryDelay() + + attachmentURLAttempts := 0 + fileURLAttempts := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-1/attachment/file_img/download": + attachmentURLAttempts++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"File link not found."}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/file_img/download": + fileURLAttempts++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/file.png": + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + results, err := client.resolveOfficialImageResults(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + }, ResponsesImageEvent{ + ConversationID: "conv-1", + FileIDs: []string{"file_img"}, + SedimentIDs: []string{"file_img"}, + }) + if err != nil { + t.Fatalf("resolveOfficialImageResults() error = %v", err) + } + if attachmentURLAttempts != officialImageDownloadAttempts { + t.Fatalf("attachment URL attempts = %d, want %d", attachmentURLAttempts, officialImageDownloadAttempts) + } + if fileURLAttempts != 1 { + t.Fatalf("file URL attempts = %d, want 1", fileURLAttempts) + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one final image result", results) + } +} + func TestBuildResponsesImagePayloadSendsCompressionOnlyForJPEG(t *testing.T) { compression := 37 jpegPayload, err := buildResponsesImagePayload(ResponsesImageRequest{ diff --git a/internal/backend/responses_image.go b/internal/backend/responses_image.go index ab7f62e33..3db75825f 100644 --- a/internal/backend/responses_image.go +++ b/internal/backend/responses_image.go @@ -40,8 +40,12 @@ const ( responsesImageMaxRatio = 3 responsesImageMinPixels = 655360 responsesImageMaxPixels = 8294400 + + officialImageDownloadAttempts = 3 ) +var officialImageDownloadRetryDelay = 750 * time.Millisecond + type ResponsesInputImage struct { Data []byte ContentType string @@ -105,6 +109,16 @@ type imageConversationState struct { TurnUseCase string } +type officialImageDownloadTarget struct { + FileID string + SedimentID string +} + +type officialImageDownloadCandidate struct { + Label string + URL func(context.Context) (string, error) +} + func (c *Client) StreamResponsesImage(ctx context.Context, request ResponsesImageRequest) (<-chan ResponsesImageEvent, <-chan error) { out := make(chan ResponsesImageEvent) errCh := make(chan error, 1) @@ -1124,16 +1138,13 @@ func (c *Client) resolveOfficialImageResults(ctx context.Context, request Respon fileIDs = appendUniqueString(fileIDs, polledFiles...) sedimentIDs = appendUniqueString(sedimentIDs, polledSediments...) } - urls, err := c.resolveOfficialImageURLs(ctx, conversationID, fileIDs, sedimentIDs) - if err != nil { - return nil, err - } - if len(urls) == 0 { + targets := officialImageDownloadTargets(fileIDs, sedimentIDs) + if len(targets) == 0 { return nil, nil } - results := make([]ResponsesImageEvent, 0, len(urls)) - for index, url := range urls { - data, downloadErr := c.downloadOfficialImage(ctx, url) + results := make([]ResponsesImageEvent, 0, len(targets)) + for index, target := range targets { + data, downloadErr := c.downloadOfficialImageFromCandidates(ctx, c.officialImageDownloadCandidates(conversationID, target)) if downloadErr != nil { return nil, downloadErr } @@ -1152,6 +1163,40 @@ func (c *Client) resolveOfficialImageResults(ctx context.Context, request Respon return results, nil } +func officialImageDownloadTargets(fileIDs, sedimentIDs []string) []officialImageDownloadTarget { + fileIDs = filterOfficialImageIDs(fileIDs) + sedimentIDs = filterOfficialImageIDs(sedimentIDs) + if len(fileIDs) > 0 && len(fileIDs) == len(sedimentIDs) { + targets := make([]officialImageDownloadTarget, 0, len(fileIDs)) + for index, fileID := range fileIDs { + targets = append(targets, officialImageDownloadTarget{FileID: fileID, SedimentID: sedimentIDs[index]}) + } + return targets + } + + usedFiles := map[string]struct{}{} + fileSet := map[string]struct{}{} + for _, fileID := range fileIDs { + fileSet[fileID] = struct{}{} + } + targets := make([]officialImageDownloadTarget, 0, len(fileIDs)+len(sedimentIDs)) + for _, sedimentID := range sedimentIDs { + target := officialImageDownloadTarget{SedimentID: sedimentID} + if _, ok := fileSet[sedimentID]; ok { + target.FileID = sedimentID + usedFiles[sedimentID] = struct{}{} + } + targets = append(targets, target) + } + for _, fileID := range fileIDs { + if _, ok := usedFiles[fileID]; ok { + continue + } + targets = append(targets, officialImageDownloadTarget{FileID: fileID}) + } + return targets +} + func filterOfficialImageIDs(values []string) []string { var out []string for _, value := range values { @@ -1246,30 +1291,28 @@ func (c *Client) fetchOfficialConversationImageIDs(ctx context.Context, conversa return fileIDs, sedimentIDs, nil } -func (c *Client) resolveOfficialImageURLs(ctx context.Context, conversationID string, fileIDs, sedimentIDs []string) ([]string, error) { - var urls []string - for _, fileID := range fileIDs { - url, err := c.getOfficialFileDownloadURL(ctx, fileID) - if err != nil { - continue - } - if strings.TrimSpace(url) != "" { - urls = append(urls, url) - } - } - if len(urls) > 0 || strings.TrimSpace(conversationID) == "" { - return urls, nil +func (c *Client) officialImageDownloadCandidates(conversationID string, target officialImageDownloadTarget) []officialImageDownloadCandidate { + var candidates []officialImageDownloadCandidate + conversationID = strings.TrimSpace(conversationID) + if conversationID != "" && strings.TrimSpace(target.SedimentID) != "" { + sedimentID := strings.TrimSpace(target.SedimentID) + candidates = append(candidates, officialImageDownloadCandidate{ + Label: "official image attachment " + sedimentID, + URL: func(ctx context.Context) (string, error) { + return c.getOfficialAttachmentDownloadURL(ctx, conversationID, sedimentID) + }, + }) } - for _, sedimentID := range sedimentIDs { - url, err := c.getOfficialAttachmentDownloadURL(ctx, conversationID, sedimentID) - if err != nil { - continue - } - if strings.TrimSpace(url) != "" { - urls = append(urls, url) - } + if strings.TrimSpace(target.FileID) != "" { + fileID := strings.TrimSpace(target.FileID) + candidates = append(candidates, officialImageDownloadCandidate{ + Label: "official image file " + fileID, + URL: func(ctx context.Context) (string, error) { + return c.getOfficialFileDownloadURL(ctx, fileID) + }, + }) } - return urls, nil + return candidates } func (c *Client) getOfficialFileDownloadURL(ctx context.Context, fileID string) (string, error) { @@ -1314,6 +1357,48 @@ func (c *Client) getOfficialAttachmentDownloadURL(ctx context.Context, conversat return firstNonEmpty(util.Clean(data["download_url"]), util.Clean(data["url"])), nil } +func (c *Client) downloadOfficialImageFromCandidates(ctx context.Context, candidates []officialImageDownloadCandidate) ([]byte, error) { + var lastErr error + for _, candidate := range candidates { + data, err := c.downloadOfficialImageWithRetry(ctx, candidate) + if err == nil { + return data, nil + } + lastErr = err + } + if lastErr != nil { + return nil, fmt.Errorf("official image download failed after %d candidate(s): %w", len(candidates), lastErr) + } + return nil, fmt.Errorf("official image download failed: no downloadable image resource") +} + +func (c *Client) downloadOfficialImageWithRetry(ctx context.Context, candidate officialImageDownloadCandidate) ([]byte, error) { + var lastErr error + for attempt := 1; attempt <= officialImageDownloadAttempts; attempt++ { + url, err := candidate.URL(ctx) + if err == nil && strings.TrimSpace(url) == "" { + err = fmt.Errorf("%s returned empty download URL", candidate.Label) + } + if err == nil { + var data []byte + data, err = c.downloadOfficialImage(ctx, url) + if err == nil { + return data, nil + } + } + lastErr = err + if attempt == officialImageDownloadAttempts { + break + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(officialImageDownloadRetryDelay): + } + } + return nil, lastErr +} + func (c *Client) downloadOfficialImage(ctx context.Context, url string) ([]byte, error) { req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := c.httpClient.Do(req) diff --git a/internal/httpapi/app_test.go b/internal/httpapi/app_test.go index 616f10a81..2e15fc3ae 100644 --- a/internal/httpapi/app_test.go +++ b/internal/httpapi/app_test.go @@ -2149,6 +2149,81 @@ func TestAdminUsersManageLinuxDoUsers(t *testing.T) { } } +func TestAdminUsersListPaginationAndFilters(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + if _, err := app.auth.CreatePasswordUser("enabled_one", "Password123", "Enabled One", service.DefaultManagedRoleID, true); err != nil { + t.Fatalf("CreatePasswordUser(enabled_one) error = %v", err) + } + if _, err := app.auth.CreatePasswordUser("disabled_one", "Password123", "Disabled One", service.DefaultManagedRoleID, false); err != nil { + t.Fatalf("CreatePasswordUser(disabled_one) error = %v", err) + } + if _, err := app.auth.CreatePasswordUser("enabled_two", "Password123", "Enabled Two", service.DefaultManagedRoleID, true); err != nil { + t.Fatalf("CreatePasswordUser(enabled_two) error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/users?page=2&page_size=2", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res := httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("paged users status = %d body = %s", res.Code, res.Body.String()) + } + var payload map[string]any + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("paged users json: %v", err) + } + if payload["total"] != float64(3) || payload["page"] != float64(2) || payload["page_size"] != float64(2) || payload["total_pages"] != float64(2) { + t.Fatalf("paged metadata = %#v", payload) + } + if items := logItems(payload); len(items) != 1 { + t.Fatalf("paged items length = %d payload = %#v", len(items), payload) + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users?page=99&page_size=2", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("clamped users status = %d body = %s", res.Code, res.Body.String()) + } + payload = map[string]any{} + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("clamped users json: %v", err) + } + if payload["page"] != float64(2) || payload["total_pages"] != float64(2) { + t.Fatalf("clamped metadata = %#v", payload) + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users?page=1&page_size=20&provider=local&status=disabled&search=disabled_one", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("filtered users status = %d body = %s", res.Code, res.Body.String()) + } + payload = map[string]any{} + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("filtered users json: %v", err) + } + items := logItems(payload) + if payload["total"] != float64(1) || len(items) != 1 || items[0]["username"] != "disabled_one" { + t.Fatalf("filtered users payload = %#v", payload) + } + if _, ok := items[0]["usage_curve"].([]any); !ok { + t.Fatalf("filtered user missing usage stats: %#v", items[0]) + } + + req = httptest.NewRequest(http.MethodGet, "/api/admin/users?page=0", nil) + req.Header.Set("Authorization", adminAuthHeader(t, app)) + res = httptest.NewRecorder() + app.Handler().ServeHTTP(res, req) + if res.Code != http.StatusBadRequest { + t.Fatalf("invalid page status = %d body = %s", res.Code, res.Body.String()) + } +} + func TestLinuxDoOAuthCallbackCreatesSession(t *testing.T) { oauthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index f683a098b..880ae9056 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "time" @@ -373,7 +374,12 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { if r.URL.Path == base { switch r.Method { case http.MethodGet: - util.WriteJSON(w, http.StatusOK, map[string]any{"items": a.managedUsers()}) + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + util.WriteJSON(w, http.StatusOK, response) case http.MethodPost: body, err := readJSONMap(r) if err != nil { @@ -395,11 +401,16 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { util.WriteError(w, http.StatusBadRequest, err.Error()) return } - items := a.managedUsers() - if current := findManagedUser(items, util.Clean(item["id"])); current != nil { + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if current := a.managedUser(util.Clean(item["id"])); current != nil { item = current } - util.WriteJSON(w, http.StatusOK, map[string]any{"item": item, "items": items}) + response["item"] = item + util.WriteJSON(w, http.StatusOK, response) default: w.WriteHeader(http.StatusMethodNotAllowed) } @@ -458,11 +469,18 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { util.WriteError(w, http.StatusNotFound, "user not found") return } - items := a.managedUsers() - if current := findManagedUser(items, userID); current != nil { + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if current := a.managedUser(userID); current != nil { item = current } - util.WriteJSON(w, http.StatusOK, map[string]any{"item": item, "api_key": apiKey, "key": raw, "items": items}) + response["item"] = item + response["api_key"] = apiKey + response["key"] = raw + util.WriteJSON(w, http.StatusOK, response) return } if len(parts) != 4 { @@ -495,24 +513,82 @@ func (a *App) handleAdminUsers(w http.ResponseWriter, r *http.Request) { util.WriteError(w, http.StatusNotFound, "user not found") return } - items := a.managedUsers() - if current := findManagedUser(items, userID); current != nil { + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + if current := a.managedUser(userID); current != nil { item = current } - util.WriteJSON(w, http.StatusOK, map[string]any{"item": item, "items": items}) + response["item"] = item + util.WriteJSON(w, http.StatusOK, response) case http.MethodDelete: if !a.auth.DeleteUser(userID) { util.WriteError(w, http.StatusNotFound, "user not found") return } - util.WriteJSON(w, http.StatusOK, map[string]any{"items": a.managedUsers()}) + response, err := a.managedUsersResponse(r) + if err != nil { + util.WriteError(w, http.StatusBadRequest, err.Error()) + return + } + util.WriteJSON(w, http.StatusOK, response) default: w.WriteHeader(http.StatusMethodNotAllowed) } } -func (a *App) managedUsers() []map[string]any { - items := a.auth.ListUsers() +type managedUsersQuery struct { + Page int + PageSize int + Search string + Provider string + Status string + Total int + TotalPages int +} + +func (a *App) managedUsersResponse(r *http.Request) (map[string]any, error) { + query, err := parseManagedUsersQuery(r) + if err != nil { + return nil, err + } + items := filterManagedUsers(a.auth.ListUsers(), query) + query.Total = len(items) + query.TotalPages = managedUsersTotalPages(query.Total, query.PageSize) + if query.Page > query.TotalPages { + query.Page = query.TotalPages + } + start := (query.Page - 1) * query.PageSize + if start > query.Total { + start = query.Total + } + end := start + query.PageSize + if end > query.Total { + end = query.Total + } + pageItems := items[start:end] + a.attachManagedUserUsage(pageItems) + return map[string]any{ + "items": pageItems, + "total": query.Total, + "page": query.Page, + "page_size": query.PageSize, + "total_pages": query.TotalPages, + }, nil +} + +func (a *App) managedUser(id string) map[string]any { + item := findManagedUser(a.auth.ListUsers(), id) + if item == nil { + return nil + } + a.attachManagedUserUsage([]map[string]any{item}) + return item +} + +func (a *App) attachManagedUserUsage(items []map[string]any) { stats := a.logs.UserUsageStats(14) for _, item := range items { userID := util.Clean(item["id"]) @@ -524,7 +600,109 @@ func (a *App) managedUsers() []map[string]any { item[key] = value } } - return items +} + +func parseManagedUsersQuery(r *http.Request) (managedUsersQuery, error) { + values := r.URL.Query() + page, err := parseManagedUsersPage(values.Get("page")) + if err != nil { + return managedUsersQuery{}, err + } + pageSize, err := parseManagedUsersPageSize(values.Get("page_size")) + if err != nil { + return managedUsersQuery{}, err + } + return managedUsersQuery{ + Page: page, + PageSize: pageSize, + Search: strings.TrimSpace(values.Get("search")), + Provider: strings.TrimSpace(values.Get("provider")), + Status: strings.TrimSpace(values.Get("status")), + }, nil +} + +func parseManagedUsersPage(raw string) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 1, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return 0, fmt.Errorf("page 参数无效") + } + return value, nil +} + +func parseManagedUsersPageSize(raw string) (int, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 20, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value < 1 { + return 0, fmt.Errorf("page_size 参数无效") + } + return normalizedManagedUsersPageSize(value), nil +} + +func normalizedManagedUsersPageSize(value int) int { + if value <= 0 { + return 20 + } + if value > 100 { + return 100 + } + return value +} + +func managedUsersTotalPages(total, pageSize int) int { + if pageSize <= 0 { + pageSize = 20 + } + if total <= 0 { + return 1 + } + return (total + pageSize - 1) / pageSize +} + +func filterManagedUsers(items []map[string]any, query managedUsersQuery) []map[string]any { + out := make([]map[string]any, 0, len(items)) + search := strings.ToLower(strings.TrimSpace(query.Search)) + provider := strings.TrimSpace(query.Provider) + status := strings.TrimSpace(query.Status) + for _, item := range items { + if provider != "" && provider != "all" && util.Clean(item["provider"]) != provider { + continue + } + if status == "enabled" && !util.ToBool(item["enabled"]) { + continue + } + if status == "disabled" && util.ToBool(item["enabled"]) { + continue + } + if search != "" && !strings.Contains(managedUserSearchText(item), search) { + continue + } + out = append(out, item) + } + return out +} + +func managedUserSearchText(item map[string]any) string { + parts := []string{ + util.Clean(item["id"]), + util.Clean(item["username"]), + util.Clean(item["name"]), + util.Clean(item["role_id"]), + util.Clean(item["role_name"]), + util.Clean(item["owner_id"]), + util.Clean(item["owner_name"]), + util.Clean(item["provider"]), + util.Clean(item["linuxdo_level"]), + util.Clean(item["session_id"]), + util.Clean(item["session_name"]), + } + return strings.ToLower(strings.Join(parts, " ")) } func findManagedUser(items []map[string]any, id string) map[string]any { diff --git a/web/src/app/settings/components/announcements-card.tsx b/web/src/app/settings/components/announcements-card.tsx index a050c2e52..949df01a7 100644 --- a/web/src/app/settings/components/announcements-card.tsx +++ b/web/src/app/settings/components/announcements-card.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { AnnouncementMarkdown } from "@/components/announcement-markdown"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -263,9 +264,12 @@ export function AnnouncementsCard() { 创作台 ) : null} -

+ {item.content} -

+
更新于 {formatDateTime(item.updated_at)}
@@ -344,7 +348,7 @@ export function AnnouncementsCard() { className="min-h-36 bg-background" /> - 保存前会去除首尾空白,内容不能为空。 + 支持 Markdown 链接,例如 [官网](https://example.com),保存前会去除首尾空白。
diff --git a/web/src/app/users/page.tsx b/web/src/app/users/page.tsx index 0cc274f15..563014859 100644 --- a/web/src/app/users/page.tsx +++ b/web/src/app/users/page.tsx @@ -1,9 +1,11 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Ban, CheckCircle2, + ChevronLeft, + ChevronRight, KeyRound, LoaderCircle, Plus, @@ -80,6 +82,7 @@ type CreateUserForm = { type CreateUserErrors = Partial>; const accountUsernamePattern = /^[a-z0-9][a-z0-9_.-]{2,31}$/; +const userPageSizeOptions = ["10", "20", "50", "100"]; function createEmptyUserForm(roleId = ""): CreateUserForm { return { @@ -288,25 +291,6 @@ function UsageSparkline({ points }: { points?: ManagedUser["usage_curve"] }) { ); } -function userSearchText(user: ManagedUser) { - return [ - user.id, - user.username, - user.name, - user.role_id, - user.role_name, - user.owner_id, - user.owner_name, - user.provider, - user.linuxdo_level, - user.session_id, - user.session_name, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); -} - function roleLabel(user: ManagedUser, roles: ManagedRole[]) { const roleID = String(user.role_id || "").trim(); const role = roles.find((item) => item.id === roleID); @@ -314,11 +298,16 @@ function roleLabel(user: ManagedUser, roles: ManagedRole[]) { } function UsersContent() { + const rolesLoadedRef = useRef(false); const [items, setItems] = useState([]); const [roles, setRoles] = useState([]); const [searchText, setSearchText] = useState(""); const [providerFilter, setProviderFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState("20"); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(true); const [pendingIds, setPendingIds] = useState>(() => new Set()); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); @@ -330,47 +319,65 @@ function UsersContent() { const [selectedRoleId, setSelectedRoleId] = useState(""); const [isSavingRole, setIsSavingRole] = useState(false); - const loadUsers = useCallback(async () => { + const loadUsers = useCallback(async (overrides: { page?: number; includeRoles?: boolean } = {}) => { + const requestedPage = overrides.page ?? page; + const includeRoles = overrides.includeRoles ?? !rolesLoadedRef.current; setIsLoading(true); try { + const usersPromise = fetchManagedUsers({ + page: requestedPage, + page_size: pageSize, + search: searchText, + provider: providerFilter, + status: statusFilter, + }); const [usersData, rolesData] = await Promise.all([ - fetchManagedUsers(), - fetchManagedRoles(), + usersPromise, + includeRoles ? fetchManagedRoles() : Promise.resolve(null), ]); - const nextRoles = normalizeManagedRoles(rolesData.items); setItems(normalizeManagedUsers(usersData.items)); - setRoles(nextRoles); - setCreateForm((current) => ({ - ...current, - role_id: current.role_id || nextRoles[0]?.id || "", - })); + setTotal(Number.isFinite(usersData.total) ? usersData.total : 0); + setTotalPages(Math.max(1, Number.isFinite(usersData.total_pages) ? usersData.total_pages : 1)); + if (usersData.page && usersData.page !== page) { + setPage(usersData.page); + } + if (rolesData) { + rolesLoadedRef.current = true; + const nextRoles = normalizeManagedRoles(rolesData.items); + setRoles(nextRoles); + setCreateForm((current) => ({ + ...current, + role_id: current.role_id || nextRoles[0]?.id || "", + })); + } } catch (error) { toast.error(error instanceof Error ? error.message : "加载用户失败"); } finally { setIsLoading(false); } - }, []); + }, [page, pageSize, providerFilter, searchText, statusFilter]); useEffect(() => { void loadUsers(); }, [loadUsers]); - const filteredItems = useMemo(() => { - const keyword = searchText.trim().toLowerCase(); - return items.filter((user) => { - if (providerFilter !== "all" && user.provider !== providerFilter) { - return false; - } - if (statusFilter === "enabled" && !user.enabled) { - return false; - } - if (statusFilter === "disabled" && user.enabled) { - return false; - } - return !keyword || userSearchText(user).includes(keyword); - }); - }, [items, providerFilter, searchText, statusFilter]); + const safePage = Math.min(page, totalPages); + const startIndex = total === 0 ? 0 : (safePage - 1) * Number(pageSize) + 1; + const endIndex = Math.min(safePage * Number(pageSize), total); const hasActiveFilters = searchText.trim() !== "" || providerFilter !== "all" || statusFilter !== "all"; + const paginationItems = useMemo(() => { + const nextItems: (number | "...")[] = []; + const start = Math.max(1, safePage - 1); + const end = Math.min(totalPages, safePage + 1); + + if (start > 1) nextItems.push(1); + if (start > 2) nextItems.push("..."); + for (let current = start; current <= end; current += 1) nextItems.push(current); + if (end < totalPages - 1) nextItems.push("..."); + if (end < totalPages) nextItems.push(totalPages); + + return nextItems; + }, [safePage, totalPages]); const setItemPending = (id: string, isPending: boolean) => { setPendingIds((current) => { @@ -415,11 +422,15 @@ function UsersContent() { setIsCreating(true); try { - const data = await createManagedUser(createUserPayload(createForm)); - setItems(normalizeManagedUsers(data.items)); + await createManagedUser(createUserPayload(createForm)); setCreateForm(createEmptyUserForm(createForm.role_id)); setCreateErrors({}); closeCreateDialog(false); + if (page === 1) { + await loadUsers({ page: 1, includeRoles: false }); + } else { + setPage(1); + } toast.success("用户已创建"); } catch (error) { toast.error(error instanceof Error ? error.message : "创建用户失败"); @@ -431,8 +442,8 @@ function UsersContent() { const handleToggle = async (user: ManagedUser) => { setItemPending(user.id, true); try { - const data = await updateManagedUser(user.id, { enabled: !user.enabled }); - setItems(normalizeManagedUsers(data.items)); + await updateManagedUser(user.id, { enabled: !user.enabled }); + await loadUsers({ includeRoles: false }); toast.success(user.enabled ? "用户已禁用" : "用户已启用"); } catch (error) { toast.error(error instanceof Error ? error.message : "更新用户失败"); @@ -454,10 +465,10 @@ function UsersContent() { setIsSavingRole(true); setItemPending(user.id, true); try { - const data = await updateManagedUser(user.id, { + await updateManagedUser(user.id, { role_id: selectedRoleId, }); - setItems(normalizeManagedUsers(data.items)); + await loadUsers({ includeRoles: false }); setRoleUser(null); toast.success("角色已保存"); } catch (error) { @@ -475,9 +486,14 @@ function UsersContent() { const user = deletingUser; setItemPending(user.id, true); try { - const data = await deleteManagedUser(user.id); - setItems(normalizeManagedUsers(data.items)); + await deleteManagedUser(user.id); setDeletingUser(null); + const nextPage = items.length === 1 && page > 1 ? page - 1 : page; + if (nextPage === page) { + await loadUsers({ page: nextPage, includeRoles: false }); + } else { + setPage(nextPage); + } toast.success("用户已删除"); } catch (error) { toast.error(error instanceof Error ? error.message : "删除用户失败"); @@ -509,19 +525,28 @@ function UsersContent() {
- 共 {filteredItems.length} / {items.length} 个用户 + 共 {total} 个用户
setSearchText(event.target.value)} + onChange={(event) => { + setSearchText(event.target.value); + setPage(1); + }} placeholder="搜索用户名、用户 ID、owner 或会话" className="h-10 rounded-lg pl-9" />
- { + setProviderFilter(value); + setPage(1); + }} + > @@ -531,7 +556,13 @@ function UsersContent() { 本地 - { + setStatusFilter(value); + setPage(1); + }} + > @@ -550,6 +581,7 @@ function UsersContent() { setSearchText(""); setProviderFilter("all"); setStatusFilter("all"); + setPage(1); }} > @@ -572,7 +604,7 @@ function UsersContent() { - {filteredItems.map((user) => { + {items.map((user) => { const isPending = pendingIds.has(user.id); return ( @@ -689,7 +721,70 @@ function UsersContent() {
) : null} - {!isLoading && filteredItems.length === 0 ?
{items.length === 0 ? "暂无用户" : "没有匹配的用户"}
: null} + {!isLoading && items.length === 0 ?
{hasActiveFilters ? "没有匹配的用户" : "暂无用户"}
: null} +
+
+
+ 显示第 {startIndex} - {endIndex} 条,共 {total} 条 +
+ + {safePage} / {totalPages} 页 + + + + {paginationItems.map((item, index) => + item === "..." ? ( + + ... + + ) : ( + + ), + )} + +
+
diff --git a/web/src/components/announcement-banner.tsx b/web/src/components/announcement-banner.tsx index 6fe195905..c242a273c 100644 --- a/web/src/components/announcement-banner.tsx +++ b/web/src/components/announcement-banner.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Bell, Megaphone } from "lucide-react"; +import { AnnouncementMarkdown } from "@/components/announcement-markdown"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { fetchVisibleAnnouncements, type Announcement, type AnnouncementTarget } from "@/lib/api"; @@ -80,9 +81,7 @@ export function AnnouncementNotifications({

{announcement.title.trim() || "公告"}

-

- {announcement.content} -

+ {announcement.content}
))} diff --git a/web/src/components/announcement-markdown.tsx b/web/src/components/announcement-markdown.tsx new file mode 100644 index 000000000..0691e4a1c --- /dev/null +++ b/web/src/components/announcement-markdown.tsx @@ -0,0 +1,414 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +type AnnouncementMarkdownProps = { + children: string; + className?: string; + compact?: boolean; +}; + +type MarkdownBlock = + | { + type: "heading"; + level: number; + text: string; + } + | { + type: "list"; + ordered: boolean; + items: string[]; + } + | { + type: "paragraph"; + lines: string[]; + }; + +const BARE_LINK_PATTERN = /^(https?:\/\/[^\s<>"']+|mailto:[^\s<>"']+|tel:[^\s<>"']+)/i; +const TRAILING_URL_PUNCTUATION_PATTERN = /[),.;:!?,。!?;:、]+$/; +const LINK_CLASS_NAME = + "font-medium text-[#1456f0] underline underline-offset-2 transition hover:text-[#17437d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1456f0]/25"; + +export function AnnouncementMarkdown({ + children, + className, + compact = false, +}: AnnouncementMarkdownProps) { + const content = children.trim(); + + if (!content) { + return null; + } + + if (compact) { + return ( +
+ {renderInline(content, "compact")} +
+ ); + } + + return ( +
+ {parseBlocks(content).map((block, index) => renderBlock(block, `block-${index}`))} +
+ ); +} + +function parseBlocks(content: string): MarkdownBlock[] { + const lines = content.replace(/\r\n?/g, "\n").split("\n"); + const blocks: MarkdownBlock[] = []; + let index = 0; + + while (index < lines.length) { + if (!lines[index].trim()) { + index += 1; + continue; + } + + const heading = parseHeading(lines[index]); + if (heading) { + blocks.push(heading); + index += 1; + continue; + } + + const listItem = parseListItem(lines[index]); + if (listItem) { + const items: string[] = []; + const ordered = listItem.ordered; + + while (index < lines.length) { + const item = parseListItem(lines[index]); + if (!item || item.ordered !== ordered) { + break; + } + items.push(item.text); + index += 1; + } + + blocks.push({ type: "list", ordered, items }); + continue; + } + + const paragraphLines: string[] = []; + while (index < lines.length && lines[index].trim()) { + if (paragraphLines.length > 0 && (parseHeading(lines[index]) || parseListItem(lines[index]))) { + break; + } + paragraphLines.push(lines[index]); + index += 1; + } + blocks.push({ type: "paragraph", lines: paragraphLines }); + } + + return blocks; +} + +function parseHeading(line: string): MarkdownBlock | null { + const match = /^\s{0,3}(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line); + if (!match) { + return null; + } + return { + type: "heading", + level: match[1].length, + text: match[2], + }; +} + +function parseListItem(line: string): { ordered: boolean; text: string } | null { + const unordered = /^\s{0,3}[-*+]\s+(.+)$/.exec(line); + if (unordered) { + return { ordered: false, text: unordered[1] }; + } + + const ordered = /^\s{0,3}\d+[.)]\s+(.+)$/.exec(line); + if (ordered) { + return { ordered: true, text: ordered[1] }; + } + + return null; +} + +function renderBlock(block: MarkdownBlock, key: string) { + if (block.type === "heading") { + const className = cn( + "font-semibold text-stone-900", + block.level === 1 ? "text-base" : "text-sm", + ); + return ( +
+ {renderInline(block.text, key)} +
+ ); + } + + if (block.type === "list") { + const ListTag = block.ordered ? "ol" : "ul"; + return ( + + {block.items.map((item, index) => ( +
  • {renderInline(item, `${key}-item-${index}`)}
  • + ))} +
    + ); + } + + return ( +

    + {renderInlineLines(block.lines, key)} +

    + ); +} + +function renderInlineLines(lines: string[], keyPrefix: string) { + return lines.flatMap((line, index) => { + const nodes = renderInline(line, `${keyPrefix}-line-${index}`); + if (index === lines.length - 1) { + return nodes; + } + return [...nodes,
    ]; + }); +} + +function renderInline(text: string, keyPrefix: string): ReactNode[] { + const nodes: ReactNode[] = []; + let cursor = 0; + let textBuffer = ""; + + const flushText = () => { + if (!textBuffer) { + return; + } + nodes.push(textBuffer); + textBuffer = ""; + }; + + const pushNode = (node: ReactNode) => { + flushText(); + nodes.push(node); + }; + + while (cursor < text.length) { + const char = text[cursor]; + + if (char === "`") { + const end = text.indexOf("`", cursor + 1); + if (end > cursor + 1) { + pushNode( + + {text.slice(cursor + 1, end)} + , + ); + cursor = end + 1; + continue; + } + } + + if (text.startsWith("**", cursor)) { + const end = text.indexOf("**", cursor + 2); + if (end > cursor + 2) { + pushNode( + + {renderInline(text.slice(cursor + 2, end), `${keyPrefix}-strong-${cursor}`)} + , + ); + cursor = end + 2; + continue; + } + } + + const markdownLink = parseMarkdownLink(text, cursor); + if (markdownLink) { + pushNode( + + {renderInline(markdownLink.label, `${keyPrefix}-link-${cursor}-label`)} + , + ); + cursor = markdownLink.end; + continue; + } + + const autoLink = parseAutoLink(text, cursor); + if (autoLink) { + pushNode( + + {autoLink.href} + , + ); + cursor = autoLink.end; + continue; + } + + const bareLink = parseBareLink(text, cursor); + if (bareLink) { + pushNode( + + {bareLink.href} + , + ); + if (bareLink.trailing) { + textBuffer += bareLink.trailing; + } + cursor = bareLink.end; + continue; + } + + textBuffer += char; + cursor += 1; + } + + flushText(); + return nodes; +} + +function parseMarkdownLink(text: string, start: number) { + if (text[start] !== "[" || text[start - 1] === "!") { + return null; + } + + const labelEnd = text.indexOf("]", start + 1); + if (labelEnd <= start + 1 || text[labelEnd + 1] !== "(") { + return null; + } + + const hrefStart = labelEnd + 2; + const hrefEnd = findClosingParen(text, hrefStart); + if (hrefEnd <= hrefStart) { + return null; + } + + const href = sanitizeHref(text.slice(hrefStart, hrefEnd)); + if (!href) { + return null; + } + + return { + label: text.slice(start + 1, labelEnd), + href, + end: hrefEnd + 1, + }; +} + +function findClosingParen(text: string, start: number) { + let depth = 0; + + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (char === "(") { + depth += 1; + continue; + } + if (char !== ")") { + continue; + } + if (depth === 0) { + return index; + } + depth -= 1; + } + + return -1; +} + +function parseAutoLink(text: string, start: number) { + if (text[start] !== "<") { + return null; + } + + const end = text.indexOf(">", start + 1); + if (end <= start + 1) { + return null; + } + + const href = sanitizeHref(text.slice(start + 1, end)); + if (!href) { + return null; + } + + return { href, end: end + 1 }; +} + +function parseBareLink(text: string, start: number) { + const match = BARE_LINK_PATTERN.exec(text.slice(start)); + if (!match) { + return null; + } + + const rawHref = match[0]; + const href = sanitizeHref(rawHref.replace(TRAILING_URL_PUNCTUATION_PATTERN, "")); + if (!href) { + return null; + } + + return { + href, + trailing: rawHref.slice(href.length), + end: start + rawHref.length, + }; +} + +function sanitizeHref(rawHref: string) { + const href = rawHref.trim().replace(/^<|>$/g, ""); + if (!href || hasControlCharacter(href)) { + return ""; + } + + if (href.startsWith("#") || (href.startsWith("/") && !href.startsWith("//"))) { + return href; + } + + try { + const url = new URL(href); + if (url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:" || url.protocol === "tel:") { + return href; + } + } catch { + return ""; + } + + return ""; +} + +function hasControlCharacter(value: string) { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 31 || code === 127) { + return true; + } + } + return false; +} + +function AnnouncementLink({ + href, + children, +}: { + href: string; + children: ReactNode; +}) { + const external = /^https?:\/\//i.test(href); + + return ( + + {children} + + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 521cdb265..0d474d195 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -488,6 +488,22 @@ export type ManagedUser = { api_permissions?: string[]; }; +export type ManagedUsersQuery = { + page?: number | string; + page_size?: number | string; + search?: string; + provider?: "all" | "local" | "linuxdo" | string; + status?: "all" | "enabled" | "disabled" | string; +}; + +export type ManagedUsersResponse = { + items: ManagedUser[]; + total: number; + page: number; + page_size: number; + total_pages: number; +}; + export type ManagedRole = { id: string; name: string; @@ -1081,8 +1097,23 @@ function managedUserPath(userId: string) { return `/api/admin/users/${encodeURIComponent(userId)}`; } -export async function fetchManagedUsers() { - return httpRequest<{ items: ManagedUser[] }>("/api/admin/users"); +export async function fetchManagedUsers(query: ManagedUsersQuery = {}) { + const params = new URLSearchParams(); + if (query.page) params.set("page", String(query.page)); + if (query.page_size) params.set("page_size", String(query.page_size)); + if (query.search?.trim()) params.set("search", query.search.trim()); + if (query.provider && query.provider !== "all") params.set("provider", query.provider); + if (query.status && query.status !== "all") params.set("status", query.status); + const data = await httpRequest>( + `/api/admin/users${params.toString() ? `?${params.toString()}` : ""}`, + ); + return { + items: Array.isArray(data.items) ? data.items : [], + total: Number(data.total ?? data.items?.length ?? 0), + page: Number(data.page ?? query.page ?? 1), + page_size: Number(data.page_size ?? query.page_size ?? 20), + total_pages: Number(data.total_pages ?? 1), + }; } export async function fetchPermissionCatalog() { @@ -1126,7 +1157,7 @@ export async function deleteManagedRole(roleId: string) { } export async function createManagedUser(payload: CreateManagedUserPayload) { - return httpRequest<{ item: ManagedUser; items: ManagedUser[] }>("/api/admin/users", { + return httpRequest<{ item: ManagedUser; items?: ManagedUser[] } & Partial>("/api/admin/users", { method: "POST", body: payload, }); @@ -1136,7 +1167,7 @@ export async function updateManagedUser( userId: string, updates: { enabled?: boolean; name?: string; role_id?: string }, ) { - return httpRequest<{ item: ManagedUser; items: ManagedUser[] }>(managedUserPath(userId), { + return httpRequest<{ item: ManagedUser; items?: ManagedUser[] } & Partial>(managedUserPath(userId), { method: "POST", body: updates, }); @@ -1147,7 +1178,7 @@ export async function revealManagedUserKey(userId: string) { } export async function resetManagedUserKey(userId: string, name?: string) { - return httpRequest<{ item: ManagedUser; api_key: UserKey; key: string; items: ManagedUser[] }>( + return httpRequest<{ item: ManagedUser; api_key: UserKey; key: string; items?: ManagedUser[] } & Partial>( `${managedUserPath(userId)}/reset-key`, { method: "POST", @@ -1157,7 +1188,7 @@ export async function resetManagedUserKey(userId: string, name?: string) { } export async function deleteManagedUser(userId: string) { - return httpRequest<{ items: ManagedUser[] }>(managedUserPath(userId), { + return httpRequest<{ items?: ManagedUser[] } & Partial>(managedUserPath(userId), { method: "DELETE", }); } From 33ccf3a44c86715f5e35c38b662068766136692f Mon Sep 17 00:00:00 2001 From: ZyphrZero <133507172+ZyphrZero@users.noreply.github.com> Date: Fri, 8 May 2026 15:32:16 +0800 Subject: [PATCH 06/76] refactor(build): restructure Docker deployment, relocate main.go, and migrate to oxlint - Move Dockerfiles and compose file into deploy/ directory - Relocate main.go from cmd/chatgpt2api to internal/ - Remove goreleaser simple release path and .goreleaser.simple.yaml - Add resource-limited Docker build script (deploy/docker-build-limited.sh) - Add Docker deployment file validation to CI workflow - Refactor image response handling in backend - Migrate frontend linting from ESLint to Oxlint - Add backend tests for update service and image responses --- .env.example | 13 + .github/workflows/ci.yml | 18 ++ .github/workflows/release.yml | 8 +- .gitignore | 3 + .goreleaser.simple.yaml | 61 ---- .goreleaser.yaml | 12 +- AGENTS.md | 12 +- README.md | 66 ++-- Dockerfile => deploy/Dockerfile | 37 ++- .../Dockerfile.dockerignore | 7 + .../Dockerfile.release | 3 + deploy/docker-build-limited.sh | 160 ++++++++++ deploy/docker-compose.yml | 13 + docker-compose.build.yml | 9 - docker-compose.local.yml | 35 --- docker-compose.yml | 29 -- internal/backend/backend_test.go | 113 +++++-- internal/backend/responses_image.go | 175 ++++------- {cmd/chatgpt2api => internal}/main.go | 0 internal/protocol/conversation.go | 6 + internal/protocol/conversation_test.go | 2 + internal/service/update_test.go | 86 ++++++ web/.oxlintrc.json | 25 ++ web/bun.lock | 290 +++--------------- web/eslint.config.mjs | 40 --- web/index.html | 3 +- web/package.json | 10 +- web/public/favicon.ico | Bin 25931 -> 60148 bytes web/src/app/settings/store.ts | 2 +- web/src/lib/request.ts | 4 +- 30 files changed, 611 insertions(+), 631 deletions(-) delete mode 100644 .goreleaser.simple.yaml rename Dockerfile => deploy/Dockerfile (53%) rename .dockerignore => deploy/Dockerfile.dockerignore (81%) rename Dockerfile.goreleaser => deploy/Dockerfile.release (78%) create mode 100644 deploy/docker-build-limited.sh create mode 100644 deploy/docker-compose.yml delete mode 100644 docker-compose.build.yml delete mode 100644 docker-compose.local.yml delete mode 100644 docker-compose.yml rename {cmd/chatgpt2api => internal}/main.go (100%) create mode 100644 web/.oxlintrc.json delete mode 100644 web/eslint.config.mjs diff --git a/.env.example b/.env.example index 9528dc996..1b4cd6ae1 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,19 @@ CHATGPT2API_REGISTRATION_ENABLED=false # 检查更新访问 DockerHub / Release API 的代理;未设置时复用 CHATGPT2API_PROXY # CHATGPT2API_UPDATE_PROXY_URL=http://127.0.0.1:7890 +# ============================================ +# 服务器源码 Docker 构建资源控制(仅 sh deploy/docker-build-limited.sh 使用) +# ============================================ + +# 需要在服务器从源码构建镜像时,推荐用脚本创建受限 BuildKit builder 后再构建。 +# BUILD_CPUS=2 +# BUILD_MEMORY=2g +# BUILDKIT_MAX_PARALLELISM=2 +# BUILD_GOMAXPROCS=2 +# BUILD_GOMEMLIMIT=1GiB +# BUILD_NODE_OPTIONS=--max-old-space-size=1024 +# CHATGPT2API_LOCAL_IMAGE=chatgpt2api:local + # 限流账号检查间隔,单位:分钟 CHATGPT2API_REFRESH_ACCOUNT_INTERVAL_MINUTE=5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b84aa3e32..7568af883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,21 @@ jobs: - name: Test backend run: go test ./... + + - name: Validate Docker deployment files + run: | + test -f deploy/Dockerfile + test -f deploy/Dockerfile.release + test -f deploy/Dockerfile.dockerignore + test -f deploy/docker-compose.yml + sh -n deploy/docker-build-limited.sh + test ! -e .dockerignore + test ! -e Dockerfile + test ! -e Dockerfile.goreleaser + test ! -e docker-compose.yml + test ! -e docker-compose.build.yml + test ! -e docker-compose.local.yml + test ! -e .goreleaser.simple.yaml + CHATGPT2API_ENV_FILE="$PWD/.env.example" \ + CHATGPT2API_DATA_DIR="$PWD/data" \ + docker compose -f deploy/docker-compose.yml config >/dev/null diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d802f2c4d..95b80d4a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,18 +10,12 @@ on: description: "Tag to release, for example v1.0.0" required: true type: string - simple_release: - description: "Only publish linux/amd64 GHCR image" - required: false - type: boolean - default: false permissions: contents: write packages: write env: - SIMPLE_RELEASE: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.simple_release || 'false' }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} jobs: @@ -129,7 +123,7 @@ jobs: uses: goreleaser/goreleaser-action@v7 with: version: "~> v2" - args: release --clean --skip=validate ${{ env.SIMPLE_RELEASE == 'true' && '--config=.goreleaser.simple.yaml' || '' }} + args: release --clean --skip=validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_MESSAGE: ${{ steps.meta.outputs.body }} diff --git a/.gitignore b/.gitignore index 279322c7f..6276b3f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,12 @@ dist/ web_dist chatgpt2api.exe .omx/ +.codex/ venv/ .venv/ __pycache__/ +docs/ +!jshook/docs/ .idea node_modules/ diff --git a/.goreleaser.simple.yaml b/.goreleaser.simple.yaml deleted file mode 100644 index 89f6c6b49..000000000 --- a/.goreleaser.simple.yaml +++ /dev/null @@ -1,61 +0,0 @@ -version: 2 - -project_name: chatgpt2api - -builds: - - id: chatgpt2api - main: ./cmd/chatgpt2api - binary: chatgpt2api - flags: - - -tags=embed - env: - - CGO_ENABLED=0 - goos: - - linux - goarch: - - amd64 - ldflags: - - -s -w - - -X chatgpt2api/internal/version.Version={{ .Version }} - - -X chatgpt2api/internal/version.Commit={{ .Commit }} - - -X chatgpt2api/internal/version.Date={{ .Date }} - - -X chatgpt2api/internal/version.BuildType=release - -archives: [] - -checksum: - disable: true - -changelog: - disable: true - -dockers: - - id: ghcr-amd64 - goos: linux - goarch: amd64 - image_templates: - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-amd64" - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}" - - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:latest" - dockerfile: Dockerfile.goreleaser - use: buildx - build_flag_templates: - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.version={{ .Version }}" - - "--label=org.opencontainers.image.revision={{ .Commit }}" - - "--label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_REPO_OWNER }}/{{ .Env.GITHUB_REPO_NAME }}" - -docker_manifests: [] - -release: - draft: false - prerelease: auto - name_template: "chatgpt2api {{ .Version }} (Simple)" - skip_upload: true - header: | - {{ .Env.TAG_MESSAGE }} - footer: | - - --- - - Simple release only publishes the linux/amd64 GHCR Docker image. diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e3de5fc1c..fe4b0adc9 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,7 +4,7 @@ project_name: chatgpt2api builds: - id: chatgpt2api - main: ./cmd/chatgpt2api + main: ./internal binary: chatgpt2api flags: - -tags=embed @@ -30,7 +30,7 @@ archives: files: - README.md - .env.example - - docker-compose.yml + - deploy/docker-compose.yml checksum: name_template: checksums.txt @@ -46,7 +46,7 @@ dockers: skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}' image_templates: - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-amd64" - dockerfile: Dockerfile.goreleaser + dockerfile: deploy/Dockerfile.release use: buildx build_flag_templates: - "--platform=linux/amd64" @@ -59,7 +59,7 @@ dockers: skip_push: '{{ if eq .Env.DOCKERHUB_USERNAME "skip" }}true{{ else }}false{{ end }}' image_templates: - "{{ .Env.DOCKERHUB_USERNAME }}/chatgpt2api:{{ .Version }}-arm64" - dockerfile: Dockerfile.goreleaser + dockerfile: deploy/Dockerfile.release use: buildx build_flag_templates: - "--platform=linux/arm64" @@ -71,7 +71,7 @@ dockers: goarch: amd64 image_templates: - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-amd64" - dockerfile: Dockerfile.goreleaser + dockerfile: deploy/Dockerfile.release use: buildx build_flag_templates: - "--platform=linux/amd64" @@ -84,7 +84,7 @@ dockers: goarch: arm64 image_templates: - "ghcr.io/{{ .Env.GITHUB_REPO_OWNER_LOWER }}/chatgpt2api:{{ .Version }}-arm64" - dockerfile: Dockerfile.goreleaser + dockerfile: deploy/Dockerfile.release use: buildx build_flag_templates: - "--platform=linux/arm64" diff --git a/AGENTS.md b/AGENTS.md index a2c71b4c1..c9ba756d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,23 +7,23 @@ Write for the current API version only. Do not add fallbacks, shims, feature fla ## Project Structure & Module Organization -This repository is a Go backend with a Vite/React admin UI. The backend entry point is `cmd/chatgpt2api/main.go`; implementation packages live under `internal/` (`httpapi`, `service`, `protocol`, `backend`, `storage`, `config`, and helpers). Frontend source is in `web/src/`, with pages under `web/src/app/`, shared UI in `web/src/components/`, API helpers in `web/src/lib/`, and stores in `web/src/store/`. Screenshots are in `assets/`. ChatGPT web reverse-engineering notes live in `jshook/docs/`, with validation scripts and sanitized response samples under `jshook/`. +This repository is a Go backend with a Vite/React admin UI. The backend entry point is `internal/main.go`; implementation packages live under `internal/` (`httpapi`, `service`, `protocol`, `backend`, `storage`, `config`, and helpers). Frontend source is in `web/src/`, with pages under `web/src/app/`, shared UI in `web/src/components/`, API helpers in `web/src/lib/`, and stores in `web/src/store/`. Screenshots are in `assets/`. ChatGPT web reverse-engineering notes live in `jshook/docs/`, with validation scripts and sanitized response samples under `jshook/`. ## Build, Test, and Development Commands - `cd web && npm run build` generates the embedded admin UI assets under `internal/web/dist`. - `go test ./...` runs all backend tests after the frontend assets exist. -- `go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o chatgpt2api ./cmd/chatgpt2api` builds the service binary with embedded admin UI assets. +- `go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o chatgpt2api ./internal` builds the service binary with embedded admin UI assets. - `CHATGPT2API_ADMIN_PASSWORD=change_me_please ./chatgpt2api` runs the backend locally after build. -- `docker compose up -d` starts the default containerized deployment using `.env`. -- `docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build` rebuilds the image from local source. +- `docker compose -f deploy/docker-compose.yml up -d` starts the default containerized deployment using `.env`. +- `sh deploy/docker-build-limited.sh up` rebuilds the image from local source on a server with a resource-capped BuildKit builder. - `cd web && npm run dev` starts the frontend dev server. - `cd web && npm run build` type-checks and builds the frontend. -- `cd web && npm run lint` runs ESLint. +- `cd web && npm run lint` runs Oxlint. ## Coding Style & Naming Conventions -Use `gofmt` for Go code and keep package names short, lowercase, and domain-oriented. Place tests beside the code they exercise as `*_test.go`. Frontend code uses TypeScript, React 19, Vite, ESLint, Tailwind CSS, and shadcn-style components. Prefer kebab-case filenames for React components (`image-composer.tsx`) and PascalCase exports. Reuse helpers from `web/src/lib/` and primitives from `web/src/components/ui/` before adding abstractions. +Use `gofmt` for Go code and keep package names short, lowercase, and domain-oriented. Place tests beside the code they exercise as `*_test.go`. Frontend code uses TypeScript, React 19, Vite, Oxlint, Tailwind CSS, and shadcn-style components. Prefer kebab-case filenames for React components (`image-composer.tsx`) and PascalCase exports. Reuse helpers from `web/src/lib/` and primitives from `web/src/components/ui/` before adding abstractions. Admin async creation-task routes use `/api/creation-tasks` as the resource root. Submit task-type-specific work through explicit child resources: `image-generations`, `image-edits`, and `chat-completions`. Do not introduce image-named task aliases or chat routes under image-named resources. diff --git a/README.md b/README.md index 454dacd4b..d1505ec8c 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ CHATGPT2API_ADMIN_PASSWORD=change_me_please ### 2. 启动服务 ```bash -docker compose up -d +docker compose -f deploy/docker-compose.yml up -d ``` 默认 Compose 配置: @@ -119,16 +119,16 @@ docker compose up -d http://localhost:3000 ``` -查看日志(需要在 `docker-compose.yml` 所在目录执行): +查看日志(需要在仓库根目录执行): ```bash -docker compose logs -f app +docker compose -f deploy/docker-compose.yml logs -f app ``` -查看自动生成的管理员密码(需要在 `docker-compose.yml` 所在目录执行): +查看自动生成的管理员密码(需要在仓库根目录执行): ```bash -docker compose logs app | grep "bootstrap admin password generated" +docker compose -f deploy/docker-compose.yml logs app | grep "bootstrap admin password generated" ``` 日志行格式: @@ -143,7 +143,7 @@ bootstrap admin password generated: username=admin password=生成的密码 Windows PowerShell: ```powershell -docker compose logs app | Select-String "bootstrap admin password generated" +docker compose -f deploy/docker-compose.yml logs app | Select-String "bootstrap admin password generated" ``` 默认容器名方式: @@ -152,7 +152,7 @@ docker compose logs app | Select-String "bootstrap admin password generated" docker logs chatgpt2api 2>&1 | grep "bootstrap admin password generated" ``` -如果提示 `no configuration file provided: not found`,说明当前目录没有 Compose 配置文件。先进入部署目录再执行 `docker compose logs app`,或直接使用上面的 `docker logs chatgpt2api ...` 命令。 +如果提示 `no configuration file provided: not found`,说明当前命令没有指定 Compose 配置文件。先进入仓库根目录再执行 `docker compose -f deploy/docker-compose.yml logs app`,或直接使用上面的 `docker logs chatgpt2api ...` 命令。 如果查不到日志,先确认 `.env` 或容器环境里是否已经设置了固定密码: @@ -175,7 +175,7 @@ cd /opt/chatgpt2api # 编辑 .env,设置一个新的已知管理员密码: # CHATGPT2API_ADMIN_PASSWORD=your_new_password -docker compose down +docker compose -f deploy/docker-compose.yml down cp -a data "data.bak.$(date +%Y%m%d-%H%M%S)" python3 - <<'PY' import sqlite3 @@ -191,28 +191,48 @@ con.commit() print(f"removed auth_users.json rows: {cur.rowcount}") con.close() PY -docker compose up -d +docker compose -f deploy/docker-compose.yml up -d ``` 如果使用 `STORAGE_BACKEND=json`,本地登录账号保存在 `data/auth_users.json`,可在备份后删除该文件再重启: ```bash -docker compose down +docker compose -f deploy/docker-compose.yml down cp -a data "data.bak.$(date +%Y%m%d-%H%M%S)" rm -f data/auth_users.json -docker compose up -d +docker compose -f deploy/docker-compose.yml up -d ``` -### 3. 自建镜像 +### 3. 服务器源码构建(可选) -如果需要从当前源码构建本地镜像: +发布镜像由 GitHub Actions 构建。如果你需要在自己的服务器上从当前源码构建镜像,使用受限 BuildKit 脚本: ```bash -docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build +sh deploy/docker-build-limited.sh up ``` +该脚本会创建独立的 `docker-container` Buildx builder,并对构建容器设置 CPU / 内存上限。默认给构建过程 2 核、2 GB 内存、BuildKit 并行度 2,Go 编译并行度 2: + +```bash +BUILD_CPUS=2 BUILD_MEMORY=2g sh deploy/docker-build-limited.sh up +``` + +低配服务器可以进一步收紧配额,构建会变慢,但不会让构建进程吃满整台机器: + +```bash +BUILD_CPUS=1 BUILD_MEMORY=1536m BUILD_GOMEMLIMIT=768MiB sh deploy/docker-build-limited.sh up +``` + +如果只想构建本地镜像、不重启容器: + +```bash +sh deploy/docker-build-limited.sh build +``` + +脚本使用 `deploy/Dockerfile` 从源码构建本地镜像,默认镜像名为 `chatgpt2api:local`;`up` 模式会继续用 `deploy/docker-compose.yml` 启动该本地镜像。 + ## 升级与在线更新 ### Docker 镜像升级 @@ -220,11 +240,11 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build Docker 部署的推荐升级方式: ```bash -docker compose pull -docker compose up -d +docker compose -f deploy/docker-compose.yml pull +docker compose -f deploy/docker-compose.yml up -d ``` -默认 Compose 使用 DockerHub 公共镜像,普通用户不需要配置 GitHub Release 源、GitHub Token,也不需要登录 GitHub。也可以按需将 `docker-compose.yml` 的 `image` 改为 GHCR: +默认 Compose 使用 DockerHub 公共镜像,普通用户不需要配置 GitHub Release 源、GitHub Token,也不需要登录 GitHub。也可以按需将 `deploy/docker-compose.yml` 的 `image` 改为 GHCR: ```yaml image: ghcr.io/zyphrzero/chatgpt2api:latest @@ -241,7 +261,7 @@ ghcr.io/zyphrzero/chatgpt2api:latest 设置页的“版本更新”卡片会按部署方式选择更新来源: -- Docker 镜像:默认匿名检查 DockerHub 公共镜像标签,升级方式是 `docker compose pull && docker compose up -d`。 +- Docker 镜像:默认匿名检查 DockerHub 公共镜像标签,升级方式是 `docker compose -f deploy/docker-compose.yml pull && docker compose -f deploy/docker-compose.yml up -d`。 - Release 二进制:检查项目 GitHub Release,只有这种非 Docker 部署会显示“立即更新”并替换当前 `chatgpt2api` 二进制。 Release 二进制在线更新流程: @@ -257,12 +277,11 @@ Release 二进制在线更新流程: 重要说明: - Docker 部署默认从 DockerHub 拉取镜像,不需要填写 GitHub Release 源或 GitHub Token。 -- Docker 容器内不会执行二进制替换;请用 `docker compose pull && docker compose up -d` 更新镜像。 +- Docker 容器内不会执行二进制替换;请用 `docker compose -f deploy/docker-compose.yml pull && docker compose -f deploy/docker-compose.yml up -d` 更新镜像。 - 在线二进制替换只在非 Docker 的 `BuildType=release` 构建中开放。 - 前端资源已嵌入 Release 二进制,在线更新只替换 `chatgpt2api` 这一个运行文件。 - 检查更新访问 DockerHub / Release API 可通过 `CHATGPT2API_UPDATE_PROXY_URL` 配置代理;未设置时复用 `CHATGPT2API_PROXY`。 - 正式 Release archive 只发布 Linux `amd64` / `arm64` 构建;Windows 和 macOS 不提供在线更新压缩包。 -- 简化发布只推送 Docker 镜像,不上传二进制压缩包时,在线更新无法找到可下载的 Release archive。 ### 源码部署升级 @@ -273,7 +292,7 @@ git pull bun install --cwd web --frozen-lockfile bun --cwd web run build go test ./... -go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o chatgpt2api ./cmd/chatgpt2api +go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=1.0.0" -o chatgpt2api ./internal ``` ## 配置说明 @@ -349,7 +368,7 @@ CHATGPT2API_LINUXDO_FRONTEND_REDIRECT_URL=/auth/linuxdo/callback bun install --cwd web --frozen-lockfile bun --cwd web run build go test ./... -go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=0.0.0-dev" -o chatgpt2api ./cmd/chatgpt2api +go build -tags=embed -ldflags "-X chatgpt2api/internal/version.Version=0.0.0-dev" -o chatgpt2api ./internal CHATGPT2API_ADMIN_PASSWORD=change_me_please ./chatgpt2api ``` @@ -398,6 +417,7 @@ bun run build - `go test ./...` - `bun install --frozen-lockfile` - `bun run build` +- `docker compose -f deploy/docker-compose.yml config` ### Release @@ -408,7 +428,7 @@ bun run build 3. 将前端 artifact 下载到 `internal/web/dist`。 4. GoReleaser 使用 `-tags=embed` 构建 Linux `amd64` / `arm64` 二进制。 5. 生成 GitHub Release archive 和 `checksums.txt`。 -6. 使用 `Dockerfile.goreleaser` 构建多架构 Docker 镜像。 +6. 使用 `deploy/Dockerfile.release` 构建多架构 Docker 镜像。 7. 推送 DockerHub 镜像。 8. 推送 GHCR 镜像。 diff --git a/Dockerfile b/deploy/Dockerfile similarity index 53% rename from Dockerfile rename to deploy/Dockerfile index 10a98cd96..817cf93d2 100644 --- a/Dockerfile +++ b/deploy/Dockerfile @@ -1,10 +1,14 @@ # syntax=docker/dockerfile:1.7 ARG VERSION=0.0.0-dev +ARG BUN_IMAGE=oven/bun:1-alpine +ARG GO_IMAGE=golang:1.26.2-bookworm +ARG RUNTIME_IMAGE=debian:bookworm-slim -FROM --platform=$BUILDPLATFORM oven/bun:1-alpine AS web-deps +FROM --platform=$BUILDPLATFORM ${BUN_IMAGE} AS web-deps WORKDIR /app/web +ENV CI=1 COPY web/package.json web/bun.lock ./ RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \ @@ -15,52 +19,55 @@ FROM web-deps AS web-build COPY web ./ ARG VERSION=0.0.0-dev -ENV VITE_APP_VERSION=${VERSION} +ARG BUILD_NODE_OPTIONS=--max-old-space-size=1024 +ENV VITE_APP_VERSION=${VERSION} \ + NODE_OPTIONS=${BUILD_NODE_OPTIONS} RUN bun run build -FROM --platform=$BUILDPLATFORM golang:1.26.2-bookworm AS go-build +FROM --platform=$BUILDPLATFORM ${GO_IMAGE} AS go-build WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked go mod download -COPY cmd ./cmd COPY internal ./internal COPY --from=web-build /app/internal/web/dist ./internal/web/dist ARG TARGETOS ARG TARGETARCH ARG VERSION=0.0.0-dev -# GOMEMLIMIT 是 Go 1.19+ 的软内存上限,仅在内存紧张时触发更激进的 GC, -# 不影响构建速度,仅作为防止 OOM 的安全阀 +ARG BUILD_GOMAXPROCS=2 +ARG BUILD_GOMEMLIMIT=1GiB RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - GOMEMLIMIT=1GiB \ + --mount=type=cache,target=/root/.cache/go-build,sharing=locked \ + GOMAXPROCS=${BUILD_GOMAXPROCS} GOMEMLIMIT=${BUILD_GOMEMLIMIT} \ CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ - go build -trimpath -tags=embed \ + go build -p=${BUILD_GOMAXPROCS} -trimpath -tags=embed \ -ldflags="-s -w -X chatgpt2api/internal/version.Version=${VERSION}" \ - -o /out/chatgpt2api ./cmd/chatgpt2api + -o /out/chatgpt2api ./internal -FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS app +FROM --platform=$TARGETPLATFORM ${RUNTIME_IMAGE} AS app WORKDIR /app ENV PORT=80 ENV CHATGPT2API_DEPLOYMENT=docker -# 运行时依赖: -# - ca-certificates: HTTPS 上游请求需要 -# - git: Git 存储后端需要 -# - tzdata: 保持容器内时区数据可用 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ + curl \ git \ tzdata \ && rm -rf /var/lib/apt/lists/* COPY --from=go-build /out/chatgpt2api ./chatgpt2api +RUN mkdir -p /app/data && chmod +x /app/chatgpt2api + EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:${PORT:-80}/health || exit 1 + CMD ["/app/chatgpt2api"] diff --git a/.dockerignore b/deploy/Dockerfile.dockerignore similarity index 81% rename from .dockerignore rename to deploy/Dockerfile.dockerignore index 758c432d3..1f6adb6a9 100644 --- a/.dockerignore +++ b/deploy/Dockerfile.dockerignore @@ -6,6 +6,7 @@ .DS_Store .claude .omx +.codex .ace-tool .github @@ -38,6 +39,8 @@ web/yarn-debug.log* web/yarn-error.log* web/pnpm-debug.log* +internal/web/dist + assets docs README.md @@ -45,8 +48,12 @@ AGENTS.md DESIGN.md LICENSE docker-compose*.yml +deploy/docker-compose.yml +deploy/Dockerfile.release +deploy/docker-build-limited.sh scripts +venv .venv __pycache__ *.pyc diff --git a/Dockerfile.goreleaser b/deploy/Dockerfile.release similarity index 78% rename from Dockerfile.goreleaser rename to deploy/Dockerfile.release index b6c53b68e..bc40c8094 100644 --- a/Dockerfile.goreleaser +++ b/deploy/Dockerfile.release @@ -1,4 +1,7 @@ # syntax=docker/dockerfile:1.7 +# +# Release-only runtime image. GoReleaser builds the chatgpt2api binary first, +# then uses this Dockerfile to package that binary for DockerHub/GHCR. FROM debian:bookworm-slim diff --git a/deploy/docker-build-limited.sh b/deploy/docker-build-limited.sh new file mode 100644 index 000000000..7467d84c1 --- /dev/null +++ b/deploy/docker-build-limited.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env sh +set -eu + +usage() { + cat <<'EOF' +Usage: + sh deploy/docker-build-limited.sh [up|build] + +Creates a resource-capped BuildKit builder, then builds the local Docker image. + +Tunable environment variables: + CHATGPT2API_BUILDER_NAME Builder name (default: chatgpt2api-local-build) + BUILD_CPUS Whole CPU cores available to BuildKit (default: 2) + BUILD_MEMORY BuildKit memory limit (default: 2g) + BUILD_MEMORY_SWAP BuildKit memory+swap limit (default: BUILD_MEMORY) + BUILDKIT_MAX_PARALLELISM BuildKit solver parallelism (default: BUILD_CPUS) + BUILD_GOMAXPROCS Go compiler parallelism (default: BUILD_CPUS) + BUILD_GOMEMLIMIT Go soft memory limit (default: 1GiB) + BUILD_NODE_OPTIONS Node options for the web build + BUILD_CPUSET_CPUS Optional cpuset, for example 0-1 + +Examples: + BUILD_CPUS=2 BUILD_MEMORY=2g sh deploy/docker-build-limited.sh up + BUILD_CPUS=1 BUILD_MEMORY=1536m BUILD_GOMEMLIMIT=768MiB sh deploy/docker-build-limited.sh build +EOF +} + +require_uint() { + name="$1" + value="$2" + case "$value" in + ''|*[!0-9]*) + echo "$name must be a positive integer, got: $value" >&2 + exit 2 + ;; + 0) + echo "$name must be greater than zero" >&2 + exit 2 + ;; + esac +} + +command="${1:-up}" +case "$command" in + up|build) + ;; + -h|--help|help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; +esac + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd) + +builder_name="${CHATGPT2API_BUILDER_NAME:-chatgpt2api-local-build}" +build_cpus="${BUILD_CPUS:-2}" +build_cpu_period="${BUILD_CPU_PERIOD:-100000}" +build_memory="${BUILD_MEMORY:-2g}" +build_memory_swap="${BUILD_MEMORY_SWAP:-$build_memory}" +buildkit_max_parallelism="${BUILDKIT_MAX_PARALLELISM:-$build_cpus}" + +require_uint BUILD_CPUS "$build_cpus" +require_uint BUILD_CPU_PERIOD "$build_cpu_period" +require_uint BUILDKIT_MAX_PARALLELISM "$buildkit_max_parallelism" + +build_cpu_quota="${BUILD_CPU_QUOTA:-$((build_cpus * build_cpu_period))}" +require_uint BUILD_CPU_QUOTA "$build_cpu_quota" + +export DOCKER_BUILDKIT=1 +export BUILDX_BUILDER="$builder_name" +export BUILD_GOMAXPROCS="${BUILD_GOMAXPROCS:-$build_cpus}" +export BUILD_GOMEMLIMIT="${BUILD_GOMEMLIMIT:-1GiB}" +export BUILD_NODE_OPTIONS="${BUILD_NODE_OPTIONS:---max-old-space-size=1024}" +export CHATGPT2API_LOCAL_IMAGE="${CHATGPT2API_LOCAL_IMAGE:-chatgpt2api:local}" +export CHATGPT2API_VERSION="${CHATGPT2API_VERSION:-0.0.0-dev}" + +require_uint BUILD_GOMAXPROCS "$BUILD_GOMAXPROCS" + +cache_root="${XDG_CACHE_HOME:-${HOME:-.}/.cache}/chatgpt2api-buildkit" +mkdir -p "$cache_root" +buildkit_config="$cache_root/buildkitd.toml" +fingerprint_file="$cache_root/$builder_name.options" + +cat > "$buildkit_config" </dev/null + else + docker buildx create \ + --name "$builder_name" \ + --driver docker-container \ + --driver-opt "image=moby/buildkit:buildx-stable-1" \ + --driver-opt "cpu-period=$build_cpu_period" \ + --driver-opt "cpu-quota=$build_cpu_quota" \ + --driver-opt "memory=$build_memory" \ + --driver-opt "memory-swap=$build_memory_swap" \ + --buildkitd-config "$buildkit_config" \ + --use \ + --bootstrap >/dev/null + fi + printf '%s' "$fingerprint" > "$fingerprint_file" +} + +if docker buildx inspect "$builder_name" >/dev/null 2>&1; then + if [ ! -f "$fingerprint_file" ] || [ "$(cat "$fingerprint_file")" != "$fingerprint" ]; then + docker buildx rm --keep-state "$builder_name" >/dev/null 2>&1 || docker buildx rm "$builder_name" >/dev/null + create_builder + else + docker buildx use "$builder_name" >/dev/null + docker buildx inspect --bootstrap "$builder_name" >/dev/null + fi +else + create_builder +fi + +docker buildx build \ + --builder "$builder_name" \ + --load \ + --tag "$CHATGPT2API_LOCAL_IMAGE" \ + --file "$repo_root/deploy/Dockerfile" \ + --build-arg "VERSION=$CHATGPT2API_VERSION" \ + --build-arg "BUILD_GOMAXPROCS=$BUILD_GOMAXPROCS" \ + --build-arg "BUILD_GOMEMLIMIT=$BUILD_GOMEMLIMIT" \ + --build-arg "BUILD_NODE_OPTIONS=$BUILD_NODE_OPTIONS" \ + "$repo_root" + +if [ "$command" = "up" ]; then + CHATGPT2API_DATA_DIR="$repo_root/data" \ + CHATGPT2API_ENV_FILE="$repo_root/.env" \ + CHATGPT2API_IMAGE="$CHATGPT2API_LOCAL_IMAGE" \ + CHATGPT2API_PULL_POLICY=never \ + docker compose --env-file "$repo_root/.env" -f "$repo_root/deploy/docker-compose.yml" up -d --no-build +fi diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 000000000..5abb2bbbd --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,13 @@ +services: + app: + image: ${CHATGPT2API_IMAGE:-zyphrzero/chatgpt2api:latest} + pull_policy: ${CHATGPT2API_PULL_POLICY:-always} + container_name: chatgpt2api + restart: unless-stopped + ports: + - "3000:80" + env_file: + - ${CHATGPT2API_ENV_FILE:-../.env} + volumes: + - ${CHATGPT2API_DATA_DIR:-../data}:/app/data + - ${CHATGPT2API_ENV_FILE:-../.env}:/app/.env diff --git a/docker-compose.build.yml b/docker-compose.build.yml deleted file mode 100644 index cb33e3741..000000000 --- a/docker-compose.build.yml +++ /dev/null @@ -1,9 +0,0 @@ -services: - app: - build: - context: . - dockerfile: Dockerfile - args: - VERSION: ${CHATGPT2API_VERSION:-0.0.0-dev} - image: ${CHATGPT2API_LOCAL_IMAGE:-chatgpt2api:local} - pull_policy: build diff --git a/docker-compose.local.yml b/docker-compose.local.yml deleted file mode 100644 index 6bdb8cb47..000000000 --- a/docker-compose.local.yml +++ /dev/null @@ -1,35 +0,0 @@ -services: - app: - build: - context: . - dockerfile: Dockerfile - args: - VERSION: ${CHATGPT2API_VERSION:-0.0.0-dev} - image: chatgpt2api:local - container_name: chatgpt2api-local - ports: - - "8000:80" - env_file: - - .env - volumes: - - ./data:/app/data - - ./.env:/app/.env - environment: - STORAGE_BACKEND: sqlite - DATABASE_URL: sqlite:////app/data/chatgpt2api.db - # 存储后端配置 (可选值: json, sqlite, postgres, git) - # environment: - # STORAGE_BACKEND: json - - # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用) - # DATABASE_URL: postgresql://user:password@host:5432/dbname - # DATABASE_URL: sqlite:////app/data/chatgpt2api.db - - # 初始管理员密码 (可选,覆盖 .env) - # CHATGPT2API_ADMIN_PASSWORD: change_me_please - - # 基础 URL (可选) - # CHATGPT2API_BASE_URL: https://your-domain.com - - # 在线更新代理 (可选,未设置时复用 CHATGPT2API_PROXY) - # CHATGPT2API_UPDATE_PROXY_URL: http://127.0.0.1:7890 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index efb30db9b..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - app: - image: zyphrzero/chatgpt2api:latest - pull_policy: always - container_name: chatgpt2api - restart: unless-stopped - ports: - - "3000:80" - env_file: - - .env - volumes: - - ./data:/app/data - - ./.env:/app/.env - environment: - # 存储后端配置 (可选值: json, sqlite, postgres, git) - - STORAGE_BACKEND=${STORAGE_BACKEND:-sqlite} - - # 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用) - # - DATABASE_URL=postgresql://user:password@host:5432/dbname - # - DATABASE_URL=sqlite:////app/data/chatgpt2api.db - - # 初始管理员密码 (可选,覆盖 .env) - # - CHATGPT2API_ADMIN_PASSWORD=change_me_please - - # 基础 URL (可选) - # - CHATGPT2API_BASE_URL=https://your-domain.com - - # 检查更新代理 (可选,未设置时复用 CHATGPT2API_PROXY) - - CHATGPT2API_UPDATE_PROXY_URL=${CHATGPT2API_UPDATE_PROXY_URL:-} diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go index b1f2283b9..a1be0cb16 100644 --- a/internal/backend/backend_test.go +++ b/internal/backend/backend_test.go @@ -219,7 +219,13 @@ func TestStreamResponsesImageUsesOfficialPrepareAndConversationRoutes(t *testing case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-1": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"mapping":{"node-1":{"message":{"author":{"role":"tool"},"metadata":{"async_task_type":"image_gen"},"content":{"content_type":"multimodal_text","parts":[{"asset_pointer":"file-service://file_abc"}]}}}}}`)) - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/file_abc/download": + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_abc": + if got := r.URL.Query().Get("conversation_id"); got != "conv-1" { + t.Fatalf("conversation_id = %q", got) + } + if got := r.URL.Query().Get("inline"); got != "false" { + t.Fatalf("inline = %q", got) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_abc.png"}`)) case r.Method == http.MethodGet && r.URL.Path == "/download/file_abc.png": @@ -328,7 +334,13 @@ func TestStreamResponsesImageDoesNotTreatQueuedAssistantNoticeAsFinalText(t *tes pollCount++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"mapping":{"node-1":{"message":{"author":{"role":"tool"},"metadata":{"async_task_type":"image_gen"},"content":{"content_type":"multimodal_text","parts":[{"asset_pointer":"file-service://file_ready"}]}}}}}`)) - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/file_ready/download": + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_ready": + if got := r.URL.Query().Get("conversation_id"); got != "conv-queued" { + t.Fatalf("conversation_id = %q", got) + } + if got := r.URL.Query().Get("inline"); got != "false" { + t.Fatalf("inline = %q", got) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file_ready.png"}`)) case r.Method == http.MethodGet && r.URL.Path == "/download/file_ready.png": @@ -389,15 +401,26 @@ func TestResolveOfficialImageResultsRetriesTransientDownloadURL404(t *testing.T) defer resetOfficialImageRetryDelay() downloadAttempts := 0 + urlAttempts := 0 var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-1/attachment/file_img/download": + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_img": + urlAttempts++ + if got := r.URL.Query().Get("conversation_id"); got != "conv-1" { + t.Fatalf("conversation_id = %q", got) + } + if got := r.URL.Query().Get("inline"); got != "false" { + t.Fatalf("inline = %q", got) + } + if got := r.Header.Get("X-OpenAI-Target-Path"); got != "/backend-api/files/download/file_img" { + t.Fatalf("target path = %q", got) + } w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/attachment.png"}`)) - case r.Method == http.MethodGet && r.URL.Path == "/download/attachment.png": + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/image.png"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/download/image.png": downloadAttempts++ - if downloadAttempts == 1 { + if downloadAttempts < officialImageDownloadAttempts { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"detail":"File link not found."}`)) @@ -421,35 +444,39 @@ func TestResolveOfficialImageResultsRetriesTransientDownloadURL404(t *testing.T) if err != nil { t.Fatalf("resolveOfficialImageResults() error = %v", err) } - if downloadAttempts != 2 { - t.Fatalf("download attempts = %d, want 2", downloadAttempts) + if urlAttempts != officialImageDownloadAttempts { + t.Fatalf("download URL attempts = %d, want %d", urlAttempts, officialImageDownloadAttempts) + } + if downloadAttempts != officialImageDownloadAttempts { + t.Fatalf("download attempts = %d, want %d", downloadAttempts, officialImageDownloadAttempts) } if len(results) != 1 || results[0].Result != png1x1 { t.Fatalf("results = %#v, want one final image result", results) } } -func TestResolveOfficialImageResultsFallsBackFromAttachmentToFileDownload(t *testing.T) { +func TestResolveOfficialImageResultsUsesConversationScopedFileDownloadForSedimentID(t *testing.T) { const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" imageBytes, err := base64.StdEncoding.DecodeString(png1x1) if err != nil { t.Fatalf("decode png: %v", err) } - resetOfficialImageRetryDelay := setOfficialImageDownloadRetryDelayForTest(0) - defer resetOfficialImageRetryDelay() - attachmentURLAttempts := 0 fileURLAttempts := 0 var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-1/attachment/file_img/download": - attachmentURLAttempts++ - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"detail":"File link not found."}`)) - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/file_img/download": + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_img": fileURLAttempts++ + if got := r.URL.Query().Get("conversation_id"); got != "conv-1" { + t.Fatalf("conversation_id = %q", got) + } + if got := r.URL.Query().Get("inline"); got != "false" { + t.Fatalf("inline = %q", got) + } + if got := r.Header.Get("X-OpenAI-Target-Path"); got != "/backend-api/files/download/file_img" { + t.Fatalf("target path = %q", got) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/download/file.png"}`)) case r.Method == http.MethodGet && r.URL.Path == "/download/file.png": @@ -466,15 +493,11 @@ func TestResolveOfficialImageResultsFallsBackFromAttachmentToFileDownload(t *tes Prompt: "生成封面", }, ResponsesImageEvent{ ConversationID: "conv-1", - FileIDs: []string{"file_img"}, SedimentIDs: []string{"file_img"}, }) if err != nil { t.Fatalf("resolveOfficialImageResults() error = %v", err) } - if attachmentURLAttempts != officialImageDownloadAttempts { - t.Fatalf("attachment URL attempts = %d, want %d", attachmentURLAttempts, officialImageDownloadAttempts) - } if fileURLAttempts != 1 { t.Fatalf("file URL attempts = %d, want 1", fileURLAttempts) } @@ -483,6 +506,52 @@ func TestResolveOfficialImageResultsFallsBackFromAttachmentToFileDownload(t *tes } } +func TestResolveOfficialImageResultsAuthenticatesBackendDownloadURL(t *testing.T) { + const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" + imageBytes, err := base64.StdEncoding.DecodeString(png1x1) + if err != nil { + t.Fatalf("decode png: %v", err) + } + + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/files/download/file_img": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"download_url":"` + server.URL + `/backend-api/estuary/content?id=file_img&sig=test"}`)) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/estuary/content": + if got := r.Header.Get("Authorization"); got != "Bearer token-1" { + t.Fatalf("Authorization = %q", got) + } + if got := r.Header.Get("X-OpenAI-Target-Path"); got != "/backend-api/estuary/content" { + t.Fatalf("target path = %q", got) + } + if got := r.Header.Get("Accept"); got != "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8" { + t.Fatalf("Accept = %q", got) + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageBytes) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + results, err := client.resolveOfficialImageResults(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + }, ResponsesImageEvent{ + ConversationID: "conv-1", + FileIDs: []string{"file_img"}, + }) + if err != nil { + t.Fatalf("resolveOfficialImageResults() error = %v", err) + } + if len(results) != 1 || results[0].Result != png1x1 { + t.Fatalf("results = %#v, want one final image result", results) + } +} + func TestBuildResponsesImagePayloadSendsCompressionOnlyForJPEG(t *testing.T) { compression := 37 jpegPayload, err := buildResponsesImagePayload(ResponsesImageRequest{ diff --git a/internal/backend/responses_image.go b/internal/backend/responses_image.go index 3db75825f..152357517 100644 --- a/internal/backend/responses_image.go +++ b/internal/backend/responses_image.go @@ -13,6 +13,7 @@ import ( "io" "math" "net/http" + urlpkg "net/url" "regexp" "strconv" "strings" @@ -109,16 +110,6 @@ type imageConversationState struct { TurnUseCase string } -type officialImageDownloadTarget struct { - FileID string - SedimentID string -} - -type officialImageDownloadCandidate struct { - Label string - URL func(context.Context) (string, error) -} - func (c *Client) StreamResponsesImage(ctx context.Context, request ResponsesImageRequest) (<-chan ResponsesImageEvent, <-chan error) { out := make(chan ResponsesImageEvent) errCh := make(chan error, 1) @@ -1138,13 +1129,13 @@ func (c *Client) resolveOfficialImageResults(ctx context.Context, request Respon fileIDs = appendUniqueString(fileIDs, polledFiles...) sedimentIDs = appendUniqueString(sedimentIDs, polledSediments...) } - targets := officialImageDownloadTargets(fileIDs, sedimentIDs) - if len(targets) == 0 { + imageFileIDs := officialImageFileIDs(fileIDs, sedimentIDs) + if len(imageFileIDs) == 0 { return nil, nil } - results := make([]ResponsesImageEvent, 0, len(targets)) - for index, target := range targets { - data, downloadErr := c.downloadOfficialImageFromCandidates(ctx, c.officialImageDownloadCandidates(conversationID, target)) + results := make([]ResponsesImageEvent, 0, len(imageFileIDs)) + for index, fileID := range imageFileIDs { + data, downloadErr := c.downloadOfficialImageFile(ctx, conversationID, fileID) if downloadErr != nil { return nil, downloadErr } @@ -1163,38 +1154,12 @@ func (c *Client) resolveOfficialImageResults(ctx context.Context, request Respon return results, nil } -func officialImageDownloadTargets(fileIDs, sedimentIDs []string) []officialImageDownloadTarget { - fileIDs = filterOfficialImageIDs(fileIDs) - sedimentIDs = filterOfficialImageIDs(sedimentIDs) - if len(fileIDs) > 0 && len(fileIDs) == len(sedimentIDs) { - targets := make([]officialImageDownloadTarget, 0, len(fileIDs)) - for index, fileID := range fileIDs { - targets = append(targets, officialImageDownloadTarget{FileID: fileID, SedimentID: sedimentIDs[index]}) - } - return targets - } - - usedFiles := map[string]struct{}{} - fileSet := map[string]struct{}{} - for _, fileID := range fileIDs { - fileSet[fileID] = struct{}{} - } - targets := make([]officialImageDownloadTarget, 0, len(fileIDs)+len(sedimentIDs)) +func officialImageFileIDs(fileIDs, sedimentIDs []string) []string { + out := appendUniqueString(nil, filterOfficialImageIDs(fileIDs)...) for _, sedimentID := range sedimentIDs { - target := officialImageDownloadTarget{SedimentID: sedimentID} - if _, ok := fileSet[sedimentID]; ok { - target.FileID = sedimentID - usedFiles[sedimentID] = struct{}{} - } - targets = append(targets, target) - } - for _, fileID := range fileIDs { - if _, ok := usedFiles[fileID]; ok { - continue - } - targets = append(targets, officialImageDownloadTarget{FileID: fileID}) + out = appendUniqueString(out, sedimentID) } - return targets + return out } func filterOfficialImageIDs(values []string) []string { @@ -1291,55 +1256,18 @@ func (c *Client) fetchOfficialConversationImageIDs(ctx context.Context, conversa return fileIDs, sedimentIDs, nil } -func (c *Client) officialImageDownloadCandidates(conversationID string, target officialImageDownloadTarget) []officialImageDownloadCandidate { - var candidates []officialImageDownloadCandidate +func (c *Client) getOfficialFileDownloadURL(ctx context.Context, conversationID, fileID string) (string, error) { conversationID = strings.TrimSpace(conversationID) - if conversationID != "" && strings.TrimSpace(target.SedimentID) != "" { - sedimentID := strings.TrimSpace(target.SedimentID) - candidates = append(candidates, officialImageDownloadCandidate{ - Label: "official image attachment " + sedimentID, - URL: func(ctx context.Context) (string, error) { - return c.getOfficialAttachmentDownloadURL(ctx, conversationID, sedimentID) - }, - }) - } - if strings.TrimSpace(target.FileID) != "" { - fileID := strings.TrimSpace(target.FileID) - candidates = append(candidates, officialImageDownloadCandidate{ - Label: "official image file " + fileID, - URL: func(ctx context.Context) (string, error) { - return c.getOfficialFileDownloadURL(ctx, fileID) - }, - }) - } - return candidates -} - -func (c *Client) getOfficialFileDownloadURL(ctx context.Context, fileID string) (string, error) { - path := "/backend-api/files/" + fileID + "/download" - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) - for key, value := range c.headers(path, map[string]string{"Accept": "application/json"}) { - req.Header.Set(key, value) - } - resp, err := c.httpClient.Do(req) - if err != nil { - return "", upstreamTransportError(path, err) - } - defer resp.Body.Close() - if err := ensureOK(resp, path); err != nil { - return "", err - } - var data map[string]any - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return "", err - } - return firstNonEmpty(util.Clean(data["download_url"]), util.Clean(data["url"])), nil -} - -func (c *Client) getOfficialAttachmentDownloadURL(ctx context.Context, conversationID, attachmentID string) (string, error) { - path := "/backend-api/conversation/" + conversationID + "/attachment/" + attachmentID + "/download" + if conversationID == "" { + return "", fmt.Errorf("conversation_id is required for official image download") + } + query := urlpkg.Values{} + query.Set("conversation_id", conversationID) + query.Set("inline", "false") + targetPath := "/backend-api/files/download/" + urlpkg.PathEscape(fileID) + path := targetPath + "?" + query.Encode() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) - for key, value := range c.headers(path, map[string]string{"Accept": "application/json"}) { + for key, value := range c.headers(targetPath, map[string]string{"Accept": "application/json"}) { req.Header.Set(key, value) } resp, err := c.httpClient.Do(req) @@ -1357,31 +1285,16 @@ func (c *Client) getOfficialAttachmentDownloadURL(ctx context.Context, conversat return firstNonEmpty(util.Clean(data["download_url"]), util.Clean(data["url"])), nil } -func (c *Client) downloadOfficialImageFromCandidates(ctx context.Context, candidates []officialImageDownloadCandidate) ([]byte, error) { - var lastErr error - for _, candidate := range candidates { - data, err := c.downloadOfficialImageWithRetry(ctx, candidate) - if err == nil { - return data, nil - } - lastErr = err - } - if lastErr != nil { - return nil, fmt.Errorf("official image download failed after %d candidate(s): %w", len(candidates), lastErr) - } - return nil, fmt.Errorf("official image download failed: no downloadable image resource") -} - -func (c *Client) downloadOfficialImageWithRetry(ctx context.Context, candidate officialImageDownloadCandidate) ([]byte, error) { +func (c *Client) downloadOfficialImageFile(ctx context.Context, conversationID, fileID string) ([]byte, error) { var lastErr error for attempt := 1; attempt <= officialImageDownloadAttempts; attempt++ { - url, err := candidate.URL(ctx) - if err == nil && strings.TrimSpace(url) == "" { - err = fmt.Errorf("%s returned empty download URL", candidate.Label) + downloadURL, err := c.getOfficialFileDownloadURL(ctx, conversationID, fileID) + if err == nil && strings.TrimSpace(downloadURL) == "" { + err = fmt.Errorf("official image file %s returned empty download URL", fileID) } if err == nil { var data []byte - data, err = c.downloadOfficialImage(ctx, url) + data, err = c.downloadOfficialImage(ctx, downloadURL) if err == nil { return data, nil } @@ -1400,7 +1313,28 @@ func (c *Client) downloadOfficialImageWithRetry(ctx context.Context, candidate o } func (c *Client) downloadOfficialImage(ctx context.Context, url string) ([]byte, error) { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + target := strings.TrimSpace(url) + parsed, err := urlpkg.Parse(target) + if err != nil { + return nil, err + } + if !parsed.IsAbs() { + base, baseErr := urlpkg.Parse(c.BaseURL) + if baseErr != nil { + return nil, baseErr + } + parsed = base.ResolveReference(parsed) + target = parsed.String() + } + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if c.isChatGPTBackendURL(parsed) { + path := parsed.EscapedPath() + for key, value := range c.headers(path, map[string]string{"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"}) { + req.Header.Set(key, value) + } + } else if c.userAgent != "" { + req.Header.Set("User-Agent", c.userAgent) + } resp, err := c.httpClient.Do(req) if err != nil { return nil, upstreamTransportError("image_download", err) @@ -1413,6 +1347,21 @@ func (c *Client) downloadOfficialImage(ctx context.Context, url string) ([]byte, return io.ReadAll(resp.Body) } +func (c *Client) isChatGPTBackendURL(parsed *urlpkg.URL) bool { + if parsed == nil { + return false + } + base, err := urlpkg.Parse(c.BaseURL) + if err != nil || base.Host == "" { + return false + } + if !strings.EqualFold(parsed.Host, base.Host) { + return false + } + path := parsed.EscapedPath() + return strings.HasPrefix(path, "/backend-api/") || strings.HasPrefix(path, "/backend-anon/") +} + func appendUniqueString(base []string, values ...string) []string { seen := map[string]struct{}{} for _, item := range base { diff --git a/cmd/chatgpt2api/main.go b/internal/main.go similarity index 100% rename from cmd/chatgpt2api/main.go rename to internal/main.go diff --git a/internal/protocol/conversation.go b/internal/protocol/conversation.go index 74473bf31..fde65c83d 100644 --- a/internal/protocol/conversation.go +++ b/internal/protocol/conversation.go @@ -233,6 +233,12 @@ func isTransientImageStreamErrorMessage(message string) bool { if lower == "" { return false } + if strings.Contains(lower, strings.ToLower(util.UpstreamConnectionFailureMessage)) { + return true + } + if _, ok := util.SummarizeUpstreamConnectionError(lower); ok { + return true + } for _, token := range []string{ "sse read error", "responses sse read error", diff --git a/internal/protocol/conversation_test.go b/internal/protocol/conversation_test.go index eeb37d4c3..b336d50c8 100644 --- a/internal/protocol/conversation_test.go +++ b/internal/protocol/conversation_test.go @@ -339,6 +339,8 @@ func TestIsTransientImageStreamErrorMessage(t *testing.T) { "unexpected EOF", "connection reset by peer", "stream closed", + "bootstrap failed: upstream connection failed before TLS handshake completed; check proxy reachability to chatgpt.com or change proxy", + `bootstrap failed: Get "https://chatgpt.com/": surf: HTTP/2 request failed: uTLS.HandshakeContext() error: EOF; HTTP/1.1 fallback failed: uTLS.HandshakeContext() error: EOF`, } for _, input := range transient { if !isTransientImageStreamErrorMessage(input) { diff --git a/internal/service/update_test.go b/internal/service/update_test.go index 1257ae02a..af4018128 100644 --- a/internal/service/update_test.go +++ b/internal/service/update_test.go @@ -80,12 +80,27 @@ func TestGoReleaserArchiveDoesNotShipWebDist(t *testing.T) { t.Fatalf("read .goreleaser.yaml: %v", err) } config := string(data) + if !strings.Contains(config, "main: ./internal") { + t.Fatal(".goreleaser.yaml must build the current internal main package") + } if strings.Contains(config, "web_dist") { t.Fatal(".goreleaser.yaml must not ship runtime web_dist assets") } if !strings.Contains(config, "-tags=embed") { t.Fatal(".goreleaser.yaml must build the binary with embedded frontend assets") } + if !strings.Contains(config, "- deploy/docker-compose.yml") { + t.Fatal(".goreleaser.yaml archive must ship deploy/docker-compose.yml") + } + if strings.Contains(config, "- docker-compose.yml") { + t.Fatal(".goreleaser.yaml archive must not reference root docker-compose.yml") + } + if !strings.Contains(config, "dockerfile: deploy/Dockerfile.release") { + t.Fatal(".goreleaser.yaml Docker images must use deploy/Dockerfile.release") + } + if strings.Contains(config, "Dockerfile.goreleaser") { + t.Fatal(".goreleaser.yaml must not reference Dockerfile.goreleaser") + } } func TestGoReleaserBuildTargetsLinuxOnly(t *testing.T) { @@ -107,6 +122,77 @@ func TestGoReleaserBuildTargetsLinuxOnly(t *testing.T) { } } +func TestReleaseWorkflowUsesSingleGoReleaserConfig(t *testing.T) { + if _, err := os.Stat(filepath.Join("..", "..", ".goreleaser.simple.yaml")); !os.IsNotExist(err) { + t.Fatal(".goreleaser.simple.yaml must not exist; releases use the main GoReleaser config") + } + data, err := os.ReadFile(filepath.Join("..", "..", ".github", "workflows", "release.yml")) + if err != nil { + t.Fatalf("read release workflow: %v", err) + } + workflow := string(data) + if strings.Contains(workflow, "simple_release") { + t.Fatal("release workflow must not expose a simple_release path") + } + if strings.Contains(workflow, ".goreleaser.simple.yaml") { + t.Fatal("release workflow must not reference .goreleaser.simple.yaml") + } + if !strings.Contains(workflow, "args: release --clean --skip=validate") { + t.Fatal("release workflow must run the main GoReleaser release path") + } +} + +func TestRetiredDockerBuildFilesDoNotReturn(t *testing.T) { + for _, path := range []string{ + ".dockerignore", + "Dockerfile", + "Dockerfile.goreleaser", + "docker-compose.yml", + "docker-compose.build.yml", + "docker-compose.local.yml", + } { + if _, err := os.Stat(filepath.Join("..", "..", path)); !os.IsNotExist(err) { + t.Fatalf("%s must not exist; Docker deployment config belongs under deploy/", path) + } + } +} + +func TestServerSourceDockerBuildFilesStayUnderDeploy(t *testing.T) { + for _, path := range []string{ + filepath.Join("deploy", "Dockerfile"), + filepath.Join("deploy", "Dockerfile.dockerignore"), + filepath.Join("deploy", "docker-build-limited.sh"), + } { + if _, err := os.Stat(filepath.Join("..", "..", path)); err != nil { + t.Fatalf("%s must exist for server-side source builds: %v", path, err) + } + } + + dockerfileData, err := os.ReadFile(filepath.Join("..", "..", "deploy", "Dockerfile")) + if err != nil { + t.Fatalf("read deploy/Dockerfile: %v", err) + } + dockerfile := string(dockerfileData) + if !strings.Contains(dockerfile, "go build") || !strings.Contains(dockerfile, "./internal") { + t.Fatal("deploy/Dockerfile must build the current internal main package") + } + if strings.Contains(dockerfile, "./cmd/chatgpt2api") || strings.Contains(dockerfile, "COPY cmd ") { + t.Fatal("deploy/Dockerfile must not reference the retired cmd/chatgpt2api entrypoint") + } + + scriptData, err := os.ReadFile(filepath.Join("..", "..", "deploy", "docker-build-limited.sh")) + if err != nil { + t.Fatalf("read deploy/docker-build-limited.sh: %v", err) + } + script := string(scriptData) + if !strings.Contains(script, `--file "$repo_root/deploy/Dockerfile"`) { + t.Fatal("docker-build-limited.sh must build from deploy/Dockerfile") + } + if !strings.Contains(script, `-f "$repo_root/deploy/docker-compose.yml"`) { + t.Fatal("docker-build-limited.sh must run deploy/docker-compose.yml") + } +} + func yamlListContains(config, value string) bool { for _, line := range strings.Split(config, "\n") { switch strings.TrimSpace(line) { diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 000000000..51e72de31 --- /dev/null +++ b/web/.oxlintrc.json @@ -0,0 +1,25 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "react"], + "categories": { + "correctness": "error" + }, + "env": { + "browser": true, + "es2020": true + }, + "ignorePatterns": ["**/.next/**", "**/dist/**", "**/node_modules/**", "**/out/**"], + "rules": { + "no-unused-vars": "off", + "typescript/no-explicit-any": "off", + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + "react/only-export-components": [ + "warn", + { + "allowConstantExport": true, + "allowExportNames": ["badgeVariants", "buttonVariants"] + } + ] + } +} diff --git a/web/bun.lock b/web/bun.lock index 26e8880cc..3a0e32d1c 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -29,24 +29,18 @@ "zustand": "^5.0.8", }, "devDependencies": { - "@eslint/js": "^9", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "19.1.12", "@types/react-dom": "19.1.9", "@umijs/openapi": "^1.13.15", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", + "oxlint": "^1.63.0", "prettier-plugin-organize-imports": "^4.2.0", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4", "tw-animate-css": "^1.3.4", "typescript": "^5", - "typescript-eslint": "^8.59.1", "vite": "^8.0.10", }, }, @@ -62,95 +56,77 @@ "@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.0", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/core": ["@babel/core@7.29.0", "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], - "@babel/parser": ["@babel/parser@7.29.2", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], - "@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "https://registry.npmmirror.com/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], - "@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], - "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@eslint/config-array": ["@eslint/config-array@0.20.1", "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.20.1.tgz", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.2.3", "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", {}, "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@eslint/core": ["@eslint/core@0.14.0", "https://registry.npmmirror.com/@eslint/core/-/core-0.14.0.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + "@oxc-project/types": ["@oxc-project/types@0.127.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.127.0.tgz", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], - "@eslint/js": ["@eslint/js@9.29.0", "https://registry.npmmirror.com/@eslint/js/-/js-9.29.0.tgz", {}, "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.63.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg=="], - "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.6.tgz", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-android-arm64/-/binding-android-arm64-1.63.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.63.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g=="], - "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "https://registry.npmmirror.com/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.63.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw=="], - "@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.63.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA=="], - "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.63.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w=="], - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.63.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg=="], - "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.63.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.63.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g=="], - "@humanfs/node": ["@humanfs/node@0.16.6", "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.6.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.63.0.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ=="], - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.63.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA=="], - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.63.0.tgz", { "os": "linux", "cpu": "none" }, "sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ=="], - "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "https://registry.npmmirror.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.63.0.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.63.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA=="], - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.63.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A=="], - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.63.0.tgz", { "os": "none", "cpu": "arm64" }, "sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.63.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg=="], - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.63.0.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - - "@oxc-project/types": ["@oxc-project/types@0.127.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.127.0.tgz", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.63.0", "https://registry.npmmirror.com/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.63.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], @@ -278,48 +254,18 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@20.19.1", "https://registry.npmmirror.com/@types/node/-/node-20.19.1.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="], "@types/react": ["@types/react@19.1.12", "https://registry.npmmirror.com/@types/react/-/react-19.1.12.tgz", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="], "@types/react-dom": ["@types/react-dom@19.1.9", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.1.9.tgz", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/type-utils": "8.59.1", "@typescript-eslint/utils": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.1.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1" } }, "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.1.tgz", {}, "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.1.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], - "@umijs/openapi": ["@umijs/openapi@1.13.15", "https://registry.npmmirror.com/@umijs/openapi/-/openapi-1.13.15.tgz", { "dependencies": { "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "dayjs": "^1.10.3", "glob": "^7.1.6", "lodash": "^4.17.21", "memoizee": "^0.4.15", "mock.js": "^0.2.0", "mockjs": "^1.1.0", "node-fetch": "^2.6.1", "number-to-words": "^1.2.4", "nunjucks": "^3.2.2", "openapi3-ts": "^2.0.1", "prettier": "^2.2.1", "reserved-words": "^0.1.2", "rimraf": "^3.0.2", "swagger2openapi": "^7.0.4", "tiny-pinyin": "^1.3.2" }, "bin": { "openapi2ts": "dist/cli.js" } }, "sha512-+oJBEXV9Liu7tZzkYANs72hXiwqEngVhpUQN+XLVsAr49+D6thr+Fyb0cezcrydulOSsxa+VPaPMXPmXbAVuYA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "a-sync-waterfall": ["a-sync-waterfall@1.0.1", "https://registry.npmmirror.com/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="], - "acorn": ["acorn@8.15.0", "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "ajv": ["ajv@6.12.6", "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], - "ansi-regex": ["ansi-regex@5.0.1", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -336,20 +282,14 @@ "balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.23", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g=="], - "brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-me-maybe": ["call-me-maybe@1.0.2", "https://registry.npmmirror.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz", {}, "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ=="], "callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001791", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], - "chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chownr": ["chownr@3.0.0", "https://registry.npmmirror.com/chownr/-/chownr-3.0.0.tgz", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -370,14 +310,10 @@ "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "cosmiconfig": ["cosmiconfig@9.0.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], - "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "csstype": ["csstype@3.1.3", "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "d": ["d@1.0.2", "https://registry.npmmirror.com/d/-/d-1.0.2.tgz", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="], @@ -388,10 +324,6 @@ "dayjs": ["dayjs@1.11.13", "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], - "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -400,8 +332,6 @@ "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "electron-to-chromium": ["electron-to-chromium@1.5.344", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", {}, "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg=="], - "emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "enhanced-resolve": ["enhanced-resolve@5.18.2", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ=="], @@ -430,54 +360,16 @@ "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@9.29.0", "https://registry.npmmirror.com/eslint/-/eslint-9.29.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.1", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "bin": "bin/eslint.js" }, "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ=="], - - "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": "bin/cli.js" }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], - - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="], - - "eslint-scope": ["eslint-scope@8.4.0", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - "esniff": ["esniff@2.0.1", "https://registry.npmmirror.com/esniff/-/esniff-2.0.1.tgz", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="], - "espree": ["espree@10.4.0", "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - - "esquery": ["esquery@1.6.0", "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], - - "esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "event-emitter": ["event-emitter@0.3.5", "https://registry.npmmirror.com/event-emitter/-/event-emitter-0.3.5.tgz", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], "ext": ["ext@1.7.0", "https://registry.npmmirror.com/ext/-/ext-1.7.0.tgz", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.3.3", "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -490,8 +382,6 @@ "function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-caller-file": ["get-caller-file@2.0.5", "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -502,10 +392,6 @@ "glob": ["glob@7.2.3", "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], - "glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@17.5.0", "https://registry.npmmirror.com/globals/-/globals-17.5.0.tgz", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], - "gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -518,60 +404,32 @@ "hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], - - "hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "http2-client": ["http2-client@1.3.5", "https://registry.npmmirror.com/http2-client/-/http2-client-1.3.5.tgz", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="], - "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], "immer": ["immer@10.1.3", "https://registry.npmmirror.com/immer/-/immer-10.1.3.tgz", {}, "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw=="], "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "inflight": ["inflight@1.0.6", "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "is-arrayish": ["is-arrayish@0.2.1", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - "is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-promise": ["is-promise@2.2.2", "https://registry.npmmirror.com/is-promise/-/is-promise-2.2.2.tgz", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], - "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jiti": ["jiti@2.4.2", "https://registry.npmmirror.com/jiti/-/jiti-2.4.2.tgz", { "bin": "lib/jiti-cli.mjs" }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="], "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.0", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], - "jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - "lie": ["lie@3.1.1", "https://registry.npmmirror.com/lie/-/lie-3.1.1.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw=="], "lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -602,14 +460,8 @@ "localforage": ["localforage@1.10.0", "https://registry.npmmirror.com/localforage/-/localforage-1.10.0.tgz", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="], - "locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.17.21", "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - - "lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lru-queue": ["lru-queue@0.1.0", "https://registry.npmmirror.com/lru-queue/-/lru-queue-0.1.0.tgz", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="], "lucide-react": ["lucide-react@0.523.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.523.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-rUjQoy7egZT9XYVXBK1je9ckBnNp7qzRZOhLQx5RcEp2dCGlXo+mv6vf7Am4LimEcFBJIIZzSGfgTqc9QCrPSw=="], @@ -642,12 +494,8 @@ "motion-utils": ["motion-utils@12.36.0", "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.36.0.tgz", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], - "ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "next-tick": ["next-tick@1.1.0", "https://registry.npmmirror.com/next-tick/-/next-tick-1.1.0.tgz", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], "node-fetch": ["node-fetch@2.7.0", "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -656,8 +504,6 @@ "node-readfiles": ["node-readfiles@0.2.0", "https://registry.npmmirror.com/node-readfiles/-/node-readfiles-0.2.0.tgz", { "dependencies": { "es6-promise": "^3.2.1" } }, "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA=="], - "node-releases": ["node-releases@2.0.38", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", {}, "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw=="], - "number-to-words": ["number-to-words@1.2.4", "https://registry.npmmirror.com/number-to-words/-/number-to-words-1.2.4.tgz", {}, "sha512-/fYevVkXRcyBiZDg6yzZbm0RuaD6i0qRfn8yr+6D0KgBMOndFPxuW10qCHpzs50nN8qKuv78k8MuotZhcVX6Pw=="], "nunjucks": ["nunjucks@3.2.4", "https://registry.npmmirror.com/nunjucks/-/nunjucks-3.2.4.tgz", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="], @@ -676,30 +522,20 @@ "openapi3-ts": ["openapi3-ts@2.0.2", "https://registry.npmmirror.com/openapi3-ts/-/openapi3-ts-2.0.2.tgz", { "dependencies": { "yaml": "^1.10.2" } }, "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw=="], - "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "oxlint": ["oxlint@1.63.0", "https://registry.npmmirror.com/oxlint/-/oxlint-1.63.0.tgz", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.63.0", "@oxlint/binding-android-arm64": "1.63.0", "@oxlint/binding-darwin-arm64": "1.63.0", "@oxlint/binding-darwin-x64": "1.63.0", "@oxlint/binding-freebsd-x64": "1.63.0", "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", "@oxlint/binding-linux-arm-musleabihf": "1.63.0", "@oxlint/binding-linux-arm64-gnu": "1.63.0", "@oxlint/binding-linux-arm64-musl": "1.63.0", "@oxlint/binding-linux-ppc64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-musl": "1.63.0", "@oxlint/binding-linux-s390x-gnu": "1.63.0", "@oxlint/binding-linux-x64-gnu": "1.63.0", "@oxlint/binding-linux-x64-musl": "1.63.0", "@oxlint/binding-openharmony-arm64": "1.63.0", "@oxlint/binding-win32-arm64-msvc": "1.63.0", "@oxlint/binding-win32-ia32-msvc": "1.63.0", "@oxlint/binding-win32-x64-msvc": "1.63.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg=="], "parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-json": ["parse-json@5.2.0", "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@2.8.8", "https://registry.npmmirror.com/prettier/-/prettier-2.8.8.tgz", { "bin": "bin-prettier.js" }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], "prettier-plugin-organize-imports": ["prettier-plugin-organize-imports@4.2.0", "https://registry.npmmirror.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.2.0.tgz", { "peerDependencies": { "prettier": ">=2.0", "typescript": ">=2.9", "vue-tsc": "^2.1.0 || 3" }, "optionalPeers": ["vue-tsc"] }, "sha512-Zdy27UhlmyvATZi67BTnLcKTo8fm6Oik59Sz6H64PgZJVs6NJpPD1mT240mmJn62c98/QaL+r3kx9Q3gRpDajg=="], @@ -708,8 +544,6 @@ "proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "react": ["react@19.2.5", "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], "react-day-picker": ["react-day-picker@9.14.0", "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-9.14.0.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], @@ -744,14 +578,8 @@ "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], - "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "should": ["should@13.2.3", "https://registry.npmmirror.com/should/-/should-13.2.3.tgz", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="], "should-equal": ["should-equal@2.0.0", "https://registry.npmmirror.com/should-equal/-/should-equal-2.0.0.tgz", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="], @@ -772,8 +600,6 @@ "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "swagger2openapi": ["swagger2openapi@7.0.8", "https://registry.npmmirror.com/swagger2openapi/-/swagger2openapi-7.0.8.tgz", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "boast": "boast.js", "oas-validate": "oas-validate.js", "swagger2openapi": "swagger2openapi.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="], @@ -794,26 +620,16 @@ "tr46": ["tr46@0.0.3", "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - "tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tw-animate-css": ["tw-animate-css@1.3.4", "https://registry.npmmirror.com/tw-animate-css/-/tw-animate-css-1.3.4.tgz", {}, "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg=="], "type": ["type@2.7.3", "https://registry.npmmirror.com/type/-/type-2.7.3.tgz", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], - "type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "typescript": ["typescript@5.8.3", "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "typescript-eslint": ["typescript-eslint@8.59.1", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.1.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.1", "@typescript-eslint/parser": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ=="], - "undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], @@ -824,10 +640,6 @@ "whatwg-url": ["whatwg-url@5.0.0", "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -842,34 +654,10 @@ "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - "zustand": ["zustand@5.0.8", "https://registry.npmmirror.com/zustand/-/zustand-5.0.8.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - - "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.1", "https://registry.npmmirror.com/@eslint/core/-/core-0.15.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="], - - "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.3.1.tgz", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], - "@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.1.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "lru-cache/yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "nunjucks/commander": ["commander@5.1.0", "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "prettier-plugin-tailwindcss/prettier": ["prettier@3.6.2", "https://registry.npmmirror.com/prettier/-/prettier-3.6.2.tgz", { "bin": "bin/prettier.cjs" }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], @@ -897,9 +685,5 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs deleted file mode 100644 index b2ba20b79..000000000 --- a/web/eslint.config.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import js from '@eslint/js'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import globals from 'globals'; -import prettier from 'eslint-config-prettier/flat'; -import tseslint from 'typescript-eslint'; - -const eslintConfig = [ - { ignores: ['**/.next/**', '**/dist/**', '**/node_modules/**', '**/out/**'] }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn', - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true, allowExportNames: ['badgeVariants', 'buttonVariants'] }, - ], - }, - }, - prettier, - { - rules: { - '@typescript-eslint/no-unused-vars': 'off', // 不检查未使用的变量 - '@typescript-eslint/no-explicit-any': 'off', // 关闭 any 报错 - }, - }, -]; - -export default eslintConfig; diff --git a/web/index.html b/web/index.html index a3c8f56d3..51a7739bd 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,8 @@ - + + ChatGptImage diff --git a/web/package.json b/web/package.json index 458cdd552..16de79012 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview --host 0.0.0.0", - "lint": "eslint ." + "lint": "oxlint" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", @@ -34,24 +34,18 @@ "zustand": "^5.0.8" }, "devDependencies": { - "@eslint/js": "^9", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "19.1.12", "@types/react-dom": "19.1.9", "@umijs/openapi": "^1.13.15", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^9", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.5.0", + "oxlint": "^1.63.0", "prettier-plugin-organize-imports": "^4.2.0", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4", "tw-animate-css": "^1.3.4", "typescript": "^5", - "typescript-eslint": "^8.59.1", "vite": "^8.0.10" }, "overrides": { diff --git a/web/public/favicon.ico b/web/public/favicon.ico index 718d6fea4835ec2d246af9800eddb7ffb276240c..74d77c072db07546304fe6c6517652e9281db002 100644 GIT binary patch literal 60148 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 LrJugUA?W`A8`#=m diff --git a/web/src/app/settings/store.ts b/web/src/app/settings/store.ts index 214167198..4e7864238 100644 --- a/web/src/app/settings/store.ts +++ b/web/src/app/settings/store.ts @@ -594,7 +594,7 @@ export const useSettingsStore = create((set, get) => ({ set((state) => { if (!state.registerConfig) return {}; const providers = [...(state.registerConfig.mail.providers || [])]; - providers[index] = { ...(providers[index] || {}), ...updates }; + providers[index] = { ...providers[index], ...updates }; return { registerConfig: { ...state.registerConfig, mail: { ...state.registerConfig.mail, providers } } }; }); }, diff --git a/web/src/lib/request.ts b/web/src/lib/request.ts index dcb6db56c..6145f0089 100644 --- a/web/src/lib/request.ts +++ b/web/src/lib/request.ts @@ -36,11 +36,11 @@ const request = axios.create({ request.interceptors.request.use(async (config) => { const nextConfig = {...config}; const sessionToken = await getStoredSessionToken(); - const headers = {...(nextConfig.headers || {})} as Record; + const headers = {...nextConfig.headers} as Record; if (sessionToken && !headers.Authorization) { headers.Authorization = `Bearer ${sessionToken}`; } - // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // oxlint-disable-next-line typescript/ban-ts-comment // @ts-expect-error nextConfig.headers = headers; return nextConfig; From e8248cc823f0391596e79d0a67e2130ab0eb8229 Mon Sep 17 00:00:00 2001 From: ZyphrZero <133507172+ZyphrZero@users.noreply.github.com> Date: Fri, 8 May 2026 15:50:14 +0800 Subject: [PATCH 07/76] Update README.md --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index d1505ec8c..70d71599d 100644 --- a/README.md +++ b/README.md @@ -663,14 +663,10 @@ Telegram 群组:[ChatGPT2API](https://t.me/+YBR7t_CPOYBkYzU1) 学 AI,上 L 站:[LinuxDO](https://linux.do) -内置提示词参考: - - [banana-prompt-quicker](https://github.com/glidea/banana-prompt-quicker),作者:[阿良](https://linux.do/u/ajd) - [awesome-gpt-image-2-prompts](https://github.com/EvoLinkAI/awesome-gpt-image-2-prompts) - -生图配置参考: - - [ChatGpt-Image-Studio](https://github.com/peiyizhi0724/ChatGpt-Image-Studio),作者:[小怪兽](https://linux.do/u/peiyizhi) +- [sub2api](https://github.com/Wei-Shaw/sub2api) ## Contributors From 896f525d2e6812cf8afecbf184c35ec802500eca Mon Sep 17 00:00:00 2001 From: ZyphrZero <133507172+ZyphrZero@users.noreply.github.com> Date: Fri, 8 May 2026 17:35:12 +0800 Subject: [PATCH 08/76] Support image text responses and build resource detection Image generation can return text clarification responses instead of image assets, so the backend now preserves those responses and the admin UI can surface them consistently. Build resource detection is updated in the same branch because the image response flow depends on current frontend resource metadata. Constraint: Keep test fixtures neutral and free of real-person prompt examples Rejected: Preserve the two original commits | their fixture text should not remain in branch history Confidence: high Scope-risk: moderate Tested: go test ./internal/httpapi Tested: go test ./internal/backend --- .env.example | 7 +- README.md | 8 +- deploy/Dockerfile | 2 +- deploy/docker-build-limited.sh | 92 ++++++++++++++++--- internal/backend/backend_test.go | 120 +++++++++++++++++++++++++ internal/backend/responses_image.go | 67 ++++++++++++-- internal/httpapi/app.go | 19 ++++ internal/httpapi/app_test.go | 38 ++++++++ internal/protocol/conversation.go | 31 ++++++- internal/protocol/conversation_test.go | 33 +++++++ internal/service/image_task.go | 41 +++++++-- internal/service/image_task_test.go | 24 +++++ internal/service/update_test.go | 26 ++++++ web/src/app/image/page.tsx | 27 +++++- web/src/lib/api.ts | 2 +- web/src/store/image-conversations.ts | 8 +- 16 files changed, 499 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index 1b4cd6ae1..5f2be99f5 100644 --- a/.env.example +++ b/.env.example @@ -23,11 +23,14 @@ CHATGPT2API_REGISTRATION_ENABLED=false # ============================================ # 需要在服务器从源码构建镜像时,推荐用脚本创建受限 BuildKit builder 后再构建。 +# 直接运行脚本时会按服务器资源自动选择默认值,资源充足时默认使用 2 核和更高内存。 +# 如需手动覆盖,可取消下面注释。 # BUILD_CPUS=2 -# BUILD_MEMORY=2g +# BUILD_MEMORY=4g +# BUILD_MEMORY_SWAP=4g # BUILDKIT_MAX_PARALLELISM=2 # BUILD_GOMAXPROCS=2 -# BUILD_GOMEMLIMIT=1GiB +# BUILD_GOMEMLIMIT=2GiB # BUILD_NODE_OPTIONS=--max-old-space-size=1024 # CHATGPT2API_LOCAL_IMAGE=chatgpt2api:local diff --git a/README.md b/README.md index 70d71599d..6aa2f7c66 100644 --- a/README.md +++ b/README.md @@ -213,16 +213,16 @@ docker compose -f deploy/docker-compose.yml up -d sh deploy/docker-build-limited.sh up ``` -该脚本会创建独立的 `docker-container` Buildx builder,并对构建容器设置 CPU / 内存上限。默认给构建过程 2 核、2 GB 内存、BuildKit 并行度 2,Go 编译并行度 2: +该脚本会创建独立的 `docker-container` Buildx builder,并对构建容器设置 CPU / 内存上限。直接运行时会按服务器资源自动选择默认值:最多使用 2 核;内存充足时默认放开到 3-4 GB;低内存机器才会降低 Go 编译并行度,避免 `compile: signal: killed` 这类 OOM: ```bash -BUILD_CPUS=2 BUILD_MEMORY=2g sh deploy/docker-build-limited.sh up +sh deploy/docker-build-limited.sh up ``` -低配服务器可以进一步收紧配额,构建会变慢,但不会让构建进程吃满整台机器: +如果你想显式放开配额: ```bash -BUILD_CPUS=1 BUILD_MEMORY=1536m BUILD_GOMEMLIMIT=768MiB sh deploy/docker-build-limited.sh up +BUILD_CPUS=2 BUILD_MEMORY=4g BUILD_MEMORY_SWAP=4g BUILD_GOMAXPROCS=2 BUILD_GOMEMLIMIT=2GiB sh deploy/docker-build-limited.sh up ``` 如果只想构建本地镜像、不重启容器: diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 817cf93d2..dac2ab926 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -38,7 +38,7 @@ ARG TARGETOS ARG TARGETARCH ARG VERSION=0.0.0-dev ARG BUILD_GOMAXPROCS=2 -ARG BUILD_GOMEMLIMIT=1GiB +ARG BUILD_GOMEMLIMIT=2GiB RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build,sharing=locked \ GOMAXPROCS=${BUILD_GOMAXPROCS} GOMEMLIMIT=${BUILD_GOMEMLIMIT} \ diff --git a/deploy/docker-build-limited.sh b/deploy/docker-build-limited.sh index 7467d84c1..ceaef83f8 100644 --- a/deploy/docker-build-limited.sh +++ b/deploy/docker-build-limited.sh @@ -10,18 +10,18 @@ Creates a resource-capped BuildKit builder, then builds the local Docker image. Tunable environment variables: CHATGPT2API_BUILDER_NAME Builder name (default: chatgpt2api-local-build) - BUILD_CPUS Whole CPU cores available to BuildKit (default: 2) - BUILD_MEMORY BuildKit memory limit (default: 2g) - BUILD_MEMORY_SWAP BuildKit memory+swap limit (default: BUILD_MEMORY) + BUILD_CPUS Whole CPU cores available to BuildKit (default: auto, up to 2) + BUILD_MEMORY BuildKit memory limit (default: auto, 2g-4g) + BUILD_MEMORY_SWAP BuildKit memory+swap limit (default: auto, 4g when possible) BUILDKIT_MAX_PARALLELISM BuildKit solver parallelism (default: BUILD_CPUS) - BUILD_GOMAXPROCS Go compiler parallelism (default: BUILD_CPUS) - BUILD_GOMEMLIMIT Go soft memory limit (default: 1GiB) + BUILD_GOMAXPROCS Go compiler parallelism (default: auto, 1 on low-memory hosts, otherwise BUILD_CPUS) + BUILD_GOMEMLIMIT Go soft memory limit (default: auto) BUILD_NODE_OPTIONS Node options for the web build BUILD_CPUSET_CPUS Optional cpuset, for example 0-1 Examples: - BUILD_CPUS=2 BUILD_MEMORY=2g sh deploy/docker-build-limited.sh up - BUILD_CPUS=1 BUILD_MEMORY=1536m BUILD_GOMEMLIMIT=768MiB sh deploy/docker-build-limited.sh build + sh deploy/docker-build-limited.sh up + BUILD_CPUS=2 BUILD_MEMORY=4g BUILD_MEMORY_SWAP=4g BUILD_GOMAXPROCS=2 BUILD_GOMEMLIMIT=2GiB sh deploy/docker-build-limited.sh up EOF } @@ -54,15 +54,66 @@ case "$command" in ;; esac +detect_cpu_count() { + if command -v nproc >/dev/null 2>&1; then + nproc + elif command -v getconf >/dev/null 2>&1; then + getconf _NPROCESSORS_ONLN + else + echo 2 + fi +} + +detect_memory_mib() { + if [ -r /proc/meminfo ]; then + awk '/MemTotal:/ { print int($2 / 1024); exit }' /proc/meminfo + else + echo 4096 + fi +} + script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd) +detected_cpus="$(detect_cpu_count)" +require_uint detected_cpus "$detected_cpus" +if [ "$detected_cpus" -gt 2 ]; then + default_build_cpus=2 +else + default_build_cpus="$detected_cpus" +fi + +detected_memory_mib="$(detect_memory_mib)" +require_uint detected_memory_mib "$detected_memory_mib" +if [ "$detected_memory_mib" -ge 6144 ]; then + default_build_memory=4g + default_build_memory_swap=4g + default_buildkit_max_parallelism="$default_build_cpus" + default_build_gomaxprocs="$default_build_cpus" + default_build_gomemlimit=2GiB + default_build_node_options=--max-old-space-size=1024 +elif [ "$detected_memory_mib" -ge 4096 ]; then + default_build_memory=3g + default_build_memory_swap=4g + default_buildkit_max_parallelism="$default_build_cpus" + default_build_gomaxprocs="$default_build_cpus" + default_build_gomemlimit=1536MiB + default_build_node_options=--max-old-space-size=1024 +else + default_build_memory=2g + default_build_memory_swap=4g + default_buildkit_max_parallelism=1 + default_build_gomaxprocs=1 + default_build_gomemlimit=1GiB + default_build_node_options=--max-old-space-size=768 +fi + builder_name="${CHATGPT2API_BUILDER_NAME:-chatgpt2api-local-build}" -build_cpus="${BUILD_CPUS:-2}" +build_cpus="${BUILD_CPUS:-$default_build_cpus}" build_cpu_period="${BUILD_CPU_PERIOD:-100000}" -build_memory="${BUILD_MEMORY:-2g}" -build_memory_swap="${BUILD_MEMORY_SWAP:-$build_memory}" -buildkit_max_parallelism="${BUILDKIT_MAX_PARALLELISM:-$build_cpus}" +build_memory="${BUILD_MEMORY:-$default_build_memory}" +build_memory_swap="${BUILD_MEMORY_SWAP:-$default_build_memory_swap}" +buildkit_max_parallelism="${BUILDKIT_MAX_PARALLELISM:-$default_buildkit_max_parallelism}" require_uint BUILD_CPUS "$build_cpus" require_uint BUILD_CPU_PERIOD "$build_cpu_period" @@ -73,9 +124,9 @@ require_uint BUILD_CPU_QUOTA "$build_cpu_quota" export DOCKER_BUILDKIT=1 export BUILDX_BUILDER="$builder_name" -export BUILD_GOMAXPROCS="${BUILD_GOMAXPROCS:-$build_cpus}" -export BUILD_GOMEMLIMIT="${BUILD_GOMEMLIMIT:-1GiB}" -export BUILD_NODE_OPTIONS="${BUILD_NODE_OPTIONS:---max-old-space-size=1024}" +export BUILD_GOMAXPROCS="${BUILD_GOMAXPROCS:-$default_build_gomaxprocs}" +export BUILD_GOMEMLIMIT="${BUILD_GOMEMLIMIT:-$default_build_gomemlimit}" +export BUILD_NODE_OPTIONS="${BUILD_NODE_OPTIONS:-$default_build_node_options}" export CHATGPT2API_LOCAL_IMAGE="${CHATGPT2API_LOCAL_IMAGE:-chatgpt2api:local}" export CHATGPT2API_VERSION="${CHATGPT2API_VERSION:-0.0.0-dev}" @@ -98,6 +149,19 @@ memory-swap=$build_memory_swap max-parallelism=$buildkit_max_parallelism cpuset-cpus=${BUILD_CPUSET_CPUS:-}" +cat <Enable JavaScript and cookies to continue`)) got := err.Error() @@ -391,6 +402,113 @@ func TestStreamResponsesImageDoesNotTreatQueuedAssistantNoticeAsFinalText(t *tes } } +func TestStreamResponsesImageReportsAsyncResultTimeout(t *testing.T) { + resetPollTiming := setOfficialImagePollTimingForTest(20*time.Millisecond, time.Millisecond) + defer resetPollTiming() + + pollCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialImagePreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialImageStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"server_ste_metadata\",\"metadata\":{\"tool_invoked\":true,\"turn_use_case\":\"image gen\"},\"conversation_id\":\"conv-empty\"}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-empty\"}\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-empty": + pollCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: "生成封面", + Model: "gpt-image-2", + }) + for range events { + } + err := <-errCh + if err == nil { + t.Fatal("StreamResponsesImage() error = nil, want async timeout error") + } + if pollCount == 0 { + t.Fatal("expected conversation polling before timeout") + } + if !strings.Contains(err.Error(), "timed out waiting for async image generation results in conversation conv-empty") { + t.Fatalf("error = %q, want async timeout context", err.Error()) + } +} + +func TestStreamResponsesImageIgnoresInputMessageTextAsFinalText(t *testing.T) { + resetPollTiming := setOfficialImagePollTimingForTest(20*time.Millisecond, time.Millisecond) + defer resetPollTiming() + + prompt := "生成两位虚构角色在温馨场景中互动的图像" + var pollCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) + case r.Method == http.MethodPost && r.URL.Path == officialImagePreparePath: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) + case r.Method == http.MethodPost && r.URL.Path == officialImageStreamPath: + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"type":"input_message","message":{"author":{"role":"user"},"content":{"content_type":"text","parts":["` + prompt + `"]}},"conversation_id":"conv-input"}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"type":"server_ste_metadata","metadata":{"tool_invoked":true,"turn_use_case":"image gen"},"conversation_id":"conv-input"}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"type":"message_stream_complete","conversation_id":"conv-input"}` + "\n\n")) + case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-input": + pollCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"mapping":{}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := newTestBackendClient(server) + events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ + Prompt: prompt, + Model: "gpt-image-2", + }) + var texts []string + for event := range events { + if strings.TrimSpace(event.Text) != "" { + texts = append(texts, event.Text) + } + } + err := <-errCh + if err == nil { + t.Fatal("StreamResponsesImage() error = nil, want async timeout error") + } + if pollCount == 0 { + t.Fatal("expected polling after input message was ignored as final text") + } + if strings.Contains(err.Error(), prompt) { + t.Fatalf("error echoed user prompt: %q", err.Error()) + } + if len(texts) != 0 { + t.Fatalf("input message text leaked as response text: %#v", texts) + } +} + func TestResolveOfficialImageResultsRetriesTransientDownloadURL404(t *testing.T) { const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" imageBytes, err := base64.StdEncoding.DecodeString(png1x1) @@ -604,7 +722,9 @@ func TestShouldTreatOfficialImageEventAsFinalText(t *testing.T) { {name: "blocked text", event: ResponsesImageEvent{Text: "blocked", Blocked: true}, want: true}, {name: "explicit no tool", event: ResponsesImageEvent{Text: "denied", ToolInvoked: &toolFalse, TurnUseCase: "multimodal"}, want: true}, {name: "text use case", event: ResponsesImageEvent{Text: "plain text", ToolInvoked: &toolTrue, TurnUseCase: "text"}, want: true}, + {name: "non queued text without metadata", event: ResponsesImageEvent{Text: "你希望我画成什么风格?", ConversationID: "conv-1"}, want: true}, {name: "queued notice still pending", event: ResponsesImageEvent{Text: "正在处理图片", ToolInvoked: nil, TurnUseCase: ""}, want: false}, + {name: "english queued notice still pending", event: ResponsesImageEvent{Text: "I'm creating your image now.", ToolInvoked: nil, TurnUseCase: ""}, want: false}, {name: "image result present", event: ResponsesImageEvent{Text: "ignored", Result: "b64"}, want: false}, {name: "empty text", event: ResponsesImageEvent{Text: "", ToolInvoked: &toolFalse}, want: false}, } diff --git a/internal/backend/responses_image.go b/internal/backend/responses_image.go index 152357517..dfde7b698 100644 --- a/internal/backend/responses_image.go +++ b/internal/backend/responses_image.go @@ -45,7 +45,11 @@ const ( officialImageDownloadAttempts = 3 ) -var officialImageDownloadRetryDelay = 750 * time.Millisecond +var ( + officialImageDownloadRetryDelay = 750 * time.Millisecond + officialImagePollTimeout = 120 * time.Second + officialImagePollInterval = 4 * time.Second +) type ResponsesInputImage struct { Data []byte @@ -933,14 +937,15 @@ func iterOfficialImageSSE(ctx context.Context, client *Client, reader io.Reader, return ctx.Err() } } - if len(resolved) == 0 && strings.TrimSpace(lastEvent.Text) == "" { - return fmt.Errorf("image generation failed") + if len(resolved) == 0 { + return officialImageEmptyResultError(lastEvent) } return nil } func shouldTreatOfficialImageEventAsFinalText(event ResponsesImageEvent) bool { - if strings.TrimSpace(event.Text) == "" || event.Result != "" { + text := strings.TrimSpace(event.Text) + if text == "" || event.Result != "" { return false } if event.Blocked { @@ -949,7 +954,53 @@ func shouldTreatOfficialImageEventAsFinalText(event ResponsesImageEvent) bool { if strings.EqualFold(strings.TrimSpace(event.TurnUseCase), "text") { return true } - return event.ToolInvoked != nil && !*event.ToolInvoked + if event.ToolInvoked != nil { + return !*event.ToolInvoked + } + return !isOfficialImageQueuedText(text) +} + +func isOfficialImageQueuedText(text string) bool { + normalized := strings.ToLower(strings.TrimSpace(text)) + if normalized == "" { + return false + } + for _, token := range []string{ + "正在处理图片", + "创建图片", + "图片准备好后", + "生成图片", + "processing your image", + "creating your image", + "image is ready", + "your image is ready", + "generating your image", + } { + if strings.Contains(normalized, token) { + return true + } + } + return false +} + +func officialImageEmptyResultError(event ResponsesImageEvent) error { + text := strings.TrimSpace(event.Text) + if event.Blocked { + return fmt.Errorf("no image was generated; upstream moderation blocked the request") + } + if event.ToolInvoked != nil && !*event.ToolInvoked { + return fmt.Errorf("no image was generated; upstream did not invoke the image tool") + } + if conversationID := strings.TrimSpace(event.ConversationID); conversationID != "" { + if text != "" { + return fmt.Errorf("timed out waiting for async image generation results in conversation %s; last upstream message: %s", conversationID, text) + } + return fmt.Errorf("timed out waiting for async image generation results in conversation %s", conversationID) + } + if text != "" { + return fmt.Errorf("no image was generated; upstream returned text instead: %s", text) + } + return fmt.Errorf("no image was generated; upstream returned no image output") } func parseOfficialImagePayload(payload string, state *imageConversationState) (ResponsesImageEvent, bool, error) { @@ -976,7 +1027,7 @@ func parseOfficialImagePayload(payload string, state *imageConversationState) (R TurnUseCase: state.TurnUseCase, Raw: data, } - if message := officialImageTextMessage(data); message != "" && event.Result == "" { + if message := officialImageAssistantText(data); message != "" && event.Result == "" { event.Text = message } switch eventType { @@ -1122,7 +1173,7 @@ func (c *Client) resolveOfficialImageResults(ctx context.Context, request Respon fileIDs := filterOfficialImageIDs(event.FileIDs) sedimentIDs := filterOfficialImageIDs(event.SedimentIDs) if conversationID != "" && len(fileIDs) == 0 && len(sedimentIDs) == 0 { - polledFiles, polledSediments, err := c.pollOfficialImageResults(ctx, conversationID, 120*time.Second) + polledFiles, polledSediments, err := c.pollOfficialImageResults(ctx, conversationID, officialImagePollTimeout) if err != nil { return nil, err } @@ -1190,7 +1241,7 @@ func (c *Client) pollOfficialImageResults(ctx context.Context, conversationID st select { case <-ctx.Done(): return nil, nil, ctx.Err() - case <-time.After(4 * time.Second): + case <-time.After(officialImagePollInterval): } } return nil, nil, nil diff --git a/internal/httpapi/app.go b/internal/httpapi/app.go index a5793fafe..0aabd65a7 100644 --- a/internal/httpapi/app.go +++ b/internal/httpapi/app.go @@ -1329,6 +1329,20 @@ func (a *App) runLoggedImageTask(ctx context.Context, identity service.Identity, result, err := run(ctx, payload) urls := collectURLs(result) a.recordGeneratedImagesForPayload(identity, urls, util.Clean(payload["visibility"]), payload) + if imageTaskTextResponseError(err) { + if result == nil { + result = map[string]any{} + } + message := util.Clean(result["message"]) + if message == "" { + message = err.Error() + } + result["output_type"] = "text" + result["message"] = message + result["data"] = []map[string]any{{"text_response": message}} + a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "success", http.StatusOK, "", urls) + return result, nil + } if err != nil { a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), urls) return result, err @@ -1342,6 +1356,11 @@ func (a *App) runLoggedImageTask(ctx context.Context, identity service.Identity, return result, nil } +func imageTaskTextResponseError(err error) bool { + var imageErr *protocol.ImageGenerationError + return errors.As(err, &imageErr) && imageErr.Code == "image_generation_text_response" +} + func (a *App) attachCreationTaskLimiter(body map[string]any, identity service.Identity) { if a == nil || a.tasks == nil || body == nil { return diff --git a/internal/httpapi/app_test.go b/internal/httpapi/app_test.go index 2e15fc3ae..1134becd2 100644 --- a/internal/httpapi/app_test.go +++ b/internal/httpapi/app_test.go @@ -524,6 +524,44 @@ func TestRunLoggedImageTaskLogsTextOutputAsFailure(t *testing.T) { } } +func TestRunLoggedImageTaskReturnsOfficialTextResponseAsSuccess(t *testing.T) { + app := newTestApp(t) + defer app.Close() + + identity := service.Identity{ID: "admin", Role: service.AuthRoleAdmin, Name: "Admin"} + officialText := "我可以帮你生成一张基于你描述的场景的创意图像,但我需要确认一下细节:\n\n你希望的图片是两位虚构角色在温馨场景中互动,对吗?为了确保生成合适内容,我需要知道:\n\n这两位角色是公众人物、虚拟角色,还是你的原创角色?\n你希望风格是写实、漫画、插画、还是卡通?\n图片的场景或氛围是浪漫、搞笑、温馨、还是幻想?\n\n确认这些后我可以生成图片。" + result, err := app.runLoggedImageTask( + context.Background(), + identity, + map[string]any{"model": "gpt-image-2"}, + "/api/creation-tasks/image-generations", + "文生图", + func(context.Context, map[string]any) (map[string]any, error) { + return map[string]any{"output_type": "text", "message": officialText, "data": []map[string]any{}}, + &protocol.ImageGenerationError{Message: officialText, StatusCode: http.StatusBadRequest, Type: "invalid_request_error", Code: "image_generation_text_response"} + }, + ) + if err != nil { + t.Fatalf("runLoggedImageTask() error = %v", err) + } + if result["output_type"] != "text" { + t.Fatalf("output_type = %#v, want text in %#v", result["output_type"], result) + } + data := util.AsMapSlice(result["data"]) + if len(data) != 1 || data[0]["text_response"] != officialText { + t.Fatalf("text response data = %#v", result) + } + logs := app.logs.Search(service.LogQuery{Limit: 10}) + item := findLogBySummary(logs, "文生图调用完成") + if item == nil { + t.Fatalf("expected official text response to write success log, got %#v", logs) + } + detail := util.StringMap(item["detail"]) + if detail["outcome"] != "success" || util.ToInt(detail["status"], 0) != http.StatusOK { + t.Fatalf("success log detail = %#v", detail) + } +} + func TestDirectImageGenerationUsesCreationLimiter(t *testing.T) { t.Setenv("CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT", "2") app := newTestApp(t) diff --git a/internal/protocol/conversation.go b/internal/protocol/conversation.go index fde65c83d..9c9223a97 100644 --- a/internal/protocol/conversation.go +++ b/internal/protocol/conversation.go @@ -784,7 +784,8 @@ func firstNonZeroInt64(values ...int64) int64 { } func isFinalImageTextEvent(event backend.ResponsesImageEvent) bool { - if strings.TrimSpace(event.Text) == "" || event.Result != "" { + text := strings.TrimSpace(event.Text) + if text == "" || event.Result != "" { return false } if event.Blocked { @@ -793,7 +794,33 @@ func isFinalImageTextEvent(event backend.ResponsesImageEvent) bool { if strings.EqualFold(strings.TrimSpace(event.TurnUseCase), "text") { return true } - return event.ToolInvoked != nil && !*event.ToolInvoked + if event.ToolInvoked != nil { + return !*event.ToolInvoked + } + return !isImageQueuedText(text) +} + +func isImageQueuedText(text string) bool { + normalized := strings.ToLower(strings.TrimSpace(text)) + if normalized == "" { + return false + } + for _, token := range []string{ + "正在处理图片", + "创建图片", + "图片准备好后", + "生成图片", + "processing your image", + "creating your image", + "image is ready", + "your image is ready", + "generating your image", + } { + if strings.Contains(normalized, token) { + return true + } + } + return false } func (e *Engine) CollectImageOutputs(outputs <-chan ImageOutput, errCh <-chan error) (map[string]any, error) { diff --git a/internal/protocol/conversation_test.go b/internal/protocol/conversation_test.go index b336d50c8..c67d620d9 100644 --- a/internal/protocol/conversation_test.go +++ b/internal/protocol/conversation_test.go @@ -564,6 +564,39 @@ func TestStreamImageOutputsWithPoolReportsCodexUnauthorizedPermission(t *testing } } +func TestStreamResponsesImageOutputsTreatsUpstreamQuestionAsTextMessage(t *testing.T) { + engine := &Engine{ + ImageTokenProvider: func(context.Context) (string, error) { return "test-token", nil }, + ImageClientFactory: func(string) *backend.Client { return &backend.Client{} }, + } + asked := "你希望我把这张图画成什么风格?" + engine.StreamImageOutputsFunc = func(ctx context.Context, client *backend.Client, request ConversationRequest, index, total int) (<-chan ImageOutput, <-chan error) { + out := make(chan ImageOutput, 1) + errCh := make(chan error, 1) + out <- ImageOutput{Kind: "message", Model: request.Model, Index: index, Total: total, Created: 1, Text: asked} + close(out) + errCh <- nil + close(errCh) + return out, errCh + } + + outputs, errCh := engine.StreamImageOutputsWithPool(context.Background(), ConversationRequest{ + Model: "gpt-image-2", + N: 1, + MessageAsError: true, + }) + for range outputs { + } + err := <-errCh + imageErr, ok := err.(*ImageGenerationError) + if !ok { + t.Fatalf("err = %T %v, want ImageGenerationError", err, err) + } + if imageErr.Code != "image_generation_text_response" || imageErr.Message != asked { + t.Fatalf("imageErr = %#v, want text response error", imageErr) + } +} + func TestCollectImageOutputsKeepsImageOrderByIndex(t *testing.T) { outputs := make(chan ImageOutput, 2) errCh := make(chan error, 1) diff --git a/internal/service/image_task.go b/internal/service/image_task.go index a70c7c19d..64120521b 100644 --- a/internal/service/image_task.go +++ b/internal/service/image_task.go @@ -369,7 +369,11 @@ func (s *ImageTaskService) runTask(ctx context.Context, key, mode string, identi } else if runCtx.Err() == context.DeadlineExceeded { message = "图片生成超时,请稍后重试或降低分辨率" } - updates := map[string]any{"status": status, "error": message, "data": taskResultData(result)} + data := taskResultData(result) + updates := map[string]any{"status": status, "error": message, "data": data} + if mode == "generate" || mode == "edit" { + updates["output_statuses"] = terminalImageOutputStatuses(status, taskCount(mode, payload), data) + } if outputType := util.Clean(result["output_type"]); outputType != "" { updates["output_type"] = outputType } @@ -386,6 +390,9 @@ func (s *ImageTaskService) runTask(ctx context.Context, key, mode string, identi if len(data) == 0 { message := firstNonEmpty(util.Clean(result["message"]), "task returned no output data") updates := map[string]any{"status": TaskStatusError, "error": message, "data": []any{}} + if mode == "generate" || mode == "edit" { + updates["output_statuses"] = terminalImageOutputStatuses(TaskStatusError, taskCount(mode, payload), nil) + } if outputType != "" { updates["output_type"] = outputType } @@ -642,13 +649,13 @@ func (s *ImageTaskService) loadLocked() map[string]map[string]any { normalized["output_compression"] = compression } } - if data := util.AsMapSlice(task["data"]); data != nil { - normalized["data"] = data - } - if statuses := normalizedImageOutputStatuses(mode, count, task["output_statuses"]); len(statuses) > 0 { - normalized["output_statuses"] = statuses - } - if errText := util.Clean(task["error"]); errText != "" { + if data := util.AsMapSlice(task["data"]); data != nil { + normalized["data"] = data + } + if statuses := normalizedImageOutputStatuses(mode, count, task["output_statuses"]); len(statuses) > 0 { + normalized["output_statuses"] = statuses + } + if errText := util.Clean(task["error"]); errText != "" { normalized["error"] = errText } if outputType := util.Clean(task["output_type"]); outputType != "" { @@ -808,6 +815,22 @@ func initialImageOutputStatuses(count int) []string { return statuses } +func terminalImageOutputStatuses(status string, count int, data []map[string]any) []string { + statuses := initialImageOutputStatuses(count) + for index := range statuses { + if index < len(data) && hasImageTaskOutputData(data[index]) { + statuses[index] = TaskStatusSuccess + continue + } + if status == TaskStatusCancelled { + statuses[index] = TaskStatusCancelled + continue + } + statuses[index] = TaskStatusError + } + return statuses +} + func normalizedImageOutputStatuses(mode string, count int, value any) []string { if mode != "generate" && mode != "edit" { return nil @@ -821,7 +844,7 @@ func normalizedImageOutputStatuses(mode string, count int, value any) []string { status := "queued" if index < len(source) { switch source[index] { - case "queued", "running", "success": + case "queued", "running", "success", "error", "cancelled": status = source[index] } } diff --git a/internal/service/image_task_test.go b/internal/service/image_task_test.go index 312d1005f..79d7126d3 100644 --- a/internal/service/image_task_test.go +++ b/internal/service/image_task_test.go @@ -610,6 +610,30 @@ func TestImageTaskServicePreservesPartialDataOnFailure(t *testing.T) { if item["error"] != "second image failed" { t.Fatalf("partial failure error = %#v", item) } + statuses := util.AsStringSlice(item["output_statuses"]) + if strings.Join(statuses, ",") != "success,error" { + t.Fatalf("partial failure output_statuses = %#v, want success,error in %#v", statuses, item) + } +} + +func TestImageTaskServiceMarksOutputStatusesOnEmptyFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "image_tasks.json") + handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { + return nil, errors.New("no image was generated") + } + svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) + identity := Identity{ID: "alice", Name: "Alice", Role: "user"} + + if _, err := svc.SubmitGeneration(context.Background(), identity, "task-1", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 1, nil); err != nil { + t.Fatalf("SubmitGeneration() error = %v", err) + } + waitForTaskStatus(t, svc, identity, "task-1", TaskStatusError) + got := svc.ListTasks(identity, []string{"task-1"}) + item := got["items"].([]map[string]any)[0] + statuses := util.AsStringSlice(item["output_statuses"]) + if strings.Join(statuses, ",") != "error" { + t.Fatalf("output_statuses = %#v, want error in %#v", statuses, item) + } } func TestImageTaskServiceMarksTimedOutTaskAsError(t *testing.T) { diff --git a/internal/service/update_test.go b/internal/service/update_test.go index af4018128..2defec288 100644 --- a/internal/service/update_test.go +++ b/internal/service/update_test.go @@ -179,6 +179,15 @@ func TestServerSourceDockerBuildFilesStayUnderDeploy(t *testing.T) { if strings.Contains(dockerfile, "./cmd/chatgpt2api") || strings.Contains(dockerfile, "COPY cmd ") { t.Fatal("deploy/Dockerfile must not reference the retired cmd/chatgpt2api entrypoint") } + for _, want := range []string{ + "ARG BUILD_NODE_OPTIONS=--max-old-space-size=1024", + "ARG BUILD_GOMAXPROCS=2", + "ARG BUILD_GOMEMLIMIT=2GiB", + } { + if !strings.Contains(dockerfile, want) { + t.Fatalf("deploy/Dockerfile must keep safe direct-build default %q", want) + } + } scriptData, err := os.ReadFile(filepath.Join("..", "..", "deploy", "docker-build-limited.sh")) if err != nil { @@ -191,6 +200,23 @@ func TestServerSourceDockerBuildFilesStayUnderDeploy(t *testing.T) { if !strings.Contains(script, `-f "$repo_root/deploy/docker-compose.yml"`) { t.Fatal("docker-build-limited.sh must run deploy/docker-compose.yml") } + for _, want := range []string{ + `detect_cpu_count()`, + `detect_memory_mib()`, + `default_build_cpus=2`, + `default_build_memory=4g`, + `default_build_memory=3g`, + `default_buildkit_max_parallelism=1`, + `default_build_gomaxprocs=1`, + `build_cpus="${BUILD_CPUS:-$default_build_cpus}"`, + `buildkit_max_parallelism="${BUILDKIT_MAX_PARALLELISM:-$default_buildkit_max_parallelism}"`, + `export BUILD_GOMAXPROCS="${BUILD_GOMAXPROCS:-$default_build_gomaxprocs}"`, + `export BUILD_GOMEMLIMIT="${BUILD_GOMEMLIMIT:-$default_build_gomemlimit}"`, + } { + if !strings.Contains(script, want) { + t.Fatalf("docker-build-limited.sh must keep adaptive direct-run default %q", want) + } + } } func yamlListContains(config, value string) bool { diff --git a/web/src/app/image/page.tsx b/web/src/app/image/page.tsx index cad178639..40bee3d17 100644 --- a/web/src/app/image/page.tsx +++ b/web/src/app/image/page.tsx @@ -447,9 +447,18 @@ function updateStoredImage(image: StoredImage, updates: Partial): S return STORED_IMAGE_FIELDS.every((field) => image[field] === next[field]) ? image : next; } -function creationTaskImageStatus(task: CreationTask, dataIndex = 0): "queued" | "running" | "success" | undefined { +function creationTaskImageStatus( + task: CreationTask, + dataIndex = 0, +): "queued" | "running" | "success" | "error" | "cancelled" | undefined { const outputStatus = task.output_statuses?.[dataIndex]; - if (outputStatus === "queued" || outputStatus === "running" || outputStatus === "success") { + if ( + outputStatus === "queued" || + outputStatus === "running" || + outputStatus === "success" || + outputStatus === "error" || + outputStatus === "cancelled" + ) { return outputStatus; } if (task.status === "queued" || task.status === "running" || task.status === "success") { @@ -710,8 +719,20 @@ function formatCreationTaskErrorMessage(message: string) { if (normalized.includes("no images generated") && normalized.includes("model may have refused")) { return "没有生成图片,模型可能检测到敏感内容并拒绝了这次请求,请调整提示词后重试。"; } + if (normalized.includes("upstream moderation blocked the request")) { + return "没有生成图片,上游安全策略拦截了这次请求,请调整提示词后重试。"; + } + if (normalized.includes("upstream did not invoke the image tool")) { + return "没有生成图片,上游没有调用图片工具,可能是提示词被当作普通文本或被拒绝,请调整提示词后重试。"; + } + if (normalized.includes("upstream returned text instead")) { + return trimmed.replace(/^no image was generated; upstream returned text instead:\s*/i, ""); + } if (normalized.includes("timed out waiting for async image generation")) { - return "图片生成等待超时,建议稍后重试;如果使用 Codex 结构化高分辨率参数,可降低尺寸后再试。"; + return "图片生成已提交到上游,但等待结果超时,建议稍后重试;如果重复出现,请换账号或降低请求复杂度。"; + } + if (normalized.includes("upstream returned no image output")) { + return "上游没有返回图片结果,请稍后重试;如果重复出现,请检查账号能力、代理和提示词内容。"; } if (normalized.includes("no available image quota")) { return "当前没有可用的图片额度,请检查账号额度或稍后重试。"; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0d474d195..79b8a405f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -372,7 +372,7 @@ export type CreationTask = { created_at: string; updated_at: string; data?: CreationTaskData[]; - output_statuses?: ("queued" | "running" | "success")[]; + output_statuses?: ("queued" | "running" | "success" | "error" | "cancelled")[]; error?: string; output_type?: "text"; visibility?: ImageVisibility; diff --git a/web/src/store/image-conversations.ts b/web/src/store/image-conversations.ts index dcfdda47d..781666632 100644 --- a/web/src/store/image-conversations.ts +++ b/web/src/store/image-conversations.ts @@ -33,7 +33,7 @@ export type StoredImage = { id: string; taskId?: string; status?: "loading" | "success" | "error" | "cancelled" | "message"; - taskStatus?: "queued" | "running" | "success"; + taskStatus?: "queued" | "running" | "success" | "error" | "cancelled"; path?: string; visibility?: ImageVisibility; b64_json?: string; @@ -164,7 +164,11 @@ function normalizeStoredImage(image: StoredImage): StoredImage { const height = Number(image.height); const resolution = typeof image.resolution === "string" && image.resolution ? image.resolution : undefined; const taskStatus = - image.taskStatus === "queued" || image.taskStatus === "running" || image.taskStatus === "success" + image.taskStatus === "queued" || + image.taskStatus === "running" || + image.taskStatus === "success" || + image.taskStatus === "error" || + image.taskStatus === "cancelled" ? image.taskStatus : image.status === "loading" ? "queued" From 81397245b8aba622744bc0a21ab929e934a87a0b Mon Sep 17 00:00:00 2001 From: ZyphrZero <133507172+ZyphrZero@users.noreply.github.com> Date: Fri, 8 May 2026 17:45:41 +0800 Subject: [PATCH 09/76] revert(image): undo text response handling This intentionally rolls back the image-generation text-response handling added on the current branch while leaving the build resource detection changes intact. The reverted path converted upstream assistant text into successful creation-task output and expanded per-output terminal statuses. That behavior needs to be removed for now so the branch returns to the previous image task semantics. Constraint: User explicitly requested a rollback commit and asked the commit to state that it is revertive. Rejected: git revert 896f525 wholesale | that commit also contains build resource detection work that should remain. Confidence: high Scope-risk: narrow Directive: Reintroduce upstream text-response handling only after the desired API/UI contract is agreed and covered by tests. Tested: go test ./internal/httpapi ./internal/backend ./internal/protocol ./internal/service Tested: npm run build Tested: npm run lint Tested: go test ./... --- internal/backend/backend_test.go | 120 ------------------------- internal/backend/responses_image.go | 67 ++------------ internal/httpapi/app.go | 19 ---- internal/httpapi/app_test.go | 38 -------- internal/protocol/conversation.go | 31 +------ internal/protocol/conversation_test.go | 33 ------- internal/service/image_task.go | 41 ++------- internal/service/image_task_test.go | 24 ----- web/src/app/image/page.tsx | 27 +----- web/src/lib/api.ts | 2 +- web/src/store/image-conversations.ts | 8 +- 11 files changed, 25 insertions(+), 385 deletions(-) diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go index 839b9908d..a1be0cb16 100644 --- a/internal/backend/backend_test.go +++ b/internal/backend/backend_test.go @@ -40,17 +40,6 @@ func setOfficialImageDownloadRetryDelayForTest(delay time.Duration) func() { } } -func setOfficialImagePollTimingForTest(timeout, interval time.Duration) func() { - previousTimeout := officialImagePollTimeout - previousInterval := officialImagePollInterval - officialImagePollTimeout = timeout - officialImagePollInterval = interval - return func() { - officialImagePollTimeout = previousTimeout - officialImagePollInterval = previousInterval - } -} - func TestUpstreamHTTPErrorSummarizesCloudflareChallenge(t *testing.T) { err := upstreamHTTPError("bootstrap", 403, []byte(`Enable JavaScript and cookies to continue`)) got := err.Error() @@ -402,113 +391,6 @@ func TestStreamResponsesImageDoesNotTreatQueuedAssistantNoticeAsFinalText(t *tes } } -func TestStreamResponsesImageReportsAsyncResultTimeout(t *testing.T) { - resetPollTiming := setOfficialImagePollTimingForTest(20*time.Millisecond, time.Millisecond) - defer resetPollTiming() - - pollCount := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(``)) - case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImagePreparePath: - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImageStreamPath: - w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte("data: {\"type\":\"server_ste_metadata\",\"metadata\":{\"tool_invoked\":true,\"turn_use_case\":\"image gen\"},\"conversation_id\":\"conv-empty\"}\n\n")) - _, _ = w.Write([]byte("data: {\"type\":\"message_stream_complete\",\"conversation_id\":\"conv-empty\"}\n\n")) - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-empty": - pollCount++ - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"mapping":{}}`)) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - client := newTestBackendClient(server) - events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ - Prompt: "生成封面", - Model: "gpt-image-2", - }) - for range events { - } - err := <-errCh - if err == nil { - t.Fatal("StreamResponsesImage() error = nil, want async timeout error") - } - if pollCount == 0 { - t.Fatal("expected conversation polling before timeout") - } - if !strings.Contains(err.Error(), "timed out waiting for async image generation results in conversation conv-empty") { - t.Fatalf("error = %q, want async timeout context", err.Error()) - } -} - -func TestStreamResponsesImageIgnoresInputMessageTextAsFinalText(t *testing.T) { - resetPollTiming := setOfficialImagePollTimingForTest(20*time.Millisecond, time.Millisecond) - defer resetPollTiming() - - prompt := "生成两位虚构角色在温馨场景中互动的图像" - var pollCount int - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == http.MethodGet && r.URL.Path == "/": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(``)) - case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImagePreparePath: - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImageStreamPath: - w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte(`data: {"type":"input_message","message":{"author":{"role":"user"},"content":{"content_type":"text","parts":["` + prompt + `"]}},"conversation_id":"conv-input"}` + "\n\n")) - _, _ = w.Write([]byte(`data: {"type":"server_ste_metadata","metadata":{"tool_invoked":true,"turn_use_case":"image gen"},"conversation_id":"conv-input"}` + "\n\n")) - _, _ = w.Write([]byte(`data: {"type":"message_stream_complete","conversation_id":"conv-input"}` + "\n\n")) - case r.Method == http.MethodGet && r.URL.Path == "/backend-api/conversation/conv-input": - pollCount++ - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"mapping":{}}`)) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - client := newTestBackendClient(server) - events, errCh := client.StreamResponsesImage(context.Background(), ResponsesImageRequest{ - Prompt: prompt, - Model: "gpt-image-2", - }) - var texts []string - for event := range events { - if strings.TrimSpace(event.Text) != "" { - texts = append(texts, event.Text) - } - } - err := <-errCh - if err == nil { - t.Fatal("StreamResponsesImage() error = nil, want async timeout error") - } - if pollCount == 0 { - t.Fatal("expected polling after input message was ignored as final text") - } - if strings.Contains(err.Error(), prompt) { - t.Fatalf("error echoed user prompt: %q", err.Error()) - } - if len(texts) != 0 { - t.Fatalf("input message text leaked as response text: %#v", texts) - } -} - func TestResolveOfficialImageResultsRetriesTransientDownloadURL404(t *testing.T) { const png1x1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ioAAAAASUVORK5CYII=" imageBytes, err := base64.StdEncoding.DecodeString(png1x1) @@ -722,9 +604,7 @@ func TestShouldTreatOfficialImageEventAsFinalText(t *testing.T) { {name: "blocked text", event: ResponsesImageEvent{Text: "blocked", Blocked: true}, want: true}, {name: "explicit no tool", event: ResponsesImageEvent{Text: "denied", ToolInvoked: &toolFalse, TurnUseCase: "multimodal"}, want: true}, {name: "text use case", event: ResponsesImageEvent{Text: "plain text", ToolInvoked: &toolTrue, TurnUseCase: "text"}, want: true}, - {name: "non queued text without metadata", event: ResponsesImageEvent{Text: "你希望我画成什么风格?", ConversationID: "conv-1"}, want: true}, {name: "queued notice still pending", event: ResponsesImageEvent{Text: "正在处理图片", ToolInvoked: nil, TurnUseCase: ""}, want: false}, - {name: "english queued notice still pending", event: ResponsesImageEvent{Text: "I'm creating your image now.", ToolInvoked: nil, TurnUseCase: ""}, want: false}, {name: "image result present", event: ResponsesImageEvent{Text: "ignored", Result: "b64"}, want: false}, {name: "empty text", event: ResponsesImageEvent{Text: "", ToolInvoked: &toolFalse}, want: false}, } diff --git a/internal/backend/responses_image.go b/internal/backend/responses_image.go index dfde7b698..152357517 100644 --- a/internal/backend/responses_image.go +++ b/internal/backend/responses_image.go @@ -45,11 +45,7 @@ const ( officialImageDownloadAttempts = 3 ) -var ( - officialImageDownloadRetryDelay = 750 * time.Millisecond - officialImagePollTimeout = 120 * time.Second - officialImagePollInterval = 4 * time.Second -) +var officialImageDownloadRetryDelay = 750 * time.Millisecond type ResponsesInputImage struct { Data []byte @@ -937,15 +933,14 @@ func iterOfficialImageSSE(ctx context.Context, client *Client, reader io.Reader, return ctx.Err() } } - if len(resolved) == 0 { - return officialImageEmptyResultError(lastEvent) + if len(resolved) == 0 && strings.TrimSpace(lastEvent.Text) == "" { + return fmt.Errorf("image generation failed") } return nil } func shouldTreatOfficialImageEventAsFinalText(event ResponsesImageEvent) bool { - text := strings.TrimSpace(event.Text) - if text == "" || event.Result != "" { + if strings.TrimSpace(event.Text) == "" || event.Result != "" { return false } if event.Blocked { @@ -954,53 +949,7 @@ func shouldTreatOfficialImageEventAsFinalText(event ResponsesImageEvent) bool { if strings.EqualFold(strings.TrimSpace(event.TurnUseCase), "text") { return true } - if event.ToolInvoked != nil { - return !*event.ToolInvoked - } - return !isOfficialImageQueuedText(text) -} - -func isOfficialImageQueuedText(text string) bool { - normalized := strings.ToLower(strings.TrimSpace(text)) - if normalized == "" { - return false - } - for _, token := range []string{ - "正在处理图片", - "创建图片", - "图片准备好后", - "生成图片", - "processing your image", - "creating your image", - "image is ready", - "your image is ready", - "generating your image", - } { - if strings.Contains(normalized, token) { - return true - } - } - return false -} - -func officialImageEmptyResultError(event ResponsesImageEvent) error { - text := strings.TrimSpace(event.Text) - if event.Blocked { - return fmt.Errorf("no image was generated; upstream moderation blocked the request") - } - if event.ToolInvoked != nil && !*event.ToolInvoked { - return fmt.Errorf("no image was generated; upstream did not invoke the image tool") - } - if conversationID := strings.TrimSpace(event.ConversationID); conversationID != "" { - if text != "" { - return fmt.Errorf("timed out waiting for async image generation results in conversation %s; last upstream message: %s", conversationID, text) - } - return fmt.Errorf("timed out waiting for async image generation results in conversation %s", conversationID) - } - if text != "" { - return fmt.Errorf("no image was generated; upstream returned text instead: %s", text) - } - return fmt.Errorf("no image was generated; upstream returned no image output") + return event.ToolInvoked != nil && !*event.ToolInvoked } func parseOfficialImagePayload(payload string, state *imageConversationState) (ResponsesImageEvent, bool, error) { @@ -1027,7 +976,7 @@ func parseOfficialImagePayload(payload string, state *imageConversationState) (R TurnUseCase: state.TurnUseCase, Raw: data, } - if message := officialImageAssistantText(data); message != "" && event.Result == "" { + if message := officialImageTextMessage(data); message != "" && event.Result == "" { event.Text = message } switch eventType { @@ -1173,7 +1122,7 @@ func (c *Client) resolveOfficialImageResults(ctx context.Context, request Respon fileIDs := filterOfficialImageIDs(event.FileIDs) sedimentIDs := filterOfficialImageIDs(event.SedimentIDs) if conversationID != "" && len(fileIDs) == 0 && len(sedimentIDs) == 0 { - polledFiles, polledSediments, err := c.pollOfficialImageResults(ctx, conversationID, officialImagePollTimeout) + polledFiles, polledSediments, err := c.pollOfficialImageResults(ctx, conversationID, 120*time.Second) if err != nil { return nil, err } @@ -1241,7 +1190,7 @@ func (c *Client) pollOfficialImageResults(ctx context.Context, conversationID st select { case <-ctx.Done(): return nil, nil, ctx.Err() - case <-time.After(officialImagePollInterval): + case <-time.After(4 * time.Second): } } return nil, nil, nil diff --git a/internal/httpapi/app.go b/internal/httpapi/app.go index 0aabd65a7..a5793fafe 100644 --- a/internal/httpapi/app.go +++ b/internal/httpapi/app.go @@ -1329,20 +1329,6 @@ func (a *App) runLoggedImageTask(ctx context.Context, identity service.Identity, result, err := run(ctx, payload) urls := collectURLs(result) a.recordGeneratedImagesForPayload(identity, urls, util.Clean(payload["visibility"]), payload) - if imageTaskTextResponseError(err) { - if result == nil { - result = map[string]any{} - } - message := util.Clean(result["message"]) - if message == "" { - message = err.Error() - } - result["output_type"] = "text" - result["message"] = message - result["data"] = []map[string]any{{"text_response": message}} - a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "success", http.StatusOK, "", urls) - return result, nil - } if err != nil { a.logCall(identity, summary, http.MethodPost, endpoint, model, start, "failed", protocolErrorHTTPStatus(err), err.Error(), urls) return result, err @@ -1356,11 +1342,6 @@ func (a *App) runLoggedImageTask(ctx context.Context, identity service.Identity, return result, nil } -func imageTaskTextResponseError(err error) bool { - var imageErr *protocol.ImageGenerationError - return errors.As(err, &imageErr) && imageErr.Code == "image_generation_text_response" -} - func (a *App) attachCreationTaskLimiter(body map[string]any, identity service.Identity) { if a == nil || a.tasks == nil || body == nil { return diff --git a/internal/httpapi/app_test.go b/internal/httpapi/app_test.go index 1134becd2..2e15fc3ae 100644 --- a/internal/httpapi/app_test.go +++ b/internal/httpapi/app_test.go @@ -524,44 +524,6 @@ func TestRunLoggedImageTaskLogsTextOutputAsFailure(t *testing.T) { } } -func TestRunLoggedImageTaskReturnsOfficialTextResponseAsSuccess(t *testing.T) { - app := newTestApp(t) - defer app.Close() - - identity := service.Identity{ID: "admin", Role: service.AuthRoleAdmin, Name: "Admin"} - officialText := "我可以帮你生成一张基于你描述的场景的创意图像,但我需要确认一下细节:\n\n你希望的图片是两位虚构角色在温馨场景中互动,对吗?为了确保生成合适内容,我需要知道:\n\n这两位角色是公众人物、虚拟角色,还是你的原创角色?\n你希望风格是写实、漫画、插画、还是卡通?\n图片的场景或氛围是浪漫、搞笑、温馨、还是幻想?\n\n确认这些后我可以生成图片。" - result, err := app.runLoggedImageTask( - context.Background(), - identity, - map[string]any{"model": "gpt-image-2"}, - "/api/creation-tasks/image-generations", - "文生图", - func(context.Context, map[string]any) (map[string]any, error) { - return map[string]any{"output_type": "text", "message": officialText, "data": []map[string]any{}}, - &protocol.ImageGenerationError{Message: officialText, StatusCode: http.StatusBadRequest, Type: "invalid_request_error", Code: "image_generation_text_response"} - }, - ) - if err != nil { - t.Fatalf("runLoggedImageTask() error = %v", err) - } - if result["output_type"] != "text" { - t.Fatalf("output_type = %#v, want text in %#v", result["output_type"], result) - } - data := util.AsMapSlice(result["data"]) - if len(data) != 1 || data[0]["text_response"] != officialText { - t.Fatalf("text response data = %#v", result) - } - logs := app.logs.Search(service.LogQuery{Limit: 10}) - item := findLogBySummary(logs, "文生图调用完成") - if item == nil { - t.Fatalf("expected official text response to write success log, got %#v", logs) - } - detail := util.StringMap(item["detail"]) - if detail["outcome"] != "success" || util.ToInt(detail["status"], 0) != http.StatusOK { - t.Fatalf("success log detail = %#v", detail) - } -} - func TestDirectImageGenerationUsesCreationLimiter(t *testing.T) { t.Setenv("CHATGPT2API_USER_DEFAULT_CONCURRENT_LIMIT", "2") app := newTestApp(t) diff --git a/internal/protocol/conversation.go b/internal/protocol/conversation.go index 9c9223a97..fde65c83d 100644 --- a/internal/protocol/conversation.go +++ b/internal/protocol/conversation.go @@ -784,8 +784,7 @@ func firstNonZeroInt64(values ...int64) int64 { } func isFinalImageTextEvent(event backend.ResponsesImageEvent) bool { - text := strings.TrimSpace(event.Text) - if text == "" || event.Result != "" { + if strings.TrimSpace(event.Text) == "" || event.Result != "" { return false } if event.Blocked { @@ -794,33 +793,7 @@ func isFinalImageTextEvent(event backend.ResponsesImageEvent) bool { if strings.EqualFold(strings.TrimSpace(event.TurnUseCase), "text") { return true } - if event.ToolInvoked != nil { - return !*event.ToolInvoked - } - return !isImageQueuedText(text) -} - -func isImageQueuedText(text string) bool { - normalized := strings.ToLower(strings.TrimSpace(text)) - if normalized == "" { - return false - } - for _, token := range []string{ - "正在处理图片", - "创建图片", - "图片准备好后", - "生成图片", - "processing your image", - "creating your image", - "image is ready", - "your image is ready", - "generating your image", - } { - if strings.Contains(normalized, token) { - return true - } - } - return false + return event.ToolInvoked != nil && !*event.ToolInvoked } func (e *Engine) CollectImageOutputs(outputs <-chan ImageOutput, errCh <-chan error) (map[string]any, error) { diff --git a/internal/protocol/conversation_test.go b/internal/protocol/conversation_test.go index c67d620d9..b336d50c8 100644 --- a/internal/protocol/conversation_test.go +++ b/internal/protocol/conversation_test.go @@ -564,39 +564,6 @@ func TestStreamImageOutputsWithPoolReportsCodexUnauthorizedPermission(t *testing } } -func TestStreamResponsesImageOutputsTreatsUpstreamQuestionAsTextMessage(t *testing.T) { - engine := &Engine{ - ImageTokenProvider: func(context.Context) (string, error) { return "test-token", nil }, - ImageClientFactory: func(string) *backend.Client { return &backend.Client{} }, - } - asked := "你希望我把这张图画成什么风格?" - engine.StreamImageOutputsFunc = func(ctx context.Context, client *backend.Client, request ConversationRequest, index, total int) (<-chan ImageOutput, <-chan error) { - out := make(chan ImageOutput, 1) - errCh := make(chan error, 1) - out <- ImageOutput{Kind: "message", Model: request.Model, Index: index, Total: total, Created: 1, Text: asked} - close(out) - errCh <- nil - close(errCh) - return out, errCh - } - - outputs, errCh := engine.StreamImageOutputsWithPool(context.Background(), ConversationRequest{ - Model: "gpt-image-2", - N: 1, - MessageAsError: true, - }) - for range outputs { - } - err := <-errCh - imageErr, ok := err.(*ImageGenerationError) - if !ok { - t.Fatalf("err = %T %v, want ImageGenerationError", err, err) - } - if imageErr.Code != "image_generation_text_response" || imageErr.Message != asked { - t.Fatalf("imageErr = %#v, want text response error", imageErr) - } -} - func TestCollectImageOutputsKeepsImageOrderByIndex(t *testing.T) { outputs := make(chan ImageOutput, 2) errCh := make(chan error, 1) diff --git a/internal/service/image_task.go b/internal/service/image_task.go index 64120521b..a70c7c19d 100644 --- a/internal/service/image_task.go +++ b/internal/service/image_task.go @@ -369,11 +369,7 @@ func (s *ImageTaskService) runTask(ctx context.Context, key, mode string, identi } else if runCtx.Err() == context.DeadlineExceeded { message = "图片生成超时,请稍后重试或降低分辨率" } - data := taskResultData(result) - updates := map[string]any{"status": status, "error": message, "data": data} - if mode == "generate" || mode == "edit" { - updates["output_statuses"] = terminalImageOutputStatuses(status, taskCount(mode, payload), data) - } + updates := map[string]any{"status": status, "error": message, "data": taskResultData(result)} if outputType := util.Clean(result["output_type"]); outputType != "" { updates["output_type"] = outputType } @@ -390,9 +386,6 @@ func (s *ImageTaskService) runTask(ctx context.Context, key, mode string, identi if len(data) == 0 { message := firstNonEmpty(util.Clean(result["message"]), "task returned no output data") updates := map[string]any{"status": TaskStatusError, "error": message, "data": []any{}} - if mode == "generate" || mode == "edit" { - updates["output_statuses"] = terminalImageOutputStatuses(TaskStatusError, taskCount(mode, payload), nil) - } if outputType != "" { updates["output_type"] = outputType } @@ -649,13 +642,13 @@ func (s *ImageTaskService) loadLocked() map[string]map[string]any { normalized["output_compression"] = compression } } - if data := util.AsMapSlice(task["data"]); data != nil { - normalized["data"] = data - } - if statuses := normalizedImageOutputStatuses(mode, count, task["output_statuses"]); len(statuses) > 0 { - normalized["output_statuses"] = statuses - } - if errText := util.Clean(task["error"]); errText != "" { + if data := util.AsMapSlice(task["data"]); data != nil { + normalized["data"] = data + } + if statuses := normalizedImageOutputStatuses(mode, count, task["output_statuses"]); len(statuses) > 0 { + normalized["output_statuses"] = statuses + } + if errText := util.Clean(task["error"]); errText != "" { normalized["error"] = errText } if outputType := util.Clean(task["output_type"]); outputType != "" { @@ -815,22 +808,6 @@ func initialImageOutputStatuses(count int) []string { return statuses } -func terminalImageOutputStatuses(status string, count int, data []map[string]any) []string { - statuses := initialImageOutputStatuses(count) - for index := range statuses { - if index < len(data) && hasImageTaskOutputData(data[index]) { - statuses[index] = TaskStatusSuccess - continue - } - if status == TaskStatusCancelled { - statuses[index] = TaskStatusCancelled - continue - } - statuses[index] = TaskStatusError - } - return statuses -} - func normalizedImageOutputStatuses(mode string, count int, value any) []string { if mode != "generate" && mode != "edit" { return nil @@ -844,7 +821,7 @@ func normalizedImageOutputStatuses(mode string, count int, value any) []string { status := "queued" if index < len(source) { switch source[index] { - case "queued", "running", "success", "error", "cancelled": + case "queued", "running", "success": status = source[index] } } diff --git a/internal/service/image_task_test.go b/internal/service/image_task_test.go index 79d7126d3..312d1005f 100644 --- a/internal/service/image_task_test.go +++ b/internal/service/image_task_test.go @@ -610,30 +610,6 @@ func TestImageTaskServicePreservesPartialDataOnFailure(t *testing.T) { if item["error"] != "second image failed" { t.Fatalf("partial failure error = %#v", item) } - statuses := util.AsStringSlice(item["output_statuses"]) - if strings.Join(statuses, ",") != "success,error" { - t.Fatalf("partial failure output_statuses = %#v, want success,error in %#v", statuses, item) - } -} - -func TestImageTaskServiceMarksOutputStatusesOnEmptyFailure(t *testing.T) { - path := filepath.Join(t.TempDir(), "image_tasks.json") - handler := func(ctx context.Context, identity Identity, payload map[string]any) (map[string]any, error) { - return nil, errors.New("no image was generated") - } - svc := NewImageTaskService(path, handler, handler, handler, func() int { return 30 }) - identity := Identity{ID: "alice", Name: "Alice", Role: "user"} - - if _, err := svc.SubmitGeneration(context.Background(), identity, "task-1", "draw", "gpt-image-2", "1024x1024", "high", "https://base.test", 1, nil); err != nil { - t.Fatalf("SubmitGeneration() error = %v", err) - } - waitForTaskStatus(t, svc, identity, "task-1", TaskStatusError) - got := svc.ListTasks(identity, []string{"task-1"}) - item := got["items"].([]map[string]any)[0] - statuses := util.AsStringSlice(item["output_statuses"]) - if strings.Join(statuses, ",") != "error" { - t.Fatalf("output_statuses = %#v, want error in %#v", statuses, item) - } } func TestImageTaskServiceMarksTimedOutTaskAsError(t *testing.T) { diff --git a/web/src/app/image/page.tsx b/web/src/app/image/page.tsx index 40bee3d17..cad178639 100644 --- a/web/src/app/image/page.tsx +++ b/web/src/app/image/page.tsx @@ -447,18 +447,9 @@ function updateStoredImage(image: StoredImage, updates: Partial): S return STORED_IMAGE_FIELDS.every((field) => image[field] === next[field]) ? image : next; } -function creationTaskImageStatus( - task: CreationTask, - dataIndex = 0, -): "queued" | "running" | "success" | "error" | "cancelled" | undefined { +function creationTaskImageStatus(task: CreationTask, dataIndex = 0): "queued" | "running" | "success" | undefined { const outputStatus = task.output_statuses?.[dataIndex]; - if ( - outputStatus === "queued" || - outputStatus === "running" || - outputStatus === "success" || - outputStatus === "error" || - outputStatus === "cancelled" - ) { + if (outputStatus === "queued" || outputStatus === "running" || outputStatus === "success") { return outputStatus; } if (task.status === "queued" || task.status === "running" || task.status === "success") { @@ -719,20 +710,8 @@ function formatCreationTaskErrorMessage(message: string) { if (normalized.includes("no images generated") && normalized.includes("model may have refused")) { return "没有生成图片,模型可能检测到敏感内容并拒绝了这次请求,请调整提示词后重试。"; } - if (normalized.includes("upstream moderation blocked the request")) { - return "没有生成图片,上游安全策略拦截了这次请求,请调整提示词后重试。"; - } - if (normalized.includes("upstream did not invoke the image tool")) { - return "没有生成图片,上游没有调用图片工具,可能是提示词被当作普通文本或被拒绝,请调整提示词后重试。"; - } - if (normalized.includes("upstream returned text instead")) { - return trimmed.replace(/^no image was generated; upstream returned text instead:\s*/i, ""); - } if (normalized.includes("timed out waiting for async image generation")) { - return "图片生成已提交到上游,但等待结果超时,建议稍后重试;如果重复出现,请换账号或降低请求复杂度。"; - } - if (normalized.includes("upstream returned no image output")) { - return "上游没有返回图片结果,请稍后重试;如果重复出现,请检查账号能力、代理和提示词内容。"; + return "图片生成等待超时,建议稍后重试;如果使用 Codex 结构化高分辨率参数,可降低尺寸后再试。"; } if (normalized.includes("no available image quota")) { return "当前没有可用的图片额度,请检查账号额度或稍后重试。"; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 79b8a405f..0d474d195 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -372,7 +372,7 @@ export type CreationTask = { created_at: string; updated_at: string; data?: CreationTaskData[]; - output_statuses?: ("queued" | "running" | "success" | "error" | "cancelled")[]; + output_statuses?: ("queued" | "running" | "success")[]; error?: string; output_type?: "text"; visibility?: ImageVisibility; diff --git a/web/src/store/image-conversations.ts b/web/src/store/image-conversations.ts index 781666632..dcfdda47d 100644 --- a/web/src/store/image-conversations.ts +++ b/web/src/store/image-conversations.ts @@ -33,7 +33,7 @@ export type StoredImage = { id: string; taskId?: string; status?: "loading" | "success" | "error" | "cancelled" | "message"; - taskStatus?: "queued" | "running" | "success" | "error" | "cancelled"; + taskStatus?: "queued" | "running" | "success"; path?: string; visibility?: ImageVisibility; b64_json?: string; @@ -164,11 +164,7 @@ function normalizeStoredImage(image: StoredImage): StoredImage { const height = Number(image.height); const resolution = typeof image.resolution === "string" && image.resolution ? image.resolution : undefined; const taskStatus = - image.taskStatus === "queued" || - image.taskStatus === "running" || - image.taskStatus === "success" || - image.taskStatus === "error" || - image.taskStatus === "cancelled" + image.taskStatus === "queued" || image.taskStatus === "running" || image.taskStatus === "success" ? image.taskStatus : image.status === "loading" ? "queued" From 5e7b1eea4548afc58c8ffa81ef56d00434f86cf7 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sat, 9 May 2026 11:15:40 +0800 Subject: [PATCH 10/76] feat: align text chat path to official prepare+conduit flow - Rename shared symbols in responses_image.go for reuse (officialPreparePath, officialStreamPath, officialHeaders) - Add textModelSlug, prepareTextConversation, startTextConversation to backend.go - Modify StreamConversation to use prepare+conduit flow for auth users, fallback to old endpoint on failure - Anonymous users keep old /backend-anon/conversation unchanged --- internal/backend/backend.go | 107 ++++++++++++++++++++++++++++ internal/backend/backend_test.go | 12 ++-- internal/backend/responses_image.go | 15 ++-- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index b6cb935fd..7f75a5f98 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -147,6 +147,19 @@ func (c *Client) StreamConversation(ctx context.Context, messages []map[string]a errCh <- err return } + if c.AccessToken != "" { + conduitToken, prepareErr := c.prepareTextConversation(ctx, messages, reqs, model) + if prepareErr == nil { + resp, startErr := c.startTextConversation(ctx, messages, reqs, conduitToken, model) + if startErr == nil { + defer resp.Body.Close() + if ensureOK(resp, officialStreamPath) == nil { + errCh <- iterSSEPayloads(ctx, resp.Body, out) + return + } + } + } + } path, timezoneName := c.chatTarget() payload := c.conversationPayload(messages, model, timezoneName) resp, err := c.postJSON(ctx, path, payload, c.conversationHeaders(path, reqs), true) @@ -430,6 +443,100 @@ func (c *Client) chatTarget() (string, string) { return "/backend-anon/conversation", "America/Los_Angeles" } +func textModelSlug(model string) string { + switch strings.TrimSpace(model) { + case "auto", "": + return "auto" + default: + return strings.TrimSpace(model) + } +} + +func (c *Client) prepareTextConversation(ctx context.Context, messages []map[string]any, reqs ChatRequirements, model string) (string, error) { + prompt := conversationPrompt(messages) + payload := map[string]any{ + "action": "next", + "fork_from_shared_post": false, + "parent_message_id": util.NewUUID(), + "model": textModelSlug(model), + "client_prepare_state": "success", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "system_hints": []any{}, + "partial_query": map[string]any{ + "id": util.NewUUID(), + "author": map[string]any{"role": "user"}, + "content": map[string]any{"content_type": "text", "parts": []any{prompt}}, + }, + "supports_buffering": true, + "supported_encodings": []any{"v1"}, + "client_contextual_info": map[string]any{ + "app_name": "chatgpt.com", + }, + } + resp, err := c.postJSON(ctx, officialPreparePath, payload, c.officialHeaders(officialPreparePath, reqs, "", "*/*"), false) + if err != nil { + return "", err + } + defer resp.Body.Close() + if err := ensureOK(resp, officialPreparePath); err != nil { + return "", err + } + var data map[string]any + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + return util.Clean(data["conduit_token"]), nil +} + +func (c *Client) startTextConversation(ctx context.Context, messages []map[string]any, reqs ChatRequirements, conduitToken, model string) (*http.Response, error) { + prompt := conversationPrompt(messages) + payload := map[string]any{ + "action": "next", + "messages": []any{ + map[string]any{ + "id": util.NewUUID(), + "author": map[string]any{"role": "user"}, + "create_time": float64(time.Now().UnixNano()) / 1e9, + "content": map[string]any{ + "content_type": "text", + "parts": []any{prompt}, + }, + "metadata": map[string]any{ + "developer_mode_connector_ids": []any{}, + "selected_github_repos": []any{}, + "selected_all_github_repos": false, + "serialization_metadata": map[string]any{"custom_symbol_offsets": []any{}}, + }, + }, + }, + "parent_message_id": util.NewUUID(), + "model": textModelSlug(model), + "client_prepare_state": "sent", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "enable_message_followups": true, + "system_hints": []any{}, + "supports_buffering": true, + "supported_encodings": []any{"v1"}, + "paragen_cot_summary_display_override": "allow", + "force_parallel_switch": "auto", + "client_contextual_info": map[string]any{ + "is_dark_mode": false, + "time_since_loaded": 1200, + "page_height": 1072, + "page_width": 1724, + "pixel_ratio": 1.2, + "screen_height": 1440, + "screen_width": 2560, + "app_name": "chatgpt.com", + }, + } + return c.postJSON(ctx, officialStreamPath, payload, c.officialHeaders(officialStreamPath, reqs, conduitToken, "text/event-stream"), true) +} + func (c *Client) conversationPayload(messages []map[string]any, model, timezoneName string) map[string]any { conversationMessages := []map[string]any{conversationUserMessage(conversationPrompt(messages))} return map[string]any{ diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go index a1be0cb16..779938457 100644 --- a/internal/backend/backend_test.go +++ b/internal/backend/backend_test.go @@ -123,7 +123,7 @@ func TestOfficialImageHeadersIncludeSentinelAndConduitTokens(t *testing.T) { "sec-ch-ua-platform-version": browserSecCHUAPlatformVersion, }, } - headers := client.officialImageHeaders(officialImageStreamPath, ChatRequirements{ + headers := client.officialHeaders(officialStreamPath, ChatRequirements{ Token: "req-token", ProofToken: "proof-token", TurnstileToken: "turn-token", @@ -138,7 +138,7 @@ func TestOfficialImageHeadersIncludeSentinelAndConduitTokens(t *testing.T) { "X-Conduit-Token": "conduit-token", "Accept": "text/event-stream", "Content-Type": "application/json", - "X-OpenAI-Target-Path": officialImageStreamPath, + "X-OpenAI-Target-Path": officialStreamPath, } { if got := headers[key]; got != want { t.Fatalf("headers[%s] = %q, want %q", key, got, want) @@ -197,7 +197,7 @@ func TestStreamResponsesImageUsesOfficialPrepareAndConversationRoutes(t *testing case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImagePreparePath: + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: if err := json.NewDecoder(r.Body).Decode(&prepareBody); err != nil { t.Fatalf("decode prepare body: %v", err) } @@ -206,7 +206,7 @@ func TestStreamResponsesImageUsesOfficialPrepareAndConversationRoutes(t *testing } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImageStreamPath: + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: if err := json.NewDecoder(r.Body).Decode(&streamBody); err != nil { t.Fatalf("decode stream body: %v", err) } @@ -322,10 +322,10 @@ func TestStreamResponsesImageDoesNotTreatQueuedAssistantNoticeAsFinalText(t *tes case r.Method == http.MethodPost && r.URL.Path == "/backend-api/sentinel/chat-requirements": w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"token":"req-token","proofofwork":{"required":false},"turnstile":{"required":false},"arkose":{"required":false}}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImagePreparePath: + case r.Method == http.MethodPost && r.URL.Path == officialPreparePath: w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"conduit_token":"conduit-token"}`)) - case r.Method == http.MethodPost && r.URL.Path == officialImageStreamPath: + case r.Method == http.MethodPost && r.URL.Path == officialStreamPath: w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte("data: {\"type\":\"title_generation\",\"title\":\"正在处理图片\",\"conversation_id\":\"conv-queued\"}\n\n")) _, _ = w.Write([]byte("data: {\"v\":{\"message\":{\"author\":{\"role\":\"assistant\"},\"content\":{\"content_type\":\"text\",\"parts\":[\"正在处理图片 目前有很多人在创建图片,因此可能需要一点时间。图片准备好后我们会通知你。\"]}}},\"conversation_id\":\"conv-queued\"}\n\n")) diff --git a/internal/backend/responses_image.go b/internal/backend/responses_image.go index 152357517..a91feb64a 100644 --- a/internal/backend/responses_image.go +++ b/internal/backend/responses_image.go @@ -25,8 +25,9 @@ import ( ) const ( - officialImagePreparePath = "/backend-api/f/conversation/prepare" - officialImageStreamPath = "/backend-api/f/conversation" + officialPreparePath = "/backend-api/f/conversation/prepare" + officialStreamPath = "/backend-api/f/conversation" + ResponsesImageMainModel = "gpt-5.4-mini" ResponsesImageCodexToolModel = "gpt-5.4-mini" @@ -178,7 +179,7 @@ func (c *Client) streamOfficialResponsesImage(ctx context.Context, request Respo defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { data, _ := io.ReadAll(resp.Body) - return upstreamHTTPError(officialImageStreamPath, resp.StatusCode, data) + return upstreamHTTPError(officialStreamPath, resp.StatusCode, data) } return iterOfficialImageSSE(ctx, c, resp.Body, request, out) } @@ -642,7 +643,7 @@ func parseOfficialImageDimensions(value string) (int, int, bool) { return width, height, true } -func (c *Client) officialImageHeaders(path string, reqs ChatRequirements, conduitToken, accept string) map[string]string { +func (c *Client) officialHeaders(path string, reqs ChatRequirements, conduitToken, accept string) map[string]string { extra := map[string]string{ "Content-Type": "application/json", "Accept": accept, @@ -688,12 +689,12 @@ func (c *Client) prepareOfficialImageConversation(ctx context.Context, prompt st "app_name": "chatgpt.com", }, } - resp, err := c.postJSON(ctx, officialImagePreparePath, payload, c.officialImageHeaders(officialImagePreparePath, reqs, "", "*/*"), false) + resp, err := c.postJSON(ctx, officialPreparePath, payload, c.officialHeaders(officialPreparePath, reqs, "", "*/*"), false) if err != nil { return "", err } defer resp.Body.Close() - if err := ensureOK(resp, officialImagePreparePath); err != nil { + if err := ensureOK(resp, officialPreparePath); err != nil { return "", err } var data map[string]any @@ -881,7 +882,7 @@ func (c *Client) startOfficialImageConversation(ctx context.Context, prompt stri "app_name": "chatgpt.com", }, } - return c.postJSON(ctx, officialImageStreamPath, payload, c.officialImageHeaders(officialImageStreamPath, reqs, conduitToken, "text/event-stream"), true) + return c.postJSON(ctx, officialStreamPath, payload, c.officialHeaders(officialStreamPath, reqs, conduitToken, "text/event-stream"), true) } func iterOfficialImageSSE(ctx context.Context, client *Client, reader io.Reader, request ResponsesImageRequest, out chan<- ResponsesImageEvent) error { From 9cafab9396d6a17a97adb5c1d1fd89ed385755a8 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sat, 9 May 2026 11:22:48 +0800 Subject: [PATCH 11/76] ci: add dev branch to push triggers --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43f8cc0c3..1fb8d347f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - dev pull_request: permissions: From a277357d16d7f80f86d7e4d877e98a5f0ecc8e18 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sat, 9 May 2026 14:07:01 +0800 Subject: [PATCH 12/76] feat: add multimodal vision support and CI Docker build for dev Support image upload for LLM visual understanding through official ChatGPT API prepare+conduit flow. Build and push Docker image to ghcr.io on dev branch pushes. --- .github/workflows/ci.yml | 42 +++++++ internal/backend/backend.go | 183 ++++++++++++++++++++++++++++++ internal/protocol/api.go | 104 +++++++++++++++++ internal/protocol/conversation.go | 9 ++ 4 files changed, 338 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fb8d347f..1a4b21bd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,3 +59,45 @@ jobs: CHATGPT2API_ENV_FILE="$PWD/.env.example" \ CHATGPT2API_DATA_DIR="$PWD/data" \ docker compose -f deploy/docker-compose.yml config >/dev/null + + build-docker-dev: + needs: [test] + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + run: | + OWNER_LOWER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]') + echo "tags=ghcr.io/${OWNER_LOWER}/${{ github.event.repository.name }}:dev" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: deploy/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + build-args: | + VERSION=dev + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 7f75a5f98..6ebac8270 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -537,6 +537,189 @@ func (c *Client) startTextConversation(ctx context.Context, messages []map[strin return c.postJSON(ctx, officialStreamPath, payload, c.officialHeaders(officialStreamPath, reqs, conduitToken, "text/event-stream"), true) } +// VisionImage represents an image to be uploaded for multimodal vision understanding. +type VisionImage struct { + Data []byte + ContentType string + FileName string +} + +func (c *Client) uploadVisionImages(ctx context.Context, images []VisionImage) ([]uploadedImageRef, error) { + refs := make([]uploadedImageRef, 0, len(images)) + for i, img := range images { + fileName := img.FileName + if fileName == "" { + fileName = fmt.Sprintf("image_%d.png", i) + } + ref, err := c.uploadImage(ctx, ResponsesInputImage{Data: img.Data, ContentType: img.ContentType}, fileName) + if err != nil { + return nil, err + } + refs = append(refs, ref) + } + return refs, nil +} + +func buildVisionParts(prompt string, refs []uploadedImageRef) []any { + parts := []any{prompt} + for _, ref := range refs { + parts = append(parts, map[string]any{ + "content_type": "image_asset_pointer", + "asset_pointer": "file-service://" + ref.FileID, + "width": ref.Width, + "height": ref.Height, + "size_bytes": ref.FileSize, + }) + } + return parts +} + +func buildVisionAttachments(refs []uploadedImageRef) []map[string]any { + attachments := make([]map[string]any, 0, len(refs)) + for _, ref := range refs { + attachments = append(attachments, map[string]any{ + "id": ref.FileID, + "mimeType": ref.MIMEType, + "name": ref.FileName, + "size": ref.FileSize, + "width": ref.Width, + "height": ref.Height, + }) + } + return attachments +} + +func (c *Client) prepareMultimodalConversation(ctx context.Context, messages []map[string]any, reqs ChatRequirements, model string, refs []uploadedImageRef) (string, error) { + prompt := conversationPrompt(messages) + payload := map[string]any{ + "action": "next", + "fork_from_shared_post": false, + "parent_message_id": util.NewUUID(), + "model": textModelSlug(model), + "client_prepare_state": "success", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "system_hints": []any{}, + "partial_query": map[string]any{ + "id": util.NewUUID(), + "author": map[string]any{"role": "user"}, + "content": map[string]any{"content_type": "multimodal_text", "parts": buildVisionParts(prompt, refs)}, + }, + "supports_buffering": true, + "supported_encodings": []any{"v1"}, + "client_contextual_info": map[string]any{ + "app_name": "chatgpt.com", + }, + } + resp, err := c.postJSON(ctx, officialPreparePath, payload, c.officialHeaders(officialPreparePath, reqs, "", "*/*"), false) + if err != nil { + return "", err + } + defer resp.Body.Close() + if err := ensureOK(resp, officialPreparePath); err != nil { + return "", err + } + var data map[string]any + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + return util.Clean(data["conduit_token"]), nil +} + +func (c *Client) startMultimodalConversation(ctx context.Context, messages []map[string]any, reqs ChatRequirements, conduitToken, model string, refs []uploadedImageRef) (*http.Response, error) { + prompt := conversationPrompt(messages) + attachments := buildVisionAttachments(refs) + payload := map[string]any{ + "action": "next", + "messages": []any{ + map[string]any{ + "id": util.NewUUID(), + "author": map[string]any{"role": "user"}, + "create_time": float64(time.Now().UnixNano()) / 1e9, + "content": map[string]any{ + "content_type": "multimodal_text", + "parts": buildVisionParts(prompt, refs), + }, + "metadata": map[string]any{ + "developer_mode_connector_ids": []any{}, + "selected_github_repos": []any{}, + "selected_all_github_repos": false, + "serialization_metadata": map[string]any{"custom_symbol_offsets": []any{}}, + "attachments": attachments, + }, + }, + }, + "parent_message_id": util.NewUUID(), + "model": textModelSlug(model), + "client_prepare_state": "sent", + "timezone_offset_min": -480, + "timezone": "Asia/Shanghai", + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "enable_message_followups": true, + "system_hints": []any{}, + "supports_buffering": true, + "supported_encodings": []any{"v1"}, + "paragen_cot_summary_display_override": "allow", + "force_parallel_switch": "auto", + "client_contextual_info": map[string]any{ + "is_dark_mode": false, + "time_since_loaded": 1200, + "page_height": 1072, + "page_width": 1724, + "pixel_ratio": 1.2, + "screen_height": 1440, + "screen_width": 2560, + "app_name": "chatgpt.com", + }, + } + return c.postJSON(ctx, officialStreamPath, payload, c.officialHeaders(officialStreamPath, reqs, conduitToken, "text/event-stream"), true) +} + +func (c *Client) StreamMultimodalConversation(ctx context.Context, messages []map[string]any, model string, images []VisionImage) (<-chan string, <-chan error) { + out := make(chan string) + errCh := make(chan error, 1) + go func() { + defer close(out) + defer close(errCh) + if c.AccessToken == "" { + errCh <- fmt.Errorf("vision requires authentication") + return + } + if err := c.bootstrap(ctx); err != nil { + errCh <- err + return + } + reqs, err := c.getChatRequirements(ctx) + if err != nil { + errCh <- err + return + } + refs, err := c.uploadVisionImages(ctx, images) + if err != nil { + errCh <- err + return + } + conduitToken, err := c.prepareMultimodalConversation(ctx, messages, reqs, model, refs) + if err != nil { + errCh <- err + return + } + resp, err := c.startMultimodalConversation(ctx, messages, reqs, conduitToken, model, refs) + if err != nil { + errCh <- err + return + } + defer resp.Body.Close() + if err := ensureOK(resp, officialStreamPath); err != nil { + errCh <- err + return + } + errCh <- iterSSEPayloads(ctx, resp.Body, out) + }() + return out, errCh +} + func (c *Client) conversationPayload(messages []map[string]any, model, timezoneName string) map[string]any { conversationMessages := []map[string]any{conversationUserMessage(conversationPrompt(messages))} return map[string]any{ diff --git a/internal/protocol/api.go b/internal/protocol/api.go index 86a5d0a20..df909bdbd 100644 --- a/internal/protocol/api.go +++ b/internal/protocol/api.go @@ -140,6 +140,12 @@ func (e *Engine) HandleChatCompletions(ctx context.Context, body map[string]any) var errCh <-chan error if IsImageChatRequest(body) { items, errCh = e.ImageChatEvents(ctx, body) + } else if HasVisionImages(body) { + model, messages, images, err := VisionChatParts(body) + if err != nil { + return nil, nil, err + } + items, errCh = e.StreamVisionChatCompletion(ctx, e.TextBackend(e.Accounts.GetTextAccessToken()), messages, model, images) } else { model, messages, err := TextChatParts(body) if err != nil { @@ -152,6 +158,17 @@ func (e *Engine) HandleChatCompletions(ctx context.Context, body map[string]any) if IsImageChatRequest(body) { return e.ImageChatResponse(ctx, body) } + if HasVisionImages(body) { + model, messages, images, err := VisionChatParts(body) + if err != nil { + return nil, nil, err + } + result, err := e.VisionChatResponse(ctx, body, model, messages, images) + if err != nil { + return nil, nil, err + } + return result, nil, nil + } model, messages, err := TextChatParts(body) if err != nil { return nil, nil, err @@ -220,6 +237,62 @@ func (e *Engine) StreamTextChatCompletion(ctx context.Context, client *backend.C return out, errOut } +func (e *Engine) StreamVisionChatCompletion(ctx context.Context, client *backend.Client, messages []map[string]any, model string, images []UploadedImage) (<-chan map[string]any, <-chan error) { + out := make(chan map[string]any) + errOut := make(chan error, 1) + go func() { + defer close(out) + defer close(errOut) + visionImages := make([]backend.VisionImage, len(images)) + for i, img := range images { + visionImages[i] = backend.VisionImage{ + Data: img.Data, + ContentType: img.ContentType, + FileName: img.Filename, + } + } + deltas, errCh := client.StreamMultimodalConversation(ctx, messages, model, visionImages) + id := "chatcmpl-" + util.NewHex(32) + created := time.Now().Unix() + sentRole := false + for deltaText := range deltas { + if !sentRole { + sentRole = true + out <- CompletionChunk(model, map[string]any{"role": "assistant", "content": deltaText}, nil, id, created) + } else { + out <- CompletionChunk(model, map[string]any{"content": deltaText}, nil, id, created) + } + } + if err := <-errCh; err != nil { + errOut <- err + return + } + if !sentRole { + out <- CompletionChunk(model, map[string]any{"role": "assistant", "content": ""}, nil, id, created) + } + out <- CompletionChunk(model, map[string]any{}, "stop", id, created) + errOut <- nil + }() + return out, errOut +} + +func (e *Engine) VisionChatResponse(ctx context.Context, body map[string]any, model string, messages []map[string]any, images []UploadedImage) (map[string]any, error) { + visionImages := make([]backend.VisionImage, len(images)) + for i, img := range images { + visionImages[i] = backend.VisionImage{ + Data: img.Data, + ContentType: img.ContentType, + FileName: img.Filename, + } + } + client := e.TextBackend(e.Accounts.GetTextAccessToken()) + text, err := e.CollectVisionText(ctx, client, messages, model, visionImages) + if err != nil { + return nil, err + } + return CompletionResponse(model, text, 0, messages), nil +} + func ChatMessagesFromBody(body map[string]any) ([]map[string]any, error) { if messages := util.AsMapSlice(body["messages"]); len(messages) > 0 { return messages, nil @@ -251,6 +324,37 @@ func IsImageChatRequest(body map[string]any) bool { return false } +func HasVisionImages(body map[string]any) bool { + if IsImageChatRequest(body) { + return false + } + for _, msg := range util.AsMapSlice(body["messages"]) { + if len(ExtractImagesFromMessageContent(msg["content"])) > 0 { + return true + } + } + return false +} + +func ExtractVisionImages(body map[string]any) []UploadedImage { + var images []UploadedImage + for _, msg := range util.AsMapSlice(body["messages"]) { + images = append(images, ExtractImagesFromMessageContent(msg["content"])...) + } + return images +} + +func VisionChatParts(body map[string]any) (string, []map[string]any, []UploadedImage, error) { + model := firstNonEmpty(util.Clean(body["model"]), "auto") + rawMessages, err := ChatMessagesFromBody(body) + if err != nil { + return "", nil, nil, err + } + messages := NormalizeMessages(rawMessages, nil) + images := ExtractVisionImages(body) + return model, messages, images, nil +} + func (e *Engine) ImageChatResponse(ctx context.Context, body map[string]any) (map[string]any, *StreamResult, error) { model, prompt, n, images, messages, err := ChatImageArgs(body) if err != nil { diff --git a/internal/protocol/conversation.go b/internal/protocol/conversation.go index fde65c83d..2cc5bb64a 100644 --- a/internal/protocol/conversation.go +++ b/internal/protocol/conversation.go @@ -385,6 +385,15 @@ func (e *Engine) CollectText(ctx context.Context, client *backend.Client, reques return strings.Join(parts, ""), <-errCh } +func (e *Engine) CollectVisionText(ctx context.Context, client *backend.Client, messages []map[string]any, model string, images []backend.VisionImage) (string, error) { + deltas, errCh := client.StreamMultimodalConversation(ctx, messages, model, images) + var parts []string + for delta := range deltas { + parts = append(parts, delta) + } + return strings.Join(parts, ""), <-errCh +} + func (e *Engine) ConversationEvents(ctx context.Context, client *backend.Client, messages []map[string]any, model, prompt string) (<-chan ConversationEvent, <-chan error) { out := make(chan ConversationEvent) errCh := make(chan error, 1) From 027aa5943a02e174c79f8dc7632c3bc3e737e31e Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sat, 9 May 2026 14:56:38 +0800 Subject: [PATCH 13/76] fix: parse multimodal SSE payloads to extract text from "v" field StreamMultimodalConversation was sending raw JSON payloads to the output channel, causing garbled output. Added iterMultimodalSSEPayloads that parses each SSE event and extracts only string text from the "v" field. --- internal/backend/backend.go | 44 ++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 6ebac8270..23a3efac7 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -715,7 +715,7 @@ func (c *Client) StreamMultimodalConversation(ctx context.Context, messages []ma errCh <- err return } - errCh <- iterSSEPayloads(ctx, resp.Body, out) + errCh <- iterMultimodalSSEPayloads(ctx, resp.Body, out) }() return out, errCh } @@ -945,6 +945,48 @@ func iterSSEPayloads(ctx context.Context, reader io.Reader, out chan<- string) e } } +func iterMultimodalSSEPayloads(ctx context.Context, reader io.Reader, out chan<- string) error { + buf := make([]byte, 0, 4096) + tmp := make([]byte, 2048) + for { + n, err := reader.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + for { + idx := bytes.IndexByte(buf, '\n') + if idx < 0 { + break + } + line := strings.TrimSpace(string(buf[:idx])) + buf = buf[idx+1:] + if strings.HasPrefix(line, "data:") { + payload := strings.TrimSpace(line[5:]) + if payload != "" && payload != "[DONE]" { + var event map[string]any + if json.Unmarshal([]byte(payload), &event) == nil { + if v, ok := event["v"]; ok { + if text, ok := v.(string); ok && text != "" { + select { + case out <- text: + case <-ctx.Done(): + return ctx.Err() + } + } + } + } + } + } + } + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + } +} + func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { From 192dc33798041128e356cf6c9ee2f5dbd91f3dcc Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sat, 9 May 2026 15:18:20 +0800 Subject: [PATCH 14/76] fix: stop forcing image-generation mode when uploading images in chat Remove forced composer mode switch and image clearing when in conversation mode. Chat mode now allows image uploads without switching to image generation mode. --- web/src/app/image/components/image-composer.tsx | 10 ---------- web/src/app/image/page.tsx | 17 +++-------------- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/web/src/app/image/components/image-composer.tsx b/web/src/app/image/components/image-composer.tsx index 64bad65be..258548abb 100644 --- a/web/src/app/image/components/image-composer.tsx +++ b/web/src/app/image/components/image-composer.tsx @@ -449,9 +449,6 @@ export function ImageComposer({ }, [isPromptAreaResizing]); const handleTextareaPaste = (event: ClipboardEvent) => { - if (composerMode === "chat") { - return; - } const imageFiles = getImageFiles(event.clipboardData.files); if (imageFiles.length === 0) { return; @@ -467,9 +464,6 @@ export function ImageComposer({ return; } - if (composerMode === "chat") { - onComposerModeChange("image"); - } void onReferenceImageChange(imageFiles); }; @@ -585,10 +579,6 @@ export function ImageComposer({ }; const handlePickReferenceImage = () => { - if (composerMode === "chat") { - onComposerModeChange("image"); - } - fileInputRef.current?.click(); }; diff --git a/web/src/app/image/page.tsx b/web/src/app/image/page.tsx index cad178639..9e89e7a58 100644 --- a/web/src/app/image/page.tsx +++ b/web/src/app/image/page.tsx @@ -1300,19 +1300,13 @@ function ImagePageContent({ session }: { session: NonNullable 0) { - setReferenceImages([]); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - } return; } if (!isImageCreationModel(imageModel)) { setImageModel(DEFAULT_IMAGE_MODEL); } - }, [composerMode, imageModel, referenceImages.length]); + }, [composerMode, imageModel]); useEffect(() => { if (typeof window === "undefined") { @@ -1428,10 +1422,6 @@ function ImagePageContent({ session }: { session: NonNullable [...prev, ...previews]); + setReferenceImages((prev) => [...prev, ...previews]); if (fileInputRef.current) { fileInputRef.current.value = ""; } @@ -2608,7 +2597,7 @@ function ImagePageContent({ session }: { session: NonNullable Date: Sat, 9 May 2026 15:50:43 +0800 Subject: [PATCH 15/76] fix: send vision images through chat completion task pipeline When images are uploaded in chat mode on the creation page, build image_url content blocks in the messages payload so the backend can route them to the vision understanding API. --- web/src/app/image/page.tsx | 9 +++++++++ web/src/lib/api.ts | 29 +++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/web/src/app/image/page.tsx b/web/src/app/image/page.tsx index 9e89e7a58..221b7c75f 100644 --- a/web/src/app/image/page.tsx +++ b/web/src/app/image/page.tsx @@ -1975,6 +1975,15 @@ function ImagePageContent({ session }: { session: NonNullable { if (activeTurn.mode === "chat") { + if (activeTurn.referenceImages.length > 0) { + return createChatCompletionTask( + group.taskId, + activeTurn.prompt, + activeTurn.model, + taskMessages, + activeTurn.referenceImages.map((img) => ({ name: img.name, dataUrl: img.dataUrl })), + ); + } return createChatCompletionTask(group.taskId, activeTurn.prompt, activeTurn.model, taskMessages); } if (usesReferenceImages(activeTurn.mode)) { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0d474d195..ac31837c6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -852,15 +852,32 @@ export async function createChatCompletionTask( prompt: string, model: ImageModel, messages: CreationTaskMessage[], + referenceImages?: { name: string; dataUrl: string }[], ) { + const body: Record = { + client_task_id: clientTaskId, + prompt, + model, + messages, + }; + + if (referenceImages && referenceImages.length > 0) { + const content: Array<{ type: string; text?: string; image_url?: { url: string } }> = [ + { type: "text", text: prompt }, + ...referenceImages.map((img) => ({ + type: "image_url" as const, + image_url: { url: img.dataUrl }, + })), + ]; + body.messages = [ + ...messages, + { role: "user" as const, content }, + ]; + } + return httpRequest("/api/creation-tasks/chat-completions", { method: "POST", - body: { - client_task_id: clientTaskId, - prompt, - model, - messages, - }, + body, }); } From 7d6d07a68a0a758618e5018a0d3a6c30e6c3905f Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sat, 9 May 2026 16:35:19 +0800 Subject: [PATCH 16/76] fix: show reference images in both chat and image modes Remove the composerMode === "image" condition from the thumbnail display so uploaded images are visible regardless of the current mode. --- web/src/app/image/components/image-composer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/image/components/image-composer.tsx b/web/src/app/image/components/image-composer.tsx index 258548abb..d57930e44 100644 --- a/web/src/app/image/components/image-composer.tsx +++ b/web/src/app/image/components/image-composer.tsx @@ -607,7 +607,7 @@ export function ImageComposer({ }} /> - {composerMode === "image" && referenceImages.length > 0 ? ( + {referenceImages.length > 0 ? (
    {referenceImages.map((image, index) => (
    From d33b8c83318b2379abbda48eca243bc1794326af Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sat, 9 May 2026 22:54:54 +0800 Subject: [PATCH 17/76] feat: add smart text account rotation and fix SSE EOF truncation - Rewrite GetTextAccessToken with pool-based selection (non-Free priority rotation, Free fallback with cooldown) - Fix iterSSEPayloads and iterMultimodalSSEPayloads discarding buffered data on EOF --- internal/backend/backend.go | 33 ++++++++++++++ internal/service/account.go | 85 +++++++++++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 23a3efac7..389afbd2c 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -937,6 +937,19 @@ func iterSSEPayloads(ctx context.Context, reader io.Reader, out chan<- string) e } } if err == io.EOF { + if len(buf) > 0 { + line := strings.TrimSpace(string(buf)) + if strings.HasPrefix(line, "data:") { + payload := strings.TrimSpace(line[5:]) + if payload != "" { + select { + case out <- payload: + case <-ctx.Done(): + return ctx.Err() + } + } + } + } return nil } if err != nil { @@ -979,6 +992,26 @@ func iterMultimodalSSEPayloads(ctx context.Context, reader io.Reader, out chan<- } } if err == io.EOF { + if len(buf) > 0 { + line := strings.TrimSpace(string(buf)) + if strings.HasPrefix(line, "data:") { + payload := strings.TrimSpace(line[5:]) + if payload != "" && payload != "[DONE]" { + var event map[string]any + if json.Unmarshal([]byte(payload), &event) == nil { + if v, ok := event["v"]; ok { + if text, ok := v.(string); ok && text != "" { + select { + case out <- text: + case <-ctx.Done(): + return ctx.Err() + } + } + } + } + } + } + } return nil } if err != nil { diff --git a/internal/service/account.go b/internal/service/account.go index a080aef2b..93730fee8 100644 --- a/internal/service/account.go +++ b/internal/service/account.go @@ -32,6 +32,8 @@ type AccountService struct { imageReservations map[string]int remoteBaseURL string browserHTTPClient func(profile string, timeout time.Duration) *http.Client + textRequestCount map[string]int + textCooldownUntil time.Time } const ( @@ -55,6 +57,7 @@ func NewAccountService(backend storage.Backend, config AccountConfig, proxy *Pro imageReservations: map[string]int{}, remoteBaseURL: "https://chatgpt.com", browserHTTPClient: browserHTTPClient, + textRequestCount: map[string]int{}, } s.items = s.loadAccounts() return s @@ -197,6 +200,7 @@ func (s *AccountService) DeleteAccounts(tokens []string) map[string]any { if _, ok := targets[token]; ok { removed++ delete(s.imageReservations, token) + delete(s.textRequestCount, token) continue } next = append(next, item) @@ -280,13 +284,88 @@ func (s *AccountService) GetAccount(accessToken string) map[string]any { func (s *AccountService) GetTextAccessToken() string { s.mu.Lock() defer s.mu.Unlock() + + nonFree := s.filterNonFreeLocked() + if len(nonFree) > 0 { + return s.selectFromTextPoolLocked(nonFree, false) + } + + free := s.filterFreeLocked() + if len(free) > 0 { + return s.selectFromTextPoolLocked(free, true) + } + + return "" +} + +func (s *AccountService) filterNonFreeLocked() []map[string]any { + var out []map[string]any for _, item := range s.items { status := util.Clean(item["status"]) - if status != "禁用" && status != "异常" { - return util.Clean(item["access_token"]) + if status == "禁用" || status == "异常" { + continue + } + if IsPaidImageAccount(item) { + out = append(out, item) } } - return "" + return out +} + +func (s *AccountService) filterFreeLocked() []map[string]any { + var out []map[string]any + for _, item := range s.items { + status := util.Clean(item["status"]) + if status == "禁用" || status == "异常" { + continue + } + if !IsPaidImageAccount(item) { + out = append(out, item) + } + } + return out +} + +func (s *AccountService) selectFromTextPoolLocked(pool []map[string]any, isFree bool) string { + const maxRequestsPerAccount = 10 + + var bestToken string + bestCount := int(^uint(0) >> 1) + allExhausted := true + for _, item := range pool { + token := util.Clean(item["access_token"]) + count := s.textRequestCount[token] + if count < bestCount { + bestCount = count + bestToken = token + } + if count < maxRequestsPerAccount { + allExhausted = false + } + } + + if allExhausted { + if isFree { + now := time.Now() + if now.After(s.textCooldownUntil) { + s.resetTextCountsLocked(pool) + s.textCooldownUntil = now.Add(5 * time.Hour) + bestCount = 0 + } + } else if len(pool) > 1 { + s.resetTextCountsLocked(pool) + bestCount = 0 + } + } + + s.textRequestCount[bestToken] = bestCount + 1 + return bestToken +} + +func (s *AccountService) resetTextCountsLocked(pool []map[string]any) { + for _, item := range pool { + s.textRequestCount[util.Clean(item["access_token"])] = 0 + } } func (s *AccountService) GetAvailableAccessToken(ctx context.Context) (string, error) { From a1660053aff835c4651b6e95c0d6a61a1cc4b961 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Sun, 10 May 2026 21:01:35 +0800 Subject: [PATCH 18/76] fix: add force_use_sse to multimodal conversation to prevent stream truncation The multimodal SSE parser was correctly extracting text from all event formats, but the startMultimodalConversation payload lacked force_use_sse which could cause the ChatGPT API to switch away from SSE streaming mode, resulting in truncated responses. --- internal/backend/backend.go | 118 ++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 32 deletions(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 389afbd2c..f10b37f45 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -662,6 +662,7 @@ func (c *Client) startMultimodalConversation(ctx context.Context, messages []map "supported_encodings": []any{"v1"}, "paragen_cot_summary_display_override": "allow", "force_parallel_switch": "auto", + "force_use_sse": true, "client_contextual_info": map[string]any{ "is_dark_mode": false, "time_since_loaded": 1200, @@ -961,6 +962,28 @@ func iterSSEPayloads(ctx context.Context, reader io.Reader, out chan<- string) e func iterMultimodalSSEPayloads(ctx context.Context, reader io.Reader, out chan<- string) error { buf := make([]byte, 0, 4096) tmp := make([]byte, 2048) + processLine := func(line string) error { + if !strings.HasPrefix(line, "data:") { + return nil + } + payload := strings.TrimSpace(line[5:]) + if payload == "" || payload == "[DONE]" { + return nil + } + var event map[string]any + if json.Unmarshal([]byte(payload), &event) != nil { + return nil + } + for _, text := range extractMultimodalText(event) { + select { + case out <- text: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + } + for { n, err := reader.Read(tmp) if n > 0 { @@ -972,49 +995,25 @@ func iterMultimodalSSEPayloads(ctx context.Context, reader io.Reader, out chan<- } line := strings.TrimSpace(string(buf[:idx])) buf = buf[idx+1:] - if strings.HasPrefix(line, "data:") { - payload := strings.TrimSpace(line[5:]) - if payload != "" && payload != "[DONE]" { - var event map[string]any - if json.Unmarshal([]byte(payload), &event) == nil { - if v, ok := event["v"]; ok { - if text, ok := v.(string); ok && text != "" { - select { - case out <- text: - case <-ctx.Done(): - return ctx.Err() - } - } - } - } - } + if err := processLine(line); err != nil { + return err } } } if err == io.EOF { if len(buf) > 0 { line := strings.TrimSpace(string(buf)) - if strings.HasPrefix(line, "data:") { - payload := strings.TrimSpace(line[5:]) - if payload != "" && payload != "[DONE]" { - var event map[string]any - if json.Unmarshal([]byte(payload), &event) == nil { - if v, ok := event["v"]; ok { - if text, ok := v.(string); ok && text != "" { - select { - case out <- text: - case <-ctx.Done(): - return ctx.Err() - } - } - } - } - } + if err := processLine(line); err != nil { + return err } } return nil } if err != nil { + if len(buf) > 0 { + line := strings.TrimSpace(string(buf)) + _ = processLine(line) + } return err } } @@ -1028,3 +1027,58 @@ func firstNonEmpty(values ...string) string { } return "" } + +func extractMultimodalText(event map[string]any) []string { + if v, ok := event["v"]; ok { + switch val := v.(type) { + case string: + if val != "" { + return []string{val} + } + case []any: + var texts []string + for _, item := range val { + if op, ok := item.(map[string]any); ok { + if op["o"] == "append" { + if t := util.Clean(op["v"]); t != "" { + texts = append(texts, t) + } + } + } + } + if len(texts) > 0 { + return texts + } + case map[string]any: + if texts := extractPartsText(val); len(texts) > 0 { + return texts + } + } + } + if event["o"] == "append" { + if t := util.Clean(event["v"]); t != "" { + return []string{t} + } + } + if msg, ok := event["message"].(map[string]any); ok { + if texts := extractPartsText(msg); len(texts) > 0 { + return texts + } + } + return nil +} + +func extractPartsText(message map[string]any) []string { + content, _ := message["content"].(map[string]any) + if content == nil { + return nil + } + parts, _ := content["parts"].([]any) + var texts []string + for _, part := range parts { + if text, ok := part.(string); ok && text != "" { + texts = append(texts, text) + } + } + return texts +} From 138678f2f93010f0853cada5588a2642458b5da2 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Mon, 11 May 2026 07:49:11 +0800 Subject: [PATCH 19/76] fix: recognize token expired error as invalid account during refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IsAccountInvalidErrorMessage now matches "token expired" and "authentication token is expired" so expired tokens are properly marked as "异常" and can be auto-removed. --- internal/service/account.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/service/account.go b/internal/service/account.go index 93730fee8..02bd5fb48 100644 --- a/internal/service/account.go +++ b/internal/service/account.go @@ -1001,7 +1001,9 @@ func IsAccountInvalidErrorMessage(message string) bool { return strings.Contains(text, "token_invalidated") || strings.Contains(text, "token_revoked") || strings.Contains(text, "authentication token has been invalidated") || - strings.Contains(text, "invalidated oauth token") + strings.Contains(text, "invalidated oauth token") || + strings.Contains(text, "token expired") || + strings.Contains(text, "authentication token is expired") } func IsAccountRateLimitedErrorMessage(message string) bool { From 668fb13d3b68d3a8e5f9045c1cc771ffdbfd8e35 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Mon, 11 May 2026 07:50:45 +0800 Subject: [PATCH 20/76] fix: use Dockerfile.release in CI build-docker-dev job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build-docker-dev job was using deploy/Dockerfile which is a release-only runtime wrapper that copies a pre-built binary without building the frontend. Changed to deploy/Dockerfile.release which performs full multi-stage builds (web-deps → web-build → go-build → app), ensuring frontend changes are included in Docker images. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a4b21bd7..e9f887eb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: deploy/Dockerfile + file: deploy/Dockerfile.release platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} From f7ba7f80ee87bb492058c02d0fd7749ce5c8e005 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Mon, 11 May 2026 08:17:55 +0800 Subject: [PATCH 21/76] fix: use Dockerfile.release in docker-build-limited.sh local build script The local development build script was still referencing deploy/Dockerfile which is a release-only wrapper without frontend/Go build stages. Changed to deploy/Dockerfile.release to match the CI build job. --- deploy/docker-build-limited.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker-build-limited.sh b/deploy/docker-build-limited.sh index ceaef83f8..489784217 100644 --- a/deploy/docker-build-limited.sh +++ b/deploy/docker-build-limited.sh @@ -208,7 +208,7 @@ docker buildx build \ --builder "$builder_name" \ --load \ --tag "$CHATGPT2API_LOCAL_IMAGE" \ - --file "$repo_root/deploy/Dockerfile" \ + --file "$repo_root/deploy/Dockerfile.release" \ --build-arg "VERSION=$CHATGPT2API_VERSION" \ --build-arg "BUILD_GOMAXPROCS=$BUILD_GOMAXPROCS" \ --build-arg "BUILD_GOMEMLIMIT=$BUILD_GOMEMLIMIT" \ From cdf1c07068b71874f5b45edf2e6fa2ed42e99fe5 Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Mon, 11 May 2026 09:59:24 +0800 Subject: [PATCH 22/76] fix: revert Dockerfile references to deploy/Dockerfile for full multi-stage builds deploy/Dockerfile is the complete multi-stage build (frontend + Go + runtime), while deploy/Dockerfile.release is a release-only wrapper for GoReleaser that expects a pre-built binary. Both CI build-docker-dev and docker-build-limited.sh must use the full build Dockerfile. --- .github/workflows/ci.yml | 2 +- deploy/docker-build-limited.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9f887eb7..1a4b21bd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: deploy/Dockerfile.release + file: deploy/Dockerfile platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/deploy/docker-build-limited.sh b/deploy/docker-build-limited.sh index 489784217..ceaef83f8 100644 --- a/deploy/docker-build-limited.sh +++ b/deploy/docker-build-limited.sh @@ -208,7 +208,7 @@ docker buildx build \ --builder "$builder_name" \ --load \ --tag "$CHATGPT2API_LOCAL_IMAGE" \ - --file "$repo_root/deploy/Dockerfile.release" \ + --file "$repo_root/deploy/Dockerfile" \ --build-arg "VERSION=$CHATGPT2API_VERSION" \ --build-arg "BUILD_GOMAXPROCS=$BUILD_GOMAXPROCS" \ --build-arg "BUILD_GOMEMLIMIT=$BUILD_GOMEMLIMIT" \ From ce9d9f5246c4e856cb4b30f7cb0e8d857365943a Mon Sep 17 00:00:00 2001 From: sofs2005 Date: Mon, 11 May 2026 11:29:04 +0800 Subject: [PATCH 23/76] fix: prevent multimodal SSE from outputting raw completion map as text Three fixes in backend.go: 1. Add is_complete guard in iterMultimodalSSEPayloads to filter out completion events 2. Type-check map values in extractMultimodalText []any branch instead of using fmt.Sprint 3. Type-check event["v"] in extractMultimodalText append branch --- internal/backend/backend.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index f10b37f45..18fca7ae7 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -974,6 +974,9 @@ func iterMultimodalSSEPayloads(ctx context.Context, reader io.Reader, out chan<- if json.Unmarshal([]byte(payload), &event) != nil { return nil } + if isComplete, _ := event["is_complete"].(bool); isComplete { + return nil + } for _, text := range extractMultimodalText(event) { select { case out <- text: @@ -1040,8 +1043,8 @@ func extractMultimodalText(event map[string]any) []string { for _, item := range val { if op, ok := item.(map[string]any); ok { if op["o"] == "append" { - if t := util.Clean(op["v"]); t != "" { - texts = append(texts, t) + if s, ok := op["v"].(string); ok && strings.TrimSpace(s) != "" { + texts = append(texts, s) } } } @@ -1056,8 +1059,8 @@ func extractMultimodalText(event map[string]any) []string { } } if event["o"] == "append" { - if t := util.Clean(event["v"]); t != "" { - return []string{t} + if s, ok := event["v"].(string); ok && strings.TrimSpace(s) != "" { + return []string{s} } } if msg, ok := event["message"].(map[string]any); ok { From 8aed84f243832b30691a661b483c16f23a9d54aa Mon Sep 17 00:00:00 2001 From: ZyphrZero <133507172+ZyphrZero@users.noreply.github.com> Date: Mon, 11 May 2026 11:49:13 +0800 Subject: [PATCH 24/76] feat(register): add inbucket and yyds_mail mail providers with enhanced login flow Add two new mail providers (inbucket and yyds_mail) with full create/fetch support. Refactor login to submit email separately before password verification, with automatic re-authorization on 409 Conflict. Normalize message response fields (provider, mailbox, message_id, sender, received_at) across all providers. Simplify duckmail CreateMailbox by removing redundant domain list fetch. Add UI config fields for the new providers in register-card. --- internal/service/mail_provider.go | 290 ++++++++++++++++-- internal/service/mail_provider_test.go | 121 ++++++++ internal/service/register.go | 50 ++- internal/service/register_flow_test.go | 52 ++++ .../app/register/components/register-card.tsx | 37 ++- 5 files changed, 523 insertions(+), 27 deletions(-) diff --git a/internal/service/mail_provider.go b/internal/service/mail_provider.go index 46e43372e..15530c5ee 100644 --- a/internal/service/mail_provider.go +++ b/internal/service/mail_provider.go @@ -81,6 +81,16 @@ type registerMoEmailProvider struct { entry map[string]any } +type registerInbucketMailProvider struct { + registerHTTPMailProvider + entry map[string]any +} + +type registerYYDSMailProvider struct { + registerHTTPMailProvider + entry map[string]any +} + func createRegisterMailbox(mailConfig map[string]any, username string) (map[string]any, error) { provider, err := createRegisterMailProvider(mailConfig, "", "") if err != nil { @@ -138,6 +148,10 @@ func createRegisterMailProvider(mailConfig map[string]any, providerName, provide return ®isterGPTMailProvider{registerHTTPMailProvider: base, entry: entry}, nil case "moemail": return ®isterMoEmailProvider{registerHTTPMailProvider: base, entry: entry}, nil + case "inbucket": + return ®isterInbucketMailProvider{registerHTTPMailProvider: base, entry: entry}, nil + case "yyds_mail": + return ®isterYYDSMailProvider{registerHTTPMailProvider: base, entry: entry}, nil default: return nil, fmt.Errorf("unsupported mail.provider: %s", util.Clean(entry["type"])) } @@ -638,11 +652,20 @@ func (p *registerCloudflareTempMailProvider) FetchLatestMessage(mailbox map[stri } message := latestRegisterMailMessage(messages) textContent, htmlContent := extractRegisterMailContent(message) + sender := firstNonNil(message["from"], message["sender"]) + if senderMap, ok := sender.(map[string]any); ok { + sender = firstNonNil(senderMap["address"], senderMap["email"], senderMap["name"]) + } return map[string]any{ + "provider": "cloudflare_temp_email", + "mailbox": util.Clean(mailbox["address"]), + "message_id": firstNonEmpty(util.Clean(message["id"]), util.Clean(message["_id"])), "subject": util.Clean(message["subject"]), + "sender": util.Clean(sender), "text_content": textContent, "html_content": htmlContent, - "raw": message["raw"], + "received_at": firstNonNil(message["createdAt"], message["created_at"], message["receivedAt"], message["date"], message["timestamp"]), + "raw": message, }, nil } @@ -698,30 +721,21 @@ func (p *registerTempMailLOLProvider) FetchLatestMessage(mailbox map[string]any) latest := latestRegisterMailMessage(items) textContent, htmlContent := extractRegisterMailContent(latest) return map[string]any{ + "provider": "tempmail_lol", + "mailbox": util.Clean(mailbox["address"]), + "message_id": firstNonEmpty(util.Clean(latest["id"]), util.Clean(latest["token"])), "subject": util.Clean(latest["subject"]), + "sender": firstNonEmpty(util.Clean(latest["from"]), util.Clean(latest["from_address"])), "text_content": textContent, "html_content": htmlContent, - "raw": latest["raw"], + "received_at": firstNonNil(latest["created_at"], latest["createdAt"], latest["date"], latest["received_at"], latest["timestamp"]), + "raw": latest, }, nil } func (p *registerDuckMailProvider) CreateMailbox(username string) (map[string]any, error) { apiKey := util.Clean(p.entry["api_key"]) - domains, err := registerMailRequestAny(p.client, http.MethodGet, "https://api.duckmail.sbs/domains", map[string]string{ - "Authorization": "Bearer " + apiKey, - "User-Agent": p.conf.UserAgent, - "Accept": "application/json", - }, nil, nil, http.StatusOK, http.StatusCreated) - if err != nil { - return nil, err - } domain := util.Clean(p.entry["default_domain"]) - for _, item := range duckMailItems(domains) { - if value := util.Clean(item["domain"]); value != "" { - domain = value - break - } - } if domain == "" { domain = "duckmail.sbs" } @@ -783,11 +797,20 @@ func (p *registerDuckMailProvider) FetchLatestMessage(mailbox map[string]any) (m return nil, err } textContent, htmlContent := extractRegisterMailContent(message) + sender := message["from"] + if senderMap, ok := sender.(map[string]any); ok { + sender = firstNonNil(senderMap["address"], senderMap["name"]) + } return map[string]any{ + "provider": "duckmail", + "mailbox": util.Clean(mailbox["address"]), + "message_id": messageID, "subject": util.Clean(message["subject"]), + "sender": util.Clean(sender), "text_content": textContent, "html_content": htmlContent, - "raw": message["raw"], + "received_at": firstNonNil(message["createdAt"], message["created_at"], message["receivedAt"], message["date"]), + "raw": message, }, nil } @@ -857,10 +880,15 @@ func (p *registerGPTMailProvider) FetchLatestMessage(mailbox map[string]any) (ma } textContent, htmlContent := extractRegisterMailContent(latest) return map[string]any{ + "provider": "gptmail", + "mailbox": util.Clean(mailbox["address"]), + "message_id": util.Clean(latest["id"]), "subject": util.Clean(latest["subject"]), + "sender": util.Clean(latest["from_address"]), "text_content": textContent, "html_content": htmlContent, - "raw": latest["raw"], + "received_at": firstNonNil(latest["timestamp"], latest["created_at"]), + "raw": latest, }, nil } @@ -956,6 +984,232 @@ func (p *registerMoEmailProvider) FetchLatestMessage(mailbox map[string]any) (ma }, nil } +func (p *registerInbucketMailProvider) CreateMailbox(username string) (map[string]any, error) { + apiBase := strings.TrimRight(util.Clean(p.entry["api_base"]), "/") + if apiBase == "" { + return nil, fmt.Errorf("inbucket api_base is required") + } + baseDomain, err := nextRegisterDomain(util.AsStringSlice(p.entry["domain"])) + if err != nil { + return nil, err + } + localPart := firstNonEmpty(strings.TrimSpace(username), registerRandomMailboxName()) + domain := baseDomain + randomSubdomain := true + if _, ok := p.entry["random_subdomain"]; ok { + randomSubdomain = util.ToBool(p.entry["random_subdomain"]) + } + if randomSubdomain { + domain = registerRandomSubdomainLabel() + "." + baseDomain + } + address := localPart + "@" + domain + return map[string]any{ + "provider": "inbucket", + "provider_ref": p.entry["provider_ref"], + "address": address, + "base_domain": baseDomain, + "mailbox_name": localPart, + }, nil +} + +func (p *registerInbucketMailProvider) FetchLatestMessage(mailbox map[string]any) (map[string]any, error) { + apiBase := strings.TrimRight(util.Clean(p.entry["api_base"]), "/") + if apiBase == "" { + return nil, fmt.Errorf("inbucket api_base is required") + } + mailboxName := util.Clean(mailbox["mailbox_name"]) + if mailboxName == "" { + mailboxName = registerInbucketMailboxName(util.Clean(mailbox["address"])) + } + if mailboxName == "" { + return nil, fmt.Errorf("inbucket missing mailbox_name") + } + data, err := registerMailRequestAny(p.client, http.MethodGet, apiBase+"/api/v1/mailbox/"+url.PathEscape(mailboxName), map[string]string{ + "User-Agent": p.conf.UserAgent, + "Accept": "application/json", + }, nil, nil, http.StatusOK) + if err != nil { + return nil, err + } + items := util.AsMapSlice(data) + if len(items) == 0 { + return nil, nil + } + sort.SliceStable(items, func(i, j int) bool { + left := registerMessageReceivedAt(items[i]) + right := registerMessageReceivedAt(items[j]) + if !left.IsZero() || !right.IsZero() { + if !left.Equal(right) { + return left.After(right) + } + return registerMessageID(items[i]) > registerMessageID(items[j]) + } + return false + }) + address := util.Clean(mailbox["address"]) + for _, item := range items { + messageID := util.Clean(item["id"]) + if messageID == "" { + continue + } + detail, detailErr := registerMailRequestJSON(p.client, http.MethodGet, apiBase+"/api/v1/mailbox/"+url.PathEscape(mailboxName)+"/"+url.PathEscape(messageID), map[string]string{ + "User-Agent": p.conf.UserAgent, + "Accept": "application/json", + }, nil, nil, http.StatusOK) + if detailErr != nil { + return nil, detailErr + } + header := util.StringMap(detail["header"]) + body := util.StringMap(detail["body"]) + normalized := map[string]any{ + "provider": "inbucket", + "mailbox": mailboxName, + "message_id": messageID, + "subject": firstNonEmpty(util.Clean(detail["subject"]), util.Clean(item["subject"])), + "sender": firstNonEmpty(util.Clean(detail["from"]), util.Clean(item["from"])), + "text_content": util.Clean(body["text"]), + "html_content": util.Clean(body["html"]), + "received_at": firstNonNil(detail["date"], item["date"]), + "to": firstNonNil(header["To"], header["to"]), + "raw": detail, + } + if registerMessageMatchesEmail(normalized, address) { + return normalized, nil + } + } + return nil, nil +} + +func registerInbucketMailboxName(address string) string { + localPart, _, _ := strings.Cut(strings.TrimSpace(address), "@") + return strings.TrimSpace(localPart) +} + +func (p *registerYYDSMailProvider) CreateMailbox(username string) (map[string]any, error) { + payload := map[string]any{"localPart": firstNonEmpty(strings.TrimSpace(username), registerRandomMailboxName())} + if domains := util.AsStringSlice(p.entry["domain"]); len(domains) > 0 { + domain, err := nextRegisterDomain(domains) + if err != nil { + return nil, err + } + payload["domain"] = domain + } + if subdomain := util.Clean(p.entry["subdomain"]); subdomain != "" { + payload["subdomain"] = subdomain + } + path := "/accounts" + if util.ToBool(p.entry["wildcard"]) { + path = "/accounts/wildcard" + } + data, err := p.request(http.MethodPost, path, "", nil, payload, http.StatusOK, http.StatusCreated, http.StatusNoContent) + if err != nil { + return nil, err + } + body := util.StringMap(data) + address := firstNonEmpty(util.Clean(body["address"]), util.Clean(body["email"])) + token := firstNonEmpty(util.Clean(body["token"]), util.Clean(body["temp_token"]), util.Clean(body["tempToken"]), util.Clean(body["access_token"])) + if address == "" || token == "" { + return nil, fmt.Errorf("YYDSMail missing address or token") + } + return map[string]any{ + "provider": "yyds_mail", + "provider_ref": p.entry["provider_ref"], + "address": address, + "token": token, + "account_id": util.Clean(body["id"]), + }, nil +} + +func (p *registerYYDSMailProvider) FetchLatestMessage(mailbox map[string]any) (map[string]any, error) { + token := util.Clean(mailbox["token"]) + if token == "" { + return nil, fmt.Errorf("YYDSMail missing token") + } + data, err := p.request(http.MethodGet, "/messages", token, map[string]string{"address": util.Clean(mailbox["address"])}, nil, http.StatusOK, http.StatusCreated, http.StatusNoContent) + if err != nil { + return nil, err + } + items := yydsMailItems(data) + if len(items) == 0 { + return nil, nil + } + latest := latestRegisterMailMessage(items) + messageID := firstNonEmpty(util.Clean(latest["id"]), util.Clean(latest["message_id"])) + message := latest + raw := any(latest) + if messageID != "" { + detail, detailErr := p.request(http.MethodGet, "/messages/"+url.PathEscape(messageID), token, map[string]string{"address": util.Clean(mailbox["address"])}, nil, http.StatusOK, http.StatusCreated, http.StatusNoContent) + if detailErr != nil { + return nil, detailErr + } + raw = detail + if detailMap := util.StringMap(detail); len(detailMap) > 0 { + message = detailMap + } + } + textContent, htmlContent := extractRegisterMailContent(message) + sender := firstNonNil(message["from"], message["sender"]) + if senderMap, ok := sender.(map[string]any); ok { + sender = firstNonNil(senderMap["address"], senderMap["email"], senderMap["name"]) + } + return map[string]any{ + "provider": "yyds_mail", + "mailbox": util.Clean(mailbox["address"]), + "message_id": messageID, + "subject": util.Clean(message["subject"]), + "sender": util.Clean(sender), + "text_content": textContent, + "html_content": htmlContent, + "received_at": firstNonNil(message["createdAt"], message["created_at"], message["receivedAt"], message["date"], message["timestamp"]), + "raw": raw, + }, nil +} + +func (p *registerYYDSMailProvider) request(method, path, token string, query map[string]string, payload any, expected ...int) (any, error) { + apiBase := strings.TrimRight(firstNonEmpty(util.Clean(p.entry["api_base"]), "https://maliapi.215.im/v1"), "/") + headers := map[string]string{ + "User-Agent": p.conf.UserAgent, + "Accept": "application/json", + "Content-Type": "application/json", + } + if token != "" { + headers["Authorization"] = "Bearer " + token + } else { + headers["X-API-Key"] = util.Clean(p.entry["api_key"]) + } + data, err := registerMailRequestAny(p.client, method, apiBase+path, headers, query, payload, expected...) + if err != nil { + return nil, err + } + body, ok := data.(map[string]any) + if !ok { + return data, nil + } + if success, exists := body["success"]; exists && !util.ToBool(success) { + return nil, fmt.Errorf("YYDSMail request failed: %s", firstNonEmpty(util.Clean(body["errorCode"]), util.Clean(body["error"]), util.Clean(body["message"]), "unknown error")) + } + if nested, exists := body["data"]; exists { + switch nested.(type) { + case map[string]any, []any: + return nested, nil + } + } + return data, nil +} + +func yydsMailItems(data any) []map[string]any { + switch typed := data.(type) { + case []map[string]any: + return typed + case []any: + return util.AsMapSlice(typed) + case map[string]any: + return util.AsMapSlice(firstNonNil(typed["items"], typed["messages"], typed["data"])) + default: + return nil + } +} + func duckMailItems(data any) []map[string]any { switch typed := data.(type) { case []any: diff --git a/internal/service/mail_provider_test.go b/internal/service/mail_provider_test.go index 61eb6bb2c..7a8867246 100644 --- a/internal/service/mail_provider_test.go +++ b/internal/service/mail_provider_test.go @@ -92,3 +92,124 @@ func TestRegisterMoEmailProviderCreatesAndReadsMailbox(t *testing.T) { t.Fatalf("message metadata = %#v", message) } } + +func TestRegisterInbucketProviderCreatesAndReadsMailbox(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/mailbox/user/"): + _, _ = w.Write([]byte(`{"id":"message-2","subject":"Verify","from":"OpenAI","date":"2026-01-01T00:00:00Z","header":{"To":["user@random.example.test"]},"body":{"text":"Verification code: 333444","html":""}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/mailbox/user": + _, _ = w.Write([]byte(`[{"id":"old","subject":"Old","date":"2025-01-01T00:00:00Z"},{"id":"message-2","subject":"Verify","date":"2026-01-01T00:00:00Z"}]`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + provider, err := createRegisterMailProvider(map[string]any{ + "request_timeout": 1, + "providers": []map[string]any{{ + "type": "inbucket", + "enable": true, + "api_base": server.URL, + "domain": []string{"example.test"}, + "random_subdomain": false, + }}, + }, "", "") + if err != nil { + t.Fatalf("createRegisterMailProvider() error = %v", err) + } + defer provider.Close() + + mailbox, err := provider.CreateMailbox("user") + if err != nil { + t.Fatalf("CreateMailbox() error = %v", err) + } + if mailbox["provider"] != "inbucket" || mailbox["address"] != "user@example.test" || mailbox["mailbox_name"] != "user" { + t.Fatalf("mailbox = %#v", mailbox) + } + mailbox["address"] = "user@random.example.test" + + message, err := provider.FetchLatestMessage(mailbox) + if err != nil { + t.Fatalf("FetchLatestMessage() error = %v", err) + } + if got := extractRegisterMailCode(message); got != "333444" { + t.Fatalf("extractRegisterMailCode() = %q, want 333444; message=%#v", got, message) + } + if message["message_id"] != "message-2" || message["sender"] != "OpenAI" { + t.Fatalf("message metadata = %#v", message) + } +} + +func TestRegisterYYDSMailProviderCreatesAndReadsMailbox(t *testing.T) { + var createPayload map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/accounts/wildcard": + if r.Header.Get("X-API-Key") != "secret-key" { + t.Errorf("X-API-Key = %q", r.Header.Get("X-API-Key")) + } + if err := json.NewDecoder(r.Body).Decode(&createPayload); err != nil { + t.Errorf("decode create payload: %v", err) + } + _, _ = w.Write([]byte(`{"success":true,"data":{"address":"user@example.test","token":"mail-token","id":"account-1"}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/messages/message-2": + if r.Header.Get("Authorization") != "Bearer mail-token" { + t.Errorf("Authorization = %q", r.Header.Get("Authorization")) + } + _, _ = w.Write([]byte(`{"success":true,"data":{"id":"message-2","subject":"Verify","text":"Verification code: 555666","timestamp":200,"from":{"email":"noreply@example.test"}}}`)) + case r.Method == http.MethodGet && r.URL.Path == "/messages": + if r.URL.Query().Get("address") != "user@example.test" { + t.Errorf("address query = %q", r.URL.Query().Get("address")) + } + _, _ = w.Write([]byte(`{"success":true,"data":{"items":[{"id":"old","subject":"Old","timestamp":100},{"id":"message-2","subject":"Verify","timestamp":200}]}}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + provider, err := createRegisterMailProvider(map[string]any{ + "request_timeout": 1, + "providers": []map[string]any{{ + "type": "yyds_mail", + "enable": true, + "api_base": server.URL, + "api_key": "secret-key", + "domain": []string{"example.test"}, + "subdomain": "sub", + "wildcard": true, + }}, + }, "", "") + if err != nil { + t.Fatalf("createRegisterMailProvider() error = %v", err) + } + defer provider.Close() + + mailbox, err := provider.CreateMailbox("user") + if err != nil { + t.Fatalf("CreateMailbox() error = %v", err) + } + if mailbox["provider"] != "yyds_mail" || mailbox["address"] != "user@example.test" || mailbox["token"] != "mail-token" { + t.Fatalf("mailbox = %#v", mailbox) + } + if createPayload["localPart"] != "user" || createPayload["domain"] != "example.test" || createPayload["subdomain"] != "sub" { + t.Fatalf("create payload = %#v", createPayload) + } + + message, err := provider.FetchLatestMessage(mailbox) + if err != nil { + t.Fatalf("FetchLatestMessage() error = %v", err) + } + if got := extractRegisterMailCode(message); got != "555666" { + t.Fatalf("extractRegisterMailCode() = %q, want 555666; message=%#v", got, message) + } + if message["message_id"] != "message-2" || message["sender"] != "noreply@example.test" { + t.Fatalf("message metadata = %#v", message) + } +} diff --git a/internal/service/register.go b/internal/service/register.go index 25aa28aed..5380d51fc 100644 --- a/internal/service/register.go +++ b/internal/service/register.go @@ -492,21 +492,47 @@ func (w *registerWorker) loginAndExchangeTokens(ctx context.Context, email, pass w.step("开始独立登录换 token") codeVerifier, codeChallenge := generateRegisterPKCE() values := registerAuthorizeParams(email, w.deviceID, registerRandomToken(), registerRandomToken(), codeChallenge) - status, _, err := w.request(ctx, http.MethodGet, registerAuthBase+"/api/accounts/authorize?"+values.Encode(), nil, w.navigateHeaders(registerPlatformBase+"/"), true) + authorizeLogin := func() error { + status, _, err := w.request(ctx, http.MethodGet, registerAuthBase+"/api/accounts/authorize?"+values.Encode(), nil, w.navigateHeaders(registerPlatformBase+"/"), true) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("platform_login_authorize_http_%d", status) + } + return nil + } + if err := authorizeLogin(); err != nil { + return nil, err + } + w.step("登录 authorize 完成") + + status, payload, err := w.submitLoginEmail(ctx, email) if err != nil { return nil, err } + if status == http.StatusConflict { + w.step("邮箱提交 invalid_state,重新 authorize 后重试") + if err := authorizeLogin(); err != nil { + return nil, err + } + status, payload, err = w.submitLoginEmail(ctx, email) + if err != nil { + return nil, err + } + } if status != http.StatusOK { - return nil, fmt.Errorf("platform_login_authorize_http_%d", status) + return nil, fmt.Errorf("email_submit_http_%d%s", status, registerResponseDetail(payload)) } - w.step("登录 authorize 完成") + w.step("邮箱提交完成") + headers := w.jsonHeaders(registerAuthBase + "/log-in/password") token, err := w.buildSentinelToken(ctx, "password_verify") if err != nil { return nil, err } headers["openai-sentinel-token"] = token - status, payload, err := w.request(ctx, http.MethodPost, registerAuthBase+"/api/accounts/password/verify", map[string]any{ + status, payload, err = w.request(ctx, http.MethodPost, registerAuthBase+"/api/accounts/password/verify", map[string]any{ "password": password, }, headers, false) if err != nil { @@ -573,6 +599,22 @@ func (w *registerWorker) loginAndExchangeTokens(ctx context.Context, email, pass }, nil } +func (w *registerWorker) submitLoginEmail(ctx context.Context, email string) (int, map[string]any, error) { + w.step("开始提交邮箱") + headers := w.jsonHeaders(registerAuthBase + "/log-in?usernameKind=email") + token, err := w.buildSentinelToken(ctx, "authorize_continue") + if err != nil { + return 0, nil, err + } + headers["openai-sentinel-token"] = token + return w.request(ctx, http.MethodPost, registerAuthBase+"/api/accounts/authorize/continue", map[string]any{ + "username": map[string]any{ + "kind": "email", + "value": email, + }, + }, headers, false) +} + func (w *registerWorker) followConsentForCode(ctx context.Context, continueURL string) (string, error) { current := continueURL if strings.HasPrefix(current, "/") { diff --git a/internal/service/register_flow_test.go b/internal/service/register_flow_test.go index 51ac3d767..e2f2d7c65 100644 --- a/internal/service/register_flow_test.go +++ b/internal/service/register_flow_test.go @@ -148,6 +148,58 @@ func TestSelectWorkspaceForConsentCodeUsesCookieFallback(t *testing.T) { } } +func TestLoginAndExchangeTokensSubmitsEmailBeforePassword(t *testing.T) { + var sequence []string + worker := ®isterWorker{ + service: &RegisterService{}, + deviceID: "device-1", + client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/api/accounts/authorize": + sequence = append(sequence, "authorize") + return registerJSONResponse(req, http.StatusOK, `{}`), nil + case "/backend-api/sentinel/req": + return registerJSONResponse(req, http.StatusOK, `{"token":"challenge-token","proofofwork":{"required":false}}`), nil + case "/api/accounts/authorize/continue": + sequence = append(sequence, "email") + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Fatalf("decode authorize/continue body: %v", err) + } + username := body["username"].(map[string]any) + if username["kind"] != "email" || username["value"] != "user@example.test" { + t.Fatalf("authorize/continue body = %#v", body) + } + return registerJSONResponse(req, http.StatusOK, `{}`), nil + case "/api/accounts/password/verify": + sequence = append(sequence, "password") + return registerJSONResponse(req, http.StatusOK, `{"continue_url":"`+registerPlatformOAuthRedirectURI+`?code=callback-code&state=state"}`), nil + case "/auth/callback": + sequence = append(sequence, "callback") + return registerJSONResponse(req, http.StatusOK, `{}`), nil + case "/oauth/token": + sequence = append(sequence, "token") + return registerJSONResponse(req, http.StatusOK, `{"access_token":"access","refresh_token":"refresh","id_token":"id"}`), nil + default: + t.Fatalf("unexpected request path: %s", req.URL.Path) + return nil, nil + } + })}, + } + + tokens, err := worker.loginAndExchangeTokens(context.Background(), "user@example.test", "Password123!", map[string]any{"address": "user@example.test"}) + if err != nil { + t.Fatalf("loginAndExchangeTokens() error = %v", err) + } + if tokens["access_token"] != "access" || tokens["refresh_token"] != "refresh" || tokens["id_token"] != "id" { + t.Fatalf("tokens = %#v", tokens) + } + want := []string{"authorize", "email", "password", "callback", "token"} + if strings.Join(sequence, ",") != strings.Join(want, ",") { + t.Fatalf("request sequence = %#v, want %#v", sequence, want) + } +} + func TestRegisterHTTPClientUsesSOCKSTransport(t *testing.T) { client, err := registerHTTPClient("socks5h://127.0.0.1:1", time.Second, "device-1") if err != nil { diff --git a/web/src/app/register/components/register-card.tsx b/web/src/app/register/components/register-card.tsx index aefb3ab34..dbf616bc5 100644 --- a/web/src/app/register/components/register-card.tsx +++ b/web/src/app/register/components/register-card.tsx @@ -52,6 +52,8 @@ export function RegisterCard() { ...(type === "duckmail" ? { api_key: "", default_domain: "duckmail.sbs" } : {}), ...(type === "gptmail" ? { api_key: "", default_domain: "" } : {}), ...(type === "moemail" ? { api_base: "", api_key: "", domain: [], expiry_time: 0 } : {}), + ...(type === "inbucket" ? { api_base: "", domain: [], random_subdomain: true } : {}), + ...(type === "yyds_mail" ? { api_base: "https://maliapi.215.im/v1", api_key: "", domain: [], subdomain: "", wildcard: false } : {}), }); }; @@ -144,7 +146,12 @@ export function RegisterCard() { {providers.map((provider, index) => { const type = String(provider.type || "tempmail_lol"); const domains = Array.isArray(provider.domain) ? provider.domain.map(String).join("\n") : ""; - const domainPlaceholder = type === "tempmail_lol" ? "每行一个域名,留空则使用服务默认域名" : "每行一个域名"; + const domainPlaceholder = + type === "inbucket" + ? "每行一个基础域名,系统会自动生成随机子域名" + : type === "tempmail_lol" + ? "每行一个域名,留空则使用服务默认域名" + : "每行一个域名"; return (
    @@ -170,10 +177,12 @@ export function RegisterCard() { duckmail gptmail(未测试) moemail + inbucket + yyds_mail
    - {type === "cloudflare_temp_email" || type === "moemail" ? ( + {type === "cloudflare_temp_email" || type === "moemail" || type === "inbucket" || type === "yyds_mail" ? (
    updateProvider(index, { api_base: event.target.value })} className="h-10 rounded-xl border-stone-200 bg-white" disabled={config.enabled} /> @@ -187,7 +196,13 @@ export function RegisterCard() {
    ) : null} - {type === "tempmail_lol" || type === "duckmail" || type === "gptmail" || type === "moemail" ? ( + {type === "inbucket" ? ( + + ) : null} + {type === "tempmail_lol" || type === "duckmail" || type === "gptmail" || type === "moemail" || type === "yyds_mail" ? (
    updateProvider(index, { api_key: event.target.value })} className="h-10 rounded-xl border-stone-200 bg-white" disabled={config.enabled} /> @@ -205,11 +220,23 @@ export function RegisterCard() { updateProvider(index, { expiry_time: Number(event.target.value) || 0 })} className="h-10 rounded-xl border-stone-200 bg-white" disabled={config.enabled} />
    ) : null} + {type === "yyds_mail" ? ( + <> +
    + + updateProvider(index, { subdomain: event.target.value })} className="h-10 rounded-xl border-stone-200 bg-white" disabled={config.enabled} /> +
    + + + ) : null}
    - {type === "tempmail_lol" || type === "cloudflare_temp_email" || type === "moemail" ? ( + {type === "tempmail_lol" || type === "cloudflare_temp_email" || type === "moemail" || type === "inbucket" || type === "yyds_mail" ? (
    - +