diff --git a/backend/internal/provider/chatgpt/client.go b/backend/internal/provider/chatgpt/client.go index 0cb6b63..01adbca 100644 --- a/backend/internal/provider/chatgpt/client.go +++ b/backend/internal/provider/chatgpt/client.go @@ -943,7 +943,7 @@ func (c *Client) pollForImage(ctx context.Context, session tlsclient.HttpClient, // Fail fast on a content-audit refusal: the assistant turn carries the // rejection text and no image will ever land, so polling to timeout only // wastes the whole budget. Only bail while we have no asset yet. - if len(fileIDs) == 0 && len(sedimentIDs) == 0 && conversationRejected(conv) { + if len(fileIDs) == 0 && len(sedimentIDs) == 0 && (conversationRejected(conv) || conversationEndedWithoutImage(conv)) { return nil, nil, ErrContentPolicy } if len(fileIDs) > 0 || len(sedimentIDs) > 0 { diff --git a/backend/internal/provider/chatgpt/util.go b/backend/internal/provider/chatgpt/util.go index 1bb0b77..ad62cdb 100644 --- a/backend/internal/provider/chatgpt/util.go +++ b/backend/internal/provider/chatgpt/util.go @@ -129,6 +129,62 @@ func conversationRejected(conversation map[string]any) bool { return false } +// conversationEndedWithoutImage reports whether the model finished its turn +// with a plain-text answer and never engaged the async image pipeline (no tool +// turn, no image_gen task). ChatGPT localizes audit refusals, so this catches +// rejections in ANY language structurally: a closed text-only turn can never +// produce an image, and polling further only burns the budget. +func conversationEndedWithoutImage(conversation map[string]any) bool { + mapping, _ := conversation["mapping"].(map[string]any) + if len(mapping) == 0 { + return false + } + for _, rawNode := range mapping { + node, _ := rawNode.(map[string]any) + message, _ := node["message"].(map[string]any) + if message == nil { + continue + } + if metadata, _ := message["metadata"].(map[string]any); metadata != nil { + if stringValue(metadata["image_gen_task_id"]) != "" || metadata["image_gen_async"] == true { + return false + } + } + author, _ := message["author"].(map[string]any) + role := strings.ToLower(strings.TrimSpace(stringValue(author["role"]))) + if role == "tool" { + return false + } + if role == "assistant" { + if content, _ := message["content"].(map[string]any); content != nil { + if ct := stringValue(content["content_type"]); ct != "" && ct != "text" { + return false // code/multimodal turn — generation underway + } + } + } + } + for _, rawNode := range mapping { + node, _ := rawNode.(map[string]any) + message, _ := node["message"].(map[string]any) + if message == nil { + continue + } + author, _ := message["author"].(map[string]any) + if strings.ToLower(strings.TrimSpace(stringValue(author["role"]))) != "assistant" { + continue + } + if message["end_turn"] != true || stringValue(message["status"]) != "finished_successfully" { + continue + } + var sb strings.Builder + collectText(message["content"], &sb) + if strings.TrimSpace(sb.String()) != "" { + return true + } + } + return false +} + func stringValue(v any) string { switch x := v.(type) { case string: diff --git a/frontend/src/views/PlaygroundView.vue b/frontend/src/views/PlaygroundView.vue index fc4a427..d60cd7f 100644 --- a/frontend/src/views/PlaygroundView.vue +++ b/frontend/src/views/PlaygroundView.vue @@ -190,9 +190,8 @@ function onFiles(ev) { if (ev.target) ev.target.value = '' } // Shared by the file picker AND drag-and-drop. Filters to images, honors the -// 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. +// per-model max + 20MB cap. The preview shows a small downscaled thumbnail; +// 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) @@ -201,13 +200,53 @@ function addFiles(files) { for (const f of files) { if (added >= room) break if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue } - refImages.value.push({ name: f.name, file: f, thumb: URL.createObjectURL(f) }) + makeThumb(f).then((thumb) => { + if (thumb) { refImages.value.push({ name: f.name, file: f, thumb }); return } + // thumbnail failed (odd format) — fall back to the full data URL preview + const reader = new FileReader() + reader.onload = () => refImages.value.push({ name: f.name, file: f, dataUrl: reader.result }) + reader.readAsDataURL(f) + }) added++ } error.value = tooBig.length ? `图片超过 20MB 已跳过:${tooBig.join('、')}(请压缩后再传)` : '' } +// Downscale an image blob/file to a small display-only thumbnail so the DOM +// never holds a multi-MB original just to render an 80px preview. The original +// bytes are untouched — uploads always use the source file/URL. +function makeThumb(blob, maxSide = 320) { + return new Promise((resolve) => { + const url = URL.createObjectURL(blob) + const img = new Image() + img.onload = () => { + try { + const scale = Math.min(1, maxSide / Math.max(img.width, img.height)) + const w = Math.max(1, Math.round(img.width * scale)) + const h = Math.max(1, Math.round(img.height * scale)) + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + canvas.getContext('2d').drawImage(img, 0, 0, w, h) + resolve(canvas.toDataURL('image/jpeg', 0.8)) + } catch { + resolve('') + } finally { + URL.revokeObjectURL(url) + } + } + img.onerror = () => { URL.revokeObjectURL(url); resolve('') } + img.src = url + }) +} + +// URL-backed refs (restored / 加入参考图) preview via the server-generated +// thumbnail object (url + '.thumb.jpg', ≤512px). The serving route falls back +// to the original when the thumb is missing, so this is always safe. The plain +// url remains what gets re-submitted. +function serverThumb(u) { return u + '.thumb.jpg' } + // Drag-and-drop onto the reference area. const dragOver = ref(false) function onDrop(ev) { @@ -225,10 +264,7 @@ function onDragLeave(ev) { if (ev.currentTarget.contains(ev.relatedTarget)) return dragOver.value = false } -function removeRef(i) { - const [gone] = refImages.value.splice(i, 1) - if (gone?.thumb?.startsWith('blob:')) URL.revokeObjectURL(gone.thumb) -} +function removeRef(i) { refImages.value.splice(i, 1) } // 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,7 +276,7 @@ function removeRef(i) { function restoreRefs(urls) { if (!Array.isArray(urls) || !urls.length) return if (refImages.value.length) return // don't clobber refs the user already added - refImages.value = urls.map((u) => ({ name: 'ref', url: u })) + refImages.value = urls.map((u) => ({ name: 'ref', url: u, thumb: serverThumb(u) })) } // refToBase64 yields the raw base64 the backend expects, from a freshly picked @@ -465,7 +501,7 @@ function useAsRef(item) { if (item.kind === 'video') return const cap = maxRefs.value if (cap <= 0) { flash('当前模型不支持参考图'); return } - const ref = { name: 'ref', url: item.url } + const ref = { name: 'ref', url: item.url, thumb: serverThumb(item.url) } if (cap === 1) { refImages.value = [ref] } else if (refImages.value.length >= cap) {