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:
boos
2026-05-18 09:33:56 +08:00
parent 5d29f03d50
commit 0eb7c32585
274 changed files with 80446 additions and 0 deletions
@@ -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');
});
});
+106
View File
@@ -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';
}
+22
View File
@@ -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);
});
});
+32
View File
@@ -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}`;
}
+61
View File
@@ -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 },
]);
});
});
+76
View File
@@ -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');
});
});
+118
View File
@@ -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');
});
});
+24
View File
@@ -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');
});
});
+58
View File
@@ -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';
}
+4
View File
@@ -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('&quot;model&quot;: &quot;gpt-5.4&quot;');
});
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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) }}
/>
);
}
+205
View File
@@ -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" 重复了',
});
});
});
+257
View File
@@ -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' }));
}