[codex] add coding plan site initialization flow (#351)
* add coding plan site initialization flow * fix ci for coding plan site init flow * address coding plan review comments
This commit is contained in:
@@ -259,4 +259,227 @@ describe('sites proxy settings', () => {
|
|||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
expect((response.json() as { error?: string }).error).toContain('must use a string value');
|
expect((response.json() as { error?: string }).error).toContain('must use a string value');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects create payloads whose name is not a string', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 123,
|
||||||
|
url: 'https://typed-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect((response.json() as { error?: string }).error).toContain('Invalid name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects update payloads whose url is not a string', async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'typed-site',
|
||||||
|
url: 'https://typed-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
const site = created.json() as { id: number };
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/sites/${site.id}`,
|
||||||
|
payload: {
|
||||||
|
url: 123,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect((response.json() as { error?: string }).error).toContain('Invalid url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects create payloads that are not objects', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect((response.json() as { error?: string }).error).toContain('Invalid site payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects update payloads that are not objects', async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'typed-site',
|
||||||
|
url: 'https://typed-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
const site = created.json() as { id: number };
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/sites/${site.id}`,
|
||||||
|
payload: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect((response.json() as { error?: string }).error).toContain('Invalid site payload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Aliyun CodingPlan endpoints with initialization preset metadata', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites/detect',
|
||||||
|
payload: {
|
||||||
|
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
platform: 'openai',
|
||||||
|
initializationPresetId: 'codingplan-openai',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates CodingPlan sites without explicit platform by using preset-backed detection', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'Aliyun CodingPlan',
|
||||||
|
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
name: 'Aliyun CodingPlan',
|
||||||
|
platform: 'openai',
|
||||||
|
initializationPresetId: 'codingplan-openai',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not force CodingPlan preset metadata when the user explicitly chooses generic openai', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'Aliyun Generic OpenAI',
|
||||||
|
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||||
|
platform: 'openai',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
name: 'Aliyun Generic OpenAI',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
expect(response.json()).not.toHaveProperty('initializationPresetId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves explicit preset metadata even when the preset uses a custom gateway url', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'Aliyun CodingPlan Gateway',
|
||||||
|
url: 'https://gateway.example.com/coding/v1',
|
||||||
|
platform: 'openai',
|
||||||
|
initializationPresetId: 'codingplan-openai',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
name: 'Aliyun CodingPlan Gateway',
|
||||||
|
platform: 'openai',
|
||||||
|
initializationPresetId: 'codingplan-openai',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes create payload url and platform before persistence and conflict checks', async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'Aliyun Generic OpenAI',
|
||||||
|
url: ' coding.dashscope.aliyuncs.com/v1/ ',
|
||||||
|
platform: ' OPENAI ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
expect(created.json()).toMatchObject({
|
||||||
|
name: 'Aliyun Generic OpenAI',
|
||||||
|
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicate = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'Aliyun Generic OpenAI Duplicate',
|
||||||
|
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||||
|
platform: 'openai',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(duplicate.statusCode).toBe(409);
|
||||||
|
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canonicalizes update payload url and platform before saving', async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'Update Canonicalization',
|
||||||
|
url: 'https://update-canonicalization.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/sites/${(created.json() as { id: number }).id}`,
|
||||||
|
payload: {
|
||||||
|
url: ' coding.dashscope.aliyuncs.com/v1/ ',
|
||||||
|
platform: ' OPENAI ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Zhipu Coding Plan OpenAI endpoint with initialization preset metadata', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites/detect',
|
||||||
|
payload: {
|
||||||
|
url: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
platform: 'openai',
|
||||||
|
initializationPresetId: 'zhipu-coding-plan-openai',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
|||||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||||
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
|
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||||
|
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
|
||||||
|
import { stripTrailingSlashes } from '../../services/urlNormalization.js';
|
||||||
|
|
||||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||||
if (input === undefined || input === null) return null;
|
if (input === undefined || input === null) return null;
|
||||||
@@ -83,7 +85,14 @@ type ErrorLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function normalizeSiteUrl(url: string): string {
|
function normalizeSiteUrl(url: string): string {
|
||||||
return url.replace(/\/+$/, '');
|
const trimmed = url.trim();
|
||||||
|
const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`;
|
||||||
|
return stripTrailingSlashes(withScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSitePlatform(platform?: string): string | undefined {
|
||||||
|
if (platform === undefined) return undefined;
|
||||||
|
return platform.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getErrorChain(error: unknown): ErrorLike[] {
|
function getErrorChain(error: unknown): ErrorLike[] {
|
||||||
@@ -280,6 +289,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
initializationPresetId?: string | null;
|
||||||
proxyUrl?: string | null;
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
@@ -289,7 +299,32 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
} }>('/api/sites', async (request, reply) => {
|
} }>('/api/sites', async (request, reply) => {
|
||||||
const { name, url, platform, proxyUrl, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid site payload. Expected object.' });
|
||||||
|
}
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
platform,
|
||||||
|
initializationPresetId,
|
||||||
|
proxyUrl,
|
||||||
|
useSystemProxy,
|
||||||
|
customHeaders,
|
||||||
|
externalCheckinUrl,
|
||||||
|
status,
|
||||||
|
isPinned,
|
||||||
|
sortOrder,
|
||||||
|
globalWeight,
|
||||||
|
} = request.body;
|
||||||
|
if (typeof name !== 'string' || !name.trim()) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' });
|
||||||
|
}
|
||||||
|
if (typeof url !== 'string' || !url.trim()) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' });
|
||||||
|
}
|
||||||
|
if (platform !== undefined && typeof platform !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'Invalid platform. Expected string.' });
|
||||||
|
}
|
||||||
const normalizedStatus = normalizeSiteStatus(status);
|
const normalizedStatus = normalizeSiteStatus(status);
|
||||||
if (status !== undefined && !normalizedStatus) {
|
if (status !== undefined && !normalizedStatus) {
|
||||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||||
@@ -322,29 +357,45 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (!normalizedCustomHeaders.valid) {
|
if (!normalizedCustomHeaders.valid) {
|
||||||
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||||
}
|
}
|
||||||
|
const explicitInitializationPreset = initializationPresetId == null || initializationPresetId === ''
|
||||||
|
? null
|
||||||
|
: getSiteInitializationPreset(initializationPresetId);
|
||||||
|
if (initializationPresetId != null && initializationPresetId !== '' && !explicitInitializationPreset) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid initializationPresetId.' });
|
||||||
|
}
|
||||||
|
|
||||||
const existingSites = await db.select().from(schema.sites).all();
|
const existingSites = await db.select().from(schema.sites).all();
|
||||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||||
const normalizedUrl = normalizeSiteUrl(url);
|
const canonicalUrl = normalizeSiteUrl(url);
|
||||||
|
const canonicalPlatform = normalizeSitePlatform(platform);
|
||||||
|
|
||||||
let detectedPlatform = platform;
|
let detectedPlatform = canonicalPlatform;
|
||||||
|
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
|
||||||
if (!detectedPlatform) {
|
if (!detectedPlatform) {
|
||||||
const detected = await detectSite(url);
|
if (explicitInitializationPreset) {
|
||||||
|
detectedPlatform = explicitInitializationPreset.platform;
|
||||||
|
} else {
|
||||||
|
const detected = await detectSite(canonicalUrl);
|
||||||
detectedPlatform = detected?.platform;
|
detectedPlatform = detected?.platform;
|
||||||
|
responseInitializationPresetId = detected?.initializationPresetId || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (explicitInitializationPreset && explicitInitializationPreset.platform !== detectedPlatform) {
|
||||||
|
return reply.code(400).send({ error: 'initializationPresetId does not match the selected platform.' });
|
||||||
}
|
}
|
||||||
if (!detectedPlatform) {
|
if (!detectedPlatform) {
|
||||||
return { error: 'Could not detect platform. Please specify manually.' };
|
return { error: 'Could not detect platform. Please specify manually.' };
|
||||||
}
|
}
|
||||||
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl);
|
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, canonicalUrl);
|
||||||
if (conflictingSite) {
|
if (conflictingSite) {
|
||||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
let inserted;
|
let inserted;
|
||||||
try {
|
try {
|
||||||
inserted = await db.insert(schema.sites).values({
|
inserted = await db.insert(schema.sites).values({
|
||||||
name,
|
name,
|
||||||
url: normalizedUrl,
|
url: canonicalUrl,
|
||||||
platform: detectedPlatform,
|
platform: detectedPlatform,
|
||||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||||
@@ -357,7 +408,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
}).run();
|
}).run();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isSitesPlatformUrlConflict(error)) {
|
if (isSitesPlatformUrlConflict(error)) {
|
||||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -370,7 +421,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(500).send({ error: 'Create site failed' });
|
return reply.code(500).send({ error: 'Create site failed' });
|
||||||
}
|
}
|
||||||
invalidateSiteCaches();
|
invalidateSiteCaches();
|
||||||
return result;
|
return {
|
||||||
|
...result,
|
||||||
|
...(responseInitializationPresetId ? { initializationPresetId: responseInitializationPresetId } : {}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a site
|
// Update a site
|
||||||
@@ -398,7 +452,19 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
|
if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid site payload. Expected object.' });
|
||||||
|
}
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
|
if (body.name !== undefined && (typeof body.name !== 'string' || !body.name.trim())) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' });
|
||||||
|
}
|
||||||
|
if (body.url !== undefined && (typeof body.url !== 'string' || !body.url.trim())) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' });
|
||||||
|
}
|
||||||
|
if (body.platform !== undefined && typeof body.platform !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'Invalid platform. Expected string.' });
|
||||||
|
}
|
||||||
const normalizedStatus = normalizeSiteStatus(body.status);
|
const normalizedStatus = normalizeSiteStatus(body.status);
|
||||||
if (body.status !== undefined && !normalizedStatus) {
|
if (body.status !== undefined && !normalizedStatus) {
|
||||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||||
@@ -432,8 +498,9 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canonicalPlatform = body.platform !== undefined ? normalizeSitePlatform(body.platform) : undefined;
|
||||||
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
|
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
|
||||||
const nextPlatform = body.platform !== undefined ? body.platform : existingSite.platform;
|
const nextPlatform = canonicalPlatform !== undefined ? canonicalPlatform : existingSite.platform;
|
||||||
const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform;
|
const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform;
|
||||||
if (siteIdentityChanged) {
|
if (siteIdentityChanged) {
|
||||||
const siteRows = await db.select({
|
const siteRows = await db.select({
|
||||||
@@ -449,7 +516,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (body.name !== undefined) updates.name = body.name;
|
if (body.name !== undefined) updates.name = body.name;
|
||||||
if (body.url !== undefined) updates.url = nextUrl;
|
if (body.url !== undefined) updates.url = nextUrl;
|
||||||
if (body.platform !== undefined) updates.platform = body.platform;
|
if (body.platform !== undefined) updates.platform = canonicalPlatform;
|
||||||
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||||
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
type SiteAnnouncement,
|
type SiteAnnouncement,
|
||||||
UserInfo,
|
UserInfo,
|
||||||
} from './base.js';
|
} from './base.js';
|
||||||
|
import { stripTrailingSlashes } from '../urlNormalization.js';
|
||||||
|
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
return stripTrailingSlashes(baseUrl || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { stripTrailingSlashes } from '../urlNormalization.js';
|
||||||
|
|
||||||
export type TitleHintPlatform =
|
export type TitleHintPlatform =
|
||||||
| 'anyrouter'
|
| 'anyrouter'
|
||||||
| 'done-hub'
|
| 'done-hub'
|
||||||
@@ -34,7 +36,7 @@ function normalizeBaseUrl(url: string): string {
|
|||||||
const parsed = new URL(trimmed);
|
const parsed = new URL(trimmed);
|
||||||
return `${parsed.protocol}//${parsed.host}`;
|
return `${parsed.protocol}//${parsed.host}`;
|
||||||
} catch {
|
} catch {
|
||||||
return trimmed.replace(/\/+$/, '');
|
return stripTrailingSlashes(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { detectPlatform } from './platforms/index.js';
|
import { detectPlatform } from './platforms/index.js';
|
||||||
|
import { detectSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
|
||||||
|
import { stripTrailingSlashes } from './urlNormalization.js';
|
||||||
|
|
||||||
export async function detectSite(url: string) {
|
export async function detectSite(url: string) {
|
||||||
const normalizedUrl = url.replace(/\/+$/, '');
|
const normalizedUrl = stripTrailingSlashes(url);
|
||||||
|
const preset = detectSiteInitializationPreset(normalizedUrl);
|
||||||
|
if (preset) {
|
||||||
|
return {
|
||||||
|
url: normalizedUrl,
|
||||||
|
platform: preset.platform,
|
||||||
|
initializationPresetId: preset.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
const adapter = await detectPlatform(normalizedUrl);
|
const adapter = await detectPlatform(normalizedUrl);
|
||||||
if (!adapter) return null;
|
if (!adapter) return null;
|
||||||
return { url: normalizedUrl, platform: adapter.platformName };
|
return { url: normalizedUrl, platform: adapter.platformName };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
|
|||||||
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
|
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
|
||||||
import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js';
|
import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js';
|
||||||
import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js';
|
import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js';
|
||||||
|
import { stripTrailingSlashes } from './urlNormalization.js';
|
||||||
|
|
||||||
const SITE_PROXY_CACHE_TTL_MS = 3_000;
|
const SITE_PROXY_CACHE_TTL_MS = 3_000;
|
||||||
const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
||||||
@@ -100,11 +101,10 @@ function normalizeSiteUrl(value: string): string {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(trimmed);
|
const parsed = new URL(trimmed);
|
||||||
const pathname = parsed.pathname.replace(/\/+$/, '');
|
const normalizedPath = stripTrailingSlashes(parsed.pathname);
|
||||||
const normalizedPath = pathname === '/' ? '' : pathname;
|
|
||||||
return `${parsed.origin}${normalizedPath}`;
|
return `${parsed.origin}${normalizedPath}`;
|
||||||
} catch {
|
} catch {
|
||||||
return trimmed.replace(/\/+$/, '');
|
return stripTrailingSlashes(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { stripTrailingSlashes } from './urlNormalization.js';
|
||||||
|
|
||||||
|
describe('stripTrailingSlashes', () => {
|
||||||
|
it('removes trailing slashes without using regex behavior', () => {
|
||||||
|
expect(stripTrailingSlashes('')).toBe('');
|
||||||
|
expect(stripTrailingSlashes('https://api.example.com')).toBe('https://api.example.com');
|
||||||
|
expect(stripTrailingSlashes('https://api.example.com///')).toBe('https://api.example.com');
|
||||||
|
expect(stripTrailingSlashes('/api/v1///')).toBe('/api/v1');
|
||||||
|
expect(stripTrailingSlashes('////')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function stripTrailingSlashes(value: string): string {
|
||||||
|
let end = value.length;
|
||||||
|
while (end > 0 && value.charCodeAt(end - 1) === 47) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
return end === value.length ? value : value.slice(0, end);
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
export type SiteInitializationPresetId =
|
||||||
|
| 'codingplan-openai'
|
||||||
|
| 'codingplan-claude'
|
||||||
|
| 'zhipu-coding-plan-openai'
|
||||||
|
| 'zhipu-coding-plan-claude';
|
||||||
|
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 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;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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) {
|
||||||
|
const parsed = parseUrlCandidate(url);
|
||||||
|
if (!parsed) return false;
|
||||||
|
return parsed.hostname === 'coding.dashscope.aliyuncs.com'
|
||||||
|
&& normalizePathname(parsed.pathname) === '/v1';
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
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) {
|
||||||
|
const parsed = parseUrlCandidate(url);
|
||||||
|
if (!parsed) return false;
|
||||||
|
const pathname = normalizePathname(parsed.pathname);
|
||||||
|
return parsed.hostname === 'coding.dashscope.aliyuncs.com'
|
||||||
|
&& (pathname === '/apps/anthropic' || pathname.startsWith('/apps/anthropic/'));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
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) {
|
||||||
|
const parsed = parseUrlCandidate(url);
|
||||||
|
if (!parsed) return false;
|
||||||
|
return parsed.hostname === 'open.bigmodel.cn'
|
||||||
|
&& normalizePathname(parsed.pathname) === '/api/coding/paas/v4';
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function clonePreset(preset) {
|
||||||
|
if (!preset) return null;
|
||||||
|
return {
|
||||||
|
...preset,
|
||||||
|
recommendedModels: [...preset.recommendedModels],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSiteInitializationPresets() {
|
||||||
|
return SITE_INITIALIZATION_PRESETS.map((preset) => clonePreset(preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
detectSiteInitializationPreset,
|
||||||
|
getSiteInitializationPreset,
|
||||||
|
listSiteInitializationPresets,
|
||||||
|
} from './siteInitializationPresets.js';
|
||||||
|
|
||||||
|
describe('siteInitializationPresets', () => {
|
||||||
|
it('exposes CodingPlan presets with recommended API-key-first initialization', () => {
|
||||||
|
const presetIds = listSiteInitializationPresets().map((preset) => preset.id);
|
||||||
|
expect(presetIds).toEqual(expect.arrayContaining([
|
||||||
|
'codingplan-openai',
|
||||||
|
'codingplan-claude',
|
||||||
|
'zhipu-coding-plan-openai',
|
||||||
|
'zhipu-coding-plan-claude',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const openaiPreset = getSiteInitializationPreset('codingplan-openai');
|
||||||
|
expect(openaiPreset).toMatchObject({
|
||||||
|
id: 'codingplan-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(openaiPreset?.recommendedModels).toEqual(expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus']));
|
||||||
|
|
||||||
|
const claudePreset = getSiteInitializationPreset('codingplan-claude');
|
||||||
|
expect(claudePreset).toMatchObject({
|
||||||
|
id: 'codingplan-claude',
|
||||||
|
platform: 'claude',
|
||||||
|
defaultUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(claudePreset?.recommendedModels).toEqual(expect.arrayContaining(['qwen3-coder-next', 'glm-5']));
|
||||||
|
|
||||||
|
const zhipuOpenAiPreset = getSiteInitializationPreset('zhipu-coding-plan-openai');
|
||||||
|
expect(zhipuOpenAiPreset).toMatchObject({
|
||||||
|
id: 'zhipu-coding-plan-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(zhipuOpenAiPreset?.recommendedModels).toEqual(['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air']);
|
||||||
|
|
||||||
|
const zhipuClaudePreset = getSiteInitializationPreset('zhipu-coding-plan-claude');
|
||||||
|
expect(zhipuClaudePreset).toMatchObject({
|
||||||
|
id: 'zhipu-coding-plan-claude',
|
||||||
|
platform: 'claude',
|
||||||
|
defaultUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(zhipuClaudePreset?.recommendedModels).toEqual(['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Aliyun CodingPlan endpoints by URL', () => {
|
||||||
|
expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com/v1')).toMatchObject({
|
||||||
|
id: 'codingplan-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com/apps/anthropic')).toMatchObject({
|
||||||
|
id: 'codingplan-claude',
|
||||||
|
platform: 'claude',
|
||||||
|
});
|
||||||
|
expect(detectSiteInitializationPreset('https://api.openai.com/v1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Zhipu Coding Plan OpenAI endpoints by URL but keeps Claude-compatible entry manual-only', () => {
|
||||||
|
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/coding/paas/v4')).toMatchObject({
|
||||||
|
id: 'zhipu-coding-plan-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/coding/paas/v4/')).toMatchObject({
|
||||||
|
id: 'zhipu-coding-plan-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/anthropic')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,6 @@ describe('SiteCreatedModal', () => {
|
|||||||
const root = create(
|
const root = create(
|
||||||
<SiteCreatedModal
|
<SiteCreatedModal
|
||||||
siteName="Demo Site"
|
siteName="Demo Site"
|
||||||
platform="new-api"
|
|
||||||
onChoice={onChoice}
|
onChoice={onChoice}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
|
||||||
|
|
||||||
type NextStepChoice = 'session' | 'apikey' | 'later';
|
type NextStepChoice = 'session' | 'apikey' | 'later';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
platform?: string | null;
|
initializationPresetId?: string | null;
|
||||||
|
initialSegment?: 'session' | 'apikey';
|
||||||
onChoice: (choice: NextStepChoice) => void;
|
onChoice: (choice: NextStepChoice) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SiteCreatedModal({ siteName, platform, onChoice, onClose }: Props) {
|
export default function SiteCreatedModal({
|
||||||
|
siteName,
|
||||||
|
initializationPresetId,
|
||||||
|
initialSegment = 'session',
|
||||||
|
onChoice,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const preset = getSiteInitializationPreset(initializationPresetId);
|
||||||
|
const apiKeyFirst = initialSegment === 'apikey';
|
||||||
|
const helperText = preset?.description
|
||||||
|
|| (apiKeyFirst
|
||||||
|
? '该平台更适合直接通过 Base URL + API Key 接入,后续再补模型初始化。'
|
||||||
|
: '接下来您可以继续补充登录连接或 API Key。');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dialog = dialogRef.current;
|
const dialog = dialogRef.current;
|
||||||
@@ -27,14 +41,22 @@ export default function SiteCreatedModal({ siteName, platform, onChoice, onClose
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onChoice('later');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className="modal"
|
className="modal"
|
||||||
onCancel={(event) => {
|
onCancel={(e) => {
|
||||||
event.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === dialogRef.current) {
|
if (e.target === dialogRef.current) {
|
||||||
onChoice('later');
|
onChoice('later');
|
||||||
@@ -46,22 +68,46 @@ export default function SiteCreatedModal({ siteName, platform, onChoice, onClose
|
|||||||
站点创建成功
|
站点创建成功
|
||||||
</h3>
|
</h3>
|
||||||
<p className="py-2 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
<p className="py-2 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
站点 <strong>"{siteName}"</strong> 已添加成功。接下来您想做什么?
|
站点 <strong>"{siteName}"</strong> 已添加成功。
|
||||||
</p>
|
</p>
|
||||||
|
{preset && (
|
||||||
|
<div className="alert alert-info" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="alert-title">{preset.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.7 }}>
|
||||||
|
{helperText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!preset && (
|
||||||
|
<p className="py-1 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="modal-action" style={{ flexDirection: 'column', gap: 12 }}>
|
<div className="modal-action" style={{ flexDirection: 'column', gap: 12 }}>
|
||||||
|
{apiKeyFirst ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-block"
|
||||||
|
onClick={() => onChoice('apikey')}
|
||||||
|
>
|
||||||
|
添加 API Key(推荐)
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-block"
|
className="btn btn-primary btn-block"
|
||||||
onClick={() => onChoice('session')}
|
onClick={() => onChoice('session')}
|
||||||
>
|
>
|
||||||
添加账号(用户名密码登录)
|
添加账号(用户名密码登录)
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{!apiKeyFirst && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline btn-block"
|
className="btn btn-outline btn-block"
|
||||||
onClick={() => onChoice('apikey')}
|
onClick={() => onChoice('apikey')}
|
||||||
>
|
>
|
||||||
添加 API Key
|
添加 API Key
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-block"
|
className="btn btn-ghost btn-block"
|
||||||
onClick={() => onChoice('later')}
|
onClick={() => onChoice('later')}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { tr } from '../i18n.js';
|
|||||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||||
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
||||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||||
|
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
|
||||||
|
|
||||||
type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
|
type ConnectionsSegment = 'session' | 'apikey' | 'tokens';
|
||||||
|
|
||||||
@@ -88,6 +89,8 @@ export default function Accounts() {
|
|||||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||||
const [loginForm, setLoginForm] = useState(createLoginForm);
|
const [loginForm, setLoginForm] = useState(createLoginForm);
|
||||||
const [tokenForm, setTokenForm] = useState(() => createTokenForm('session'));
|
const [tokenForm, setTokenForm] = useState(() => createTokenForm('session'));
|
||||||
|
const [createIntentPresetId, setCreateIntentPresetId] = useState<string | null>(null);
|
||||||
|
const [applyCreatePresetModels, setApplyCreatePresetModels] = useState(false);
|
||||||
const [verifyResult, setVerifyResult] = useState<any>(null);
|
const [verifyResult, setVerifyResult] = useState<any>(null);
|
||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -187,11 +190,17 @@ export default function Accounts() {
|
|||||||
);
|
);
|
||||||
const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api';
|
const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api';
|
||||||
const activeAddCredentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
|
const activeAddCredentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
|
||||||
|
const createIntentPreset = useMemo(
|
||||||
|
() => getSiteInitializationPreset(createIntentPresetId),
|
||||||
|
[createIntentPresetId],
|
||||||
|
);
|
||||||
|
|
||||||
const resetAddForms = (credentialMode: 'session' | 'apikey' = activeAddCredentialMode) => {
|
const resetAddForms = (credentialMode: 'session' | 'apikey' = activeAddCredentialMode) => {
|
||||||
setAddMode('token');
|
setAddMode('token');
|
||||||
setLoginForm(createLoginForm());
|
setLoginForm(createLoginForm());
|
||||||
setTokenForm(createTokenForm(credentialMode));
|
setTokenForm(createTokenForm(credentialMode));
|
||||||
|
setCreateIntentPresetId(null);
|
||||||
|
setApplyCreatePresetModels(false);
|
||||||
setVerifyResult(null);
|
setVerifyResult(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -254,18 +263,23 @@ export default function Accounts() {
|
|||||||
if (!shouldOpenCreate || !requestedSiteId) return;
|
if (!shouldOpenCreate || !requestedSiteId) return;
|
||||||
|
|
||||||
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
|
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
|
||||||
|
const initializationPreset = getSiteInitializationPreset(params.get('initPreset'));
|
||||||
setShowAdd(true);
|
setShowAdd(true);
|
||||||
setAddMode('token');
|
setAddMode('token');
|
||||||
setVerifyResult(null);
|
setVerifyResult(null);
|
||||||
|
setCreateIntentPresetId(initializationPreset?.id || null);
|
||||||
|
setApplyCreatePresetModels(Boolean(initializationPreset?.recommendedModels?.length));
|
||||||
setLoginForm(createLoginForm());
|
setLoginForm(createLoginForm());
|
||||||
setTokenForm({
|
setTokenForm({
|
||||||
...createTokenForm(credentialMode),
|
...createTokenForm(credentialMode),
|
||||||
siteId: requestedSiteId,
|
siteId: requestedSiteId,
|
||||||
|
skipModelFetch: credentialMode === 'apikey' && initializationPreset?.recommendedSkipModelFetch === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
params.delete('create');
|
params.delete('create');
|
||||||
params.delete('siteId');
|
params.delete('siteId');
|
||||||
params.delete('from');
|
params.delete('from');
|
||||||
|
params.delete('initPreset');
|
||||||
const nextSearch = params.toString();
|
const nextSearch = params.toString();
|
||||||
navigate(
|
navigate(
|
||||||
{
|
{
|
||||||
@@ -343,6 +357,7 @@ export default function Accounts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
|
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
|
||||||
|
const initializationPreset = createIntentPreset;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.addAccount({
|
const result = await api.addAccount({
|
||||||
@@ -359,6 +374,24 @@ export default function Accounts() {
|
|||||||
credentialMode,
|
credentialMode,
|
||||||
skipModelFetch: tokenForm.skipModelFetch,
|
skipModelFetch: tokenForm.skipModelFetch,
|
||||||
});
|
});
|
||||||
|
let seededRecommendedModels = false;
|
||||||
|
const recommendedModels = initializationPreset?.recommendedModels || [];
|
||||||
|
const createdAccountId = Number(result?.id) || 0;
|
||||||
|
const shouldSeedRecommendedModels = (
|
||||||
|
credentialMode === 'apikey'
|
||||||
|
&& tokenForm.skipModelFetch
|
||||||
|
&& applyCreatePresetModels
|
||||||
|
&& recommendedModels.length > 0
|
||||||
|
&& createdAccountId > 0
|
||||||
|
);
|
||||||
|
if (shouldSeedRecommendedModels) {
|
||||||
|
try {
|
||||||
|
await api.addAccountAvailableModels(createdAccountId, recommendedModels);
|
||||||
|
seededRecommendedModels = true;
|
||||||
|
} catch (seedErr: any) {
|
||||||
|
toast.error(seedErr?.message || '连接已添加,但推荐模型补录失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
closeAddPanel();
|
closeAddPanel();
|
||||||
if (result.queued) {
|
if (result.queued) {
|
||||||
toast.info(result.message || '账号已添加,后台正在同步初始化信息。');
|
toast.info(result.message || '账号已添加,后台正在同步初始化信息。');
|
||||||
@@ -371,6 +404,9 @@ export default function Accounts() {
|
|||||||
const extra = parts.length ? `(${parts.join(',')})` : '';
|
const extra = parts.length ? `(${parts.join(',')})` : '';
|
||||||
toast.success(`账号已添加${extra}`);
|
toast.success(`账号已添加${extra}`);
|
||||||
}
|
}
|
||||||
|
if (seededRecommendedModels) {
|
||||||
|
toast.success(`已补入 ${recommendedModels.length} 个推荐模型并重建路由`);
|
||||||
|
}
|
||||||
load();
|
load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || '添加失败');
|
toast.error(e.message || '添加失败');
|
||||||
@@ -1330,12 +1366,39 @@ export default function Accounts() {
|
|||||||
<div className="info-tip">
|
<div className="info-tip">
|
||||||
API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
|
API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
|
||||||
</div>
|
</div>
|
||||||
|
{createIntentPreset && (
|
||||||
|
<div className="alert alert-info animate-scale-in">
|
||||||
|
<div className="alert-title">{createIntentPreset.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.8 }}>
|
||||||
|
<div>{createIntentPreset.description}</div>
|
||||||
|
<div>推荐模型:{createIntentPreset.recommendedModels.join(' / ')}</div>
|
||||||
|
{createIntentPreset.recommendedSkipModelFetch && (
|
||||||
|
<div>建议直接跳过模型验证,先保存 Base URL + Key,再补入推荐模型完成初始化。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{createIntentPreset.recommendedModels.length > 0 && (
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, cursor: 'pointer', marginTop: 8 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={applyCreatePresetModels}
|
||||||
|
onChange={(e) => setApplyCreatePresetModels(e.target.checked)}
|
||||||
|
style={{ width: 14, height: 14 }}
|
||||||
|
/>
|
||||||
|
<span>添加后自动补入推荐模型并重建路由</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
value={String(tokenForm.siteId || 0)}
|
value={String(tokenForm.siteId || 0)}
|
||||||
onChange={(nextValue) => {
|
onChange={(nextValue) => {
|
||||||
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
|
const nextSiteId = Number.parseInt(nextValue, 10) || 0;
|
||||||
setTokenForm((f) => ({ ...f, siteId: nextSiteId, credentialMode: 'apikey' }));
|
setTokenForm((f) => ({ ...f, siteId: nextSiteId, credentialMode: 'apikey' }));
|
||||||
setVerifyResult(null);
|
setVerifyResult(null);
|
||||||
|
if (createIntentPresetId && nextSiteId !== tokenForm.siteId) {
|
||||||
|
setCreateIntentPresetId(null);
|
||||||
|
setApplyCreatePresetModels(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
options={siteSelectOptions}
|
options={siteSelectOptions}
|
||||||
placeholder="选择站点"
|
placeholder="选择站点"
|
||||||
|
|||||||
+108
-25
@@ -27,6 +27,10 @@ import {
|
|||||||
type SiteEditorState,
|
type SiteEditorState,
|
||||||
type SiteForm,
|
type SiteForm,
|
||||||
} from './helpers/sitesEditor.js';
|
} from './helpers/sitesEditor.js';
|
||||||
|
import {
|
||||||
|
getSiteInitializationPreset,
|
||||||
|
listSiteInitializationPresets,
|
||||||
|
} from '../../shared/siteInitializationPresets.js';
|
||||||
|
|
||||||
type SiteSubscriptionSummary = {
|
type SiteSubscriptionSummary = {
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
@@ -176,18 +180,18 @@ const platformColors: Record<string, string> = {
|
|||||||
|
|
||||||
const SITE_PLATFORM_OPTIONS = [
|
const SITE_PLATFORM_OPTIONS = [
|
||||||
{ value: '', label: '平台类型(可自动检测)' },
|
{ value: '', label: '平台类型(可自动检测)' },
|
||||||
{ value: 'new-api', label: 'new-api' },
|
{ value: 'new-api', label: 'new-api', description: '聚合面板,适合多渠道统一管理' },
|
||||||
{ value: 'one-api', label: 'one-api' },
|
{ value: 'one-api', label: 'one-api', description: '经典聚合面板,常见于通用 OpenAI 中转' },
|
||||||
{ value: 'anyrouter', label: 'anyrouter' },
|
{ value: 'anyrouter', label: 'anyrouter', description: 'any大善人今天还能用吗' },
|
||||||
{ value: 'veloera', label: 'veloera' },
|
{ value: 'veloera', label: 'veloera', description: 'Veloera 兼容站点,常见于聚合代理场景' },
|
||||||
{ value: 'one-hub', label: 'one-hub' },
|
{ value: 'one-hub', label: 'one-hub', description: '聚合面板,偏向多账号统一管理' },
|
||||||
{ value: 'done-hub', label: 'done-hub' },
|
{ value: 'done-hub', label: 'done-hub', description: '聚合面板,适合统一转发与管理' },
|
||||||
{ value: 'sub2api', label: 'sub2api' },
|
{ value: 'sub2api', label: 'sub2api', description: '订阅式中转面板,可同步套餐与余额信息' },
|
||||||
{ value: 'openai', label: 'openai' },
|
{ value: 'openai', label: 'openai', description: '通用 OpenAI 兼容接口,手填 Base URL 即可' },
|
||||||
{ value: 'codex', label: 'codex' },
|
{ value: 'codex', label: 'codex', description: 'Codex OAuth / Session 优先入口' },
|
||||||
{ value: 'claude', label: 'claude' },
|
{ value: 'claude', label: 'claude', description: '通用 Claude / Anthropic 兼容接口' },
|
||||||
{ value: 'gemini', label: 'gemini' },
|
{ value: 'gemini', label: 'gemini', description: '通用 Gemini / Google AI 兼容接口' },
|
||||||
{ value: 'cliproxyapi', label: 'cliproxyapi' },
|
{ value: 'cliproxyapi', label: 'cliproxyapi', description: 'CPA接入口' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Sites() {
|
export default function Sites() {
|
||||||
@@ -211,7 +215,9 @@ export default function Sites() {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
platform?: string | null;
|
platform?: string | null;
|
||||||
|
initializationPresetId?: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [selectedInitializationPresetId, setSelectedInitializationPresetId] = useState<string | null>(null);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [showMobileTools, setShowMobileTools] = useState(false);
|
const [showMobileTools, setShowMobileTools] = useState(false);
|
||||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||||
@@ -232,6 +238,11 @@ export default function Sites() {
|
|||||||
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
|
const [disabledModelsSaving, setDisabledModelsSaving] = useState(false);
|
||||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
const [disabledModelSearch, setDisabledModelSearch] = useState('');
|
const [disabledModelSearch, setDisabledModelSearch] = useState('');
|
||||||
|
const initializationPresetOptions = useMemo(() => listSiteInitializationPresets(), []);
|
||||||
|
const selectedInitializationPreset = useMemo(
|
||||||
|
() => getSiteInitializationPreset(selectedInitializationPresetId),
|
||||||
|
[selectedInitializationPresetId],
|
||||||
|
);
|
||||||
|
|
||||||
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
||||||
|
|
||||||
@@ -298,14 +309,28 @@ export default function Sites() {
|
|||||||
|
|
||||||
const platformOptions = useMemo(() => {
|
const platformOptions = useMemo(() => {
|
||||||
const current = form.platform.trim();
|
const current = form.platform.trim();
|
||||||
if (!current || SITE_PLATFORM_OPTIONS.some((option) => option.value === current)) {
|
const genericOptions = (!current || SITE_PLATFORM_OPTIONS.some((option) => option.value === current))
|
||||||
return SITE_PLATFORM_OPTIONS;
|
? SITE_PLATFORM_OPTIONS
|
||||||
}
|
: [
|
||||||
return [
|
|
||||||
...SITE_PLATFORM_OPTIONS,
|
...SITE_PLATFORM_OPTIONS,
|
||||||
{ value: current, label: `${current}(当前值)` },
|
{ value: current, label: `${current}(当前值)` },
|
||||||
];
|
];
|
||||||
}, [form.platform]);
|
const presetOptions = initializationPresetOptions.map((preset) => ({
|
||||||
|
value: `preset:${preset.id}`,
|
||||||
|
label: preset.label,
|
||||||
|
description: [
|
||||||
|
preset.defaultUrl ? '自动填充官方地址' : '',
|
||||||
|
preset.recommendedSkipModelFetch ? 'API Key 优先初始化' : '',
|
||||||
|
].filter(Boolean).join(' · '),
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
genericOptions[0]!,
|
||||||
|
...presetOptions,
|
||||||
|
...genericOptions.slice(1),
|
||||||
|
];
|
||||||
|
}, [form.platform, initializationPresetOptions]);
|
||||||
|
const activeInitializationPreset = selectedInitializationPreset;
|
||||||
|
const platformSelectValue = selectedInitializationPreset ? `preset:${selectedInitializationPreset.id}` : form.platform;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -339,6 +364,7 @@ export default function Sites() {
|
|||||||
const closeEditor = () => {
|
const closeEditor = () => {
|
||||||
setEditor(null);
|
setEditor(null);
|
||||||
setForm(emptySiteForm());
|
setForm(emptySiteForm());
|
||||||
|
setSelectedInitializationPresetId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToEditorTop = () => {
|
const scrollToEditorTop = () => {
|
||||||
@@ -355,12 +381,14 @@ export default function Sites() {
|
|||||||
}
|
}
|
||||||
setEditor({ mode: 'add' });
|
setEditor({ mode: 'add' });
|
||||||
setForm(emptySiteForm());
|
setForm(emptySiteForm());
|
||||||
|
setSelectedInitializationPresetId(null);
|
||||||
scrollToEditorTop();
|
scrollToEditorTop();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (site: SiteRow) => {
|
const openEdit = (site: SiteRow) => {
|
||||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||||
setForm(siteFormFromSite(site));
|
setForm(siteFormFromSite(site));
|
||||||
|
setSelectedInitializationPresetId(null);
|
||||||
scrollToEditorTop();
|
scrollToEditorTop();
|
||||||
// Load disabled models and discovered models independently so a best-effort
|
// Load disabled models and discovered models independently so a best-effort
|
||||||
// availability fetch cannot wipe the existing disabled-model state.
|
// availability fetch cannot wipe the existing disabled-model state.
|
||||||
@@ -448,6 +476,7 @@ export default function Sites() {
|
|||||||
url: form.url.trim(),
|
url: form.url.trim(),
|
||||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||||
platform: form.platform.trim(),
|
platform: form.platform.trim(),
|
||||||
|
initializationPresetId: selectedInitializationPresetId,
|
||||||
proxyUrl: form.proxyUrl.trim(),
|
proxyUrl: form.proxyUrl.trim(),
|
||||||
useSystemProxy: !!form.useSystemProxy,
|
useSystemProxy: !!form.useSystemProxy,
|
||||||
customHeaders: serializedCustomHeaders.customHeaders,
|
customHeaders: serializedCustomHeaders.customHeaders,
|
||||||
@@ -466,14 +495,18 @@ export default function Sites() {
|
|||||||
toast.success(`站点 "${payload.name}" 已添加`);
|
toast.success(`站点 "${payload.name}" 已添加`);
|
||||||
const createdSiteId = Number(created?.id) || 0;
|
const createdSiteId = Number(created?.id) || 0;
|
||||||
if (createdSiteId > 0) {
|
if (createdSiteId > 0) {
|
||||||
// 所有平台都弹出选择Modal,让用户决定下一步
|
|
||||||
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
|
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
|
||||||
? created.platform.trim()
|
? created.platform.trim()
|
||||||
: payload.platform;
|
: payload.platform;
|
||||||
|
const returnedPreset = getSiteInitializationPreset(created?.initializationPresetId);
|
||||||
|
const fallbackPreset = selectedInitializationPreset && selectedInitializationPreset.platform === createdPlatform
|
||||||
|
? selectedInitializationPreset
|
||||||
|
: null;
|
||||||
setCreatedSiteForChoice({
|
setCreatedSiteForChoice({
|
||||||
id: createdSiteId,
|
id: createdSiteId,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
platform: createdPlatform,
|
platform: createdPlatform,
|
||||||
|
initializationPresetId: returnedPreset?.id || fallbackPreset?.id || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -526,6 +559,9 @@ export default function Sites() {
|
|||||||
create: '1',
|
create: '1',
|
||||||
siteId: String(siteId),
|
siteId: String(siteId),
|
||||||
});
|
});
|
||||||
|
if (createdSiteForChoice.initializationPresetId) {
|
||||||
|
params.set('initPreset', createdSiteForChoice.initializationPresetId);
|
||||||
|
}
|
||||||
|
|
||||||
if (choice === 'session') {
|
if (choice === 'session') {
|
||||||
// codex平台使用OAuth流程
|
// codex平台使用OAuth流程
|
||||||
@@ -557,8 +593,19 @@ export default function Sites() {
|
|||||||
try {
|
try {
|
||||||
const result = await api.detectSite(form.url.trim());
|
const result = await api.detectSite(form.url.trim());
|
||||||
if (result?.platform) {
|
if (result?.platform) {
|
||||||
|
const detectedPreset = getSiteInitializationPreset(result?.initializationPresetId);
|
||||||
setForm((prev) => ({ ...prev, platform: result.platform }));
|
setForm((prev) => ({ ...prev, platform: result.platform }));
|
||||||
toast.success(`检测到平台: ${result.platform}`);
|
setSelectedInitializationPresetId((current) => {
|
||||||
|
if (detectedPreset) return detectedPreset.id;
|
||||||
|
const activePreset = getSiteInitializationPreset(current);
|
||||||
|
if (activePreset && activePreset.platform !== result.platform) return null;
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
detectedPreset
|
||||||
|
? `检测到平台: ${result.platform}(${detectedPreset.label})`
|
||||||
|
: `检测到平台: ${result.platform}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(result?.error || '无法识别平台类型');
|
toast.error(result?.error || '无法识别平台类型');
|
||||||
}
|
}
|
||||||
@@ -832,7 +879,11 @@ export default function Sites() {
|
|||||||
{createdSiteForChoice && (
|
{createdSiteForChoice && (
|
||||||
<SiteCreatedModal
|
<SiteCreatedModal
|
||||||
siteName={createdSiteForChoice.name}
|
siteName={createdSiteForChoice.name}
|
||||||
platform={createdSiteForChoice.platform}
|
initializationPresetId={createdSiteForChoice.initializationPresetId}
|
||||||
|
initialSegment={
|
||||||
|
getSiteInitializationPreset(createdSiteForChoice.initializationPresetId)?.initialSegment
|
||||||
|
|| resolveInitialConnectionSegment(createdSiteForChoice.platform)
|
||||||
|
}
|
||||||
onChoice={handleSiteCreatedChoice}
|
onChoice={handleSiteCreatedChoice}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setCreatedSiteForChoice(null);
|
setCreatedSiteForChoice(null);
|
||||||
@@ -904,15 +955,35 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
|
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-primary) 28%, var(--color-border))' : 'var(--color-border)'}`,
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
|
background: 'var(--color-bg)',
|
||||||
transition: 'all 0.2s',
|
boxShadow: form.platform.trim() ? '0 0 0 2px color-mix(in srgb, var(--color-primary) 10%, transparent)' : 'none',
|
||||||
|
transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
value={form.platform}
|
value={platformSelectValue}
|
||||||
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
|
onChange={(value) => {
|
||||||
|
if (value.startsWith('preset:')) {
|
||||||
|
const preset = getSiteInitializationPreset(value.slice('preset:'.length));
|
||||||
|
if (!preset) return;
|
||||||
|
setSelectedInitializationPresetId(preset.id);
|
||||||
|
setForm((prev) => {
|
||||||
|
const currentUrl = prev.url.trim();
|
||||||
|
const shouldFillDefaultUrl = !currentUrl
|
||||||
|
|| (activeInitializationPreset?.defaultUrl && currentUrl === activeInitializationPreset.defaultUrl);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
platform: preset.platform,
|
||||||
|
url: shouldFillDefaultUrl && preset.defaultUrl ? preset.defaultUrl : prev.url,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm((prev) => ({ ...prev, platform: value }));
|
||||||
|
setSelectedInitializationPresetId(null);
|
||||||
|
}}
|
||||||
options={platformOptions}
|
options={platformOptions}
|
||||||
placeholder="平台类型(可自动检测)"
|
placeholder="平台类型(可自动检测)"
|
||||||
/>
|
/>
|
||||||
@@ -924,6 +995,18 @@ export default function Sites() {
|
|||||||
style={formInputStyle}
|
style={formInputStyle}
|
||||||
/>
|
/>
|
||||||
</ResponsiveFormGrid>
|
</ResponsiveFormGrid>
|
||||||
|
{activeInitializationPreset && (
|
||||||
|
<div className="alert alert-info animate-scale-in">
|
||||||
|
<div className="alert-title">已应用官方预设 · {activeInitializationPreset.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
|
||||||
|
<div>{activeInitializationPreset.description}</div>
|
||||||
|
{form.url.trim() === activeInitializationPreset.defaultUrl && (
|
||||||
|
<div>当前已自动填入官方地址;如需走自建网关,也可以直接改 URL。</div>
|
||||||
|
)}
|
||||||
|
<div>推荐模型:{activeInitializationPreset.recommendedModels.join(' / ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Accounts from './Accounts.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getAccounts: vi.fn(),
|
||||||
|
getSites: vi.fn(),
|
||||||
|
getAccountTokens: vi.fn(),
|
||||||
|
addAccount: vi.fn(),
|
||||||
|
addAccountAvailableModels: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
const children = node.children || [];
|
||||||
|
return children.map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Accounts CodingPlan initialization', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getAccounts.mockResolvedValue([]);
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{ id: 10, name: 'Aliyun CodingPlan', url: 'https://coding.dashscope.aliyuncs.com/v1', platform: 'openai', status: 'active' },
|
||||||
|
]);
|
||||||
|
apiMock.getAccountTokens.mockResolvedValue([]);
|
||||||
|
apiMock.addAccount.mockResolvedValue({
|
||||||
|
id: 18,
|
||||||
|
siteId: 10,
|
||||||
|
tokenType: 'apikey',
|
||||||
|
queued: false,
|
||||||
|
});
|
||||||
|
apiMock.addAccountAvailableModels.mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preloads CodingPlan guidance, defaults skip-model-fetch, and seeds recommended models after add', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts?segment=apikey&create=1&siteId=10&initPreset=codingplan-openai']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const rendered = JSON.stringify(root.toJSON());
|
||||||
|
expect(rendered).toContain('添加 API Key 连接');
|
||||||
|
expect(rendered).toContain('阿里云 CodingPlan / OpenAI');
|
||||||
|
expect(rendered).toContain('qwen3-coder-plus');
|
||||||
|
|
||||||
|
const checkedBoxes = root.root.findAll((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.type === 'checkbox'
|
||||||
|
&& node.props.checked === true
|
||||||
|
));
|
||||||
|
expect(checkedBoxes.length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const tokenInput = root.root.find((node) => (
|
||||||
|
node.type === 'textarea'
|
||||||
|
&& node.props.placeholder === '粘贴 API Key'
|
||||||
|
));
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('添加连接')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
tokenInput.props.onChange({ target: { value: 'sk-codingplan-demo' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await addButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.addAccount).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
siteId: 10,
|
||||||
|
accessToken: 'sk-codingplan-demo',
|
||||||
|
credentialMode: 'apikey',
|
||||||
|
skipModelFetch: true,
|
||||||
|
}));
|
||||||
|
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
|
||||||
|
18,
|
||||||
|
expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5']),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ describe('defaultConnectionSegment', () => {
|
|||||||
expect(resolveInitialConnectionSegment('one-hub')).toBe('session');
|
expect(resolveInitialConnectionSegment('one-hub')).toBe('session');
|
||||||
expect(resolveInitialConnectionSegment('done-hub')).toBe('session');
|
expect(resolveInitialConnectionSegment('done-hub')).toBe('session');
|
||||||
expect(resolveInitialConnectionSegment('sub2api')).toBe('session');
|
expect(resolveInitialConnectionSegment('sub2api')).toBe('session');
|
||||||
|
expect(resolveInitialConnectionSegment('codex')).toBe('session');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps API-key-only platforms to the apikey segment', () => {
|
it('maps API-key-only platforms to the apikey segment', () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const SESSION_FIRST_PLATFORMS = new Set([
|
|||||||
'one-hub',
|
'one-hub',
|
||||||
'done-hub',
|
'done-hub',
|
||||||
'sub2api',
|
'sub2api',
|
||||||
|
'codex',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const API_KEY_FIRST_PLATFORMS = new Set([
|
const API_KEY_FIRST_PLATFORMS = new Set([
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type SiteSavePayload = {
|
|||||||
url: string;
|
url: string;
|
||||||
externalCheckinUrl: string;
|
externalCheckinUrl: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
|
initializationPresetId?: string | null;
|
||||||
proxyUrl: string;
|
proxyUrl: string;
|
||||||
useSystemProxy: boolean;
|
useSystemProxy: boolean;
|
||||||
customHeaders: string;
|
customHeaders: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, create } from 'react-test-renderer';
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { ToastProvider } from '../components/Toast.js';
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
@@ -38,11 +38,14 @@ function LocationProbe() {
|
|||||||
return <div>{`${location.pathname}${location.search}`}</div>;
|
return <div>{`${location.pathname}${location.search}`}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSiteAndClickModalChoice(createdSite: { id: number; name: string; platform?: string | null }, choice: 'session' | 'apikey' | 'later') {
|
async function createSiteAndClickModalChoice(
|
||||||
|
createdSite: { id: number; name: string; platform?: string | null; initializationPresetId?: string | null },
|
||||||
|
choice: 'session' | 'apikey' | 'later',
|
||||||
|
) {
|
||||||
apiMock.getSites.mockResolvedValue([]);
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
apiMock.addSite.mockResolvedValue(createdSite);
|
apiMock.addSite.mockResolvedValue(createdSite);
|
||||||
|
|
||||||
let root!: WebTestRenderer;
|
let root!: ReactTestRenderer;
|
||||||
try {
|
try {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root = create(
|
root = create(
|
||||||
@@ -148,6 +151,20 @@ describe('Sites create redirect', () => {
|
|||||||
expect(rendered).toContain('siteId=22');
|
expect(rendered).toContain('siteId=22');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes CodingPlan initialization preset into the API key flow', async () => {
|
||||||
|
const rendered = await createSiteAndClickModalChoice({
|
||||||
|
id: 25,
|
||||||
|
name: 'Aliyun CodingPlan',
|
||||||
|
platform: 'openai',
|
||||||
|
initializationPresetId: 'codingplan-openai',
|
||||||
|
}, 'apikey');
|
||||||
|
|
||||||
|
expect(rendered).toContain('/accounts?');
|
||||||
|
expect(rendered).toContain('segment=apikey');
|
||||||
|
expect(rendered).toContain('siteId=25');
|
||||||
|
expect(rendered).toContain('initPreset=codingplan-openai');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows modal after creating a codex site and allows choosing later', async () => {
|
it('shows modal after creating a codex site and allows choosing later', async () => {
|
||||||
const rendered = await createSiteAndClickModalChoice({ id: 23, name: 'Demo Site', platform: 'codex' }, 'later');
|
const rendered = await createSiteAndClickModalChoice({ id: 23, name: 'Demo Site', platform: 'codex' }, 'later');
|
||||||
|
|
||||||
@@ -165,11 +182,320 @@ describe('Sites create redirect', () => {
|
|||||||
expect(rendered).toContain('siteId=24');
|
expect(rendered).toContain('siteId=24');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists vendor-specific site types directly in the platform selector', async () => {
|
||||||
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
|
apiMock.addSite.mockResolvedValue({ id: 30, name: 'Demo Site', platform: 'openai' });
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/sites" element={<Sites />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props.className?.includes('btn btn-primary')
|
||||||
|
&& JSON.stringify(node.props.children).includes('添加站点')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
addButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const selects = root.root.findAllByType(ModernSelect);
|
||||||
|
const platformSelect = selects.at(-1);
|
||||||
|
expect(platformSelect?.props.options).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ label: '阿里云 CodingPlan / OpenAI' }),
|
||||||
|
expect.objectContaining({ label: '阿里云 CodingPlan / Claude' }),
|
||||||
|
expect.objectContaining({ label: '智谱 Coding Plan / OpenAI' }),
|
||||||
|
expect.objectContaining({ label: '智谱 Coding Plan / Claude' }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds compact descriptions to generic site types in the platform selector', async () => {
|
||||||
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
|
apiMock.addSite.mockResolvedValue({ id: 34, name: 'Demo Site', platform: 'openai' });
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/sites" element={<Sites />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props.className?.includes('btn btn-primary')
|
||||||
|
&& JSON.stringify(node.props.children).includes('添加站点')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
addButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const platformSelect = root.root.findAllByType(ModernSelect).at(-1);
|
||||||
|
expect(platformSelect?.props.options).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ value: 'new-api', description: expect.stringContaining('聚合面板') }),
|
||||||
|
expect.objectContaining({ value: 'openai', description: expect.stringContaining('OpenAI 兼容接口') }),
|
||||||
|
expect.objectContaining({ value: 'codex', description: expect.stringContaining('OAuth') }),
|
||||||
|
expect.objectContaining({ value: 'claude', description: expect.stringContaining('Claude') }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefills the official base url when selecting a vendor-specific site type', async () => {
|
||||||
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
|
apiMock.addSite.mockResolvedValue({ id: 31, name: 'Demo Site', platform: 'openai' });
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/sites" element={<Sites />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props.className?.includes('btn btn-primary')
|
||||||
|
&& JSON.stringify(node.props.children).includes('添加站点')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
addButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const urlInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||||
|
));
|
||||||
|
const selects = root.root.findAllByType(ModernSelect);
|
||||||
|
const platformSelect = selects.at(-1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
platformSelect?.props.onChange('preset:zhipu-coding-plan-openai');
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(urlInput.props.value).toBe('https://open.bigmodel.cn/api/coding/paas/v4');
|
||||||
|
expect(JSON.stringify(root.toJSON())).toContain('智谱 Coding Plan / OpenAI');
|
||||||
|
|
||||||
|
const presetAlerts = root.root.findAll((node) => (
|
||||||
|
typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('alert alert-info')
|
||||||
|
));
|
||||||
|
expect(presetAlerts).toHaveLength(1);
|
||||||
|
expect(collectText(presetAlerts[0]!)).toContain('已应用官方预设');
|
||||||
|
expect(collectText(presetAlerts[0]!)).not.toContain('建议地址:');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops claiming the official base url is auto-filled after the user edits it', async () => {
|
||||||
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
|
apiMock.addSite.mockResolvedValue({ id: 35, name: 'Demo Site', platform: 'openai' });
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/sites" element={<Sites />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props.className?.includes('btn btn-primary')
|
||||||
|
&& JSON.stringify(node.props.children).includes('添加站点')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
addButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const urlInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||||
|
));
|
||||||
|
const platformSelect = root.root.findAllByType(ModernSelect).at(-1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
platformSelect?.props.onChange('preset:zhipu-coding-plan-openai');
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
urlInput.props.onChange({ target: { value: 'https://gateway.example.com/coding' } });
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const presetAlert = root.root.find((node) => (
|
||||||
|
typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('alert alert-info')
|
||||||
|
));
|
||||||
|
expect(collectText(presetAlert)).toContain('已应用官方预设');
|
||||||
|
expect(collectText(presetAlert)).not.toContain('当前已自动填入官方地址');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a manually selected generic openai platform even when the url matches a Coding Plan preset', async () => {
|
||||||
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
|
apiMock.addSite.mockResolvedValue({ id: 32, name: 'Demo Site', platform: 'openai' });
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/sites" element={<Sites />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props.className?.includes('btn btn-primary')
|
||||||
|
&& JSON.stringify(node.props.children).includes('添加站点')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
addButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const urlInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
urlInput.props.onChange({ target: { value: 'https://coding.dashscope.aliyuncs.com/v1' } });
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
let platformSelect = root.root.findAllByType(ModernSelect).at(-1);
|
||||||
|
await act(async () => {
|
||||||
|
platformSelect?.props.onChange('openai');
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
platformSelect = root.root.findAllByType(ModernSelect).at(-1);
|
||||||
|
expect(platformSelect?.props.value).toBe('openai');
|
||||||
|
expect(JSON.stringify(root.toJSON())).not.toContain('已应用官方预设');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends explicit preset metadata only when the vendor-specific type is selected', async () => {
|
||||||
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
|
apiMock.addSite.mockResolvedValue({ id: 33, name: 'Aliyun CodingPlan', platform: 'openai', initializationPresetId: 'codingplan-openai' });
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/sites" element={<Sites />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& node.props.className?.includes('btn btn-primary')
|
||||||
|
&& JSON.stringify(node.props.children).includes('添加站点')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
addButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const nameInput = root.root.find((node) => node.type === 'input' && node.props.placeholder === '站点名称');
|
||||||
|
let platformSelect = root.root.findAllByType(ModernSelect).at(-1);
|
||||||
|
const saveButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('保存站点')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
nameInput.props.onChange({ target: { value: 'Aliyun CodingPlan' } });
|
||||||
|
platformSelect?.props.onChange('preset:codingplan-openai');
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await saveButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.addSite).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
platform: 'openai',
|
||||||
|
initializationPresetId: 'codingplan-openai',
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('shows modal with all three choices after creating a site', async () => {
|
it('shows modal with all three choices after creating a site', async () => {
|
||||||
apiMock.getSites.mockResolvedValue([]);
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
apiMock.addSite.mockResolvedValue({ id: 24, name: 'Demo Site', platform: 'new-api' });
|
apiMock.addSite.mockResolvedValue({ id: 24, name: 'Demo Site', platform: 'new-api' });
|
||||||
|
|
||||||
let root!: WebTestRenderer;
|
let root!: ReactTestRenderer;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root = create(
|
root = create(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user