feat(dingtalk): UX pass — progress hints, queue position, error distinction

- Bridge: tag isTimeout=true in timeout callbacks for semantic error routing
- Agent-service: show " 还在努力想呢" progress batchSend after 25s silence
- Agent-service: queue position feedback ("前面还有 N 条") via sessionWebhook
- Agent-service: buildErrorReply() maps timeout/disconnect/abort to distinct msgs
- Agent-service: instance status hints (stopped/starting/error) with action guidance
- Agent-service: all user-facing strings rewritten for conversational, friendly tone
- Agent-channel: pass isTimeout from bridge callback through to resolveCallbackReply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-09 01:03:07 -07:00
parent f5f051bcab
commit fa2212c7bb
3 changed files with 85 additions and 23 deletions

View File

@ -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 });
});
});

View File

@ -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<string, string> = {
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<string>((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 13 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<void> {

View File

@ -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 };
}