更新chatgpt协议

This commit is contained in:
2026-07-06 10:21:44 +08:00
parent c96bee4094
commit e517c56225
5 changed files with 78 additions and 21 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ func readMultipartImages(c *gin.Context, keys ...string) []string {
if e != nil { if e != nil {
continue continue
} }
b, _ := io.ReadAll(io.LimitReader(f, 8<<20+1)) b, _ := io.ReadAll(io.LimitReader(f, 20<<20+1))
f.Close() f.Close()
if len(b) > 0 { if len(b) > 0 {
out = append(out, base64.StdEncoding.EncodeToString(b)) out = append(out, base64.StdEncoding.EncodeToString(b))
+34 -6
View File
@@ -73,6 +73,15 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model,
return nil, nil, err 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) scriptSources, dataBuild, err := c.bootstrap(ctx, session)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -142,6 +151,10 @@ func (c *Client) FetchImageQuota(ctx context.Context, accessToken string) (map[s
if err != nil { if err != nil {
return nil, err 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" path := "/backend-api/conversation/init"
body, _ := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"gizmo_id": nil, "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) { 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 // Current web client form: GET /backend-api/files/download/{id}
// with NO query params. The old /files/download/{id}?conversation_id&inline // ?conversation_id=...&inline=false → {"status":"success","download_url":...}.
// form returned an inline stream URL that 403s with "File stream access denied". // Falls back to the legacy /files/{id}/download form if the new one fails.
_ = conversationID paths := []string{
_ = inline "/backend-api/files/download/" + fileID + "?conversation_id=" + conversationID + "&inline=" + strconv.FormatBool(inline),
path := "/backend-api/files/" + fileID + "/download" "/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) req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
if err != nil { if err != nil {
return "", err return "", err
+16
View File
@@ -43,6 +43,22 @@ var (
"this request may violate our content polic", "this request may violate our content polic",
"this prompt may violate our content polic", "this prompt may violate our content polic",
"may violate our content policies", "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",
} }
) )
+2 -2
View File
@@ -58,10 +58,10 @@ var (
ErrVideoNotReady = errors.New("video is not ready yet") 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 // comfortably covers real photos/screenshots; anything larger is almost
// certainly abuse or a mistake. Mirrors Python core/refs.py. // certainly abuse or a mistake. Mirrors Python core/refs.py.
const maxReferenceImageBytes = 8 * 1024 * 1024 const maxReferenceImageBytes = 20 * 1024 * 1024
type V1Service struct { type V1Service struct {
cfg *config.Config cfg *config.Config
+25 -12
View File
@@ -181,16 +181,18 @@ function setMode(m) {
} }
function openPicker() { fileInput.value && fileInput.value.click() } 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 // here at pick time so an oversized image fails fast with a clear message instead
// of charging + failing upstream after the upload. // of charging + failing upstream after the upload.
const MAX_REF_BYTES = 8 * 1024 * 1024 const MAX_REF_BYTES = 20 * 1024 * 1024
function onFiles(ev) { function onFiles(ev) {
addFiles(Array.from(ev.target.files || [])) addFiles(Array.from(ev.target.files || []))
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 + 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) { 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)
@@ -199,13 +201,11 @@ 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 }
const reader = new FileReader() refImages.value.push({ name: f.name, file: f, thumb: URL.createObjectURL(f) })
reader.onload = () => refImages.value.push({ name: f.name, dataUrl: reader.result })
reader.readAsDataURL(f)
added++ added++
} }
error.value = tooBig.length error.value = tooBig.length
? `图片超过 8MB 已跳过:${tooBig.join('、')}(请压缩后再传)` ? `图片超过 20MB 已跳过:${tooBig.join('、')}(请压缩后再传)`
: '' : ''
} }
// Drag-and-drop onto the reference area. // Drag-and-drop onto the reference area.
@@ -225,7 +225,10 @@ 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) { 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 // 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,10 +243,20 @@ function restoreRefs(urls) {
refImages.value = urls.map((u) => ({ name: 'ref', url: u })) refImages.value = urls.map((u) => ({ name: 'ref', url: u }))
} }
// refToBase64 yields the raw base64 the backend expects, from either a freshly // refToBase64 yields the raw base64 the backend expects, from a freshly picked
// uploaded ref (dataUrl) or a restored one (url → fetch). Returns '' on failure. // 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) { async function refToBase64(r) {
try { 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.dataUrl) return r.dataUrl.replace(/^data:[^,]*,/, '')
if (r.url) { if (r.url) {
const blob = await (await fetch(r.url)).blob() const blob = await (await fetch(r.url)).blob()
@@ -650,7 +663,7 @@ onUnmounted(() => {
<label class="block text-xs font-medium text-slate-500 mb-1.5"> <label class="block text-xs font-medium text-slate-500 mb-1.5">
参考图 参考图
<span class="text-slate-400 font-normal"> <span class="text-slate-400 font-normal">
(最多 {{ maxRefs }} 张{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单张 ≤8MB) (最多 {{ maxRefs }} 张{{ refMode === 'frame' && mode === 'video' ? (maxRefs >= 2 ? ' · 首帧/末帧' : ' · 首帧') : '' }} · 单张 ≤20MB)
</span> </span>
<span v-if="refsRequired" class="text-rose-500">*</span> <span v-if="refsRequired" class="text-rose-500">*</span>
</label> </label>
@@ -659,7 +672,7 @@ onUnmounted(() => {
@drop="onDrop" @dragover="onDragOver" @dragleave="onDragLeave"> @drop="onDrop" @dragover="onDragOver" @dragleave="onDragLeave">
<div v-for="(img, i) in refImages" :key="i" <div v-for="(img, i) in refImages" :key="i"
class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all"> class="relative w-20 h-20 rounded-lg overflow-hidden border border-slate-200 bg-slate-50 transition-all">
<img :src="img.dataUrl || img.url" class="w-full h-full object-cover" /> <img :src="img.thumb || img.dataUrl || img.url" class="w-full h-full object-cover" />
<button type="button" @click="removeRef(i)" <button type="button" @click="removeRef(i)"
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed"> class="absolute top-1 right-1 w-5 h-5 rounded-full bg-slate-900/70 text-white hover:bg-rose-500 grid place-items-center disabled:opacity-40 disabled:cursor-not-allowed">
<Icon name="close" class="w-3 h-3" /> <Icon name="close" class="w-3 h-3" />