forked from boos/BoosAPI
fix: align explicit group route picker and details (#158)
* fix: align explicit group route picker and details * fix: move responses stream closeout into transformer facade
This commit is contained in:
@@ -244,6 +244,75 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fills missing sourceModel from source exact routes when loading explicit-group channels', async () => {
|
||||||
|
const sourceA = await seedAccountWithToken('deepseek-chat');
|
||||||
|
const sourceB = await seedAccountWithToken('deepseek-reasoner');
|
||||||
|
|
||||||
|
const exactRouteA = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'deepseek-chat',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
const exactRouteB = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'deepseek-reasoner',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.routeChannels).values([
|
||||||
|
{
|
||||||
|
routeId: exactRouteA.id,
|
||||||
|
accountId: sourceA.account.id,
|
||||||
|
tokenId: sourceA.token.id,
|
||||||
|
sourceModel: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routeId: exactRouteB.id,
|
||||||
|
accountId: sourceB.account.id,
|
||||||
|
tokenId: sourceB.token.id,
|
||||||
|
sourceModel: null,
|
||||||
|
priority: 1,
|
||||||
|
weight: 8,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/routes',
|
||||||
|
payload: {
|
||||||
|
routeMode: 'explicit_group',
|
||||||
|
displayName: 'deepseekv1',
|
||||||
|
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const createdRouteId = (createResponse.json() as { id: number }).id;
|
||||||
|
|
||||||
|
const channelsResponse = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/routes/${createdRouteId}/channels`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(channelsResponse.statusCode).toBe(200);
|
||||||
|
expect(channelsResponse.json()).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
routeId: exactRouteA.id,
|
||||||
|
accountId: sourceA.account.id,
|
||||||
|
sourceModel: 'deepseek-chat',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
routeId: exactRouteB.id,
|
||||||
|
accountId: sourceB.account.id,
|
||||||
|
sourceModel: 'deepseek-reasoner',
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid explicit-group payloads', async () => {
|
it('rejects invalid explicit-group payloads', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
|||||||
const explicitSourceRouteIds = Array.from(new Set(routes
|
const explicitSourceRouteIds = Array.from(new Set(routes
|
||||||
.filter((route) => isExplicitGroupRoute(route))
|
.filter((route) => isExplicitGroupRoute(route))
|
||||||
.flatMap((route) => route.sourceRouteIds)));
|
.flatMap((route) => route.sourceRouteIds)));
|
||||||
const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0
|
const explicitSourceRoutes = explicitSourceRouteIds.length > 0
|
||||||
? (await db.select({
|
? (await db.select({
|
||||||
id: schema.tokenRoutes.id,
|
id: schema.tokenRoutes.id,
|
||||||
modelPattern: schema.tokenRoutes.modelPattern,
|
modelPattern: schema.tokenRoutes.modelPattern,
|
||||||
@@ -543,9 +543,10 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
|||||||
}).from(schema.tokenRoutes)
|
}).from(schema.tokenRoutes)
|
||||||
.where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds))
|
.where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds))
|
||||||
.all())
|
.all())
|
||||||
.filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
|
|
||||||
.map((route) => route.id)
|
|
||||||
: [];
|
: [];
|
||||||
|
const enabledExplicitSourceRouteIds = explicitSourceRoutes
|
||||||
|
.filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
|
||||||
|
.map((route) => route.id);
|
||||||
const actualRouteIds = Array.from(new Set([
|
const actualRouteIds = Array.from(new Set([
|
||||||
...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id),
|
...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id),
|
||||||
...enabledExplicitSourceRouteIds,
|
...enabledExplicitSourceRouteIds,
|
||||||
@@ -554,6 +555,14 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
|||||||
return new Map(routes.map((route) => [route.id, []]));
|
return new Map(routes.map((route) => [route.id, []]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actualRouteById = new Map<number, { modelPattern: string; routeMode: string | null }>();
|
||||||
|
for (const route of routes.filter((item) => !isExplicitGroupRoute(item))) {
|
||||||
|
actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null });
|
||||||
|
}
|
||||||
|
for (const route of explicitSourceRoutes) {
|
||||||
|
actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
const channelRows = await db.select().from(schema.routeChannels)
|
const channelRows = await db.select().from(schema.routeChannels)
|
||||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
@@ -565,9 +574,15 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
|||||||
|
|
||||||
for (const row of channelRows) {
|
for (const row of channelRows) {
|
||||||
const routeId = row.route_channels.routeId;
|
const routeId = row.route_channels.routeId;
|
||||||
|
const actualRoute = actualRouteById.get(routeId);
|
||||||
|
const fallbackSourceModel = actualRoute && !isExplicitGroupRoute(actualRoute) && isExactModelPattern(actualRoute.modelPattern)
|
||||||
|
? actualRoute.modelPattern
|
||||||
|
: null;
|
||||||
|
const resolvedSourceModel = (row.route_channels.sourceModel || fallbackSourceModel || '').trim();
|
||||||
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
|
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
|
||||||
channelsByActualRouteId.get(routeId)!.push({
|
channelsByActualRouteId.get(routeId)!.push({
|
||||||
...row.route_channels,
|
...row.route_channels,
|
||||||
|
sourceModel: resolvedSourceModel || null,
|
||||||
account: row.accounts,
|
account: row.accounts,
|
||||||
site: row.sites,
|
site: row.sites,
|
||||||
token: row.account_tokens
|
token: row.account_tokens
|
||||||
|
|||||||
@@ -434,6 +434,34 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
reply.raw.setHeader('Connection', 'keep-alive');
|
reply.raw.setHeader('Connection', 'keep-alive');
|
||||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||||
};
|
};
|
||||||
|
let parsedUsage: UsageSummary = {
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
promptTokensIncludeCache: null,
|
||||||
|
};
|
||||||
|
const writeLines = (lines: string[]) => {
|
||||||
|
for (const line of lines) reply.raw.write(line);
|
||||||
|
};
|
||||||
|
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
|
||||||
|
modelName,
|
||||||
|
successfulUpstreamPath,
|
||||||
|
strictTerminalEvents: Object.entries(request.headers as Record<string, unknown>)
|
||||||
|
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
|
||||||
|
&& String(rawValue).trim() === '1'),
|
||||||
|
getUsage: () => parsedUsage,
|
||||||
|
onParsedPayload: (payload) => {
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
writeLines,
|
||||||
|
writeRaw: (chunk) => {
|
||||||
|
reply.raw.write(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!upstreamContentType.includes('text/event-stream')) {
|
if (!upstreamContentType.includes('text/event-stream')) {
|
||||||
const rawText = await upstream.text();
|
const rawText = await upstream.text();
|
||||||
@@ -447,7 +475,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
parsedUsage = parseProxyUsage(upstreamData);
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
@@ -483,17 +511,31 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
startSseResponse();
|
||||||
upstreamData,
|
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
||||||
modelName,
|
if (streamResult.status === 'failed') {
|
||||||
rawText,
|
tokenRouter.recordFailure(selected.channel.id);
|
||||||
|
logProxy(
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
'failed',
|
||||||
|
200,
|
||||||
|
latency,
|
||||||
|
streamResult.errorMessage,
|
||||||
|
retryCount,
|
||||||
|
downstreamPath,
|
||||||
|
parsedUsage.promptTokens,
|
||||||
|
parsedUsage.completionTokens,
|
||||||
|
parsedUsage.totalTokens,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
successfulUpstreamPath,
|
||||||
|
clientContext,
|
||||||
|
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||||
);
|
);
|
||||||
const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({
|
return;
|
||||||
upstreamPayload: upstreamData,
|
}
|
||||||
normalized,
|
|
||||||
usage: parsedUsage,
|
|
||||||
serializationMode: 'response',
|
|
||||||
});
|
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
@@ -526,32 +568,6 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const streamPayload = isRecord(downstreamData) ? downstreamData : {
|
|
||||||
id: `resp_${Date.now()}`,
|
|
||||||
object: 'response',
|
|
||||||
status: 'completed',
|
|
||||||
model: modelName,
|
|
||||||
output: [],
|
|
||||||
output_text: '',
|
|
||||||
usage: {
|
|
||||||
input_tokens: parsedUsage.promptTokens,
|
|
||||||
output_tokens: parsedUsage.completionTokens,
|
|
||||||
total_tokens: parsedUsage.totalTokens,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const createdPayload = {
|
|
||||||
...streamPayload,
|
|
||||||
status: 'in_progress',
|
|
||||||
output: [],
|
|
||||||
output_text: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
startSseResponse();
|
|
||||||
reply.raw.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`);
|
|
||||||
reply.raw.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`);
|
|
||||||
reply.raw.write('data: [DONE]\n\n');
|
|
||||||
reply.raw.end();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,14 +577,6 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||||
? createGeminiCliStreamReader(upstreamReader)
|
? createGeminiCliStreamReader(upstreamReader)
|
||||||
: upstreamReader;
|
: upstreamReader;
|
||||||
let parsedUsage: UsageSummary = {
|
|
||||||
promptTokens: 0,
|
|
||||||
completionTokens: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cacheReadTokens: 0,
|
|
||||||
cacheCreationTokens: 0,
|
|
||||||
promptTokensIncludeCache: null,
|
|
||||||
};
|
|
||||||
let rawText = '';
|
let rawText = '';
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const reader = baseReader
|
const reader = baseReader
|
||||||
@@ -588,26 +596,6 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: baseReader;
|
: baseReader;
|
||||||
const writeLines = (lines: string[]) => {
|
|
||||||
for (const line of lines) reply.raw.write(line);
|
|
||||||
};
|
|
||||||
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
|
|
||||||
modelName,
|
|
||||||
successfulUpstreamPath,
|
|
||||||
strictTerminalEvents: Object.entries(request.headers as Record<string, unknown>)
|
|
||||||
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
|
|
||||||
&& String(rawValue).trim() === '1'),
|
|
||||||
getUsage: () => parsedUsage,
|
|
||||||
onParsedPayload: (payload) => {
|
|
||||||
if (payload && typeof payload === 'object') {
|
|
||||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
writeLines,
|
|
||||||
writeRaw: (chunk) => {
|
|
||||||
reply.raw.write(chunk);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const streamResult = await streamSession.run(reader, reply.raw);
|
const streamResult = await streamSession.run(reader, reply.raw);
|
||||||
rawText += decoder.decode();
|
rawText += decoder.decode();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createResponsesProxyStreamSession } from './proxyStream.js';
|
||||||
|
|
||||||
|
describe('createResponsesProxyStreamSession', () => {
|
||||||
|
it('serializes non-SSE fallback payloads into canonical responses SSE closeout events', () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
let ended = false;
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 5,
|
||||||
|
completionTokens: 3,
|
||||||
|
totalTokens: 8,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
promptTokensIncludeCache: null,
|
||||||
|
};
|
||||||
|
const payload = {
|
||||||
|
id: 'resp_fallback_1',
|
||||||
|
object: 'response',
|
||||||
|
status: 'completed',
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
output_text: 'hello from responses upstream',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_fallback_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'hello from responses upstream' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: usage.promptTokens,
|
||||||
|
output_tokens: usage.completionTokens,
|
||||||
|
total_tokens: usage.totalTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = createResponsesProxyStreamSession({
|
||||||
|
modelName: 'gpt-5.2',
|
||||||
|
successfulUpstreamPath: '/v1/responses',
|
||||||
|
getUsage: () => usage,
|
||||||
|
writeLines: (nextLines) => {
|
||||||
|
lines.push(...nextLines);
|
||||||
|
},
|
||||||
|
writeRaw: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = session.consumeUpstreamFinalPayload(
|
||||||
|
payload,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{
|
||||||
|
end() {
|
||||||
|
ended = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'completed',
|
||||||
|
errorMessage: null,
|
||||||
|
});
|
||||||
|
expect(ended).toBe(true);
|
||||||
|
|
||||||
|
const output = lines.join('');
|
||||||
|
expect(output).toContain('event: response.created');
|
||||||
|
expect(output).toContain('event: response.completed');
|
||||||
|
expect(output).toContain('"type":"response.completed"');
|
||||||
|
expect(output).toContain('"output_text":"hello from responses upstream"');
|
||||||
|
expect(output).toContain('data: [DONE]');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
|
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
|
||||||
import { type ParsedSseEvent } from '../../shared/normalized.js';
|
import { type ParsedSseEvent } from '../../shared/normalized.js';
|
||||||
import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js';
|
import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js';
|
||||||
|
import { openAiResponsesOutbound } from './outbound.js';
|
||||||
import { openAiResponsesStream } from './stream.js';
|
import { openAiResponsesStream } from './stream.js';
|
||||||
|
|
||||||
type StreamReader = {
|
type StreamReader = {
|
||||||
@@ -148,6 +149,51 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink): ResponsesProxyStreamResult {
|
||||||
|
if (payload && typeof payload === 'object') {
|
||||||
|
input.onParsedPayload?.(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadType = (isRecord(payload) && typeof payload.type === 'string')
|
||||||
|
? payload.type
|
||||||
|
: '';
|
||||||
|
if (payloadType === 'error' || payloadType === 'response.failed') {
|
||||||
|
fail(payload);
|
||||||
|
response?.end();
|
||||||
|
return terminalResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFinal = openAiResponsesOutbound.normalizeFinal(payload, input.modelName, fallbackText);
|
||||||
|
streamContext.id = normalizedFinal.id;
|
||||||
|
streamContext.model = normalizedFinal.model;
|
||||||
|
streamContext.created = normalizedFinal.created;
|
||||||
|
|
||||||
|
const streamPayload = openAiResponsesOutbound.serializeFinal({
|
||||||
|
upstreamPayload: payload,
|
||||||
|
normalized: normalizedFinal,
|
||||||
|
usage: input.getUsage(),
|
||||||
|
serializationMode: 'response',
|
||||||
|
});
|
||||||
|
const createdPayload = {
|
||||||
|
...streamPayload,
|
||||||
|
status: 'in_progress',
|
||||||
|
output: [],
|
||||||
|
output_text: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
finalized = true;
|
||||||
|
terminalResult = {
|
||||||
|
status: 'completed',
|
||||||
|
errorMessage: null,
|
||||||
|
};
|
||||||
|
input.writeLines([
|
||||||
|
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||||
|
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`,
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]);
|
||||||
|
response?.end();
|
||||||
|
return terminalResult;
|
||||||
|
},
|
||||||
async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise<ResponsesProxyStreamResult> {
|
async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise<ResponsesProxyStreamResult> {
|
||||||
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
|
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
|
||||||
reader,
|
reader,
|
||||||
|
|||||||
@@ -563,6 +563,15 @@ export default function TokenRoutes() {
|
|||||||
}) as [string, number][];
|
}) as [string, number][];
|
||||||
}, [listVisibleRoutes, routeEndpointTypesByRouteId]);
|
}, [listVisibleRoutes, routeEndpointTypesByRouteId]);
|
||||||
|
|
||||||
|
const sourceEndpointTypesByRouteId = useMemo(() => {
|
||||||
|
const next: Record<number, string[]> = {};
|
||||||
|
for (const route of exactSourceRouteOptions) {
|
||||||
|
next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set<string>())
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, [exactSourceRouteOptions, routeEndpointTypesByRouteId]);
|
||||||
|
|
||||||
const routeBrandIconCandidates = useMemo(() => {
|
const routeBrandIconCandidates = useMemo(() => {
|
||||||
const byIcon = new Map<string, BrandInfo>();
|
const byIcon = new Map<string, BrandInfo>();
|
||||||
|
|
||||||
@@ -600,6 +609,7 @@ export default function TokenRoutes() {
|
|||||||
brand: routeBrandById.get(route.id) || null,
|
brand: routeBrandById.get(route.id) || null,
|
||||||
modelPattern: route.modelPattern,
|
modelPattern: route.modelPattern,
|
||||||
channelCount: route.channelCount,
|
channelCount: route.channelCount,
|
||||||
|
sourceRouteCount: Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' });
|
if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' });
|
||||||
@@ -1152,6 +1162,7 @@ export default function TokenRoutes() {
|
|||||||
routeIconSelectOptions={routeIconSelectOptions}
|
routeIconSelectOptions={routeIconSelectOptions}
|
||||||
previewModelSamples={previewModelSamples}
|
previewModelSamples={previewModelSamples}
|
||||||
exactSourceRouteOptions={exactSourceRouteOptions}
|
exactSourceRouteOptions={exactSourceRouteOptions}
|
||||||
|
sourceEndpointTypesByRouteId={sourceEndpointTypesByRouteId}
|
||||||
onSave={handleAddRoute}
|
onSave={handleAddRoute}
|
||||||
onCancel={handleCancelEditRoute}
|
onCancel={handleCancelEditRoute}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
import { useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
|
||||||
|
import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
|
||||||
import CenteredModal from '../../components/CenteredModal.js';
|
import CenteredModal from '../../components/CenteredModal.js';
|
||||||
import ModernSelect from '../../components/ModernSelect.js';
|
import ModernSelect from '../../components/ModernSelect.js';
|
||||||
import { tr } from '../../i18n.js';
|
import { tr } from '../../i18n.js';
|
||||||
@@ -11,6 +12,9 @@ import {
|
|||||||
matchesModelPattern,
|
matchesModelPattern,
|
||||||
normalizeRouteDisplayIconValue,
|
normalizeRouteDisplayIconValue,
|
||||||
normalizeRouteMode,
|
normalizeRouteMode,
|
||||||
|
resolveEndpointTypeIconModel,
|
||||||
|
resolveRouteBrand,
|
||||||
|
siteAvatarLetters,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
|
|
||||||
type RouteEditorForm = {
|
type RouteEditorForm = {
|
||||||
@@ -32,6 +36,7 @@ type ManualRoutePanelProps = {
|
|||||||
routeIconSelectOptions: RouteIconOption[];
|
routeIconSelectOptions: RouteIconOption[];
|
||||||
previewModelSamples: string[];
|
previewModelSamples: string[];
|
||||||
exactSourceRouteOptions: RouteSummaryRow[];
|
exactSourceRouteOptions: RouteSummaryRow[];
|
||||||
|
sourceEndpointTypesByRouteId: Record<number, string[]>;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
@@ -71,6 +76,41 @@ function SearchField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FilterChip({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
count?: number;
|
||||||
|
icon?: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`filter-chip ${active ? 'active' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon ? <span className="filter-chip-icon">{icon}</span> : null}
|
||||||
|
<span className="filter-chip-label">{label}</span>
|
||||||
|
{count !== undefined ? <span className="filter-chip-count">{count}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="route-filter-row">
|
||||||
|
<span className="route-filter-row-label">{label}</span>
|
||||||
|
<div className="route-filter-row-chips">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ManualRoutePanel({
|
export default function ManualRoutePanel({
|
||||||
show,
|
show,
|
||||||
editingRouteId,
|
editingRouteId,
|
||||||
@@ -81,18 +121,25 @@ export default function ManualRoutePanel({
|
|||||||
routeIconSelectOptions,
|
routeIconSelectOptions,
|
||||||
previewModelSamples,
|
previewModelSamples,
|
||||||
exactSourceRouteOptions,
|
exactSourceRouteOptions,
|
||||||
|
sourceEndpointTypesByRouteId,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: ManualRoutePanelProps) {
|
}: ManualRoutePanelProps) {
|
||||||
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||||
const [sourceSearch, setSourceSearch] = useState('');
|
const [sourceSearch, setSourceSearch] = useState('');
|
||||||
const [sourcePickerSelection, setSourcePickerSelection] = useState<number[]>([]);
|
const [sourcePickerSelection, setSourcePickerSelection] = useState<number[]>([]);
|
||||||
|
const [activeSourceBrand, setActiveSourceBrand] = useState<string | null>(null);
|
||||||
|
const [activeSourceSite, setActiveSourceSite] = useState<string | null>(null);
|
||||||
|
const [activeSourceEndpointType, setActiveSourceEndpointType] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!show) {
|
if (!show) {
|
||||||
setShowSourcePicker(false);
|
setShowSourcePicker(false);
|
||||||
setSourceSearch('');
|
setSourceSearch('');
|
||||||
setSourcePickerSelection([]);
|
setSourcePickerSelection([]);
|
||||||
|
setActiveSourceBrand(null);
|
||||||
|
setActiveSourceSite(null);
|
||||||
|
setActiveSourceEndpointType(null);
|
||||||
}
|
}
|
||||||
}, [show]);
|
}, [show]);
|
||||||
|
|
||||||
@@ -119,14 +166,136 @@ export default function ManualRoutePanel({
|
|||||||
return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
|
return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
|
||||||
}, [form.modelPattern, modelPatternError, previewModelSamples]);
|
}, [form.modelPattern, modelPatternError, previewModelSamples]);
|
||||||
|
|
||||||
|
const sourceRouteBrandById = useMemo(() => {
|
||||||
|
const next = new Map<number, BrandInfo | null>();
|
||||||
|
for (const route of exactSourceRouteOptions) {
|
||||||
|
next.set(route.id, resolveRouteBrand(route));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}, [exactSourceRouteOptions]);
|
||||||
|
|
||||||
|
const sourceBrandList = useMemo(() => {
|
||||||
|
const grouped = new Map<string, { count: number; brand: BrandInfo }>();
|
||||||
|
let otherCount = 0;
|
||||||
|
|
||||||
|
for (const route of exactSourceRouteOptions) {
|
||||||
|
const brand = sourceRouteBrandById.get(route.id) || null;
|
||||||
|
if (!brand) {
|
||||||
|
otherCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = grouped.get(brand.name);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
} else {
|
||||||
|
grouped.set(brand.name, { count: 1, brand });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: [...grouped.entries()].sort((a, b) => {
|
||||||
|
if (a[1].count === b[1].count) {
|
||||||
|
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
return b[1].count - a[1].count;
|
||||||
|
}) as [string, { count: number; brand: BrandInfo }][],
|
||||||
|
otherCount,
|
||||||
|
};
|
||||||
|
}, [exactSourceRouteOptions, sourceRouteBrandById]);
|
||||||
|
|
||||||
|
const sourceSiteList = useMemo(() => {
|
||||||
|
const grouped = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const route of exactSourceRouteOptions) {
|
||||||
|
const seenSites = new Set<string>();
|
||||||
|
for (const siteName of route.siteNames || []) {
|
||||||
|
const normalizedSite = String(siteName || '').trim();
|
||||||
|
if (!normalizedSite || seenSites.has(normalizedSite)) continue;
|
||||||
|
seenSites.add(normalizedSite);
|
||||||
|
grouped.set(normalizedSite, (grouped.get(normalizedSite) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.entries()].sort((a, b) => {
|
||||||
|
if (a[1] === b[1]) {
|
||||||
|
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
return b[1] - a[1];
|
||||||
|
}) as [string, number][];
|
||||||
|
}, [exactSourceRouteOptions]);
|
||||||
|
|
||||||
|
const sourceEndpointTypeList = useMemo(() => {
|
||||||
|
const grouped = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const route of exactSourceRouteOptions) {
|
||||||
|
const endpointTypes = sourceEndpointTypesByRouteId[route.id] || [];
|
||||||
|
for (const endpointType of endpointTypes) {
|
||||||
|
const normalizedType = String(endpointType || '').trim();
|
||||||
|
if (!normalizedType) continue;
|
||||||
|
grouped.set(normalizedType, (grouped.get(normalizedType) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.entries()].sort((a, b) => {
|
||||||
|
if (a[1] === b[1]) {
|
||||||
|
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
return b[1] - a[1];
|
||||||
|
}) as [string, number][];
|
||||||
|
}, [exactSourceRouteOptions, sourceEndpointTypesByRouteId]);
|
||||||
|
|
||||||
const filteredSourceRoutes = useMemo(() => {
|
const filteredSourceRoutes = useMemo(() => {
|
||||||
|
let list = [...exactSourceRouteOptions];
|
||||||
|
|
||||||
|
if (activeSourceBrand) {
|
||||||
|
if (activeSourceBrand === '__other__') {
|
||||||
|
list = list.filter((route) => !(sourceRouteBrandById.get(route.id) || null));
|
||||||
|
} else {
|
||||||
|
list = list.filter((route) => (sourceRouteBrandById.get(route.id)?.name || '') === activeSourceBrand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSourceSite) {
|
||||||
|
list = list.filter((route) => (route.siteNames || []).includes(activeSourceSite));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSourceEndpointType) {
|
||||||
|
list = list.filter((route) => (sourceEndpointTypesByRouteId[route.id] || []).includes(activeSourceEndpointType));
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedSearch = sourceSearch.trim().toLowerCase();
|
const normalizedSearch = sourceSearch.trim().toLowerCase();
|
||||||
if (!normalizedSearch) return exactSourceRouteOptions;
|
if (normalizedSearch) {
|
||||||
return exactSourceRouteOptions.filter((route) => {
|
list = list.filter((route) => {
|
||||||
const label = renderRouteOptionLabel(route).toLowerCase();
|
const label = renderRouteOptionLabel(route).toLowerCase();
|
||||||
return label.includes(normalizedSearch) || route.modelPattern.toLowerCase().includes(normalizedSearch);
|
const modelPattern = route.modelPattern.toLowerCase();
|
||||||
|
const brandName = (sourceRouteBrandById.get(route.id)?.name || '').toLowerCase();
|
||||||
|
const siteText = (route.siteNames || []).join(' ').toLowerCase();
|
||||||
|
const endpointTypes = (sourceEndpointTypesByRouteId[route.id] || []).join(' ').toLowerCase();
|
||||||
|
return (
|
||||||
|
label.includes(normalizedSearch)
|
||||||
|
|| modelPattern.includes(normalizedSearch)
|
||||||
|
|| brandName.includes(normalizedSearch)
|
||||||
|
|| siteText.includes(normalizedSearch)
|
||||||
|
|| endpointTypes.includes(normalizedSearch)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}, [exactSourceRouteOptions, sourceSearch]);
|
}
|
||||||
|
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
if (a.channelCount === b.channelCount) {
|
||||||
|
return renderRouteOptionLabel(a).localeCompare(renderRouteOptionLabel(b), undefined, { sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
return b.channelCount - a.channelCount;
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
activeSourceBrand,
|
||||||
|
activeSourceEndpointType,
|
||||||
|
activeSourceSite,
|
||||||
|
exactSourceRouteOptions,
|
||||||
|
sourceEndpointTypesByRouteId,
|
||||||
|
sourceRouteBrandById,
|
||||||
|
sourceSearch,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedSourceRoutes = useMemo(() => {
|
const selectedSourceRoutes = useMemo(() => {
|
||||||
const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route]));
|
const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route]));
|
||||||
@@ -135,18 +304,29 @@ export default function ManualRoutePanel({
|
|||||||
.filter((route): route is RouteSummaryRow => !!route);
|
.filter((route): route is RouteSummaryRow => !!route);
|
||||||
}, [exactSourceRouteOptions, form.sourceRouteIds]);
|
}, [exactSourceRouteOptions, form.sourceRouteIds]);
|
||||||
|
|
||||||
|
const sourcePickerSelectionSet = useMemo(
|
||||||
|
() => new Set(sourcePickerSelection),
|
||||||
|
[sourcePickerSelection],
|
||||||
|
);
|
||||||
|
|
||||||
const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon);
|
const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon);
|
||||||
const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon);
|
const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon);
|
||||||
|
|
||||||
const openSourcePicker = () => {
|
const openSourcePicker = () => {
|
||||||
setSourcePickerSelection([...form.sourceRouteIds]);
|
setSourcePickerSelection([...form.sourceRouteIds]);
|
||||||
setSourceSearch('');
|
setSourceSearch('');
|
||||||
|
setActiveSourceBrand(null);
|
||||||
|
setActiveSourceSite(null);
|
||||||
|
setActiveSourceEndpointType(null);
|
||||||
setShowSourcePicker(true);
|
setShowSourcePicker(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeSourcePicker = () => {
|
const closeSourcePicker = () => {
|
||||||
setShowSourcePicker(false);
|
setShowSourcePicker(false);
|
||||||
setSourceSearch('');
|
setSourceSearch('');
|
||||||
|
setActiveSourceBrand(null);
|
||||||
|
setActiveSourceSite(null);
|
||||||
|
setActiveSourceEndpointType(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmSourcePicker = () => {
|
const confirmSourcePicker = () => {
|
||||||
@@ -156,6 +336,9 @@ export default function ManualRoutePanel({
|
|||||||
}));
|
}));
|
||||||
setShowSourcePicker(false);
|
setShowSourcePicker(false);
|
||||||
setSourceSearch('');
|
setSourceSearch('');
|
||||||
|
setActiveSourceBrand(null);
|
||||||
|
setActiveSourceSite(null);
|
||||||
|
setActiveSourceEndpointType(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
@@ -584,7 +767,101 @@ export default function ManualRoutePanel({
|
|||||||
placeholder={tr('搜索来源模型')}
|
placeholder={tr('搜索来源模型')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ maxHeight: 440, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="route-filter-bar">
|
||||||
|
<div className="route-filter-bar-expanded" style={{ opacity: 1, transform: 'none' }}>
|
||||||
|
<FilterRow label={tr('品牌')}>
|
||||||
|
<FilterChip
|
||||||
|
active={!activeSourceBrand}
|
||||||
|
label={tr('全部')}
|
||||||
|
count={exactSourceRouteOptions.length}
|
||||||
|
icon={<span style={{ fontSize: 10 }}>✦</span>}
|
||||||
|
onClick={() => setActiveSourceBrand(null)}
|
||||||
|
/>
|
||||||
|
{sourceBrandList.list.map(([brandName, { count, brand }]) => (
|
||||||
|
<FilterChip
|
||||||
|
key={brandName}
|
||||||
|
active={activeSourceBrand === brandName}
|
||||||
|
label={brandName}
|
||||||
|
count={count}
|
||||||
|
icon={<BrandGlyph brand={brand} size={12} fallbackText={brandName} />}
|
||||||
|
onClick={() => setActiveSourceBrand(activeSourceBrand === brandName ? null : brandName)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sourceBrandList.otherCount > 0 ? (
|
||||||
|
<FilterChip
|
||||||
|
active={activeSourceBrand === '__other__'}
|
||||||
|
label={tr('其他')}
|
||||||
|
count={sourceBrandList.otherCount}
|
||||||
|
icon={<span style={{ fontSize: 10 }}>?</span>}
|
||||||
|
onClick={() => setActiveSourceBrand(activeSourceBrand === '__other__' ? null : '__other__')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</FilterRow>
|
||||||
|
|
||||||
|
{sourceSiteList.length > 0 ? (
|
||||||
|
<FilterRow label={tr('站点')}>
|
||||||
|
<FilterChip
|
||||||
|
active={!activeSourceSite}
|
||||||
|
label={tr('全部')}
|
||||||
|
count={exactSourceRouteOptions.length}
|
||||||
|
icon={<span style={{ fontSize: 10 }}>⚡</span>}
|
||||||
|
onClick={() => setActiveSourceSite(null)}
|
||||||
|
/>
|
||||||
|
{sourceSiteList.map(([siteName, count]) => (
|
||||||
|
<FilterChip
|
||||||
|
key={siteName}
|
||||||
|
active={activeSourceSite === siteName}
|
||||||
|
label={siteName}
|
||||||
|
count={count}
|
||||||
|
icon={(
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 8,
|
||||||
|
background: hashColor(siteName),
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: '1px 2px',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{siteAvatarLetters(siteName)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveSourceSite(activeSourceSite === siteName ? null : siteName)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</FilterRow>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FilterRow label={tr('能力')}>
|
||||||
|
<FilterChip
|
||||||
|
active={!activeSourceEndpointType}
|
||||||
|
label={tr('全部')}
|
||||||
|
count={exactSourceRouteOptions.length}
|
||||||
|
icon={<span style={{ fontSize: 10 }}>⚙</span>}
|
||||||
|
onClick={() => setActiveSourceEndpointType(null)}
|
||||||
|
/>
|
||||||
|
{sourceEndpointTypeList.map(([endpointType, count]) => {
|
||||||
|
const iconModel = resolveEndpointTypeIconModel(endpointType);
|
||||||
|
return (
|
||||||
|
<FilterChip
|
||||||
|
key={endpointType}
|
||||||
|
active={activeSourceEndpointType === endpointType}
|
||||||
|
label={endpointType}
|
||||||
|
count={count}
|
||||||
|
icon={iconModel ? <InlineBrandIcon model={iconModel} size={12} /> : <span style={{ fontSize: 10 }}>⚙</span>}
|
||||||
|
onClick={() => setActiveSourceEndpointType(activeSourceEndpointType === endpointType ? null : endpointType)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{sourceEndpointTypeList.length === 0 ? (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{tr('暂无接口能力数据')}</span>
|
||||||
|
) : null}
|
||||||
|
</FilterRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 520, overflowY: 'auto', paddingRight: 4 }}>
|
||||||
{filteredSourceRoutes.length === 0 ? (
|
{filteredSourceRoutes.length === 0 ? (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
|
||||||
{exactSourceRouteOptions.length === 0
|
{exactSourceRouteOptions.length === 0
|
||||||
@@ -592,50 +869,131 @@ export default function ManualRoutePanel({
|
|||||||
: tr('没有匹配的来源模型。')}
|
: tr('没有匹配的来源模型。')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredSourceRoutes.map((route) => {
|
<div
|
||||||
const selected = sourcePickerSelection.includes(route.id);
|
className="source-route-picker-grid"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'stretch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredSourceRoutes.map((route) => {
|
||||||
|
const selected = sourcePickerSelectionSet.has(route.id);
|
||||||
const label = renderRouteOptionLabel(route);
|
const label = renderRouteOptionLabel(route);
|
||||||
|
const brand = sourceRouteBrandById.get(route.id) || null;
|
||||||
|
const endpointTypes = (sourceEndpointTypesByRouteId[route.id] || []).slice(0, 3);
|
||||||
|
const siteNames = Array.from(new Set((route.siteNames || []).filter((siteName) => String(siteName || '').trim())));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={route.id}
|
key={route.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
|
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost source-route-picker-card"
|
||||||
style={{
|
style={{
|
||||||
|
minHeight: 156,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'stretch',
|
||||||
justifyContent: 'space-between',
|
textAlign: 'left',
|
||||||
gap: 12,
|
padding: 0,
|
||||||
padding: '12px 14px',
|
|
||||||
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||||
background: selected
|
background: selected
|
||||||
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
|
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
|
||||||
: 'var(--color-bg-card)',
|
: 'var(--color-bg-card)',
|
||||||
boxShadow: selected
|
boxShadow: selected
|
||||||
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent)'
|
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)'
|
||||||
: 'none',
|
: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: '100%', padding: '14px 15px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected}
|
checked={selected}
|
||||||
readOnly
|
readOnly
|
||||||
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none' }}
|
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none', flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{label}</span>
|
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 22, height: 22, flexShrink: 0 }}>
|
||||||
{label !== route.modelPattern && (
|
{brand ? <BrandGlyph brand={brand} size={18} fallbackText={label} /> : <InlineBrandIcon model={route.modelPattern} size={18} />}
|
||||||
<code style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
|
</span>
|
||||||
)}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0, flex: 1 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{label !== route.modelPattern ? (
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{route.modelPattern}
|
||||||
|
</code>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
|
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
|
||||||
{selected ? tr('已选中') : tr('可选择')}
|
{selected ? tr('已选中') : tr('可选择')}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||||
|
{route.channelCount} {tr('通道')}
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||||
|
{siteNames.length} {tr('站点')}
|
||||||
|
</span>
|
||||||
|
{endpointTypes.map((endpointType) => (
|
||||||
|
<span key={`${route.id}-${endpointType}`} className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||||
|
{endpointType}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{siteNames.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||||
|
{siteNames.slice(0, 3).map((siteName) => (
|
||||||
|
<span
|
||||||
|
key={`${route.id}-${siteName}`}
|
||||||
|
className="badge badge-muted"
|
||||||
|
style={{ fontSize: 10 }}
|
||||||
|
>
|
||||||
|
{siteName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{siteNames.length > 3 ? (
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||||
|
+{siteNames.length - 3}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||||
|
{tr('当前未绑定站点信息')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ function RouteCardInner({
|
|||||||
const routeIcon = resolveRouteIcon(route);
|
const routeIcon = resolveRouteIcon(route);
|
||||||
const exactRoute = isRouteExactModel(route);
|
const exactRoute = isRouteExactModel(route);
|
||||||
const explicitGroupRoute = isExplicitGroupRoute(route);
|
const explicitGroupRoute = isExplicitGroupRoute(route);
|
||||||
|
const explicitGroupSourceCount = Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0;
|
||||||
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
||||||
const channelManagementDisabled = explicitGroupRoute;
|
const channelManagementDisabled = explicitGroupRoute;
|
||||||
const title = resolveRouteTitle(route);
|
const title = resolveRouteTitle(route);
|
||||||
@@ -206,9 +207,20 @@ function RouteCardInner({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||||
|
{explicitGroupSourceCount} {tr('来源模型')}
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||||
|
{route.channelCount} {tr('通道')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||||
{route.channelCount} {tr('通道')}
|
{route.channelCount} {tr('通道')}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{readOnlyRoute ? (
|
{readOnlyRoute ? (
|
||||||
<span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
|
<span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||||
@@ -269,9 +281,20 @@ function RouteCardInner({
|
|||||||
{route.enabled ? tr('启用') : tr('禁用')}
|
{route.enabled ? tr('启用') : tr('禁用')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||||
|
{explicitGroupSourceCount} {tr('来源模型')}
|
||||||
|
</span>
|
||||||
|
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||||
|
{route.channelCount} {tr('通道')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||||
{route.channelCount} {tr('通道')}
|
{route.channelCount} {tr('通道')}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{readOnlyRoute && (
|
{readOnlyRoute && (
|
||||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>
|
<span className="badge badge-warning" style={{ fontSize: 10 }}>
|
||||||
{tr('0 通道')}
|
{tr('0 通道')}
|
||||||
@@ -445,35 +468,29 @@ function RouteCardInner({
|
|||||||
|
|
||||||
<AnimatedCollapseSection open={isGroupExpanded}>
|
<AnimatedCollapseSection open={isGroupExpanded}>
|
||||||
{explicitGroupRoute ? (
|
{explicitGroupRoute ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||||
{group.channels.map((channel) => (
|
{group.channels.map((channel) => {
|
||||||
<div
|
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
||||||
|
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||||
|
return (
|
||||||
|
<SortableChannelRow
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
style={{
|
channel={channel}
|
||||||
display: 'flex',
|
decisionCandidate={decisionMap.get(channel.id)}
|
||||||
alignItems: 'center',
|
isExactRoute={exactRoute}
|
||||||
gap: 8,
|
loadingDecision={loadingDecision}
|
||||||
flexWrap: 'wrap',
|
isSavingPriority={!!savingPriority}
|
||||||
padding: '8px 12px',
|
readOnly
|
||||||
borderLeft: '2px solid var(--color-primary)',
|
tokenOptions={tokenOptions}
|
||||||
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
|
activeTokenId={activeTokenId}
|
||||||
background: 'rgba(79,70,229,0.02)',
|
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||||
}}
|
onTokenDraftChange={onTokenDraftChange}
|
||||||
>
|
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||||
{channel.account?.username || `account-${channel.accountId}`}
|
/>
|
||||||
</span>
|
);
|
||||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
})}
|
||||||
{channel.site?.name || 'unknown'}
|
</SortableContext>
|
||||||
</span>
|
|
||||||
{channel.sourceModel ? (
|
|
||||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
|
||||||
{channel.sourceModel}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||||
{group.channels.map((channel) => {
|
{group.channels.map((channel) => {
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
|
|||||||
key={groupRoute.id}
|
key={groupRoute.id}
|
||||||
active={activeGroupFilter === groupRoute.id}
|
active={activeGroupFilter === groupRoute.id}
|
||||||
label={groupRoute.title}
|
label={groupRoute.title}
|
||||||
count={groupRoute.channelCount}
|
count={groupRoute.sourceRouteCount > 0 ? groupRoute.sourceRouteCount : groupRoute.channelCount}
|
||||||
icon={
|
icon={
|
||||||
groupRoute.icon.kind === 'brand' ? (
|
groupRoute.icon.kind === 'brand' ? (
|
||||||
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
|
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function SortableChannelRow({
|
|||||||
isExactRoute,
|
isExactRoute,
|
||||||
loadingDecision,
|
loadingDecision,
|
||||||
isSavingPriority,
|
isSavingPriority,
|
||||||
|
readOnly = false,
|
||||||
tokenOptions,
|
tokenOptions,
|
||||||
activeTokenId,
|
activeTokenId,
|
||||||
isUpdatingToken,
|
isUpdatingToken,
|
||||||
@@ -33,7 +34,7 @@ export function SortableChannelRow({
|
|||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
disabled: isSavingPriority,
|
disabled: isSavingPriority || readOnly,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowStyle: CSSProperties = {
|
const rowStyle: CSSProperties = {
|
||||||
@@ -42,7 +43,7 @@ export function SortableChannelRow({
|
|||||||
opacity: isDragging ? 0.72 : 1,
|
opacity: isDragging ? 0.72 : 1,
|
||||||
zIndex: isDragging ? 10 : 1,
|
zIndex: isDragging ? 10 : 1,
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
|
gridTemplateColumns: readOnly ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
@@ -63,7 +64,7 @@ export function SortableChannelRow({
|
|||||||
ref={setActivatorNodeRef}
|
ref={setActivatorNodeRef}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
disabled={isSavingPriority}
|
disabled={isSavingPriority || readOnly}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{
|
style={{
|
||||||
width: 22,
|
width: 22,
|
||||||
@@ -72,9 +73,10 @@ export function SortableChannelRow({
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
border: '1px solid var(--color-border-light)',
|
border: '1px solid var(--color-border-light)',
|
||||||
color: 'var(--color-text-muted)',
|
color: 'var(--color-text-muted)',
|
||||||
cursor: isSavingPriority ? 'not-allowed' : 'grab',
|
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
|
||||||
|
opacity: readOnly ? 0.65 : 1,
|
||||||
}}
|
}}
|
||||||
data-tooltip="拖拽调整优先级"
|
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
|
||||||
aria-label="拖拽调整优先级"
|
aria-label="拖拽调整优先级"
|
||||||
>
|
>
|
||||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
||||||
@@ -197,6 +199,8 @@ export function SortableChannelRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!readOnly ? (
|
||||||
|
<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<div style={{ minWidth: 220, flex: 1 }}>
|
<div style={{ minWidth: 220, flex: 1 }}>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
@@ -237,6 +241,8 @@ export function SortableChannelRow({
|
|||||||
>
|
>
|
||||||
移除
|
移除
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export type SortableChannelRowProps = {
|
|||||||
isExactRoute: boolean;
|
isExactRoute: boolean;
|
||||||
loadingDecision: boolean;
|
loadingDecision: boolean;
|
||||||
isSavingPriority: boolean;
|
isSavingPriority: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
tokenOptions: RouteTokenOption[];
|
tokenOptions: RouteTokenOption[];
|
||||||
activeTokenId: number;
|
activeTokenId: number;
|
||||||
isUpdatingToken: boolean;
|
isUpdatingToken: boolean;
|
||||||
@@ -165,4 +166,5 @@ export type GroupRouteItem = {
|
|||||||
brand: BrandInfo | null;
|
brand: BrandInfo | null;
|
||||||
modelPattern: string;
|
modelPattern: string;
|
||||||
channelCount: number;
|
channelCount: number;
|
||||||
|
sourceRouteCount: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,6 +48,23 @@ function findButtonByText(root: ReactTestInstance, text: string): ReactTestInsta
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findButtonByClassAndText(root: ReactTestInstance, className: string, text: string): ReactTestInstance {
|
||||||
|
return root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& String(node.props.className || '').includes(className)
|
||||||
|
&& collectText(node).includes(text)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findButtonByAriaLabel(root: ReactTestInstance, label: string): ReactTestInstance {
|
||||||
|
return root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props['aria-label'] === 'string'
|
||||||
|
&& node.props['aria-label'] === label
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string): ReactTestInstance {
|
function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string): ReactTestInstance {
|
||||||
return root.find((node) => (
|
return root.find((node) => (
|
||||||
node.type === 'input'
|
node.type === 'input'
|
||||||
@@ -865,6 +882,195 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the source picker like the route page with brand, site, ability filters and a card grid', async () => {
|
||||||
|
getBrandMock.mockImplementation((modelName: string) => {
|
||||||
|
const model = String(modelName);
|
||||||
|
if (model.includes('gpt')) {
|
||||||
|
return { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg,#111,#555)' };
|
||||||
|
}
|
||||||
|
if (model.includes('claude')) {
|
||||||
|
return { name: 'Anthropic', icon: 'anthropic', color: 'linear-gradient(135deg,#d97706,#f59e0b)' };
|
||||||
|
}
|
||||||
|
if (model.includes('gemini')) {
|
||||||
|
return { name: 'Gemini', icon: 'gemini', color: 'linear-gradient(135deg,#2563eb,#7c3aed)' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 11, modelPattern: 'gpt-5.4', displayName: null,
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
routeMode: 'pattern', sourceRouteIds: [],
|
||||||
|
channelCount: 3, enabledChannelCount: 3, siteNames: ['Wong'],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
routeMode: 'pattern', sourceRouteIds: [],
|
||||||
|
channelCount: 2, enabledChannelCount: 2, siteNames: ['Alpha'],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13, modelPattern: 'gemini-2.5-pro', displayName: null,
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
routeMode: 'pattern', sourceRouteIds: [],
|
||||||
|
channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||||
|
models: {},
|
||||||
|
endpointTypesByModel: {
|
||||||
|
'gpt-5.4': ['openai'],
|
||||||
|
'claude-sonnet-4-5': ['anthropic'],
|
||||||
|
'gemini-2.5-pro': ['gemini'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByText(root.root, '新建群组').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByText(root.root, '选择来源模型').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const pickerGrid = root.root.find((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& String(node.props.className || '').includes('source-route-picker-grid')
|
||||||
|
));
|
||||||
|
expect(String(pickerGrid.props.style?.display || '')).toBe('grid');
|
||||||
|
expect(String(pickerGrid.props.style?.gridTemplateColumns || '')).toContain('repeat(');
|
||||||
|
|
||||||
|
expect(findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI')).toBeTruthy();
|
||||||
|
expect(findButtonByClassAndText(root.root, 'filter-chip', 'Wong')).toBeTruthy();
|
||||||
|
expect(findButtonByClassAndText(root.root, 'filter-chip', 'gemini')).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(collectText(pickerGrid)).toContain('gpt-5.4');
|
||||||
|
expect(collectText(pickerGrid)).toContain('gemini-2.5-pro');
|
||||||
|
expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(collectText(pickerGrid)).toContain('OpenAI');
|
||||||
|
expect(collectText(pickerGrid)).toContain('gpt-5.4');
|
||||||
|
expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5');
|
||||||
|
expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByClassAndText(root.root, 'filter-chip', 'anthropic').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(collectText(pickerGrid)).toContain('Anthropic');
|
||||||
|
expect(collectText(pickerGrid)).toContain('claude-sonnet-4-5');
|
||||||
|
expect(collectText(pickerGrid)).not.toContain('gpt-5.4');
|
||||||
|
expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows explicit-group source counts instead of aggregated channel counts in the route list and filter chips', async () => {
|
||||||
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 11, modelPattern: 'deepseek-chat', displayName: null,
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
routeMode: 'pattern', sourceRouteIds: [],
|
||||||
|
channelCount: 40, enabledChannelCount: 40, siteNames: ['Wong'],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12, modelPattern: 'deepseek-reasoner', displayName: null,
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
routeMode: 'pattern', sourceRouteIds: [],
|
||||||
|
channelCount: 55, enabledChannelCount: 55, siteNames: ['Wong'],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 21, modelPattern: 'deepseekv1', displayName: 'deepseekv1',
|
||||||
|
displayIcon: '', modelMapping: null, enabled: true,
|
||||||
|
routeMode: 'explicit_group', sourceRouteIds: [11, 12],
|
||||||
|
channelCount: 95, enabledChannelCount: 95, siteNames: ['Wong'],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getRouteChannels.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 101, accountId: 1, tokenId: 1, sourceModel: 'deepseek-chat',
|
||||||
|
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||||
|
successCount: 0, failCount: 0,
|
||||||
|
account: { username: 'user-a' }, site: { name: 'Wong' }, token: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const filterSummary = root.root.find((node) =>
|
||||||
|
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
filterSummary.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const groupChip = findButtonByClassAndText(root.root, 'filter-chip', 'deepseekv1');
|
||||||
|
expect(collectText(groupChip)).toContain('2');
|
||||||
|
expect(collectText(groupChip)).not.toContain('95');
|
||||||
|
|
||||||
|
const routeCard = root.root.find((node) =>
|
||||||
|
node.type === 'div'
|
||||||
|
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||||
|
&& collectText(node).includes('deepseekv1'),
|
||||||
|
);
|
||||||
|
expect(collectText(routeCard).replace(/\s+/g, '')).toContain('2来源模型');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => {
|
it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => {
|
||||||
apiMock.getRoutesSummary.mockResolvedValue([
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -1169,4 +1375,80 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses the standard channel row presentation for explicit-group details in read-only mode', async () => {
|
||||||
|
apiMock.getRoutesSummary.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 11, modelPattern: 'claude-haiku-4-5-20251001', displayName: null,
|
||||||
|
displayIcon: null, modelMapping: null, enabled: true,
|
||||||
|
routeMode: 'pattern', sourceRouteIds: [],
|
||||||
|
channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 21, modelPattern: 'claude-haiku-proxy', displayName: 'claude-haiku-proxy',
|
||||||
|
displayIcon: '', modelMapping: null, enabled: true,
|
||||||
|
routeMode: 'explicit_group', sourceRouteIds: [11],
|
||||||
|
channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'],
|
||||||
|
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getRouteChannels.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
routeId: 11,
|
||||||
|
accountId: 301,
|
||||||
|
tokenId: 401,
|
||||||
|
sourceModel: 'claude-haiku-4-5-20251001',
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
successCount: 6,
|
||||||
|
failCount: 1,
|
||||||
|
account: { username: 'linuxdo_131936' },
|
||||||
|
site: { name: 'Wong' },
|
||||||
|
token: { id: 401, name: 'token-a', accountId: 301, enabled: true, isDefault: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const expandBtn = root.root.find((node) =>
|
||||||
|
node.type === 'div'
|
||||||
|
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||||
|
&& collectText(node).includes('claude-haiku-proxy'),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
expandBtn.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByText(root.root, 'claude-haiku-4-5-20251001').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const expandedText = collectText(root.root);
|
||||||
|
expect(expandedText).toContain('P0');
|
||||||
|
expect(expandedText).toContain('当前生效:token-a');
|
||||||
|
expect(expandedText).toContain('选中概率');
|
||||||
|
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级').props.disabled).toBe(true);
|
||||||
|
expect(expandedText).not.toContain('保存');
|
||||||
|
expect(expandedText).not.toContain('移除');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user