feat: multi-feature update

This commit is contained in:
chaos
2026-06-15 06:16:16 +08:00
parent 6f415428d3
commit 04d30f9dd1
58 changed files with 4610 additions and 419 deletions
+139
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+160
View File
@@ -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
}
+36
View File
@@ -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))
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#app {
height: 100%;
}