feat(upstream): 自定义上游支持 Vivid 协议(/v1/generate NDJSON)

- custom 上游新增 protocol 选项(openai|vivid);vivid 走
  POST /v1/generate NDJSON 流:跳过心跳空行,取末行终态,
  断流携 jobId 重连不重复扣费;参考图先 POST /v1/uploads
  拿预签名再 PUT 直传;错误码 4011/4002/4293/5001/4001 分类
- 添加上游弹窗加协议二选一;账号 meta 存 protocol 并可回显
- 附 vivid 链路单测(成功流/URL 透传/失败帧/错误码/两步上传)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-14 12:18:26 +08:00
co-authored by Devin
parent ae49b95e0f
commit babfc71f13
6 changed files with 513 additions and 26 deletions
+15 -1
View File
@@ -9,6 +9,7 @@ const emit = defineEmits(['close', 'imported'])
const isEdit = !!props.account
const name = ref(props.account?.email || '')
const baseUrl = ref(props.account?.base_url || '')
const protocol = ref(props.account?.protocol === 'vivid' ? 'vivid' : 'openai')
const key = ref('') // edit: blank = keep existing key
const allModels = ref([]) // existing models to pick from
const selected = ref(props.account?.models ? String(props.account.models).split(',').map((x) => x.trim()).filter(Boolean) : [])
@@ -43,6 +44,7 @@ async function submit() {
base_url: baseUrl.value.trim(),
key: key.value.trim(), // blank in edit = keep existing
models: selected.value.join(','),
protocol: protocol.value,
weight: Number(weight.value) || 0,
concurrency: Number(concurrency.value) || 1,
}))
@@ -65,7 +67,7 @@ async function submit() {
@click.self="emit('close')">
<div class="card !shadow-xl mt-14 mb-14 w-full max-w-lg">
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑上游' : '添加上游(自定义 OpenAI 兼容)' }}</h2>
<h2 class="text-sm font-semibold">{{ isEdit ? '编辑上游' : '添加上游' }}</h2>
<button @click="emit('close')" class="text-slate-400 hover:text-slate-700 transition-colors">
<Icon name="close" class="w-5 h-5" />
</button>
@@ -79,6 +81,18 @@ async function submit() {
<label class="text-xs text-slate-500">备注名</label>
<input v-model="name" class="field" placeholder="例如:我的中转 / xx-api" />
</div>
<div>
<label class="text-xs text-slate-500">协议</label>
<div class="flex gap-2">
<button v-for="p in [{id:'openai',label:'OpenAI',desc:'/v1/images/* 标准格式'},{id:'vivid',label:'Vivid',desc:'/v1/generate NDJSON 流'}]" :key="p.id" type="button"
@click="protocol = p.id"
class="flex-1 rounded-lg ring-1 px-3 py-2 text-left transition-colors"
:class="protocol === p.id ? 'ring-sky-400 bg-sky-500/10' : 'ring-slate-200 bg-white hover:ring-slate-300'">
<div class="text-xs font-medium" :class="protocol === p.id ? 'text-sky-700' : 'text-slate-700'">{{ p.label }}</div>
<div class="text-[11px] text-slate-400 font-mono mt-0.5">{{ p.desc }}</div>
</button>
</div>
</div>
<div>
<label class="text-xs text-slate-500">v1 URL <span class="text-rose-500">*</span></label>
<input v-model="baseUrl" class="field font-mono text-xs" placeholder="https://api.example.com(无需 /v1 结尾)" />