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:
parent
f5f051bcab
commit
fa2212c7bb
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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<void> {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue