Merge origin/main into codex/modeltester-attachment-review-fixes
This commit is contained in:
@@ -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(可选,毫秒时间戳)"
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
}];
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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};以后账号默认变化时会自动切换`
|
||||
: '以后账号默认变化时会自动切换')),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user