优化错误检查
This commit is contained in:
@@ -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
|
// 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
|
// 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.
|
// 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
|
return nil, nil, ErrContentPolicy
|
||||||
}
|
}
|
||||||
if len(fileIDs) > 0 || len(sedimentIDs) > 0 {
|
if len(fileIDs) > 0 || len(sedimentIDs) > 0 {
|
||||||
|
|||||||
@@ -129,6 +129,62 @@ func conversationRejected(conversation map[string]any) bool {
|
|||||||
return false
|
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 {
|
func stringValue(v any) string {
|
||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
|||||||
@@ -190,9 +190,8 @@ function onFiles(ev) {
|
|||||||
if (ev.target) ev.target.value = ''
|
if (ev.target) ev.target.value = ''
|
||||||
}
|
}
|
||||||
// Shared by the file picker AND drag-and-drop. Filters to images, honors the
|
// 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
|
// per-model max + 20MB cap. The preview shows a small downscaled thumbnail;
|
||||||
// object URL (browser scales it down, no base64 copy in the DOM); the ORIGINAL
|
// the ORIGINAL file is kept untouched and is what gets uploaded at submit time.
|
||||||
// file is kept untouched and is what gets uploaded at submit time.
|
|
||||||
function addFiles(files) {
|
function addFiles(files) {
|
||||||
files = files.filter((f) => f && f.type && f.type.startsWith('image/'))
|
files = files.filter((f) => f && f.type && f.type.startsWith('image/'))
|
||||||
const room = Math.max(0, maxRefs.value - refImages.value.length)
|
const room = Math.max(0, maxRefs.value - refImages.value.length)
|
||||||
@@ -201,13 +200,53 @@ function addFiles(files) {
|
|||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (added >= room) break
|
if (added >= room) break
|
||||||
if (f.size > MAX_REF_BYTES) { tooBig.push(f.name); continue }
|
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++
|
added++
|
||||||
}
|
}
|
||||||
error.value = tooBig.length
|
error.value = tooBig.length
|
||||||
? `图片超过 20MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
|
? `图片超过 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.
|
// Drag-and-drop onto the reference area.
|
||||||
const dragOver = ref(false)
|
const dragOver = ref(false)
|
||||||
function onDrop(ev) {
|
function onDrop(ev) {
|
||||||
@@ -225,10 +264,7 @@ function onDragLeave(ev) {
|
|||||||
if (ev.currentTarget.contains(ev.relatedTarget)) return
|
if (ev.currentTarget.contains(ev.relatedTarget)) return
|
||||||
dragOver.value = false
|
dragOver.value = false
|
||||||
}
|
}
|
||||||
function removeRef(i) {
|
function removeRef(i) { refImages.value.splice(i, 1) }
|
||||||
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
|
// 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
|
// each /images URL (same-origin, cookie-authed) and converts to a data URL so
|
||||||
@@ -240,7 +276,7 @@ function removeRef(i) {
|
|||||||
function restoreRefs(urls) {
|
function restoreRefs(urls) {
|
||||||
if (!Array.isArray(urls) || !urls.length) return
|
if (!Array.isArray(urls) || !urls.length) return
|
||||||
if (refImages.value.length) return // don't clobber refs the user already added
|
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
|
// refToBase64 yields the raw base64 the backend expects, from a freshly picked
|
||||||
@@ -465,7 +501,7 @@ function useAsRef(item) {
|
|||||||
if (item.kind === 'video') return
|
if (item.kind === 'video') return
|
||||||
const cap = maxRefs.value
|
const cap = maxRefs.value
|
||||||
if (cap <= 0) { flash('当前模型不支持参考图'); return }
|
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) {
|
if (cap === 1) {
|
||||||
refImages.value = [ref]
|
refImages.value = [ref]
|
||||||
} else if (refImages.value.length >= cap) {
|
} else if (refImages.value.length >= cap) {
|
||||||
|
|||||||
Reference in New Issue
Block a user