增加了空回为失败和自定义失败关键词的功能,修复了机器人提出的问题 (#153)
* Add proxy failure rules for keywords and empty responses * fix(proxy): detect failure keywords for streamed chat responses * Fix(tester): treat empty stream output as error * feat(server): default listen host to IPv6 any-address * fix(proxy): treat empty upstream content as failure * Revert "feat(server): default listen host to IPv6 any-address" This reverts commit 2ca49a3891cc3a2e37138156b17b660b6a3cb7ea. * fix(settings): validate proxy failure settings * fix: address CodeRabbit feedback in proxy failure detection * test: force NODE_ENV=test for vitest to avoid React act() prod build * fix(proxy/chat): preserve true SSE streaming (no upstream.text buffering) * fix: avoid http fallback after hijacked SSE stream * fix: wire proxy failure detection into chat and responses --------- Co-authored-by: metapi-bot <metapi-bot@local> Co-authored-by: openclaw <bot@local> Co-authored-by: openclaw <openclaw@local> Co-authored-by: openclaw-executor <executor@openclaw.local> Co-authored-by: xuyufengfei <xuyufengfei@users.noreply.github.com> Co-authored-by: cita <juricek.chen@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user