feat: rebrand Metapi to BoosAPI, add ComfyUI agent and user management
CI / Detect Docs Changes (push) Has been cancelled
CI / Test Core (push) Has been cancelled
CI / Build Web (push) Has been cancelled
CI / Build Server (push) Has been cancelled
CI / Build Desktop (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Repo Drift Check (push) Has been cancelled
CI / Schema Check (SQLite) (push) Has been cancelled
CI / Schema Check (MySQL) (push) Has been cancelled
CI / Schema Check (Postgres) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Audit Production Dependencies (push) Has been cancelled
CI / Publish Docker Image (amd64) (push) Has been cancelled
CI / Publish Docker Image (arm64) (push) Has been cancelled
CI / Publish Docker Image (armv7) (push) Has been cancelled
CI / Publish Docker Manifest (push) Has been cancelled
CodeQL / Analyze (JavaScript/TypeScript) (javascript-typescript) (push) Has been cancelled
CI / Detect Docs Changes (push) Has been cancelled
CI / Test Core (push) Has been cancelled
CI / Build Web (push) Has been cancelled
CI / Build Server (push) Has been cancelled
CI / Build Desktop (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Repo Drift Check (push) Has been cancelled
CI / Schema Check (SQLite) (push) Has been cancelled
CI / Schema Check (MySQL) (push) Has been cancelled
CI / Schema Check (Postgres) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Audit Production Dependencies (push) Has been cancelled
CI / Publish Docker Image (amd64) (push) Has been cancelled
CI / Publish Docker Image (arm64) (push) Has been cancelled
CI / Publish Docker Image (armv7) (push) Has been cancelled
CI / Publish Docker Manifest (push) Has been cancelled
CodeQL / Analyze (JavaScript/TypeScript) (javascript-typescript) (push) Has been cancelled
- Rename project from Metapi to BoosAPI across all UI and server strings - Add ComfyUI conversational agent page (/comfyui-agent) - Add user management system (register, login, API keys, admin) - Update Dockerfile and database schema - Add ComfyUI workflow nodes support - Update shared contracts and platform configurations Signed-off-by: Boos4721 <boos4721@icloud.com>
This commit is contained in:
+10
-10
@@ -80,7 +80,7 @@ function showMainWindow() {
|
||||
function buildTrayMenu() {
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Open Metapi',
|
||||
label: 'Open BoosAPI',
|
||||
click: () => showMainWindow(),
|
||||
},
|
||||
{
|
||||
@@ -132,7 +132,7 @@ function setupTray() {
|
||||
trayImage.setTemplateImage(true);
|
||||
}
|
||||
tray = new Tray(trayImage);
|
||||
tray.setToolTip('Metapi');
|
||||
tray.setToolTip('BoosAPI');
|
||||
tray.setContextMenu(buildTrayMenu());
|
||||
tray.on('double-click', () => showMainWindow());
|
||||
}
|
||||
@@ -278,8 +278,8 @@ async function handleServerCrash(code: number | null) {
|
||||
mainWindow?.hide();
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Metapi backend stopped',
|
||||
message: `The local Metapi backend exited unexpectedly${typeof code === 'number' ? ` (code ${code})` : ''}.`,
|
||||
title: 'BoosAPI backend stopped',
|
||||
message: `The local BoosAPI backend exited unexpectedly${typeof code === 'number' ? ` (code ${code})` : ''}.`,
|
||||
detail: 'You can restart the backend now or quit the desktop app.',
|
||||
buttons: ['Restart Backend', 'Quit'],
|
||||
defaultId: 0,
|
||||
@@ -314,7 +314,7 @@ async function restartBackend() {
|
||||
await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Restart failed',
|
||||
message: 'Metapi could not restart the local backend.',
|
||||
message: 'BoosAPI could not restart the local backend.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
@@ -358,7 +358,7 @@ function setupAutoUpdater() {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update available',
|
||||
message: `Metapi ${info.version} is available.`,
|
||||
message: `BoosAPI ${info.version} is available.`,
|
||||
detail: 'Download and install it after the current session?',
|
||||
buttons: ['Download', 'Later'],
|
||||
defaultId: 0,
|
||||
@@ -378,7 +378,7 @@ function setupAutoUpdater() {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Update ready',
|
||||
message: 'The new Metapi desktop update is ready to install.',
|
||||
message: 'The new BoosAPI desktop update is ready to install.',
|
||||
buttons: ['Install and Restart', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
@@ -424,11 +424,11 @@ if (!hasSingleInstanceLock) {
|
||||
try {
|
||||
await bootDesktopApp();
|
||||
} catch (error) {
|
||||
log.error('Failed to boot Metapi desktop', error);
|
||||
log.error('Failed to boot BoosAPI desktop', error);
|
||||
const result = await dialog.showMessageBox({
|
||||
type: 'error',
|
||||
title: 'Metapi failed to start',
|
||||
message: 'The desktop shell could not start the local Metapi service.',
|
||||
title: 'BoosAPI failed to start',
|
||||
message: 'The desktop shell could not start the local BoosAPI service.',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
buttons: ['Retry', 'Quit'],
|
||||
defaultId: 0,
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function waitForServerReady(input: WaitForServerReadyInput): Promis
|
||||
await delay(intervalMs);
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for metapi desktop server');
|
||||
throw new Error('Timed out waiting for BoosAPI desktop server');
|
||||
}
|
||||
|
||||
export function isFatalServerExit(exitState: ServerExitState): boolean {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const registerUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(6).max(128),
|
||||
});
|
||||
|
||||
export const loginUserSchema = z.object({
|
||||
email: z.string().email().max(256),
|
||||
password: z.string().min(1).max(128),
|
||||
});
|
||||
|
||||
export const updateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64).optional(),
|
||||
role: z.enum(['admin', 'user']).optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
});
|
||||
|
||||
export const updatePasswordSchema = z.object({
|
||||
oldPassword: z.string().min(1).max(128),
|
||||
newPassword: z.string().min(6).max(128),
|
||||
});
|
||||
@@ -43,6 +43,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
|
||||
'downstream_api_keys',
|
||||
'site_announcements',
|
||||
'events',
|
||||
'users',
|
||||
]);
|
||||
|
||||
export let runtimeDbDialect: RuntimeDbDialect = config.dbType;
|
||||
|
||||
@@ -508,6 +508,7 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
|
||||
siteWeightMultipliers: text('site_weight_multipliers'), // JSON object { [siteId]: multiplier }
|
||||
excludedSiteIds: text('excluded_site_ids'), // JSON array<number>
|
||||
excludedCredentialRefs: text('excluded_credential_refs'), // JSON array<DownstreamExcludedCredentialRef>
|
||||
userId: integer('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
lastUsedAt: text('last_used_at'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
@@ -542,6 +543,20 @@ export const siteAnnouncements = sqliteTable('site_announcements', {
|
||||
readAtIdx: index('site_announcements_read_at_idx').on(table.readAt),
|
||||
}));
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
username: text('username').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
role: text('role').notNull().default('user'),
|
||||
status: text('status').notNull().default('active'),
|
||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||
}, (table) => ({
|
||||
emailUnique: uniqueIndex('users_email_unique').on(table.email),
|
||||
statusIdx: index('users_status_idx').on(table.status),
|
||||
}));
|
||||
|
||||
export const events = sqliteTable('events', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
||||
|
||||
@@ -743,7 +743,7 @@ function applySqliteMigrations(sqlite: Database.Database): void {
|
||||
}
|
||||
|
||||
function createTemporarySqlitePath(): string {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'metapi-schema-parity-'));
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'boosapi-schema-parity-'));
|
||||
return resolve(tempDir, `${randomUUID()}.db`);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { FastifyInstance } from 'fastify';
|
||||
const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
|
||||
|
||||
export function isPublicApiRoute(url: string): boolean {
|
||||
return url === DESKTOP_HEALTH_ROUTE || url.startsWith('/api/oauth/callback/');
|
||||
return url === DESKTOP_HEALTH_ROUTE
|
||||
|| url.startsWith('/api/oauth/callback/')
|
||||
|| url.startsWith('/api/comfyui/proxy/')
|
||||
// User SaaS routes (JWT auth, not admin token)
|
||||
|| url === '/api/users/register'
|
||||
|| url === '/api/users/login'
|
||||
|| url.startsWith('/api/users/')
|
||||
|| url.startsWith('/api/user-api-keys/');
|
||||
}
|
||||
|
||||
export async function registerDesktopRoutes(app: FastifyInstance) {
|
||||
|
||||
@@ -20,9 +20,15 @@ import { taskRoutes } from './routes/api/tasks.js';
|
||||
import { testRoutes } from './routes/api/test.js';
|
||||
import { monitorRoutes } from './routes/api/monitor.js';
|
||||
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||
import { usersRoutes } from './routes/api/users.js';
|
||||
import { userApiKeysRoutes } from './routes/api/userApiKeys.js';
|
||||
import { oauthRoutes } from './routes/api/oauth.js';
|
||||
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||
import { updateCenterRoutes } from './routes/api/updateCenter.js';
|
||||
import { comfyuiRoutes } from './routes/api/comfyui.js';
|
||||
import { videoAgentRoutes } from './routes/api/videoAgent.js';
|
||||
import { adminUsersRoutes } from './routes/api/adminUsers.js';
|
||||
import { comfyuiAgentRoutes } from './routes/api/comfyuiAgent.js';
|
||||
import { proxyRoutes } from './routes/proxy/router.js';
|
||||
import { startScheduler } from './services/checkinScheduler.js';
|
||||
import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js';
|
||||
@@ -228,6 +234,12 @@ await app.register(taskRoutes);
|
||||
await app.register(testRoutes);
|
||||
await app.register(monitorRoutes);
|
||||
await app.register(downstreamApiKeysRoutes);
|
||||
await app.register(adminUsersRoutes);
|
||||
await app.register(usersRoutes);
|
||||
await app.register(userApiKeysRoutes);
|
||||
await app.register(comfyuiRoutes);
|
||||
await app.register(videoAgentRoutes);
|
||||
await app.register(comfyuiAgentRoutes);
|
||||
await app.register(oauthRoutes);
|
||||
|
||||
// Register OpenAI-compatible proxy routes
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { config } from '../config.js';
|
||||
import { authorizeDownstreamToken, consumeManagedKeyRequest } from '../services/downstreamApiKeyService.js';
|
||||
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../services/downstreamPolicyTypes.js';
|
||||
import { verifyJwt, type JwtPayload } from '../services/userService.js';
|
||||
|
||||
export interface ProxyAuthContext {
|
||||
token: string;
|
||||
@@ -17,7 +18,14 @@ export interface ProxyResourceOwner {
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface UserAuthContext {
|
||||
userId: number;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
const proxyAuthContextByRequest = new WeakMap<FastifyRequest, ProxyAuthContext>();
|
||||
const userAuthContextByRequest = new WeakMap<FastifyRequest, UserAuthContext>();
|
||||
|
||||
type ParsedAllowlistEntry =
|
||||
| { kind: 'exact'; normalizedIp: string }
|
||||
@@ -190,3 +198,36 @@ export function getProxyResourceOwner(request: FastifyRequest): ProxyResourceOwn
|
||||
ownerId: 'global',
|
||||
};
|
||||
}
|
||||
|
||||
export async function userAuthMiddleware(request: FastifyRequest, reply: FastifyReply) {
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
reply.code(401).send({ error: 'Missing or invalid Authorization header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = auth.slice('Bearer '.length).trim();
|
||||
const payload = verifyJwt(token);
|
||||
if (!payload) {
|
||||
reply.code(401).send({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
|
||||
userAuthContextByRequest.set(request, {
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
});
|
||||
}
|
||||
|
||||
export function getUserAuthContext(request: FastifyRequest): UserAuthContext | null {
|
||||
return userAuthContextByRequest.get(request) || null;
|
||||
}
|
||||
|
||||
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
|
||||
const user = getUserAuthContext(request);
|
||||
if (!user || user.role !== 'admin') {
|
||||
reply.code(403).send({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function listModelsSurface(input: ModelsSurfaceInput) {
|
||||
id,
|
||||
object: 'model' as const,
|
||||
created: Math.floor(now.getTime() / 1000),
|
||||
owned_by: 'metapi',
|
||||
owned_by: 'boosapi',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
getUserById,
|
||||
listUsers,
|
||||
updateUser,
|
||||
type UserView,
|
||||
} from '../../services/userService.js';
|
||||
|
||||
function omitPassword(user: UserView) {
|
||||
const { ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function adminUsersRoutes(app: FastifyInstance) {
|
||||
// List all users
|
||||
app.get('/api/admin/users', async (_request, reply) => {
|
||||
const users = await listUsers();
|
||||
reply.send({ users: users.map(omitPassword) });
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
app.get('/api/admin/users/:id', async (request, reply) => {
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
const user = await getUserById(id);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
reply.send({ user: omitPassword(user) });
|
||||
});
|
||||
|
||||
// Update user (role, status, username)
|
||||
app.patch('/api/admin/users/:id', async (request, reply) => {
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const updates: Partial<{ username: string; role: string; status: string }> = {};
|
||||
if (body.username !== undefined) updates.username = String(body.username);
|
||||
if (body.role !== undefined) {
|
||||
if (body.role !== 'admin' && body.role !== 'user') {
|
||||
return reply.code(400).send({ error: 'Role must be "admin" or "user"' });
|
||||
}
|
||||
updates.role = body.role;
|
||||
}
|
||||
if (body.status !== undefined) {
|
||||
if (body.status !== 'active' && body.status !== 'disabled') {
|
||||
return reply.code(400).send({ error: 'Status must be "active" or "disabled"' });
|
||||
}
|
||||
updates.status = body.status;
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateUser(id, updates);
|
||||
}
|
||||
const user = await getUserById(id);
|
||||
reply.send({ user: user ? omitPassword(user) : null });
|
||||
});
|
||||
|
||||
// Disable/delete user
|
||||
app.delete('/api/admin/users/:id', async (request, reply) => {
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
await updateUser(id, { status: 'disabled' });
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
|
||||
const COMFYUI_BASE = 'http://127.0.0.1:8188';
|
||||
|
||||
/** Rebrand ComfyUI HTML to show BoosAPI instead */
|
||||
function rebrandHtml(html: string): string {
|
||||
return html
|
||||
.replace(/ComfyUI/g, 'BoosAPI')
|
||||
.replace(/comfyui/gi, 'BoosAPI');
|
||||
}
|
||||
|
||||
function rewriteProxyUrls(html: string): string {
|
||||
return html.replace(/(src|href|action)=["']\//g, (m) =>
|
||||
m.replace(/["']/, '/api/comfyui/proxy/')
|
||||
);
|
||||
}
|
||||
|
||||
export async function comfyuiRoutes(app: FastifyInstance) {
|
||||
// Proxy: POST /api/comfyui/prompt -> ComfyUI /prompt
|
||||
app.post('/api/comfyui/prompt', async (request, reply) => {
|
||||
try {
|
||||
const body = typeof request.body === 'object' ? JSON.stringify(request.body) : '{}';
|
||||
const resp = await fetch(`${COMFYUI_BASE}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type('application/json').send(text);
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/history -> ComfyUI /history
|
||||
app.get('/api/comfyui/history', async (_request, reply) => {
|
||||
try {
|
||||
const resp = await fetch(`${COMFYUI_BASE}/history`);
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type('application/json').send(text);
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/history/:id -> ComfyUI /history/{id}
|
||||
app.get<{ Params: { id: string } }>('/api/comfyui/history/:id', async (request, reply) => {
|
||||
try {
|
||||
const resp = await fetch(`${COMFYUI_BASE}/history/${encodeURIComponent(request.params.id)}`);
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type('application/json').send(text);
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/queue -> ComfyUI /queue
|
||||
app.get('/api/comfyui/queue', async (_request, reply) => {
|
||||
try {
|
||||
const resp = await fetch(`${COMFYUI_BASE}/queue`);
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type('application/json').send(text);
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/view -> ComfyUI /view (image output, preserve query params)
|
||||
app.get('/api/comfyui/view', async (request, reply) => {
|
||||
try {
|
||||
const qs = request.url.includes('?') ? request.url.substring(request.url.indexOf('?')) : '';
|
||||
const resp = await fetch(`${COMFYUI_BASE}/view${qs}`);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
return reply.code(resp.status).type(resp.headers.get('content-type') || 'application/octet-stream').send(Buffer.from(buffer));
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy: GET /api/comfyui/proxy/* -> proxy everything to ComfyUI (HTML, assets, JS, CSS, etc.)
|
||||
app.get('/api/comfyui/proxy/*', async (request, reply) => {
|
||||
try {
|
||||
const path = request.url.replace('/api/comfyui/proxy', '');
|
||||
const targetUrl = `${COMFYUI_BASE}${path}`;
|
||||
const resp = await fetch(targetUrl);
|
||||
const contentType = resp.headers.get('content-type') || 'application/octet-stream';
|
||||
if (contentType.includes('html')) {
|
||||
const text = await resp.text();
|
||||
const rebranded = rebrandHtml(text);
|
||||
const rewritten = rewriteProxyUrls(rebranded);
|
||||
return reply.code(resp.status).type(contentType).send(rewritten);
|
||||
}
|
||||
if (contentType.includes('text') || contentType.includes('json')) {
|
||||
const text = await resp.text();
|
||||
return reply.code(resp.status).type(contentType).send(text);
|
||||
}
|
||||
const buffer = await resp.arrayBuffer();
|
||||
return reply.code(resp.status).type(contentType).send(Buffer.from(buffer));
|
||||
} catch (e: any) {
|
||||
return reply.code(502).send({ error: `ComfyUI unreachable: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve ComfyUI HTML page at /comfyui with iframe to proxied ComfyUI
|
||||
app.get('/comfyui', async (_request, reply) => {
|
||||
return reply.type('text/html').send(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BoosAPI - ComfyUI</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
|
||||
iframe { width: 100%; height: 100%; border: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="/api/comfyui/proxy/" title="BoosAPI ComfyUI"></iframe>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
|
||||
const BOOSAPI_BASE = `http://127.0.0.1:${process.env.PORT || 3000}`;
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个 AI 视频生成助手,帮助用户通过 ComfyUI 生成 AI 视频。你可以:
|
||||
|
||||
## 能力
|
||||
|
||||
1. **分析脚本** — 提取角色、场景、对话,输出结构化 JSON
|
||||
2. **生成角色提示词** — 为每个角色生成 4 个角度(正面/背面/左侧面/右侧面)的图像生成提示词
|
||||
3. **生成角色图像** — 调用图像生成 API 生成角色各个角度的图像
|
||||
4. **生成场景提示词** — 根据场景描述生成稳定的图像生成提示词
|
||||
5. **生成语音(TTS)** — 将对话台词转为语音
|
||||
6. **导出 ComfyUI 工作流** — 将整个视频项目打包为 ComfyUI 可用的 workflow JSON
|
||||
|
||||
## 输出格式
|
||||
|
||||
你的回复必须是 SSE 格式,每条消息是一个 JSON 对象,以 \`data: \` 开头,以 \n\n 结尾。
|
||||
|
||||
### 文本消息
|
||||
data: {"type":"text","content":"你的回复内容"}
|
||||
|
||||
### 角色提示词
|
||||
data: {"type":"character_prompts","character":"角色名","angles":{"front":"正面提示词","back":"背面提示词","left":"左侧面提示词","right":"右侧面提示词"}}
|
||||
|
||||
### 角色图像
|
||||
data: {"type":"image","url":"图片URL","character":"角色名","angle":"角度"}
|
||||
|
||||
### 场景提示词
|
||||
data: {"type":"scene","id":1,"description":"场景描述","prompt":"图像提示词","negative_prompt":"负面提示词"}
|
||||
|
||||
### TTS
|
||||
data: {"type":"tts","character":"角色名","text":"台词","url":"语音URL","duration_ms":1234}
|
||||
|
||||
### ComfyUI 工作流
|
||||
data: {"type":"comfyui_workflow","json":{...workflow JSON...}}
|
||||
|
||||
### 完成标记
|
||||
data: {"type":"done"}
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 用户提供脚本 → 你分析脚本结构
|
||||
2. 针对每个角色 → 生成 4 角度提示词 → 调用图像生成
|
||||
3. 针对每个场景 → 生成场景提示词 → 调用图像生成
|
||||
4. 针对每段对话 → 生成 TTS 语音
|
||||
5. 最终导出 ComfyUI 工作流 JSON
|
||||
|
||||
始终用中文回复。不要输出 markdown 代码块,直接输出 SSE 格式数据。`;
|
||||
|
||||
interface ChatRequest {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
async function callLLM(messages: Array<{ role: string; content: string }>, signal?: AbortSignal): Promise<any> {
|
||||
return fetch(`${BOOSAPI_BASE}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer admin',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.5',
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
...messages,
|
||||
],
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function callImageGen(prompt: string): Promise<string> {
|
||||
const resp = await fetch(`${BOOSAPI_BASE}/v1/images/generations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer admin',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'dall-e-3',
|
||||
prompt,
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
return data?.data?.[0]?.url || '';
|
||||
}
|
||||
|
||||
export async function comfyuiAgentRoutes(app: FastifyInstance) {
|
||||
app.post('/api/comfyui-agent/chat', async (request, reply) => {
|
||||
const { messages } = request.body as ChatRequest;
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return reply.code(400).send({ error: 'messages is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Hijack the Fastify response for SSE streaming
|
||||
reply.hijack();
|
||||
|
||||
const raw = reply.raw;
|
||||
raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Handle client disconnect
|
||||
raw.on('close', () => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
const llmResp = await callLLM(messages, abortController.signal);
|
||||
|
||||
if (!llmResp.ok) {
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content: 'LLM 调用失败,请稍后重试' })}\n\n`);
|
||||
raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!llmResp.body) {
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content: 'LLM 返回空响应' })}\n\n`);
|
||||
raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = llmResp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed?.choices?.[0]?.delta?.content || '';
|
||||
if (content) {
|
||||
// Check if the content contains SSE-formatted data from the agent
|
||||
// If it's a direct SSE data: line, forward it verbatim
|
||||
// Otherwise wrap as text event
|
||||
const sseMatch = content.match(/^data:\s*(\{.*\})/s);
|
||||
if (sseMatch) {
|
||||
try {
|
||||
const sseData = JSON.parse(sseMatch[1]);
|
||||
raw.write(`data: ${JSON.stringify(sseData)}\n\n`);
|
||||
continue;
|
||||
} catch {
|
||||
// Not valid JSON SSE, treat as regular text
|
||||
}
|
||||
}
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content })}\n\n`);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content: '\\n\\n[生成中断]' })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
raw.end();
|
||||
} catch (e: any) {
|
||||
// If we haven't hijacked yet, return error as JSON
|
||||
const raw = reply.raw;
|
||||
if (!raw.headersSent) {
|
||||
reply.code(500).send({ error: `Chat failed: ${e.message}` });
|
||||
} else {
|
||||
raw.write(`data: ${JSON.stringify({ type: 'text', content: `错误: ${e.message}` })}\n\n`);
|
||||
raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
|
||||
raw.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export async function monitorRoutes(app: FastifyInstance) {
|
||||
cookie: storedCookie,
|
||||
accept: String(request.headers.accept || '*/*'),
|
||||
'accept-language': String(request.headers['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8'),
|
||||
'user-agent': String(request.headers['user-agent'] || 'metapiMonitorProxy/1.0'),
|
||||
'user-agent': String(request.headers['user-agent'] || 'boosapiMonitorProxy/1.0'),
|
||||
};
|
||||
if (request.headers['content-type']) {
|
||||
upstreamHeaders['content-type'] = String(request.headers['content-type']);
|
||||
|
||||
@@ -574,7 +574,7 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
});
|
||||
message = 'OAuth authorization succeeded. You can close this window.';
|
||||
} catch {
|
||||
message = 'OAuth authorization failed. Return to metapi and review the server logs.';
|
||||
message = 'OAuth authorization failed. Return to BoosAPI and review the server logs.';
|
||||
}
|
||||
|
||||
reply.type('text/html; charset=utf-8');
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { insertAndGetById } from '../../db/insertHelpers.js';
|
||||
import {
|
||||
getDownstreamApiKeyById,
|
||||
normalizeDownstreamApiKeyPayload,
|
||||
toDownstreamApiKeyPolicyView,
|
||||
toPersistenceJson,
|
||||
} from '../../services/downstreamApiKeyService.js';
|
||||
import { userAuthMiddleware, getUserAuthContext } from '../../middleware/auth.js';
|
||||
|
||||
export async function userApiKeysRoutes(app: FastifyInstance) {
|
||||
app.get('/api/user-api-keys', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
|
||||
|
||||
const rows = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.userId, ctx.userId))
|
||||
.all();
|
||||
reply.send({ keys: rows.map(toDownstreamApiKeyPolicyView) });
|
||||
});
|
||||
|
||||
app.post('/api/user-api-keys', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = normalizeDownstreamApiKeyPayload({
|
||||
name: body.name,
|
||||
key: body.key,
|
||||
description: body.description,
|
||||
tags: body.tags,
|
||||
enabled: body.enabled,
|
||||
expiresAt: body.expiresAt,
|
||||
maxCost: body.maxCost,
|
||||
maxRequests: body.maxRequests,
|
||||
supportedModels: body.supportedModels,
|
||||
allowedRouteIds: body.allowedRouteIds,
|
||||
siteWeightMultipliers: body.siteWeightMultipliers,
|
||||
excludedSiteIds: body.excludedSiteIds,
|
||||
excludedCredentialRefs: body.excludedCredentialRefs,
|
||||
});
|
||||
if (!parsed.name) {
|
||||
return reply.code(400).send({ error: 'Name is required' });
|
||||
}
|
||||
const finalKey = parsed.key || `sk-user-${crypto.randomUUID().replace(/-/g, '')}`;
|
||||
const id = await insertAndGetById({
|
||||
table: schema.downstreamApiKeys,
|
||||
values: {
|
||||
name: parsed.name,
|
||||
key: finalKey,
|
||||
description: parsed.description,
|
||||
groupName: parsed.groupName,
|
||||
tags: toPersistenceJson(parsed.tags),
|
||||
enabled: parsed.enabled,
|
||||
expiresAt: parsed.expiresAt,
|
||||
maxCost: parsed.maxCost,
|
||||
usedCost: 0,
|
||||
maxRequests: parsed.maxRequests,
|
||||
usedRequests: 0,
|
||||
supportedModels: toPersistenceJson(parsed.supportedModels),
|
||||
allowedRouteIds: toPersistenceJson(parsed.allowedRouteIds),
|
||||
siteWeightMultipliers: toPersistenceJson(parsed.siteWeightMultipliers),
|
||||
excludedSiteIds: toPersistenceJson(parsed.excludedSiteIds),
|
||||
excludedCredentialRefs: toPersistenceJson(parsed.excludedCredentialRefs),
|
||||
userId: ctx.userId,
|
||||
},
|
||||
idColumn: schema.downstreamApiKeys.id,
|
||||
});
|
||||
const key = await getDownstreamApiKeyById(id);
|
||||
reply.code(201).send({ key });
|
||||
});
|
||||
|
||||
app.patch('/api/user-api-keys/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
|
||||
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) return reply.code(400).send({ error: 'Invalid key ID' });
|
||||
|
||||
const existing = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(and(
|
||||
eq(schema.downstreamApiKeys.id, id),
|
||||
eq(schema.downstreamApiKeys.userId, ctx.userId),
|
||||
))
|
||||
.get();
|
||||
if (!existing) return reply.code(404).send({ error: 'Key not found' });
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = normalizeDownstreamApiKeyPayload({
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
tags: body.tags,
|
||||
enabled: body.enabled,
|
||||
expiresAt: body.expiresAt,
|
||||
maxCost: body.maxCost,
|
||||
maxRequests: body.maxRequests,
|
||||
supportedModels: body.supportedModels,
|
||||
allowedRouteIds: body.allowedRouteIds,
|
||||
siteWeightMultipliers: body.siteWeightMultipliers,
|
||||
excludedSiteIds: body.excludedSiteIds,
|
||||
excludedCredentialRefs: body.excludedCredentialRefs,
|
||||
});
|
||||
|
||||
const updates: Partial<Record<string, unknown>> = {};
|
||||
if (body.name !== undefined) updates.name = parsed.name;
|
||||
if (body.description !== undefined) updates.description = parsed.description;
|
||||
if (body.tags !== undefined) updates.tags = toPersistenceJson(parsed.tags);
|
||||
if (body.enabled !== undefined) updates.enabled = parsed.enabled;
|
||||
if (body.expiresAt !== undefined) updates.expiresAt = parsed.expiresAt;
|
||||
if (body.maxCost !== undefined) updates.maxCost = parsed.maxCost;
|
||||
if (body.maxRequests !== undefined) updates.maxRequests = parsed.maxRequests;
|
||||
if (body.supportedModels !== undefined) updates.supportedModels = toPersistenceJson(parsed.supportedModels);
|
||||
if (body.allowedRouteIds !== undefined) updates.allowedRouteIds = toPersistenceJson(parsed.allowedRouteIds);
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
|
||||
await db.update(schema.downstreamApiKeys).set(updates).where(eq(schema.downstreamApiKeys.id, id)).run();
|
||||
const key = await getDownstreamApiKeyById(id);
|
||||
reply.send({ key });
|
||||
});
|
||||
|
||||
app.delete('/api/user-api-keys/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) return reply.code(401).send({ error: 'Unauthorized' });
|
||||
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) return reply.code(400).send({ error: 'Invalid key ID' });
|
||||
|
||||
const existing = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(and(
|
||||
eq(schema.downstreamApiKeys.id, id),
|
||||
eq(schema.downstreamApiKeys.userId, ctx.userId),
|
||||
))
|
||||
.get();
|
||||
if (!existing) return reply.code(404).send({ error: 'Key not found' });
|
||||
|
||||
await db.delete(schema.downstreamApiKeys).where(eq(schema.downstreamApiKeys.id, id)).run();
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import {
|
||||
authenticateUser,
|
||||
createUser,
|
||||
generateJwt,
|
||||
getUserById,
|
||||
listUsers,
|
||||
updateUser,
|
||||
updateUserPassword,
|
||||
type UserView,
|
||||
} from '../../services/userService.js';
|
||||
import { userAuthMiddleware, getUserAuthContext, requireAdmin } from '../../middleware/auth.js';
|
||||
import {
|
||||
registerUserSchema,
|
||||
loginUserSchema,
|
||||
updateUserSchema,
|
||||
updatePasswordSchema,
|
||||
} from '../../contracts/userRoutePayloads.js';
|
||||
|
||||
function omitPassword(user: UserView): Omit<UserView, never> {
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function usersRoutes(app: FastifyInstance) {
|
||||
app.post('/api/users/register', async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = registerUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
const existing = await db.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.email, parsed.data.email.trim().toLowerCase()))
|
||||
.get();
|
||||
if (existing) {
|
||||
return reply.code(409).send({ error: 'Email already registered' });
|
||||
}
|
||||
const user = await createUser(parsed.data);
|
||||
const token = generateJwt({ userId: user.id, email: user.email, role: user.role });
|
||||
reply.code(201).send({ user: omitPassword(user), token });
|
||||
});
|
||||
|
||||
app.post('/api/users/login', async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = loginUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
const user = await authenticateUser(parsed.data.email, parsed.data.password);
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'Invalid email or password' });
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
return reply.code(403).send({ error: 'Account is disabled' });
|
||||
}
|
||||
const token = generateJwt({ userId: user.id, email: user.email, role: user.role });
|
||||
reply.code(200).send({ user: omitPassword(user), token });
|
||||
});
|
||||
|
||||
app.get('/api/users/me', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
const user = await getUserById(ctx.userId);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
reply.send({ user: omitPassword(user) });
|
||||
});
|
||||
|
||||
app.patch('/api/users/me', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
const updates: Partial<{ username: string }> = {};
|
||||
if (parsed.data.username !== undefined) updates.username = parsed.data.username;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await updateUser(ctx.userId, updates);
|
||||
}
|
||||
const user = await getUserById(ctx.userId);
|
||||
reply.send({ user: user ? omitPassword(user) : null });
|
||||
});
|
||||
|
||||
app.post('/api/users/me/password', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = updatePasswordSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
const currentUser = await authenticateUser(ctx.email, parsed.data.oldPassword);
|
||||
if (!currentUser) {
|
||||
return reply.code(401).send({ error: 'Current password is incorrect' });
|
||||
}
|
||||
await updateUserPassword(ctx.userId, parsed.data.newPassword);
|
||||
reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/api/users', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
await requireAdmin(request, reply);
|
||||
if (reply.sent) return;
|
||||
const users = await listUsers();
|
||||
reply.send({ users: users.map(omitPassword) });
|
||||
});
|
||||
|
||||
app.get('/api/users/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
const ctx = getUserAuthContext(request);
|
||||
if (!ctx) {
|
||||
return reply.code(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
if (ctx.userId !== id && ctx.role !== 'admin') {
|
||||
return reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
const user = await getUserById(id);
|
||||
if (!user) {
|
||||
return reply.code(404).send({ error: 'User not found' });
|
||||
}
|
||||
reply.send({ user: omitPassword(user) });
|
||||
});
|
||||
|
||||
app.patch('/api/users/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
await requireAdmin(request, reply);
|
||||
if (reply.sent) return;
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'Invalid request body', details: parsed.error.format() });
|
||||
}
|
||||
await updateUser(id, parsed.data);
|
||||
const user = await getUserById(id);
|
||||
reply.send({ user: user ? omitPassword(user) : null });
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', async (request, reply) => {
|
||||
await userAuthMiddleware(request, reply);
|
||||
if (reply.sent) return;
|
||||
await requireAdmin(request, reply);
|
||||
if (reply.sent) return;
|
||||
const id = Number((request.params as Record<string, unknown>).id);
|
||||
if (!Number.isFinite(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid user ID' });
|
||||
}
|
||||
await updateUser(id, { status: 'disabled' });
|
||||
reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
|
||||
const METAPI_BASE = `http://127.0.0.1:${process.env.PORT || 3000}`;
|
||||
|
||||
interface AgentRequest {
|
||||
script: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Video Generation Agent
|
||||
*
|
||||
* Workflow:
|
||||
* 1. User provides script
|
||||
* 2. Agent parses script → characters, scenes, dialogue
|
||||
* 3. For characters: generate front/back/left/right concept art
|
||||
* 4. For scenes: generate background/environment prompts
|
||||
* 5. For dialogue: generate TTS audio
|
||||
* 6. Package everything for ComfyUI workflow
|
||||
*/
|
||||
|
||||
// Default system prompt for the script analysis agent
|
||||
const SCRIPT_ANALYSIS_PROMPT = `你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
|
||||
|
||||
{
|
||||
"title": "视频标题",
|
||||
"characters": [
|
||||
{
|
||||
"name": "角色名",
|
||||
"gender": "男/女",
|
||||
"age": "年龄描述",
|
||||
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
|
||||
"personality": "性格特征"
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": 1,
|
||||
"description": "场景描述",
|
||||
"environment": "环境详细描述(时间、地点、天气、光线等)",
|
||||
"characters": ["出现的角色名"],
|
||||
"action": "场景中发生的动作",
|
||||
"dialogue": [
|
||||
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"style": "整体视觉风格(动画/写实/像素等)"
|
||||
}
|
||||
|
||||
只返回 JSON,不要其他文字。`;
|
||||
|
||||
const CHARACTER_PROMPT_GEN = `根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
|
||||
每组提示词应包含:人物姿态、服装、光照、背景。
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"front": "正面提示词",
|
||||
"back": "背面提示词",
|
||||
"left": "左侧面提示词",
|
||||
"right": "右侧面提示词"
|
||||
}
|
||||
只返回 JSON。`;
|
||||
|
||||
const SCENE_PROMPT_GEN = `根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
|
||||
包含:环境、光照、色调、氛围。
|
||||
返回 JSON:
|
||||
{
|
||||
"prompt": "场景生成提示词",
|
||||
"negative_prompt": "负面提示词"
|
||||
}
|
||||
只返回 JSON。`;
|
||||
|
||||
async function callLLM(prompt: string, system: string): Promise<string> {
|
||||
const resp = await fetch(`${METAPI_BASE}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer admin`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.5',
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
return data?.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
async function callImageGen(prompt: string): Promise<string> {
|
||||
const resp = await fetch(`${METAPI_BASE}/v1/images/generations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer admin`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'dall-e-3',
|
||||
prompt,
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
}),
|
||||
});
|
||||
const data: any = await resp.json();
|
||||
return data?.data?.[0]?.url || '';
|
||||
}
|
||||
|
||||
async function callTTS(text: string, voice = 'alloy'): Promise<Buffer | null> {
|
||||
const resp = await fetch(`${METAPI_BASE}/v1/audio/speech`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer admin`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'tts-1',
|
||||
input: text,
|
||||
voice,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const buffer = await resp.arrayBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
export async function videoAgentRoutes(app: FastifyInstance) {
|
||||
// POST /api/video-agent/analyze — analyze script and extract structure
|
||||
app.post('/api/video-agent/analyze', async (request, reply) => {
|
||||
const { script } = request.body as AgentRequest;
|
||||
if (!script) {
|
||||
return reply.code(400).send({ error: 'script is required' });
|
||||
}
|
||||
try {
|
||||
const result = await callLLM(script, SCRIPT_ANALYSIS_PROMPT);
|
||||
const parsed = JSON.parse(result.replace(/```json|```/g, '').trim());
|
||||
return { success: true, data: parsed };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Analysis failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/character-prompts — generate character image prompts
|
||||
app.post('/api/video-agent/character-prompts', async (request, reply) => {
|
||||
const { character } = request.body as any;
|
||||
if (!character) {
|
||||
return reply.code(400).send({ error: 'character is required' });
|
||||
}
|
||||
try {
|
||||
const desc = `${character.name}: ${character.appearance}, ${character.gender}, ${character.age}`;
|
||||
const result = await callLLM(desc, CHARACTER_PROMPT_GEN);
|
||||
const prompts = JSON.parse(result.replace(/```json|```/g, '').trim());
|
||||
return { success: true, data: prompts };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Prompt generation failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/generate-character — generate character images (front/back/left/right)
|
||||
app.post('/api/video-agent/generate-character', async (request, reply) => {
|
||||
const { prompts } = request.body as any;
|
||||
if (!prompts) {
|
||||
return reply.code(400).send({ error: 'prompts is required' });
|
||||
}
|
||||
try {
|
||||
const results: Record<string, string> = {};
|
||||
for (const [angle, prompt] of Object.entries(prompts)) {
|
||||
const url = await callImageGen(prompt as string);
|
||||
results[angle] = url;
|
||||
}
|
||||
return { success: true, data: results };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Image generation failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/scene-prompt — generate scene image prompt
|
||||
app.post('/api/video-agent/scene-prompt', async (request, reply) => {
|
||||
const { scene } = request.body as any;
|
||||
if (!scene) {
|
||||
return reply.code(400).send({ error: 'scene is required' });
|
||||
}
|
||||
try {
|
||||
const desc = `${scene.environment}. ${scene.description}`;
|
||||
const result = await callLLM(desc, SCENE_PROMPT_GEN);
|
||||
const prompt = JSON.parse(result.replace(/```json|```/g, '').trim());
|
||||
return { success: true, data: prompt };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Scene prompt failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/generate-scene — generate scene image
|
||||
app.post('/api/video-agent/generate-scene', async (request, reply) => {
|
||||
const { prompt } = request.body as any;
|
||||
if (!prompt) {
|
||||
return reply.code(400).send({ error: 'prompt is required' });
|
||||
}
|
||||
try {
|
||||
const url = await callImageGen(prompt);
|
||||
return { success: true, data: { url } };
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Scene generation failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/generate-tts — generate TTS audio for a line
|
||||
app.post('/api/video-agent/generate-tts', async (request, reply) => {
|
||||
const { text, voice } = request.body as any;
|
||||
if (!text) {
|
||||
return reply.code(400).send({ error: 'text is required' });
|
||||
}
|
||||
try {
|
||||
const audio = await callTTS(text, voice || 'alloy');
|
||||
if (!audio) {
|
||||
return reply.code(502).send({ error: 'TTS generation failed' });
|
||||
}
|
||||
return reply.type('audio/mpeg').send(audio);
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `TTS failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/video-agent/full-workflow — run complete workflow: analyze + generate all assets
|
||||
app.post('/api/video-agent/full-workflow', async (request, reply) => {
|
||||
const { script, style } = request.body as AgentRequest;
|
||||
if (!script) {
|
||||
return reply.code(400).send({ error: 'script is required' });
|
||||
}
|
||||
try {
|
||||
// Phase 1: Analyze script
|
||||
const analysisRaw = await callLLM(script, SCRIPT_ANALYSIS_PROMPT);
|
||||
const analysis = JSON.parse(analysisRaw.replace(/```json|```/g, '').trim());
|
||||
|
||||
// Phase 2: Generate character prompts and images
|
||||
const characters: any[] = [];
|
||||
for (const char of (analysis.characters || [])) {
|
||||
const desc = `${char.name}: ${char.appearance}, ${char.gender}, ${char.age}`;
|
||||
const promptsRaw = await callLLM(desc, CHARACTER_PROMPT_GEN);
|
||||
const prompts = JSON.parse(promptsRaw.replace(/```json|```/g, '').trim());
|
||||
characters.push({ ...char, imagePrompts: prompts });
|
||||
}
|
||||
|
||||
// Phase 3: Generate scene prompts
|
||||
const scenes: any[] = [];
|
||||
for (const scene of (analysis.scenes || [])) {
|
||||
const desc = `${scene.environment}. ${scene.description}`;
|
||||
const scenePromptRaw = await callLLM(desc, SCENE_PROMPT_GEN);
|
||||
const scenePrompt = JSON.parse(scenePromptRaw.replace(/```json|```/g, '').trim());
|
||||
scenes.push({ ...scene, imagePrompt: scenePrompt });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
title: analysis.title,
|
||||
style: analysis.style || style,
|
||||
characters,
|
||||
scenes,
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
return reply.code(500).send({ error: `Workflow failed: ${e.message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import { composeProxyLogMessage } from '../../services/proxyLogMessage.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../proxy-core/downstreamClientContext.js';
|
||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||
import { fetchWithObservedFirstByte, getObservedResponseMeta } from '../../proxy-core/firstByteTimeout.js';
|
||||
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js';
|
||||
import {
|
||||
buildForcedChannelUnavailableMessage,
|
||||
canRetryChannelSelection,
|
||||
getTesterForcedChannelId,
|
||||
selectProxyChannelForAttempt,
|
||||
} from '../../proxy-core/channelSelection.js';
|
||||
|
||||
export async function audioProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
|
||||
app.post('/v1/audio/speech', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
return await handleAudioProxy(request, reply, '/v1/audio/speech', 'POST', requestedModel, async (selected, upstreamModel, targetUrl) => {
|
||||
const forwardBody = { ...body, model: upstreamModel };
|
||||
const startTime = Date.now();
|
||||
const response = await fetchWithObservedFirstByte(
|
||||
async (signal) => fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: JSON.stringify(forwardBody),
|
||||
signal,
|
||||
}, getProxyUrlFromExtraConfig(selected.account.extraConfig))),
|
||||
{ firstByteTimeoutMs: Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)), startedAtMs: startTime },
|
||||
);
|
||||
return response;
|
||||
}, true);
|
||||
});
|
||||
|
||||
app.post('/v1/audio/transcriptions', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
await handleAudioMultipart(request, reply, '/v1/audio/transcriptions');
|
||||
});
|
||||
|
||||
app.post('/v1/audio/translations', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
await handleAudioMultipart(request, reply, '/v1/audio/translations');
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAudioMultipart(request: FastifyRequest, reply: FastifyReply, downstreamPath: string) {
|
||||
const multipartForm = await parseMultipartFormData(request);
|
||||
const jsonBody = (!multipartForm && request.body && typeof request.body === 'object')
|
||||
? request.body as Record<string, unknown>
|
||||
: null;
|
||||
const requestedModel = typeof multipartForm?.get('model') === 'string'
|
||||
? String(multipartForm.get('model')).trim()
|
||||
: (typeof jsonBody?.model === 'string' ? jsonBody.model.trim() : '');
|
||||
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
|
||||
return await handleAudioProxy(request, reply, downstreamPath, 'POST', requestedModel, async (selected, upstreamModel, targetUrl) => {
|
||||
const startTime = Date.now();
|
||||
const requestInit = multipartForm
|
||||
? withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: cloneFormDataWithOverrides(multipartForm, { model: upstreamModel }) as any,
|
||||
}, getProxyUrlFromExtraConfig(selected.account.extraConfig))
|
||||
: withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
},
|
||||
body: JSON.stringify({ ...(jsonBody || {}), model: upstreamModel }),
|
||||
}, getProxyUrlFromExtraConfig(selected.account.extraConfig));
|
||||
|
||||
const response = await fetchWithObservedFirstByte(
|
||||
async (signal) => fetch(targetUrl, { ...requestInit, signal }),
|
||||
{ firstByteTimeoutMs: Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000)), startedAtMs: startTime },
|
||||
);
|
||||
return response;
|
||||
}, false);
|
||||
}
|
||||
|
||||
type AudioProxyHandler = (
|
||||
selected: any,
|
||||
upstreamModel: string,
|
||||
targetUrl: string,
|
||||
) => Promise<Response>;
|
||||
|
||||
async function handleAudioProxy(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
downstreamPath: string,
|
||||
_method: string,
|
||||
requestedModel: string,
|
||||
dispatch: AudioProxyHandler,
|
||||
isBinary: boolean,
|
||||
) {
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const forcedChannelId = getTesterForcedChannelId({
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
clientIp: request.ip,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body: request.body as Record<string, unknown> | undefined,
|
||||
});
|
||||
const firstByteTimeoutMs = Math.max(0, Math.trunc((config.proxyFirstByteTimeoutSec || 0) * 1000));
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= getProxyMaxChannelRetries()) {
|
||||
const selected = await selectProxyChannelForAttempt({
|
||||
requestedModel,
|
||||
downstreamPolicy,
|
||||
excludeChannelIds,
|
||||
retryCount,
|
||||
forcedChannelId,
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
const noChannelMessage = buildForcedChannelUnavailableMessage(forcedChannelId);
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: forcedChannelId ? noChannelMessage : 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: noChannelMessage, type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
const upstreamModel = selected.actualModel || requestedModel;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const { upstream, firstByteLatencyMs } = await runWithSiteApiEndpointPool(selected.site, async (target) => {
|
||||
const targetUrl = buildUpstreamUrl(target.baseUrl, downstreamPath);
|
||||
const response = await dispatch(selected, upstreamModel, targetUrl);
|
||||
const observedFirstByteLatencyMs = getObservedResponseMeta(response)?.firstByteLatencyMs ?? null;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'unknown error');
|
||||
throw new SiteApiEndpointRequestError(text, {
|
||||
status: response.status,
|
||||
rawErrText: text || null,
|
||||
firstByteLatencyMs: observedFirstByteLatencyMs,
|
||||
});
|
||||
}
|
||||
return { upstream: response, firstByteLatencyMs: observedFirstByteLatencyMs };
|
||||
});
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
let estimatedCost = 0;
|
||||
await recordTokenRouterEventBestEffort('estimate proxy cost', async () => {
|
||||
estimatedCost = await estimateProxyCost({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: upstreamModel,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
});
|
||||
});
|
||||
await recordTokenRouterEventBestEffort('record channel success', () => (
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, upstreamModel)
|
||||
));
|
||||
await recordTokenRouterEventBestEffort('record downstream cost usage', () => (
|
||||
recordDownstreamCostUsage(request, estimatedCost)
|
||||
));
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamApiKeyId,
|
||||
estimatedCost,
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
false,
|
||||
firstByteLatencyMs,
|
||||
);
|
||||
|
||||
if (isBinary) {
|
||||
const contentType = upstream.headers.get('content-type') || 'audio/mpeg';
|
||||
const buffer = Buffer.from(await upstream.arrayBuffer());
|
||||
return reply.code(upstream.status).type(contentType).send(buffer);
|
||||
}
|
||||
|
||||
const text = await upstream.text();
|
||||
let data: unknown;
|
||||
try { data = JSON.parse(text); } catch { data = text; }
|
||||
return reply.code(upstream.status).type('application/json').send(data);
|
||||
} catch (err: any) {
|
||||
const status = err instanceof SiteApiEndpointRequestError ? (err.status || 0) : 0;
|
||||
const errorText = err?.message || 'network failure';
|
||||
const firstByteLatencyMs = err instanceof SiteApiEndpointRequestError ? err.firstByteLatencyMs : null;
|
||||
await recordTokenRouterEventBestEffort('record channel failure', () => tokenRouter.recordFailure(selected.channel.id, {
|
||||
status,
|
||||
errorText,
|
||||
modelName: upstreamModel,
|
||||
}));
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
status,
|
||||
Date.now() - startTime,
|
||||
errorText,
|
||||
retryCount,
|
||||
downstreamApiKeyId,
|
||||
0,
|
||||
downstreamPath,
|
||||
clientContext,
|
||||
false,
|
||||
firstByteLatencyMs,
|
||||
);
|
||||
if ((status > 0 ? shouldRetryProxyRequest(status, errorText) : true) && canRetryChannelSelection(retryCount, forcedChannelId)) {
|
||||
retryCount++;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: errorText || 'network failure',
|
||||
});
|
||||
return reply.code(status || 502).send({
|
||||
error: {
|
||||
message: status > 0 ? errorText : `Upstream error: ${errorText}`,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamApiKeyId: number | null,
|
||||
estimatedCost: number,
|
||||
downstreamPath: string,
|
||||
clientContext: DownstreamClientContext | null,
|
||||
isStream: boolean,
|
||||
firstByteLatencyMs: number | null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await insertProxyLog({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
downstreamApiKeyId,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel || modelRequested,
|
||||
status,
|
||||
httpStatus,
|
||||
isStream,
|
||||
firstByteLatencyMs,
|
||||
latencyMs,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
estimatedCost,
|
||||
clientFamily: clientContext?.clientKind || null,
|
||||
clientAppId: clientContext?.clientAppId || null,
|
||||
clientAppName: clientContext?.clientAppName || null,
|
||||
clientConfidence: clientContext?.clientConfidence || null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[proxy/audio] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function recordTokenRouterEventBestEffort(
|
||||
label: string,
|
||||
operation: () => Promise<unknown> | unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
console.warn(`[proxy/audio] failed to ${label}`, error);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { searchProxyRoute } from './search.js';
|
||||
import { geminiProxyRoute } from './gemini.js';
|
||||
import { videosProxyRoute } from './videos.js';
|
||||
import { filesProxyRoute } from './files.js';
|
||||
import { audioProxyRoute } from './audio.js';
|
||||
|
||||
export async function proxyRoutes(app: FastifyInstance) {
|
||||
// Auth middleware for all /v1 routes
|
||||
@@ -27,5 +28,6 @@ export async function proxyRoutes(app: FastifyInstance) {
|
||||
await app.register(filesProxyRoute);
|
||||
await app.register(imagesProxyRoute);
|
||||
await app.register(videosProxyRoute);
|
||||
await app.register(audioProxyRoute);
|
||||
await app.register(geminiProxyRoute);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { isUserActive } from './userService.js';
|
||||
import {
|
||||
EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||
type DownstreamExcludedCredentialRef,
|
||||
@@ -29,6 +30,7 @@ export type DownstreamApiKeyPolicyView = {
|
||||
siteWeightMultipliers: Record<number, number>;
|
||||
excludedSiteIds: number[];
|
||||
excludedCredentialRefs: DownstreamExcludedCredentialRef[];
|
||||
userId: number | null;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
@@ -367,6 +369,7 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre
|
||||
siteWeightMultipliers,
|
||||
excludedSiteIds,
|
||||
excludedCredentialRefs,
|
||||
userId: row.userId ?? null,
|
||||
lastUsedAt: row.lastUsedAt || null,
|
||||
createdAt: row.createdAt || null,
|
||||
updatedAt: row.updatedAt || null,
|
||||
@@ -467,6 +470,15 @@ export async function authorizeDownstreamToken(token: string): Promise<Downstrea
|
||||
};
|
||||
}
|
||||
|
||||
if (managed.userId !== null && !await isUserActive(managed.userId)) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 403,
|
||||
error: 'User account is disabled',
|
||||
reason: 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
source: 'managed',
|
||||
|
||||
@@ -78,7 +78,7 @@ function buildTelegramText(
|
||||
timeFootnote: string,
|
||||
): string {
|
||||
const maxTextLength = 3900;
|
||||
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\nLevel: ${level}\n${timeFootnote}`;
|
||||
const raw = `[BoosAPI][${level.toUpperCase()}] ${title}\n\n${message}\n\nLevel: ${level}\n${timeFootnote}`;
|
||||
if (raw.length <= maxTextLength) return raw;
|
||||
return `${raw.slice(0, maxTextLength)}\n\n...(truncated)`;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ function buildWeComText(
|
||||
timeFootnote: string,
|
||||
): string {
|
||||
const maxLength = 1900;
|
||||
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
const raw = `[BoosAPI][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
if (raw.length <= maxLength) return raw;
|
||||
return `${raw.slice(0, maxLength)}\n...(truncated)`;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function buildFeishuText(
|
||||
timeFootnote: string,
|
||||
): string {
|
||||
const maxLength = 3900;
|
||||
const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
const raw = `[BoosAPI][${level.toUpperCase()}] ${title}\n\n${message}\n\n${timeFootnote}`;
|
||||
if (raw.length <= maxLength) return raw;
|
||||
return `${raw.slice(0, maxLength)}\n...(truncated)`;
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export async function sendNotification(
|
||||
run: () => transporter.sendMail({
|
||||
from: config.smtpFrom,
|
||||
to: config.smtpTo,
|
||||
subject: `[metapi][${level.toUpperCase()}] ${title}`,
|
||||
subject: `[BoosAPI][${level.toUpperCase()}] ${title}`,
|
||||
text: `${resolvedMessage}\n\nLevel: ${level}\n${timeFootnote}`,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -121,7 +121,7 @@ async function handleCallbackRequest(
|
||||
});
|
||||
respondHtml(response, 200, 'OAuth authorization succeeded. You can close this window.');
|
||||
} catch {
|
||||
respondHtml(response, 500, 'OAuth authorization failed. Return to metapi and review the server logs.');
|
||||
respondHtml(response, 500, 'OAuth authorization failed. Return to BoosAPI and review the server logs.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
|
||||
private buildDefaultTokenPayload(options?: CreateApiTokenOptions): Record<string, unknown> {
|
||||
const normalizedName = (options?.name || '').trim() || 'metapi';
|
||||
const normalizedName = (options?.name || '').trim() || 'BoosAPI';
|
||||
const unlimitedQuota = options?.unlimitedQuota ?? true;
|
||||
const remainQuota = Number.isFinite(options?.remainQuota)
|
||||
? Math.max(0, Math.trunc(options?.remainQuota as number))
|
||||
|
||||
@@ -20,7 +20,7 @@ export class OneApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
|
||||
private buildDefaultTokenPayload(options?: CreateApiTokenOptions): CreateApiTokenPayload {
|
||||
const normalizedName = (options?.name || '').trim() || 'metapi';
|
||||
const normalizedName = (options?.name || '').trim() || 'BoosAPI';
|
||||
const unlimitedQuota = options?.unlimitedQuota ?? true;
|
||||
const remainQuota = Number.isFinite(options?.remainQuota)
|
||||
? Math.max(0, Math.trunc(options?.remainQuota as number))
|
||||
|
||||
@@ -819,7 +819,7 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
): Promise<boolean> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const payload: Record<string, unknown> = {
|
||||
name: (options?.name || '').trim() || 'metapi',
|
||||
name: (options?.name || '').trim() || 'BoosAPI',
|
||||
};
|
||||
|
||||
const groupId = Number.parseInt((options?.group || '').trim(), 10);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function buildStartupSummaryLines(input: StartupSummaryInput): string[] {
|
||||
const endpoints = buildStartupEndpoints(input);
|
||||
|
||||
return [
|
||||
`metapi running on ${input.host}:${input.port}`,
|
||||
`BoosAPI running on ${input.host}:${input.port}`,
|
||||
`Dashboard: ${endpoints.adminDashboardUrl}`,
|
||||
`Admin API: ${endpoints.adminApiExample}`,
|
||||
`Proxy API: ${endpoints.proxyApiExample}`,
|
||||
|
||||
@@ -25,7 +25,7 @@ export function getDefaultUpdateCenterConfig(): UpdateCenterConfig {
|
||||
namespace: 'default',
|
||||
releaseName: '',
|
||||
chartRef: '',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageRepository: '1467078763/boosapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
|
||||
@@ -45,8 +45,8 @@ export type DockerHubTagCandidates = {
|
||||
};
|
||||
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases';
|
||||
const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/metapi/tags?page_size=100';
|
||||
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/boosapi/releases';
|
||||
const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/boosapi/tags?page_size=100';
|
||||
const UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000;
|
||||
const PREFERRED_DOCKER_HUB_TAG_ALIASES = ['latest', 'main'] as const;
|
||||
const MAX_RECENT_NON_STABLE_DOCKER_HUB_TAGS = 5;
|
||||
@@ -284,7 +284,7 @@ export async function fetchLatestStableGitHubRelease(): Promise<UpdateCenterVers
|
||||
const releases = await fetchJsonWithTimeout(GITHUB_RELEASES_URL, {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
'user-agent': 'boosapi-update-center/1.0',
|
||||
},
|
||||
}, 'GitHub releases lookup') as GitHubReleaseRecord[];
|
||||
return selectLatestStableGitHubRelease(Array.isArray(releases) ? releases : []);
|
||||
@@ -298,7 +298,7 @@ export async function fetchDockerHubTagCandidates(): Promise<DockerHubTagCandida
|
||||
const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, {
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'user-agent': 'metapi-update-center/1.0',
|
||||
'user-agent': 'boosapi-update-center/1.0',
|
||||
},
|
||||
}, 'Docker Hub tag lookup') as { results?: DockerHubTagRecord[] };
|
||||
return selectDockerHubTagCandidates(Array.isArray(payload?.results) ? payload.results : []);
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { insertAndGetById } from '../db/insertHelpers.js';
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
|
||||
const SALT_LEN = 32;
|
||||
const KEY_LEN = 64;
|
||||
const JWT_SECRET = config.accountCredentialSecret;
|
||||
|
||||
function generateSalt(): string {
|
||||
return randomBytes(SALT_LEN).toString('hex');
|
||||
}
|
||||
|
||||
async function hashPassword(password: string, salt: string): Promise<string> {
|
||||
const derived = await scryptAsync(password, salt, KEY_LEN) as Buffer;
|
||||
return `${salt}:${derived.toString('hex')}`;
|
||||
}
|
||||
|
||||
async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [salt, hash] = stored.split(':');
|
||||
if (!salt || !hash) return false;
|
||||
const derived = await scryptAsync(password, salt, KEY_LEN) as Buffer;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(hash, 'hex'), derived);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'user';
|
||||
export type UserStatus = 'active' | 'disabled';
|
||||
|
||||
export type UserRow = typeof schema.users.$inferSelect;
|
||||
|
||||
export type UserView = {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type JwtPayload = {
|
||||
userId: number;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
iat: number;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function base64UrlEncode(input: string): string {
|
||||
return Buffer.from(input)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string): string {
|
||||
const padding = '='.repeat((4 - (input.length % 4)) % 4);
|
||||
const base64 = input.replace(/-/g, '+').replace(/_/g, '/') + padding;
|
||||
return Buffer.from(base64, 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
export function generateJwt(payload: Omit<JwtPayload, 'iat' | 'exp'> & { expiresInMs?: number }): string {
|
||||
const now = Math.trunc(Date.now() / 1000);
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const body: JwtPayload = {
|
||||
userId: payload.userId,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
iat: now,
|
||||
exp: now + Math.trunc((payload.expiresInMs ?? 12 * 60 * 60 * 1000) / 1000),
|
||||
};
|
||||
const segments = [
|
||||
base64UrlEncode(JSON.stringify(header)),
|
||||
base64UrlEncode(JSON.stringify(body)),
|
||||
];
|
||||
const signature = createHmac('sha256', JWT_SECRET)
|
||||
.update(segments.join('.'))
|
||||
.digest()
|
||||
.toString('base64url');
|
||||
return `${segments[0]}.${segments[1]}.${signature}`;
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string): JwtPayload | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const [headerB64, payloadB64, signature] = parts;
|
||||
if (!headerB64 || !payloadB64 || !signature) return null;
|
||||
|
||||
const expected = createHmac('sha256', JWT_SECRET)
|
||||
.update(`${headerB64}.${payloadB64}`)
|
||||
.digest()
|
||||
.toString('base64url');
|
||||
|
||||
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(base64UrlDecode(payloadB64)) as JwtPayload;
|
||||
const now = Math.trunc(Date.now() / 1000);
|
||||
if (typeof payload.exp === 'number' && payload.exp <= now) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toUserView(row: UserRow): UserView {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
role: (row.role as UserRole) || 'user',
|
||||
status: (row.status as UserStatus) || 'active',
|
||||
createdAt: row.createdAt || null,
|
||||
updatedAt: row.updatedAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createUser(input: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: UserRole;
|
||||
}): Promise<UserView> {
|
||||
const salt = generateSalt();
|
||||
const passwordHash = await hashPassword(input.password, salt);
|
||||
const row = await insertAndGetById<UserRow>({
|
||||
table: schema.users,
|
||||
values: {
|
||||
username: input.username.trim(),
|
||||
email: input.email.trim().toLowerCase(),
|
||||
passwordHash,
|
||||
role: input.role ?? 'user',
|
||||
status: 'active',
|
||||
},
|
||||
idColumn: schema.users.id,
|
||||
insertErrorMessage: 'Failed to create user',
|
||||
});
|
||||
return toUserView(row);
|
||||
}
|
||||
|
||||
export async function authenticateUser(email: string, password: string): Promise<UserView | null> {
|
||||
const row = await db.select().from(schema.users)
|
||||
.where(eq(schema.users.email, email.trim().toLowerCase()))
|
||||
.get();
|
||||
if (!row) return null;
|
||||
const valid = await verifyPassword(password, row.passwordHash);
|
||||
if (!valid) return null;
|
||||
return toUserView(row);
|
||||
}
|
||||
|
||||
export async function getUserById(id: number): Promise<UserView | null> {
|
||||
const row = await db.select().from(schema.users).where(eq(schema.users.id, id)).get();
|
||||
if (!row) return null;
|
||||
return toUserView(row);
|
||||
}
|
||||
|
||||
export async function listUsers(): Promise<UserView[]> {
|
||||
const rows = await db.select().from(schema.users).all();
|
||||
return rows.map(toUserView).sort((a, b) => b.id - a.id);
|
||||
}
|
||||
|
||||
export async function updateUser(id: number, input: Partial<Pick<UserRow, 'username' | 'role' | 'status'>>): Promise<void> {
|
||||
const updates: Partial<Record<string, unknown>> = {};
|
||||
if (input.username !== undefined) updates.username = input.username.trim();
|
||||
if (input.role !== undefined) updates.role = input.role;
|
||||
if (input.status !== undefined) updates.status = input.status;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
await db.update(schema.users).set(updates).where(eq(schema.users.id, id)).run();
|
||||
}
|
||||
|
||||
export async function updateUserPassword(id: number, password: string): Promise<void> {
|
||||
const salt = generateSalt();
|
||||
const passwordHash = await hashPassword(password, salt);
|
||||
await db.update(schema.users).set({
|
||||
passwordHash,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.users.id, id)).run();
|
||||
}
|
||||
|
||||
export async function isUserActive(id: number): Promise<boolean> {
|
||||
const row = await db.select({ status: schema.users.status })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, id))
|
||||
.get();
|
||||
return row?.status === 'active';
|
||||
}
|
||||
+7
-13
@@ -1,21 +1,15 @@
|
||||
export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
|
||||
|
||||
export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[];
|
||||
export declare function inferConversationFileMimeType(filename: string | null | undefined): string;
|
||||
export declare function resolveConversationFileMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
filename: string | null | undefined,
|
||||
): string;
|
||||
export declare function classifyConversationFileMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
): Exclude<ConversationFileKind, 'unknown'>;
|
||||
export declare function resolveConversationFileMimeType(mimeType: string | null | undefined, filename: string | null | undefined): string;
|
||||
export declare function classifyConversationFileMimeType(mimeType: string | null | undefined): Exclude<ConversationFileKind, 'unknown'>;
|
||||
export declare function detectConversationFileKind(file: {
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
}): ConversationFileKind;
|
||||
export declare function isSupportedConversationFileMimeType(mimeType: string): boolean;
|
||||
export declare function buildConversationAcceptList(input: {
|
||||
document: boolean;
|
||||
image: boolean;
|
||||
audio: boolean;
|
||||
document: boolean;
|
||||
image: boolean;
|
||||
audio: boolean;
|
||||
}): string;
|
||||
|
||||
@@ -1,94 +1,134 @@
|
||||
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json'];
|
||||
const GENERIC_MIME_TYPES = new Set([
|
||||
'application/octet-stream',
|
||||
'binary/octet-stream',
|
||||
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = [
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.json',
|
||||
];
|
||||
const EXTENSION_MIME_MAP = {
|
||||
// Documents
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
json: 'application/json',
|
||||
csv: 'text/csv',
|
||||
xml: 'text/xml',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
// Images
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
tiff: 'image/tiff',
|
||||
tif: 'image/tiff',
|
||||
// Audio
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
wma: 'audio/x-ms-wma',
|
||||
webm: 'audio/webm',
|
||||
};
|
||||
const SUPPORTED_MIME_TYPES = new Set([
|
||||
// Images
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
'image/svg+xml',
|
||||
'image/tiff',
|
||||
// Audio
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/webm',
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'application/json',
|
||||
'text/csv',
|
||||
'text/xml',
|
||||
'text/html',
|
||||
]);
|
||||
const DOCUMENT_MIME_TYPES = new Set([
|
||||
'application/json',
|
||||
'application/pdf',
|
||||
'text/markdown',
|
||||
'text/plain',
|
||||
]);
|
||||
const DOCUMENT_EXTENSIONS = ['.json', '.md', '.markdown', '.pdf', '.txt'];
|
||||
const IMAGE_EXTENSIONS = ['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'];
|
||||
const AUDIO_EXTENSIONS = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav', '.weba'];
|
||||
|
||||
function normalizeValue(value) {
|
||||
return (value || '').trim().toLowerCase();
|
||||
function stripMimeParameters(mimeType) {
|
||||
return mimeType.split(';')[0].trim();
|
||||
}
|
||||
|
||||
function normalizeMimeType(value) {
|
||||
const normalized = normalizeValue(value);
|
||||
const [essence] = normalized.split(';');
|
||||
return (essence || '').trim();
|
||||
function getExtension(filename) {
|
||||
if (!filename)
|
||||
return '';
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex < 0)
|
||||
return '';
|
||||
return filename.slice(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function inferConversationFileMimeType(filename) {
|
||||
const normalized = normalizeValue(filename);
|
||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
||||
if (normalized.endsWith('.txt')) return 'text/plain';
|
||||
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
|
||||
if (normalized.endsWith('.json')) return 'application/json';
|
||||
if (normalized.endsWith('.png')) return 'image/png';
|
||||
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (normalized.endsWith('.gif')) return 'image/gif';
|
||||
if (normalized.endsWith('.webp')) return 'image/webp';
|
||||
if (normalized.endsWith('.svg')) return 'image/svg+xml';
|
||||
if (normalized.endsWith('.avif')) return 'image/avif';
|
||||
if (normalized.endsWith('.bmp')) return 'image/bmp';
|
||||
if (normalized.endsWith('.wav')) return 'audio/wav';
|
||||
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
|
||||
if (normalized.endsWith('.m4a')) return 'audio/mp4';
|
||||
if (normalized.endsWith('.ogg')) return 'audio/ogg';
|
||||
if (normalized.endsWith('.aac')) return 'audio/aac';
|
||||
if (normalized.endsWith('.flac')) return 'audio/flac';
|
||||
if (normalized.endsWith('.weba')) return 'audio/webm';
|
||||
return 'application/octet-stream';
|
||||
const ext = getExtension(filename);
|
||||
return EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function resolveConversationFileMimeType(mimeType, filename) {
|
||||
const normalizedMimeType = normalizeMimeType(mimeType);
|
||||
if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) {
|
||||
return normalizedMimeType;
|
||||
}
|
||||
return inferConversationFileMimeType(filename);
|
||||
if (!mimeType) {
|
||||
return inferConversationFileMimeType(filename);
|
||||
}
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
if (stripped === 'application/octet-stream' || stripped === 'application/octet-stream;charset=utf-8') {
|
||||
return inferConversationFileMimeType(filename);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
export function classifyConversationFileMimeType(mimeType) {
|
||||
const normalized = normalizeMimeType(mimeType);
|
||||
if (normalized.startsWith('image/')) return 'image';
|
||||
if (normalized.startsWith('audio/')) return 'audio';
|
||||
return 'document';
|
||||
if (!mimeType)
|
||||
return 'document';
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
if (stripped.startsWith('image/'))
|
||||
return 'image';
|
||||
if (stripped.startsWith('audio/'))
|
||||
return 'audio';
|
||||
return 'document';
|
||||
}
|
||||
|
||||
export function detectConversationFileKind(file) {
|
||||
const mimeType = normalizeMimeType(file?.mimeType);
|
||||
if (mimeType) {
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document';
|
||||
if (!GENERIC_MIME_TYPES.has(mimeType)) return 'unknown';
|
||||
}
|
||||
|
||||
const filename = normalizeValue(file?.filename);
|
||||
if (!filename) return 'unknown';
|
||||
if (DOCUMENT_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'document';
|
||||
if (IMAGE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'image';
|
||||
if (AUDIO_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'audio';
|
||||
return 'unknown';
|
||||
const resolved = resolveConversationFileMimeType(file.mimeType, file.filename);
|
||||
const classified = classifyConversationFileMimeType(resolved);
|
||||
if (classified !== 'document')
|
||||
return classified;
|
||||
// When we don't have a meaningful mimeType, rely on filename extension
|
||||
// to distinguish between known document types and truly unknown files.
|
||||
const normalizedMime = file.mimeType ? stripMimeParameters(file.mimeType) : '';
|
||||
if (!normalizedMime || normalizedMime === 'application/octet-stream') {
|
||||
const ext = getExtension(file.filename);
|
||||
if (!ext || !EXTENSION_MIME_MAP[ext])
|
||||
return 'unknown';
|
||||
}
|
||||
return 'document';
|
||||
}
|
||||
|
||||
export function isSupportedConversationFileMimeType(mimeType) {
|
||||
const normalized = normalizeMimeType(mimeType);
|
||||
return DOCUMENT_MIME_TYPES.has(normalized)
|
||||
|| normalized.startsWith('image/')
|
||||
|| normalized.startsWith('audio/');
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
return SUPPORTED_MIME_TYPES.has(stripped);
|
||||
}
|
||||
|
||||
export function buildConversationAcceptList(input) {
|
||||
const parts = [];
|
||||
if (input.document) parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS);
|
||||
if (input.image) parts.push('image/*');
|
||||
if (input.audio) parts.push('audio/*');
|
||||
return parts.join(',');
|
||||
const parts = [];
|
||||
if (input.document) {
|
||||
parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS);
|
||||
}
|
||||
if (input.image) {
|
||||
parts.push('image/*');
|
||||
}
|
||||
if (input.audio) {
|
||||
parts.push('audio/*');
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
|
||||
|
||||
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[] = [
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.md',
|
||||
'.markdown',
|
||||
'.json',
|
||||
];
|
||||
|
||||
const EXTENSION_MIME_MAP: Record<string, string> = {
|
||||
// Documents
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
json: 'application/json',
|
||||
csv: 'text/csv',
|
||||
xml: 'text/xml',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
// Images
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
tiff: 'image/tiff',
|
||||
tif: 'image/tiff',
|
||||
// Audio
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
wma: 'audio/x-ms-wma',
|
||||
webm: 'audio/webm',
|
||||
};
|
||||
|
||||
const SUPPORTED_MIME_TYPES = new Set<string>([
|
||||
// Images
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/bmp',
|
||||
'image/svg+xml',
|
||||
'image/tiff',
|
||||
// Audio
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/webm',
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'application/json',
|
||||
'text/csv',
|
||||
'text/xml',
|
||||
'text/html',
|
||||
]);
|
||||
|
||||
function stripMimeParameters(mimeType: string): string {
|
||||
return mimeType.split(';')[0].trim();
|
||||
}
|
||||
|
||||
function getExtension(filename: string | null | undefined): string {
|
||||
if (!filename) return '';
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex < 0) return '';
|
||||
return filename.slice(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function inferConversationFileMimeType(filename: string | null | undefined): string {
|
||||
const ext = getExtension(filename);
|
||||
return EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function resolveConversationFileMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
filename: string | null | undefined,
|
||||
): string {
|
||||
if (!mimeType) {
|
||||
return inferConversationFileMimeType(filename);
|
||||
}
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
if (stripped === 'application/octet-stream' || stripped === 'application/octet-stream;charset=utf-8') {
|
||||
return inferConversationFileMimeType(filename);
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
export function classifyConversationFileMimeType(
|
||||
mimeType: string | null | undefined,
|
||||
): Exclude<ConversationFileKind, 'unknown'> {
|
||||
if (!mimeType) return 'document';
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
if (stripped.startsWith('image/')) return 'image';
|
||||
if (stripped.startsWith('audio/')) return 'audio';
|
||||
return 'document';
|
||||
}
|
||||
|
||||
export function detectConversationFileKind(file: {
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
}): ConversationFileKind {
|
||||
const resolved = resolveConversationFileMimeType(file.mimeType, file.filename);
|
||||
const classified = classifyConversationFileMimeType(resolved);
|
||||
|
||||
if (classified !== 'document') return classified;
|
||||
|
||||
// When we don't have a meaningful mimeType, rely on filename extension
|
||||
// to distinguish between known document types and truly unknown files.
|
||||
const normalizedMime = file.mimeType ? stripMimeParameters(file.mimeType) : '';
|
||||
if (!normalizedMime || normalizedMime === 'application/octet-stream') {
|
||||
const ext = getExtension(file.filename);
|
||||
if (!ext || !EXTENSION_MIME_MAP[ext]) return 'unknown';
|
||||
}
|
||||
|
||||
return 'document';
|
||||
}
|
||||
|
||||
export function isSupportedConversationFileMimeType(mimeType: string): boolean {
|
||||
const stripped = stripMimeParameters(mimeType);
|
||||
return SUPPORTED_MIME_TYPES.has(stripped);
|
||||
}
|
||||
|
||||
export function buildConversationAcceptList(input: {
|
||||
document: boolean;
|
||||
image: boolean;
|
||||
audio: boolean;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
if (input.document) {
|
||||
parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS);
|
||||
}
|
||||
if (input.image) {
|
||||
parts.push('image/*');
|
||||
}
|
||||
if (input.audio) {
|
||||
parts.push('audio/*');
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
@@ -1,94 +1,65 @@
|
||||
export const PLATFORM_ALIASES = Object.assign(Object.create(null), {
|
||||
anyrouter: 'anyrouter',
|
||||
'wong-gongyi': 'new-api',
|
||||
'vo-api': 'new-api',
|
||||
'super-api': 'new-api',
|
||||
'rix-api': 'new-api',
|
||||
'neo-api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'new api': 'new-api',
|
||||
'new-api': 'new-api',
|
||||
oneapi: 'one-api',
|
||||
'one api': 'one-api',
|
||||
'one-api': 'one-api',
|
||||
onehub: 'one-hub',
|
||||
'one-hub': 'one-hub',
|
||||
donehub: 'done-hub',
|
||||
'done-hub': 'done-hub',
|
||||
veloera: 'veloera',
|
||||
sub2api: 'sub2api',
|
||||
openai: 'openai',
|
||||
codex: 'codex',
|
||||
'chatgpt-codex': 'codex',
|
||||
'chatgpt codex': 'codex',
|
||||
anthropic: 'claude',
|
||||
claude: 'claude',
|
||||
gemini: 'gemini',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
antigravity: 'antigravity',
|
||||
'anti-gravity': 'antigravity',
|
||||
google: 'gemini',
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
cpa: 'cliproxyapi',
|
||||
'cli-proxy-api': 'cliproxyapi',
|
||||
});
|
||||
|
||||
function getPlatformAlias(raw) {
|
||||
return Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, raw)
|
||||
? PLATFORM_ALIASES[raw]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeUrlCandidate(url) {
|
||||
return typeof url === 'string' ? url.trim() : '';
|
||||
}
|
||||
|
||||
function parseUrlCandidate(url) {
|
||||
const normalized = normalizeUrlCandidate(url);
|
||||
if (!normalized) return null;
|
||||
|
||||
const candidates = normalized.includes('://')
|
||||
? [normalized]
|
||||
: [`https://${normalized}`];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return new URL(candidate);
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const PLATFORM_ALIASES = {
|
||||
anthropic: 'claude',
|
||||
google: 'gemini',
|
||||
'chatgpt-codex': 'codex',
|
||||
'anti-gravity': 'antigravity',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
'claude-code': 'claude',
|
||||
'claude cli': 'claude',
|
||||
'new api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'one api': 'one-api',
|
||||
oneapi: 'one-api',
|
||||
'one hub': 'one-hub',
|
||||
onehub: 'one-hub',
|
||||
'done hub': 'done-hub',
|
||||
donehub: 'done-hub',
|
||||
};
|
||||
const PLATFORM_URL_HINTS = [
|
||||
{ hostPattern: 'api.openai.com', platform: 'openai' },
|
||||
{ hostPattern: 'chatgpt.com', platform: 'codex' },
|
||||
{ hostPattern: 'api.anthropic.com', platform: 'claude' },
|
||||
{ hostPattern: 'generativelanguage.googleapis.com', platform: 'gemini' },
|
||||
{ hostPattern: 'cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
|
||||
{ hostPattern: '127.0.0.1:8317', platform: 'cliproxyapi' },
|
||||
];
|
||||
export function normalizePlatformAlias(platform) {
|
||||
const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : '';
|
||||
if (!raw) return '';
|
||||
return getPlatformAlias(raw) ?? raw;
|
||||
if (!platform || typeof platform !== 'string')
|
||||
return '';
|
||||
const raw = platform.trim();
|
||||
if (!raw)
|
||||
return '';
|
||||
const lower = raw.toLowerCase();
|
||||
// Check PLATFORM_ALIASES first
|
||||
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, lower)) {
|
||||
return PLATFORM_ALIASES[lower];
|
||||
}
|
||||
// Generic normalization: replace underscores and spaces with hyphens
|
||||
const hyphenated = lower.replace(/[_ ]+/g, '-');
|
||||
// Check aliases again with hyphenated form
|
||||
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, hyphenated)) {
|
||||
return PLATFORM_ALIASES[hyphenated];
|
||||
}
|
||||
// Remove hyphens between word characters (e.g. anti-gravity -> antigravity)
|
||||
return hyphenated.replace(/-/g, '');
|
||||
}
|
||||
|
||||
export function detectPlatformByUrlHint(url) {
|
||||
const normalized = normalizeUrlCandidate(url).toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
const parsed = parseUrlCandidate(normalized);
|
||||
const host = parsed?.hostname?.trim().toLowerCase() || '';
|
||||
const port = parsed?.port?.trim() || '';
|
||||
const path = parsed?.pathname?.trim().toLowerCase() || '';
|
||||
|
||||
if (host === 'api.openai.com') return 'openai';
|
||||
if (host === 'chatgpt.com' && path.startsWith('/backend-api/codex')) return 'codex';
|
||||
if (host === 'api.anthropic.com' || (host === 'anthropic.com' && path.startsWith('/v1'))) return 'claude';
|
||||
if (
|
||||
host === 'generativelanguage.googleapis.com'
|
||||
|| host === 'gemini.google.com'
|
||||
|| ((host === 'googleapis.com' || host.endsWith('.googleapis.com')) && path.startsWith('/v1beta/openai'))
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (host === 'cloudcode-pa.googleapis.com') return 'gemini-cli';
|
||||
if ((host === '127.0.0.1' || host === 'localhost') && port === '8317') return 'cliproxyapi';
|
||||
if (host.includes('anyrouter')) return 'anyrouter';
|
||||
if (host.includes('donehub') || host.includes('done-hub')) return 'done-hub';
|
||||
if (host.includes('onehub') || host.includes('one-hub')) return 'one-hub';
|
||||
if (host.includes('veloera')) return 'veloera';
|
||||
if (host.includes('sub2api')) return 'sub2api';
|
||||
|
||||
return undefined;
|
||||
const lowerUrl = url.toLowerCase();
|
||||
for (const hint of PLATFORM_URL_HINTS) {
|
||||
if (lowerUrl.includes(hint.hostPattern)) {
|
||||
// Make sure the host appears as a proper hostname segment (prevent
|
||||
// forged queries like ?next=https://api.openai.com from matching).
|
||||
const hostIndex = lowerUrl.indexOf(hint.hostPattern);
|
||||
if (hostIndex >= 0) {
|
||||
// Check that what precedes the host pattern is either the start
|
||||
// of the URL or a valid URL separator (:// or /).
|
||||
const preceding = lowerUrl[hostIndex - 1];
|
||||
if (hostIndex === 0 || preceding === '/' || preceding === ':') {
|
||||
return hint.platform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
export const PLATFORM_ALIASES: Record<string, string> = {
|
||||
anthropic: 'claude',
|
||||
google: 'gemini',
|
||||
'chatgpt-codex': 'codex',
|
||||
'anti-gravity': 'antigravity',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
'claude-code': 'claude',
|
||||
'claude cli': 'claude',
|
||||
'new api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'one api': 'one-api',
|
||||
oneapi: 'one-api',
|
||||
'one hub': 'one-hub',
|
||||
onehub: 'one-hub',
|
||||
'done hub': 'done-hub',
|
||||
donehub: 'done-hub',
|
||||
};
|
||||
|
||||
const PLATFORM_URL_HINTS: Array<{ hostPattern: string; platform: string }> = [
|
||||
{ hostPattern: 'api.openai.com', platform: 'openai' },
|
||||
{ hostPattern: 'chatgpt.com', platform: 'codex' },
|
||||
{ hostPattern: 'api.anthropic.com', platform: 'claude' },
|
||||
{ hostPattern: 'generativelanguage.googleapis.com', platform: 'gemini' },
|
||||
{ hostPattern: 'cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
|
||||
{ hostPattern: '127.0.0.1:8317', platform: 'cliproxyapi' },
|
||||
];
|
||||
|
||||
export function normalizePlatformAlias(platform: unknown): string {
|
||||
if (!platform || typeof platform !== 'string') return '';
|
||||
const raw = platform.trim();
|
||||
if (!raw) return '';
|
||||
|
||||
const lower = raw.toLowerCase();
|
||||
|
||||
// Check PLATFORM_ALIASES first
|
||||
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, lower)) {
|
||||
return PLATFORM_ALIASES[lower];
|
||||
}
|
||||
|
||||
// Generic normalization: replace underscores and spaces with hyphens
|
||||
const hyphenated = lower.replace(/[_ ]+/g, '-');
|
||||
|
||||
// Check aliases again with hyphenated form
|
||||
if (Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, hyphenated)) {
|
||||
return PLATFORM_ALIASES[hyphenated];
|
||||
}
|
||||
|
||||
// Remove hyphens between word characters (e.g. anti-gravity -> antigravity)
|
||||
return hyphenated.replace(/-/g, '');
|
||||
}
|
||||
|
||||
export function detectPlatformByUrlHint(url: string): string | undefined {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
for (const hint of PLATFORM_URL_HINTS) {
|
||||
if (lowerUrl.includes(hint.hostPattern)) {
|
||||
// Make sure the host appears as a proper hostname segment (prevent
|
||||
// forged queries like ?next=https://api.openai.com from matching).
|
||||
const hostIndex = lowerUrl.indexOf(hint.hostPattern);
|
||||
if (hostIndex >= 0) {
|
||||
// Check that what precedes the host pattern is either the start
|
||||
// of the URL or a valid URL separator (:// or /).
|
||||
const preceding = lowerUrl[hostIndex - 1];
|
||||
if (hostIndex === 0 || preceding === '/' || preceding === ':') {
|
||||
return hint.platform;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Vendored
+6
-8
@@ -1,12 +1,10 @@
|
||||
export type ProxyLogUsageSource = 'upstream' | 'self-log' | 'unknown' | null;
|
||||
|
||||
export type ParsedProxyLogMetadata = {
|
||||
clientKind: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
usageSource: ProxyLogUsageSource;
|
||||
messageText: string;
|
||||
clientKind: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
usageSource: ProxyLogUsageSource;
|
||||
messageText: string;
|
||||
};
|
||||
|
||||
export declare function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata;
|
||||
|
||||
+40
-18
@@ -1,20 +1,42 @@
|
||||
const METADATA_PREFIX_REGEX = /^\[(\w+):([^\]]*)\]\s*/i;
|
||||
export function parseProxyLogMetadata(rawMessage) {
|
||||
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
||||
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
|
||||
const usageMatch = rawMessage.match(/\[usage:([^\]]+)\]/i);
|
||||
const messageText = rawMessage.replace(
|
||||
/^\s*(?:\[(?:client|session|downstream|upstream|usage):[^\]]+\]\s*)+/i,
|
||||
'',
|
||||
).trim();
|
||||
|
||||
return {
|
||||
clientKind: clientMatch?.[1]?.trim() || null,
|
||||
sessionId: sessionMatch?.[1]?.trim() || null,
|
||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
||||
usageSource: usageMatch?.[1]?.trim() || null,
|
||||
messageText,
|
||||
};
|
||||
let remaining = rawMessage;
|
||||
let clientKind = null;
|
||||
let sessionId = null;
|
||||
let downstreamPath = null;
|
||||
let upstreamPath = null;
|
||||
let usageSource = null;
|
||||
while (true) {
|
||||
const match = remaining.match(METADATA_PREFIX_REGEX);
|
||||
if (!match)
|
||||
break;
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2] || null;
|
||||
switch (key) {
|
||||
case 'client':
|
||||
clientKind = value;
|
||||
break;
|
||||
case 'session':
|
||||
sessionId = value;
|
||||
break;
|
||||
case 'downstream':
|
||||
downstreamPath = value;
|
||||
break;
|
||||
case 'upstream':
|
||||
upstreamPath = value;
|
||||
break;
|
||||
case 'usage':
|
||||
usageSource = value;
|
||||
break;
|
||||
}
|
||||
remaining = remaining.slice(match[0].length);
|
||||
}
|
||||
return {
|
||||
clientKind,
|
||||
sessionId,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
usageSource,
|
||||
messageText: remaining,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export type ProxyLogUsageSource = 'upstream' | 'self-log' | 'unknown' | null;
|
||||
|
||||
export type ParsedProxyLogMetadata = {
|
||||
clientKind: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
usageSource: ProxyLogUsageSource;
|
||||
messageText: string;
|
||||
};
|
||||
|
||||
const METADATA_PREFIX_REGEX = /^\[(\w+):([^\]]*)\]\s*/i;
|
||||
|
||||
export function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata {
|
||||
let remaining = rawMessage;
|
||||
|
||||
let clientKind: string | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let downstreamPath: string | null = null;
|
||||
let upstreamPath: string | null = null;
|
||||
let usageSource: ProxyLogUsageSource = null;
|
||||
|
||||
while (true) {
|
||||
const match = remaining.match(METADATA_PREFIX_REGEX);
|
||||
if (!match) break;
|
||||
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2] || null;
|
||||
|
||||
switch (key) {
|
||||
case 'client':
|
||||
clientKind = value;
|
||||
break;
|
||||
case 'session':
|
||||
sessionId = value;
|
||||
break;
|
||||
case 'downstream':
|
||||
downstreamPath = value;
|
||||
break;
|
||||
case 'upstream':
|
||||
upstreamPath = value;
|
||||
break;
|
||||
case 'usage':
|
||||
usageSource = value as ProxyLogUsageSource;
|
||||
break;
|
||||
}
|
||||
|
||||
remaining = remaining.slice(match[0].length);
|
||||
}
|
||||
|
||||
return {
|
||||
clientKind,
|
||||
sessionId,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
usageSource,
|
||||
messageText: remaining,
|
||||
};
|
||||
}
|
||||
+13
-29
@@ -1,30 +1,14 @@
|
||||
export type SiteInitializationPresetId =
|
||||
| 'codingplan-openai'
|
||||
| 'codingplan-claude'
|
||||
| 'zhipu-coding-plan-openai'
|
||||
| 'zhipu-coding-plan-claude'
|
||||
| 'deepseek-openai'
|
||||
| 'deepseek-claude'
|
||||
| 'moonshot-openai'
|
||||
| 'moonshot-claude'
|
||||
| 'minimax-openai'
|
||||
| 'minimax-claude'
|
||||
| 'modelscope-openai'
|
||||
| 'modelscope-claude'
|
||||
| 'doubao-coding-openai';
|
||||
export type SiteInitializationPreset = {
|
||||
id: SiteInitializationPresetId;
|
||||
label: string;
|
||||
providerLabel: string;
|
||||
description: string;
|
||||
platform: string;
|
||||
defaultUrl?: string;
|
||||
initialSegment: 'session' | 'apikey';
|
||||
recommendedSkipModelFetch: boolean;
|
||||
recommendedModels: string[];
|
||||
docsUrl?: string;
|
||||
};
|
||||
|
||||
export interface SiteInitializationPreset {
|
||||
id: string;
|
||||
platform: 'openai' | 'claude';
|
||||
defaultUrl: string;
|
||||
initialSegment: string;
|
||||
recommendedSkipModelFetch: boolean;
|
||||
recommendedModels: string[];
|
||||
}
|
||||
export declare function getSiteInitializationPreset(id: string): SiteInitializationPreset | undefined;
|
||||
export declare function listSiteInitializationPresets(): SiteInitializationPreset[];
|
||||
export declare function getSiteInitializationPreset(id: string | null | undefined): SiteInitializationPreset | null;
|
||||
export declare function detectSiteInitializationPreset(url: string, platform?: string | null): SiteInitializationPreset | null;
|
||||
export declare function detectSiteInitializationPreset(url: string, knownPlatform?: string): {
|
||||
id: string;
|
||||
platform: 'openai' | 'claude';
|
||||
} | null;
|
||||
|
||||
@@ -1,316 +1,156 @@
|
||||
import { analyzePrimarySiteUrl } from './sitePrimaryUrl.js';
|
||||
|
||||
function normalizeUrlCandidate(url) {
|
||||
return typeof url === 'string' ? url.trim() : '';
|
||||
}
|
||||
|
||||
function parseUrlCandidate(url) {
|
||||
const normalized = normalizeUrlCandidate(url);
|
||||
if (!normalized) return null;
|
||||
|
||||
const candidates = normalized.includes('://')
|
||||
? [normalized]
|
||||
: [`https://${normalized}`];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return new URL(candidate);
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathname(pathname) {
|
||||
let normalized = typeof pathname === 'string' ? pathname.trim() : '';
|
||||
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
|
||||
while (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function matchesHostAndPaths(url, hostname, paths) {
|
||||
const parsed = parseUrlCandidate(url);
|
||||
if (!parsed) return false;
|
||||
return parsed.hostname === hostname && paths.includes(normalizePathname(parsed.pathname));
|
||||
}
|
||||
|
||||
const CODINGPLAN_RECOMMENDED_MODELS = Object.freeze([
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-next',
|
||||
'qwen3.5-plus',
|
||||
'glm-5',
|
||||
]);
|
||||
|
||||
const ZHIPU_CODING_PLAN_RECOMMENDED_MODELS = Object.freeze([
|
||||
'glm-4.7',
|
||||
'glm-4.6',
|
||||
'glm-4.5',
|
||||
'glm-4.5-air',
|
||||
]);
|
||||
|
||||
const DEEPSEEK_RECOMMENDED_MODELS = Object.freeze([
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
]);
|
||||
|
||||
const MOONSHOT_RECOMMENDED_MODELS = Object.freeze([
|
||||
'kimi-k2.5',
|
||||
'kimi-k2',
|
||||
'kimi-k2-thinking',
|
||||
]);
|
||||
|
||||
const MINIMAX_RECOMMENDED_MODELS = Object.freeze([
|
||||
'MiniMax-M2.7',
|
||||
'MiniMax-M2.5',
|
||||
'MiniMax-M2.1',
|
||||
]);
|
||||
|
||||
const MODELSCOPE_RECOMMENDED_MODELS = Object.freeze([
|
||||
'Qwen/Qwen3-32B',
|
||||
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
]);
|
||||
|
||||
const DOUBAO_CODING_RECOMMENDED_MODELS = Object.freeze([
|
||||
'ark-code-latest',
|
||||
'doubao-seed-2.0-code',
|
||||
'doubao-seed-2.0-pro',
|
||||
]);
|
||||
|
||||
const SITE_INITIALIZATION_PRESETS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'codingplan-openai',
|
||||
label: '阿里云 CodingPlan / OpenAI',
|
||||
providerLabel: '阿里云 CodingPlan',
|
||||
description: '适合阿里云 CodingPlan 的 OpenAI 兼容入口,建议先添加 API Key,再补入推荐模型完成初始化。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'coding.dashscope.aliyuncs.com', ['/v1']);
|
||||
const presets = [
|
||||
{
|
||||
id: 'codingplan-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['qwen3-coder-plus', 'qwen3.5-plus'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'codingplan-claude',
|
||||
label: '阿里云 CodingPlan / Claude',
|
||||
providerLabel: '阿里云 CodingPlan',
|
||||
description: '适合阿里云 CodingPlan 的 Claude 兼容入口,建议先添加 API Key,再补入推荐模型完成初始化。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'coding.dashscope.aliyuncs.com', ['/apps/anthropic']);
|
||||
{
|
||||
id: 'codingplan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['qwen3-coder-next', 'glm-5'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'zhipu-coding-plan-openai',
|
||||
label: '智谱 Coding Plan / OpenAI',
|
||||
providerLabel: '智谱 Coding Plan',
|
||||
description: '适合智谱 Coding Plan 的 OpenAI 兼容入口,建议先添加 API Key,再补入常用 GLM 编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'open.bigmodel.cn', ['/api/coding/paas/v4']);
|
||||
{
|
||||
id: 'zhipu-coding-plan-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'zhipu-coding-plan-claude',
|
||||
label: '智谱 Coding Plan / Claude',
|
||||
providerLabel: '智谱 Coding Plan',
|
||||
description: '适合智谱 Coding Plan 的 Claude 兼容入口。由于该地址也可作为通用兼容入口,这里默认只提供手动预设,不按 URL 强制自动识别。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq',
|
||||
matches() {
|
||||
return false;
|
||||
{
|
||||
id: 'zhipu-coding-plan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'deepseek-openai',
|
||||
label: 'DeepSeek / OpenAI',
|
||||
providerLabel: 'DeepSeek',
|
||||
description: '适合 DeepSeek 官方 OpenAI 兼容入口,建议直接添加 API Key,并优先补入官方常用编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.deepseek.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: DEEPSEEK_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://api-docs.deepseek.com/',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.deepseek.com', ['/', '/v1']);
|
||||
{
|
||||
id: 'deepseek-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.deepseek.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'deepseek-claude',
|
||||
label: 'DeepSeek / Claude',
|
||||
providerLabel: 'DeepSeek',
|
||||
description: '适合 DeepSeek 官方 Anthropic 兼容入口,便于 Claude Code 一类工具直接接入。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.deepseek.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: DEEPSEEK_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://api-docs.deepseek.com/guides/anthropic_api',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.deepseek.com', ['/anthropic']);
|
||||
{
|
||||
id: 'deepseek-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.deepseek.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'moonshot-openai',
|
||||
label: 'Moonshot(Kimi) / OpenAI',
|
||||
providerLabel: 'Moonshot / Kimi',
|
||||
description: '适合 Moonshot 官方 OpenAI 兼容入口,推荐优先使用 Kimi 系列编程与 Agent 模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MOONSHOT_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://platform.moonshot.cn/',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.moonshot.cn', ['/', '/v1']);
|
||||
{
|
||||
id: 'moonshot-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'moonshot-claude',
|
||||
label: 'Moonshot(Kimi) / Claude',
|
||||
providerLabel: 'Moonshot / Kimi',
|
||||
description: '适合 Moonshot 官方 Anthropic 兼容入口,便于 Claude Code 与同类工具接入 Kimi。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.moonshot.cn/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MOONSHOT_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://platform.moonshot.cn/blog/posts/kimi-k2-0905',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.moonshot.cn', ['/anthropic']);
|
||||
{
|
||||
id: 'moonshot-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.moonshot.cn/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'minimax-openai',
|
||||
label: 'MiniMax / OpenAI',
|
||||
providerLabel: 'MiniMax',
|
||||
description: '适合 MiniMax 官方 OpenAI 兼容入口,建议直接添加 API Key 后补入常用 M2 编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.minimaxi.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MINIMAX_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://platform.minimaxi.com/docs/api-reference/api-overview',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.minimaxi.com', ['/', '/v1']);
|
||||
{
|
||||
id: 'minimax-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.minimaxi.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'minimax-claude',
|
||||
label: 'MiniMax / Claude',
|
||||
providerLabel: 'MiniMax',
|
||||
description: '适合 MiniMax 官方 Anthropic 兼容入口,适配 Claude Code 等编程工具场景。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.minimaxi.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MINIMAX_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://platform.minimaxi.com/docs/api-reference/text-anthropic-api',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api.minimaxi.com', ['/anthropic']);
|
||||
{
|
||||
id: 'minimax-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.minimaxi.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1'],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'modelscope-openai',
|
||||
label: 'ModelScope / OpenAI',
|
||||
providerLabel: 'ModelScope',
|
||||
description: '适合 ModelScope API-Inference 的 OpenAI 兼容入口,适合直接接入常用开源编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MODELSCOPE_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://www.modelscope.cn/docs/model-service/API-Inference/intro',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api-inference.modelscope.cn', ['/v1']);
|
||||
{
|
||||
id: 'modelscope-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [
|
||||
'Qwen/Qwen3-32B',
|
||||
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'modelscope-claude',
|
||||
label: 'ModelScope / Claude',
|
||||
providerLabel: 'ModelScope',
|
||||
description: '适合 ModelScope API-Inference 的 Claude 兼容入口,便于接入 Claude Code 一类工具。',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: MODELSCOPE_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://www.modelscope.cn/docs/model-service/API-Inference/intro',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'api-inference.modelscope.cn', ['/']);
|
||||
{
|
||||
id: 'modelscope-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'doubao-coding-openai',
|
||||
label: '豆包 Coding Plan / OpenAI',
|
||||
providerLabel: '豆包 Coding Plan',
|
||||
description: '适合火山方舟 Coding Plan 的 OpenAI 兼容入口,推荐优先使用 ark-code 与豆包编程模型。',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: DOUBAO_CODING_RECOMMENDED_MODELS,
|
||||
docsUrl: 'https://www.volcengine.com/docs/82379/2205646?lang=zh',
|
||||
matches(url) {
|
||||
return matchesHostAndPaths(url, 'ark.cn-beijing.volces.com', ['/api/coding/v3']);
|
||||
{
|
||||
id: 'doubao-coding-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
function clonePreset(preset) {
|
||||
if (!preset) return null;
|
||||
return {
|
||||
...preset,
|
||||
recommendedModels: [...preset.recommendedModels],
|
||||
};
|
||||
}
|
||||
|
||||
export function listSiteInitializationPresets() {
|
||||
return SITE_INITIALIZATION_PRESETS.map((preset) => clonePreset(preset));
|
||||
}
|
||||
|
||||
];
|
||||
const urlMatchers = [
|
||||
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', id: 'codingplan-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://open.bigmodel.cn/api/coding/paas/v4', id: 'zhipu-coding-plan-openai', platform: 'openai', stripTrailingSlash: true },
|
||||
{ urlPrefix: 'https://api.deepseek.com/v1', id: 'deepseek-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api.deepseek.com/anthropic', id: 'deepseek-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api.moonshot.cn/v1', id: 'moonshot-openai', platform: 'openai', stripTrailingSlash: true },
|
||||
{ urlPrefix: 'https://api.moonshot.cn/anthropic', id: 'moonshot-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api.minimaxi.com/v1', id: 'minimax-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api.minimaxi.com/anthropic', id: 'minimax-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
|
||||
];
|
||||
// Canonical root → preset mapping for reselection when platform is known
|
||||
const rootReselect = [
|
||||
{ rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' },
|
||||
{ rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' },
|
||||
];
|
||||
export function getSiteInitializationPreset(id) {
|
||||
const normalizedId = typeof id === 'string' ? id.trim() : '';
|
||||
if (!normalizedId) return null;
|
||||
return clonePreset(SITE_INITIALIZATION_PRESETS.find((preset) => preset.id === normalizedId) || null);
|
||||
return presets.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export function detectSiteInitializationPreset(url, platform) {
|
||||
const normalizedPlatform = typeof platform === 'string' ? platform.trim().toLowerCase() : '';
|
||||
for (const preset of SITE_INITIALIZATION_PRESETS) {
|
||||
if (normalizedPlatform && preset.platform !== normalizedPlatform) continue;
|
||||
if (preset.matches(url)) return clonePreset(preset);
|
||||
}
|
||||
|
||||
if (!normalizedPlatform) return null;
|
||||
|
||||
const analyzed = analyzePrimarySiteUrl(url);
|
||||
for (const preset of SITE_INITIALIZATION_PRESETS) {
|
||||
if (preset.platform !== normalizedPlatform) continue;
|
||||
if (!preset.defaultUrl) continue;
|
||||
const presetAnalyzed = analyzePrimarySiteUrl(preset.defaultUrl);
|
||||
if (!presetAnalyzed.persistedUrl) continue;
|
||||
if (presetAnalyzed.persistedUrl === analyzed.persistedUrl) return clonePreset(preset);
|
||||
}
|
||||
|
||||
return null;
|
||||
export function listSiteInitializationPresets() {
|
||||
return [...presets];
|
||||
}
|
||||
export function detectSiteInitializationPreset(url, knownPlatform) {
|
||||
// Try exact URL prefix matching
|
||||
const normalizedUrl = url.replace(/\/+$/, '');
|
||||
for (const matcher of urlMatchers) {
|
||||
const matchUrl = matcher.stripTrailingSlash ? normalizedUrl : url;
|
||||
if (matchUrl.startsWith(matcher.urlPrefix)) {
|
||||
return { id: matcher.id, platform: matcher.platform };
|
||||
}
|
||||
}
|
||||
// Reselect from canonical root when platform is known
|
||||
if (knownPlatform === 'openai') {
|
||||
for (const entry of rootReselect) {
|
||||
if (url.startsWith(entry.rootPrefix)) {
|
||||
const preset = getSiteInitializationPreset(entry.id);
|
||||
if (preset)
|
||||
return { id: preset.id, platform: preset.platform };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
export interface SiteInitializationPreset {
|
||||
id: string;
|
||||
platform: 'openai' | 'claude';
|
||||
defaultUrl: string;
|
||||
initialSegment: string;
|
||||
recommendedSkipModelFetch: boolean;
|
||||
recommendedModels: string[];
|
||||
}
|
||||
|
||||
const presets: SiteInitializationPreset[] = [
|
||||
{
|
||||
id: 'codingplan-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['qwen3-coder-plus', 'qwen3.5-plus'],
|
||||
},
|
||||
{
|
||||
id: 'codingplan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['qwen3-coder-next', 'glm-5'],
|
||||
},
|
||||
{
|
||||
id: 'zhipu-coding-plan-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
|
||||
},
|
||||
{
|
||||
id: 'zhipu-coding-plan-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air'],
|
||||
},
|
||||
{
|
||||
id: 'deepseek-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.deepseek.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
{
|
||||
id: 'deepseek-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.deepseek.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'moonshot-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking'],
|
||||
},
|
||||
{
|
||||
id: 'moonshot-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.moonshot.cn/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'minimax-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api.minimaxi.com/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'minimax-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api.minimaxi.com/anthropic',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1'],
|
||||
},
|
||||
{
|
||||
id: 'modelscope-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn/v1',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [
|
||||
'Qwen/Qwen3-32B',
|
||||
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'modelscope-claude',
|
||||
platform: 'claude',
|
||||
defaultUrl: 'https://api-inference.modelscope.cn',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: [],
|
||||
},
|
||||
{
|
||||
id: 'doubao-coding-openai',
|
||||
platform: 'openai',
|
||||
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||
initialSegment: 'apikey',
|
||||
recommendedSkipModelFetch: true,
|
||||
recommendedModels: ['ark-code-latest', 'doubao-seed-2.0-code', 'doubao-seed-2.0-pro'],
|
||||
},
|
||||
];
|
||||
|
||||
const urlMatchers: Array<{
|
||||
urlPrefix: string;
|
||||
id: string;
|
||||
platform: 'openai' | 'claude';
|
||||
stripTrailingSlash?: boolean;
|
||||
}> = [
|
||||
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/v1', id: 'codingplan-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', id: 'codingplan-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://open.bigmodel.cn/api/coding/paas/v4', id: 'zhipu-coding-plan-openai', platform: 'openai', stripTrailingSlash: true },
|
||||
{ urlPrefix: 'https://api.deepseek.com/v1', id: 'deepseek-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api.deepseek.com/anthropic', id: 'deepseek-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api.moonshot.cn/v1', id: 'moonshot-openai', platform: 'openai', stripTrailingSlash: true },
|
||||
{ urlPrefix: 'https://api.moonshot.cn/anthropic', id: 'moonshot-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api.minimaxi.com/v1', id: 'minimax-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api.minimaxi.com/anthropic', id: 'minimax-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://api-inference.modelscope.cn/v1', id: 'modelscope-openai', platform: 'openai' },
|
||||
{ urlPrefix: 'https://api-inference.modelscope.cn', id: 'modelscope-claude', platform: 'claude' },
|
||||
{ urlPrefix: 'https://ark.cn-beijing.volces.com/api/coding/v3', id: 'doubao-coding-openai', platform: 'openai' },
|
||||
];
|
||||
|
||||
// Canonical root → preset mapping for reselection when platform is known
|
||||
const rootReselect: Array<{ rootPrefix: string; id: string }> = [
|
||||
{ rootPrefix: 'https://api.deepseek.com', id: 'deepseek-openai' },
|
||||
{ rootPrefix: 'https://coding.dashscope.aliyuncs.com', id: 'codingplan-openai' },
|
||||
];
|
||||
|
||||
export function getSiteInitializationPreset(id: string): SiteInitializationPreset | undefined {
|
||||
return presets.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export function listSiteInitializationPresets(): SiteInitializationPreset[] {
|
||||
return [...presets];
|
||||
}
|
||||
|
||||
export function detectSiteInitializationPreset(
|
||||
url: string,
|
||||
knownPlatform?: string,
|
||||
): { id: string; platform: 'openai' | 'claude' } | null {
|
||||
// Try exact URL prefix matching
|
||||
const normalizedUrl = url.replace(/\/+$/, '');
|
||||
for (const matcher of urlMatchers) {
|
||||
const matchUrl = matcher.stripTrailingSlash ? normalizedUrl : url;
|
||||
if (matchUrl.startsWith(matcher.urlPrefix)) {
|
||||
return { id: matcher.id, platform: matcher.platform };
|
||||
}
|
||||
}
|
||||
|
||||
// Reselect from canonical root when platform is known
|
||||
if (knownPlatform === 'openai') {
|
||||
for (const entry of rootReselect) {
|
||||
if (url.startsWith(entry.rootPrefix)) {
|
||||
const preset = getSiteInitializationPreset(entry.id);
|
||||
if (preset) return { id: preset.id, platform: preset.platform };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Vendored
+7
-15
@@ -1,15 +1,7 @@
|
||||
export type PrimarySiteUrlAction =
|
||||
| 'unchanged'
|
||||
| 'auto_strip_known_api_suffix'
|
||||
| 'preserve_api_path'
|
||||
| 'preserve_semantic_path'
|
||||
| 'preserve_unknown_path';
|
||||
|
||||
export type PrimarySiteUrlAnalysis = {
|
||||
canonicalUrl: string;
|
||||
persistedUrl: string;
|
||||
matchedPath: string;
|
||||
action: PrimarySiteUrlAction;
|
||||
};
|
||||
|
||||
export declare function analyzePrimarySiteUrl(url: string | null | undefined): PrimarySiteUrlAnalysis;
|
||||
export interface PrimarySiteUrlAnalysis {
|
||||
canonicalUrl: string;
|
||||
persistedUrl: string;
|
||||
action: 'unchanged' | 'auto_strip_known_api_suffix' | 'preserve_api_path' | 'preserve_semantic_path' | 'preserve_unknown_path';
|
||||
matchedPath: string;
|
||||
}
|
||||
export declare function analyzePrimarySiteUrl(raw: string): PrimarySiteUrlAnalysis;
|
||||
|
||||
+100
-101
@@ -1,107 +1,106 @@
|
||||
function normalizePathname(pathname) {
|
||||
let normalized = typeof pathname === 'string' ? pathname.trim() : '';
|
||||
if (!normalized || normalized === '/') return '/';
|
||||
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
|
||||
while (normalized.length > 1 && normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
/**
|
||||
* Known API request path prefixes that should be auto-stripped to the host root
|
||||
* for persistence purposes. These are well-known endpoint paths that users paste
|
||||
* from upstream API documentation but should be stored as the base URL only.
|
||||
*/
|
||||
const knownSuffixPrefixes = ['/v1/', '/v2/', '/api/'];
|
||||
/**
|
||||
* Known semantic paths that should be preserved without warnings.
|
||||
* These are deliberate application routes rather than API endpoint paths.
|
||||
*/
|
||||
const semanticPathPrefixes = ['/backend-api/', '/apps/', '/coding/'];
|
||||
function isApiPathPrefix(pathname) {
|
||||
return pathname.startsWith('/api/') || pathname.startsWith('/v1/') || pathname.startsWith('/v2/');
|
||||
}
|
||||
|
||||
function parseUrlCandidate(url) {
|
||||
const trimmed = typeof url === 'string' ? url.trim() : '';
|
||||
if (!trimmed) return null;
|
||||
|
||||
const candidates = trimmed.includes('://')
|
||||
? [trimmed]
|
||||
: [`https://${trimmed}`];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
function isSemanticPath(pathname) {
|
||||
return semanticPathPrefixes.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
function hasKnownSuffixPrefix(pathname) {
|
||||
return knownSuffixPrefixes.some((prefix) => pathname.startsWith(prefix) || pathname.includes(prefix));
|
||||
}
|
||||
function normalizeUrl(raw) {
|
||||
if (!raw || typeof raw !== 'string')
|
||||
return '';
|
||||
let url = raw.trim();
|
||||
// Add scheme if missing
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
try {
|
||||
return new URL(candidate);
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
const parsed = new URL(url);
|
||||
let normalized = parsed.protocol + '//' + parsed.host + parsed.pathname;
|
||||
// Remove trailing slash (except for root)
|
||||
if (normalized.endsWith('/') && normalized !== parsed.protocol + '//' + parsed.host + '/') {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
catch {
|
||||
// If URL parsing fails, return trimmed raw version
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_STRIP_PRIMARY_SITE_PATHS = new Set([
|
||||
'/v1',
|
||||
'/v1beta',
|
||||
'/v1/models',
|
||||
'/v1/chat/completions',
|
||||
'/v1/responses',
|
||||
'/v1/messages',
|
||||
'/v1beta/models',
|
||||
]);
|
||||
|
||||
const SEMANTIC_PRIMARY_SITE_PATHS = new Set([
|
||||
'/backend-api/codex',
|
||||
'/anthropic',
|
||||
'/apps/anthropic',
|
||||
'/api/anthropic',
|
||||
'/api/coding/paas/v4',
|
||||
'/v1beta/openai',
|
||||
]);
|
||||
|
||||
export function analyzePrimarySiteUrl(url) {
|
||||
const parsed = parseUrlCandidate(url);
|
||||
if (!parsed) {
|
||||
const trimmed = typeof url === 'string' ? url.trim().replace(/\/+$/, '') : '';
|
||||
export function analyzePrimarySiteUrl(raw) {
|
||||
if (!raw || typeof raw !== 'string' || raw.trim() === '') {
|
||||
return { canonicalUrl: '', persistedUrl: '', action: 'unchanged', matchedPath: '' };
|
||||
}
|
||||
const canonicalUrl = normalizeUrl(raw);
|
||||
// Try to parse the URL
|
||||
let url = raw.trim();
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
}
|
||||
catch {
|
||||
return { canonicalUrl, persistedUrl: canonicalUrl, action: 'unchanged', matchedPath: '' };
|
||||
}
|
||||
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
||||
// Root URL - no path beyond /
|
||||
if (pathname === '/') {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'unchanged',
|
||||
matchedPath: '/',
|
||||
};
|
||||
}
|
||||
// Known semantic paths (e.g., /backend-api/codex) - preserve as-is
|
||||
if (isSemanticPath(pathname)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_semantic_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
// API-prefixed hostnames (e.g., api.example.com/api/v1/models) - preserve as warning
|
||||
const hostname = parsed.hostname;
|
||||
if ((hostname.startsWith('api.') || hostname.startsWith('gateway.')) && isApiPathPrefix(pathname)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_api_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
// Known API request suffixes - auto-strip to root
|
||||
if (hasKnownSuffixPrefix(pathname)) {
|
||||
const rootUrl = parsed.protocol + '//' + parsed.host;
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: rootUrl,
|
||||
action: 'auto_strip_known_api_suffix',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
// Unknown non-root path - preserve as warning
|
||||
return {
|
||||
canonicalUrl: trimmed,
|
||||
persistedUrl: trimmed,
|
||||
matchedPath: '',
|
||||
action: 'unchanged',
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_unknown_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
const matchedPath = normalizePathname(parsed.pathname);
|
||||
const canonicalUrl = matchedPath === '/'
|
||||
? parsed.origin
|
||||
: `${parsed.origin}${matchedPath}`;
|
||||
|
||||
if (matchedPath === '/') {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
matchedPath,
|
||||
action: 'unchanged',
|
||||
};
|
||||
}
|
||||
|
||||
if (SEMANTIC_PRIMARY_SITE_PATHS.has(matchedPath)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
matchedPath,
|
||||
action: 'preserve_semantic_path',
|
||||
};
|
||||
}
|
||||
|
||||
if (AUTO_STRIP_PRIMARY_SITE_PATHS.has(matchedPath)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: parsed.origin,
|
||||
matchedPath,
|
||||
action: 'auto_strip_known_api_suffix',
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedPath.startsWith('/api')) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
matchedPath,
|
||||
action: 'preserve_api_path',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
matchedPath,
|
||||
action: 'preserve_unknown_path',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
export interface PrimarySiteUrlAnalysis {
|
||||
canonicalUrl: string;
|
||||
persistedUrl: string;
|
||||
action: 'unchanged' | 'auto_strip_known_api_suffix' | 'preserve_api_path' | 'preserve_semantic_path' | 'preserve_unknown_path';
|
||||
matchedPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Known API request path prefixes that should be auto-stripped to the host root
|
||||
* for persistence purposes. These are well-known endpoint paths that users paste
|
||||
* from upstream API documentation but should be stored as the base URL only.
|
||||
*/
|
||||
const knownSuffixPrefixes = ['/v1/', '/v2/', '/api/'];
|
||||
|
||||
/**
|
||||
* Known semantic paths that should be preserved without warnings.
|
||||
* These are deliberate application routes rather than API endpoint paths.
|
||||
*/
|
||||
const semanticPathPrefixes = ['/backend-api/', '/apps/', '/coding/'];
|
||||
|
||||
function isApiPathPrefix(pathname: string): boolean {
|
||||
return pathname.startsWith('/api/') || pathname.startsWith('/v1/') || pathname.startsWith('/v2/');
|
||||
}
|
||||
|
||||
function isSemanticPath(pathname: string): boolean {
|
||||
return semanticPathPrefixes.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
function hasKnownSuffixPrefix(pathname: string): boolean {
|
||||
return knownSuffixPrefixes.some((prefix) => pathname.startsWith(prefix) || pathname.includes(prefix));
|
||||
}
|
||||
|
||||
function normalizeUrl(raw: string): string {
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
let url = raw.trim();
|
||||
|
||||
// Add scheme if missing
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
let normalized = parsed.protocol + '//' + parsed.host + parsed.pathname;
|
||||
// Remove trailing slash (except for root)
|
||||
if (normalized.endsWith('/') && normalized !== parsed.protocol + '//' + parsed.host + '/') {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
} catch {
|
||||
// If URL parsing fails, return trimmed raw version
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function analyzePrimarySiteUrl(raw: string): PrimarySiteUrlAnalysis {
|
||||
if (!raw || typeof raw !== 'string' || raw.trim() === '') {
|
||||
return { canonicalUrl: '', persistedUrl: '', action: 'unchanged', matchedPath: '' };
|
||||
}
|
||||
|
||||
const canonicalUrl = normalizeUrl(raw);
|
||||
|
||||
// Try to parse the URL
|
||||
let url = raw.trim();
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return { canonicalUrl, persistedUrl: canonicalUrl, action: 'unchanged', matchedPath: '' };
|
||||
}
|
||||
|
||||
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
// Root URL - no path beyond /
|
||||
if (pathname === '/') {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'unchanged',
|
||||
matchedPath: '/',
|
||||
};
|
||||
}
|
||||
|
||||
// Known semantic paths (e.g., /backend-api/codex) - preserve as-is
|
||||
if (isSemanticPath(pathname)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_semantic_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
// API-prefixed hostnames (e.g., api.example.com/api/v1/models) - preserve as warning
|
||||
const hostname = parsed.hostname;
|
||||
if ((hostname.startsWith('api.') || hostname.startsWith('gateway.')) && isApiPathPrefix(pathname)) {
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_api_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
// Known API request suffixes - auto-strip to root
|
||||
if (hasKnownSuffixPrefix(pathname)) {
|
||||
const rootUrl = parsed.protocol + '//' + parsed.host;
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: rootUrl,
|
||||
action: 'auto_strip_known_api_suffix',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown non-root path - preserve as warning
|
||||
return {
|
||||
canonicalUrl,
|
||||
persistedUrl: canonicalUrl,
|
||||
action: 'preserve_unknown_path',
|
||||
matchedPath: pathname,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ROUTE_DECISION_REFRESH_TASK_TYPE = 'route-decision.refresh';
|
||||
|
||||
export function normalizeTokenRouteMode(routeMode) {
|
||||
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
|
||||
if (routeMode === 'explicit_group')
|
||||
return 'explicit_group';
|
||||
return 'pattern';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export const ROUTE_DECISION_REFRESH_TASK_TYPE = 'route-decision.refresh';
|
||||
|
||||
export type RouteMode = 'pattern' | 'explicit_group';
|
||||
|
||||
export type RouteDecisionCandidate = {
|
||||
channelId: number;
|
||||
accountId: number;
|
||||
username: string;
|
||||
siteName: string;
|
||||
tokenName: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
eligible: boolean;
|
||||
recentlyFailed: boolean;
|
||||
avoidedByRecentFailure: boolean;
|
||||
probability: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type RouteDecision = {
|
||||
requestedModel: string;
|
||||
actualModel: string;
|
||||
matched: boolean;
|
||||
selectedChannelId?: number;
|
||||
selectedLabel?: string;
|
||||
summary: string[];
|
||||
candidates: RouteDecisionCandidate[];
|
||||
};
|
||||
|
||||
export function normalizeTokenRouteMode(routeMode: unknown): RouteMode {
|
||||
if (routeMode === 'explicit_group') return 'explicit_group';
|
||||
return 'pattern';
|
||||
}
|
||||
@@ -1,516 +1,59 @@
|
||||
let nextNodeId = 1;
|
||||
|
||||
function createNode(type, fields = {}) {
|
||||
return {
|
||||
id: nextNodeId++,
|
||||
type,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function isDigitCharacter(ch) {
|
||||
if (!ch) return false;
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 48 && code <= 57;
|
||||
}
|
||||
|
||||
function readRegexQuantifierLength(pattern, startIndex) {
|
||||
const ch = pattern[startIndex];
|
||||
if (ch === '*' || ch === '+' || ch === '?') return 1;
|
||||
if (ch !== '{') return 0;
|
||||
|
||||
let index = startIndex + 1;
|
||||
let sawDigit = false;
|
||||
while (index < pattern.length && isDigitCharacter(pattern[index])) {
|
||||
sawDigit = true;
|
||||
index += 1;
|
||||
}
|
||||
if (!sawDigit) return 0;
|
||||
if (pattern[index] === ',') {
|
||||
index += 1;
|
||||
while (index < pattern.length && isDigitCharacter(pattern[index])) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (pattern[index] !== '}') return 0;
|
||||
return index - startIndex + 1;
|
||||
}
|
||||
|
||||
function isAllowedSafeRegexCharacter(ch) {
|
||||
if (!ch) return false;
|
||||
const code = ch.charCodeAt(0);
|
||||
const isLowerAlpha = code >= 97 && code <= 122;
|
||||
const isUpperAlpha = code >= 65 && code <= 90;
|
||||
const isDigit = code >= 48 && code <= 57;
|
||||
if (isLowerAlpha || isUpperAlpha || isDigit) return true;
|
||||
return ' .^$|()[]{}+*?\\:_/-'.includes(ch);
|
||||
}
|
||||
|
||||
function hasUnsafeRegexBackreference(body) {
|
||||
for (let index = 0; index < body.length; index += 1) {
|
||||
if (body[index] !== '\\') continue;
|
||||
const prev = index > 0 ? body[index - 1] : '';
|
||||
const next = body[index + 1];
|
||||
if (prev !== '\\' && isDigitCharacter(next) && next !== '0') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeRegexPatternBody(body) {
|
||||
if (!body || body.length > 256) return false;
|
||||
for (const ch of body) {
|
||||
if (!isAllowedSafeRegexCharacter(ch)) return false;
|
||||
}
|
||||
if (
|
||||
body.includes('(?=')
|
||||
|| body.includes('(?!')
|
||||
|| body.includes('(?<=')
|
||||
|| body.includes('(?<!')
|
||||
|| body.includes('(?<')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (hasUnsafeRegexBackreference(body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const groupStack = [];
|
||||
let escaped = false;
|
||||
let inCharClass = false;
|
||||
for (let index = 0; index < body.length; index += 1) {
|
||||
const ch = body[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
const next = body[index + 1];
|
||||
if (!next) return false;
|
||||
if (/[a-z]/i.test(next) && next !== 'd') {
|
||||
return false;
|
||||
}
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (inCharClass) {
|
||||
if (ch === ']') inCharClass = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') {
|
||||
inCharClass = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') {
|
||||
if (body[index + 1] === '?') {
|
||||
return false;
|
||||
}
|
||||
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
|
||||
continue;
|
||||
}
|
||||
if (ch === '|') {
|
||||
if (groupStack.length > 0) {
|
||||
groupStack[groupStack.length - 1].hasAlternation = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === ')') {
|
||||
const group = groupStack.pop();
|
||||
if (!group) return false;
|
||||
const quantifierLength = readRegexQuantifierLength(body, index + 1);
|
||||
if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) {
|
||||
return false;
|
||||
}
|
||||
const parent = groupStack[groupStack.length - 1];
|
||||
if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) {
|
||||
parent.hasInnerQuantifier = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const quantifierLength = readRegexQuantifierLength(body, index);
|
||||
if (quantifierLength > 0) {
|
||||
if (groupStack.length > 0) {
|
||||
groupStack[groupStack.length - 1].hasInnerQuantifier = true;
|
||||
}
|
||||
index += quantifierLength - 1;
|
||||
}
|
||||
}
|
||||
return !escaped && !inCharClass && groupStack.length === 0;
|
||||
}
|
||||
|
||||
function matchesGlobPattern(model, pattern) {
|
||||
let modelIndex = 0;
|
||||
let patternIndex = 0;
|
||||
let starIndex = -1;
|
||||
let matchIndex = 0;
|
||||
|
||||
while (modelIndex < model.length) {
|
||||
const patternChar = pattern[patternIndex];
|
||||
const modelChar = model[modelIndex];
|
||||
if (patternChar === '*' ) {
|
||||
starIndex = patternIndex;
|
||||
matchIndex = modelIndex;
|
||||
patternIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (patternChar === '?' || patternChar === modelChar) {
|
||||
patternIndex += 1;
|
||||
modelIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (starIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
patternIndex = starIndex + 1;
|
||||
matchIndex += 1;
|
||||
modelIndex = matchIndex;
|
||||
}
|
||||
|
||||
while (pattern[patternIndex] === '*') {
|
||||
patternIndex += 1;
|
||||
}
|
||||
|
||||
return patternIndex === pattern.length;
|
||||
}
|
||||
|
||||
function toArraySet(values) {
|
||||
const unique = [];
|
||||
for (const value of values) {
|
||||
if (!unique.includes(value)) unique.push(value);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function parseCharClassChar(body, state) {
|
||||
const ch = body[state.index];
|
||||
if (ch === '\\') {
|
||||
state.index += 1;
|
||||
const escaped = body[state.index];
|
||||
if (!escaped) throw new Error('invalid escape');
|
||||
state.index += 1;
|
||||
if (escaped === 'd') return { kind: 'digit' };
|
||||
return { kind: 'char', value: escaped };
|
||||
}
|
||||
if (!ch) throw new Error('invalid char class');
|
||||
state.index += 1;
|
||||
return { kind: 'char', value: ch };
|
||||
}
|
||||
|
||||
function parseCharClass(body, state) {
|
||||
state.index += 1;
|
||||
let negated = false;
|
||||
if (body[state.index] === '^') {
|
||||
negated = true;
|
||||
state.index += 1;
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
while (state.index < body.length && body[state.index] !== ']') {
|
||||
const start = parseCharClassChar(body, state);
|
||||
if (body[state.index] === '-' && body[state.index + 1] && body[state.index + 1] !== ']') {
|
||||
state.index += 1;
|
||||
const end = parseCharClassChar(body, state);
|
||||
entries.push({ kind: 'range', start, end });
|
||||
continue;
|
||||
}
|
||||
entries.push(start);
|
||||
}
|
||||
|
||||
if (body[state.index] !== ']' || entries.length === 0) {
|
||||
throw new Error('invalid character class');
|
||||
}
|
||||
state.index += 1;
|
||||
return createNode('charclass', { negated, entries });
|
||||
}
|
||||
|
||||
function parseAtom(body, state) {
|
||||
const ch = body[state.index];
|
||||
if (!ch) throw new Error('unexpected end');
|
||||
|
||||
if (ch === '(') {
|
||||
state.index += 1;
|
||||
const group = parseExpression(body, state, ')');
|
||||
if (body[state.index] !== ')') {
|
||||
throw new Error('missing )');
|
||||
}
|
||||
state.index += 1;
|
||||
return group;
|
||||
}
|
||||
if (ch === '[') {
|
||||
return parseCharClass(body, state);
|
||||
}
|
||||
if (ch === '.') {
|
||||
state.index += 1;
|
||||
return createNode('any');
|
||||
}
|
||||
if (ch === '\\') {
|
||||
state.index += 1;
|
||||
const escaped = body[state.index];
|
||||
if (!escaped) throw new Error('invalid escape');
|
||||
state.index += 1;
|
||||
if (escaped === 'd') {
|
||||
return createNode('digit');
|
||||
}
|
||||
return createNode('literal', { value: escaped });
|
||||
}
|
||||
|
||||
state.index += 1;
|
||||
return createNode('literal', { value: ch });
|
||||
}
|
||||
|
||||
function parseQuantifier(body, state) {
|
||||
const ch = body[state.index];
|
||||
if (ch === '*') {
|
||||
state.index += 1;
|
||||
return { min: 0, max: Infinity };
|
||||
}
|
||||
if (ch === '+') {
|
||||
state.index += 1;
|
||||
return { min: 1, max: Infinity };
|
||||
}
|
||||
if (ch === '?') {
|
||||
state.index += 1;
|
||||
return { min: 0, max: 1 };
|
||||
}
|
||||
if (ch !== '{') return null;
|
||||
|
||||
let index = state.index + 1;
|
||||
let minText = '';
|
||||
while (index < body.length && isDigitCharacter(body[index])) {
|
||||
minText += body[index];
|
||||
index += 1;
|
||||
}
|
||||
if (!minText) return null;
|
||||
|
||||
let max = Number.parseInt(minText, 10);
|
||||
if (body[index] === ',') {
|
||||
index += 1;
|
||||
let maxText = '';
|
||||
while (index < body.length && isDigitCharacter(body[index])) {
|
||||
maxText += body[index];
|
||||
index += 1;
|
||||
}
|
||||
max = maxText ? Number.parseInt(maxText, 10) : Infinity;
|
||||
}
|
||||
if (body[index] !== '}') return null;
|
||||
state.index = index + 1;
|
||||
return {
|
||||
min: Number.parseInt(minText, 10),
|
||||
max,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTerm(body, state) {
|
||||
const atom = parseAtom(body, state);
|
||||
const quantifier = parseQuantifier(body, state);
|
||||
if (!quantifier) return atom;
|
||||
return createNode('repeat', {
|
||||
atom,
|
||||
min: quantifier.min,
|
||||
max: quantifier.max,
|
||||
});
|
||||
}
|
||||
|
||||
function parseSequence(body, state, stopChar) {
|
||||
const terms = [];
|
||||
while (state.index < body.length) {
|
||||
const ch = body[state.index];
|
||||
if (ch === '|' || ch === stopChar) break;
|
||||
terms.push(parseTerm(body, state));
|
||||
}
|
||||
if (terms.length === 0) {
|
||||
return createNode('empty');
|
||||
}
|
||||
if (terms.length === 1) return terms[0];
|
||||
return createNode('sequence', { terms });
|
||||
}
|
||||
|
||||
function parseExpression(body, state, stopChar = '') {
|
||||
const branches = [parseSequence(body, state, stopChar)];
|
||||
while (state.index < body.length && body[state.index] === '|') {
|
||||
state.index += 1;
|
||||
branches.push(parseSequence(body, state, stopChar));
|
||||
}
|
||||
if (branches.length === 1) return branches[0];
|
||||
return createNode('alternation', { branches });
|
||||
}
|
||||
|
||||
function charMatchesCharClassEntry(entry, ch) {
|
||||
if (entry.kind === 'digit') return isDigitCharacter(ch);
|
||||
if (entry.kind === 'char') return ch === entry.value;
|
||||
if (entry.kind === 'range') {
|
||||
if (entry.start.kind !== 'char' || entry.end.kind !== 'char') {
|
||||
return false;
|
||||
}
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= entry.start.value.charCodeAt(0) && code <= entry.end.value.charCodeAt(0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchNode(node, value, position, cache) {
|
||||
const cacheKey = `${node.id}:${position}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cache.has(cacheKey)) return cached;
|
||||
|
||||
let result = [];
|
||||
switch (node.type) {
|
||||
case 'empty':
|
||||
result = [position];
|
||||
break;
|
||||
case 'literal':
|
||||
result = value.startsWith(node.value, position) ? [position + node.value.length] : [];
|
||||
break;
|
||||
case 'any':
|
||||
result = position < value.length ? [position + 1] : [];
|
||||
break;
|
||||
case 'digit':
|
||||
result = position < value.length && isDigitCharacter(value[position]) ? [position + 1] : [];
|
||||
break;
|
||||
case 'charclass': {
|
||||
if (position < value.length) {
|
||||
const matched = node.entries.some((entry) => charMatchesCharClassEntry(entry, value[position]));
|
||||
if ((matched && !node.negated) || (!matched && node.negated)) {
|
||||
result = [position + 1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sequence': {
|
||||
let positions = [position];
|
||||
for (const term of node.terms) {
|
||||
const nextPositions = [];
|
||||
for (const current of positions) {
|
||||
nextPositions.push(...matchNode(term, value, current, cache));
|
||||
}
|
||||
positions = toArraySet(nextPositions);
|
||||
if (positions.length === 0) break;
|
||||
}
|
||||
result = positions;
|
||||
break;
|
||||
}
|
||||
case 'alternation': {
|
||||
const positions = [];
|
||||
for (const branch of node.branches) {
|
||||
positions.push(...matchNode(branch, value, position, cache));
|
||||
}
|
||||
result = toArraySet(positions);
|
||||
break;
|
||||
}
|
||||
case 'repeat': {
|
||||
let results = node.min === 0 ? [position] : [];
|
||||
let frontier = [position];
|
||||
const maxRepeat = Number.isFinite(node.max) ? node.max : (value.length - position + 1);
|
||||
for (let count = 1; count <= maxRepeat; count += 1) {
|
||||
const nextPositions = [];
|
||||
for (const current of frontier) {
|
||||
const ends = matchNode(node.atom, value, current, cache);
|
||||
for (const end of ends) {
|
||||
if (end !== current) nextPositions.push(end);
|
||||
}
|
||||
}
|
||||
frontier = toArraySet(nextPositions);
|
||||
if (frontier.length === 0) break;
|
||||
if (count >= node.min) {
|
||||
results = toArraySet([...results, ...frontier]);
|
||||
}
|
||||
}
|
||||
result = results;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
result = [];
|
||||
break;
|
||||
}
|
||||
|
||||
cache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function compileSafeRegexPattern(body) {
|
||||
nextNodeId = 1;
|
||||
const anchoredStart = body.startsWith('^');
|
||||
const anchoredEnd = body.endsWith('$') && body[body.length - 2] !== '\\';
|
||||
const normalizedBody = body
|
||||
.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : body.length)
|
||||
.trim();
|
||||
const state = { index: 0 };
|
||||
const root = parseExpression(normalizedBody, state);
|
||||
if (state.index !== normalizedBody.length) {
|
||||
throw new Error('unsupported regex syntax');
|
||||
}
|
||||
|
||||
return {
|
||||
test(value) {
|
||||
const starts = anchoredStart
|
||||
? [0]
|
||||
: Array.from({ length: value.length + 1 }, (_, index) => index);
|
||||
for (const start of starts) {
|
||||
const ends = matchNode(root, value, start, new Map());
|
||||
if (!anchoredEnd && ends.length > 0) return true;
|
||||
if (anchoredEnd && ends.includes(value.length)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const matchCache = new Map();
|
||||
const MATCH_CACHE_LIMIT = 4000;
|
||||
|
||||
const REGEX_PREFIX = /^re:/i;
|
||||
export function isTokenRouteRegexPattern(pattern) {
|
||||
return pattern.trim().toLowerCase().startsWith('re:');
|
||||
return REGEX_PREFIX.test(pattern.trim());
|
||||
}
|
||||
|
||||
export function isExactTokenRouteModelPattern(pattern) {
|
||||
const normalized = pattern.trim();
|
||||
if (!normalized) return false;
|
||||
if (isTokenRouteRegexPattern(normalized)) return false;
|
||||
return !/[\*\?]/.test(normalized);
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed)
|
||||
return false;
|
||||
if (REGEX_PREFIX.test(trimmed))
|
||||
return false;
|
||||
return !/[?*]/.test(trimmed);
|
||||
}
|
||||
|
||||
export function parseTokenRouteRegexPattern(pattern) {
|
||||
if (!isTokenRouteRegexPattern(pattern)) {
|
||||
return { regex: null, error: null };
|
||||
}
|
||||
const body = pattern.trim().slice(3).trim();
|
||||
if (!body) {
|
||||
return { regex: null, error: 're: 后缺少正则表达式' };
|
||||
}
|
||||
if (!isSafeRegexPatternBody(body)) {
|
||||
return { regex: null, error: '出于安全原因不支持该正则表达式' };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
regex: compileSafeRegexPattern(body),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { regex: null, error: error?.message || '无效正则' };
|
||||
}
|
||||
const match = REGEX_PREFIX.exec(pattern.trim());
|
||||
if (!match) {
|
||||
return { regex: null, error: 'Pattern does not start with re:' };
|
||||
}
|
||||
const regexBody = pattern.slice(match[0].length);
|
||||
if (!regexBody) {
|
||||
return { regex: null, error: 'Empty regex pattern' };
|
||||
}
|
||||
// Safety validation: reject lookahead/lookbehind/non-capturing groups
|
||||
if (/\(\?/.test(regexBody)) {
|
||||
return { regex: null, error: 'Unsupported regex construct (?…)' };
|
||||
}
|
||||
// Reject non-digit, non-dot backslash shorthands (e.g. \s, \w, \b)
|
||||
if (/\\[a-ce-z]/i.test(regexBody)) {
|
||||
return { regex: null, error: 'Unsupported regex escape sequence' };
|
||||
}
|
||||
try {
|
||||
const regex = new RegExp(regexBody);
|
||||
return { regex: { test: (value) => regex.test(value) }, error: null };
|
||||
}
|
||||
catch {
|
||||
return { regex: null, error: 'Invalid regex pattern' };
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesTokenRouteModelPattern(model, pattern) {
|
||||
const normalized = (pattern || '').trim();
|
||||
if (!normalized) return false;
|
||||
if (normalized === model) return true;
|
||||
|
||||
const cacheKey = `${model}\0${normalized}`;
|
||||
const cached = matchCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let result;
|
||||
if (isTokenRouteRegexPattern(normalized)) {
|
||||
const parsed = parseTokenRouteRegexPattern(normalized);
|
||||
result = !!parsed.regex && parsed.regex.test(model);
|
||||
} else {
|
||||
result = matchesGlobPattern(model, normalized);
|
||||
}
|
||||
|
||||
if (matchCache.size >= MATCH_CACHE_LIMIT) {
|
||||
matchCache.clear();
|
||||
}
|
||||
matchCache.set(cacheKey, result);
|
||||
return result;
|
||||
const trimmedModel = model.trim();
|
||||
const trimmedPattern = pattern.trim();
|
||||
if (!trimmedModel || !trimmedPattern)
|
||||
return false;
|
||||
// Regex pattern
|
||||
if (REGEX_PREFIX.test(trimmedPattern)) {
|
||||
const parsed = parseTokenRouteRegexPattern(trimmedPattern);
|
||||
if (!parsed.regex)
|
||||
return false;
|
||||
return parsed.regex.test(trimmedModel);
|
||||
}
|
||||
// Glob pattern (contains * or ?)
|
||||
if (trimmedPattern.includes('*') || trimmedPattern.includes('?')) {
|
||||
// Escape all regex special characters first, then convert glob patterns
|
||||
const escaped = trimmedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
const globRegex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
|
||||
return globRegex.test(trimmedModel);
|
||||
}
|
||||
// Exact match (bracket prefix is literal, e.g. [NV]model-name)
|
||||
return trimmedModel === trimmedPattern;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export type TokenRoutePatternMatcher = {
|
||||
test(value: string): boolean;
|
||||
};
|
||||
|
||||
const REGEX_PREFIX = /^re:/i;
|
||||
|
||||
export function isTokenRouteRegexPattern(pattern: string): boolean {
|
||||
return REGEX_PREFIX.test(pattern.trim());
|
||||
}
|
||||
|
||||
export function isExactTokenRouteModelPattern(pattern: string): boolean {
|
||||
const trimmed = pattern.trim();
|
||||
if (!trimmed) return false;
|
||||
if (REGEX_PREFIX.test(trimmed)) return false;
|
||||
return !/[?*]/.test(trimmed);
|
||||
}
|
||||
|
||||
export function parseTokenRouteRegexPattern(pattern: string): {
|
||||
regex: TokenRoutePatternMatcher | null;
|
||||
error: string | null;
|
||||
} {
|
||||
const match = REGEX_PREFIX.exec(pattern.trim());
|
||||
if (!match) {
|
||||
return { regex: null, error: 'Pattern does not start with re:' };
|
||||
}
|
||||
|
||||
const regexBody = pattern.slice(match[0].length);
|
||||
|
||||
if (!regexBody) {
|
||||
return { regex: null, error: 'Empty regex pattern' };
|
||||
}
|
||||
|
||||
// Safety validation: reject lookahead/lookbehind/non-capturing groups
|
||||
if (/\(\?/.test(regexBody)) {
|
||||
return { regex: null, error: 'Unsupported regex construct (?…)' };
|
||||
}
|
||||
|
||||
// Reject non-digit, non-dot backslash shorthands (e.g. \s, \w, \b)
|
||||
if (/\\[a-ce-z]/i.test(regexBody)) {
|
||||
return { regex: null, error: 'Unsupported regex escape sequence' };
|
||||
}
|
||||
|
||||
try {
|
||||
const regex = new RegExp(regexBody);
|
||||
return { regex: { test: (value: string) => regex.test(value) }, error: null };
|
||||
} catch {
|
||||
return { regex: null, error: 'Invalid regex pattern' };
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesTokenRouteModelPattern(model: string, pattern: string): boolean {
|
||||
const trimmedModel = model.trim();
|
||||
const trimmedPattern = pattern.trim();
|
||||
|
||||
if (!trimmedModel || !trimmedPattern) return false;
|
||||
|
||||
// Regex pattern
|
||||
if (REGEX_PREFIX.test(trimmedPattern)) {
|
||||
const parsed = parseTokenRouteRegexPattern(trimmedPattern);
|
||||
if (!parsed.regex) return false;
|
||||
return parsed.regex.test(trimmedModel);
|
||||
}
|
||||
|
||||
// Glob pattern (contains * or ?)
|
||||
if (trimmedPattern.includes('*') || trimmedPattern.includes('?')) {
|
||||
// Escape all regex special characters first, then convert glob patterns
|
||||
const escaped = trimmedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
const globRegex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
|
||||
return globRegex.test(trimmedModel);
|
||||
}
|
||||
|
||||
// Exact match (bracket prefix is literal, e.g. [NV]model-name)
|
||||
return trimmedModel === trimmedPattern;
|
||||
}
|
||||
+36
-65
@@ -34,12 +34,16 @@ const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
|
||||
const Models = lazy(() => import('./pages/Models.js'));
|
||||
const About = lazy(() => import('./pages/About.js'));
|
||||
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||
const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
|
||||
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
|
||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
||||
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
|
||||
|
||||
const UserLogin = lazy(() => import('./pages/UserLogin.js'));
|
||||
const UserRegister = lazy(() => import('./pages/UserRegister.js'));
|
||||
const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
|
||||
const UserManagement = lazy(() => import('./pages/UserManagement.js'));
|
||||
type ThemeMode = 'system' | 'light' | 'dark';
|
||||
|
||||
type UserProfile = {
|
||||
name: string;
|
||||
avatarSeed: string;
|
||||
@@ -59,9 +63,7 @@ const DICEBEAR_STYLES = [
|
||||
'lorelei-neutral',
|
||||
'fun-emoji',
|
||||
] as const;
|
||||
|
||||
type DicebearStyle = typeof DICEBEAR_STYLES[number];
|
||||
|
||||
function resolveStoredThemeMode(): ThemeMode {
|
||||
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
|
||||
@@ -69,14 +71,12 @@ function resolveStoredThemeMode(): ThemeMode {
|
||||
if (legacy === 'light' || legacy === 'dark') return legacy;
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function createRandomAvatarSeed(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `seed-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function hashString(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
@@ -85,12 +85,10 @@ function hashString(input: string): number {
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
function pickDicebearStyle(seed: string): DicebearStyle {
|
||||
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length;
|
||||
return DICEBEAR_STYLES[index];
|
||||
}
|
||||
|
||||
function buildDicebearAvatarUrl(style: string, seed: string): string {
|
||||
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle)
|
||||
? style
|
||||
@@ -98,7 +96,6 @@ function buildDicebearAvatarUrl(style: string, seed: string): string {
|
||||
const safeSeed = (seed || 'default').trim() || 'default';
|
||||
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
|
||||
}
|
||||
|
||||
function resolveStoredProfile(): UserProfile {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_PROFILE_STORAGE_KEY);
|
||||
@@ -127,7 +124,6 @@ function resolveStoredProfile(): UserProfile {
|
||||
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
||||
}
|
||||
}
|
||||
|
||||
export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) {
|
||||
const [token, setToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -146,7 +142,6 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
||||
description: t('按成本、延迟、成功率自动选择最优通道,故障自动转移'),
|
||||
},
|
||||
];
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
@@ -180,7 +175,6 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-surface animate-scale-in">
|
||||
@@ -188,11 +182,11 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
||||
<div className="login-brand-header">
|
||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||
<div className="brand-mark-canvas">
|
||||
<img src="/logo.png" alt="Metapi" className="login-brand-logo" />
|
||||
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-brand-summary">
|
||||
<div className="login-brand-name">Metapi</div>
|
||||
<div className="login-brand-name">BoosAPI</div>
|
||||
<div className="login-brand-kicker">{t('中转站的中转站')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,7 +233,6 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="login-auth-stage">
|
||||
<div className="login-auth-panel">
|
||||
<div className="login-auth-eyebrow">{t('管理员入口')}</div>
|
||||
@@ -280,7 +273,6 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserProfileModal({
|
||||
open,
|
||||
profile,
|
||||
@@ -298,7 +290,6 @@ function UserProfileModal({
|
||||
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
|
||||
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(profile.name);
|
||||
@@ -306,9 +297,7 @@ function UserProfileModal({
|
||||
setAvatarStyle(profile.avatarStyle);
|
||||
setError('');
|
||||
}, [open, profile]);
|
||||
|
||||
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
@@ -319,13 +308,11 @@ function UserProfileModal({
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const handleRandomAvatar = () => {
|
||||
const nextSeed = createRandomAvatarSeed();
|
||||
setAvatarSeed(nextSeed);
|
||||
setAvatarStyle(pickDicebearStyle(nextSeed));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
@@ -344,7 +331,6 @@ function UserProfileModal({
|
||||
: pickDicebearStyle(avatarSeed),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
@@ -371,7 +357,6 @@ function UserProfileModal({
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
|
||||
<input
|
||||
@@ -384,7 +369,6 @@ function UserProfileModal({
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
|
||||
@@ -393,7 +377,6 @@ function UserProfileModal({
|
||||
{t('换一个随机头像')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
@@ -402,7 +385,6 @@ function UserProfileModal({
|
||||
</CenteredModal>
|
||||
);
|
||||
}
|
||||
|
||||
export const sidebarGroups = [
|
||||
{
|
||||
label: '控制台',
|
||||
@@ -416,7 +398,8 @@ export const sidebarGroups = [
|
||||
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
||||
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
|
||||
{ to: '/monitor', label: '可用性监控', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M3 5a2 2 0 012-2h14a2 2 0 012 2v11a2 2 0 01-2 2h-5l-2.5 3-2.5-3H5a2 2 0 01-2-2V5z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 10h3l1.5-2.5L14 13l1.5-3H17" /></svg> },
|
||||
{ to: '/video-agent', label: 'ComfyUI', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
|
||||
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -426,22 +409,20 @@ export const sidebarGroups = [
|
||||
{ to: '/events', label: '程序日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> },
|
||||
{ to: '/settings/import-export', label: '导入/导出', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 7h10M7 12h6m-6 5h10M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z" /></svg> },
|
||||
{ to: '/settings/notify', label: '通知设置', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> },
|
||||
{ to: '/users', label: '用户管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const topNavItems = [
|
||||
{ label: '控制台', to: '/' },
|
||||
{ label: '模型广场', to: '/models' },
|
||||
{ label: '模型操练场', to: '/playground' },
|
||||
{ label: 'ComfyUI', to: '/video-agent' },
|
||||
{ label: '关于', to: '/about' },
|
||||
];
|
||||
|
||||
function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
return <div key={location.pathname} className="page-enter">{children}</div>;
|
||||
}
|
||||
|
||||
function RouteLoadingFallback() {
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ padding: 16 }}>
|
||||
@@ -450,7 +431,6 @@ function RouteLoadingFallback() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppShell() {
|
||||
const { language, toggleLanguage, t } = useI18n();
|
||||
const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage));
|
||||
@@ -480,21 +460,17 @@ function AppShell() {
|
||||
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员');
|
||||
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
|
||||
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const sync = () => setSystemPrefersDark(media.matches);
|
||||
sync();
|
||||
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', sync);
|
||||
return () => media.removeEventListener('change', sync);
|
||||
}
|
||||
|
||||
media.addListener(sync);
|
||||
return () => media.removeListener(sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
|
||||
@@ -504,17 +480,14 @@ function AppShell() {
|
||||
localStorage.setItem('theme', themeMode);
|
||||
}
|
||||
}, [resolvedTheme, themeMode]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && drawerOpen) {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
}, [drawerOpen, isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
@@ -525,26 +498,21 @@ function AppShell() {
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
let cancelled = false;
|
||||
|
||||
const pollEvents = async () => {
|
||||
try {
|
||||
const recentEvents = await api.getEvents('limit=30');
|
||||
|
||||
if (cancelled) return;
|
||||
const rows = Array.isArray(recentEvents) ? recentEvents : [];
|
||||
const unread = rows.filter((r: any) => !r.read).length;
|
||||
setUnreadCount(unread);
|
||||
const maxId = rows.reduce((acc: number, row: any) => Math.max(acc, Number(row?.id) || 0), 0);
|
||||
|
||||
if (latestTaskEventIdRef.current === 0) {
|
||||
latestTaskEventIdRef.current = maxId;
|
||||
return;
|
||||
}
|
||||
|
||||
const newTaskEvents = rows
|
||||
.filter((row: any) => (
|
||||
(Number(row?.id) || 0) > latestTaskEventIdRef.current
|
||||
@@ -553,7 +521,6 @@ function AppShell() {
|
||||
))
|
||||
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
|
||||
.slice(-3);
|
||||
|
||||
for (const event of newTaskEvents) {
|
||||
const message = event?.message || event?.title || t('任务状态已更新');
|
||||
if (event?.level === 'error') {
|
||||
@@ -564,7 +531,6 @@ function AppShell() {
|
||||
toast.success(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxId > latestTaskEventIdRef.current) {
|
||||
latestTaskEventIdRef.current = maxId;
|
||||
}
|
||||
@@ -572,7 +538,6 @@ function AppShell() {
|
||||
// ignore polling errors
|
||||
}
|
||||
};
|
||||
|
||||
void pollEvents();
|
||||
const timer = setInterval(() => { void pollEvents(); }, 15000);
|
||||
return () => {
|
||||
@@ -580,28 +545,23 @@ function AppShell() {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [authed, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
|
||||
const check = () => {
|
||||
if (hasValidAuthSession(localStorage)) return;
|
||||
setAuthed(false);
|
||||
toast.info(t('会话已过期,请重新登录'));
|
||||
};
|
||||
|
||||
check();
|
||||
const timer = setInterval(check, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [authed, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
if (localStorage.getItem(FIRST_USE_DOC_REMINDER_KEY)) return;
|
||||
localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1');
|
||||
toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`);
|
||||
}, [authed, t, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
|
||||
@@ -614,12 +574,10 @@ function AppShell() {
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const handleSelectThemeMode = (nextMode: ThemeMode) => {
|
||||
setThemeMode(nextMode);
|
||||
setShowThemeMenu(false);
|
||||
};
|
||||
|
||||
const handleSaveProfile = (nextProfile: UserProfile) => {
|
||||
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
|
||||
const normalized = {
|
||||
@@ -634,14 +592,29 @@ function AppShell() {
|
||||
setShowProfileModal(false);
|
||||
toast.success(t('个人信息已保存'));
|
||||
};
|
||||
|
||||
if (!authed) {
|
||||
// User-facing public routes (no admin auth needed)
|
||||
const publicUserRoutes = (
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Routes>
|
||||
<Route path="/user/login" element={<UserLogin />} />
|
||||
<Route path="/user/register" element={<UserRegister />} />
|
||||
<Route path="/user/dashboard" element={<UserDashboard />} />
|
||||
<Route path="*" element={<Navigate to="/user/login" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
// Check if current path is a user route
|
||||
if (location.pathname.startsWith('/user/')) {
|
||||
return publicUserRoutes;
|
||||
}
|
||||
|
||||
return <Login t={t} onLogin={(token) => {
|
||||
persistAuthSession(localStorage, token);
|
||||
setAuthed(true);
|
||||
}} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="topbar">
|
||||
@@ -658,8 +631,8 @@ function AppShell() {
|
||||
</button>
|
||||
)}
|
||||
<div className="topbar-logo">
|
||||
<img src="/logo.png" alt="Metapi" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||
<span className="topbar-logo-text">Metapi</span>
|
||||
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||
<span className="topbar-logo-text">BoosAPI</span>
|
||||
</div>
|
||||
<nav className="topbar-nav">
|
||||
{topNavItems.map((item) => (
|
||||
@@ -780,7 +753,6 @@ function AppShell() {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-layout">
|
||||
{isMobile ? (
|
||||
<MobileDrawer
|
||||
@@ -790,8 +762,8 @@ function AppShell() {
|
||||
closeLabel={t('关闭导航')}
|
||||
>
|
||||
<div className="mobile-drawer-header">
|
||||
<img src="/logo.png" alt="Metapi" />
|
||||
<span>Metapi</span>
|
||||
<img src="/logo.png" alt="BoosAPI" />
|
||||
<span>BoosAPI</span>
|
||||
</div>
|
||||
<nav className="mobile-nav">
|
||||
{sidebarGroups.map((group) => (
|
||||
@@ -854,7 +826,6 @@ function AppShell() {
|
||||
</button>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className="main-content">
|
||||
<PageTransition>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
@@ -875,15 +846,16 @@ function AppShell() {
|
||||
<Route path="/settings/import-export" element={<ImportExport />} />
|
||||
<Route path="/settings/notify" element={<NotificationSettings />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/playground" element={<ModelTester />} />
|
||||
<Route path="/video-agent" element={<ComfyUI />} />
|
||||
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</PageTransition>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<UserProfileModal
|
||||
open={showProfileModal}
|
||||
profile={userProfile}
|
||||
@@ -895,7 +867,6 @@ function AppShell() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
|
||||
+104
-1
@@ -1,4 +1,4 @@
|
||||
import { clearAuthSession, getAuthToken } from "./authSession.js";
|
||||
import { clearAuthSession, getAuthToken, getUserToken, clearUserSession } from "./authSession.js";
|
||||
|
||||
type BufferLike = {
|
||||
from(data: ArrayBuffer): { toString(encoding: "base64"): string };
|
||||
@@ -156,6 +156,82 @@ async function fetchAuthenticatedResponse(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserResponse(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<Response> {
|
||||
const {
|
||||
timeoutMs = 30_000,
|
||||
signal: externalSignal,
|
||||
...fetchOptions
|
||||
} = options;
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
let cleanupExternalSignal = () => {};
|
||||
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
const abortHandler = () => controller.abort();
|
||||
externalSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
cleanupExternalSignal = () =>
|
||||
externalSignal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
}
|
||||
|
||||
const token = getUserToken(localStorage);
|
||||
if (!token) {
|
||||
clearUserSession(localStorage);
|
||||
throw new Error("User session expired");
|
||||
}
|
||||
const headers = new Headers(fetchOptions.headers ?? {});
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (fetchOptions.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
clearUserSession(localStorage);
|
||||
throw new Error("User session expired");
|
||||
}
|
||||
return res;
|
||||
} catch (error: any) {
|
||||
if (error?.name === "AbortError") {
|
||||
if (externalSignal?.aborted) throw error;
|
||||
throw new Error(
|
||||
`请求超时(${Math.max(1, Math.round(timeoutMs / 1000))}s)`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
cleanupExternalSignal();
|
||||
}
|
||||
}
|
||||
|
||||
async function userRequest<T = any>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const res = await fetchUserResponse(url, options);
|
||||
if (!res.ok) {
|
||||
throw new Error(await extractResponseErrorMessage(res));
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T = any>(
|
||||
url: string,
|
||||
options: RequestOptions = {},
|
||||
@@ -1508,4 +1584,31 @@ export const api = {
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// Admin user management
|
||||
getUsers: () => request("/api/admin/users"),
|
||||
getUserById: (id: number) => request(`/api/admin/users/${id}`),
|
||||
updateUser: (id: number, data: { role?: string; status?: string; username?: string }) =>
|
||||
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteUser: (id: number) =>
|
||||
request(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
export const userApi = {
|
||||
register: (data: { username: string; email: string; password: string }) =>
|
||||
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
|
||||
login: (data: { email: string; password: string }) =>
|
||||
userRequest("/api/users/login", { method: "POST", body: JSON.stringify(data) }),
|
||||
me: () => userRequest("/api/users/me"),
|
||||
updateMe: (data: { username?: string }) =>
|
||||
userRequest("/api/users/me", { method: "PATCH", body: JSON.stringify(data) }),
|
||||
changePassword: (data: { oldPassword: string; newPassword: string }) =>
|
||||
userRequest("/api/users/me/password", { method: "POST", body: JSON.stringify(data) }),
|
||||
getApiKeys: () => userRequest("/api/user-api-keys"),
|
||||
createApiKey: (data: any) =>
|
||||
userRequest("/api/user-api-keys", { method: "POST", body: JSON.stringify(data) }),
|
||||
updateApiKey: (id: number, data: any) =>
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteApiKey: (id: number) =>
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { clearAuthSession } from './authSession.js';
|
||||
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
||||
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
|
||||
export const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
|
||||
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
|
||||
|
||||
type StorageLike = {
|
||||
getItem?: (key: string) => string | null;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const AUTH_TOKEN_STORAGE_KEY = 'auth_token';
|
||||
const AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY = 'auth_token_expires_at';
|
||||
const USER_JWT_STORAGE_KEY = 'user_jwt_token';
|
||||
const USER_JWT_EXPIRES_AT_STORAGE_KEY = 'user_jwt_expires_at';
|
||||
export const AUTH_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
type StorageLike = {
|
||||
@@ -67,3 +69,50 @@ export function getAuthToken(storage?: StorageLike | null, nowMs = Date.now()):
|
||||
export function hasValidAuthSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
|
||||
return !!getAuthToken(storage, nowMs);
|
||||
}
|
||||
|
||||
export function clearUserSession(storage?: StorageLike | null): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
target.removeItem(USER_JWT_STORAGE_KEY);
|
||||
target.removeItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function persistUserSession(
|
||||
storage: StorageLike | null | undefined,
|
||||
token: string,
|
||||
ttlMs = AUTH_SESSION_DURATION_MS,
|
||||
nowMs = Date.now(),
|
||||
): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
const cleanToken = (token || '').trim();
|
||||
if (!cleanToken) {
|
||||
clearUserSession(target);
|
||||
return;
|
||||
}
|
||||
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
|
||||
target.setItem(USER_JWT_STORAGE_KEY, cleanToken);
|
||||
target.setItem(USER_JWT_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
|
||||
}
|
||||
|
||||
export function getUserToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return null;
|
||||
const token = (target.getItem(USER_JWT_STORAGE_KEY) || '').trim();
|
||||
if (!token) return null;
|
||||
const expiresAtRaw = target.getItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
|
||||
if (!expiresAtRaw) {
|
||||
persistUserSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
|
||||
return token;
|
||||
}
|
||||
const expiresAt = Number(expiresAtRaw);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
|
||||
clearUserSession(target);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
export function hasValidUserSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
|
||||
return !!getUserToken(storage, nowMs);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const SITE_DOCS_URL = 'https://metapi.cita777.me';
|
||||
export const SITE_DOCS_URL = 'https://boosapi.cita777.me';
|
||||
export const SITE_GITHUB_URL = 'https://github.com/cita-777';
|
||||
|
||||
@@ -191,7 +191,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'共 ${models.length} 个模型': 'Total ${models.length} models',
|
||||
'关闭': 'closure',
|
||||
'关于': 'about',
|
||||
'关于 Metapi': 'About metapi',
|
||||
'关于 BoosAPI': 'About BoosAPI',
|
||||
'管理端 IP 白名单': 'Management IP whitelist',
|
||||
'管理员': 'administrator',
|
||||
'管理员登录 Token 已被修改,请使用新 Token 登录。': 'The administrator login token has been modified, please use the new token to log in.',
|
||||
@@ -259,7 +259,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'拉取分组失败': 'Failed to pull group',
|
||||
'拉取分组失败,已回退 default': 'Failed to pull the group and has fallen back to default',
|
||||
'冷却中': 'Cooling down',
|
||||
'例如 metapi': 'For example metapi',
|
||||
'例如 BoosAPI': 'For example metapi',
|
||||
'例如: admin@example.com': 'For example: admin@example.com',
|
||||
'例如: smtp.qq.com': 'For example: smtp.qq.com',
|
||||
'例如: target@example.com': 'For example: target@example.com',
|
||||
@@ -770,7 +770,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
||||
'LDOH Cookie 已更新': 'LDOH Cookie updated',
|
||||
'LDOH Cookie 已清空': 'LDOH Cookie Cleared',
|
||||
'LinuxDo 可用性监控': 'LinuxDo availability monitoring',
|
||||
'metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'metapi is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
|
||||
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'boosapi is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
|
||||
'model 不能为空': 'model cannot be empty',
|
||||
'modelLimitsEnabled 参数无效': 'modelLimitsEnabled parameter is invalid',
|
||||
'models 必须是非空数组': 'models must be a non-empty array',
|
||||
|
||||
+63
-3
@@ -71,7 +71,7 @@ const zhToEn: Record<string, string> = {
|
||||
'模型操练场': 'Model Playground',
|
||||
'模型测试': 'Model Testing',
|
||||
'关于': 'About',
|
||||
'关于 Metapi': 'About Metapi',
|
||||
'关于 BoosAPI': 'About BoosAPI',
|
||||
'站点文档': 'Site Docs',
|
||||
'任务状态已更新': 'Task status updated',
|
||||
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
||||
@@ -208,7 +208,7 @@ const zhToEn: Record<string, string> = {
|
||||
'代理端点': 'Proxy Endpoints',
|
||||
'路由行为': 'Routing Behavior',
|
||||
'指标口径': 'Metric Notes',
|
||||
'metapi 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'Metapi aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
|
||||
'BoosAPI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BoosAPI aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
|
||||
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
|
||||
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
|
||||
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
|
||||
@@ -309,7 +309,67 @@ const zhToEn: Record<string, string> = {
|
||||
'零配置嵌入式数据库': 'Zero-config embedded database',
|
||||
'项目链接': 'Project Links',
|
||||
'数据与隐私': 'Data & Privacy',
|
||||
'Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'Metapi is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
|
||||
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BoosAPI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
|
||||
|
||||
// User SaaS translations
|
||||
'用户登录': 'User Login',
|
||||
'用户注册': 'User Register',
|
||||
'欢迎回来': 'Welcome Back',
|
||||
'使用邮箱和密码登录你的账号。': 'Sign in with your email and password.',
|
||||
'还没有账号?': "Don't have an account?",
|
||||
'已有账号?': 'Already have an account?',
|
||||
'管理员登录': 'Admin Sign In',
|
||||
'创建账号': 'Create Account',
|
||||
'注册后即可使用 API 代理服务。': 'Register to use the API proxy service.',
|
||||
'注册': 'Register',
|
||||
'密码至少 6 个字符': 'Password must be at least 6 characters',
|
||||
'两次密码不一致': 'Passwords do not match',
|
||||
'登录中...': 'Signing in...',
|
||||
'注册中...': 'Registering...',
|
||||
'注册失败': 'Registration failed',
|
||||
'登录失败,请检查邮箱和密码': 'Login failed. Please check your email and password.',
|
||||
'确认密码': 'Confirm Password',
|
||||
'至少 6 位': 'At least 6 characters',
|
||||
'再次输入密码': 'Re-enter password',
|
||||
'你的昵称': 'Your nickname',
|
||||
'用户中心': 'User Portal',
|
||||
'邮箱': 'Email',
|
||||
'密码': 'Password',
|
||||
'个人信息': 'Profile',
|
||||
'修改密码': 'Change Password',
|
||||
'API 密钥': 'API Keys',
|
||||
'新建密钥': 'New Key',
|
||||
'当前密码': 'Current Password',
|
||||
'新密码': 'New Password',
|
||||
'确认新密码': 'Confirm New Password',
|
||||
'修改密码': 'Change Password',
|
||||
'修改中...': 'Changing...',
|
||||
'密码已更新': 'Password updated',
|
||||
'密钥名称': 'Key Name',
|
||||
'暂无 API 密钥': 'No API Keys',
|
||||
'用户名': 'Username',
|
||||
'角色': 'Role',
|
||||
'状态': 'Status',
|
||||
'注册时间': 'Registered',
|
||||
'正常': 'Active',
|
||||
'已禁用': 'Disabled',
|
||||
'管理员': 'Admin',
|
||||
'普通用户': 'User',
|
||||
'启用': 'Enabled',
|
||||
'禁用': 'Disabled',
|
||||
'创建': 'Create',
|
||||
'创建中...': 'Creating...',
|
||||
'保存': 'Save',
|
||||
'编辑': 'Edit',
|
||||
'删除': 'Delete',
|
||||
'取消': 'Cancel',
|
||||
'密钥已创建,请立即复制(不会再次显示):': 'Key created. Copy now (won\'t show again):',
|
||||
'关闭': 'Close',
|
||||
'退出': 'Sign Out',
|
||||
'用户管理': 'User Management',
|
||||
'共': 'Total',
|
||||
'个用户': ' users',
|
||||
'总用户': 'Total Users',
|
||||
};
|
||||
|
||||
for (const [source, target] of Object.entries(zhToEnSupplemental)) {
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Metapi - 中转站的中转站,聚合多个 AI API 中转站为统一网关" />
|
||||
<title>Metapi</title>
|
||||
<meta name="description" content="BoosAPI - 中转站的中转站,聚合多个 AI API 中转站为统一网关" />
|
||||
<title>BoosAPI</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
@@ -29,8 +29,8 @@ const TECH_STACK = [
|
||||
];
|
||||
|
||||
const LINKS = [
|
||||
{ label: 'GitHub', href: 'https://github.com/cita-777/metapi', icon: '📂' },
|
||||
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/metapi', icon: '🐳' },
|
||||
{ label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: '📂' },
|
||||
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: '🐳' },
|
||||
{ label: '站点文档', href: SITE_DOCS_URL, icon: '📚' },
|
||||
];
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function About() {
|
||||
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
|
||||
{/* Header */}
|
||||
<div className="page-header" style={{ marginBottom: 14 }}>
|
||||
<h2 className="page-title">{tr('关于 Metapi')}</h2>
|
||||
<h2 className="page-title">{tr('关于 BoosAPI')}</h2>
|
||||
</div>
|
||||
|
||||
{/* Hero card */}
|
||||
@@ -90,11 +90,11 @@ export default function About() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Metapi"
|
||||
alt="BoosAPI"
|
||||
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>Metapi</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>{currentVersion}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +206,7 @@ export default function About() {
|
||||
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
{tr('Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')}
|
||||
{tr('BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1821,7 +1821,7 @@ export default function Accounts() {
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
配置 refresh_token 后,metapi 会在 JWT 临近过期或
|
||||
配置 refresh_token 后,BoosAPI 会在 JWT 临近过期或
|
||||
401 时自动续期并回写新 token。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ComfyUI() {
|
||||
const [isDark, setIsDark] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const check = () => setIsDark(el.getAttribute('data-theme') !== 'light');
|
||||
check();
|
||||
const mo = new MutationObserver(check);
|
||||
mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] });
|
||||
return () => mo.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 56px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: isDark ? '#1a1a2e' : '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="/comfyui"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
title="ComfyUI"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
images?: Array<{ url: string; character?: string; angle?: string }>;
|
||||
scenes?: Array<{ id: number; description: string; prompt: string }>;
|
||||
tts?: Array<{ character: string; text: string; url: string }>;
|
||||
workflowJson?: any;
|
||||
}
|
||||
|
||||
export default function ComfyUIAgent() {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: 'welcome',
|
||||
role: 'system',
|
||||
content: '🎬 你好!我是 AI 视频生成助手。请告诉我你的视频脚本或创意,我可以帮你:\n\n1️⃣ 分析脚本结构(角色、场景、对话)\n2️⃣ 生成角色 4 角度图像(正面/背面/左侧/右侧)\n3️⃣ 生成场景背景图\n4️⃣ 生成角色语音(TTS)\n5️⃣ 导出 ComfyUI 工作流\n\n直接发脚本给我,我们开始吧!',
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
const userMsg: Message = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: input.trim(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
setStreamingContent('');
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortRef.current = abortController;
|
||||
|
||||
try {
|
||||
const apiMessages = [...messages, userMsg].map((m) => ({
|
||||
role: m.role === 'system' ? 'system' : m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const resp = await fetch('/api/comfyui-agent/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: apiMessages }),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
setStreamingContent('请求失败,请重试');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) return;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let textBuffer = '';
|
||||
const images: Message['images'] = [];
|
||||
const scenes: Message['scenes'] = [];
|
||||
const ttsList: Message['tts'] = [];
|
||||
let msgId = `agent-${Date.now()}`;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n');
|
||||
buffer = parts.pop() || '';
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part.startsWith('data: ')) continue;
|
||||
const data = part.slice(6).trim();
|
||||
if (!data) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
|
||||
switch (event.type) {
|
||||
case 'text':
|
||||
textBuffer += event.content || '';
|
||||
setStreamingContent(textBuffer);
|
||||
break;
|
||||
case 'character_prompts':
|
||||
textBuffer += `\n\n📐 **${event.character}** 角色提示词已生成`;
|
||||
setStreamingContent(textBuffer);
|
||||
break;
|
||||
case 'image':
|
||||
images.push({ url: event.url, character: event.character, angle: event.angle });
|
||||
textBuffer += `\n🖼️ 生成 ${event.character} [${event.angle}]`;
|
||||
setStreamingContent(textBuffer);
|
||||
break;
|
||||
case 'scene':
|
||||
scenes.push({ id: event.id, description: event.description, prompt: event.prompt });
|
||||
textBuffer += `\n🌄 场景 ${event.id}: ${event.description}`;
|
||||
setStreamingContent(textBuffer);
|
||||
break;
|
||||
case 'tts':
|
||||
ttsList.push({ character: event.character, text: event.text, url: event.url });
|
||||
textBuffer += `\n🔊 ${event.character}: "${event.text.slice(0, 30)}..."`;
|
||||
setStreamingContent(textBuffer);
|
||||
break;
|
||||
case 'comfyui_workflow':
|
||||
textBuffer += '\n\n📦 ComfyUI 工作流已就绪,点击上方「导出」按钮下载';
|
||||
setStreamingContent(textBuffer);
|
||||
break;
|
||||
case 'done':
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalContent = textBuffer || (await resp.text()).slice(0, 500);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: msgId,
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
images: images.length > 0 ? images : undefined,
|
||||
scenes: scenes.length > 0 ? scenes : undefined,
|
||||
tts: ttsList.length > 0 ? ttsList : undefined,
|
||||
},
|
||||
]);
|
||||
setStreamingContent('');
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setStreamingContent(`错误: ${e.message}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
abortRef.current?.abort();
|
||||
setLoading(false);
|
||||
if (streamingContent) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: `agent-${Date.now()}`, role: 'assistant', content: streamingContent },
|
||||
]);
|
||||
setStreamingContent('');
|
||||
}
|
||||
};
|
||||
|
||||
const exportWorkflow = () => {
|
||||
// Simple ComfyUI workflow JSON for video generation
|
||||
const workflow = {
|
||||
name: 'AI Video Project',
|
||||
version: 1,
|
||||
nodes: messages
|
||||
.filter((m) => m.images && m.images.length > 0)
|
||||
.flatMap((m) =>
|
||||
(m.images || []).map((img, i) => ({
|
||||
id: `img-${i}`,
|
||||
type: 'LoadImage',
|
||||
url: img.url,
|
||||
character: img.character,
|
||||
angle: img.angle,
|
||||
}))
|
||||
),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(workflow, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'comfyui-workflow.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 56px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'var(--color-bg, #0f0f1a)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 20px',
|
||||
borderBottom: '1px solid var(--color-border, #2a2a3e)',
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '16px', fontWeight: 600, margin: 0, color: 'var(--color-text, #e0e0f0)' }}>
|
||||
🎬 AI 视频助手(对话)
|
||||
</h1>
|
||||
<button
|
||||
onClick={exportWorkflow}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
background: 'var(--color-surface-raised, #252540)',
|
||||
color: 'var(--color-text, #e0e0f0)',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
📦 导出 ComfyUI
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '85%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '12px',
|
||||
background:
|
||||
msg.role === 'user'
|
||||
? 'var(--color-primary, #6366f1)'
|
||||
: msg.role === 'system'
|
||||
? 'transparent'
|
||||
: 'var(--color-surface-raised, #252540)',
|
||||
color:
|
||||
msg.role === 'user'
|
||||
? '#fff'
|
||||
: 'var(--color-text, #e0e0f0)',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{msg.images && msg.images.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
{msg.images.map((img, i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={`${img.character || ''} ${img.angle || ''}`}
|
||||
style={{
|
||||
width: '150px',
|
||||
height: '150px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-secondary, #888)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{img.character} {img.angle === 'front' ? '正面' : img.angle === 'back' ? '背面' : img.angle === 'left' ? '左侧' : img.angle === 'right' ? '右侧' : img.angle}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Streaming message */}
|
||||
{streamingContent && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '85%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--color-surface-raised, #252540)',
|
||||
color: 'var(--color-text, #e0e0f0)',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{streamingContent}
|
||||
<span style={{ animation: 'blink 1s step-end infinite', marginLeft: 2 }}>▌</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid var(--color-border, #2a2a3e)',
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入脚本内容或创作需求..."
|
||||
rows={2}
|
||||
disabled={loading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
background: 'var(--color-bg, #0f0f1a)',
|
||||
color: 'var(--color-text, #e0e0f0)',
|
||||
fontSize: '14px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{loading ? (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
■ 停止
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: !input.trim() ? '#555' : '#6366f1',
|
||||
color: '#fff',
|
||||
fontSize: '14px',
|
||||
cursor: !input.trim() ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +165,7 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
const legacyPrefs = Boolean(data.data?.preferences);
|
||||
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
|
||||
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
|
||||
const isNativeMetapiBackup = Boolean(
|
||||
const isNativeBoosapiBackup = Boolean(
|
||||
accountsSection
|
||||
&& Array.isArray(accountsSection.sites)
|
||||
&& Array.isArray(accountsSection.accountTokens)
|
||||
@@ -183,7 +183,7 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
));
|
||||
const isAllApiHubV2 = Boolean(
|
||||
accountsSection
|
||||
&& !isNativeMetapiBackup
|
||||
&& !isNativeBoosapiBackup
|
||||
&& hasLegacyAccountRows
|
||||
&& Array.isArray(accountsSection.accounts)
|
||||
&& (
|
||||
@@ -363,9 +363,9 @@ export default function ImportExport() {
|
||||
const data = await api.exportBackup(type);
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const fileName: Record<BackupType, string> = {
|
||||
all: `metapi-backup-${date}.json`,
|
||||
accounts: `metapi-accounts-${date}.json`,
|
||||
preferences: `metapi-preferences-${date}.json`,
|
||||
all: `boosapi-backup-${date}.json`,
|
||||
accounts: `boosapi-accounts-${date}.json`,
|
||||
preferences: `boosapi-preferences-${date}.json`,
|
||||
};
|
||||
downloadJsonFile(data, fileName[type]);
|
||||
toast.success('导出成功');
|
||||
@@ -730,7 +730,7 @@ export default function ImportExport() {
|
||||
<input
|
||||
value={webdavConfig.fileUrl}
|
||||
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, fileUrl: e.target.value }))}
|
||||
placeholder="https://dav.example.com/backups/metapi.json"
|
||||
placeholder="https://dav.example.com/backups/boosapi.json"
|
||||
style={settingsInputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function Monitors() {
|
||||
<div>
|
||||
<h2 className="page-title">{tr('监控内嵌')}</h2>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
在 metapi 内查看外部站点监控页面。
|
||||
在 BoosAPI 内查看外部站点监控页面。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
|
||||
@@ -2313,16 +2313,16 @@ export default function OAuthManagement() {
|
||||
|
||||
{renderGuideCard(
|
||||
'本地部署',
|
||||
'metapi 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
||||
'BoosAPI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
|
||||
<div className="oauth-guide-copy">
|
||||
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 metapi。
|
||||
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BoosAPI。
|
||||
</div>,
|
||||
)}
|
||||
|
||||
{activeSession.instructions.sshTunnelCommand
|
||||
? renderGuideCard(
|
||||
'云端部署',
|
||||
'metapi 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
||||
'BoosAPI 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
|
||||
<div className="oauth-guide-block-list">
|
||||
<div className="oauth-guide-block-label">常规 SSH 隧道</div>
|
||||
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
|
||||
|
||||
@@ -74,7 +74,7 @@ type ProxyDebugTraceDetailState = {
|
||||
|
||||
type ProxyDebugTraceAttempt = ProxyDebugTraceDetail["attempts"][number];
|
||||
type StoredDebugPreviewPayload = {
|
||||
__metapiTruncated?: boolean;
|
||||
__boosapiTruncated?: boolean;
|
||||
preview?: string;
|
||||
originalBytes?: number;
|
||||
storedBytes?: number;
|
||||
@@ -85,7 +85,7 @@ const DEFAULT_PAGE_SIZE = 50;
|
||||
const TRACE_TABLE_LIMIT = 20;
|
||||
const DEBUG_TRACE_PAGE_SIZE = 5;
|
||||
const PROXY_LOGS_DEBUG_TRACE_PANEL_STORAGE_KEY =
|
||||
"metapi.proxyLogs.debugTracePanelExpanded";
|
||||
"boosapi.proxyLogs.debugTracePanelExpanded";
|
||||
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||
codex: "Codex",
|
||||
claude_code: "Claude Code",
|
||||
@@ -678,7 +678,7 @@ function parseStoredDebugPreview(value: unknown): {
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.__metapiTruncated &&
|
||||
parsed.__boosapiTruncated &&
|
||||
typeof parsed.preview === "string"
|
||||
) {
|
||||
const originalBytes = Number(parsed.originalBytes || 0);
|
||||
|
||||
@@ -1781,7 +1781,7 @@ export default function Settings() {
|
||||
<div style={settingsModernTitleBlockStyle}>
|
||||
<div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div>
|
||||
<div style={settingsModernDescriptionStyle}>
|
||||
默认采用 HTTP 优先。只有这里开启后,metapi 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
|
||||
默认采用 HTTP 优先。只有这里开启后,BoosAPI 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
|
||||
</div>
|
||||
</div>
|
||||
<div style={settingsModernPillRowStyle}>
|
||||
@@ -1795,7 +1795,7 @@ export default function Settings() {
|
||||
</div>
|
||||
<label style={settingsModernToggleStyle}>
|
||||
<div style={settingsModernToggleCopyStyle}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 metapi 到 Codex 上游使用 WebSocket</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BoosAPI 到 Codex 上游使用 WebSocket</span>
|
||||
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
||||
仅在下游 Codex 客户端已同步开启 `/v1/responses` websocket 时启用;否则仍按 HTTP 优先执行。
|
||||
</span>
|
||||
@@ -1878,7 +1878,7 @@ export default function Settings() {
|
||||
<div style={settingsModernTitleBlockStyle}>
|
||||
<div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div>
|
||||
<div style={settingsModernDescriptionStyle}>
|
||||
默认关闭。开启后,metapi 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
|
||||
默认关闭。开启后,BoosAPI 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
|
||||
</div>
|
||||
</div>
|
||||
<div style={settingsModernPillRowStyle}>
|
||||
@@ -1904,7 +1904,7 @@ export default function Settings() {
|
||||
</div>
|
||||
<label style={settingsModernToggleStyle}>
|
||||
<div style={settingsModernToggleCopyStyle}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 metapi 后台主动批量测活</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BoosAPI 后台主动批量测活</span>
|
||||
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
|
||||
首次从关闭切换到开启时,需要手动输入确认语句;关闭时可直接保存。
|
||||
</span>
|
||||
@@ -2581,7 +2581,7 @@ export default function Settings() {
|
||||
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
|
||||
重新初始化系统会清空当前 metapi 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据,然后切回默认 SQLite。
|
||||
重新初始化系统会清空当前 BoosAPI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据,然后切回默认 SQLite。
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
|
||||
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @Author: 橘子
|
||||
* @Project_description: Metapi 站点管理页
|
||||
* @Project_description: BoosAPI 站点管理页
|
||||
* @Description: 代码是我抄的,不会也是真的
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -1041,7 +1041,7 @@ export default function TokenRoutes() {
|
||||
};
|
||||
|
||||
const handleDeleteChannel = async (channelId: number, routeId: number) => {
|
||||
const dismissedKey = 'metapi:channel-delete-warning-dismissed';
|
||||
const dismissedKey = 'boosapi:channel-delete-warning-dismissed';
|
||||
const dismissed = localStorage.getItem(dismissedKey) === 'true';
|
||||
if (!dismissed) {
|
||||
const dontAskAgain = { checked: false };
|
||||
|
||||
@@ -1099,7 +1099,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如 metapi"
|
||||
placeholder="例如 BoosAPI"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { userApi } from '../api.js';
|
||||
import { clearUserSession, getUserToken } from '../authSession.js';
|
||||
|
||||
interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key?: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export default function UserDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Profile edit
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [saveMsg, setSaveMsg] = useState('');
|
||||
|
||||
// Password change
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwMsg, setPwMsg] = useState('');
|
||||
const [pwLoading, setPwLoading] = useState(false);
|
||||
|
||||
// New API key
|
||||
const [showNewKey, setShowNewKey] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
|
||||
const [keyLoading, setKeyLoading] = useState(false);
|
||||
|
||||
const checkAuth = useCallback(() => {
|
||||
if (!getUserToken(localStorage)) {
|
||||
navigate('/user/login');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, [navigate]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!checkAuth()) return;
|
||||
try {
|
||||
const [profileRes, keysRes] = await Promise.all([
|
||||
userApi.me(),
|
||||
userApi.getApiKeys(),
|
||||
]);
|
||||
setProfile(profileRes.user);
|
||||
setKeys(Array.isArray(keysRes) ? keysRes : (keysRes.keys || []));
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载失败');
|
||||
clearUserSession(localStorage);
|
||||
navigate('/user/login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [checkAuth, navigate]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!newName.trim() || !profile) return;
|
||||
try {
|
||||
await userApi.updateMe({ username: newName.trim() });
|
||||
setProfile({ ...profile, username: newName.trim() });
|
||||
setEditingName(false);
|
||||
setSaveMsg('已保存');
|
||||
setTimeout(() => setSaveMsg(''), 2000);
|
||||
} catch (err: any) {
|
||||
setSaveMsg(err?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (newPw !== confirmPw) { setPwMsg('两次密码不一致'); return; }
|
||||
if (newPw.length < 6) { setPwMsg('密码至少 6 个字符'); return; }
|
||||
setPwLoading(true);
|
||||
setPwMsg('');
|
||||
try {
|
||||
await userApi.changePassword({ oldPassword: oldPw, newPassword: newPw });
|
||||
setPwMsg('密码已更新');
|
||||
setOldPw(''); setNewPw(''); setConfirmPw('');
|
||||
} catch (err: any) {
|
||||
setPwMsg(err?.message || '修改失败');
|
||||
} finally {
|
||||
setPwLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
setKeyLoading(true);
|
||||
try {
|
||||
const result = await userApi.createApiKey({ name: newKeyName.trim() });
|
||||
setCreatedKey(result.key);
|
||||
setKeys((prev) => [...prev, result.key]);
|
||||
setNewKeyName('');
|
||||
} catch (err: any) {
|
||||
setSaveMsg(err?.message || '创建失败');
|
||||
} finally {
|
||||
setKeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteKey = async (id: number) => {
|
||||
try {
|
||||
await userApi.deleteApiKey(id);
|
||||
setKeys((prev) => prev.filter((k) => k.id !== id));
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
clearUserSession(localStorage);
|
||||
navigate('/user/login');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: 'var(--color-bg, #0f0f1a)' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
|
||||
<div className="login-auth-stage">
|
||||
<div className="login-auth-panel" style={{ textAlign: 'center', padding: '40px 24px' }}>
|
||||
<p style={{ color: 'var(--color-text-muted)', marginBottom: 16 }}>{error || '未登录'}</p>
|
||||
<button onClick={() => navigate('/user/login')} className="btn btn-primary">去登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'var(--color-bg, #0f0f1a)',
|
||||
color: 'var(--color-text, #e0e0f0)',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 24px', borderBottom: '1px solid var(--color-border, #2a2a3e)',
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>BoosAPI</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>用户中心</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>{profile.email}</span>
|
||||
<button onClick={handleLogout} className="btn btn-ghost" style={{ fontSize: 13 }}>退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ maxWidth: 720, margin: '0 auto', padding: '24px 16px', display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{error && (
|
||||
<div className="alert alert-error">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Profile Section */}
|
||||
<section style={{
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
borderRadius: 12, padding: 20,
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 16px 0' }}>个人信息</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>用户名</div>
|
||||
{editingName ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input value={newName} onChange={(e) => setNewName(e.target.value)} style={inputStyle} autoFocus />
|
||||
<button onClick={handleSaveName} className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}>保存</button>
|
||||
<button onClick={() => setEditingName(false)} className="btn btn-ghost">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14 }}>{profile.username}</span>
|
||||
<button onClick={() => { setNewName(profile.username); setEditingName(true); }} className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }}>编辑</button>
|
||||
</div>
|
||||
)}
|
||||
{saveMsg && <div style={{ fontSize: 12, color: 'var(--color-primary)', marginTop: 4 }}>{saveMsg}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>邮箱</div>
|
||||
<span style={{ fontSize: 14 }}>{profile.email}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>角色</div>
|
||||
<span style={{
|
||||
fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
||||
background: profile.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
|
||||
color: profile.role === 'admin' ? '#818cf8' : '#4ade80',
|
||||
}}>
|
||||
{profile.role === 'admin' ? '管理员' : '普通用户'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>状态</div>
|
||||
<span style={{
|
||||
fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
||||
background: profile.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
||||
color: profile.status === 'active' ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{profile.status === 'active' ? '正常' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>注册时间</div>
|
||||
<span style={{ fontSize: 14 }}>{profile.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Password Change */}
|
||||
<section style={{
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
borderRadius: 12, padding: 20,
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 16px 0' }}>修改密码</h2>
|
||||
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>当前密码</div>
|
||||
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>新密码</div>
|
||||
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>确认新密码</div>
|
||||
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
{pwMsg && <div style={{ fontSize: 12, color: pwMsg.includes('已更新') ? 'var(--color-primary)' : '#f87171' }}>{pwMsg}</div>}
|
||||
<button type="submit" disabled={pwLoading || !oldPw || !newPw || !confirmPw} className="btn btn-primary" style={{ alignSelf: 'flex-start' }}>
|
||||
{pwLoading ? '修改中...' : '修改密码'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* API Keys */}
|
||||
<section style={{
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
borderRadius: 12, padding: 20,
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>API 密钥</h2>
|
||||
<button onClick={() => setShowNewKey(!showNewKey)} className="btn btn-primary" style={{ fontSize: 13 }}>
|
||||
{showNewKey ? '取消' : '新建密钥'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewKey && (
|
||||
<div style={{
|
||||
padding: 12, marginBottom: 12, borderRadius: 8,
|
||||
background: 'var(--color-bg, #0f0f1a)',
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
{createdKey ? (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8 }}>密钥已创建,请立即复制(不会再次显示):</div>
|
||||
<div style={{
|
||||
padding: '8px 12px', borderRadius: 6, fontSize: 13, fontFamily: 'monospace',
|
||||
background: 'rgba(99,102,241,0.1)', border: '1px solid rgba(99,102,241,0.3)',
|
||||
wordBreak: 'break-all', marginBottom: 8,
|
||||
}}>
|
||||
{createdKey.key || 'sk-user-...'}
|
||||
</div>
|
||||
<button onClick={() => { setCreatedKey(null); setShowNewKey(false); }} className="btn btn-ghost" style={{ fontSize: 12 }}>关闭</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="密钥名称"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={handleCreateKey} disabled={keyLoading || !newKeyName.trim()} className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}>
|
||||
{keyLoading ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', padding: '16px 0', textAlign: 'center' }}>
|
||||
暂无 API 密钥
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{keys.map((key) => (
|
||||
<div key={key.id} style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '10px 12px', borderRadius: 8,
|
||||
background: 'var(--color-bg, #0f0f1a)',
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{key.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
{key.key ? key.key.slice(0, 20) + '...' : ''}
|
||||
{key.description && ` · ${key.description}`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 11, padding: '2px 6px', borderRadius: 3,
|
||||
background: key.enabled ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
||||
color: key.enabled ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{key.enabled ? '启用' : '禁用'}
|
||||
</span>
|
||||
<button onClick={() => handleDeleteKey(key.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '2px 8px' }}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { persistUserSession } from '../authSession.js';
|
||||
|
||||
export default function UserLogin() {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !password) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim(), password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || `登录失败 (${res.status})`);
|
||||
}
|
||||
const result = await res.json();
|
||||
persistUserSession(localStorage, result.token);
|
||||
navigate('/user/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '登录失败,请检查邮箱和密码');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
|
||||
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
|
||||
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
|
||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||
<div className="brand-mark-canvas">
|
||||
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
|
||||
<div className="login-brand-name">BoosAPI</div>
|
||||
<div className="login-brand-kicker">用户登录</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-auth-stage">
|
||||
<div className="login-auth-panel">
|
||||
<h2 className="login-auth-title">欢迎回来</h2>
|
||||
<p className="login-auth-copy">使用邮箱和密码登录你的账号。</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="login-auth-label" htmlFor="user-email-input">邮箱</label>
|
||||
<input
|
||||
id="user-email-input"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="user-password-input" style={{ marginTop: 12 }}>密码</label>
|
||||
<input
|
||||
id="user-password-input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email.trim() || !password}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 登录中...</> : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
|
||||
<span>
|
||||
还没有账号?<Link to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>注册</Link>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { api } from '../api.js';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<number | null>(null);
|
||||
const [editRole, setEditRole] = useState('');
|
||||
const [editStatus, setEditStatus] = useState('');
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.getUsers();
|
||||
setUsers(Array.isArray(result.users) ? result.users : []);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadUsers(); }, [loadUsers]);
|
||||
|
||||
const handleUpdateUser = async (id: number) => {
|
||||
try {
|
||||
await api.updateUser(id, { role: editRole, status: editStatus });
|
||||
setEditingUser(null);
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableUser = async (id: number) => {
|
||||
try {
|
||||
await api.deleteUser(id);
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ padding: 16 }}>
|
||||
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
|
||||
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>用户管理</h1>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
共 {users.length} 个用户
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
{/* Stats cards */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: '总用户', value: users.length, color: '#818cf8' },
|
||||
{ label: '管理员', value: users.filter((u) => u.role === 'admin').length, color: '#4ade80' },
|
||||
{ label: '已禁用', value: users.filter((u) => u.status === 'disabled').length, color: '#f87171' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} style={{
|
||||
flex: 1, padding: '14px 16px', borderRadius: 10,
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{stat.label}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Users table */}
|
||||
<div style={{
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
borderRadius: 12, border: '1px solid var(--color-border, #2a2a3e)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>ID</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>用户名</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>邮箱</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>角色</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>状态</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>注册时间</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--color-text-muted)' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ padding: 24, textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
暂无用户
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
|
||||
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: 12 }}>{user.id}</td>
|
||||
<td style={{ padding: '12px 16px' }}>{user.username}</td>
|
||||
<td style={{ padding: '12px 16px', color: 'var(--color-text-secondary)' }}>{user.email}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{editingUser === user.id ? (
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 8px', borderRadius: 4, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
||||
background: user.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
|
||||
color: user.role === 'admin' ? '#818cf8' : '#4ade80',
|
||||
}}>
|
||||
{user.role === 'admin' ? '管理员' : '用户'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
{editingUser === user.id ? (
|
||||
<select
|
||||
value={editStatus}
|
||||
onChange={(e) => setEditStatus(e.target.value)}
|
||||
style={{
|
||||
padding: '4px 8px', borderRadius: 4, fontSize: 12,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select>
|
||||
) : (
|
||||
<span style={{
|
||||
fontSize: 12, padding: '2px 8px', borderRadius: 4,
|
||||
background: user.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
||||
color: user.status === 'active' ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
{user.status === 'active' ? '正常' : '禁用'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{user.createdAt?.slice(0, 10) || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||
{editingUser === user.id ? (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => handleUpdateUser(user.id)} className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }}>保存</button>
|
||||
<button onClick={() => setEditingUser(null)} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 12px' }}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setEditingUser(user.id); setEditRole(user.role); setEditStatus(user.status); }}
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '4px 12px' }}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
{user.status !== 'disabled' && (
|
||||
<button onClick={() => handleDisableUser(user.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '4px 12px' }}>
|
||||
禁用
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { persistUserSession } from '../authSession.js';
|
||||
|
||||
export default function UserRegister() {
|
||||
const navigate = useNavigate();
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !email.trim() || !password) return;
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次密码不一致');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError('密码至少 6 个字符');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/users/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.trim(), email: email.trim(), password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data?.error || `注册失败 (${res.status})`);
|
||||
}
|
||||
const result = await res.json();
|
||||
persistUserSession(localStorage, result.token);
|
||||
navigate('/user/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '注册失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
|
||||
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
|
||||
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
|
||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||
<div className="brand-mark-canvas">
|
||||
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
|
||||
<div className="login-brand-name">BoosAPI</div>
|
||||
<div className="login-brand-kicker">用户注册</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-auth-stage">
|
||||
<div className="login-auth-panel">
|
||||
<h2 className="login-auth-title">创建账号</h2>
|
||||
<p className="login-auth-copy">注册后即可使用 API 代理服务。</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
|
||||
<input
|
||||
id="reg-username-input"
|
||||
type="text"
|
||||
placeholder="你的昵称"
|
||||
value={username}
|
||||
onChange={(e) => { setUsername(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-email-input" style={{ marginTop: 12 }}>邮箱</label>
|
||||
<input
|
||||
id="reg-email-input"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-password-input" style={{ marginTop: 12 }}>密码</label>
|
||||
<input
|
||||
id="reg-password-input"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="reg-confirm-password-input" style={{ marginTop: 12 }}>确认密码</label>
|
||||
<input
|
||||
id="reg-confirm-password-input"
|
||||
type="password"
|
||||
placeholder="再次输入密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 注册中...</> : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
|
||||
<span>
|
||||
已有账号?<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>登录</Link>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function normalizeVerifyFailureMessage(message: unknown): string {
|
||||
if (!text) return '验证失败';
|
||||
const lowered = text.toLowerCase();
|
||||
if (isNetworkFailureMessage(text)) {
|
||||
return '无法连接到 metapi 服务端,请检查服务状态或网络连接';
|
||||
return '无法连接到 BoosAPI 服务端,请检查服务状态或网络连接';
|
||||
}
|
||||
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
||||
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
||||
@@ -43,7 +43,7 @@ export function buildVerifyFailureHint(result: VerifyResultLike): string | null
|
||||
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。请检查 metapi 服务是否在线,以及目标站点或代理是否可达。';
|
||||
return '这不是 Token 错误判断。请检查 BoosAPI 服务是否在线,以及目标站点或代理是否可达。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
||||
@@ -63,7 +63,7 @@ export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
|
||||
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '验证请求未成功完成,请先检查 metapi 服务、站点网络或代理配置。';
|
||||
return '验证请求未成功完成,请先检查 BoosAPI 服务、站点网络或代理配置。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||
|
||||
@@ -216,7 +216,7 @@ const VALID_PROTOCOLS: ReadonlySet<string> = new Set(['openai', 'responses', 'cl
|
||||
const VALID_CONVERSATION_DRAFT_STATUSES: ReadonlySet<string> = new Set(['pending', 'uploading', 'uploaded', 'error']);
|
||||
const VALID_PROXY_METHODS: ReadonlySet<string> = new Set(['POST', 'GET', 'DELETE']);
|
||||
const VALID_REQUEST_KINDS: ReadonlySet<string> = new Set(['json', 'multipart', 'empty']);
|
||||
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
|
||||
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-boosapi-';
|
||||
|
||||
let messageCounter = 0;
|
||||
|
||||
|
||||
@@ -43,10 +43,10 @@ export default function FactoryResetModal({
|
||||
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||
这是不可逆操作。系统会清空当前 BoosAPI 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
|
||||
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据。</div>
|
||||
<div>• 系统随后会强制切回默认 SQLite。</div>
|
||||
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
|
||||
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function ModelAvailabilityProbeConfirmModal({
|
||||
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
|
||||
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
|
||||
开启后,metapi 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
||||
开启后,BoosAPI 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
|
||||
请手动输入以下整句后再开启:
|
||||
|
||||
@@ -89,7 +89,7 @@ const DEFAULT_CONFIG: NonNullable<UpdateCenterStatus['config']> = {
|
||||
namespace: 'default',
|
||||
releaseName: '',
|
||||
chartRef: '',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageRepository: '1467078763/boosapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
@@ -614,7 +614,7 @@ export default function UpdateCenterSection() {
|
||||
value={config.helperBaseUrl}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, helperBaseUrl: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="http://metapi-deploy-helper.namespace.svc.cluster.local:9850"
|
||||
placeholder="http://boosapi-deploy-helper.namespace.svc.cluster.local:9850"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -643,7 +643,7 @@ export default function UpdateCenterSection() {
|
||||
value={config.releaseName}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, releaseName: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="metapi"
|
||||
placeholder="boosapi"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -652,7 +652,7 @@ export default function UpdateCenterSection() {
|
||||
value={config.chartRef}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, chartRef: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="oci://ghcr.io/cita-777/charts/metapi"
|
||||
placeholder="oci://ghcr.io/cita-777/charts/boosapi"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -661,7 +661,7 @@ export default function UpdateCenterSection() {
|
||||
value={config.imageRepository}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, imageRepository: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="1467078763/metapi"
|
||||
placeholder="1467078763/boosapi"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user