Merge remote-tracking branch 'origin/main' into feat/account-level-proxy

# Conflicts:
#	src/server/routes/proxy/completions.ts
#	src/server/services/siteProxy.ts
This commit is contained in:
Hureru
2026-03-19 00:00:10 +08:00
20 changed files with 1747 additions and 137 deletions
+60 -10
View File
@@ -1413,6 +1413,8 @@ export default function ModelTester() {
setActiveDebugTab(DEBUG_TABS.RESPONSE);
const reader = response.body.getReader();
let doneReceived = false;
let hasAnyContent = false;
let hasAnyReasoning = false;
while (true) {
const { done, value } = await reader.read();
@@ -1447,6 +1449,12 @@ export default function ModelTester() {
}
const delta = parseAnyStreamDelta(eventPayload);
if (typeof delta.reasoningDelta === 'string' && delta.reasoningDelta.trim().length > 0) {
hasAnyReasoning = true;
}
if (typeof delta.contentDelta === 'string' && delta.contentDelta.trim().length > 0) {
hasAnyContent = true;
}
if (delta.reasoningDelta || delta.contentDelta) {
setMessages((prev) => applyAssistantDelta(prev, {
reasoningDelta: delta.reasoningDelta,
@@ -1457,21 +1465,38 @@ export default function ModelTester() {
}
}
const emptyOutput = !hasAnyContent && !hasAnyReasoning;
setMessages((prev) => {
const idx = findLastLoadingAssistantIndex(prev);
if (idx === -1) return prev;
const finalized = finalizeIncompleteMessage(prev[idx]);
if (emptyOutput && !(finalized.content || '').trim() && !(finalized.reasoningContent || '').trim()) {
return replaceMessageAt(prev, idx, {
...finalized,
content: '空回复(上游未返回任何内容)',
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
}
return replaceMessageAt(prev, idx, {
...finalizeIncompleteMessage(prev[idx]),
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true,
});
});
setPendingPayload(null);
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '流式传输已成功完成。'
: '流式传输未收到 [DONE] 信号,已在本地完成。');
if (emptyOutput) {
const message = '上游返回空内容';
setError(message);
pushDebug('error', '流式传输完成但内容为空。');
} else {
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '流式传输已成功完成。'
: '流式传输未收到 [DONE] 信号,已在本地完成。');
}
} catch (streamError: any) {
const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current;
const abortedUnexpectedly = controller.signal.aborted
@@ -1539,6 +1564,8 @@ export default function ModelTester() {
const reader = response.body.getReader();
let doneReceived = false;
let hasAnyContent = false;
let hasAnyReasoning = false;
while (true) {
const { done, value } = await reader.read();
@@ -1571,6 +1598,12 @@ export default function ModelTester() {
}
const delta = parseAnyStreamDelta(eventPayload);
if (typeof delta.reasoningDelta === 'string' && delta.reasoningDelta.trim().length > 0) {
hasAnyReasoning = true;
}
if (typeof delta.contentDelta === 'string' && delta.contentDelta.trim().length > 0) {
hasAnyContent = true;
}
if (delta.reasoningDelta || delta.contentDelta) {
setMessages((prev) => applyAssistantDelta(prev, {
reasoningDelta: delta.reasoningDelta,
@@ -1581,20 +1614,37 @@ export default function ModelTester() {
}
}
const emptyOutput = !hasAnyContent && !hasAnyReasoning;
setMessages((prev) => {
const idx = findLastLoadingAssistantIndex(prev);
if (idx === -1) return prev;
const finalized = finalizeIncompleteMessage(prev[idx]);
if (emptyOutput && !(finalized.content || '').trim() && !(finalized.reasoningContent || '').trim()) {
return replaceMessageAt(prev, idx, {
...finalized,
content: '空回复(上游未返回任何内容)',
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
}
return replaceMessageAt(prev, idx, {
...finalizeIncompleteMessage(prev[idx]),
...finalized,
status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true,
});
});
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '代理流式传输已成功完成。'
: '代理流式传输未收到 [DONE] 信号,已在本地完成。');
if (emptyOutput) {
const message = '上游返回空内容';
setError(message);
pushDebug('error', '代理流式传输完成但内容为空。');
} else {
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '代理流式传输已成功完成。'
: '代理流式传输未收到 [DONE] 信号,已在本地完成。');
}
} catch (streamError: any) {
const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current;
const abortedUnexpectedly = controller.signal.aborted
+79
View File
@@ -34,6 +34,8 @@ type RuntimeSettings = {
routingFallbackUnitCost: number;
routingWeights: RoutingWeights;
systemProxyUrl: string;
proxyErrorKeywords: string[];
proxyEmptyContentFailEnabled: boolean;
proxyTokenMasked?: string;
adminIpAllowlist?: string[];
currentAdminIp?: string;
@@ -193,13 +195,17 @@ export default function Settings() {
routingFallbackUnitCost: 1,
routingWeights: defaultWeights,
systemProxyUrl: '',
proxyErrorKeywords: [],
proxyEmptyContentFailEnabled: false,
});
const [proxyTokenSuffix, setProxyTokenSuffix] = useState('');
const [proxyErrorKeywordsText, setProxyErrorKeywordsText] = useState('');
const [maskedToken, setMaskedToken] = useState('');
const [loading, setLoading] = useState(true);
const [savingSchedule, setSavingSchedule] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
const [savingSecurity, setSavingSecurity] = useState(false);
@@ -406,12 +412,21 @@ export default function Settings() {
...(runtimeInfo.routingWeights || {}),
},
systemProxyUrl: typeof runtimeInfo.systemProxyUrl === 'string' ? runtimeInfo.systemProxyUrl : '',
proxyErrorKeywords: Array.isArray(runtimeInfo.proxyErrorKeywords)
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string')
: [],
proxyEmptyContentFailEnabled: !!runtimeInfo.proxyEmptyContentFailEnabled,
proxyTokenMasked: runtimeInfo.proxyTokenMasked || '',
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
: [],
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
});
setProxyErrorKeywordsText(
Array.isArray(runtimeInfo.proxyErrorKeywords)
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
: '',
);
setAdminIpAllowlistText(
Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.join('\n')
@@ -463,6 +478,11 @@ export default function Settings() {
return compact;
};
const parseProxyErrorKeywords = (raw: string) => raw
.split(/\r?\n|,/g)
.map((item) => item.trim())
.filter((item) => item.length > 0);
const saveSchedule = async () => {
setSavingSchedule(true);
try {
@@ -521,6 +541,33 @@ export default function Settings() {
}
};
const saveProxyFailureRules = async () => {
setSavingProxyFailureRules(true);
try {
const keywords = parseProxyErrorKeywords(proxyErrorKeywordsText);
const res = await api.updateRuntimeSettings({
proxyErrorKeywords: keywords,
proxyEmptyContentFailEnabled: runtime.proxyEmptyContentFailEnabled,
});
const nextKeywords = Array.isArray(res?.proxyErrorKeywords)
? res.proxyErrorKeywords
: keywords;
setRuntime((prev) => ({
...prev,
proxyErrorKeywords: nextKeywords,
proxyEmptyContentFailEnabled: typeof res?.proxyEmptyContentFailEnabled === 'boolean'
? res.proxyEmptyContentFailEnabled
: prev.proxyEmptyContentFailEnabled,
}));
setProxyErrorKeywordsText(nextKeywords.join('\n'));
toast.success('代理失败规则已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingProxyFailureRules(false);
}
};
const resetDownstreamForm = () => {
setEditingDownstreamId(null);
setDownstreamCreate({
@@ -1007,6 +1054,38 @@ export default function Settings() {
</button>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>代理失败判定</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
命中任一关键词或空内容时判定失败,可触发重试。
</div>
<textarea
value={proxyErrorKeywordsText}
onChange={(e) => setProxyErrorKeywordsText(e.target.value)}
placeholder="一行一个关键词,或逗号分隔"
style={{
...inputStyle,
fontFamily: 'var(--font-mono)',
minHeight: 96,
resize: 'vertical',
marginBottom: 12,
}}
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 12 }}>
<input
type="checkbox"
checked={runtime.proxyEmptyContentFailEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, proxyEmptyContentFailEnabled: e.target.checked }))}
/>
空内容(completion=0,即使 prompt 有 token 也算)判定失败
</label>
<div>
<button onClick={saveProxyFailureRules} disabled={savingProxyFailureRules} className="btn btn-primary">
{savingProxyFailureRules ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存失败规则'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>