feat: sync all features to image2api

This commit is contained in:
2026-08-15 23:00:30 +08:00
parent 5403acc4c9
commit 4fb7fc9204
19 changed files with 1301 additions and 50 deletions
+15
View File
@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1
# Production frontend: built from source, served by nginx on HTTP :80.
# ---- build stage ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# ---- serve stage ----
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.prod.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+44
View File
@@ -0,0 +1,44 @@
# nginx for the docker production stack — HTTP on :80.
# TLS + domain are handled externally; this just serves the SPA and proxies API.
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 50m;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# video / large-image generation can block minutes — avoid the 60s 504.
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# Hashed build assets never change — cache hard.
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# SPA fallback; index.html must never be cached (else stale bundle hash).
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# ---- API / media / health -> backend ----
location ^~ /admin/api/ { proxy_pass http://backend:6666; add_header Cache-Control "no-store" always; }
location ^~ /images/ { proxy_pass http://backend:6666; }
location = /health { proxy_pass http://backend:6666; }
# /v1 is per-API-key authenticated — never cache.
location ^~ /v1/ {
proxy_pass http://backend:6666;
add_header Cache-Control "no-store" always;
}
}
+10 -3
View File
@@ -13,7 +13,7 @@ const isError = ref(false)
const submitting = ref(false)
// type → token pool (for the post-import weight PATCH).
const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok' }
const TYPE_POOL = { openai: 'chatgpt', adobe: 'adobe', runway: 'runway', leonardo: 'leonardo', krea: 'krea', imagine: 'imagine', grok: 'grok', creativefabrica: 'creativefabrica' }
// Live preview of what the parser would extract — updates as the user types
// so they can see whether their paste was understood before clicking import.
@@ -26,7 +26,8 @@ const detected = computed(() => {
const krea = items.filter((x) => x.type === 'krea').length
const imagine = items.filter((x) => x.type === 'imagine').length
const grok = items.filter((x) => x.type === 'grok').length
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok }
const cf = items.filter((x) => x.type === 'creativefabrica').length
return { total: items.length, openai, adobe, runway, leonardo, krea, imagine, grok, cf }
})
function setStatus(text, err = false) {
@@ -58,7 +59,9 @@ async function doSmartImport() {
? await api('/tokens/import-krea-cookie', jsonBody('POST', { cookie: it.value }))
: it.type === 'imagine'
? await api('/tokens/import-imagine-token', jsonBody('POST', { value: it.value }))
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
: it.type === 'creativefabrica'
? await api('/tokens/import-creativefabrica-cookie', jsonBody('POST', { cookie: it.value }))
: await api('/tokens/import-adobe-cookie', jsonBody('POST', { cookie: it.value }))
if (r.ok) {
ok++
// Apply the chosen weight to the freshly-imported account (best-effort).
@@ -107,6 +110,7 @@ async function doSmartImport() {
<strong class="text-slate-700">Runway JWT</strong>(自动与 ChatGPT 区分)
<strong class="text-slate-700">Leonardo Cookie</strong>(须含 better-auth.session_data)
<strong class="text-slate-700">Krea Cookie</strong>( sb-superb-auth)
<strong class="text-slate-700">Creative Fabrica Cookie</strong>( cfauth_* wordpress_logged_in_)
<strong class="text-slate-700">Imagine Token</strong>(<code class="px-1 bg-slate-100 rounded">{"token","refreshToken","email","parentId"}</code>)
<strong class="text-slate-700">Grok SSO</strong>(grok.com <code class="px-1 bg-slate-100 rounded">sso</code> ,仅含 session_id,自动与 ChatGPT/Runway 区分)
<strong class="text-slate-700">多个 JWT</strong>(换行分隔)
@@ -142,6 +146,9 @@ async function doSmartImport() {
<span v-if="detected.grok" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-slate-700 bg-slate-100 ring-1 ring-slate-300">
Grok · <span class="tabular-nums">{{ detected.grok }}</span>
</span>
<span v-if="detected.cf" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-fuchsia-700 bg-fuchsia-50 ring-1 ring-fuchsia-200">
Creative Fabrica · <span class="tabular-nums">{{ detected.cf }}</span>
</span>
</template>
<span v-else class="text-rose-600">未识别到任何 Cookie JWT</span>
</div>
+8
View File
@@ -49,6 +49,13 @@ export function looksLikeKreaCookie(s) {
return /sb-superb-auth-token/.test(s || '')
}
// Creative Fabrica session cookies carry the cfauth_* family (cfauth_uid /
// cfauth_sig / cfauth_utp) plus the WordPress SSO cookie — those markers tell
// them apart from an Adobe/Krea/Leonardo cookie (all otherwise opaque strings).
export function looksLikeCreativeFabricaCookie(s) {
return /cfauth_/.test(s || '') || /cfAmpAnonymousId/.test(s || '') || /wordpress_logged_in_/.test(s || '')
}
// An Imagine.art credential is a JSON object { token, refreshToken } (both JWTs).
function isImagineObj(o) {
return !!o && typeof o === 'object' &&
@@ -67,6 +74,7 @@ function cookieType(v) {
if (looksLikeImagineToken(v)) return 'imagine'
if (looksLikeKreaCookie(v)) return 'krea'
if (looksLikeLeonardoCookie(v)) return 'leonardo'
if (looksLikeCreativeFabricaCookie(v)) return 'creativefabrica'
return 'adobe'
}
+3 -1
View File
@@ -12,7 +12,9 @@ function declared(v) {
// 参考资产时返回 null。model 兼容 /managed-models(type) 与 /models(kind) 两种字段。
export function mediaCaps(model, preset) {
if ((model?.type || model?.kind) !== 'video') return null
const isSeedance = /^seedance/.test(model?.id || '')
// creativefabrica 上游只收图片参考(VIDEO_FRAME_TYPE_REFERENCE),不收视频/音频;
// 它虽然叫 seedance,但要排除,否则会错误地显示「视频 3 音频 3」。
const isSeedance = /^seedance/.test(model?.id || '') && (model?.provider || '') !== 'creativefabrica'
const videos = declared(preset?.max_videos) ?? (isSeedance ? SEEDANCE_FALLBACK.videos : 0)
const audios = declared(preset?.max_audios) ?? (isSeedance ? SEEDANCE_FALLBACK.audios : 0)
if (!videos && !audios) return null
+7 -3
View File
@@ -55,7 +55,7 @@ const stats = ref({
total: 0, dead_total: 0,
openai: { ...EMPTY_TYPE }, adobe: { ...EMPTY_TYPE }, runway: { ...EMPTY_TYPE },
leonardo: { ...EMPTY_TYPE }, krea: { ...EMPTY_TYPE }, imagine: { ...EMPTY_TYPE },
grok: { ...EMPTY_TYPE },
grok: { ...EMPTY_TYPE }, creativefabrica: { ...EMPTY_TYPE },
})
// 异常账号 = 已失效(401)被锁定的号(红色锁定行)。用于「一键删除异常账号」。
@@ -69,6 +69,7 @@ function typePill(t) {
leonardo: 'bg-amber-500/10 text-amber-300 ring-amber-400/30',
krea: 'bg-sky-500/10 text-sky-300 ring-sky-400/30',
imagine: 'bg-teal-500/10 text-teal-300 ring-teal-400/30',
creativefabrica: 'bg-fuchsia-500/10 text-fuchsia-300 ring-fuchsia-400/30',
}[t] || 'bg-white/[0.06] text-white/70 ring-white/15'
}
// 与后端 accountConcurrency 保持一致:adobe 普号固定 1 并发,会员号取配置值
@@ -338,7 +339,7 @@ onMounted(() => { loadAccounts(); loadModelList() })
<div class="text-2xl font-semibold mt-1 tabular-nums">{{ stats.total }}</div>
<div class="text-[10px] text-white/35 mt-0.5">成功/失败/限额</div>
</div>
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80'],['grok','Grok','text-slate-300/80']]"
<div v-for="t in [['openai','OpenAI','text-emerald-300/80'],['adobe','Adobe','text-rose-300/80'],['runway','Runway','text-violet-300/80'],['leonardo','Leonardo','text-amber-300/80'],['krea','Krea','text-sky-300/80'],['imagine','Imagine','text-teal-300/80'],['grok','Grok','text-slate-300/80'],['creativefabrica','Creative Fabrica','text-fuchsia-300/80']]"
:key="t[0]" class="card p-4">
<div class="text-[11px] uppercase tracking-wider" :class="t[2]">{{ t[1] }}</div>
<div class="text-2xl font-semibold mt-1 tabular-nums">
@@ -373,6 +374,9 @@ onMounted(() => { loadAccounts(); loadModelList() })
<button @click="setFilter(() => typeFilter = 'grok')" class="fp" :class="typeFilter === 'grok' && 'fp-on'">
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>Grok
</button>
<button @click="setFilter(() => typeFilter = 'creativefabrica')" class="fp" :class="typeFilter === 'creativefabrica' && 'fp-on'">
<span class="w-1.5 h-1.5 rounded-full bg-fuchsia-400"></span>Creative Fabrica
</button>
</div>
<div class="w-px h-5 bg-white/10"></div>
<div class="flex items-center gap-1">
@@ -507,7 +511,7 @@ onMounted(() => { loadAccounts(); loadModelList() })
<span class="text-white/20">/</span>
<span :class="a.video_remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.video_remaining }}</span>
</span>
<span v-else-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine') && a.remaining != null && a.remaining !== -1"
<span v-else-if="(a.type === 'openai' || a.type === 'adobe' || a.type === 'runway' || a.type === 'leonardo' || a.type === 'krea' || a.type === 'imagine' || a.type === 'creativefabrica') && a.remaining != null && a.remaining !== -1"
class="font-mono font-semibold"
:class="a.remaining > 0 ? 'text-emerald-300' : 'text-rose-300'">{{ a.remaining }}</span>
<span v-else class="text-white/25" :title="a._quotaError || ''"></span>
+3 -1
View File
@@ -135,7 +135,9 @@ const maxAudiosRaw = computed(() => {
const n = familyPreset.value?.max_audios
return n === undefined || n === null ? null : Number(n)
})
const isSeedanceModel = computed(() => /^seedance/.test(model.value?.id || ''))
// creativefabrica 上游只收图片参考,没有首尾帧/视频/音频,虽叫 seedance 也要排除。
const isSeedanceModel = computed(() =>
/^seedance/.test(model.value?.id || '') && (model.value?.provider || '') !== 'creativefabrica')
// 支持图片以外的参考资产(视频/音频)的模型:seedance 系 + 预设声明了音视频上限的
const supportsMediaRefs = computed(() =>
isSeedanceModel.value || maxVideosRaw.value > 0 || maxAudiosRaw.value > 0)