mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 16:40:46 +08:00
fix: surface actionable channel message errors
This commit is contained in:
parent
b42fc411c2
commit
7cbed3aefb
59 changed files with 2405 additions and 607 deletions
|
|
@ -532,6 +532,41 @@ export class HarnessInteractionError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class HarnessTurnError extends Error {
|
||||
constructor(code, { reason, providerCode } = {}) {
|
||||
super(`Harness turn failed (${code})`);
|
||||
this.name = 'HarnessTurnError';
|
||||
this.code = code;
|
||||
this.promptAccepted = true;
|
||||
if (reason && typeof reason === 'object') this.reason = reason;
|
||||
if (typeof providerCode === 'string' && providerCode) this.providerCode = providerCode;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessTurnError(reason) {
|
||||
const kind = nonEmptyText(reason?.kind) ?? nonEmptyText(reason);
|
||||
if (kind === 'error') {
|
||||
const failure = reason?.error ?? reason?.failure;
|
||||
return new HarnessTurnError('harness-turn-failed', {
|
||||
reason,
|
||||
providerCode: nonEmptyText(failure?.code) ?? undefined,
|
||||
});
|
||||
}
|
||||
if (kind === 'max-tokens') return new HarnessTurnError('model-max-tokens', { reason });
|
||||
if (kind === 'blocked') return new HarnessTurnError('turn-blocked', { reason });
|
||||
if (['interrupted', 'stopped', 'cancelled', 'canceled'].includes(kind)) {
|
||||
return new HarnessTurnError('turn-interrupted', { reason });
|
||||
}
|
||||
if (kind === 'aborted') return new HarnessTurnError('turn-aborted', { reason });
|
||||
if (kind === 'completed') return new HarnessTurnError('model-empty-response', { reason });
|
||||
return new HarnessTurnError('harness-turn-failed', { reason });
|
||||
}
|
||||
|
||||
function harnessTurnSucceeded(reason) {
|
||||
if (reason === null || reason === undefined) return true;
|
||||
return (nonEmptyText(reason?.kind) ?? nonEmptyText(reason)) === 'completed';
|
||||
}
|
||||
|
||||
export class HarnessClient {
|
||||
#baseUrl;
|
||||
#workspace;
|
||||
|
|
@ -1282,6 +1317,9 @@ export class HarnessClient {
|
|||
}
|
||||
if (!tracker.finished) continue;
|
||||
turnFinished = true;
|
||||
if (!ownership?.stopRequested && !harnessTurnSucceeded(tracker.reason)) {
|
||||
throw harnessTurnError(tracker.reason);
|
||||
}
|
||||
// An accepted /stop revokes attachment delivery even when Harness
|
||||
// preserved a useful partial text answer for the existing UX.
|
||||
const artifactCount = ownership?.stopRequested
|
||||
|
|
@ -1292,11 +1330,9 @@ export class HarnessClient {
|
|||
}
|
||||
if (artifactCount > 0) return '';
|
||||
if (ownership?.stopRequested) throw turnStoppedError();
|
||||
throw new Error(
|
||||
`Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`,
|
||||
);
|
||||
throw harnessTurnError(tracker.reason);
|
||||
}
|
||||
throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`);
|
||||
throw new HarnessTurnError('harness-reply-timeout');
|
||||
} catch (error) {
|
||||
// Once cancellation was accepted, transport/poll failures and timeouts
|
||||
// describe the convergence of that stop, not an unrelated ask failure.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,83 @@ export default {
|
|||
'结果文件「{name}」已生成,但当前渠道暂时未能发送,请稍后重试。':
|
||||
'The result file "{name}" was generated, but this channel could not send it right now. Please try again later.',
|
||||
'消息处理失败,请稍后重试。': 'Failed to process the message. Please try again later.',
|
||||
'卡片已结束,请查看后续消息。': 'This card has ended. Please check the next message.',
|
||||
'工具调用「{name}」未成功,请检查工具配置或稍后重试。': 'Tool call "{name}" did not succeed. Check the tool configuration or try again later.',
|
||||
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。':
|
||||
'Could not connect to the processing service, so the message was not submitted. Make sure DeepSeek Harness is running, then try again.',
|
||||
'处理服务响应超时,消息尚未开始处理。请稍后重试。':
|
||||
'The processing service timed out before the message started. Please try again later.',
|
||||
'暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。':
|
||||
'The task status could not be confirmed and the task may have started. Wait or send /stop before submitting it again.',
|
||||
'消息提交结果未能确认,任务可能已经开始。请先等待或发送 /status 查看状态,不要立即重复提交。':
|
||||
'The message submission could not be confirmed and the task may have started. Wait or send /status before submitting it again.',
|
||||
'暂时无法读取任务进度,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
|
||||
'Task progress is temporarily unavailable and the task may still be running. Wait or send /stop before submitting it again.',
|
||||
'处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。':
|
||||
'The processing service rejected the bot connection. Ask an administrator to check the Harness address, proxy, or access settings.',
|
||||
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。':
|
||||
'The bot and DeepSeek Harness use incompatible APIs. Ask an administrator to check the Harness address and update the related components.',
|
||||
'DeepSeek Harness 暂时无法完成请求,请稍后重试。':
|
||||
'DeepSeek Harness could not complete the request. Please try again later.',
|
||||
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。':
|
||||
'Waiting for the model reply timed out and the task may still be running. Wait or send /stop before submitting it again.',
|
||||
'模型凭据缺失或已失效。请管理员检查模型配置后重试。':
|
||||
'The model credentials are missing or invalid. Ask an administrator to check the model settings.',
|
||||
'模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。':
|
||||
'The model quota or balance is insufficient. Ask an administrator to add quota or switch models, then try again.',
|
||||
'模型服务正在限流,本次任务未完成。请稍后重试。':
|
||||
'The model service is rate-limiting requests. Please try again later.',
|
||||
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。':
|
||||
'This conversation exceeds the model context limit. Send /compact or /new, then try again.',
|
||||
'当前模型不存在或暂不可用。请发送 /models 查看并使用 /model 切换模型。':
|
||||
'The current model does not exist or is unavailable. Send /models and use /model to switch models.',
|
||||
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。':
|
||||
'The current model does not exist or does not support the selected settings. Send /models and use /model to choose again.',
|
||||
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。':
|
||||
'The current model does not support this content or the selected settings. Adjust the content, model, or reasoning effort and try again.',
|
||||
'当前模型不支持所选配置。请切换模型或推理等级后重试。':
|
||||
'The current model does not support the selected settings. Switch the model or reasoning effort, then try again.',
|
||||
'模型服务响应超时,本次任务未完成。请稍后重试。':
|
||||
'The model service timed out and the task did not finish. Please try again later.',
|
||||
'暂时无法连接模型服务,本次任务未完成。请稍后重试。':
|
||||
'The model service is temporarily unreachable and the task did not finish. Please try again later.',
|
||||
'模型服务暂时异常,本次任务未完成。请稍后重试。':
|
||||
'The model service is temporarily unavailable and the task did not finish. Please try again later.',
|
||||
'模型回复中断或格式异常,本次任务未完成。请重试。':
|
||||
'The model reply was interrupted or malformed, so the task did not finish. Please try again.',
|
||||
'模型没有返回可显示的内容。请重试;若持续发生,请切换模型。':
|
||||
'The model returned no displayable content. Try again, or switch models if this keeps happening.',
|
||||
'模型拒绝处理当前内容。请调整问题内容后重试。':
|
||||
'The model rejected the current content. Revise the request and try again.',
|
||||
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。':
|
||||
'The model reached its output limit without producing a displayable result. Reduce the task scope and try again.',
|
||||
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。':
|
||||
'The task is waiting for an action that cannot be completed in this channel. Handle it in DeepSeek Harness, then try again.',
|
||||
'任务被意外中断,本次未完成。请重试。':
|
||||
'The task was unexpectedly interrupted and did not finish. Please try again.',
|
||||
'当前会话已不存在。请发送 /new 创建新会话后重试。':
|
||||
'The current Session no longer exists. Send /new to create one, then try again.',
|
||||
'当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。':
|
||||
'The current Session is still processing the previous task. Wait for it to finish, or send /stop before trying again.',
|
||||
'工作区或会话状态刚刚发生变化。请重新发送这条消息。':
|
||||
'The Workspace or Session state just changed. Please send this message again.',
|
||||
'当前工作区不存在或暂不可用。请重新选择工作区后重试。':
|
||||
'The current Workspace does not exist or is unavailable. Select another Workspace and try again.',
|
||||
'当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。':
|
||||
'The current Agent Preset does not exist or is unavailable. Send /presetlist and select another one.',
|
||||
'回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。':
|
||||
'The reply was generated, but the bot cannot send it. Ask an administrator to check channel permissions or reconnect the bot.',
|
||||
'回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。':
|
||||
'The reply was generated, but this channel is rate-limiting messages. Please try again later.',
|
||||
'回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。':
|
||||
'Reply delivery could not be confirmed. Check whether it already arrived before submitting the task again.',
|
||||
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。':
|
||||
'The reply was generated, but this channel could not send it. Please try again later.',
|
||||
'当前消息包含无法处理的图片或文件。请调整后重新发送。':
|
||||
'This message contains an image or file that cannot be processed. Adjust it and send it again.',
|
||||
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。':
|
||||
'The task did not finish and the cause could not be determined. Try again; if it persists, give the reference ID to an administrator.',
|
||||
'错误码:{code};参考号:{referenceId}': 'Error code: {code}; reference: {referenceId}',
|
||||
'{label}机器人': '{label} bot',
|
||||
'目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
|
||||
'目前支持文字、图片和文件消息。':
|
||||
|
|
|
|||
244
src/channels/shared/message-failure.mjs
Normal file
244
src/channels/shared/message-failure.mjs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { t } from './i18n.mjs';
|
||||
|
||||
const PROVIDER_FAILURES = Object.freeze({
|
||||
AUTH: 'MODEL_AUTH',
|
||||
MISSING_CREDENTIAL: 'MODEL_AUTH',
|
||||
INVALID_CREDENTIAL: 'MODEL_AUTH',
|
||||
QUOTA: 'MODEL_QUOTA',
|
||||
RATE_LIMIT: 'MODEL_RATE_LIMIT',
|
||||
CONTEXT_WINDOW_EXCEEDED: 'MODEL_CONTEXT_LIMIT',
|
||||
UNKNOWN_MODEL: 'MODEL_UNAVAILABLE',
|
||||
NO_ADAPTER: 'MODEL_UNAVAILABLE',
|
||||
UNSUPPORTED_OPTION: 'MODEL_CONFIG',
|
||||
UNSUPPORTED_REASONING_EFFORT: 'MODEL_CONFIG',
|
||||
TIMEOUT: 'MODEL_TIMEOUT',
|
||||
TRANSPORT: 'MODEL_TRANSPORT',
|
||||
SERVER: 'MODEL_SERVICE',
|
||||
STREAM_CLOSED: 'MODEL_STREAM',
|
||||
MALFORMED_RESPONSE: 'MODEL_STREAM',
|
||||
EMPTY_RESPONSE: 'MODEL_EMPTY_REPLY',
|
||||
CONTENT_FILTER: 'MODEL_CONTENT_REJECTED',
|
||||
UNSUPPORTED_CONTENT: 'MODEL_CONFIG',
|
||||
});
|
||||
|
||||
const FAILURE_MESSAGES = Object.freeze({
|
||||
HARNESS_CONNECT:
|
||||
'无法连接处理服务,消息尚未提交。请确认 DeepSeek Harness 正在运行后重试。',
|
||||
HARNESS_TIMEOUT:
|
||||
'处理服务响应超时,消息尚未开始处理。请稍后重试。',
|
||||
HARNESS_RESULT_UNCERTAIN:
|
||||
'暂时无法确认任务状态,任务可能已经开始。请先等待或发送 /stop,不要立即重复提交。',
|
||||
HARNESS_ACCESS:
|
||||
'处理服务拒绝了机器人连接。请管理员检查 Harness 地址、代理或访问配置后重试。',
|
||||
HARNESS_PROTOCOL:
|
||||
'机器人与 DeepSeek Harness 的接口不兼容。请管理员检查 Harness 地址并更新相关版本。',
|
||||
HARNESS_SERVICE:
|
||||
'DeepSeek Harness 暂时无法完成请求,请稍后重试。',
|
||||
MODEL_REPLY_TIMEOUT:
|
||||
'等待模型回复超时,任务可能仍在运行。请先等待或发送 /stop,不要立即重复提交。',
|
||||
MODEL_AUTH:
|
||||
'模型凭据缺失或已失效。请管理员检查模型配置后重试。',
|
||||
MODEL_QUOTA:
|
||||
'模型额度或余额不足,本次任务未完成。请管理员补充额度或切换模型后重试。',
|
||||
MODEL_RATE_LIMIT:
|
||||
'模型服务正在限流,本次任务未完成。请稍后重试。',
|
||||
MODEL_CONTEXT_LIMIT:
|
||||
'当前会话内容超过模型上下文上限。请发送 /compact 或 /new 后重试。',
|
||||
MODEL_UNAVAILABLE:
|
||||
'当前模型不存在或不支持所选配置。请发送 /models,并使用 /model 重新选择。',
|
||||
MODEL_CONFIG:
|
||||
'当前模型不支持这类内容或所选配置。请调整内容、模型或推理等级后重试。',
|
||||
MODEL_TIMEOUT:
|
||||
'模型服务响应超时,本次任务未完成。请稍后重试。',
|
||||
MODEL_TRANSPORT:
|
||||
'暂时无法连接模型服务,本次任务未完成。请稍后重试。',
|
||||
MODEL_SERVICE:
|
||||
'模型服务暂时异常,本次任务未完成。请稍后重试。',
|
||||
MODEL_STREAM:
|
||||
'模型回复中断或格式异常,本次任务未完成。请重试。',
|
||||
MODEL_EMPTY_REPLY:
|
||||
'模型没有返回可显示的内容。请重试;若持续发生,请切换模型。',
|
||||
MODEL_CONTENT_REJECTED:
|
||||
'模型拒绝处理当前内容。请调整问题内容后重试。',
|
||||
MODEL_OUTPUT_LIMIT:
|
||||
'模型达到输出长度上限,但没有生成可显示的结果。请缩小任务范围后重试。',
|
||||
TURN_BLOCKED:
|
||||
'任务正在等待无法在当前渠道完成的操作。请在 DeepSeek Harness 中处理后再试。',
|
||||
TURN_INTERRUPTED:
|
||||
'任务被意外中断,本次未完成。请重试。',
|
||||
SESSION_NOT_FOUND:
|
||||
'当前会话已不存在。请发送 /new 创建新会话后重试。',
|
||||
SESSION_BUSY:
|
||||
'当前会话仍在处理上一项任务。请等待完成,或发送 /stop 后重试。',
|
||||
SESSION_STALE:
|
||||
'工作区或会话状态刚刚发生变化。请重新发送这条消息。',
|
||||
WORKSPACE_UNAVAILABLE:
|
||||
'当前工作区不存在或暂不可用。请重新选择工作区后重试。',
|
||||
PRESET_UNAVAILABLE:
|
||||
'当前 Agent Preset 不存在或暂不可用。请发送 /presetlist 后重新选择。',
|
||||
CHANNEL_PERMISSION:
|
||||
'回复已经生成,但机器人没有发送权限。请联系管理员检查渠道权限或重新绑定机器人。',
|
||||
CHANNEL_RATE_LIMIT:
|
||||
'回复已经生成,但当前渠道正在限流,暂时无法发送。请稍后重试。',
|
||||
CHANNEL_DELIVERY_UNCERTAIN:
|
||||
'回复发送结果未能确认。请先检查聊天内是否已经收到,不要立即重复提交。',
|
||||
CHANNEL_DELIVERY:
|
||||
'回复已经生成,但当前渠道暂时无法发送。请稍后重试。',
|
||||
INPUT_INVALID:
|
||||
'当前消息包含无法处理的图片或文件。请调整后重新发送。',
|
||||
INTERNAL_UNKNOWN:
|
||||
'任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
|
||||
});
|
||||
|
||||
function providerFailureCode(error) {
|
||||
if (error?.code !== 'harness-turn-failed') return null;
|
||||
const value = error?.providerCode ?? error?.details?.providerCode;
|
||||
if (typeof value !== 'string') return null;
|
||||
return PROVIDER_FAILURES[value.trim().toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
function failureCode(error) {
|
||||
const code = typeof error?.code === 'string' ? error.code : '';
|
||||
const providerCode = providerFailureCode(error);
|
||||
if (providerCode) return providerCode;
|
||||
|
||||
if (code === 'harness-connect-failed') {
|
||||
return ['session.prompt', 'session.history'].includes(error?.method)
|
||||
? 'HARNESS_RESULT_UNCERTAIN'
|
||||
: 'HARNESS_CONNECT';
|
||||
}
|
||||
if (code === 'harness-timeout') {
|
||||
return ['session.prompt', 'session.history'].includes(error?.method)
|
||||
? 'HARNESS_RESULT_UNCERTAIN'
|
||||
: 'HARNESS_TIMEOUT';
|
||||
}
|
||||
if (code === 'harness-reply-timeout') return 'MODEL_REPLY_TIMEOUT';
|
||||
if ([
|
||||
'harness-auth-required', 'harness-proxy-auth-required',
|
||||
'harness-loopback-forbidden', 'harness-host-untrusted',
|
||||
'harness-request-forbidden',
|
||||
].includes(code)) return 'HARNESS_ACCESS';
|
||||
if (['harness-api-not-found', 'harness-response-invalid'].includes(code)) {
|
||||
return 'HARNESS_PROTOCOL';
|
||||
}
|
||||
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
|
||||
if (['harness-http-failed', 'harness-rpc-rejected'].includes(code)) return 'HARNESS_SERVICE';
|
||||
if (code === 'model-empty-response') return 'MODEL_EMPTY_REPLY';
|
||||
if (code === 'model-max-tokens') return 'MODEL_OUTPUT_LIMIT';
|
||||
if (code === 'turn-blocked') return 'TURN_BLOCKED';
|
||||
if (['turn-interrupted', 'turn-aborted'].includes(code)) return 'TURN_INTERRUPTED';
|
||||
if (code === 'session-not-found') return 'SESSION_NOT_FOUND';
|
||||
if (code === 'agent-busy') return 'SESSION_BUSY';
|
||||
if (code === 'workspace-session-stale') return 'SESSION_STALE';
|
||||
if (code.startsWith('workspace-')) return 'WORKSPACE_UNAVAILABLE';
|
||||
if (code.startsWith('agent-preset-')) return 'PRESET_UNAVAILABLE';
|
||||
if (code.startsWith('image-') || code.startsWith('inbound-file-')
|
||||
|| code === 'attachment-error') return 'INPUT_INVALID';
|
||||
|
||||
const status = Number(error?.status ?? error?.httpStatus);
|
||||
if (status === 401 || status === 403
|
||||
|| [
|
||||
'channel-permission',
|
||||
'permission-required',
|
||||
'artifact-permission-required',
|
||||
'forbidden',
|
||||
'stale-token',
|
||||
].includes(code)) {
|
||||
return 'CHANNEL_PERMISSION';
|
||||
}
|
||||
if (status === 429
|
||||
|| ['channel-rate-limit', 'rate-limited', 'artifact-rate-limited'].includes(code)) {
|
||||
return 'CHANNEL_RATE_LIMIT';
|
||||
}
|
||||
if (['channel-delivery-uncertain', 'delivery-uncertain', 'artifact-delivery-uncertain']
|
||||
.includes(code)) {
|
||||
return 'CHANNEL_DELIVERY_UNCERTAIN';
|
||||
}
|
||||
if (code === 'channel-delivery-failed' || code.startsWith('artifact-')
|
||||
|| ['network-error', 'timeout'].includes(code)) {
|
||||
return 'CHANNEL_DELIVERY';
|
||||
}
|
||||
return 'INTERNAL_UNKNOWN';
|
||||
}
|
||||
|
||||
function safeReferenceId(value) {
|
||||
return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
|
||||
? value
|
||||
: `MF-${randomUUID().slice(0, 8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function safeFailureReason(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/u.test(value)) return null;
|
||||
return value.toUpperCase().replaceAll('-', '_');
|
||||
}
|
||||
|
||||
export function classifyMessageFailure(error, {
|
||||
userMessage,
|
||||
reason,
|
||||
referenceId,
|
||||
at = Date.now(),
|
||||
} = {}) {
|
||||
const safeReason = safeFailureReason(reason);
|
||||
const classifiedCode = failureCode(error);
|
||||
const code = classifiedCode === 'INTERNAL_UNKNOWN'
|
||||
&& safeReason
|
||||
&& typeof userMessage === 'string'
|
||||
&& userMessage.trim()
|
||||
? 'INPUT_INVALID'
|
||||
: classifiedCode;
|
||||
return Object.freeze({
|
||||
code,
|
||||
reason: safeReason ?? code,
|
||||
message: typeof userMessage === 'string' && userMessage.trim()
|
||||
? userMessage.trim()
|
||||
: t(FAILURE_MESSAGES[code]),
|
||||
referenceId: safeReferenceId(referenceId),
|
||||
at: Number.isFinite(at) ? at : Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function messageFailureText(failure) {
|
||||
return `${failure.message}\n\n${t('错误码:{code};参考号:{referenceId}', failure)}`;
|
||||
}
|
||||
|
||||
export function setLastMessageFailure(status, error, options) {
|
||||
const failure = classifyMessageFailure(error, options);
|
||||
status.lastMessageError = failure;
|
||||
return failure;
|
||||
}
|
||||
|
||||
export function clearLastMessageFailure(status) {
|
||||
status.lastMessageError = null;
|
||||
}
|
||||
|
||||
export function channelDeliveryFailure(error, { uncertain = true } = {}) {
|
||||
const wrapped = new Error('Channel message delivery failed', { cause: error });
|
||||
const status = Number(error?.status ?? error?.httpStatus);
|
||||
wrapped.code = status === 401 || status === 403
|
||||
? 'channel-permission'
|
||||
: status === 429
|
||||
? 'channel-rate-limit'
|
||||
: uncertain
|
||||
? 'channel-delivery-uncertain'
|
||||
: 'channel-delivery-failed';
|
||||
if (Number.isInteger(status)) wrapped.status = status;
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
export function publicMessageFailure(value) {
|
||||
if (!value || typeof value !== 'object'
|
||||
|| typeof value.code !== 'string' || !value.code
|
||||
|| typeof value.reason !== 'string' || !value.reason
|
||||
|| typeof value.message !== 'string' || !value.message
|
||||
|| typeof value.referenceId !== 'string' || !value.referenceId
|
||||
|| !Number.isFinite(value.at)) return null;
|
||||
return {
|
||||
code: value.code.slice(0, 64),
|
||||
reason: value.reason.slice(0, 64),
|
||||
message: value.message.slice(0, 500),
|
||||
referenceId: value.referenceId.slice(0, 40),
|
||||
at: value.at,
|
||||
};
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ export async function deliverOutboundArtifacts({
|
|||
sendFile,
|
||||
sendImage,
|
||||
sendFailureNotice,
|
||||
onFailure,
|
||||
logger,
|
||||
}) {
|
||||
const receipts = baseReceipt ? [baseReceipt] : [];
|
||||
|
|
@ -107,14 +108,20 @@ export async function deliverOutboundArtifacts({
|
|||
} catch (error) {
|
||||
if (isAbort(error, signal)) throw error;
|
||||
artifactSendErrors += 1;
|
||||
const failure = typeof onFailure === 'function'
|
||||
? await onFailure(artifact, error)
|
||||
: null;
|
||||
const reference = typeof failure?.referenceId === 'string'
|
||||
? ` [${failure.referenceId}]`
|
||||
: '';
|
||||
logger?.warn?.(
|
||||
`[dsh-im:${channelKey}] result artifact delivery failed (${error?.code ?? 'unknown'})`,
|
||||
`[dsh-im:${channelKey}] result artifact delivery failed${reference} (${error?.code ?? 'unknown'})`,
|
||||
);
|
||||
let messageIds = [];
|
||||
if (typeof sendFailureNotice === 'function') {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const notice = await sendFailureNotice(artifact, error);
|
||||
const notice = await sendFailureNotice(artifact, error, failure);
|
||||
signal?.throwIfAborted();
|
||||
messageIds = providerIds(notice);
|
||||
failureNoticeVisible = true;
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from './batch-input.mjs';
|
||||
import {
|
||||
hasInboundImages,
|
||||
imagePromptDiagnostic,
|
||||
imagePromptUserMessage,
|
||||
promptContentForMessage,
|
||||
} from './image-prompt.mjs';
|
||||
|
|
@ -45,6 +46,12 @@ import {
|
|||
createTextDeliveryBlock,
|
||||
providerMessageIdsFor,
|
||||
} from './semantic/delivery.mjs';
|
||||
import {
|
||||
channelDeliveryFailure,
|
||||
clearLastMessageFailure,
|
||||
messageFailureText,
|
||||
setLastMessageFailure,
|
||||
} from './message-failure.mjs';
|
||||
|
||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
|
||||
|
|
@ -106,6 +113,7 @@ export function createTextBridgeStatus() {
|
|||
lastReplyAt: null,
|
||||
lastRejectedAt: null,
|
||||
lastError: null,
|
||||
lastMessageError: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -320,8 +328,9 @@ export class TextHarnessBridge {
|
|||
})().catch(async (error) => {
|
||||
if (this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
}).finally(() => {
|
||||
|
|
@ -399,8 +408,12 @@ export class TextHarnessBridge {
|
|||
} catch (error) {
|
||||
if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error);
|
||||
await this.#bot.sendText(target, t('消息处理失败,请稍后重试。')).catch(() => undefined);
|
||||
const failure = setLastMessageFailure(this.#status, error);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -426,9 +439,13 @@ export class TextHarnessBridge {
|
|||
sendFile: typeof this.#bot.sendFile === 'function'
|
||||
? (file) => this.#bot.sendFile(target, file)
|
||||
: undefined,
|
||||
sendFailureNotice: (artifact, error) => this.#bot.sendText(
|
||||
onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
|
||||
userMessage: artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
reason: error?.code,
|
||||
}),
|
||||
sendFailureNotice: (_artifact, _error, failure) => this.#bot.sendText(
|
||||
target,
|
||||
artifactFailureText(artifact?.fileName, error, this.#descriptor),
|
||||
messageFailureText(failure),
|
||||
),
|
||||
logger: this.#logger,
|
||||
});
|
||||
|
|
@ -436,7 +453,11 @@ export class TextHarnessBridge {
|
|||
+ delivery.artifactsSent;
|
||||
this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
|
||||
+ delivery.artifactSendErrors;
|
||||
return { receipt: delivery.receipt, userVisible: delivery.userVisible };
|
||||
return {
|
||||
receipt: delivery.receipt,
|
||||
userVisible: delivery.userVisible,
|
||||
artifactSendErrors: delivery.artifactSendErrors,
|
||||
};
|
||||
}
|
||||
|
||||
async #process(message, messageId, senderId, conversationKey, {
|
||||
|
|
@ -643,25 +664,37 @@ export class TextHarnessBridge {
|
|||
reason: result?.reason,
|
||||
});
|
||||
} catch (error) {
|
||||
textDeliveryError = error;
|
||||
textDeliveryError = channelDeliveryFailure(error);
|
||||
}
|
||||
}
|
||||
const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
|
||||
if (textReceipt?.deliveryOutcome === 'failed') {
|
||||
const reason = textReceipt.reason ?? 'text-delivery-failed';
|
||||
textDeliveryError = new Error(`Final text delivery failed (${reason})`);
|
||||
textDeliveryError.code = reason;
|
||||
textDeliveryError = channelDeliveryFailure(
|
||||
new Error(`Final text delivery failed (${textReceipt.reason ?? 'unknown'})`),
|
||||
{ uncertain: false },
|
||||
);
|
||||
} else if (finalDeliveryUnknown) {
|
||||
textDeliveryError = channelDeliveryFailure(
|
||||
new Error(`Final text delivery outcome is unknown (${textReceipt.reason ?? 'unknown'})`),
|
||||
);
|
||||
}
|
||||
// A failed final text must not discard an already registered result file.
|
||||
// Settle the independent attachment path before surfacing the text error.
|
||||
const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
|
||||
if (textDeliveryError && !delivery.userVisible) {
|
||||
if (textDeliveryError && (!delivery.userVisible || finalDeliveryUnknown)) {
|
||||
textDeliveryError.deliveryReceipt = delivery.receipt;
|
||||
throw textDeliveryError;
|
||||
}
|
||||
if (textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||
setLastMessageFailure(this.#status, textDeliveryError);
|
||||
}
|
||||
if (delivery.userVisible) {
|
||||
this.#status.messagesReplied += 1;
|
||||
this.#status.lastReplyAt = new Date().toISOString();
|
||||
this.#status.lastError = null;
|
||||
if (!textDeliveryError && delivery.artifactSendErrors === 0) {
|
||||
clearLastMessageFailure(this.#status);
|
||||
}
|
||||
}
|
||||
return delivery.receipt;
|
||||
} catch (error) {
|
||||
|
|
@ -688,10 +721,15 @@ export class TextHarnessBridge {
|
|||
}
|
||||
this.#status.lastError = error?.message ?? String(error);
|
||||
const presentStreamFailure = async (text) => {
|
||||
if (typeof stream?.fail !== 'function') return false;
|
||||
const method = typeof stream?.fail === 'function'
|
||||
? 'fail'
|
||||
: (typeof stream?.finish === 'function' ? 'finish' : null);
|
||||
if (!method) return false;
|
||||
try {
|
||||
const result = await stream.fail(text);
|
||||
return Boolean(result) && result.deliveryOutcome !== 'failed';
|
||||
const result = await stream[method](text);
|
||||
return method === 'fail'
|
||||
? Boolean(result) && result.deliveryOutcome !== 'failed'
|
||||
: result !== false && result?.deliveryOutcome !== 'failed';
|
||||
} catch (streamError) {
|
||||
this.#logger.warn?.(
|
||||
`[dsh-im:${this.#descriptor.key}] unable to finalize the failed stream:`,
|
||||
|
|
@ -700,58 +738,25 @@ export class TextHarnessBridge {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
if (failedBatch?.retained) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
|
||||
error,
|
||||
);
|
||||
if (await presentStreamFailure(failedBatch.message)) return;
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, failedBatch.message);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const imageErrorMessage = imagePromptUserMessage(error);
|
||||
if (imageErrorMessage) {
|
||||
if (await presentStreamFailure(imageErrorMessage)) return;
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, imageErrorMessage);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const fileErrorMessage = inboundFileUserMessage(error);
|
||||
if (fileErrorMessage) {
|
||||
if (await presentStreamFailure(fileErrorMessage)) return;
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, fileErrorMessage);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
|
||||
sendError,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
|
||||
if (await presentStreamFailure('消息处理失败,请稍后重试。')) {
|
||||
const failure = setLastMessageFailure(this.#status, error, {
|
||||
userMessage: fileErrorMessage ?? imageErrorMessage,
|
||||
reason: imagePromptDiagnostic(error)?.reason,
|
||||
});
|
||||
const failureText = failedBatch?.retained
|
||||
? `${messageFailureText(failure)}\n\n${failedBatch.message}`
|
||||
: messageFailureText(failure);
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
|
||||
error,
|
||||
);
|
||||
if (await presentStreamFailure(failureText)) {
|
||||
return error.deliveryReceipt;
|
||||
}
|
||||
stream?.cancel?.();
|
||||
try {
|
||||
await this.#bot.sendText(target, t('消息处理失败,请稍后重试。'));
|
||||
await this.#bot.sendText(target, failureText);
|
||||
} catch (sendError) {
|
||||
this.#logger.error?.(
|
||||
`[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { connectionTestMessage } from './connection-test.mjs';
|
||||
import { t } from './i18n.mjs';
|
||||
import { publicMessageFailure } from './message-failure.mjs';
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
|
@ -291,6 +292,7 @@ export class TokenBotController {
|
|||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||||
},
|
||||
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
||||
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue