diff --git a/packages/openclaw-bridge/src/index.ts b/packages/openclaw-bridge/src/index.ts index b39f27b..b60046d 100644 --- a/packages/openclaw-bridge/src/index.ts +++ b/packages/openclaw-bridge/src/index.ts @@ -145,7 +145,8 @@ app.post('/task-async', async (req, res) => { }).then((reply: string) => { postCallback({ ok: true, result: reply, callbackData }); }).catch((err: Error) => { - postCallback({ ok: false, error: err.message, callbackData }); + const isTimeout = err.message.toLowerCase().includes('timeout'); + postCallback({ ok: false, error: err.message, isTimeout, callbackData }); }); }); diff --git a/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts b/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts index 47574d0..df43029 100644 --- a/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts +++ b/packages/services/agent-service/src/infrastructure/dingtalk/dingtalk-router.service.ts @@ -82,6 +82,7 @@ const WS_RECONNECT_BASE_MS = 2_000; const WS_RECONNECT_MAX_MS = 60_000; const TASK_TIMEOUT_S = 120; // seconds — async bridge timeout (LLM may run >1 min) const CALLBACK_TIMEOUT_MS = 180_000; // 3 min — max wait for async bridge callback +const THINKING_REMINDER_MS = 25_000; // show "still thinking" progress if no reply in 25s const DEDUP_TTL_MS = 10 * 60 * 1000; const RATE_LIMIT_PER_MIN = 10; const QUEUE_MAX_DEPTH = 5; @@ -174,7 +175,7 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { * Called by AgentChannelController when the OpenClaw bridge POSTs a callback. * Resolves (or rejects) the Promise that routeToAgent is awaiting. */ - resolveCallbackReply(msgId: string, ok: boolean, content: string): void { + resolveCallbackReply(msgId: string, ok: boolean, content: string, isTimeout?: boolean): void { const cb = this.pendingCallbacks.get(msgId); if (!cb) { this.logger.warn(`Received callback for unknown msgId=${msgId} (already resolved or timed out)`); @@ -185,7 +186,9 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { if (ok) { cb.resolve(content); } else { - cb.reject(new Error(content)); + const err: Error & { isTimeout?: boolean } = new Error(content); + err.isTimeout = isTimeout ?? content.toLowerCase().includes('timeout'); + cb.reject(err); } } @@ -551,7 +554,7 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { // Non-text message types (image, file, richText, audio, video, @mention, etc.) const text = msg.text?.content?.trim() ?? ''; if (!text) { - this.reply(msg, '我目前只能处理文字消息,请发送文字与小龙虾沟通。'); + this.reply(msg, '我目前只能处理文字消息~\n图片、语音请转换成文字后再发给我。'); return; } @@ -582,9 +585,14 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { } // Route to agent container (serial per-user queue) + const pendingDepth = this.queueDepths.get(userId) ?? 0; const accepted = this.enqueue(userId, () => this.routeToAgent(userId, text, msg)); if (!accepted) { - this.reply(msg, '当前请求排队已满(最多5条),请稍后再试。'); + this.reply(msg, '消息太多了,请稍后再说~(当前排队已满,最多5条)'); + } else if (pendingDepth > 0) { + // Message is queued behind existing tasks — give immediate position feedback + // while the sessionWebhook is still valid (it expires ~30s after message) + this.reply(msg, `📋 消息已收到,前面还有 ${pendingDepth} 条在处理,请稍候~`); } } @@ -635,14 +643,22 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { this.logger.warn(`No binding found for senderStaffId=${staffId ?? 'none'} or senderId=${userId}`); this.reply( msg, - '你还没有绑定小龙虾。\n\n请在 IT0 App 中创建一只小龙虾,然后点击「绑定钉钉」获取验证码。', + '👋 你还没有绑定专属小龙虾。\n\n步骤:\n1. 打开 IT0 App\n2. 创建或选择一只小龙虾\n3. 点击「绑定钉钉」获取验证码\n4. 把验证码发给我就好了~', ); return; } if (instance.status !== 'running') { this.logger.warn(`Instance ${instance.id} (${instance.name}) not running: status=${instance.status}`); - this.reply(msg, `小龙虾「${instance.name}」当前状态为 ${instance.status},暂时无法接收指令。`); + const statusHint: Record = { + stopped: `💤 小龙虾「${instance.name}」正在休息,请在 IT0 App 中点击启动后再来找我~`, + starting: `⏳ 小龙虾「${instance.name}」还在启动中,请等待约1分钟后重试。`, + error: `⚠️ 小龙虾「${instance.name}」遇到了问题,请在 IT0 App 中检查状态。`, + }; + this.reply( + msg, + statusHint[instance.status] ?? `小龙虾「${instance.name}」当前无法接收指令(${instance.status}),请在 IT0 App 中处理。`, + ); return; } @@ -660,21 +676,36 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { ); // Async bridge strategy: - // 1. Immediately send "处理中..." via sessionWebhook — instant ack to user + // 1. Immediately send ack via sessionWebhook — instant feedback to user // 2. POST to /task-async → bridge returns immediately, LLM runs in background - // 3. Bridge POSTs result to callbackUrl when done - // 4. resolveCallbackReply() fires → awaited Promise resolves → batchSend - // Serial queue is maintained: routeToAgent awaits the callback Promise, - // so the next queued message only starts after the current one completes. - this.reply(msg, '🤔 小虾米正在思考,稍等...'); + // 3. If no reply in THINKING_REMINDER_MS, send "still working" via batchSend + // 4. Bridge POSTs result to callbackUrl when done + // 5. resolveCallbackReply() fires → awaited Promise resolves → batchSend final reply + // Serial queue: routeToAgent awaits callbackPromise, so next message only starts after. + this.reply(msg, '🤔 小虾米正在思考,稍等~'); - let reply: string; + // Progress reminder: if LLM takes >25s, let user know it's still alive + let thinkingTimer: NodeJS.Timeout | undefined; + if (staffId) { + thinkingTimer = setTimeout(() => { + this.batchSend( + staffId!, + '⏳ 还在努力想呢,这个任务有点复杂,请再等一下~', + `${msg.msgId}:thinking`, + ).catch(() => {}); + }, THINKING_REMINDER_MS); + if (thinkingTimer.unref) thinkingTimer.unref(); + } + + let reply = ''; try { // Register callback before posting (avoids race if bridge responds instantly) const callbackPromise = new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingCallbacks.delete(msg.msgId); - reject(new Error(`Async bridge callback timeout after ${CALLBACK_TIMEOUT_MS / 1000}s`)); + const err: Error & { isTimeout?: boolean } = new Error(`Async bridge callback timeout after ${CALLBACK_TIMEOUT_MS / 1000}s`); + err.isTimeout = true; + reject(err); }, CALLBACK_TIMEOUT_MS); this.pendingCallbacks.set(msg.msgId, { resolve, reject, timer }); }); @@ -694,24 +725,51 @@ export class DingTalkRouterService implements OnModuleInit, OnModuleDestroy { ); if (!ack.ok) { - // Bridge rejected the task + // Bridge rejected the task immediately (not connected, bad request, etc.) this.pendingCallbacks.delete(msg.msgId); - reply = ack.error ?? '智能体拒绝了请求,请稍后重试。'; - this.logger.warn(`Bridge rejected async task for instance ${instance.id}: ${reply}`); + const bridgeError = ack.error ?? ''; + if (bridgeError.includes('not connected') || bridgeError.includes('Gateway not connected')) { + reply = `🔄 小虾米正在重启,请等待约30秒后重试。`; + } else { + reply = `小虾米遇到了问题,请稍后重试。`; + } + this.logger.warn(`Bridge rejected async task for instance ${instance.id}: ${bridgeError}`); } else { - // Wait for the callback (may take 1–3 minutes for complex tasks) + // Wait for the callback (may take up to CALLBACK_TIMEOUT_MS) reply = await callbackPromise; this.logger.log(`Bridge callback received for instance ${instance.id}, reply length=${reply.length}`); } } catch (e: any) { this.pendingCallbacks.delete(msg.msgId); this.logger.error(`Async bridge failed for instance ${instance.id}:`, e.message); - reply = '与小龙虾通信时出现错误,请稍后重试。'; + reply = this.buildErrorReply(e.message, instance.name, !!e.isTimeout); + } finally { + clearTimeout(thinkingTimer); } await this.batchSend(staffId, reply, msg.msgId); } + /** Map an error thrown during async bridge execution to a user-facing message. */ + private buildErrorReply(error: string, instanceName: string, isTimeout: boolean): string { + if (isTimeout) { + return ( + `⏱️ 这个任务花的时间太长了,小虾米超时了。\n\n` + + `建议:\n• 把任务拆成更小的步骤\n• 简化指令后重试\n• 如果问题复杂,可以分多轮来说` + ); + } + if (error.includes('disconnected') || error.includes('not connected')) { + return `🔄 「${instanceName}」与服务的连接中断了,请等待约30秒后重试。`; + } + if (error.includes('aborted')) { + return `⚠️ 任务被中止了,请重新发送。`; + } + if (error.includes('shutting down')) { + return `🔄 服务正在重启,请稍后重试。`; + } + return `😰 小虾米遇到了点问题,请稍后重试。如果持续出现,请联系管理员。`; + } + /** Send a proactive message to a DingTalk user via batchSend. Used for LLM replies * so that users receive the response regardless of sessionWebhook state. */ private batchSend(staffId: string | undefined, content: string, msgId: string): Promise { diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/agent-channel.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/agent-channel.controller.ts index b6fc8e0..940ba28 100644 --- a/packages/services/agent-service/src/interfaces/rest/controllers/agent-channel.controller.ts +++ b/packages/services/agent-service/src/interfaces/rest/controllers/agent-channel.controller.ts @@ -113,16 +113,19 @@ export class AgentChannelController { ok: boolean; result?: string; error?: string; + isTimeout?: boolean; callbackData: { staffId: string; msgId: string }; }, ) { - const { ok, result, error, callbackData } = body; + const { ok, result, error, isTimeout, callbackData } = body; const { staffId, msgId } = callbackData ?? {}; this.logger.log( `Bridge callback: ok=${ok} msgId=${msgId} staffId=${staffId} ` + - `${ok ? `replyLen=${result?.length ?? 0}` : `error=${error}`}`, + `${ok ? `replyLen=${result?.length ?? 0}` : `error=${error} isTimeout=${isTimeout}`}`, + ); + this.dingTalkRouter.resolveCallbackReply( + msgId, ok, ok ? (result ?? '') : (error ?? '智能体没有返回内容。'), isTimeout, ); - this.dingTalkRouter.resolveCallbackReply(msgId, ok, ok ? (result ?? '') : (error ?? '智能体没有返回内容。')); return { received: true }; }