Files
image2api/frontend/src/views/DocsView.vue
T
chiyiandClaude Opus 4.8 606caaf047 Initial open-source release (MIT): image2api AI gateway
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>
2026-06-25 22:59:04 +08:00

373 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 &lt;key&gt;</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>