feat: multi-feature update
This commit is contained in:
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import SettingsPanel from './components/SettingsPanel.vue'
|
||||
import ImageGenerator from './components/ImageGenerator.vue'
|
||||
import Gallery from './components/Gallery.vue'
|
||||
import { probeSession, listModels, getSelf, type NewApiUser, type Auth } from './lib/api'
|
||||
import { loadSettings } from './lib/settings'
|
||||
|
||||
const user = ref<NewApiUser | null>(null)
|
||||
const auth = ref<Auth>({ kind: 'session' })
|
||||
const baseUrl = ref<string>(loadSettings().baseUrl)
|
||||
const models = ref<string[]>([])
|
||||
const error = ref<string>('')
|
||||
const settingsRef = ref<InstanceType<typeof SettingsPanel> | null>(null)
|
||||
const galleryRef = ref<InstanceType<typeof Gallery> | null>(null)
|
||||
const checking = ref<boolean>(true)
|
||||
|
||||
async function refreshSession() {
|
||||
checking.value = true
|
||||
try {
|
||||
const u = await probeSession(baseUrl.value)
|
||||
user.value = u
|
||||
error.value = ''
|
||||
if (u) {
|
||||
try {
|
||||
models.value = await listModels(baseUrl.value, { kind: 'session' })
|
||||
} catch (e) {
|
||||
error.value = `已登录,但拉取模型失败: ${e}`
|
||||
}
|
||||
} else {
|
||||
models.value = []
|
||||
}
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshSession)
|
||||
// Re-probe when the tab regains focus (e.g. user came back from /sign-in).
|
||||
// `visibilitychange` covers mobile / tab-switch too; `focus` covers desktop.
|
||||
window.addEventListener('focus', refreshSession)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') refreshSession()
|
||||
})
|
||||
|
||||
async function onSessionChanged(u: NewApiUser | null) {
|
||||
user.value = u
|
||||
if (u) {
|
||||
try {
|
||||
models.value = await listModels(baseUrl.value, { kind: 'session' })
|
||||
} catch (e) {
|
||||
error.value = `已登录,但拉取模型失败: ${e}`
|
||||
}
|
||||
} else {
|
||||
models.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function onAuthOverridden(a: Auth) {
|
||||
auth.value = a
|
||||
if (a.kind === 'sk') {
|
||||
// refresh user info using the new auth
|
||||
getSelf(baseUrl.value, a)
|
||||
.then((u) => {
|
||||
user.value = u
|
||||
})
|
||||
.catch((e) => {
|
||||
error.value = `sk-key 无效: ${e}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onGenerated(img: { prompt: string; model: string; urls: string[] }) {
|
||||
galleryRef.value?.add(img)
|
||||
// refresh quota after a generation (best-effort)
|
||||
if (user.value) {
|
||||
probeSession(baseUrl.value)
|
||||
.then((u) => {
|
||||
if (u) user.value = u
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore */
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onError(msg: string) {
|
||||
error.value = msg
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-full max-w-5xl mx-auto p-6 space-y-5">
|
||||
<header class="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">newapi-image-gen</h1>
|
||||
<p class="text-xs text-zinc-500 mt-0.5">
|
||||
走 new-api 的 <code class="text-zinc-400">/v1/images/generations</code>,额度扣 new-api 账户
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
v-if="user"
|
||||
href="/"
|
||||
class="text-xs text-zinc-500 hover:text-zinc-300"
|
||||
>← 返回 new-api 主页</a>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="rounded-md border border-rose-800 bg-rose-950/40 px-3 py-2 text-sm text-rose-300">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<SettingsPanel
|
||||
ref="settingsRef"
|
||||
:user="user"
|
||||
@session-changed="onSessionChanged"
|
||||
@auth-overridden="onAuthOverridden"
|
||||
/>
|
||||
|
||||
<div v-if="checking" class="text-sm text-zinc-500">检查登录态…</div>
|
||||
|
||||
<ImageGenerator
|
||||
v-else-if="user"
|
||||
:base-url="baseUrl"
|
||||
:auth="auth"
|
||||
:models="models"
|
||||
@generated="onGenerated"
|
||||
@error="onError"
|
||||
/>
|
||||
<div v-else class="rounded-xl border border-dashed border-zinc-800 p-6 text-center text-sm text-zinc-500">
|
||||
先在上方登录,登录后会自动出现生图面板。
|
||||
</div>
|
||||
|
||||
<Gallery ref="galleryRef" :base-url="baseUrl" />
|
||||
|
||||
<footer class="pt-4 pb-2 text-center text-xs text-zinc-600">
|
||||
登录走 new-api 自己的 OAuth,会话 cookie 跟 new-api 主站共享;本前端不会碰你的密码或 token。
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
prompt: string
|
||||
model: string
|
||||
urls: string[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const props = defineProps<{ baseUrl: string }>()
|
||||
const items = ref<Item[]>([])
|
||||
|
||||
const STORAGE_KEY = 'newapi-image-gen:gallery:v1'
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
items.value = raw ? (JSON.parse(raw) as Item[]) : []
|
||||
} catch {
|
||||
items.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items.value.slice(0, 200)))
|
||||
}
|
||||
|
||||
function add(item: Omit<Item, 'id' | 'createdAt'>) {
|
||||
items.value.unshift({
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
save()
|
||||
}
|
||||
|
||||
defineExpose({ add })
|
||||
|
||||
onMounted(load)
|
||||
|
||||
watch(() => props.baseUrl, () => {
|
||||
// gallery is base-agnostic; nothing to reload
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="font-semibold text-zinc-200">3. 本地作品集</h2>
|
||||
<span class="text-xs text-zinc-500">本浏览器保留,最多 200 条</span>
|
||||
</div>
|
||||
|
||||
<p v-if="!items.length" class="text-sm text-zinc-500">还没生成过图片。</p>
|
||||
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div v-for="it in items" :key="it.id" class="space-y-1">
|
||||
<a
|
||||
v-for="(u, i) in it.urls"
|
||||
:key="i"
|
||||
:href="u"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="block aspect-square overflow-hidden rounded-md border border-zinc-800 bg-zinc-950"
|
||||
>
|
||||
<img :src="u" class="w-full h-full object-cover" referrerpolicy="no-referrer" loading="lazy" />
|
||||
</a>
|
||||
<p class="text-xs text-zinc-400 line-clamp-2" :title="it.prompt">{{ it.prompt }}</p>
|
||||
<p class="text-[10px] text-zinc-600">{{ it.model }} · {{ new Date(it.createdAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { generateImage, type GeneratedImage, type Auth } from '../lib/api'
|
||||
|
||||
const props = defineProps<{
|
||||
baseUrl: string
|
||||
auth: Auth
|
||||
models: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'generated', img: { prompt: string; model: string; urls: string[] }): void
|
||||
(e: 'error', msg: string): void
|
||||
}>()
|
||||
|
||||
const prompt = ref<string>('')
|
||||
const negativePrompt = ref<string>('')
|
||||
const model = ref<string>('')
|
||||
const n = ref<number>(1)
|
||||
const size = ref<string>('1024x1024')
|
||||
const running = ref<boolean>(false)
|
||||
const preview = ref<GeneratedImage[]>([])
|
||||
|
||||
watch(
|
||||
() => props.models,
|
||||
(m) => {
|
||||
if (m.length && !model.value) model.value = m[0]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function go() {
|
||||
if (!prompt.value.trim() || !model.value) return
|
||||
running.value = true
|
||||
preview.value = []
|
||||
try {
|
||||
const imgs = await generateImage(props.baseUrl, props.auth, {
|
||||
model: model.value,
|
||||
prompt: prompt.value,
|
||||
negative_prompt: negativePrompt.value || undefined,
|
||||
n: n.value,
|
||||
size: size.value,
|
||||
response_format: 'url',
|
||||
})
|
||||
preview.value = imgs
|
||||
const urls = imgs.map((i) => i.url).filter((u): u is string => !!u)
|
||||
emit('generated', { prompt: prompt.value, model: model.value, urls })
|
||||
} catch (e) {
|
||||
emit('error', String(e))
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 space-y-3">
|
||||
<h2 class="font-semibold text-zinc-200">2. 写提示词生图</h2>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs text-zinc-400">正向提示词</span>
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
rows="3"
|
||||
placeholder="a cat astronaut floating in space, ultra-detailed, cinematic lighting"
|
||||
class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs text-zinc-400">负向提示词(可选,部分模型支持)</span>
|
||||
<input
|
||||
v-model="negativePrompt"
|
||||
placeholder="blurry, low quality, watermark"
|
||||
class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<label class="block">
|
||||
<span class="text-xs text-zinc-400">模型</span>
|
||||
<select
|
||||
v-model="model"
|
||||
class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm"
|
||||
>
|
||||
<option v-if="!models.length" disabled>暂无可用模型</option>
|
||||
<option v-for="m in models" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-zinc-400">张数</span>
|
||||
<input
|
||||
v-model.number="n"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4"
|
||||
class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-zinc-400">尺寸</span>
|
||||
<select v-model="size" class="mt-1 w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm">
|
||||
<option value="256x256">256×256</option>
|
||||
<option value="512x512">512×512</option>
|
||||
<option value="1024x1024">1024×1024</option>
|
||||
<option value="1024x1792">1024×1792</option>
|
||||
<option value="1792x1024">1792×1024</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="go"
|
||||
:disabled="running || !prompt.trim() || !model"
|
||||
class="w-full rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:hover:bg-indigo-600 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ running ? '生成中…' : '生成' }}
|
||||
</button>
|
||||
|
||||
<div v-if="preview.length" class="grid grid-cols-2 gap-2 pt-2">
|
||||
<a
|
||||
v-for="(img, i) in preview"
|
||||
:key="i"
|
||||
:href="img.url || '#'"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="block aspect-square overflow-hidden rounded-md border border-zinc-800 bg-zinc-950"
|
||||
>
|
||||
<img
|
||||
v-if="img.url"
|
||||
:src="img.url"
|
||||
class="w-full h-full object-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { loadSettings, saveSettings, type Settings } from '../lib/settings'
|
||||
import { getSelf, probeSession, type NewApiUser, type Auth } from '../lib/api'
|
||||
|
||||
const props = defineProps<{
|
||||
user: NewApiUser | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'session-changed', user: NewApiUser | null): void
|
||||
(e: 'auth-overridden', auth: Auth): void
|
||||
}>()
|
||||
|
||||
const s = ref<Settings>(loadSettings())
|
||||
const checking = ref<boolean>(false)
|
||||
const skKeyInput = ref<string>('')
|
||||
const usingSk = ref<boolean>(false)
|
||||
const skTestMessage = ref<string>('')
|
||||
const skTestStatus = ref<'idle' | 'ok' | 'fail'>('idle')
|
||||
|
||||
onMounted(() => {
|
||||
// If the URL contains ?session=ok (the /sign-in page can be configured
|
||||
// to bounce back here on success), re-probe immediately.
|
||||
if (window.location.search.includes('session=ok')) {
|
||||
recheck()
|
||||
// strip the param so a refresh doesn't re-trigger
|
||||
const u = new URL(window.location.href)
|
||||
u.searchParams.delete('session')
|
||||
window.history.replaceState({}, '', u.toString())
|
||||
}
|
||||
})
|
||||
|
||||
async function recheck() {
|
||||
checking.value = true
|
||||
try {
|
||||
const u = await probeSession(s.value.baseUrl)
|
||||
emit('session-changed', u)
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goSignIn() {
|
||||
// new-api's /sign-in handles OAuth (LinuxDO / GitHub / Discord / OIDC / 自定义)
|
||||
// and username+password. We just bounce through it; on success the user can
|
||||
// click "返回 newapi-image-gen" or simply navigate back to /image-gen.
|
||||
const returnTo = '/image-gen?session=ok'
|
||||
window.location.href = `/sign-in?redirect=${encodeURIComponent(returnTo)}`
|
||||
}
|
||||
|
||||
async function testSk() {
|
||||
skTestStatus.value = 'idle'
|
||||
skTestMessage.value = ''
|
||||
if (!skKeyInput.value.trim().startsWith('sk-')) {
|
||||
skTestStatus.value = 'fail'
|
||||
skTestMessage.value = 'sk-key 应该以 sk- 开头'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const auth: Auth = { kind: 'sk', token: skKeyInput.value.trim() }
|
||||
const u = await getSelf(s.value.baseUrl, auth)
|
||||
skTestStatus.value = 'ok'
|
||||
skTestMessage.value = `已验证: ${u.username} · 剩余 ${u.quota.toLocaleString()}`
|
||||
emit('auth-overridden', auth)
|
||||
} catch (e) {
|
||||
skTestStatus.value = 'fail'
|
||||
skTestMessage.value = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSk() {
|
||||
skKeyInput.value = ''
|
||||
skTestStatus.value = 'idle'
|
||||
skTestMessage.value = ''
|
||||
emit('auth-overridden', { kind: 'session' })
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// persist any edits the user made
|
||||
saveSettings(s.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-zinc-200">1. 登录态</h2>
|
||||
<button
|
||||
@click="recheck"
|
||||
:disabled="checking"
|
||||
class="text-xs text-zinc-500 hover:text-zinc-300 disabled:opacity-50"
|
||||
>{{ checking ? '检查中…' : '刷新状态' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 登录态展示 -->
|
||||
<div v-if="props.user" class="flex items-center gap-3 text-sm">
|
||||
<span class="inline-flex h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||
<span class="text-zinc-300">{{ props.user.display_name || props.user.username }}</span>
|
||||
<span class="text-xs text-zinc-500">@{{ props.user.username }}</span>
|
||||
<span class="text-xs text-zinc-500">· 剩余额度</span>
|
||||
<span class="text-emerald-400 font-mono">{{ props.user.quota.toLocaleString() }}</span>
|
||||
<span class="text-xs text-zinc-600">· 组: {{ props.user.group }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="inline-flex h-2 w-2 rounded-full bg-zinc-600"></span>
|
||||
<span class="text-zinc-400">未登录</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="goSignIn"
|
||||
class="rounded-md bg-indigo-600 hover:bg-indigo-500 px-4 py-1.5 text-sm font-medium"
|
||||
>前往 new-api 登录</button>
|
||||
<a
|
||||
href="/sign-in"
|
||||
class="rounded-md border border-zinc-700 hover:border-zinc-500 px-3 py-1.5 text-sm text-zinc-300"
|
||||
>在新窗口打开登录页</a>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500">
|
||||
登录会跳到 new-api 自家登录页(OAuth / 用户名密码,看你 new-api 怎么配的),登完回到这里。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 高级:sk-key 兜底 -->
|
||||
<details class="pt-1">
|
||||
<summary class="text-xs text-zinc-500 cursor-pointer select-none hover:text-zinc-300">
|
||||
高级:用 sk-key 登录(不推荐,只在 cookie 不灵时用)
|
||||
</summary>
|
||||
<div class="pt-2 space-y-2">
|
||||
<input
|
||||
v-model="skKeyInput"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
class="w-full rounded-md bg-zinc-950 border border-zinc-800 px-3 py-2 text-sm font-mono focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="testSk"
|
||||
class="rounded-md border border-zinc-700 hover:border-zinc-500 px-3 py-1 text-xs"
|
||||
>验证</button>
|
||||
<button
|
||||
v-if="usingSk || skTestStatus === 'ok'"
|
||||
@click="clearSk"
|
||||
class="rounded-md border border-zinc-700 hover:border-zinc-500 px-3 py-1 text-xs"
|
||||
>清除,回到 session</button>
|
||||
<span v-if="skTestStatus === 'ok'" class="text-xs text-emerald-400">{{ skTestMessage }}</span>
|
||||
<span v-else-if="skTestStatus === 'fail'" class="text-xs text-rose-400">{{ skTestMessage }}</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-zinc-600">
|
||||
注意:sk-key 不会存到 localStorage,刷新页面就丢。
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
Vendored
+160
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Thin client for new-api. All API paths are RELATIVE because image-gen is
|
||||
* served from the same origin as new-api (embedded in the Go binary at
|
||||
* `/image-gen/`). Cookies are sent automatically with `credentials: 'include'`.
|
||||
*
|
||||
* Auth model after the "同源" integration:
|
||||
* - new-api sets a `session` cookie on login (Path=/, SameSite=Strict,
|
||||
* HttpOnly, 30 days). All /api/* and /v1/* endpoints accept the cookie.
|
||||
* - We do NOT handle the login form ourselves. We just call /api/user/self
|
||||
* to see whether the cookie is present, and if not, redirect the user to
|
||||
* new-api's own /sign-in page.
|
||||
* - As a power-user escape hatch, an `sk-` key still works the same way it
|
||||
* did in v1: paste it in the settings panel, and we add it as
|
||||
* `Authorization: Bearer ...`. Sk-keys are NOT persisted (security).
|
||||
*/
|
||||
|
||||
export type Auth =
|
||||
| { kind: 'session' } // uses the new-api session cookie
|
||||
| { kind: 'sk'; token: string } // explicit sk-key fallback
|
||||
| { kind: 'none' }
|
||||
|
||||
export interface NewApiUser {
|
||||
id: number
|
||||
username: string
|
||||
display_name?: string
|
||||
quota: number
|
||||
used_quota: number
|
||||
group: string
|
||||
role: string
|
||||
status: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export interface GenerateParams {
|
||||
model: string
|
||||
prompt: string
|
||||
n?: number
|
||||
size?: string
|
||||
response_format?: 'url' | 'b64_json'
|
||||
negative_prompt?: string
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
url?: string
|
||||
b64_json?: string
|
||||
revised_prompt?: string
|
||||
}
|
||||
|
||||
export class NewApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(message: string, status: number, body: unknown) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
function joinUrl(base: string, path: string): string {
|
||||
return base.replace(/\/+$/, '') + (path.startsWith('/') ? path : '/' + path)
|
||||
}
|
||||
|
||||
function authHeader(auth: Auth): Record<string, string> {
|
||||
if (auth.kind === 'sk') return { Authorization: `Bearer ${auth.token}` }
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* `base` should usually be `''` (same-origin) since image-gen is served
|
||||
* from inside new-api. A non-empty base is still allowed for local dev where
|
||||
* you run `npm run dev` on :5174 and proxy /api to :3000.
|
||||
*/
|
||||
async function request<T>(
|
||||
base: string,
|
||||
path: string,
|
||||
init: RequestInit & { auth?: Auth } = {},
|
||||
): Promise<T> {
|
||||
const { auth = { kind: 'session' }, headers, ...rest } = init
|
||||
const useCredentials = auth.kind !== 'sk' // session auth = rely on cookie
|
||||
const res = await fetch(joinUrl(base, path), {
|
||||
...rest,
|
||||
credentials: useCredentials ? 'include' : rest.credentials,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader(auth),
|
||||
...(headers ?? {}),
|
||||
},
|
||||
})
|
||||
const text = await res.text()
|
||||
let body: unknown
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
body = text
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(body && typeof body === 'object' && 'message' in body && (body as { message?: string }).message) ||
|
||||
(body && typeof body === 'object' && 'error' in body && (body as { error?: { message?: string } }).error?.message) ||
|
||||
`HTTP ${res.status}`
|
||||
throw new NewApiError(String(msg), res.status, body)
|
||||
}
|
||||
return body as T
|
||||
}
|
||||
|
||||
/** Probe the current session. Returns the user if a valid cookie is present, else throws. */
|
||||
export async function getSelf(base: string, auth: Auth): Promise<NewApiUser> {
|
||||
const r = await request<{ success: boolean; data: NewApiUser; message?: string }>(
|
||||
base,
|
||||
'/api/user/self',
|
||||
{ auth },
|
||||
)
|
||||
if (!r.success) throw new NewApiError(r.message || 'not logged in', 401, r)
|
||||
return r.data
|
||||
}
|
||||
|
||||
/** Best-effort session probe. Returns the user or null. Never throws. */
|
||||
export async function probeSession(base: string): Promise<NewApiUser | null> {
|
||||
try {
|
||||
return await getSelf(base, { kind: 'session' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** List models the user can access. */
|
||||
export async function listModels(base: string, auth: Auth): Promise<string[]> {
|
||||
const r = await request<{ data: Array<{ id: string }> } | Array<{ id: string }>>(
|
||||
base,
|
||||
'/v1/models',
|
||||
{ auth },
|
||||
)
|
||||
const arr = Array.isArray(r) ? r : r.data
|
||||
return arr.map((m) => m.id)
|
||||
}
|
||||
|
||||
/** Call /v1/images/generations. */
|
||||
export async function generateImage(
|
||||
base: string,
|
||||
auth: Auth,
|
||||
params: GenerateParams,
|
||||
): Promise<GeneratedImage[]> {
|
||||
const r = await request<{ data: GeneratedImage[]; created: number }>(
|
||||
base,
|
||||
'/v1/images/generations',
|
||||
{
|
||||
method: 'POST',
|
||||
auth,
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
prompt: params.prompt,
|
||||
n: params.n ?? 1,
|
||||
size: params.size ?? '1024x1024',
|
||||
response_format: params.response_format ?? 'url',
|
||||
...(params.negative_prompt ? { negative_prompt: params.negative_prompt } : {}),
|
||||
}),
|
||||
},
|
||||
)
|
||||
return r.data
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Settings for image-gen.
|
||||
*
|
||||
* After the "同源" integration (image-gen is served from new-api at /image-gen/),
|
||||
* the only persistent setting is the base URL (mostly empty / same-origin).
|
||||
* The session cookie is managed by the browser; we don't see or store it.
|
||||
*
|
||||
* `skKey` is intentionally NOT persisted: if a power user pastes one, it
|
||||
* lives only in memory for the tab session and is wiped on refresh.
|
||||
*/
|
||||
|
||||
const KEY = 'newapi-image-gen:settings:v2'
|
||||
|
||||
export interface Settings {
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
const DEFAULTS: Settings = {
|
||||
// Empty = same origin (image-gen is at /image-gen/, new-api at /).
|
||||
// The dev override (Vite on :5174) sets this via .env / runtime config.
|
||||
baseUrl: '',
|
||||
}
|
||||
|
||||
export function loadSettings(): Settings {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return { ...DEFAULTS }
|
||||
return { ...DEFAULTS, ...(JSON.parse(raw) as Partial<Settings>) }
|
||||
} catch {
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(s: Settings): void {
|
||||
localStorage.setItem(KEY, JSON.stringify(s))
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
Reference in New Issue
Block a user