Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
373 lines
19 KiB
Vue
373 lines
19 KiB
Vue
<script setup>
|
||
// API 对接文档 — OpenAI-compatible. Lists live models and shows ready-to-run
|
||
// curl / Python(openai SDK) examples for image + video, wired to this
|
||
// deployment's base URL and the caller's model ids.
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { auth } from '../auth'
|
||
import { api } from '../api'
|
||
import { points } from '../credits'
|
||
import Icon from '../components/Icon.vue'
|
||
|
||
const base = computed(() => location.origin) // /v1 is same-origin (dev: Vite proxy)
|
||
const keyHint = computed(() => auth.user?.api_keys?.[0]?.key_preview || 'YOUR_API_KEY')
|
||
|
||
const models = ref([])
|
||
onMounted(async () => {
|
||
const r = await api('/managed-models')
|
||
if (r.ok) models.value = (r.data?.data || []).filter((m) => m.enabled !== false)
|
||
})
|
||
|
||
const imageModels = computed(() => models.value.filter((m) => m.type === 'image'))
|
||
const videoModels = computed(() => models.value.filter((m) => m.type === 'video'))
|
||
const sampleImage = computed(() => imageModels.value[0]?.id || 'firefly-image-4')
|
||
const sampleVideo = computed(() => videoModels.value[0]?.id || 'firefly-kling3')
|
||
const sampleSeconds = computed(() => String(videoModels.value[0]?.durations?.[0] || '8s').replace(/s$/, ''))
|
||
|
||
function priceOf(m) {
|
||
if (m.type === 'video') {
|
||
// Video charge = resolution price + duration price; show the combined range.
|
||
const rv = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
|
||
const dv = Object.values(m.duration_prices || {}).filter((v) => v != null).map(Number)
|
||
if (!rv.length || !dv.length) return '—'
|
||
const lo = Math.min(...rv) + Math.min(...dv)
|
||
const hi = Math.max(...rv) + Math.max(...dv)
|
||
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分`
|
||
}
|
||
const vals = Object.values(m.prices || {}).filter((v) => v != null).map(Number)
|
||
if (!vals.length) return '—'
|
||
const lo = Math.min(...vals), hi = Math.max(...vals)
|
||
return lo === hi ? `${points(lo)} 积分` : `${points(lo)}–${points(hi)} 积分`
|
||
}
|
||
|
||
// ---- request parameter tables ----
|
||
const imageParams = [
|
||
['model', 'string', '必填', '模型 id,见上表(图像)'],
|
||
['prompt', 'string', '必填', '文字描述'],
|
||
['size', 'string', '可选', '"1024x1024" / "1536x1024" / "1024x1536" / "auto" → 决定比例'],
|
||
['quality', 'string', '可选', '"low"|"medium"|"high"|"auto" → 画质档 1K/2K/4K(钳到模型支持档)'],
|
||
]
|
||
const editParams = [
|
||
['image', 'file', '必填', '输入图;多张参考图重复 image[] 字段(multipart 文件上传)'],
|
||
['prompt', 'string', '必填', '编辑/参考描述'],
|
||
['model', 'string', '必填', '模型 id(需支持图生图)'],
|
||
['size', 'string', '可选', '同图像:决定比例'],
|
||
['quality', 'string', '可选', '同图像:决定画质档'],
|
||
]
|
||
const videoParams = [
|
||
['model', 'string', '必填', '模型 id,见上表(视频)'],
|
||
['prompt', 'string', '必填', '文字描述'],
|
||
['seconds', 'string|int', '必填', '时长秒数,如 "5" "8"(取决于模型支持)'],
|
||
['size', 'string', '可选', '如 "1280x720" / "720x1280" → 决定比例与分辨率'],
|
||
['input_reference', 'file', '可选', '首帧/参考图(multipart 文件;runway 图生视频必填 1 张)'],
|
||
]
|
||
|
||
// ---- examples (built in script so refs resolve correctly) ----
|
||
const examples = computed(() => [
|
||
{
|
||
title: '文生图 · curl',
|
||
code:
|
||
`curl ${base.value}/v1/images/generations \\
|
||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{
|
||
"model": "${sampleImage.value}",
|
||
"prompt": "a corgi running in a golden wheat field, cinematic",
|
||
"size": "1024x1024",
|
||
"quality": "high"
|
||
}'`,
|
||
},
|
||
{
|
||
title: '文生图 · Python (openai SDK)',
|
||
code:
|
||
`import base64
|
||
from openai import OpenAI
|
||
|
||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||
|
||
resp = client.images.generate(
|
||
model="${sampleImage.value}",
|
||
prompt="a corgi running in a golden wheat field, cinematic",
|
||
size="1024x1024",
|
||
quality="high",
|
||
)
|
||
# 结果是 base64(无 URL)
|
||
with open("out.png", "wb") as f:
|
||
f.write(base64.b64decode(resp.data[0].b64_json))`,
|
||
},
|
||
{
|
||
title: '图生图 / 参考图 · curl (multipart)',
|
||
code:
|
||
`curl ${base.value}/v1/images/edits \\
|
||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||
-F model="${sampleImage.value}" \\
|
||
-F prompt="把这张图改成赛博朋克风格" \\
|
||
-F quality="high" \\
|
||
-F image=@input.png
|
||
# 多张参考图:重复 -F image=@a.png -F image=@b.png`,
|
||
},
|
||
{
|
||
title: '图生图 · Python (openai SDK)',
|
||
code:
|
||
`import base64
|
||
from openai import OpenAI
|
||
|
||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||
|
||
resp = client.images.edit(
|
||
model="${sampleImage.value}",
|
||
image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
|
||
prompt="把这张图改成赛博朋克风格",
|
||
)
|
||
with open("out.png", "wb") as f:
|
||
f.write(base64.b64decode(resp.data[0].b64_json))`,
|
||
},
|
||
{
|
||
title: '视频 · curl(创建 → 轮询 → 下载)',
|
||
code:
|
||
`# 1) 创建任务 → 立即返回 {"id": "...", "status": "queued"}
|
||
curl ${base.value}/v1/videos \\
|
||
-H "Authorization: Bearer ${keyHint.value}" \\
|
||
-H "Content-Type: application/json" \\
|
||
-d '{
|
||
"model": "${sampleVideo.value}",
|
||
"prompt": "a paper boat sailing down a rainy street, cinematic",
|
||
"seconds": "${sampleSeconds.value}",
|
||
"size": "1280x720"
|
||
}'
|
||
|
||
# 2) 轮询状态,直到 status=completed
|
||
curl ${base.value}/v1/videos/<VIDEO_ID> \\
|
||
-H "Authorization: Bearer ${keyHint.value}"
|
||
|
||
# 3) 下载 mp4(完成后)
|
||
curl ${base.value}/v1/videos/<VIDEO_ID>/content \\
|
||
-H "Authorization: Bearer ${keyHint.value}" -o out.mp4`,
|
||
},
|
||
{
|
||
title: '视频 · Python (requests, 轮询)',
|
||
code:
|
||
`import time, requests
|
||
|
||
base = "${base.value}/v1"
|
||
h = {"Authorization": "Bearer ${keyHint.value}"}
|
||
|
||
# 1) 创建
|
||
job = requests.post(f"{base}/videos", headers=h, json={
|
||
"model": "${sampleVideo.value}",
|
||
"prompt": "a paper boat sailing down a rainy street",
|
||
"seconds": "${sampleSeconds.value}",
|
||
"size": "1280x720",
|
||
}).json()
|
||
vid = job["id"]
|
||
|
||
# 2) 轮询
|
||
while True:
|
||
s = requests.get(f"{base}/videos/{vid}", headers=h).json()
|
||
if s["status"] in ("completed", "failed"):
|
||
break
|
||
time.sleep(5)
|
||
|
||
# 3) 下载
|
||
if s["status"] == "completed":
|
||
mp4 = requests.get(f"{base}/videos/{vid}/content", headers=h).content
|
||
open("out.mp4", "wb").write(mp4)`,
|
||
},
|
||
{
|
||
title: '列出模型 · curl',
|
||
code:
|
||
`curl ${base.value}/v1/models \\
|
||
-H "Authorization: Bearer ${keyHint.value}"`,
|
||
},
|
||
])
|
||
|
||
// ---- copy + toast ----
|
||
const toastMsg = ref('')
|
||
let t = null
|
||
function toast(m) { toastMsg.value = m; clearTimeout(t); t = setTimeout(() => (toastMsg.value = ''), 1800) }
|
||
async function copy(text) {
|
||
try { await navigator.clipboard.writeText(text); toast('已复制') } catch { toast('复制失败') }
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="theme-text space-y-10">
|
||
<header>
|
||
<div class="text-[10px] uppercase tracking-[0.3em] text-sky-300/70 font-medium">开发者</div>
|
||
<h1 class="mt-2 text-4xl md:text-5xl font-bold tracking-tight">接口文档</h1>
|
||
<p class="text-white/45 mt-2">完全兼容 OpenAI 接口规范 — 改个 <code class="text-white/70">base_url</code> 和 <code class="text-white/70">api_key</code> 即可直接调用。图像 / 视频 / 图生图全支持。</p>
|
||
</header>
|
||
|
||
<!-- quickstart -->
|
||
<section class="grid md:grid-cols-2 gap-4">
|
||
<div class="card p-6">
|
||
<h2 class="text-sm font-semibold text-white/80">基础信息</h2>
|
||
<dl class="mt-4 space-y-3 text-sm">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<dt class="text-white/45">Base URL</dt><dd class="font-mono text-white/90">{{ base }}/v1</dd>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3">
|
||
<dt class="text-white/45">鉴权</dt><dd class="font-mono text-white/90">Authorization: Bearer <key></dd>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3">
|
||
<dt class="text-white/45">你的 Key</dt><dd class="font-mono text-white/70">{{ keyHint }}</dd>
|
||
</div>
|
||
</dl>
|
||
<p class="text-[11px] text-white/40 mt-4">还没有 Key?去 <router-link to="/settings" class="text-violet-300 underline">设置 → API Key</router-link> 生成。</p>
|
||
</div>
|
||
|
||
<div class="card p-6">
|
||
<h2 class="text-sm font-semibold text-white/80">端点</h2>
|
||
<ul class="mt-4 space-y-2.5 text-sm font-mono">
|
||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/models</span></li>
|
||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/generations</span><span class="text-white/35 font-sans text-xs">文生图</span></li>
|
||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/images/edits</span><span class="text-white/35 font-sans text-xs">图生图(multipart)</span></li>
|
||
<li class="flex items-center gap-2"><span class="badge-post">POST</span><span class="text-white/80">/v1/videos</span><span class="text-white/35 font-sans text-xs">建视频任务</span></li>
|
||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}</span><span class="text-white/35 font-sans text-xs">查状态</span></li>
|
||
<li class="flex items-center gap-2"><span class="badge-get">GET</span><span class="text-white/80">/v1/videos/{id}/content</span><span class="text-white/35 font-sans text-xs">下载 mp4</span></li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- models -->
|
||
<section>
|
||
<h2 class="text-lg font-semibold mb-3">可用模型</h2>
|
||
<div class="card overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead>
|
||
<tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||
<th class="px-4 py-3 font-medium">model</th>
|
||
<th class="px-4 py-3 font-medium">类型</th>
|
||
<th class="px-4 py-3 font-medium">分辨率 / 时长</th>
|
||
<th class="px-4 py-3 font-medium text-right">价格</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="m in models" :key="m.id" class="border-b border-white/[0.04] last:border-0">
|
||
<td class="px-4 py-3 font-mono text-white/90">{{ m.id }}</td>
|
||
<td class="px-4 py-3 text-white/60">{{ m.type === 'video' ? '视频' : '图像' }}</td>
|
||
<td class="px-4 py-3 text-white/60">{{ (m.type === 'video' ? m.durations : m.resolutions || [])?.join(' · ') || '—' }}</td>
|
||
<td class="px-4 py-3 text-right tabular-nums text-white/80">{{ priceOf(m) }}</td>
|
||
</tr>
|
||
<tr v-if="!models.length"><td colspan="4" class="px-4 py-10 text-center text-white/35">暂无可用模型</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- parameters -->
|
||
<section class="grid lg:grid-cols-2 gap-6">
|
||
<div>
|
||
<h2 class="text-lg font-semibold mb-3">文生图参数 <span class="text-xs font-normal text-white/40">/v1/images/generations</span></h2>
|
||
<div class="card overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr v-for="p in imageParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h2 class="text-lg font-semibold mb-3">图生图参数 <span class="text-xs font-normal text-white/40">/v1/images/edits · multipart</span></h2>
|
||
<div class="card overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr v-for="p in editParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lg:col-span-2">
|
||
<h2 class="text-lg font-semibold mb-3">视频参数 <span class="text-xs font-normal text-white/40">/v1/videos · 异步</span></h2>
|
||
<div class="card overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead><tr class="text-left text-[11px] uppercase tracking-wider text-white/40 border-b border-white/[0.08]">
|
||
<th class="px-4 py-2.5 font-medium">参数</th><th class="px-4 py-2.5 font-medium">类型</th><th class="px-4 py-2.5 font-medium">必填</th><th class="px-4 py-2.5 font-medium">说明</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr v-for="p in videoParams" :key="p[0]" class="border-b border-white/[0.04] last:border-0">
|
||
<td class="px-4 py-2.5 font-mono text-white/85">{{ p[0] }}</td>
|
||
<td class="px-4 py-2.5 text-white/50 font-mono text-xs">{{ p[1] }}</td>
|
||
<td class="px-4 py-2.5 text-white/55">{{ p[2] }}</td>
|
||
<td class="px-4 py-2.5 text-white/60 text-xs">{{ p[3] }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- examples -->
|
||
<section class="space-y-4">
|
||
<h2 class="text-lg font-semibold">调用示例</h2>
|
||
<div v-for="ex in examples" :key="ex.title" class="card overflow-hidden">
|
||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-white/[0.06]">
|
||
<span class="text-xs text-white/55">{{ ex.title }}</span>
|
||
<button @click="copy(ex.code)" class="text-xs text-white/50 hover:text-white inline-flex items-center gap-1.5 transition-colors">
|
||
<Icon name="copy" class="w-3.5 h-3.5" /> 复制
|
||
</button>
|
||
</div>
|
||
<pre class="p-4 text-[12px] leading-relaxed text-white/80 overflow-auto"><code>{{ ex.code }}</code></pre>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- responses -->
|
||
<section>
|
||
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
||
<div class="card p-6 space-y-3 text-sm text-white/70">
|
||
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "b64_json": "..." }] }' }}</code> —— 产物以 <strong class="text-white/90">base64</strong> 直接放在 <code class="text-white/85 font-mono">data[0].b64_json</code>(原始 base64、无 <code class="text-white/70">data:</code> 前缀),自行解码保存为图片。<strong class="text-white/90">不返回 URL、服务端不留存</strong>。</p>
|
||
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
||
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
||
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
|
||
<li>轮询 <code class="text-white/85 font-mono">GET /v1/videos/{id}</code>,<code class="text-white/70">status</code> 从 <code class="text-white/70">queued → in_progress → completed</code>(或 <code class="text-white/70">failed</code>)</li>
|
||
<li>完成后 <code class="text-white/85 font-mono">GET /v1/videos/{id}/content</code> 返回 <strong class="text-white/90">mp4 原始二进制</strong>(非 base64、非 URL)</li>
|
||
</ol>
|
||
<p><strong class="text-white/90">计费(预扣)</strong>:生成<strong class="text-white/90">前</strong>按上表价格从你的 Key 账号预扣积分;图像或视频上游失败会自动退回 —— 失败不扣费。</p>
|
||
<p><strong class="text-white/90">参数映射</strong>:<code class="text-white/70">size</code>→比例,<code class="text-white/70">quality</code>(low/medium/high)→画质档(1K/2K/4K,钳到模型支持档),<code class="text-white/70">seconds</code>→视频时长。参数须落在该模型定价表内,否则 400;余额不足 402。</p>
|
||
<div class="pt-2 grid sm:grid-cols-2 gap-2 text-xs">
|
||
<div class="flex items-center gap-2"><span class="badge-err">401</span> Key 无效 / 上游需重新授权</div>
|
||
<div class="flex items-center gap-2"><span class="badge-err">404</span> 未知 model / 视频任务不存在</div>
|
||
<div class="flex items-center gap-2"><span class="badge-err">400</span> 参数缺失 / 不支持或未定价</div>
|
||
<div class="flex items-center gap-2"><span class="badge-err">402</span> 积分不足</div>
|
||
<div class="flex items-center gap-2"><span class="badge-err">409</span> 视频尚未完成(content 未就绪)</div>
|
||
<div class="flex items-center gap-2"><span class="badge-err">429</span> 账号并发已满,请重试</div>
|
||
<div class="flex items-center gap-2"><span class="badge-err">503</span> 上游繁忙,请重试</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<transition name="fade">
|
||
<div v-if="toastMsg" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-white text-black text-sm font-medium px-5 py-2.5 rounded-full shadow-2xl">{{ toastMsg }}</div>
|
||
</transition>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.badge-get, .badge-post, .badge-err {
|
||
border-radius: 4px; padding: 2px 6px; font-size: 10px; line-height: 1;
|
||
}
|
||
.badge-get { background: rgb(16 185 129 / 0.14); color: rgb(4 120 87); box-shadow: inset 0 0 0 1px rgb(16 185 129 / 0.35); }
|
||
.badge-post { background: rgb(14 165 233 / 0.14); color: rgb(3 105 161); box-shadow: inset 0 0 0 1px rgb(14 165 233 / 0.35); }
|
||
.badge-err { background: rgb(244 63 94 / 0.12); color: rgb(190 18 60); box-shadow: inset 0 0 0 1px rgb(244 63 94 / 0.3); font-family: ui-monospace, monospace; }
|
||
html.dark .badge-get { background: rgb(16 185 129 / 0.15); color: rgb(110 231 183); box-shadow: inset 0 0 0 1px rgb(52 211 153 / 0.3); }
|
||
html.dark .badge-post { background: rgb(14 165 233 / 0.15); color: rgb(125 211 252); box-shadow: inset 0 0 0 1px rgb(56 189 248 / 0.3); }
|
||
html.dark .badge-err { background: rgb(244 63 94 / 0.15); color: rgb(253 164 175); box-shadow: inset 0 0 0 1px rgb(251 113 133 / 0.3); }
|
||
</style>
|