From e517c56225f55f2e8270426673b2e17567e06fee Mon Sep 17 00:00:00 2001 From: chiyi Date: Mon, 6 Jul 2026 10:21:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0chatgpt=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/http/handler/v1.go | 2 +- backend/internal/provider/chatgpt/client.go | 40 +++++++++++++++++---- backend/internal/provider/chatgpt/util.go | 16 +++++++++ backend/internal/service/v1.go | 4 +-- frontend/src/views/PlaygroundView.vue | 37 ++++++++++++------- 5 files changed, 78 insertions(+), 21 deletions(-) diff --git a/backend/internal/http/handler/v1.go b/backend/internal/http/handler/v1.go index f3fcbe4..a743150 100644 --- a/backend/internal/http/handler/v1.go +++ b/backend/internal/http/handler/v1.go @@ -223,7 +223,7 @@ func readMultipartImages(c *gin.Context, keys ...string) []string { if e != nil { continue } - b, _ := io.ReadAll(io.LimitReader(f, 8<<20+1)) + b, _ := io.ReadAll(io.LimitReader(f, 20<<20+1)) f.Close() if len(b) > 0 { out = append(out, base64.StdEncoding.EncodeToString(b)) diff --git a/backend/internal/provider/chatgpt/client.go b/backend/internal/provider/chatgpt/client.go index d27386e..0cb6b63 100644 --- a/backend/internal/provider/chatgpt/client.go +++ b/backend/internal/provider/chatgpt/client.go @@ -73,6 +73,15 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, return nil, nil, err } + // Fail over immediately when the account's image_gen allowance is spent: + // submitting anyway just burns the whole poll budget and surfaces as + // "image poll timeout". Unknown quota (init failed) proceeds as before. + if quota, qErr := c.fetchImageQuota(ctx, session, accessToken); qErr == nil && quota["unknown"] == false { + if remaining, ok := quota["remaining"].(int); ok && remaining <= 0 { + return nil, nil, fmt.Errorf("%w: image_gen remaining 0 (resets %s)", ErrQuotaExhausted, stringValue(quota["reset_after"])) + } + } + scriptSources, dataBuild, err := c.bootstrap(ctx, session) if err != nil { return nil, nil, err @@ -142,6 +151,10 @@ func (c *Client) FetchImageQuota(ctx context.Context, accessToken string) (map[s if err != nil { return nil, err } + return c.fetchImageQuota(ctx, session, accessToken) +} + +func (c *Client) fetchImageQuota(ctx context.Context, session tlsclient.HttpClient, accessToken string) (map[string]any, error) { path := "/backend-api/conversation/init" body, _ := json.Marshal(map[string]any{ "gizmo_id": nil, @@ -949,12 +962,27 @@ func (c *Client) pollForImage(ctx context.Context, session tlsclient.HttpClient, } func (c *Client) getFileDownloadURL(ctx context.Context, session tlsclient.HttpClient, accessToken, conversationID, fileID string, inline bool) (string, error) { - // Python parity (_get_file_download_url): GET /backend-api/files/{id}/download - // with NO query params. The old /files/download/{id}?conversation_id&inline - // form returned an inline stream URL that 403s with "File stream access denied". - _ = conversationID - _ = inline - path := "/backend-api/files/" + fileID + "/download" + // Current web client form: GET /backend-api/files/download/{id} + // ?conversation_id=...&inline=false → {"status":"success","download_url":...}. + // Falls back to the legacy /files/{id}/download form if the new one fails. + paths := []string{ + "/backend-api/files/download/" + fileID + "?conversation_id=" + conversationID + "&inline=" + strconv.FormatBool(inline), + "/backend-api/files/" + fileID + "/download", + } + var lastErr error + for _, path := range paths { + rawURL, err := c.fetchDownloadURL(ctx, session, accessToken, path) + if err == nil && rawURL != "" { + return rawURL, nil + } + if err != nil { + lastErr = err + } + } + return "", lastErr +} + +func (c *Client) fetchDownloadURL(ctx context.Context, session tlsclient.HttpClient, accessToken, path string) (string, error) { req, err := http.NewRequest(http.MethodGet, baseURL+path, nil) if err != nil { return "", err diff --git a/backend/internal/provider/chatgpt/util.go b/backend/internal/provider/chatgpt/util.go index f8711bf..1bb0b77 100644 --- a/backend/internal/provider/chatgpt/util.go +++ b/backend/internal/provider/chatgpt/util.go @@ -43,6 +43,22 @@ var ( "this request may violate our content polic", "this prompt may violate our content polic", "may violate our content policies", + "i can't help with", + "i can\u2019t help with", + "i cannot help with", + "i can't assist with", + "i can\u2019t assist with", + "i cannot assist with", + "i'm unable to help with", + "i\u2019m unable to help with", + "can't create images", + "can\u2019t create images", + "cannot create images", + "can't generate images", + "can\u2019t generate images", + "cannot generate images", + "unable to create images", + "unable to generate images", } ) diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index 4e194b3..21a85e8 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -58,10 +58,10 @@ var ( ErrVideoNotReady = errors.New("video is not ready yet") ) -// maxReferenceImageBytes bounds a single decoded reference image. 8 MB +// maxReferenceImageBytes bounds a single decoded reference image. 20 MB // comfortably covers real photos/screenshots; anything larger is almost // certainly abuse or a mistake. Mirrors Python core/refs.py. -const maxReferenceImageBytes = 8 * 1024 * 1024 +const maxReferenceImageBytes = 20 * 1024 * 1024 type V1Service struct { cfg *config.Config diff --git a/frontend/src/views/PlaygroundView.vue b/frontend/src/views/PlaygroundView.vue index 816f583..fc4a427 100644 --- a/frontend/src/views/PlaygroundView.vue +++ b/frontend/src/views/PlaygroundView.vue @@ -181,16 +181,18 @@ function setMode(m) { } function openPicker() { fileInput.value && fileInput.value.click() } -// Backend rejects reference images over 8MB (maxReferenceImageBytes). Enforce it +// Backend rejects reference images over 20MB (maxReferenceImageBytes). Enforce it // here at pick time so an oversized image fails fast with a clear message instead // of charging + failing upstream after the upload. -const MAX_REF_BYTES = 8 * 1024 * 1024 +const MAX_REF_BYTES = 20 * 1024 * 1024 function onFiles(ev) { addFiles(Array.from(ev.target.files || [])) if (ev.target) ev.target.value = '' } // Shared by the file picker AND drag-and-drop. Filters to images, honors the -// per-model max + 8MB cap, reads each to a data URL. +// per-model max + 20MB cap. The preview renders the picked file directly via an +// object URL (browser scales it down, no base64 copy in the DOM); the ORIGINAL +// file is kept untouched and is what gets uploaded at submit time. function addFiles(files) { files = files.filter((f) => f && f.type && f.type.startsWith('image/')) const room = Math.max(0, maxRefs.value - refImages.value.length) @@ -199,13 +201,11 @@ function addFiles(files) { for (const f of files) { if (added >= room) break if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue } - const reader = new FileReader() - reader.onload = () => refImages.value.push({ name: f.name, dataUrl: reader.result }) - reader.readAsDataURL(f) + refImages.value.push({ name: f.name, file: f, thumb: URL.createObjectURL(f) }) added++ } error.value = tooBig.length - ? `图片超过 8MB 已跳过:${tooBig.join('、')}(请压缩后再传)` + ? `图片超过 20MB 已跳过:${tooBig.join('、')}(请压缩后再传)` : '' } // Drag-and-drop onto the reference area. @@ -225,7 +225,10 @@ function onDragLeave(ev) { if (ev.currentTarget.contains(ev.relatedTarget)) return dragOver.value = false } -function removeRef(i) { refImages.value.splice(i, 1) } +function removeRef(i) { + const [gone] = refImages.value.splice(i, 1) + if (gone?.thumb?.startsWith('blob:')) URL.revokeObjectURL(gone.thumb) +} // Re-hydrate reference thumbnails from server URLs (after a reload). Fetches // each /images URL (same-origin, cookie-authed) and converts to a data URL so @@ -240,10 +243,20 @@ function restoreRefs(urls) { refImages.value = urls.map((u) => ({ name: 'ref', url: u })) } -// refToBase64 yields the raw base64 the backend expects, from either a freshly -// uploaded ref (dataUrl) or a restored one (url → fetch). Returns '' on failure. +// refToBase64 yields the raw base64 the backend expects, from a freshly picked +// ref (file — the untouched original), a data-URL ref (pasted/frame captures) +// or a restored one (url → fetch). Returns '' on failure. async function refToBase64(r) { try { + if (r.file) { + const dataUrl = await new Promise((res, rej) => { + const fr = new FileReader() + fr.onload = () => res(fr.result) + fr.onerror = rej + fr.readAsDataURL(r.file) + }) + return dataUrl.replace(/^data:[^,]*,/, '') + } if (r.dataUrl) return r.dataUrl.replace(/^data:[^,]*,/, '') if (r.url) { const blob = await (await fetch(r.url)).blob() @@ -650,7 +663,7 @@ onUnmounted(() => { @@ -659,7 +672,7 @@ onUnmounted(() => { @drop="onDrop" @dragover="onDragOver" @dragleave="onDragLeave">
- +