1
0
forked from boos/BoosAPI

Merge origin/main into codex/modeltester-attachment-review-fixes

This commit is contained in:
cita
2026-03-20 23:09:27 +08:00
46 changed files with 1633 additions and 139 deletions
+20
View File
@@ -156,6 +156,21 @@ export type ProxyTestJobResponse = {
expiresAt?: string;
};
export type SystemProxyTestRequest = {
proxyUrl?: string;
};
export type SystemProxyTestResponse = {
success: true;
proxyUrl: string;
probeUrl: string;
finalUrl: string;
reachable: true;
ok: boolean;
statusCode: number;
latencyMs: number;
};
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
@@ -511,6 +526,11 @@ export const api = {
method: 'PUT',
body: JSON.stringify(data),
}),
testSystemProxy: (data: SystemProxyTestRequest) => request('/api/settings/system-proxy/test', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: 20_000,
}),
getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
request('/api/settings/database/runtime', {
+11 -6
View File
@@ -1164,12 +1164,17 @@ export default function Accounts() {
</div>
{isSub2ApiSelected && (
<>
<input
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
value={tokenForm.refreshToken}
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
value={tokenForm.refreshToken}
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
可在浏览器控制台执行 <code style={{ fontFamily: 'var(--font-mono)' }}>localStorage.getItem('refresh_token')</code> 获取。
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input
placeholder="token_expires_at(可选,毫秒时间戳)"
+62 -4
View File
@@ -55,6 +55,11 @@ type RuntimeSettings = {
currentAdminIp?: string;
};
type SystemProxyTestState =
| { kind: 'success'; text: string }
| { kind: 'error'; text: string }
| null;
type DownstreamApiKeyItem = {
id: number;
name: string;
@@ -222,6 +227,8 @@ export default function Settings() {
const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
@@ -576,6 +583,31 @@ export default function Settings() {
}
};
const testSystemProxy = async () => {
const proxyUrl = runtime.systemProxyUrl.trim();
if (!proxyUrl) {
const message = '请先填写系统代理地址';
setSystemProxyTestState({ kind: 'error', text: message });
toast.info(message);
return;
}
setTestingSystemProxy(true);
setSystemProxyTestState(null);
try {
const res = await api.testSystemProxy({ proxyUrl });
const summary = `连通成功,延迟 ${res.latencyMs} ms`;
setSystemProxyTestState({ kind: 'success', text: summary });
toast.success(`系统代理测试成功(${res.latencyMs} ms)`);
} catch (err: any) {
const message = err?.message || '系统代理测试失败';
setSystemProxyTestState({ kind: 'error', text: message });
toast.error(message);
} finally {
setTestingSystemProxy(false);
}
};
const saveProxyFailureRules = async () => {
setSavingProxyFailureRules(true);
try {
@@ -1114,13 +1146,39 @@ export default function Settings() {
</div>
<input
value={runtime.systemProxyUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }))}
onChange={(e) => {
setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }));
setSystemProxyTestState(null);
}}
placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }}
/>
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
</button>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
</button>
<button
onClick={testSystemProxy}
disabled={testingSystemProxy}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{testingSystemProxy ? <><span className="spinner spinner-sm" /> 测试中...</> : '测试系统代理'}
</button>
</div>
{systemProxyTestState && (
<div
style={{
fontSize: 12,
marginTop: 10,
color: systemProxyTestState.kind === 'success'
? 'var(--color-success)'
: 'var(--color-danger)',
}}
>
{systemProxyTestState.text}
</div>
)}
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
+11
View File
@@ -40,6 +40,7 @@ type SiteRow = {
externalCheckinUrl?: string | null;
platform?: string;
status?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean;
customHeaders?: string | null;
globalWeight?: number;
@@ -382,6 +383,7 @@ export default function Sites() {
url: form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(),
proxyUrl: form.proxyUrl.trim(),
useSystemProxy: !!form.useSystemProxy,
customHeaders: serializedCustomHeaders.customHeaders,
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
@@ -918,6 +920,15 @@ export default function Sites() {
</div>
)}
</div>
<input
placeholder="站点代理(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
value={form.proxyUrl}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
style={formInputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
</div>
<label style={{
display: 'flex',
alignItems: 'center',
@@ -31,14 +31,14 @@ describe('resolveConversationFileCapability', () => {
imageMode: 'native',
audioMode: 'unsupported',
documentMode: 'inline_only',
reason: '',
reason: '当前界面的会话附件会以内联文档方式发送。',
});
expect(resolveConversationFileCapability('gemini')).toEqual({
supported: true,
imageMode: 'native',
audioMode: 'native',
documentMode: 'inline_only',
reason: '',
reason: '当前界面的会话附件会以内联文档方式发送。',
});
});
@@ -87,7 +87,7 @@ export function resolveConversationFileCapability(
imageMode: 'native',
audioMode: 'unsupported',
documentMode: 'inline_only',
reason: '',
reason: '当前界面的会话附件会以内联文档方式发送。',
};
}
@@ -97,7 +97,7 @@ export function resolveConversationFileCapability(
imageMode: 'native',
audioMode: 'native',
documentMode: 'inline_only',
reason: '',
reason: '当前界面的会话附件会以内联文档方式发送。',
};
}
@@ -223,30 +223,52 @@ describe('modelTesterSession', () => {
]);
});
it('extracts both uploaded references and inline attachments from user messages', () => {
const message = createConversationUserMessage('请重新发送附件', [
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([
{
fileId: 'file-metapi-123',
filename: 'paper.pdf',
filename: 'brief.pdf',
mimeType: 'application/pdf',
},
{
filename: 'voice.mp3',
mimeType: 'audio/mpeg',
data: 'data:audio/mpeg;base64,QUFBQQ==',
data: 'data:application/pdf;base64,JVBERi0xLjQK',
},
]);
});
expect(extractConversationUploadedFilesFromMessage(message)).toEqual([
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-123',
filename: 'paper.pdf',
mimeType: 'application/pdf',
},
{
filename: 'voice.mp3',
mimeType: 'audio/mpeg',
data: 'data:audio/mpeg;base64,QUFBQQ==',
fileId: 'file-metapi-456',
filename: 'appendix.txt',
mimeType: 'text/plain',
},
]);
});
+17 -8
View File
@@ -918,17 +918,26 @@ export const extractConversationUploadedFilesFromMessage = (
const parts = Array.isArray(message.parts) ? message.parts : [];
return parts.flatMap((part) => {
if (part.type !== 'input_file') return [];
const fileId = typeof part.fileId === 'string' ? part.fileId.trim() : '';
const data = typeof part.data === 'string' ? part.data.trim() : '';
const fileId = typeof part.fileId === 'string' && part.fileId.trim()
? part.fileId.trim()
: null;
const filename = typeof part.filename === 'string' && part.filename.trim()
? part.filename.trim()
: null;
const mimeType = typeof part.mimeType === 'string' && part.mimeType.trim()
? part.mimeType.trim()
: null;
const data = typeof part.data === 'string' && part.data.trim()
? part.data.trim()
: null;
if (!fileId && !data) return [];
return [{
...(fileId ? { fileId } : {}),
...(typeof part.filename === 'string' && part.filename.trim()
? { filename: part.filename.trim() }
: {}),
...(typeof part.mimeType === 'string' && part.mimeType.trim()
? { mimeType: part.mimeType.trim() }
: {}),
...(filename ? { filename } : {}),
...(mimeType ? { mimeType } : {}),
...(data ? { data } : {}),
}];
});
+10 -1
View File
@@ -16,6 +16,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
@@ -29,6 +30,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
@@ -44,6 +46,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
proxyUrl: '',
useSystemProxy: true,
customHeaders: '',
globalWeight: 0.8,
@@ -58,6 +61,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
proxyUrl: '',
useSystemProxy: true,
customHeaders: '',
globalWeight: 0.8,
@@ -74,6 +78,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://c.example.com',
externalCheckinUrl: '',
platform: '',
proxyUrl: '',
useSystemProxy: false,
customHeaders: '',
globalWeight: 1,
@@ -85,16 +90,20 @@ describe('buildSiteSaveAction', () => {
it('does not expose deprecated apiKey in site editor state', () => {
expect(emptySiteForm()).not.toHaveProperty('apiKey');
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
expect(emptySiteForm().proxyUrl).toBe('');
expect(siteFormFromSite({
name: 'site-d',
url: 'https://d.example.com',
externalCheckinUrl: null,
platform: 'new-api',
proxyUrl: null,
proxyUrl: 'http://127.0.0.1:8080',
customHeaders: '{"x-site-token":"alpha"}',
globalWeight: 1,
apiKey: 'sk-legacy-site-key',
})).not.toHaveProperty('apiKey');
expect(siteFormFromSite({
proxyUrl: 'http://127.0.0.1:8080',
}).proxyUrl).toBe('http://127.0.0.1:8080');
});
it('parses custom headers json into key value rows', () => {
+6 -1
View File
@@ -8,6 +8,7 @@ export type SiteForm = {
url: string;
externalCheckinUrl: string;
platform: string;
proxyUrl: string;
useSystemProxy: boolean;
customHeaders: SiteCustomHeaderField[];
globalWeight: string;
@@ -22,6 +23,7 @@ export type SiteSavePayload = {
url: string;
externalCheckinUrl: string;
platform: string;
proxyUrl: string;
useSystemProxy: boolean;
customHeaders: string;
globalWeight: number;
@@ -45,6 +47,7 @@ export function emptySiteForm(): SiteForm {
url: '',
externalCheckinUrl: '',
platform: '',
proxyUrl: '',
useSystemProxy: false,
customHeaders: [emptySiteCustomHeader()],
globalWeight: '1',
@@ -76,8 +79,9 @@ function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
}
}
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'useSystemProxy'>> & {
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
externalCheckinUrl?: string | null;
proxyUrl?: string | null;
useSystemProxy?: boolean | null;
customHeaders?: string | null;
globalWeight?: number | string | null;
@@ -89,6 +93,7 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' |
url: site.url ?? '',
externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '',
proxyUrl: site.proxyUrl ?? '',
useSystemProxy: !!site.useSystemProxy,
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
globalWeight,
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(),
testSystemProxy: vi.fn(),
},
}));
@@ -67,6 +68,16 @@ describe('Settings system proxy', () => {
success: true,
systemProxyUrl: 'http://127.0.0.1:7890',
});
apiMock.testSystemProxy.mockResolvedValue({
success: true,
proxyUrl: 'http://127.0.0.1:7890',
probeUrl: 'https://www.gstatic.com/generate_204',
finalUrl: 'https://www.gstatic.com/generate_204',
reachable: true,
ok: true,
statusCode: 204,
latencyMs: 321,
});
});
afterEach(() => {
@@ -112,4 +123,45 @@ describe('Settings system proxy', () => {
root?.unmount();
}
});
it('tests system proxy from settings', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const proxyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)'
));
await act(async () => {
proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } });
});
const testButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '测试系统代理'
));
await act(async () => {
testButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.testSystemProxy).toHaveBeenCalledWith({
proxyUrl: 'http://127.0.0.1:7890',
});
expect(collectText(root.root)).toContain('连通成功,延迟 321 ms');
} finally {
root?.unmount();
}
});
});
@@ -7,6 +7,7 @@ import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js';
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
@@ -54,7 +55,15 @@ export function SortableChannelRow({
};
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
const tokenBinding = describeTokenBinding(tokenOptions, activeTokenId, channel.token?.name ?? null);
const tokenBinding = describeTokenBinding(
tokenOptions,
activeTokenId,
channel.token?.name ?? null,
{
connectionMode: resolveTokenBindingConnectionMode(channel.account),
accountName: channel.account?.username || `account-${channel.accountId}`,
},
);
return (
<div ref={setNodeRef} style={rowStyle}>
@@ -113,10 +122,10 @@ export function SortableChannelRow({
className="badge"
style={{
fontSize: 10,
background: tokenBinding.isFollowingAccountDefault
background: tokenBinding.badgeTone === 'info'
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: tokenBinding.isFollowingAccountDefault ? 'var(--color-info)' : 'var(--color-warning)',
color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)',
}}
>
{tokenBinding.bindingModeLabel}
@@ -211,7 +220,7 @@ export function SortableChannelRow({
options={[
{
value: '0',
label: '跟随账号默认',
label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
@@ -3,6 +3,7 @@ import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js';
describe('tokenBindingPresentation', () => {
@@ -13,8 +14,10 @@ describe('tokenBindingPresentation', () => {
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('token-a');
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('当前生效:token-a');
});
@@ -25,6 +28,7 @@ describe('tokenBindingPresentation', () => {
], 1);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('default');
expect(result.helperText).toContain('它目前也是账号默认');
expect(result.helperText).toContain('不会跟着变');
@@ -37,8 +41,10 @@ describe('tokenBindingPresentation', () => {
], 2);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('backup');
expect(result.helperText).toContain('不会随账号默认变化');
expect(result.followOptionLabel).toBe('跟随账号默认');
});
it('describes follow-account-default mode when no default token exists yet', () => {
@@ -48,11 +54,76 @@ describe('tokenBindingPresentation', () => {
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('未设置默认令牌');
expect(result.helperText).toContain('当前账号还没有默认令牌');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
});
it('describes follow-api-setting mode for direct apikey connections', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'apikey',
accountName: 'elysiver_api',
});
expect(result.bindingModeLabel).toBe('API令牌');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('elysiver_api');
expect(result.helperText).toContain('连接「elysiver_api」保存的 API Key');
expect(result.followOptionLabel).toBe('固定使用:elysiver_api(跟随 API Key 设置)');
expect(result.followOptionDescription).toContain('API Key');
});
it('describes oauth direct binding without default-token wording', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('OAuth授权');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('mail@urlk.cn');
expect(result.helperText).toContain('OAuth 授权');
expect(result.helperText).not.toContain('默认令牌');
expect(result.followOptionLabel).toBe('固定使用:mail@urlk.cn(OAuth 授权)');
expect(result.followOptionDescription).toContain('OAuth 授权');
});
it('uses oauth fallback copy when a fixed token route is switched back to oauth direct binding', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 2, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.followOptionLabel).toBe('OAuth授权');
expect(result.followOptionDescription).toContain('mail@urlk.cn');
expect(result.followOptionDescription).not.toContain('当前生效:default');
});
it('resolves token binding connection mode from stored account fields', () => {
expect(resolveTokenBindingConnectionMode({
accessToken: '',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
})).toBe('apikey');
expect(resolveTokenBindingConnectionMode({
accessToken: 'session-token',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe('session');
expect(resolveTokenBindingConnectionMode({
accessToken: 'oauth-access-token',
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe('oauth');
});
it('formats fixed token options with clearer labels and descriptions', () => {
const token = {
id: 3,
@@ -8,11 +8,93 @@ export type TokenBindingOption = {
export type TokenBindingPresentation = {
isFollowingAccountDefault: boolean;
bindingModeLabel: string;
badgeTone: 'info' | 'warning';
effectiveTokenName: string;
helperText: string;
followOptionLabel: string;
followOptionDescription: string;
};
type TokenBindingConnectionMode = 'session' | 'apikey' | 'oauth';
type TokenBindingContext = {
connectionMode?: TokenBindingConnectionMode;
accountName?: string | null;
};
function parseExtraConfigHints(extraConfig?: string | null): {
credentialMode: Extract<TokenBindingConnectionMode, 'session' | 'apikey'> | null;
hasOauthProvider: boolean;
} {
if (typeof extraConfig !== 'string' || !extraConfig.trim()) {
return {
credentialMode: null,
hasOauthProvider: false,
};
}
try {
const parsed = JSON.parse(extraConfig) as {
credentialMode?: unknown;
oauth?: { provider?: unknown } | null;
};
const raw = String(parsed?.credentialMode || '').trim().toLowerCase();
return {
credentialMode: raw === 'session' || raw === 'apikey' ? raw : null,
hasOauthProvider: typeof parsed?.oauth?.provider === 'string' && parsed.oauth.provider.trim().length > 0,
};
} catch {}
return {
credentialMode: null,
hasOauthProvider: false,
};
}
function buildDirectBindingPresentation(
connectionMode: Extract<TokenBindingConnectionMode, 'apikey' | 'oauth'>,
accountName: string,
): TokenBindingPresentation {
if (connectionMode === 'oauth') {
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'OAuth授权',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」的 OAuth 授权,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(OAuth 授权)`,
followOptionDescription: `直接使用连接「${accountName}」的 OAuth 授权`,
};
}
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'API令牌',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」保存的 API Key,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(跟随 API Key 设置)`,
followOptionDescription: `直接使用连接「${accountName}」保存的 API Key`,
};
}
export function resolveTokenBindingConnectionMode(account?: {
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
} | null): TokenBindingConnectionMode {
const parsedHints = parseExtraConfigHints(account?.extraConfig);
if (parsedHints.hasOauthProvider) return 'oauth';
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'session') return 'session';
if (rawMode === 'apikey') return 'apikey';
if (parsedHints.credentialMode) return parsedHints.credentialMode;
return typeof account?.accessToken === 'string' && account.accessToken.trim()
? 'session'
: 'apikey';
}
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
return options.find((option) => option.isDefault) || null;
}
@@ -21,21 +103,30 @@ export function describeTokenBinding(
options: TokenBindingOption[],
activeTokenId: number,
fallbackTokenName?: string | null,
context: TokenBindingContext = {},
): TokenBindingPresentation {
const defaultToken = getDefaultTokenOption(options);
const selectedToken = activeTokenId
? options.find((option) => option.id === activeTokenId) || null
: null;
const connectionMode = context.connectionMode || 'session';
const accountName = String(context.accountName || '').trim() || '当前连接';
if (!activeTokenId) {
if (connectionMode === 'apikey' || connectionMode === 'oauth') {
return buildDirectBindingPresentation(connectionMode, accountName);
}
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
return {
isFollowingAccountDefault: true,
bindingModeLabel: '跟随账号默认',
badgeTone: 'info',
effectiveTokenName,
helperText: defaultToken
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
: '跟随账号默认。当前账号还没有默认令牌。',
followOptionLabel: '跟随账号默认',
followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
@@ -46,13 +137,21 @@ export function describeTokenBinding(
return {
isFollowingAccountDefault: false,
bindingModeLabel: '固定令牌',
badgeTone: 'warning',
effectiveTokenName,
helperText: selectedToken?.isDefault
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
followOptionLabel: connectionMode === 'oauth'
? 'OAuth授权'
: (connectionMode === 'apikey' ? 'API 设置' : '跟随账号默认'),
followOptionDescription: connectionMode === 'oauth'
? `直接使用连接「${accountName}」的 OAuth 授权`
: (connectionMode === 'apikey'
? `直接使用连接「${accountName}」保存的 API Key`
: (defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换')),
};
}
+3
View File
@@ -28,6 +28,9 @@ export type RouteChannel = {
cooldownUntil?: string | null;
account?: {
username: string | null;
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
};
site?: {
id: number;