fix: restore frontend source code (src/web/) that was accidentally deleted
Frontend source is needed for development/building the SPA. Only Node.js runtime server code should be removed.
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isTruthyFlag,
|
||||
parsePositiveInt,
|
||||
resolveAccountCredentialMode,
|
||||
} from './accountConnection.js';
|
||||
|
||||
describe('accountConnection helpers', () => {
|
||||
it('resolves account credential mode from explicit mode, capabilities, and token presence', () => {
|
||||
expect(resolveAccountCredentialMode({ credentialMode: 'apikey' })).toBe('apikey');
|
||||
expect(resolveAccountCredentialMode({ capabilities: { proxyOnly: true } })).toBe('apikey');
|
||||
expect(resolveAccountCredentialMode({ accessToken: ' session-token ' })).toBe('session');
|
||||
expect(resolveAccountCredentialMode({
|
||||
credentialMode: 'session',
|
||||
capabilities: { proxyOnly: true },
|
||||
accessToken: 'api-token-ignored-by-explicit-mode',
|
||||
})).toBe('session');
|
||||
expect(resolveAccountCredentialMode({})).toBe('apikey');
|
||||
});
|
||||
|
||||
it('parses positive integers from query values', () => {
|
||||
expect(parsePositiveInt('42')).toBe(42);
|
||||
expect(parsePositiveInt(' 0 ')).toBe(0);
|
||||
expect(parsePositiveInt('abc')).toBe(0);
|
||||
expect(parsePositiveInt('42abc')).toBe(0);
|
||||
expect(parsePositiveInt('1e3')).toBe(0);
|
||||
expect(parsePositiveInt('1.5')).toBe(0);
|
||||
expect(parsePositiveInt(null)).toBe(0);
|
||||
});
|
||||
|
||||
it('treats common truthy query flags as enabled', () => {
|
||||
expect(isTruthyFlag('1')).toBe(true);
|
||||
expect(isTruthyFlag(' TRUE ')).toBe(true);
|
||||
expect(isTruthyFlag('yes')).toBe(true);
|
||||
expect(isTruthyFlag('no')).toBe(false);
|
||||
expect(isTruthyFlag(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
export function resolveAccountCredentialMode(account: any): 'session' | 'apikey' {
|
||||
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
if (rawMode === 'apikey') return 'apikey';
|
||||
if (rawMode === 'session') return 'session';
|
||||
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
|
||||
return account.capabilities.proxyOnly ? 'apikey' : 'session';
|
||||
}
|
||||
return typeof account?.accessToken === 'string' && account.accessToken.trim()
|
||||
? 'session'
|
||||
: 'apikey';
|
||||
}
|
||||
|
||||
export function parsePositiveInt(input: string | null): number {
|
||||
const normalized = String(input || '').trim();
|
||||
if (!/^\d+$/.test(normalized)) return 0;
|
||||
const value = Number.parseInt(normalized, 10);
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function isTruthyFlag(input: string | null): boolean {
|
||||
if (!input) return false;
|
||||
const normalized = input.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildAddAccountPrereqHint,
|
||||
buildVerifyFailureHint,
|
||||
normalizeVerifyFailureMessage,
|
||||
} from './accountVerifyFeedback.js';
|
||||
|
||||
describe('account verify feedback', () => {
|
||||
it('treats fetch failures as connectivity issues instead of token mistakes', () => {
|
||||
expect(normalizeVerifyFailureMessage('Failed to fetch')).not.toBe('Failed to fetch');
|
||||
expect(buildVerifyFailureHint({ success: false, message: 'Failed to fetch' })).not.toBeNull();
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: 'Failed to fetch' })).toMatch(/metapi|站点|代理/i);
|
||||
});
|
||||
|
||||
it('treats timeout failures as reachability issues instead of token mistakes', () => {
|
||||
const timeoutMessage = 'Token verification timed out (10s)';
|
||||
expect(normalizeVerifyFailureMessage(timeoutMessage)).toBe(timeoutMessage);
|
||||
expect(buildVerifyFailureHint({ success: false, message: timeoutMessage })).toMatch(/Token|站点|代理|超时/i);
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: timeoutMessage })).toMatch(/站点|代理|超时/i);
|
||||
});
|
||||
|
||||
it('keeps token guidance for actual credential failures', () => {
|
||||
const invalidHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
|
||||
expect(invalidHint).toMatch(/Token/i);
|
||||
expect(buildAddAccountPrereqHint({ success: false, message: 'Token invalid' })).toMatch(/Token/i);
|
||||
});
|
||||
|
||||
it('normalizes invalid user-id mismatch messages for display', () => {
|
||||
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
|
||||
.toMatch(/ID/i);
|
||||
expect(normalizeVerifyFailureMessage('The provided user ID does not match this token. Please check your site user ID.'))
|
||||
.not.toContain('does not match this token');
|
||||
});
|
||||
|
||||
it('prioritizes user-id guidance when the site requires it', () => {
|
||||
expect(buildVerifyFailureHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toBeNull();
|
||||
expect(buildAddAccountPrereqHint({ success: false, needsUserId: true, message: 'missing New-Api-User' })).toMatch(/ID/i);
|
||||
});
|
||||
|
||||
it('treats invalid user-id as a mismatch instead of a missing-value hint', () => {
|
||||
const invalidUserIdHint = buildVerifyFailureHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
|
||||
const genericTokenHint = buildVerifyFailureHint({ success: false, message: 'Token invalid' });
|
||||
const invalidUserIdPrereq = buildAddAccountPrereqHint({ success: false, invalidUserId: true, message: 'user id mismatch' });
|
||||
const genericPrereq = buildAddAccountPrereqHint({ success: false, message: 'Token invalid' });
|
||||
|
||||
expect(invalidUserIdHint).toMatch(/ID/i);
|
||||
expect(invalidUserIdHint).not.toBe(genericTokenHint);
|
||||
expect(invalidUserIdPrereq).toMatch(/ID/i);
|
||||
expect(invalidUserIdPrereq).not.toBe(genericPrereq);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
type VerifyResultLike = {
|
||||
success?: boolean;
|
||||
needsUserId?: boolean;
|
||||
invalidUserId?: boolean;
|
||||
message?: string | null;
|
||||
} | null | undefined;
|
||||
|
||||
function normalizeMessageText(message: unknown): string {
|
||||
return typeof message === 'string' ? message.trim() : '';
|
||||
}
|
||||
|
||||
export function isNetworkFailureMessage(message: unknown): boolean {
|
||||
const lowered = normalizeMessageText(message).toLowerCase();
|
||||
return lowered.includes('failed to fetch')
|
||||
|| lowered.includes('fetch failed')
|
||||
|| lowered.includes('networkerror')
|
||||
|| lowered.includes('load failed');
|
||||
}
|
||||
|
||||
export function isTimeoutFailureMessage(message: unknown): boolean {
|
||||
const lowered = normalizeMessageText(message).toLowerCase();
|
||||
return lowered.includes('timed out')
|
||||
|| lowered.includes('timeout')
|
||||
|| lowered.includes('请求超时');
|
||||
}
|
||||
|
||||
export function normalizeVerifyFailureMessage(message: unknown): string {
|
||||
const text = normalizeMessageText(message);
|
||||
if (!text) return '验证失败';
|
||||
const lowered = text.toLowerCase();
|
||||
if (isNetworkFailureMessage(text)) {
|
||||
return '无法连接到 BoosAPI 服务端,请检查服务状态或网络连接';
|
||||
}
|
||||
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
|
||||
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function buildVerifyFailureHint(result: VerifyResultLike): string | null {
|
||||
if (!result || result.success || result.needsUserId) return null;
|
||||
if (result.invalidUserId) {
|
||||
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。请检查 BoosAPI 服务是否在线,以及目标站点或代理是否可达。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
|
||||
}
|
||||
return '请检查 Token 是否正确';
|
||||
}
|
||||
|
||||
export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
|
||||
if (!result) {
|
||||
return '请先点击“验证 Token”,验证成功后才能添加账号。';
|
||||
}
|
||||
if (result.success) return '';
|
||||
if (result.needsUserId) {
|
||||
return '请先补充用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (result.invalidUserId) {
|
||||
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
|
||||
}
|
||||
if (isNetworkFailureMessage(result.message)) {
|
||||
return '验证请求未成功完成,请先检查 BoosAPI 服务、站点网络或代理配置。';
|
||||
}
|
||||
if (isTimeoutFailureMessage(result.message)) {
|
||||
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||
}
|
||||
return '请先点击“验证 Token”,验证成功后才能添加账号。';
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getAccountsAddPanelStyle } from './accountsPanelStyle.js';
|
||||
|
||||
describe('getAccountsAddPanelStyle', () => {
|
||||
it('preserves spacing and raises stacking context so select dropdown is clickable', () => {
|
||||
const style = getAccountsAddPanelStyle();
|
||||
|
||||
expect(style.padding).toBe(20);
|
||||
expect(style.marginBottom).toBe(16);
|
||||
expect(style.position).toBe('relative');
|
||||
expect(style.zIndex).toBeGreaterThan(0);
|
||||
expect(style.overflow).toBe('visible');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getAccountsAddPanelStyle() {
|
||||
return {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
position: 'relative' as const,
|
||||
zIndex: 10,
|
||||
overflow: 'visible' as const,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatCheckinLogTime,
|
||||
formatDateLocal,
|
||||
formatDateTimeMinuteLocal,
|
||||
} from './checkinLogTime.js';
|
||||
|
||||
describe('formatCheckinLogTime', () => {
|
||||
it('formats sqlite UTC timestamp as local display time', () => {
|
||||
const formatted = formatCheckinLogTime('2026-02-25 03:51:58', 'en-US', 'UTC');
|
||||
expect(formatted).toBe('02/25/2026, 03:51:58');
|
||||
});
|
||||
|
||||
it('keeps original value for invalid timestamp', () => {
|
||||
expect(formatCheckinLogTime('invalid-time', 'en-US', 'UTC')).toBe('invalid-time');
|
||||
});
|
||||
|
||||
it('formats local date with minute precision helpers', () => {
|
||||
expect(formatDateLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026');
|
||||
expect(formatDateTimeMinuteLocal('2026-02-25 03:51:58', 'en-US', 'UTC')).toBe('02/25/2026, 03:51');
|
||||
});
|
||||
|
||||
it('supports postgres-style timezone offset without colon', () => {
|
||||
expect(formatDateTimeMinuteLocal('2026-03-05 20:14:39+08', 'en-US', 'UTC')).toBe('03/05/2026, 12:14');
|
||||
expect(formatDateTimeMinuteLocal('2026-03-05T20:14:39+0800', 'en-US', 'UTC')).toBe('03/05/2026, 12:14');
|
||||
});
|
||||
|
||||
it('supports unix timestamp strings', () => {
|
||||
expect(formatDateTimeMinuteLocal('1709640000', 'en-US', 'UTC')).toBe('03/05/2024, 12:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
function parseServerUtcDate(value: string | null | undefined): { date: Date | null; raw: string } {
|
||||
if (!value) return { date: null, raw: '' };
|
||||
|
||||
const raw = String(value).trim();
|
||||
if (!raw) return { date: null, raw };
|
||||
|
||||
if (/^\d{10,13}$/.test(raw)) {
|
||||
const asNumber = Number(raw);
|
||||
if (Number.isFinite(asNumber)) {
|
||||
const epochMs = raw.length === 13 ? asNumber : asNumber * 1000;
|
||||
const date = new Date(epochMs);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return { date, raw };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let normalized = raw;
|
||||
if (!normalized.includes('T') && normalized.includes(' ')) {
|
||||
normalized = normalized.replace(' ', 'T');
|
||||
}
|
||||
normalized = normalized.replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
|
||||
normalized = normalized.replace(/([+-]\d{2})$/, '$1:00');
|
||||
|
||||
const hasTimeZone = /[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized);
|
||||
if (!hasTimeZone) {
|
||||
normalized = `${normalized}Z`;
|
||||
}
|
||||
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return { date: null, raw };
|
||||
}
|
||||
|
||||
return { date, raw };
|
||||
}
|
||||
|
||||
export function parseServerUtcDateTime(value: string | null | undefined): Date | null {
|
||||
return parseServerUtcDate(value).date;
|
||||
}
|
||||
|
||||
function formatWithParts(
|
||||
value: string | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
locale = 'zh-CN',
|
||||
timeZone?: string,
|
||||
): string {
|
||||
const { date, raw } = parseServerUtcDate(value);
|
||||
if (!date) return raw || '-';
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
...options,
|
||||
...(timeZone ? { timeZone } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatDateTimeLocal(
|
||||
value: string | null | undefined,
|
||||
locale = 'zh-CN',
|
||||
timeZone?: string,
|
||||
): string {
|
||||
return formatWithParts(value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}, locale, timeZone);
|
||||
}
|
||||
|
||||
export function formatDateTimeMinuteLocal(
|
||||
value: string | null | undefined,
|
||||
locale = 'zh-CN',
|
||||
timeZone?: string,
|
||||
): string {
|
||||
return formatWithParts(value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}, locale, timeZone);
|
||||
}
|
||||
|
||||
export function formatDateLocal(
|
||||
value: string | null | undefined,
|
||||
locale = 'zh-CN',
|
||||
timeZone?: string,
|
||||
): string {
|
||||
return formatWithParts(value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}, locale, timeZone);
|
||||
}
|
||||
|
||||
export function formatCheckinLogTime(
|
||||
value: string | null | undefined,
|
||||
locale?: string,
|
||||
timeZone?: string,
|
||||
): string {
|
||||
return formatDateTimeLocal(value, locale, timeZone);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildConversationFileAccept,
|
||||
buildConversationFileHint,
|
||||
isConversationUploadedFileSupported,
|
||||
resolveConversationFileCapability,
|
||||
} from './conversationFileCapabilities.js';
|
||||
|
||||
describe('resolveConversationFileCapability', () => {
|
||||
it('keeps OpenAI and Responses on native file references', () => {
|
||||
expect(resolveConversationFileCapability('openai')).toEqual({
|
||||
supported: true,
|
||||
imageMode: 'native',
|
||||
audioMode: 'native',
|
||||
documentMode: 'native',
|
||||
reason: '',
|
||||
});
|
||||
expect(resolveConversationFileCapability('responses')).toEqual({
|
||||
supported: true,
|
||||
imageMode: 'native',
|
||||
audioMode: 'native',
|
||||
documentMode: 'native',
|
||||
reason: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Claude and Gemini media support separately', () => {
|
||||
expect(resolveConversationFileCapability('claude')).toEqual({
|
||||
supported: true,
|
||||
imageMode: 'native',
|
||||
audioMode: 'unsupported',
|
||||
documentMode: 'inline_only',
|
||||
reason: '当前界面的会话附件会以内联文档方式发送。',
|
||||
});
|
||||
expect(resolveConversationFileCapability('gemini')).toEqual({
|
||||
supported: true,
|
||||
imageMode: 'native',
|
||||
audioMode: 'native',
|
||||
documentMode: 'inline_only',
|
||||
reason: '当前界面的会话附件会以内联文档方式发送。',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the accept list from the supported file kinds', () => {
|
||||
expect(buildConversationFileAccept(resolveConversationFileCapability('claude'))).toBe(
|
||||
'.pdf,.txt,.md,.markdown,.json,image/*',
|
||||
);
|
||||
expect(buildConversationFileAccept(resolveConversationFileCapability('gemini'))).toBe(
|
||||
'.pdf,.txt,.md,.markdown,.json,image/*,audio/*',
|
||||
);
|
||||
});
|
||||
|
||||
it('describes the actual per-kind transport behavior', () => {
|
||||
expect(buildConversationFileHint(resolveConversationFileCapability('claude'))).toBe(
|
||||
'支持 PDF / TXT / Markdown / JSON / 图片;文档会以内联数据注入,图片会按图片部件发送。',
|
||||
);
|
||||
expect(buildConversationFileHint(resolveConversationFileCapability('gemini'))).toBe(
|
||||
'支持 PDF / TXT / Markdown / JSON / 图片 / 音频;文档会以内联数据注入,图片会按图片部件发送,音频会按音频部件发送。',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unsupported file kinds before send', () => {
|
||||
const claudeCapability = resolveConversationFileCapability('claude');
|
||||
|
||||
expect(isConversationUploadedFileSupported(claudeCapability, {
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
})).toBe(true);
|
||||
expect(isConversationUploadedFileSupported(claudeCapability, {
|
||||
filename: 'chart.png',
|
||||
mimeType: 'image/png',
|
||||
})).toBe(true);
|
||||
expect(isConversationUploadedFileSupported(claudeCapability, {
|
||||
filename: 'voice.mp3',
|
||||
mimeType: 'audio/mpeg',
|
||||
})).toBe(false);
|
||||
expect(isConversationUploadedFileSupported(claudeCapability, {
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/octet-stream',
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PlaygroundProtocol } from './modelTesterSession.js';
|
||||
import {
|
||||
buildConversationAcceptList,
|
||||
detectConversationFileKind,
|
||||
} from '../../../shared/conversationFileTypes.js';
|
||||
|
||||
export type ConversationFileTransportMode = 'native' | 'inline_only' | 'unsupported';
|
||||
|
||||
export type ConversationFileCapability = {
|
||||
supported: boolean;
|
||||
imageMode: ConversationFileTransportMode;
|
||||
audioMode: ConversationFileTransportMode;
|
||||
documentMode: ConversationFileTransportMode;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
type ConversationFileDescriptor = {
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
const isSupportedMode = (mode: ConversationFileTransportMode): boolean => mode !== 'unsupported';
|
||||
|
||||
function buildSupportedTypeLabel(capability: ConversationFileCapability): string {
|
||||
const labels: string[] = [];
|
||||
if (isSupportedMode(capability.documentMode)) labels.push('PDF / TXT / Markdown / JSON');
|
||||
if (isSupportedMode(capability.imageMode)) labels.push('图片');
|
||||
if (isSupportedMode(capability.audioMode)) labels.push('音频');
|
||||
return labels.join(' / ');
|
||||
}
|
||||
|
||||
function buildTransportNotes(capability: ConversationFileCapability): string[] {
|
||||
const notes: string[] = [];
|
||||
if (capability.documentMode === 'inline_only') notes.push('文档会以内联数据注入');
|
||||
if (capability.imageMode === 'native' && capability.documentMode === 'inline_only') {
|
||||
notes.push('图片会按图片部件发送');
|
||||
}
|
||||
if (capability.audioMode === 'native' && capability.documentMode === 'inline_only') {
|
||||
notes.push('音频会按音频部件发送');
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
export function resolveConversationFileCapability(
|
||||
protocol: PlaygroundProtocol,
|
||||
): ConversationFileCapability {
|
||||
if (protocol === 'openai' || protocol === 'responses') {
|
||||
return {
|
||||
supported: true,
|
||||
imageMode: 'native',
|
||||
audioMode: 'native',
|
||||
documentMode: 'native',
|
||||
reason: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (protocol === 'claude') {
|
||||
return {
|
||||
supported: true,
|
||||
imageMode: 'native',
|
||||
audioMode: 'unsupported',
|
||||
documentMode: 'inline_only',
|
||||
reason: '当前界面的会话附件会以内联文档方式发送。',
|
||||
};
|
||||
}
|
||||
|
||||
if (protocol === 'gemini') {
|
||||
return {
|
||||
supported: true,
|
||||
imageMode: 'native',
|
||||
audioMode: 'native',
|
||||
documentMode: 'inline_only',
|
||||
reason: '当前界面的会话附件会以内联文档方式发送。',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: false,
|
||||
imageMode: 'unsupported',
|
||||
audioMode: 'unsupported',
|
||||
documentMode: 'unsupported',
|
||||
reason: '当前协议暂不支持会话附件。',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConversationFileAccept(capability: ConversationFileCapability): string {
|
||||
return buildConversationAcceptList({
|
||||
document: isSupportedMode(capability.documentMode),
|
||||
image: isSupportedMode(capability.imageMode),
|
||||
audio: isSupportedMode(capability.audioMode),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildConversationFileHint(capability: ConversationFileCapability): string {
|
||||
if (!capability.supported) {
|
||||
return capability.reason || '当前协议暂不支持会话附件。';
|
||||
}
|
||||
|
||||
const typeLabel = buildSupportedTypeLabel(capability);
|
||||
if (!typeLabel) {
|
||||
return capability.reason || '当前协议暂不支持会话附件。';
|
||||
}
|
||||
|
||||
if (
|
||||
capability.documentMode === 'native'
|
||||
&& capability.imageMode === 'native'
|
||||
&& capability.audioMode === 'native'
|
||||
) {
|
||||
return `支持 ${typeLabel};发送前会先上传到 /v1/files。`;
|
||||
}
|
||||
|
||||
const notes = buildTransportNotes(capability);
|
||||
if (notes.length <= 0) {
|
||||
return `支持 ${typeLabel}。`;
|
||||
}
|
||||
return `支持 ${typeLabel};${notes.join(',')}。`;
|
||||
}
|
||||
|
||||
export function isConversationUploadedFileSupported(
|
||||
capability: ConversationFileCapability,
|
||||
file: ConversationFileDescriptor,
|
||||
): boolean {
|
||||
const kind = detectConversationFileKind(file);
|
||||
if (kind === 'document') return isSupportedMode(capability.documentMode);
|
||||
if (kind === 'image') return isSupportedMode(capability.imageMode);
|
||||
if (kind === 'audio') return isSupportedMode(capability.audioMode);
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveInitialConnectionSegment } from './defaultConnectionSegment.js';
|
||||
|
||||
describe('defaultConnectionSegment', () => {
|
||||
it('maps management-style platforms to the session segment', () => {
|
||||
expect(resolveInitialConnectionSegment('new-api')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('one-api')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('anyrouter')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('veloera')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('one-hub')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('done-hub')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('sub2api')).toBe('session');
|
||||
expect(resolveInitialConnectionSegment('codex')).toBe('session');
|
||||
});
|
||||
|
||||
it('maps API-key-only platforms to the apikey segment', () => {
|
||||
expect(resolveInitialConnectionSegment('openai')).toBe('apikey');
|
||||
expect(resolveInitialConnectionSegment('claude')).toBe('apikey');
|
||||
expect(resolveInitialConnectionSegment('gemini')).toBe('apikey');
|
||||
expect(resolveInitialConnectionSegment('cliproxyapi')).toBe('apikey');
|
||||
});
|
||||
|
||||
it('falls back to the apikey segment for unknown platforms', () => {
|
||||
expect(resolveInitialConnectionSegment('')).toBe('apikey');
|
||||
expect(resolveInitialConnectionSegment('unknown-platform')).toBe('apikey');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export type InitialConnectionSegment = 'session' | 'apikey';
|
||||
|
||||
const SESSION_FIRST_PLATFORMS = new Set([
|
||||
'new-api',
|
||||
'one-api',
|
||||
'anyrouter',
|
||||
'veloera',
|
||||
'one-hub',
|
||||
'done-hub',
|
||||
'sub2api',
|
||||
'codex',
|
||||
]);
|
||||
|
||||
const API_KEY_FIRST_PLATFORMS = new Set([
|
||||
'openai',
|
||||
'claude',
|
||||
'gemini',
|
||||
'cliproxyapi',
|
||||
]);
|
||||
|
||||
export function resolveInitialConnectionSegment(platform?: string | null): InitialConnectionSegment {
|
||||
const normalized = String(platform || '').trim().toLowerCase();
|
||||
if (SESSION_FIRST_PLATFORMS.has(normalized)) return 'session';
|
||||
if (API_KEY_FIRST_PLATFORMS.has(normalized)) return 'apikey';
|
||||
return 'apikey';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fuzzyMatch } from './fuzzySearch.js';
|
||||
|
||||
describe('fuzzyMatch', () => {
|
||||
it('matches plain substring case-insensitively', () => {
|
||||
expect(fuzzyMatch('claude-opus-4-6', 'OPUS')).toBe(true);
|
||||
expect(fuzzyMatch('gpt-4o-mini', 'gpt')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches after removing separators', () => {
|
||||
expect(fuzzyMatch('gpt-5.2-codex', 'gpt52')).toBe(true);
|
||||
expect(fuzzyMatch('re:^claude-(opus|sonnet)-4-5$', 'claude45')).toBe(true);
|
||||
});
|
||||
|
||||
it('supports subsequence fuzzy matching', () => {
|
||||
expect(fuzzyMatch('cerebras-llama-3.1-8b', 'cll318')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when query cannot be matched', () => {
|
||||
expect(fuzzyMatch('claude-opus-4-6', 'gemini')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
function normalize(value: string): string {
|
||||
return (value || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
function compact(value: string): string {
|
||||
return normalize(value).replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function isSubsequence(text: string, query: string): boolean {
|
||||
if (!query) return true;
|
||||
let qi = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === query[qi]) qi += 1;
|
||||
if (qi >= query.length) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function fuzzyMatch(text: string, query: string): boolean {
|
||||
const q = normalize(query);
|
||||
if (!q) return true;
|
||||
|
||||
const t = normalize(text);
|
||||
if (t.includes(q)) return true;
|
||||
|
||||
const compactText = compact(t);
|
||||
const compactQuery = compact(q);
|
||||
if (!compactQuery) return true;
|
||||
if (compactText.includes(compactQuery)) return true;
|
||||
|
||||
return isSubsequence(compactText, compactQuery);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function generateDownstreamSkKey(prefix: string): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${prefix}${hex}`;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './listSorting.js';
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
isPinned?: boolean | null;
|
||||
sortOrder?: number | null;
|
||||
balance?: number | null;
|
||||
};
|
||||
|
||||
function ids(items: Item[]): number[] {
|
||||
return items.map((item) => item.id);
|
||||
}
|
||||
|
||||
describe('sortItemsForDisplay', () => {
|
||||
const base: Item[] = [
|
||||
{ id: 1, isPinned: false, sortOrder: 2, balance: 5 },
|
||||
{ id: 2, isPinned: true, sortOrder: 1, balance: 1 },
|
||||
{ id: 3, isPinned: false, sortOrder: 0, balance: 20 },
|
||||
{ id: 4, isPinned: true, sortOrder: 0, balance: 10 },
|
||||
];
|
||||
|
||||
it('keeps pinned items first in custom mode', () => {
|
||||
const mode: SortMode = 'custom';
|
||||
const sorted = sortItemsForDisplay(base, mode, (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
|
||||
});
|
||||
|
||||
it('sorts by balance desc while keeping pinned items first', () => {
|
||||
const sorted = sortItemsForDisplay(base, 'balance-desc', (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([4, 2, 3, 1]);
|
||||
});
|
||||
|
||||
it('sorts by balance asc while keeping pinned items first', () => {
|
||||
const sorted = sortItemsForDisplay(base, 'balance-asc', (item) => item.balance || 0);
|
||||
expect(ids(sorted)).toEqual([2, 4, 1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCustomReorderUpdates', () => {
|
||||
const list: Item[] = [
|
||||
{ id: 10, isPinned: true, sortOrder: 0 },
|
||||
{ id: 11, isPinned: true, sortOrder: 1 },
|
||||
{ id: 20, isPinned: false, sortOrder: 0 },
|
||||
{ id: 21, isPinned: false, sortOrder: 1 },
|
||||
];
|
||||
|
||||
it('reorders only inside the same pinned group', () => {
|
||||
const updates = buildCustomReorderUpdates(list, 20, 'up');
|
||||
// First unpinned item cannot move above pinned group.
|
||||
expect(updates).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns normalized sortOrder updates after moving down', () => {
|
||||
const updates = buildCustomReorderUpdates(list, 20, 'down');
|
||||
expect(updates).toEqual([
|
||||
{ id: 21, sortOrder: 0 },
|
||||
{ id: 20, sortOrder: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export type SortMode = 'custom' | 'balance-desc' | 'balance-asc';
|
||||
|
||||
type SortableBase = {
|
||||
id: number;
|
||||
isPinned?: boolean | null;
|
||||
sortOrder?: number | null;
|
||||
};
|
||||
|
||||
export function sortItemsForDisplay<T extends SortableBase>(
|
||||
items: T[],
|
||||
mode: SortMode,
|
||||
getBalance: (item: T) => number,
|
||||
): T[] {
|
||||
const list = [...items];
|
||||
const customComparator = (a: T, b: T) => {
|
||||
const aPinned = a.isPinned ? 1 : 0;
|
||||
const bPinned = b.isPinned ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
|
||||
const aOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return a.id - b.id;
|
||||
};
|
||||
|
||||
if (mode === 'custom') {
|
||||
return list.sort(customComparator);
|
||||
}
|
||||
|
||||
return list.sort((a, b) => {
|
||||
const aPinned = a.isPinned ? 1 : 0;
|
||||
const bPinned = b.isPinned ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
|
||||
const aBalance = Number.isFinite(getBalance(a)) ? getBalance(a) : 0;
|
||||
const bBalance = Number.isFinite(getBalance(b)) ? getBalance(b) : 0;
|
||||
if (aBalance !== bBalance) {
|
||||
return mode === 'balance-desc' ? bBalance - aBalance : aBalance - bBalance;
|
||||
}
|
||||
|
||||
return customComparator(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCustomReorderUpdates<T extends SortableBase>(
|
||||
items: T[],
|
||||
targetId: number,
|
||||
direction: 'up' | 'down',
|
||||
): Array<{ id: number; sortOrder: number }> {
|
||||
const sorted = sortItemsForDisplay(items, 'custom', () => 0);
|
||||
const target = sorted.find((item) => item.id === targetId);
|
||||
if (!target) return [];
|
||||
|
||||
const targetPinned = !!target.isPinned;
|
||||
const group = sorted.filter((item) => !!item.isPinned === targetPinned);
|
||||
const index = group.findIndex((item) => item.id === targetId);
|
||||
if (index < 0) return [];
|
||||
|
||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (swapIndex < 0 || swapIndex >= group.length) return [];
|
||||
|
||||
const next = [...group];
|
||||
const temp = next[index];
|
||||
next[index] = next[swapIndex];
|
||||
next[swapIndex] = temp;
|
||||
|
||||
const updates: Array<{ id: number; sortOrder: number }> = [];
|
||||
next.forEach((item, idx) => {
|
||||
const prev = Number.isFinite(item.sortOrder as number) ? Number(item.sortOrder) : Number.MAX_SAFE_INTEGER;
|
||||
if (prev !== idx) {
|
||||
updates.push({ id: item.id, sortOrder: idx });
|
||||
}
|
||||
});
|
||||
|
||||
return updates;
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as modelTesterSessionModule from './modelTesterSession.js';
|
||||
import {
|
||||
DEBUG_TABS,
|
||||
DEFAULT_INPUTS,
|
||||
DEFAULT_MODE_STATE,
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
MODEL_TESTER_SESSION_VERSION,
|
||||
MESSAGE_STATUS,
|
||||
buildApiPayload,
|
||||
buildEmbeddingsRequestEnvelope,
|
||||
buildFileUploadRequestEnvelope,
|
||||
buildGeminiNativeConversationProxyEnvelope,
|
||||
buildRawProxyRequestEnvelope,
|
||||
buildSearchRequestEnvelope,
|
||||
attachForcedChannelToEnvelope,
|
||||
collectModelTesterModelNames,
|
||||
countConversationTurns,
|
||||
createConversationUserMessage,
|
||||
extractConversationUploadedFilesFromMessage,
|
||||
filterModelTesterModelNames,
|
||||
parseCustomRequestBody,
|
||||
parseModelTesterSession,
|
||||
serializeModelTesterSession,
|
||||
syncMessagesToCustomRequestBody,
|
||||
toApiMessages,
|
||||
type ChatMessage,
|
||||
type ModelTesterSessionState,
|
||||
} from './modelTesterSession.js';
|
||||
|
||||
describe('modelTesterSession', () => {
|
||||
it('counts only user messages as turns', () => {
|
||||
const turns = countConversationTurns([
|
||||
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
|
||||
{ id: '2', role: 'assistant', content: 'hi', createAt: 2 },
|
||||
{ id: '3', role: 'system', content: 'meta', createAt: 3 },
|
||||
{ id: '4', role: 'user', content: 'again', createAt: 4 },
|
||||
]);
|
||||
expect(turns).toBe(2);
|
||||
});
|
||||
|
||||
it('serializes and parses full playground session state', () => {
|
||||
const state: ModelTesterSessionState = {
|
||||
version: MODEL_TESTER_SESSION_VERSION,
|
||||
input: 'draft',
|
||||
inputs: {
|
||||
...DEFAULT_INPUTS,
|
||||
mode: 'search',
|
||||
protocol: 'gemini',
|
||||
targetFormat: 'gemini',
|
||||
model: 'gemini-2.5-pro',
|
||||
systemPrompt: 'be concise',
|
||||
temperature: 0.6,
|
||||
top_p: 0.9,
|
||||
max_tokens: 2048,
|
||||
frequency_penalty: 0.1,
|
||||
presence_penalty: 0.2,
|
||||
seed: 12,
|
||||
stream: true,
|
||||
searchMaxResults: 7,
|
||||
},
|
||||
parameterEnabled: {
|
||||
...DEFAULT_PARAMETER_ENABLED,
|
||||
max_tokens: true,
|
||||
seed: true,
|
||||
},
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', content: 'hello', createAt: 1 },
|
||||
{ id: 'm2', role: 'assistant', content: 'hi', createAt: 2, status: MESSAGE_STATUS.COMPLETE },
|
||||
],
|
||||
pendingPayload: {
|
||||
method: 'POST',
|
||||
path: '/v1/search',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
forcedChannelId: 44,
|
||||
jsonBody: { model: '__search', query: 'hello', max_results: 7 },
|
||||
},
|
||||
pendingJobId: 'job-1',
|
||||
forcedChannelId: 44,
|
||||
customRequestMode: true,
|
||||
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
|
||||
showDebugPanel: true,
|
||||
activeDebugTab: DEBUG_TABS.REQUEST,
|
||||
conversationFiles: [
|
||||
{
|
||||
localId: 'draft-file-1',
|
||||
name: 'draft.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
dataUrl: 'data:application/pdf;base64,JVBERi0x',
|
||||
fileId: 'file-metapi-draft',
|
||||
status: 'uploaded',
|
||||
errorMessage: null,
|
||||
},
|
||||
],
|
||||
modeState: {
|
||||
...DEFAULT_MODE_STATE,
|
||||
searchQuery: 'hello',
|
||||
searchAllowedDomains: 'openai.com, google.com',
|
||||
},
|
||||
};
|
||||
|
||||
const serialized = serializeModelTesterSession(state);
|
||||
const restored = parseModelTesterSession(serialized);
|
||||
|
||||
expect(restored).toEqual(state);
|
||||
});
|
||||
|
||||
it('supports parsing legacy session format into conversation/openai defaults', () => {
|
||||
const restored = parseModelTesterSession(JSON.stringify({
|
||||
model: 'gpt-4o',
|
||||
temperature: 0.5,
|
||||
input: 'legacy',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
pendingPayload: null,
|
||||
}));
|
||||
|
||||
expect(restored?.inputs.model).toBe('gpt-4o');
|
||||
expect(restored?.inputs.protocol).toBe('openai');
|
||||
expect(restored?.inputs.mode).toBe('conversation');
|
||||
expect(restored?.inputs.temperature).toBe(0.5);
|
||||
expect(restored?.parameterEnabled).toEqual(DEFAULT_PARAMETER_ENABLED);
|
||||
});
|
||||
|
||||
it('returns null for malformed or missing session payload', () => {
|
||||
expect(parseModelTesterSession(null)).toBeNull();
|
||||
expect(parseModelTesterSession('not-json')).toBeNull();
|
||||
expect(parseModelTesterSession(JSON.stringify({ messages: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
it('drops loading assistant placeholders when building API payload messages', () => {
|
||||
const payloadMessages = toApiMessages([
|
||||
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
|
||||
{ id: '2', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
|
||||
{ id: '3', role: 'assistant', content: 'done', createAt: 3, status: MESSAGE_STATUS.COMPLETE },
|
||||
]);
|
||||
|
||||
expect(payloadMessages).toEqual([
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds conversation payload as generic proxy envelope', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4o-mini',
|
||||
protocol: 'openai',
|
||||
systemPrompt: 'You are helpful.',
|
||||
temperature: 0.5,
|
||||
top_p: 0.8,
|
||||
max_tokens: 200,
|
||||
frequency_penalty: 0.2,
|
||||
presence_penalty: 0.3,
|
||||
seed: 42,
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
temperature: true,
|
||||
top_p: false,
|
||||
max_tokens: true,
|
||||
frequency_penalty: true,
|
||||
presence_penalty: false,
|
||||
seed: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are helpful.' },
|
||||
{ role: 'user', content: 'hello' },
|
||||
],
|
||||
stream: true,
|
||||
temperature: 0.5,
|
||||
max_tokens: 200,
|
||||
frequency_penalty: 0.2,
|
||||
seed: 42,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches a forced channel id to tester envelopes without mutating the request body', () => {
|
||||
const base = buildEmbeddingsRequestEnvelope('hello', {
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'text-embedding-3-small',
|
||||
});
|
||||
|
||||
const payload = attachForcedChannelToEnvelope(base, 42);
|
||||
|
||||
expect(payload).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/embeddings',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
forcedChannelId: 42,
|
||||
jsonBody: {
|
||||
model: 'text-embedding-3-small',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds multipart upload envelopes for /v1/files', () => {
|
||||
expect(buildFileUploadRequestEnvelope({
|
||||
name: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
|
||||
})).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/files',
|
||||
requestKind: 'multipart',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
multipartFields: {
|
||||
purpose: 'assistants',
|
||||
},
|
||||
multipartFiles: [
|
||||
{
|
||||
field: 'file',
|
||||
name: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
dataUrl: 'data:application/pdf;base64,JVBERi0xLjc=',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates user conversation messages that preserve uploaded file references', () => {
|
||||
const message = createConversationUserMessage('请总结附件', [
|
||||
{
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(message.role).toBe('user');
|
||||
expect(message.content).toBe('请总结附件');
|
||||
expect(message.parts).toEqual([
|
||||
{
|
||||
type: 'input_file',
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts inline conversation file data for retry flows', () => {
|
||||
const files = extractConversationUploadedFilesFromMessage({
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: '请总结附件',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0xLjQK',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts uploaded file references for retry flows', () => {
|
||||
const files = extractConversationUploadedFilesFromMessage({
|
||||
id: 'u2',
|
||||
role: 'user',
|
||||
content: '请继续分析',
|
||||
createAt: 2,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
fileId: 'file-metapi-456',
|
||||
filename: 'appendix.txt',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
fileId: 'file-metapi-456',
|
||||
filename: 'appendix.txt',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates local file ids into inline replay files for claude', async () => {
|
||||
const resolveConversationReplayFiles = (modelTesterSessionModule as Record<string, any>).resolveConversationReplayFiles;
|
||||
const loader = vi.fn(async () => ({
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0x',
|
||||
}));
|
||||
|
||||
const files = await resolveConversationReplayFiles?.(
|
||||
[
|
||||
{
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
'claude',
|
||||
loader,
|
||||
);
|
||||
|
||||
expect(loader).toHaveBeenCalledWith('file-metapi-123');
|
||||
expect(files).toEqual([
|
||||
{
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0x',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves file reference parts when building responses conversation payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: '请总结上传文件',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
fileId: 'file_123',
|
||||
filename: 'notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
],
|
||||
} as ChatMessage],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-5',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: '请总结上传文件' },
|
||||
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt' },
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes assistant history as output_text for responses payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[
|
||||
{ id: 'u1', role: 'user', content: '先看附件', createAt: 1 },
|
||||
{ id: 'a1', role: 'assistant', content: '我已经看过摘要', createAt: 2 },
|
||||
],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-5.2',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'gpt-5.2',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: '先看附件' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'output_text', text: '我已经看过摘要' },
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds gemini conversation envelope with generationConfig', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gemini-2.5-pro',
|
||||
protocol: 'gemini',
|
||||
systemPrompt: 'system text',
|
||||
temperature: 0.2,
|
||||
max_tokens: 300,
|
||||
},
|
||||
{
|
||||
...DEFAULT_PARAMETER_ENABLED,
|
||||
max_tokens: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.path).toBe('/v1beta/models/gemini-2.5-pro:generateContent');
|
||||
expect(payload.jsonBody).toEqual({
|
||||
systemInstruction: { parts: [{ text: 'system text' }] },
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
generationConfig: { temperature: 0.2, maxOutputTokens: 300 },
|
||||
});
|
||||
});
|
||||
|
||||
it('builds gemini native proxy envelope without loading assistant placeholders', () => {
|
||||
const payload = buildGeminiNativeConversationProxyEnvelope(
|
||||
[
|
||||
{ id: 'u1', role: 'user', content: 'hello', createAt: 1 },
|
||||
{ id: 'a1', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
|
||||
{ id: 'a2', role: 'assistant', content: 'partial', createAt: 3, status: MESSAGE_STATUS.INCOMPLETE },
|
||||
{ id: 'a3', role: 'assistant', content: 'done', createAt: 4, status: MESSAGE_STATUS.COMPLETE },
|
||||
],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gemini-2.5-pro',
|
||||
protocol: 'gemini',
|
||||
systemPrompt: 'system text',
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
...DEFAULT_PARAMETER_ENABLED,
|
||||
max_tokens: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload.path).toBe('/gemini/v1beta/models/gemini-2.5-pro:generateContent?alt=sse');
|
||||
expect(payload.jobMode).toBe(false);
|
||||
expect(payload.jsonBody).toEqual({
|
||||
systemInstruction: { parts: [{ text: 'system text' }] },
|
||||
contents: [
|
||||
{ role: 'user', parts: [{ text: 'hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'done' }] },
|
||||
],
|
||||
generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes conversation file parts into openai and responses payloads', () => {
|
||||
const message = {
|
||||
id: 'u1',
|
||||
role: 'user' as const,
|
||||
content: 'summarize this',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file' as const,
|
||||
fileId: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const openaiPayload = buildApiPayload(
|
||||
[message],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'openai',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(openaiPayload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
});
|
||||
|
||||
const responsesPayload = buildApiPayload(
|
||||
[message],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(responsesPayload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'summarize this' },
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers native file ids over inline backups for OpenAI and Responses payloads', () => {
|
||||
const message = {
|
||||
id: 'u1',
|
||||
role: 'user' as const,
|
||||
content: 'reuse native file id',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file' as const,
|
||||
fileId: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0x',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const openAiPayload = buildApiPayload(
|
||||
[message],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'openai',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(openAiPayload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'reuse native file id' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_id: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
});
|
||||
|
||||
const responsesPayload = buildApiPayload(
|
||||
[message],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(responsesPayload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: 'reuse native file id' },
|
||||
{
|
||||
type: 'input_file',
|
||||
file_id: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips data URL wrappers when replaying inline files into OpenAI chat payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: 'summarize this inline file',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0x',
|
||||
},
|
||||
],
|
||||
} as ChatMessage],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-4.1',
|
||||
protocol: 'openai',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'gpt-4.1',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this inline file' },
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
file_data: 'JVBERi0x',
|
||||
filename: 'brief.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Claude conversation payloads with inline document blocks for conversation files', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: 'summarize this',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0xLjc=',
|
||||
},
|
||||
],
|
||||
} as ChatMessage],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'claude-opus-4-6',
|
||||
protocol: 'claude',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'summarize this' },
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: 'JVBERi0xLjc=',
|
||||
},
|
||||
title: 'brief.pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Claude conversation payloads with image blocks for inline image attachments', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: 'describe this image',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'chart.png',
|
||||
mimeType: 'image/png',
|
||||
data: 'data:image/png;base64,QUFBQQ==',
|
||||
},
|
||||
],
|
||||
} as ChatMessage],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'claude-opus-4-6',
|
||||
protocol: 'claude',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this image' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'QUFBQQ==',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Gemini conversation payloads with inlineData document parts for conversation files', () => {
|
||||
const payload = buildApiPayload(
|
||||
[{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: 'summarize this',
|
||||
createAt: 1,
|
||||
parts: [
|
||||
{
|
||||
type: 'input_file',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,JVBERi0xLjc=',
|
||||
},
|
||||
],
|
||||
} as ChatMessage],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gemini-2.5-pro',
|
||||
protocol: 'gemini',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'summarize this' },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'application/pdf',
|
||||
data: 'JVBERi0xLjc=',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: { temperature: 0.7 },
|
||||
});
|
||||
});
|
||||
|
||||
it('builds embeddings and search envelopes', () => {
|
||||
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/embeddings',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'text-embedding-3-large',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildSearchRequestEnvelope(
|
||||
{ ...DEFAULT_INPUTS, model: '__search', searchMaxResults: 3 },
|
||||
{ ...DEFAULT_MODE_STATE, searchQuery: 'what is ai', searchAllowedDomains: 'openai.com', searchBlockedDomains: 'example.com' },
|
||||
)).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/search',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: '__search',
|
||||
query: 'what is ai',
|
||||
max_results: 3,
|
||||
allowed_domains: ['openai.com'],
|
||||
blocked_domains: ['example.com'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses raw custom body without dropping unknown fields', () => {
|
||||
const parsed = parseCustomRequestBody('{"model":"gpt-5","include":["foo"],"reasoning":{"effort":"high"}}');
|
||||
expect(parsed).toEqual({
|
||||
model: 'gpt-5',
|
||||
include: ['foo'],
|
||||
reasoning: { effort: 'high' },
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs messages into custom request body while preserving unknown fields', () => {
|
||||
const synced = syncMessagesToCustomRequestBody(
|
||||
'{"model":"legacy","metadata":{"trace":"keep"}}',
|
||||
[{ id: '1', role: 'user', content: 'new', createAt: 1 }],
|
||||
{ ...DEFAULT_INPUTS, model: 'gpt-4o', protocol: 'responses', systemPrompt: 'system' },
|
||||
);
|
||||
|
||||
expect(JSON.parse(synced)).toEqual({
|
||||
model: 'gpt-4o',
|
||||
metadata: { trace: 'keep' },
|
||||
input: 'new',
|
||||
instructions: 'system',
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds raw proxy envelope for passthrough mode', () => {
|
||||
expect(buildRawProxyRequestEnvelope('POST', '/v1/responses', 'json', '{"foo":1}', { stream: true })).toEqual({
|
||||
method: 'POST',
|
||||
path: '/v1/responses',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: true,
|
||||
rawJsonText: '{"foo":1}',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges marketplace models with exact enabled route models for tester options', () => {
|
||||
const modelNames = collectModelTesterModelNames(
|
||||
{
|
||||
models: [
|
||||
{ name: 'gpt-4o-mini' },
|
||||
{ name: 'bge-large-en-v1.5' },
|
||||
],
|
||||
},
|
||||
[
|
||||
{ modelPattern: 'BAAI/bge-large-en-v1.5', enabled: true },
|
||||
{ modelPattern: 'claude-*', enabled: true },
|
||||
{ modelPattern: 'gemini-2.5-pro', enabled: false },
|
||||
],
|
||||
);
|
||||
|
||||
expect(modelNames).toEqual([
|
||||
'gpt-4o-mini',
|
||||
'bge-large-en-v1.5',
|
||||
'BAAI/bge-large-en-v1.5',
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters models by keyword and keeps best matches first', () => {
|
||||
const filtered = filterModelTesterModelNames(
|
||||
[
|
||||
'BAAI/bge-large-en-v1.5',
|
||||
'text-embedding-3-large',
|
||||
'bge-m3',
|
||||
],
|
||||
'bge',
|
||||
);
|
||||
|
||||
expect(filtered).toEqual([
|
||||
'bge-m3',
|
||||
'BAAI/bge-large-en-v1.5',
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
mergeMarketplaceMetadata,
|
||||
shouldHydrateMarketplaceMetadata,
|
||||
type MarketplaceModelMetadataShape,
|
||||
} from './modelsMarketplaceMetadata.js';
|
||||
|
||||
interface TestModel extends MarketplaceModelMetadataShape {
|
||||
name: string;
|
||||
accountCount: number;
|
||||
}
|
||||
|
||||
function createModel(overrides: Partial<TestModel>): TestModel {
|
||||
return {
|
||||
name: 'gpt-4o',
|
||||
accountCount: 1,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('modelsMarketplaceMetadata', () => {
|
||||
it('identifies when metadata hydration is needed', () => {
|
||||
const models = [
|
||||
createModel({ name: 'gpt-4o', description: 'base model' }),
|
||||
createModel({ name: 'claude-sonnet-4' }),
|
||||
];
|
||||
|
||||
expect(shouldHydrateMarketplaceMetadata(models)).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies when metadata is already complete', () => {
|
||||
const models = [
|
||||
createModel({
|
||||
name: 'gpt-4o',
|
||||
description: 'base model',
|
||||
tags: ['chat'],
|
||||
supportedEndpointTypes: ['chat'],
|
||||
pricingSources: [{ source: 'site-a' }],
|
||||
}),
|
||||
createModel({
|
||||
name: 'claude-sonnet-4',
|
||||
description: 'anthropic model',
|
||||
tags: ['reasoning'],
|
||||
supportedEndpointTypes: ['chat'],
|
||||
pricingSources: [{ source: 'site-b' }],
|
||||
}),
|
||||
];
|
||||
|
||||
expect(shouldHydrateMarketplaceMetadata(models)).toBe(false);
|
||||
});
|
||||
|
||||
it('merges metadata into current models without overriding non-metadata fields', () => {
|
||||
const base = [
|
||||
createModel({ name: 'GPT-4O', accountCount: 3 }),
|
||||
createModel({ name: 'claude-sonnet-4', accountCount: 5 }),
|
||||
];
|
||||
const detail = [
|
||||
createModel({
|
||||
name: 'gpt-4o',
|
||||
accountCount: 99,
|
||||
description: 'OpenAI flagship model',
|
||||
tags: ['openai'],
|
||||
supportedEndpointTypes: ['chat', 'responses'],
|
||||
pricingSources: [{ source: 'site-a' }],
|
||||
}),
|
||||
];
|
||||
|
||||
const merged = mergeMarketplaceMetadata(base, detail);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{
|
||||
...base[0],
|
||||
description: 'OpenAI flagship model',
|
||||
tags: ['openai'],
|
||||
supportedEndpointTypes: ['chat', 'responses'],
|
||||
pricingSources: [{ source: 'site-a' }],
|
||||
},
|
||||
base[1],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface MarketplaceModelMetadataShape {
|
||||
description: string | null;
|
||||
tags: string[];
|
||||
supportedEndpointTypes: string[];
|
||||
pricingSources: unknown[];
|
||||
}
|
||||
|
||||
interface MarketplaceModelLike extends MarketplaceModelMetadataShape {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function normalizeModelName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hasMarketplaceMetadata(model: MarketplaceModelMetadataShape): boolean {
|
||||
if (typeof model.description === 'string' && model.description.trim().length > 0) return true;
|
||||
if (Array.isArray(model.tags) && model.tags.length > 0) return true;
|
||||
if (Array.isArray(model.supportedEndpointTypes) && model.supportedEndpointTypes.length > 0) return true;
|
||||
if (Array.isArray(model.pricingSources) && model.pricingSources.length > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldHydrateMarketplaceMetadata<T extends MarketplaceModelMetadataShape>(models: T[]): boolean {
|
||||
return models.some((model) => !hasMarketplaceMetadata(model));
|
||||
}
|
||||
|
||||
export function mergeMarketplaceMetadata<T extends MarketplaceModelLike>(baseModels: T[], detailedModels: T[]): T[] {
|
||||
const detailedByName = new Map<string, T>();
|
||||
for (const model of detailedModels) {
|
||||
detailedByName.set(normalizeModelName(model.name), model);
|
||||
}
|
||||
|
||||
return baseModels.map((model) => {
|
||||
const detail = detailedByName.get(normalizeModelName(model.name));
|
||||
if (!detail) return model;
|
||||
|
||||
return {
|
||||
...model,
|
||||
description: detail.description ?? null,
|
||||
tags: Array.isArray(detail.tags) ? detail.tags : [],
|
||||
supportedEndpointTypes: Array.isArray(detail.supportedEndpointTypes) ? detail.supportedEndpointTypes : [],
|
||||
pricingSources: Array.isArray(detail.pricingSources) ? detail.pricingSources : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildAccountFocusPath,
|
||||
buildEventNavigationPath,
|
||||
buildSiteFocusPath,
|
||||
clearFocusParams,
|
||||
readFocusAccountIntent,
|
||||
readFocusAnnouncementId,
|
||||
readFocusSiteId,
|
||||
} from './navigationFocus.js';
|
||||
|
||||
describe('navigationFocus helpers', () => {
|
||||
it('builds focus paths for site and account', () => {
|
||||
expect(buildSiteFocusPath(12)).toBe('/sites?focusSiteId=12');
|
||||
expect(buildSiteFocusPath(0)).toBe('/sites');
|
||||
expect(buildAccountFocusPath(34)).toBe('/accounts?focusAccountId=34');
|
||||
expect(buildAccountFocusPath(34, { openRebind: true })).toBe('/accounts?focusAccountId=34&openRebind=1');
|
||||
expect(buildAccountFocusPath(-1)).toBe('/accounts');
|
||||
});
|
||||
|
||||
it('parses focus params from query string', () => {
|
||||
expect(readFocusSiteId('?focusSiteId=15')).toBe(15);
|
||||
expect(readFocusSiteId('?focusSiteId=abc')).toBeNull();
|
||||
expect(readFocusAnnouncementId('?focusAnnouncementId=21')).toBe(21);
|
||||
expect(readFocusAnnouncementId('?focusAnnouncementId=abc')).toBeNull();
|
||||
|
||||
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=1')).toEqual({
|
||||
accountId: 22,
|
||||
openRebind: true,
|
||||
});
|
||||
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=true')).toEqual({
|
||||
accountId: 22,
|
||||
openRebind: true,
|
||||
});
|
||||
expect(readFocusAccountIntent('?focusAccountId=22')).toEqual({
|
||||
accountId: 22,
|
||||
openRebind: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears focus params but keeps other params', () => {
|
||||
expect(clearFocusParams('?focusSiteId=12&q=abc')).toBe('?q=abc');
|
||||
expect(clearFocusParams('?focusAnnouncementId=44&type=site_notice')).toBe('?type=site_notice');
|
||||
expect(clearFocusParams('?focusAccountId=2&openRebind=1&type=token')).toBe('?type=token');
|
||||
expect(clearFocusParams('?focusAccountId=2')).toBe('');
|
||||
});
|
||||
|
||||
it('builds event navigation path by related entity', () => {
|
||||
expect(buildEventNavigationPath({
|
||||
relatedType: 'account',
|
||||
relatedId: 18,
|
||||
type: 'token',
|
||||
})).toBe('/accounts?focusAccountId=18&openRebind=1');
|
||||
|
||||
expect(buildEventNavigationPath({
|
||||
relatedType: 'account',
|
||||
relatedId: 18,
|
||||
type: 'checkin',
|
||||
})).toBe('/accounts?focusAccountId=18');
|
||||
|
||||
expect(buildEventNavigationPath({
|
||||
relatedType: 'site',
|
||||
relatedId: 9,
|
||||
type: 'status',
|
||||
})).toBe('/sites?focusSiteId=9');
|
||||
|
||||
expect(buildEventNavigationPath({
|
||||
relatedType: 'site_announcement',
|
||||
relatedId: 12,
|
||||
type: 'site_notice',
|
||||
})).toBe('/site-announcements?focusAnnouncementId=12');
|
||||
|
||||
expect(buildEventNavigationPath({
|
||||
relatedType: 'route',
|
||||
relatedId: null,
|
||||
type: 'proxy',
|
||||
})).toBe('/routes');
|
||||
|
||||
expect(buildEventNavigationPath({
|
||||
relatedType: 'update_center',
|
||||
relatedId: null,
|
||||
type: 'status',
|
||||
})).toBe('/settings');
|
||||
|
||||
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
||||
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
||||
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
||||
expect(buildEventNavigationPath({})).toBe('/events');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { isTruthyFlag } from './accountConnection.js';
|
||||
|
||||
const FOCUS_SITE_ID_KEY = 'focusSiteId';
|
||||
const FOCUS_ANNOUNCEMENT_ID_KEY = 'focusAnnouncementId';
|
||||
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
|
||||
const FOCUS_TOKEN_ID_KEY = 'focusTokenId';
|
||||
const OPEN_REBIND_KEY = 'openRebind';
|
||||
|
||||
function normalizePositiveId(input: unknown): number | null {
|
||||
const value = Number.parseInt(String(input ?? ''), 10);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildSiteFocusPath(siteId: number): string {
|
||||
const normalizedId = normalizePositiveId(siteId);
|
||||
if (!normalizedId) return '/sites';
|
||||
return `/sites?${FOCUS_SITE_ID_KEY}=${normalizedId}`;
|
||||
}
|
||||
|
||||
export function buildAnnouncementFocusPath(announcementId: number): string {
|
||||
const normalizedId = normalizePositiveId(announcementId);
|
||||
if (!normalizedId) return '/site-announcements';
|
||||
return `/site-announcements?${FOCUS_ANNOUNCEMENT_ID_KEY}=${normalizedId}`;
|
||||
}
|
||||
|
||||
export function buildAccountFocusPath(
|
||||
accountId: number,
|
||||
options?: { openRebind?: boolean; segment?: 'session' | 'apikey' | 'tokens' },
|
||||
): string {
|
||||
const normalizedId = normalizePositiveId(accountId);
|
||||
if (!normalizedId) return '/accounts';
|
||||
const params = new URLSearchParams();
|
||||
if (options?.segment && options.segment !== 'session') params.set('segment', options.segment);
|
||||
params.set(FOCUS_ACCOUNT_ID_KEY, String(normalizedId));
|
||||
if (options?.openRebind) params.set(OPEN_REBIND_KEY, '1');
|
||||
return `/accounts?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildTokenFocusPath(tokenId: number): string {
|
||||
const normalizedId = normalizePositiveId(tokenId);
|
||||
if (!normalizedId) return '/accounts?segment=tokens';
|
||||
const params = new URLSearchParams();
|
||||
params.set('segment', 'tokens');
|
||||
params.set(FOCUS_TOKEN_ID_KEY, String(normalizedId));
|
||||
return `/accounts?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function readFocusSiteId(search: string): number | null {
|
||||
const params = new URLSearchParams(search);
|
||||
return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY));
|
||||
}
|
||||
|
||||
export function readFocusAnnouncementId(search: string): number | null {
|
||||
const params = new URLSearchParams(search);
|
||||
return normalizePositiveId(params.get(FOCUS_ANNOUNCEMENT_ID_KEY));
|
||||
}
|
||||
|
||||
export function readFocusAccountIntent(search: string): { accountId: number | null; openRebind: boolean } {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
accountId: normalizePositiveId(params.get(FOCUS_ACCOUNT_ID_KEY)),
|
||||
openRebind: isTruthyFlag(params.get(OPEN_REBIND_KEY)),
|
||||
};
|
||||
}
|
||||
|
||||
export function readFocusTokenId(search: string): number | null {
|
||||
const params = new URLSearchParams(search);
|
||||
return normalizePositiveId(params.get(FOCUS_TOKEN_ID_KEY));
|
||||
}
|
||||
|
||||
export function clearFocusParams(search: string): string {
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete(FOCUS_SITE_ID_KEY);
|
||||
params.delete(FOCUS_ANNOUNCEMENT_ID_KEY);
|
||||
params.delete(FOCUS_ACCOUNT_ID_KEY);
|
||||
params.delete(FOCUS_TOKEN_ID_KEY);
|
||||
params.delete(OPEN_REBIND_KEY);
|
||||
const next = params.toString();
|
||||
return next ? `?${next}` : '';
|
||||
}
|
||||
|
||||
export function buildEventNavigationPath(event: {
|
||||
relatedType?: string | null;
|
||||
relatedId?: number | null;
|
||||
type?: string | null;
|
||||
}): string {
|
||||
const relatedType = (event.relatedType || '').toLowerCase();
|
||||
const relatedId = normalizePositiveId(event.relatedId);
|
||||
const eventType = (event.type || '').toLowerCase();
|
||||
|
||||
if (relatedType === 'account' && relatedId) {
|
||||
return buildAccountFocusPath(relatedId, { openRebind: eventType === 'token' });
|
||||
}
|
||||
if (relatedType === 'site' && relatedId) {
|
||||
return buildSiteFocusPath(relatedId);
|
||||
}
|
||||
if (relatedType === 'site_announcement' && relatedId) {
|
||||
return buildAnnouncementFocusPath(relatedId);
|
||||
}
|
||||
if (relatedType === 'update_center') {
|
||||
return '/settings';
|
||||
}
|
||||
if (relatedType === 'route') {
|
||||
return '/routes';
|
||||
}
|
||||
if (eventType === 'proxy') {
|
||||
return '/logs';
|
||||
}
|
||||
if (eventType === 'checkin') {
|
||||
return '/checkin';
|
||||
}
|
||||
if (eventType === 'site_notice') {
|
||||
return '/site-announcements';
|
||||
}
|
||||
|
||||
return '/events';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getInitialVisibleCount, getNextVisibleCount } from './progressiveRender.js';
|
||||
|
||||
describe('progressiveRender', () => {
|
||||
it('returns bounded initial visible count', () => {
|
||||
expect(getInitialVisibleCount(0, 40)).toBe(0);
|
||||
expect(getInitialVisibleCount(10, 40)).toBe(10);
|
||||
expect(getInitialVisibleCount(120, 40)).toBe(40);
|
||||
});
|
||||
|
||||
it('returns next visible count without exceeding total', () => {
|
||||
expect(getNextVisibleCount(0, 120, 40)).toBe(40);
|
||||
expect(getNextVisibleCount(40, 120, 40)).toBe(80);
|
||||
expect(getNextVisibleCount(80, 120, 40)).toBe(120);
|
||||
expect(getNextVisibleCount(200, 120, 40)).toBe(120);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function getInitialVisibleCount(total: number, chunkSize: number): number {
|
||||
if (!Number.isFinite(total) || total <= 0) return 0;
|
||||
const normalizedChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? Math.trunc(chunkSize) : 1;
|
||||
return Math.min(Math.trunc(total), normalizedChunk);
|
||||
}
|
||||
|
||||
export function getNextVisibleCount(currentVisible: number, total: number, chunkSize: number): number {
|
||||
const normalizedTotal = Number.isFinite(total) && total > 0 ? Math.trunc(total) : 0;
|
||||
if (normalizedTotal <= 0) return 0;
|
||||
|
||||
const normalizedCurrent = Number.isFinite(currentVisible) && currentVisible > 0
|
||||
? Math.trunc(currentVisible)
|
||||
: 0;
|
||||
const normalizedChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? Math.trunc(chunkSize) : 1;
|
||||
|
||||
return Math.min(normalizedTotal, normalizedCurrent + normalizedChunk);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseProxyLogPathMeta } from './proxyLogPathMeta.js';
|
||||
|
||||
describe('parseProxyLogPathMeta', () => {
|
||||
it('parses downstream and upstream paths from prefixed message', () => {
|
||||
const parsed = parseProxyLogPathMeta('[downstream:/v1/responses] [upstream:/v1/chat/completions] {"error":"x"}');
|
||||
expect(parsed.downstreamPath).toBe('/v1/responses');
|
||||
expect(parsed.upstreamPath).toBe('/v1/chat/completions');
|
||||
expect(parsed.errorMessage).toBe('{"error":"x"}');
|
||||
});
|
||||
|
||||
it('supports historical upstream-only logs', () => {
|
||||
const parsed = parseProxyLogPathMeta('[upstream:/v1/messages] messages is required');
|
||||
expect(parsed.downstreamPath).toBe(null);
|
||||
expect(parsed.upstreamPath).toBe('/v1/messages');
|
||||
expect(parsed.errorMessage).toBe('messages is required');
|
||||
});
|
||||
|
||||
it('strips legacy client and session prefixes while keeping them available for display', () => {
|
||||
const parsed = parseProxyLogPathMeta('[client:codex] [session:turn-123] [downstream:/v1/responses] boom');
|
||||
expect(parsed.clientFamily).toBe('codex');
|
||||
expect(parsed.sessionId).toBe('turn-123');
|
||||
expect(parsed.downstreamPath).toBe('/v1/responses');
|
||||
expect(parsed.errorMessage).toBe('boom');
|
||||
});
|
||||
|
||||
it('keeps plain message when no metadata exists', () => {
|
||||
const parsed = parseProxyLogPathMeta('network timeout');
|
||||
expect(parsed.downstreamPath).toBe(null);
|
||||
expect(parsed.upstreamPath).toBe(null);
|
||||
expect(parsed.errorMessage).toBe('network timeout');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { parseProxyLogMetadata, type ParsedProxyLogMetadata } from '../../../shared/proxyLogMeta.js';
|
||||
|
||||
type ProxyLogPathMeta = {
|
||||
clientFamily: string | null;
|
||||
sessionId: string | null;
|
||||
downstreamPath: string | null;
|
||||
upstreamPath: string | null;
|
||||
usageSource: ParsedProxyLogMetadata['usageSource'];
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
|
||||
const raw = typeof message === 'string' ? message.trim() : '';
|
||||
const parsed = parseProxyLogMetadata(raw);
|
||||
|
||||
return {
|
||||
clientFamily: parsed.clientKind,
|
||||
sessionId: parsed.sessionId,
|
||||
downstreamPath: parsed.downstreamPath,
|
||||
upstreamPath: parsed.upstreamPath,
|
||||
usageSource: parsed.usageSource,
|
||||
errorMessage: parsed.messageText,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { normalizeTokenRouteMode } from '../../../shared/tokenRouteContract.js';
|
||||
|
||||
export type RouteListVisibilityItem = {
|
||||
id: number;
|
||||
modelPattern: string;
|
||||
displayName?: string | null;
|
||||
routeMode?: string | null;
|
||||
sourceRouteIds?: number[];
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
function normalizeRouteMode(routeMode: string | null | undefined): 'pattern' | 'explicit_group' {
|
||||
return normalizeTokenRouteMode(routeMode);
|
||||
}
|
||||
|
||||
function isExplicitGroupRoute(route: Pick<RouteListVisibilityItem, 'routeMode'>): boolean {
|
||||
return normalizeRouteMode(route.routeMode) === 'explicit_group';
|
||||
}
|
||||
|
||||
function hasCustomDisplayName(route: Pick<RouteListVisibilityItem, 'modelPattern' | 'displayName'>): boolean {
|
||||
const displayName = (route.displayName || '').trim();
|
||||
const modelPattern = (route.modelPattern || '').trim();
|
||||
return !!displayName && displayName !== modelPattern;
|
||||
}
|
||||
|
||||
export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
|
||||
routes: T[],
|
||||
isExactModelPattern: (pattern: string) => boolean,
|
||||
matchesModelPattern: (model: string, pattern: string) => boolean,
|
||||
): T[] {
|
||||
const exactModelNames = new Set(
|
||||
routes
|
||||
.filter((route) => !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
|
||||
.map((route) => (route.modelPattern || '').trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const coveringGroups = routes.filter((route) => (
|
||||
route.enabled
|
||||
&& (
|
||||
(isExplicitGroupRoute(route) && ((route.displayName || '').trim().length > 0) && (route.sourceRouteIds || []).length > 0)
|
||||
|| (!isExplicitGroupRoute(route) && !isExactModelPattern(route.modelPattern) && hasCustomDisplayName(route))
|
||||
)
|
||||
));
|
||||
|
||||
if (coveringGroups.length === 0) return routes;
|
||||
|
||||
return routes.filter((route) => {
|
||||
if (isExplicitGroupRoute(route)) return true;
|
||||
if (!isExactModelPattern(route.modelPattern)) return true;
|
||||
if (hasCustomDisplayName(route)) return true;
|
||||
|
||||
const exactModel = (route.modelPattern || '').trim();
|
||||
if (!exactModel) return true;
|
||||
|
||||
return !coveringGroups.some((groupRoute) => (
|
||||
groupRoute.id !== route.id
|
||||
&& !exactModelNames.has((groupRoute.displayName || '').trim())
|
||||
&& (
|
||||
(isExplicitGroupRoute(groupRoute) && (groupRoute.sourceRouteIds || []).includes(route.id))
|
||||
|| (!isExplicitGroupRoute(groupRoute) && matchesModelPattern(exactModel, groupRoute.modelPattern))
|
||||
)
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildRouteMissingTokenIndex,
|
||||
normalizeMissingTokenModels,
|
||||
type MissingTokenModelsByName,
|
||||
} from './routeMissingTokenHints.js';
|
||||
|
||||
function matchesModelPattern(model: string, pattern: string): boolean {
|
||||
if (!pattern) return false;
|
||||
if (pattern === '*') return true;
|
||||
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
|
||||
return model === pattern;
|
||||
}
|
||||
|
||||
describe('buildRouteMissingTokenIndex', () => {
|
||||
it('matches missing-token models by route pattern and deduplicates accounts', () => {
|
||||
const routes = [
|
||||
{ id: 1, modelPattern: 'claude-*' },
|
||||
{ id: 2, modelPattern: 'gpt-4o-mini' },
|
||||
{ id: 3, modelPattern: '' },
|
||||
];
|
||||
|
||||
const missingByModel: MissingTokenModelsByName = {
|
||||
'claude-opus-4-6': [
|
||||
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
|
||||
{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' },
|
||||
],
|
||||
'claude-code-4-6': [
|
||||
{ accountId: 22, username: 'bob', siteId: 2, siteName: 'site-b' },
|
||||
],
|
||||
'gpt-4o-mini': [
|
||||
{ accountId: 33, username: 'charlie', siteId: 3, siteName: 'site-c' },
|
||||
],
|
||||
};
|
||||
|
||||
const index = buildRouteMissingTokenIndex(routes, missingByModel, matchesModelPattern);
|
||||
expect(index[1].map((item) => item.modelName)).toEqual(['claude-code-4-6', 'claude-opus-4-6']);
|
||||
expect(index[1][1].accounts).toEqual([{ accountId: 11, username: 'alice', siteId: 1, siteName: 'site-a' }]);
|
||||
expect(index[2].map((item) => item.modelName)).toEqual(['gpt-4o-mini']);
|
||||
expect(index[3]).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty entries when missing model map is empty', () => {
|
||||
const index = buildRouteMissingTokenIndex([{ id: 1, modelPattern: 'claude-*' }], {}, matchesModelPattern);
|
||||
expect(index[1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes missing-token map by trimming model name and deduplicating account', () => {
|
||||
const merged = normalizeMissingTokenModels({
|
||||
' claude-opus-4-6 ': [
|
||||
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
|
||||
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(merged['claude-opus-4-6']).toEqual([
|
||||
{
|
||||
accountId: 1,
|
||||
username: 'alice',
|
||||
siteId: 11,
|
||||
siteName: 'site-a',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
export type MissingTokenModelAccount = {
|
||||
accountId: number;
|
||||
username: string | null;
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
missingGroups?: string[];
|
||||
requiredGroups?: string[];
|
||||
availableGroups?: string[];
|
||||
groupCoverageUncertain?: boolean;
|
||||
};
|
||||
|
||||
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
|
||||
|
||||
export type RouteMissingTokenHint = {
|
||||
modelName: string;
|
||||
accounts: MissingTokenModelAccount[];
|
||||
};
|
||||
|
||||
type RoutePatternLike = {
|
||||
id: number;
|
||||
modelPattern: string;
|
||||
};
|
||||
|
||||
export function normalizeMissingTokenModels(
|
||||
withoutTokenByModel: MissingTokenModelsByName,
|
||||
): MissingTokenModelsByName {
|
||||
const normalized: MissingTokenModelsByName = {};
|
||||
for (const modelName of Object.keys(withoutTokenByModel || {})) {
|
||||
const normalizedModelName = String(modelName || '').trim();
|
||||
if (!normalizedModelName) continue;
|
||||
const accountMap = new Map<number, MissingTokenModelAccount>();
|
||||
for (const account of withoutTokenByModel[modelName] || []) {
|
||||
if (!account || !Number.isFinite(account.accountId)) continue;
|
||||
const accountName = (account.username || '').trim();
|
||||
const siteName = String(account.siteName || '').trim();
|
||||
const normalizeLabels = (labels: unknown): string[] => Array.isArray(labels)
|
||||
? Array.from(new Set(labels
|
||||
.map((label) => String(label || '').trim())
|
||||
.filter(Boolean)))
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||
: [];
|
||||
accountMap.set(account.accountId, {
|
||||
accountId: account.accountId,
|
||||
username: accountName || null,
|
||||
siteId: account.siteId,
|
||||
siteName,
|
||||
...(normalizeLabels(account.missingGroups).length > 0 ? { missingGroups: normalizeLabels(account.missingGroups) } : {}),
|
||||
...(normalizeLabels(account.requiredGroups).length > 0 ? { requiredGroups: normalizeLabels(account.requiredGroups) } : {}),
|
||||
...(normalizeLabels(account.availableGroups).length > 0 ? { availableGroups: normalizeLabels(account.availableGroups) } : {}),
|
||||
...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}),
|
||||
});
|
||||
}
|
||||
if (accountMap.size > 0) {
|
||||
normalized[normalizedModelName] = Array.from(accountMap.values()).sort((a, b) => a.accountId - b.accountId);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function buildRouteMissingTokenIndex(
|
||||
routes: RoutePatternLike[],
|
||||
missingByModel: MissingTokenModelsByName,
|
||||
matchesModelPattern: (model: string, pattern: string) => boolean,
|
||||
): Record<number, RouteMissingTokenHint[]> {
|
||||
const index: Record<number, RouteMissingTokenHint[]> = {};
|
||||
|
||||
for (const route of routes || []) {
|
||||
const modelPattern = (route.modelPattern || '').trim();
|
||||
if (!modelPattern) {
|
||||
index[route.id] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchedHints: RouteMissingTokenHint[] = [];
|
||||
for (const [modelName, accounts] of Object.entries(missingByModel || {})) {
|
||||
if (!matchesModelPattern(modelName, modelPattern)) continue;
|
||||
const dedupedAccounts = new Map<number, MissingTokenModelAccount>();
|
||||
for (const account of accounts || []) {
|
||||
if (!account || !Number.isFinite(account.accountId)) continue;
|
||||
dedupedAccounts.set(account.accountId, account);
|
||||
}
|
||||
|
||||
matchedHints.push({
|
||||
modelName,
|
||||
accounts: Array.from(dedupedAccounts.values()).sort((a, b) => {
|
||||
const siteCompare = String(a.siteName || '').localeCompare(String(b.siteName || ''), undefined, { sensitivity: 'base' });
|
||||
if (siteCompare !== 0) return siteCompare;
|
||||
return a.accountId - b.accountId;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
matchedHints.sort((a, b) => a.modelName.localeCompare(b.modelName, undefined, { sensitivity: 'base' }));
|
||||
index[route.id] = matchedHints;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildRouteModelCandidatesIndex,
|
||||
type IndexedRouteModelCandidate,
|
||||
type RouteModelCandidatesByModelName,
|
||||
} from './routeModelCandidatesIndex.js';
|
||||
|
||||
function matchesModelPattern(model: string, pattern: string): boolean {
|
||||
if (!pattern) return false;
|
||||
if (pattern === '*') return true;
|
||||
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
|
||||
return model === pattern;
|
||||
}
|
||||
|
||||
describe('buildRouteModelCandidatesIndex', () => {
|
||||
it('precomputes route candidates, account options and token options by account', () => {
|
||||
const routes = [
|
||||
{ id: 1, modelPattern: 'claude-*' },
|
||||
{ id: 2, modelPattern: 'gpt-4o-mini' },
|
||||
];
|
||||
|
||||
const modelCandidates: RouteModelCandidatesByModelName = {
|
||||
'claude-opus-4-6': [
|
||||
{
|
||||
modelName: 'will-be-overwritten',
|
||||
accountId: 11,
|
||||
tokenId: 101,
|
||||
tokenName: 'tk-opus',
|
||||
isDefault: false,
|
||||
username: 'alice',
|
||||
siteId: 1,
|
||||
siteName: 'site-a',
|
||||
},
|
||||
{
|
||||
modelName: 'dup',
|
||||
accountId: 11,
|
||||
tokenId: 101,
|
||||
tokenName: 'tk-opus',
|
||||
isDefault: false,
|
||||
username: 'alice',
|
||||
siteId: 1,
|
||||
siteName: 'site-a',
|
||||
},
|
||||
],
|
||||
'claude-sonnet-4-6': [
|
||||
{
|
||||
modelName: 'will-be-overwritten',
|
||||
accountId: 11,
|
||||
tokenId: 102,
|
||||
tokenName: 'tk-sonnet',
|
||||
isDefault: true,
|
||||
username: 'alice',
|
||||
siteId: 1,
|
||||
siteName: 'site-a',
|
||||
},
|
||||
],
|
||||
'gpt-4o-mini': [
|
||||
{
|
||||
modelName: 'will-be-overwritten',
|
||||
accountId: 22,
|
||||
tokenId: 201,
|
||||
tokenName: 'tk-gpt',
|
||||
isDefault: true,
|
||||
username: 'bob',
|
||||
siteId: 2,
|
||||
siteName: 'site-b',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const index = buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern);
|
||||
|
||||
expect(index[1]).toBeTruthy();
|
||||
expect(index[2]).toBeTruthy();
|
||||
|
||||
const route1Candidates = index[1].routeCandidates;
|
||||
expect(route1Candidates).toHaveLength(2);
|
||||
expect(route1Candidates.map((item) => item.modelName).sort()).toEqual([
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
]);
|
||||
|
||||
expect(index[1].accountOptions).toEqual([{ id: 11, label: 'alice @ site-a' }]);
|
||||
|
||||
const route1TokenOptions = index[1].tokenOptionsByAccountId[11];
|
||||
expect(route1TokenOptions.map((item) => `${item.id}:${item.sourceModel}:${item.isDefault}`)).toEqual([
|
||||
'102:claude-sonnet-4-6:true',
|
||||
'101:claude-opus-4-6:false',
|
||||
]);
|
||||
|
||||
expect(index[2].routeCandidates).toHaveLength(1);
|
||||
expect(index[2].routeCandidates[0].modelName).toBe('gpt-4o-mini');
|
||||
expect(index[2].accountOptions).toEqual([{ id: 22, label: 'bob @ site-b' }]);
|
||||
});
|
||||
|
||||
it('returns empty structures when route model pattern is blank', () => {
|
||||
const routes = [{ id: 1, modelPattern: ' ' }];
|
||||
const modelCandidates: RouteModelCandidatesByModelName = {
|
||||
'gpt-4o-mini': [{
|
||||
modelName: 'ignored',
|
||||
accountId: 1,
|
||||
tokenId: 2,
|
||||
tokenName: 'tk',
|
||||
isDefault: false,
|
||||
username: null,
|
||||
siteId: 1,
|
||||
siteName: 'site',
|
||||
} satisfies IndexedRouteModelCandidate],
|
||||
};
|
||||
|
||||
const index = buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern);
|
||||
expect(index[1].routeCandidates).toEqual([]);
|
||||
expect(index[1].accountOptions).toEqual([]);
|
||||
expect(index[1].tokenOptionsByAccountId).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
export type IndexedRouteModelCandidate = {
|
||||
modelName: string;
|
||||
accountId: number;
|
||||
tokenId: number;
|
||||
tokenName: string;
|
||||
isDefault: boolean;
|
||||
username: string | null;
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
};
|
||||
|
||||
export type RouteModelCandidatesByModelName = Record<string, IndexedRouteModelCandidate[]>;
|
||||
|
||||
export type RouteAccountOption = {
|
||||
id: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type RouteTokenOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
sourceModel?: string;
|
||||
};
|
||||
|
||||
export type RouteCandidateView = {
|
||||
routeCandidates: IndexedRouteModelCandidate[];
|
||||
accountOptions: RouteAccountOption[];
|
||||
tokenOptionsByAccountId: Record<number, RouteTokenOption[]>;
|
||||
};
|
||||
|
||||
export type RouteModelPatternLike = {
|
||||
id: number;
|
||||
modelPattern: string;
|
||||
routeMode?: string | null;
|
||||
};
|
||||
|
||||
const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
|
||||
routeCandidates: [],
|
||||
accountOptions: [],
|
||||
tokenOptionsByAccountId: {},
|
||||
};
|
||||
|
||||
export function buildRouteModelCandidatesIndex(
|
||||
routes: RouteModelPatternLike[],
|
||||
modelCandidates: RouteModelCandidatesByModelName,
|
||||
matchesModelPattern: (model: string, pattern: string) => boolean,
|
||||
): Record<number, RouteCandidateView> {
|
||||
const index: Record<number, RouteCandidateView> = {};
|
||||
|
||||
for (const route of routes || []) {
|
||||
if (route.routeMode === 'explicit_group') {
|
||||
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
continue;
|
||||
}
|
||||
const modelPattern = (route.modelPattern || '').trim();
|
||||
if (!modelPattern) {
|
||||
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||
continue;
|
||||
}
|
||||
|
||||
const deduped = new Map<string, IndexedRouteModelCandidate>();
|
||||
for (const [modelName, candidates] of Object.entries(modelCandidates || {})) {
|
||||
if (!matchesModelPattern(modelName, modelPattern)) continue;
|
||||
for (const candidate of candidates || []) {
|
||||
const key = `${candidate.tokenId}::${modelName}`;
|
||||
if (!deduped.has(key)) {
|
||||
deduped.set(key, {
|
||||
...candidate,
|
||||
modelName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routeCandidates = Array.from(deduped.values()).sort((a, b) => {
|
||||
if (a.accountId === b.accountId) return a.tokenId - b.tokenId;
|
||||
return a.accountId - b.accountId;
|
||||
});
|
||||
|
||||
const accountMap = new Map<number, string>();
|
||||
const tokenOptionsByAccountId: Record<number, RouteTokenOption[]> = {};
|
||||
for (const candidate of routeCandidates) {
|
||||
if (!accountMap.has(candidate.accountId)) {
|
||||
accountMap.set(candidate.accountId, `${candidate.username || `account-${candidate.accountId}`} @ ${candidate.siteName}`);
|
||||
}
|
||||
if (!tokenOptionsByAccountId[candidate.accountId]) {
|
||||
tokenOptionsByAccountId[candidate.accountId] = [];
|
||||
}
|
||||
tokenOptionsByAccountId[candidate.accountId].push({
|
||||
id: candidate.tokenId,
|
||||
name: candidate.tokenName,
|
||||
isDefault: candidate.isDefault,
|
||||
sourceModel: candidate.modelName,
|
||||
});
|
||||
}
|
||||
|
||||
for (const accountIdText of Object.keys(tokenOptionsByAccountId)) {
|
||||
const accountId = Number.parseInt(accountIdText, 10);
|
||||
tokenOptionsByAccountId[accountId].sort((a, b) => {
|
||||
if (a.isDefault === b.isDefault) {
|
||||
if (a.id === b.id) return (a.sourceModel || '').localeCompare(b.sourceModel || '', undefined, { sensitivity: 'base' });
|
||||
return a.id - b.id;
|
||||
}
|
||||
return a.isDefault ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
const accountOptions = Array.from(accountMap.entries()).map(([id, label]) => ({ id, label }));
|
||||
index[route.id] = {
|
||||
routeCandidates,
|
||||
accountOptions,
|
||||
tokenOptionsByAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ROUTING_PROFILE_PRESETS,
|
||||
applyRoutingProfilePreset,
|
||||
resolveRoutingProfilePreset,
|
||||
} from './routingProfiles.js';
|
||||
|
||||
describe('routingProfiles', () => {
|
||||
it('applies stable preset weights', () => {
|
||||
const stable = applyRoutingProfilePreset('stable');
|
||||
expect(stable).toEqual(ROUTING_PROFILE_PRESETS.stable);
|
||||
});
|
||||
|
||||
it('resolves known profile by exact weights', () => {
|
||||
const preset = resolveRoutingProfilePreset(ROUTING_PROFILE_PRESETS.cost);
|
||||
expect(preset).toBe('cost');
|
||||
});
|
||||
|
||||
it('returns custom when weights do not match presets', () => {
|
||||
const preset = resolveRoutingProfilePreset({
|
||||
baseWeightFactor: 0.61,
|
||||
valueScoreFactor: 0.39,
|
||||
costWeight: 0.22,
|
||||
balanceWeight: 0.41,
|
||||
usageWeight: 0.37,
|
||||
});
|
||||
expect(preset).toBe('custom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
export type RoutingWeights = {
|
||||
baseWeightFactor: number;
|
||||
valueScoreFactor: number;
|
||||
costWeight: number;
|
||||
balanceWeight: number;
|
||||
usageWeight: number;
|
||||
};
|
||||
|
||||
type RoutingProfilePresetId = 'balanced' | 'stable' | 'cost' | 'custom';
|
||||
|
||||
export const ROUTING_PROFILE_PRESETS: Record<Exclude<RoutingProfilePresetId, 'custom'>, RoutingWeights> = {
|
||||
balanced: {
|
||||
baseWeightFactor: 0.5,
|
||||
valueScoreFactor: 0.5,
|
||||
costWeight: 0.4,
|
||||
balanceWeight: 0.3,
|
||||
usageWeight: 0.3,
|
||||
},
|
||||
stable: {
|
||||
baseWeightFactor: 0.7,
|
||||
valueScoreFactor: 0.3,
|
||||
costWeight: 0.2,
|
||||
balanceWeight: 0.6,
|
||||
usageWeight: 0.2,
|
||||
},
|
||||
cost: {
|
||||
baseWeightFactor: 0.35,
|
||||
valueScoreFactor: 0.65,
|
||||
costWeight: 0.75,
|
||||
balanceWeight: 0.15,
|
||||
usageWeight: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
const EPSILON = 1e-6;
|
||||
|
||||
export function applyRoutingProfilePreset(presetId: Exclude<RoutingProfilePresetId, 'custom'>): RoutingWeights {
|
||||
return {
|
||||
...ROUTING_PROFILE_PRESETS[presetId],
|
||||
};
|
||||
}
|
||||
|
||||
function weightsAlmostEqual(a: RoutingWeights, b: RoutingWeights): boolean {
|
||||
return (
|
||||
Math.abs(a.baseWeightFactor - b.baseWeightFactor) <= EPSILON
|
||||
&& Math.abs(a.valueScoreFactor - b.valueScoreFactor) <= EPSILON
|
||||
&& Math.abs(a.costWeight - b.costWeight) <= EPSILON
|
||||
&& Math.abs(a.balanceWeight - b.balanceWeight) <= EPSILON
|
||||
&& Math.abs(a.usageWeight - b.usageWeight) <= EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRoutingProfilePreset(weights: RoutingWeights): RoutingProfilePresetId {
|
||||
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.balanced)) return 'balanced';
|
||||
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.stable)) return 'stable';
|
||||
if (weightsAlmostEqual(weights, ROUTING_PROFILE_PRESETS.cost)) return 'cost';
|
||||
return 'custom';
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function shouldIgnoreRowSelectionClick(target: EventTarget | null): boolean {
|
||||
const element = target as { closest?: (selector: string) => unknown } | null;
|
||||
return !!element?.closest?.('button, a, input, textarea, select, label, [role="button"]');
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
SiteAnnouncementContent,
|
||||
formatSiteAnnouncementSeenAt,
|
||||
readClientTimeZone,
|
||||
resolveSiteAnnouncementTimeZone,
|
||||
} from './siteAnnouncementPresentation.js';
|
||||
|
||||
const hasDomSanitizerSupport = typeof DOMParser === 'function' && typeof Node !== 'undefined';
|
||||
const itWithDomSupport = hasDomSanitizerSupport ? it : it.skip;
|
||||
|
||||
describe('siteAnnouncementPresentation helpers', () => {
|
||||
itWithDomSupport('renders sanitized html notices with safe links', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SiteAnnouncementContent
|
||||
content={[
|
||||
'<h2>Notice</h2>',
|
||||
'<p>Welcome <strong>back</strong>.</p>',
|
||||
'<script>alert(1)</script>',
|
||||
'<a href="javascript:alert(1)" onclick="alert(1)">bad</a>',
|
||||
'<a href="https://example.com/docs" target="_blank">docs</a>',
|
||||
].join('')}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('<h2>Notice</h2>');
|
||||
expect(markup).toContain('<strong>back</strong>');
|
||||
expect(markup).not.toContain('<script');
|
||||
expect(markup).not.toContain('onclick=');
|
||||
expect(markup).not.toContain('javascript:alert');
|
||||
expect(markup).toContain('href="https://example.com/docs"');
|
||||
expect(markup).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
itWithDomSupport('renders markdown notices as structured content', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SiteAnnouncementContent
|
||||
content={[
|
||||
'# README.md',
|
||||
'',
|
||||
'这是一个 [接入文档](https://example.com/setup)。',
|
||||
'',
|
||||
'```json',
|
||||
'{',
|
||||
' "model": "gpt-5.4"',
|
||||
'}',
|
||||
'```',
|
||||
].join('\n')}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('<h1>README.md</h1>');
|
||||
expect(markup).toContain('href="https://example.com/setup"');
|
||||
expect(markup).toContain('<pre><code class="language-json">');
|
||||
expect(markup).toContain('"model": "gpt-5.4"');
|
||||
});
|
||||
|
||||
it('formats first-seen time in the requested local timezone', () => {
|
||||
expect(formatSiteAnnouncementSeenAt('2026-03-20 04:23:27', 'Asia/Shanghai')).toBe('2026/03/20 12:23:27');
|
||||
});
|
||||
|
||||
it('prefers client timezone over server timezone', () => {
|
||||
expect(resolveSiteAnnouncementTimeZone('Asia/Shanghai', 'UTC')).toBe('Asia/Shanghai');
|
||||
expect(resolveSiteAnnouncementTimeZone('', 'UTC')).toBe('UTC');
|
||||
});
|
||||
|
||||
it('reads the browser timezone when available', () => {
|
||||
expect(readClientTimeZone()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
import { marked } from 'marked';
|
||||
import { formatDateTimeLocal } from './checkinLogTime.js';
|
||||
|
||||
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
||||
const DROPPED_TAGS = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'a',
|
||||
'b',
|
||||
'blockquote',
|
||||
'br',
|
||||
'center',
|
||||
'code',
|
||||
'del',
|
||||
'div',
|
||||
'em',
|
||||
'font',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'img',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
'span',
|
||||
'strong',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
'ul',
|
||||
]);
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizeLineEndings(value: string): string {
|
||||
return String(value || '').replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
function isLikelyHtml(content: string): boolean {
|
||||
return /<([a-z][a-z0-9-]*)(?:\s[^>]*)?>/i.test(content) || /<\/([a-z][a-z0-9-]*)>/i.test(content);
|
||||
}
|
||||
|
||||
function isLikelyMarkdown(content: string): boolean {
|
||||
return /(^|\n)(#{1,6}\s+|>\s+|[-*+]\s+|\d+\.\s+|```|~~~)/.test(content)
|
||||
|| /!\[[^\]]*]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|~~[^~]+~~/.test(content);
|
||||
}
|
||||
|
||||
function sanitizeUrl(raw: string | null | undefined, allowDataImage = false): string | null {
|
||||
const value = String(raw || '').trim();
|
||||
if (!value) return null;
|
||||
if (value.startsWith('#') || value.startsWith('/')) return value;
|
||||
if (allowDataImage && /^data:image\/[a-z0-9.+-]+;base64,/i.test(value)) return value;
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
if (/^mailto:/i.test(value)) return value;
|
||||
if (/^tel:/i.test(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractVisibleTextFallback(html: string): string {
|
||||
return normalizeLineEndings(html)
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderInlineMarkdown(text: string): string {
|
||||
const tokens: string[] = [];
|
||||
const pushToken = (html: string) => {
|
||||
const key = `\u0000${tokens.length}\u0000`;
|
||||
tokens.push(html);
|
||||
return key;
|
||||
};
|
||||
|
||||
let html = escapeHtml(text);
|
||||
|
||||
html = html.replace(/`([^`]+)`/g, (_, code) => pushToken(`<code>${escapeHtml(code)}</code>`));
|
||||
html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, alt, src, title) => {
|
||||
const safeSrc = sanitizeUrl(src, true);
|
||||
if (!safeSrc) return escapeHtml(String(alt || ''));
|
||||
const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
|
||||
return pushToken(`<img src="${escapeHtml(safeSrc)}" alt="${escapeHtml(String(alt || ''))}"${titleAttr}>`);
|
||||
});
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, label, href, title) => {
|
||||
const safeHref = sanitizeUrl(href, false);
|
||||
if (!safeHref) return escapeHtml(String(label || ''));
|
||||
const titleAttr = title ? ` title="${escapeHtml(String(title))}"` : '';
|
||||
return pushToken(`<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${escapeHtml(String(label || ''))}</a>`);
|
||||
});
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||||
html = html.replace(/(^|[^\*])\*([^*\n]+)\*(?=[^\*]|$)/g, '$1<em>$2</em>');
|
||||
|
||||
return html.replace(/\u0000(\d+)\u0000/g, (_, index) => tokens[Number(index)] || '');
|
||||
}
|
||||
|
||||
function isMarkdownListLine(line: string, ordered: boolean): boolean {
|
||||
return ordered ? /^\s*\d+\.\s+/.test(line) : /^\s*[-*+]\s+/.test(line);
|
||||
}
|
||||
|
||||
function consumeMarkdownList(lines: string[], startIndex: number, ordered: boolean) {
|
||||
const items: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length && isMarkdownListLine(lines[index] || '', ordered)) {
|
||||
const line = lines[index] || '';
|
||||
const item = ordered
|
||||
? line.replace(/^\s*\d+\.\s+/, '')
|
||||
: line.replace(/^\s*[-*+]\s+/, '');
|
||||
items.push(`<li>${renderInlineMarkdown(item.trim())}</li>`);
|
||||
index += 1;
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
return {
|
||||
html: `<${tag}>${items.join('')}</${tag}>`,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeMarkdownQuote(lines: string[], startIndex: number) {
|
||||
const parts: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length && /^\s*>\s?/.test(lines[index] || '')) {
|
||||
parts.push((lines[index] || '').replace(/^\s*>\s?/, ''));
|
||||
index += 1;
|
||||
}
|
||||
return {
|
||||
html: `<blockquote>${renderMarkdown(parts.join('\n'))}</blockquote>`,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeMarkdownFence(lines: string[], startIndex: number) {
|
||||
const firstLine = String(lines[startIndex] || '');
|
||||
const marker = firstLine.startsWith('~~~') ? '~~~' : '```';
|
||||
const language = firstLine.slice(marker.length).trim().toLowerCase();
|
||||
const codeLines: string[] = [];
|
||||
let index = startIndex + 1;
|
||||
while (index < lines.length && !String(lines[index] || '').startsWith(marker)) {
|
||||
codeLines.push(String(lines[index] || ''));
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) index += 1;
|
||||
const classAttr = language && /^[a-z0-9_-]+$/i.test(language) ? ` class="language-${escapeHtml(language)}"` : '';
|
||||
return {
|
||||
html: `<pre><code${classAttr}>${escapeHtml(codeLines.join('\n'))}</code></pre>`,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeMarkdownParagraph(lines: string[], startIndex: number) {
|
||||
const parts: string[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length) {
|
||||
const line = String(lines[index] || '');
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) break;
|
||||
if (/^#{1,6}\s+/.test(trimmed)) break;
|
||||
if (/^\s*>\s?/.test(line)) break;
|
||||
if (isMarkdownListLine(line, false) || isMarkdownListLine(line, true)) break;
|
||||
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) break;
|
||||
if (/^(-{3,}|\*{3,})$/.test(trimmed)) break;
|
||||
parts.push(line);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const content = parts.map((line) => renderInlineMarkdown(line.trim())).join('<br />');
|
||||
return {
|
||||
html: `<p>${content}</p>`,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(content: string): string {
|
||||
const lines = normalizeLineEndings(content).split('\n');
|
||||
const blocks: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < lines.length) {
|
||||
const rawLine = String(lines[index] || '');
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('```') || line.startsWith('~~~')) {
|
||||
const fenced = consumeMarkdownFence(lines, index);
|
||||
blocks.push(fenced.html);
|
||||
index = fenced.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
blocks.push(`<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>\s?/.test(rawLine)) {
|
||||
const quoted = consumeMarkdownQuote(lines, index);
|
||||
blocks.push(quoted.html);
|
||||
index = quoted.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMarkdownListLine(rawLine, false)) {
|
||||
const list = consumeMarkdownList(lines, index, false);
|
||||
blocks.push(list.html);
|
||||
index = list.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMarkdownListLine(rawLine, true)) {
|
||||
const list = consumeMarkdownList(lines, index, true);
|
||||
blocks.push(list.html);
|
||||
index = list.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(-{3,}|\*{3,})$/.test(line)) {
|
||||
blocks.push('<hr>');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph = consumeMarkdownParagraph(lines, index);
|
||||
blocks.push(paragraph.html);
|
||||
index = paragraph.nextIndex;
|
||||
}
|
||||
|
||||
return blocks.join('');
|
||||
}
|
||||
|
||||
function sanitizeNode(node: Node): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeHtml(node.textContent || '');
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const element = node as Element;
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (DROPPED_TAGS.has(tag)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const childrenHtml = Array.from(element.childNodes).map((child) => sanitizeNode(child)).join('');
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
return childrenHtml;
|
||||
}
|
||||
|
||||
const attrs: string[] = [];
|
||||
if (tag === 'a') {
|
||||
const safeHref = sanitizeUrl(element.getAttribute('href'));
|
||||
if (safeHref) {
|
||||
attrs.push(` href="${escapeHtml(safeHref)}"`);
|
||||
attrs.push(' target="_blank"');
|
||||
attrs.push(' rel="noopener noreferrer"');
|
||||
}
|
||||
const title = element.getAttribute('title');
|
||||
if (title) {
|
||||
attrs.push(` title="${escapeHtml(title)}"`);
|
||||
}
|
||||
} else if (tag === 'img') {
|
||||
const safeSrc = sanitizeUrl(element.getAttribute('src'), true);
|
||||
if (!safeSrc) return '';
|
||||
attrs.push(` src="${escapeHtml(safeSrc)}"`);
|
||||
const alt = element.getAttribute('alt');
|
||||
if (alt) attrs.push(` alt="${escapeHtml(alt)}"`);
|
||||
const title = element.getAttribute('title');
|
||||
if (title) attrs.push(` title="${escapeHtml(title)}"`);
|
||||
} else if (tag === 'code') {
|
||||
const className = String(element.getAttribute('class') || '').trim();
|
||||
if (/^language-[a-z0-9_-]+$/i.test(className)) {
|
||||
attrs.push(` class="${escapeHtml(className)}"`);
|
||||
}
|
||||
} else if (tag === 'td' || tag === 'th') {
|
||||
const colspan = Number.parseInt(String(element.getAttribute('colspan') || ''), 10);
|
||||
const rowspan = Number.parseInt(String(element.getAttribute('rowspan') || ''), 10);
|
||||
if (Number.isFinite(colspan) && colspan > 1) attrs.push(` colspan="${colspan}"`);
|
||||
if (Number.isFinite(rowspan) && rowspan > 1) attrs.push(` rowspan="${rowspan}"`);
|
||||
}
|
||||
|
||||
if (VOID_TAGS.has(tag)) {
|
||||
return `<${tag}${attrs.join('')}>`;
|
||||
}
|
||||
return `<${tag}${attrs.join('')}>${childrenHtml}</${tag}>`;
|
||||
}
|
||||
|
||||
function sanitizeAnnouncementHtml(html: string): string {
|
||||
if (typeof DOMParser !== 'function' || typeof Node === 'undefined') {
|
||||
return renderPlainText(extractVisibleTextFallback(html));
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
|
||||
return Array.from(doc.body.childNodes).map((node) => sanitizeNode(node)).join('');
|
||||
}
|
||||
|
||||
function renderPlainText(content: string): string {
|
||||
const paragraphs = normalizeLineEndings(content)
|
||||
.split(/\n{2,}/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (paragraphs.length === 0) {
|
||||
return `<p>${escapeHtml(String(content || '').trim())}</p>`;
|
||||
}
|
||||
|
||||
return paragraphs
|
||||
.map((part) => `<p>${escapeHtml(part).replace(/\n/g, '<br />')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function readClientTimeZone(): string | undefined {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSiteAnnouncementTimeZone(
|
||||
clientTimeZone?: string | null,
|
||||
serverTimeZone?: string | null,
|
||||
): string | undefined {
|
||||
const client = String(clientTimeZone || '').trim();
|
||||
if (client) return client;
|
||||
|
||||
const server = String(serverTimeZone || '').trim();
|
||||
return server || undefined;
|
||||
}
|
||||
|
||||
export function formatSiteAnnouncementSeenAt(
|
||||
value: string | null | undefined,
|
||||
timeZone = readClientTimeZone(),
|
||||
): string {
|
||||
return formatDateTimeLocal(value, 'zh-CN', timeZone);
|
||||
}
|
||||
|
||||
export function renderSiteAnnouncementHtml(content: string): string {
|
||||
const raw = String(content || '').trim();
|
||||
if (!raw) return '<p>-</p>';
|
||||
|
||||
if (isLikelyHtml(raw)) {
|
||||
return sanitizeAnnouncementHtml(raw);
|
||||
}
|
||||
|
||||
if (isLikelyMarkdown(raw)) {
|
||||
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
|
||||
return sanitizeAnnouncementHtml(rendered || renderMarkdown(raw));
|
||||
}
|
||||
|
||||
return renderPlainText(raw);
|
||||
}
|
||||
|
||||
export function SiteAnnouncementContent({ content }: { content: string }) {
|
||||
return (
|
||||
<div
|
||||
className="announcement-rich-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderSiteAnnouncementHtml(content) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteApiEndpoint,
|
||||
emptySiteCustomHeader,
|
||||
emptySiteForm,
|
||||
serializeSiteApiEndpoints,
|
||||
serializeSiteCustomHeaders,
|
||||
siteFormFromSite,
|
||||
} from './sitesEditor.js';
|
||||
|
||||
describe('buildSiteSaveAction', () => {
|
||||
it('returns add action in add mode', () => {
|
||||
const action = buildSiteSaveAction(
|
||||
{ mode: 'add' },
|
||||
{
|
||||
name: 'site-a',
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1.2,
|
||||
postRefreshProbeEnabled: true,
|
||||
postRefreshProbeModel: 'gpt-4o',
|
||||
postRefreshProbeScope: 'single',
|
||||
postRefreshProbeLatencyThresholdMs: 2500,
|
||||
},
|
||||
);
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'add',
|
||||
payload: {
|
||||
name: 'site-a',
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1.2,
|
||||
postRefreshProbeEnabled: true,
|
||||
postRefreshProbeModel: 'gpt-4o',
|
||||
postRefreshProbeScope: 'single',
|
||||
postRefreshProbeLatencyThresholdMs: 2500,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns update action in edit mode with site id', () => {
|
||||
const action = buildSiteSaveAction(
|
||||
{ mode: 'edit', editingSiteId: 12 },
|
||||
{
|
||||
name: 'site-b',
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: true,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 0.8,
|
||||
},
|
||||
);
|
||||
|
||||
expect(action).toEqual({
|
||||
kind: 'update',
|
||||
id: 12,
|
||||
payload: {
|
||||
name: 'site-b',
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: true,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 0.8,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when edit mode has no site id', () => {
|
||||
expect(() =>
|
||||
buildSiteSaveAction(
|
||||
{ mode: 'edit' } as unknown as Parameters<typeof buildSiteSaveAction>[0],
|
||||
{
|
||||
name: 'site-c',
|
||||
url: 'https://c.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: '',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: false,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 1,
|
||||
},
|
||||
),
|
||||
).toThrow('editingSiteId is required in edit mode');
|
||||
});
|
||||
|
||||
it('does not expose deprecated apiKey in site editor state', () => {
|
||||
const legacySite = {
|
||||
name: 'site-d',
|
||||
url: 'https://d.example.com',
|
||||
externalCheckinUrl: null,
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:8080',
|
||||
apiEndpoints: [
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
enabled: false,
|
||||
cooldownUntil: '2026-04-01T00:05:00.000Z',
|
||||
lastFailureReason: 'HTTP 502',
|
||||
},
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
globalWeight: 1,
|
||||
apiKey: 'sk-legacy-site-key',
|
||||
} as unknown as Parameters<typeof siteFormFromSite>[0];
|
||||
|
||||
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
||||
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
|
||||
expect(emptySiteForm().apiEndpoints).toEqual([emptySiteApiEndpoint()]);
|
||||
expect(emptySiteForm().proxyUrl).toBe('');
|
||||
expect(siteFormFromSite(legacySite)).not.toHaveProperty('apiKey');
|
||||
expect(siteFormFromSite({
|
||||
proxyUrl: 'http://127.0.0.1:8080',
|
||||
}).proxyUrl).toBe('http://127.0.0.1:8080');
|
||||
expect(siteFormFromSite(legacySite).apiEndpoints).toEqual([
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
enabled: false,
|
||||
cooldownUntil: '2026-04-01T00:05:00.000Z',
|
||||
lastFailureReason: 'HTTP 502',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses custom headers json into key value rows', () => {
|
||||
expect(siteFormFromSite({
|
||||
name: 'site-e',
|
||||
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
|
||||
}).customHeaders).toEqual([
|
||||
{ key: 'x-site-token', value: 'alpha' },
|
||||
{ key: 'cf-access-client-id', value: 'beta' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes key value rows into json', () => {
|
||||
expect(serializeSiteCustomHeaders([
|
||||
{ key: 'x-site-token', value: 'alpha' },
|
||||
{ key: 'cf-access-client-id', value: 'beta' },
|
||||
emptySiteCustomHeader(),
|
||||
])).toEqual({
|
||||
valid: true,
|
||||
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate custom header names case-insensitively', () => {
|
||||
expect(serializeSiteCustomHeaders([
|
||||
{ key: 'Authorization', value: 'Bearer a' },
|
||||
{ key: 'authorization', value: 'Bearer b' },
|
||||
])).toEqual({
|
||||
valid: false,
|
||||
customHeaders: '',
|
||||
error: '请求头 "authorization" 重复了',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes api endpoint rows into ordered payloads', () => {
|
||||
expect(serializeSiteApiEndpoints([
|
||||
{ url: 'https://api-a.example.com/', enabled: true },
|
||||
{ url: 'https://api-b.example.com', enabled: false },
|
||||
emptySiteApiEndpoint(),
|
||||
])).toEqual({
|
||||
valid: true,
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate api endpoints after normalization', () => {
|
||||
expect(serializeSiteApiEndpoints([
|
||||
{ url: 'https://api.example.com/', enabled: true },
|
||||
{ url: 'https://api.example.com', enabled: true },
|
||||
])).toEqual({
|
||||
valid: false,
|
||||
apiEndpoints: [],
|
||||
error: 'API 请求地址 "https://api.example.com" 重复了',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
export type SiteCustomHeaderField = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SiteApiEndpointField = {
|
||||
draftId?: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
cooldownUntil?: string | null;
|
||||
lastFailureReason?: string | null;
|
||||
};
|
||||
|
||||
export type SiteForm = {
|
||||
name: string;
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
apiEndpoints: SiteApiEndpointField[];
|
||||
customHeaders: SiteCustomHeaderField[];
|
||||
globalWeight: string;
|
||||
};
|
||||
|
||||
export type SiteEditorState =
|
||||
| { mode: 'add' }
|
||||
| { mode: 'edit'; editingSiteId: number };
|
||||
|
||||
export type SiteSavePayload = {
|
||||
name: string;
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
initializationPresetId?: string | null;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
customHeaders: string;
|
||||
globalWeight: number;
|
||||
postRefreshProbeEnabled?: boolean;
|
||||
postRefreshProbeModel?: string;
|
||||
postRefreshProbeScope?: 'single' | 'all';
|
||||
postRefreshProbeLatencyThresholdMs?: number;
|
||||
};
|
||||
|
||||
type SiteSaveAction =
|
||||
| { kind: 'add'; payload: SiteSavePayload }
|
||||
| { kind: 'update'; id: number; payload: SiteSavePayload };
|
||||
|
||||
export function emptySiteCustomHeader(): SiteCustomHeaderField {
|
||||
return { key: '', value: '' };
|
||||
}
|
||||
|
||||
export function emptySiteApiEndpoint(): SiteApiEndpointField {
|
||||
return {
|
||||
url: '',
|
||||
enabled: true,
|
||||
cooldownUntil: null,
|
||||
lastFailureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSiteCustomHeaderRows(rows: SiteCustomHeaderField[]): SiteCustomHeaderField[] {
|
||||
return rows.length > 0 ? rows : [emptySiteCustomHeader()];
|
||||
}
|
||||
|
||||
export function emptySiteForm(): SiteForm {
|
||||
return {
|
||||
name: '',
|
||||
url: '',
|
||||
externalCheckinUrl: '',
|
||||
platform: '',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: false,
|
||||
apiEndpoints: [emptySiteApiEndpoint()],
|
||||
customHeaders: [emptySiteCustomHeader()],
|
||||
globalWeight: '1',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSiteApiEndpointRows(rows: SiteApiEndpointField[]): SiteApiEndpointField[] {
|
||||
return rows.length > 0 ? rows : [emptySiteApiEndpoint()];
|
||||
}
|
||||
|
||||
function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
|
||||
if (typeof raw !== 'string') {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
}
|
||||
return ensureSiteCustomHeaderRows(
|
||||
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'string' ? value : String(value ?? ''),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
}
|
||||
}
|
||||
|
||||
function parseApiEndpointsForEditor(raw: unknown): SiteApiEndpointField[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return ensureSiteApiEndpointRows([]);
|
||||
}
|
||||
|
||||
const rows: SiteApiEndpointField[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
rows.push({
|
||||
url: typeof row.url === 'string' ? row.url : '',
|
||||
enabled: row.enabled !== false,
|
||||
cooldownUntil: typeof row.cooldownUntil === 'string' ? row.cooldownUntil : null,
|
||||
lastFailureReason: typeof row.lastFailureReason === 'string' ? row.lastFailureReason : null,
|
||||
});
|
||||
}
|
||||
return ensureSiteApiEndpointRows(rows);
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
|
||||
externalCheckinUrl?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
apiEndpoints?: Array<{
|
||||
url?: string | null;
|
||||
enabled?: boolean | null;
|
||||
cooldownUntil?: string | null;
|
||||
lastFailureReason?: string | null;
|
||||
}> | null;
|
||||
customHeaders?: string | null;
|
||||
globalWeight?: number | string | null;
|
||||
}): SiteForm {
|
||||
const globalWeightRaw = Number(site.globalWeight);
|
||||
const globalWeight = Number.isFinite(globalWeightRaw) && globalWeightRaw > 0 ? String(globalWeightRaw) : '1';
|
||||
return {
|
||||
name: site.name ?? '',
|
||||
url: site.url ?? '',
|
||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||
platform: site.platform ?? '',
|
||||
proxyUrl: site.proxyUrl ?? '',
|
||||
useSystemProxy: !!site.useSystemProxy,
|
||||
apiEndpoints: parseApiEndpointsForEditor(site.apiEndpoints),
|
||||
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
|
||||
globalWeight,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
|
||||
// src/server/services/siteApiEndpointService.ts.
|
||||
function normalizeSiteApiEndpointUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
|
||||
valid: boolean;
|
||||
customHeaders: string;
|
||||
error?: string;
|
||||
} {
|
||||
const headers: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const field of fields) {
|
||||
const key = field.key.trim();
|
||||
const value = field.value;
|
||||
const hasAnyInput = key.length > 0 || value.trim().length > 0;
|
||||
if (!hasAnyInput) continue;
|
||||
if (!key) {
|
||||
return { valid: false, customHeaders: '', error: '请求头名称不能为空' };
|
||||
}
|
||||
const normalizedKey = key.toLowerCase();
|
||||
if (seen.has(normalizedKey)) {
|
||||
return { valid: false, customHeaders: '', error: `请求头 "${key}" 重复了` };
|
||||
}
|
||||
seen.add(normalizedKey);
|
||||
headers[key] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
customHeaders: Object.keys(headers).length > 0 ? JSON.stringify(headers) : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeSiteApiEndpoints(fields: SiteApiEndpointField[]): {
|
||||
valid: boolean;
|
||||
apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
error?: string;
|
||||
} {
|
||||
const apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const field of fields) {
|
||||
const rawUrl = field.url.trim();
|
||||
if (!rawUrl) continue;
|
||||
const normalizedUrl = normalizeSiteApiEndpointUrl(rawUrl);
|
||||
if (!normalizedUrl) continue;
|
||||
if (seen.has(normalizedUrl)) {
|
||||
return {
|
||||
valid: false,
|
||||
apiEndpoints: [],
|
||||
error: `API 请求地址 "${normalizedUrl}" 重复了`,
|
||||
};
|
||||
}
|
||||
seen.add(normalizedUrl);
|
||||
apiEndpoints.push({
|
||||
url: normalizedUrl || rawUrl,
|
||||
enabled: field.enabled !== false,
|
||||
sortOrder: apiEndpoints.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
apiEndpoints,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteSavePayload): SiteSaveAction {
|
||||
if (editor.mode === 'edit') {
|
||||
if (!Number.isFinite(editor.editingSiteId)) {
|
||||
throw new Error('editingSiteId is required in edit mode');
|
||||
}
|
||||
return { kind: 'update', id: editor.editingSiteId, payload: form };
|
||||
}
|
||||
return { kind: 'add', payload: form };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildUpdateReminder,
|
||||
describeDockerDeployState,
|
||||
} from './updateCenterPresentation.js';
|
||||
|
||||
describe('updateCenterPresentation', () => {
|
||||
it('returns an unknown reminder when no candidate source data is available', () => {
|
||||
expect(buildUpdateReminder({
|
||||
currentVersion: '1.2.3',
|
||||
helper: null,
|
||||
githubRelease: null,
|
||||
dockerHubTag: null,
|
||||
})).toEqual({
|
||||
label: '无法检查更新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '暂未获取到可比较的版本信息。',
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a same-version Docker target with a different digest as a real new-digest deploy', () => {
|
||||
const state = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
candidate: {
|
||||
normalizedVersion: '1.2.3',
|
||||
tagName: '1.2.3',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.kind).toBe('new-digest');
|
||||
expect(state.canDeploy).toBe(true);
|
||||
expect(state.badgeLabel).toBe('发现新 digest');
|
||||
});
|
||||
|
||||
it('treats semver tags with and without a v prefix as the same Docker digest target', () => {
|
||||
const state = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: 'v1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
candidate: {
|
||||
normalizedVersion: '1.2.3',
|
||||
tagName: '1.2.3',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.kind).toBe('new-digest');
|
||||
expect(state.canDeploy).toBe(true);
|
||||
});
|
||||
|
||||
it('treats alias tags like latest as a new digest deploy target when the digest changes', () => {
|
||||
const state = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
candidate: {
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.kind).toBe('new-digest');
|
||||
expect(state.canDeploy).toBe(true);
|
||||
expect(state.badgeLabel).toBe('发现新 digest');
|
||||
});
|
||||
|
||||
it('does not advertise an older GitHub reminder when the helper is already ahead', () => {
|
||||
expect(buildUpdateReminder({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.4.0',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
tagName: 'v1.3.0',
|
||||
},
|
||||
dockerHubTag: null,
|
||||
})).toEqual({
|
||||
label: '已是最新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '当前运行版本与已发现的部署目标没有明显差异。',
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
compareStableVersions,
|
||||
isSameImageTarget,
|
||||
resolveUpdateReminderCandidate,
|
||||
type UpdateHelperRuntimeLike,
|
||||
type UpdateVersionCandidateLike,
|
||||
} from '../../../shared/updateCenterReminder.js';
|
||||
|
||||
export type UpdateDeployState = {
|
||||
kind: 'disabled' | 'missing' | 'helper-unhealthy' | 'same-version' | 'same-image' | 'new-version' | 'new-digest' | 'available';
|
||||
badgeClassName: string;
|
||||
badgeLabel: string;
|
||||
reason: string;
|
||||
canDeploy: boolean;
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
export type UpdateReminder = {
|
||||
label: string;
|
||||
badgeClassName: string;
|
||||
detail: string;
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
function normalizeString(value?: string | null): string {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeDigest(value?: string | null): string {
|
||||
const digest = normalizeString(value);
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function describeGitHubDeployState(input: {
|
||||
enabled: boolean;
|
||||
helperHealthy: boolean;
|
||||
helperError?: string | null;
|
||||
currentVersion?: string | null;
|
||||
helperImageTag?: string | null;
|
||||
candidate: UpdateVersionCandidateLike | null | undefined;
|
||||
}): UpdateDeployState {
|
||||
if (!input.enabled) {
|
||||
return {
|
||||
kind: 'disabled',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '已停用',
|
||||
reason: '当前来源已停用,开启后才会参与检查和部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidateVersion = normalizeString(input.candidate?.normalizedVersion);
|
||||
const candidateTag = normalizeString(input.candidate?.tagName || candidateVersion);
|
||||
if (!candidateVersion && !candidateTag) {
|
||||
return {
|
||||
kind: 'missing',
|
||||
badgeClassName: 'badge badge-warning',
|
||||
badgeLabel: '未发现版本',
|
||||
reason: '当前来源还没有可部署版本。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.helperHealthy) {
|
||||
return {
|
||||
kind: 'helper-unhealthy',
|
||||
badgeClassName: 'badge badge-warning',
|
||||
badgeLabel: '等待 helper',
|
||||
reason: input.helperError || 'Deploy Helper 未健康,先修复 helper 再部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidateTargetVersion = candidateVersion || candidateTag;
|
||||
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
||||
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTargetVersion);
|
||||
if (versionCompare === 0 || helperVersionCompare === 0 || helperVersionCompare === 1) {
|
||||
return {
|
||||
kind: 'same-version',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: helperVersionCompare === 1
|
||||
? 'Deploy Helper 已指向更高版本,无需回退到较旧的 GitHub 稳定版。'
|
||||
: '当前已运行该版本,无需重复部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (versionCompare === -1) {
|
||||
return {
|
||||
kind: 'new-version',
|
||||
badgeClassName: 'badge badge-success',
|
||||
badgeLabel: '发现新版本',
|
||||
reason: '检测到比当前运行版本更新的稳定版,可直接发起部署。',
|
||||
canDeploy: true,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'available',
|
||||
badgeClassName: 'badge badge-info',
|
||||
badgeLabel: '可部署',
|
||||
reason: '版本可用,点击按钮即可通过 helper 发起滚动更新。',
|
||||
canDeploy: true,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeDockerDeployState(input: {
|
||||
enabled: boolean;
|
||||
helperHealthy: boolean;
|
||||
helperError?: string | null;
|
||||
currentVersion?: string | null;
|
||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
||||
candidate: UpdateVersionCandidateLike | null | undefined;
|
||||
}): UpdateDeployState {
|
||||
if (!input.enabled) {
|
||||
return {
|
||||
kind: 'disabled',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '已停用',
|
||||
reason: '当前来源已停用,开启后才会参与检查和部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidateVersion = normalizeString(input.candidate?.normalizedVersion);
|
||||
const candidateTag = normalizeString(input.candidate?.tagName || candidateVersion);
|
||||
const candidateDigest = normalizeDigest(input.candidate?.digest);
|
||||
if (!candidateVersion && !candidateTag) {
|
||||
return {
|
||||
kind: 'missing',
|
||||
badgeClassName: 'badge badge-warning',
|
||||
badgeLabel: '未发现版本',
|
||||
reason: '当前来源还没有可部署版本。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.helperHealthy) {
|
||||
return {
|
||||
kind: 'helper-unhealthy',
|
||||
badgeClassName: 'badge badge-warning',
|
||||
badgeLabel: '等待 helper',
|
||||
reason: input.helperError || 'Deploy Helper 未健康,先修复 helper 再部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isSameImageTarget(input.helper, { tag: candidateTag, digest: candidateDigest })) {
|
||||
return {
|
||||
kind: 'same-image',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: '当前已运行该镜像,无需重复部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidateTargetVersion = candidateVersion || candidateTag;
|
||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, candidateTargetVersion);
|
||||
if (helperVersionCompare === 1) {
|
||||
return {
|
||||
kind: 'same-version',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: 'Deploy Helper 已指向更高版本,无需回退到较旧镜像。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
||||
return {
|
||||
kind: 'new-version',
|
||||
badgeClassName: 'badge badge-success',
|
||||
badgeLabel: '发现新版本',
|
||||
reason: 'Docker Hub 已出现更高版本,可直接发起部署。',
|
||||
canDeploy: true,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
||||
const hasSameStableTag = isSameImageTarget(
|
||||
{
|
||||
imageTag: input.helper?.imageTag,
|
||||
imageDigest: null,
|
||||
},
|
||||
{
|
||||
tag: candidateTag,
|
||||
digest: null,
|
||||
},
|
||||
);
|
||||
if (candidateDigest && helperDigest && hasSameStableTag && helperDigest !== candidateDigest) {
|
||||
return {
|
||||
kind: 'new-digest',
|
||||
badgeClassName: 'badge badge-success',
|
||||
badgeLabel: '发现新 digest',
|
||||
reason: '标签未变,但镜像 digest 已更新,适合按镜像级别滚动更新。',
|
||||
canDeploy: true,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'available',
|
||||
badgeClassName: 'badge badge-info',
|
||||
badgeLabel: '可部署',
|
||||
reason: '版本可用,点击按钮即可通过 helper 发起滚动更新。',
|
||||
canDeploy: true,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdateReminder(input: {
|
||||
currentVersion?: string | null;
|
||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
||||
}): UpdateReminder {
|
||||
const hasGitHubCandidate = Boolean(normalizeString(
|
||||
input.githubRelease?.displayVersion
|
||||
|| input.githubRelease?.normalizedVersion
|
||||
|| input.githubRelease?.tagName,
|
||||
));
|
||||
const hasDockerCandidate = Boolean(normalizeString(
|
||||
input.dockerHubTag?.displayVersion
|
||||
|| input.dockerHubTag?.normalizedVersion
|
||||
|| input.dockerHubTag?.tagName
|
||||
|| input.dockerHubTag?.digest,
|
||||
));
|
||||
if (!hasGitHubCandidate && !hasDockerCandidate) {
|
||||
return {
|
||||
label: '无法检查更新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '暂未获取到可比较的版本信息。',
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidate = resolveUpdateReminderCandidate({
|
||||
currentVersion: input.currentVersion,
|
||||
helper: input.helper,
|
||||
githubRelease: input.githubRelease,
|
||||
dockerHubTag: input.dockerHubTag,
|
||||
});
|
||||
if (candidate) {
|
||||
return {
|
||||
label: candidate.kind === 'new-digest' ? '发现新 digest' : '发现新版本',
|
||||
badgeClassName: 'badge badge-success',
|
||||
detail: candidate.kind === 'new-digest'
|
||||
? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。'
|
||||
: candidate.source === 'github-release'
|
||||
? `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`
|
||||
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: '已是最新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '当前运行版本与已发现的部署目标没有明显差异。',
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildZeroChannelPlaceholderRoutes } from './zeroChannelRoutes.js';
|
||||
import type { MissingTokenModelsByName } from './routeMissingTokenHints.js';
|
||||
import type { RouteSummaryRow } from '../token-routes/types.js';
|
||||
|
||||
describe('buildZeroChannelPlaceholderRoutes', () => {
|
||||
it('merges missing-token and missing-group models into exact zero-channel placeholders', () => {
|
||||
const routes: RouteSummaryRow[] = [
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
displayName: 'gpt-4o-mini',
|
||||
displayIcon: null,
|
||||
modelMapping: null,
|
||||
routingStrategy: 'weighted',
|
||||
enabled: true,
|
||||
channelCount: 1,
|
||||
enabledChannelCount: 1,
|
||||
siteNames: ['site-a'],
|
||||
decisionSnapshot: null,
|
||||
decisionRefreshedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const modelsWithoutToken: MissingTokenModelsByName = {
|
||||
'gpt-4o-mini': [
|
||||
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
|
||||
],
|
||||
'gpt-5.2-codex': [
|
||||
{ accountId: 2, username: 'bob', siteId: 12, siteName: 'site-b' },
|
||||
],
|
||||
};
|
||||
const modelsMissingTokenGroups: MissingTokenModelsByName = {
|
||||
'gpt-5.2-codex': [
|
||||
{
|
||||
accountId: 3,
|
||||
username: 'cici',
|
||||
siteId: 13,
|
||||
siteName: 'site-c',
|
||||
missingGroups: ['opus'],
|
||||
requiredGroups: ['default', 'opus'],
|
||||
availableGroups: ['default'],
|
||||
},
|
||||
],
|
||||
're:^claude-.*$': [
|
||||
{ accountId: 4, username: 'dan', siteId: 14, siteName: 'site-d' },
|
||||
],
|
||||
};
|
||||
|
||||
const placeholders = buildZeroChannelPlaceholderRoutes(routes, modelsWithoutToken, modelsMissingTokenGroups);
|
||||
|
||||
expect(placeholders).toHaveLength(1);
|
||||
expect(placeholders[0]).toMatchObject({
|
||||
modelPattern: 'gpt-5.2-codex',
|
||||
channelCount: 0,
|
||||
enabledChannelCount: 0,
|
||||
enabled: false,
|
||||
decisionSnapshot: null,
|
||||
kind: 'zero_channel',
|
||||
readOnly: true,
|
||||
isVirtual: true,
|
||||
});
|
||||
expect(placeholders[0].siteNames).toEqual(['site-b', 'site-c']);
|
||||
expect(placeholders[0].id).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { MissingTokenModelsByName } from './routeMissingTokenHints.js';
|
||||
import type { RouteSummaryRow } from '../token-routes/types.js';
|
||||
import { isExactModelPattern } from '../token-routes/utils.js';
|
||||
|
||||
function buildStableVirtualRouteId(modelName: string): number {
|
||||
const normalized = modelName.trim().toLowerCase();
|
||||
let hash = 0;
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
hash = ((hash * 131) + normalized.charCodeAt(index)) % 2_147_483_647;
|
||||
}
|
||||
return -Math.max(1, hash || normalized.length || 1);
|
||||
}
|
||||
|
||||
export function buildZeroChannelPlaceholderRoutes(
|
||||
routes: RouteSummaryRow[],
|
||||
modelsWithoutToken: MissingTokenModelsByName,
|
||||
modelsMissingTokenGroups: MissingTokenModelsByName,
|
||||
): RouteSummaryRow[] {
|
||||
const exactRouteNames = new Set(
|
||||
(routes || [])
|
||||
.filter((route) => isExactModelPattern(route.modelPattern))
|
||||
.map((route) => (route.modelPattern || '').trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
const placeholderByModel = new Map<string, RouteSummaryRow>();
|
||||
const mergeMissingHints = (missingByModel: MissingTokenModelsByName) => {
|
||||
for (const [rawModelName, accounts] of Object.entries(missingByModel || {})) {
|
||||
const modelName = String(rawModelName || '').trim();
|
||||
if (!modelName) continue;
|
||||
if (!isExactModelPattern(modelName)) continue;
|
||||
if (exactRouteNames.has(modelName.toLowerCase())) continue;
|
||||
|
||||
const routeKey = modelName.toLowerCase();
|
||||
const existing = placeholderByModel.get(routeKey);
|
||||
const siteNames = new Set<string>(existing?.siteNames || []);
|
||||
for (const account of accounts || []) {
|
||||
const siteName = String(account?.siteName || '').trim();
|
||||
if (siteName) siteNames.add(siteName);
|
||||
}
|
||||
|
||||
placeholderByModel.set(routeKey, {
|
||||
id: buildStableVirtualRouteId(modelName),
|
||||
modelPattern: modelName,
|
||||
displayName: null,
|
||||
displayIcon: null,
|
||||
modelMapping: null,
|
||||
routingStrategy: null,
|
||||
enabled: false,
|
||||
channelCount: 0,
|
||||
enabledChannelCount: 0,
|
||||
siteNames: Array.from(siteNames).sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' })),
|
||||
decisionSnapshot: null,
|
||||
decisionRefreshedAt: null,
|
||||
kind: 'zero_channel',
|
||||
readOnly: true,
|
||||
isVirtual: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
mergeMissingHints(modelsWithoutToken);
|
||||
mergeMissingHints(modelsMissingTokenGroups);
|
||||
|
||||
return Array.from(placeholderByModel.values())
|
||||
.sort((left, right) => left.modelPattern.localeCompare(right.modelPattern, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
Reference in New Issue
Block a user