优化错误检查

This commit is contained in:
2026-07-06 10:37:46 +08:00
parent e517c56225
commit e05db36f63
3 changed files with 103 additions and 11 deletions
+1 -1
View File
@@ -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 {
+56
View File
@@ -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:
+46 -10
View File
@@ -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) {