更新chatgpt协议
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user