openclaw-lark-multi-agent 1.0.23 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/feishu-bot.d.ts +6 -0
- package/dist/feishu-bot.js +109 -18
- package/dist/live-status.d.ts +66 -25
- package/dist/live-status.js +179 -77
- package/dist/openclaw-client.d.ts +9 -0
- package/dist/openclaw-client.js +33 -1
- package/package.json +1 -1
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -147,6 +147,12 @@ export declare class FeishuBot {
|
|
|
147
147
|
private getChairmanParticipant;
|
|
148
148
|
private asDiscussionParticipant;
|
|
149
149
|
private runDiscussionTurn;
|
|
150
|
+
private buildLiveStatusCard;
|
|
151
|
+
/** Minimal escaping so activity text does not break card markdown. */
|
|
152
|
+
private escapeCardText;
|
|
153
|
+
private sendLiveStatusCard;
|
|
154
|
+
private replyLiveStatusCard;
|
|
155
|
+
private patchLiveStatusCard;
|
|
150
156
|
private sendTextMessage;
|
|
151
157
|
private replyTextMessage;
|
|
152
158
|
private editTextMessage;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -1179,15 +1179,13 @@ export class FeishuBot {
|
|
|
1179
1179
|
};
|
|
1180
1180
|
liveStatus = this.shouldUseLiveStatus(chatId, isNativeCommandTrigger)
|
|
1181
1181
|
? new LiveStatusController({
|
|
1182
|
-
create: (
|
|
1183
|
-
edit: (messageId,
|
|
1182
|
+
create: (view) => this.sendOrdered(chatId, () => this.replyLiveStatusCard(lastHuman.messageId || "", view, chatId)),
|
|
1183
|
+
edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
|
|
1184
1184
|
warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, this.errorSummary(err)),
|
|
1185
1185
|
}, {
|
|
1186
1186
|
botName: this.config.name,
|
|
1187
|
+
model: this.config.model,
|
|
1187
1188
|
locale: this.isEn(chatId) ? "en" : "zh",
|
|
1188
|
-
disableHint: this.isEn(chatId)
|
|
1189
|
-
? `Tip: send /livestatus off to hide this status message`
|
|
1190
|
-
: `提示:调用 /livestatus off 关闭该状态提示`,
|
|
1191
1189
|
})
|
|
1192
1190
|
: undefined;
|
|
1193
1191
|
liveStatus?.start(this.isEn(chatId) ? "waiting for OpenClaw" : "等待 OpenClaw 回复");
|
|
@@ -1242,7 +1240,7 @@ export class FeishuBot {
|
|
|
1242
1240
|
// submitted far enough that auto-replaying risks duplicate delivery.
|
|
1243
1241
|
// The user can resend a new message if they want to retry.
|
|
1244
1242
|
markSubmittedBatch("empty-returned");
|
|
1245
|
-
liveStatus?.
|
|
1243
|
+
await liveStatus?.noReply().catch(() => { });
|
|
1246
1244
|
console.warn(`[${this.config.name}] Empty reply for ${chatId.slice(-8)} trigger=${triggerId}; not replaying submitted message(s)`);
|
|
1247
1245
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
1248
1246
|
const remainingAcks = [];
|
|
@@ -1342,7 +1340,10 @@ export class FeishuBot {
|
|
|
1342
1340
|
this.scheduleDelayedFailure(chatId, lastHuman.messageId, visibleReply, triggerId);
|
|
1343
1341
|
}
|
|
1344
1342
|
else if (!shouldReply && !hasAttachments) {
|
|
1345
|
-
|
|
1343
|
+
// Explicit NO_REPLY (or empty visible text): the model finished without
|
|
1344
|
+
// producing a user-visible reply. Mark the status card done with a
|
|
1345
|
+
// "no content" summary instead of leaving it stuck on "正在执行".
|
|
1346
|
+
await liveStatus?.noReply().catch(() => { });
|
|
1346
1347
|
}
|
|
1347
1348
|
console.log(`[${this.config.name}] [${new Date().toISOString()}] ${shouldReply || hasAttachments ? 'Replied' : 'Skipped (empty/NO_REPLY)'} (${reply.length} chars, attachments=${parsedReply.attachments.length})`);
|
|
1348
1349
|
// Replace ack reactions with DONE only for trigger messages actually
|
|
@@ -1860,15 +1861,13 @@ export class FeishuBot {
|
|
|
1860
1861
|
const liveStatusEnabled = this.store.getBotLiveStatus(this.config.name, chatId) && !this.store.getBotVerbose(this.config.name, chatId);
|
|
1861
1862
|
const liveStatus = liveStatusEnabled
|
|
1862
1863
|
? new LiveStatusController({
|
|
1863
|
-
create: (
|
|
1864
|
-
edit: (messageId,
|
|
1864
|
+
create: (view) => this.sendOrdered(chatId, () => this.sendLiveStatusCard(chatId, view)),
|
|
1865
|
+
edit: (messageId, view) => this.sendOrdered(chatId, () => this.patchLiveStatusCard(messageId, view, chatId)),
|
|
1865
1866
|
warn: (message, err) => console.warn(`[${this.config.name}] ${message}:`, err instanceof Error ? err.message : err),
|
|
1866
1867
|
}, {
|
|
1867
1868
|
botName: this.config.name,
|
|
1869
|
+
model: this.config.model,
|
|
1868
1870
|
locale: this.isEn(chatId) ? "en" : "zh",
|
|
1869
|
-
disableHint: this.isEn(chatId)
|
|
1870
|
-
? `Tip: send /livestatus off to hide this status message`
|
|
1871
|
-
: `提示:调用 /livestatus off 关闭该状态提示`,
|
|
1872
1871
|
})
|
|
1873
1872
|
: undefined;
|
|
1874
1873
|
liveStatus?.start(meta ? `第 ${meta.round}/${meta.maxRounds} 轮讨论` : "讨论中");
|
|
@@ -1929,9 +1928,100 @@ export class FeishuBot {
|
|
|
1929
1928
|
throw err;
|
|
1930
1929
|
}
|
|
1931
1930
|
}
|
|
1932
|
-
|
|
1931
|
+
// Mirror the normal path: a NO_REPLY/empty discussion turn should finish the
|
|
1932
|
+
// status card with a "no content" summary, not a plain done summary.
|
|
1933
|
+
if (!isVisible && parsedReply.attachments.length === 0) {
|
|
1934
|
+
await liveStatus?.noReply().catch(() => { });
|
|
1935
|
+
}
|
|
1936
|
+
else {
|
|
1937
|
+
await liveStatus?.complete().catch(() => { });
|
|
1938
|
+
}
|
|
1933
1939
|
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
|
|
1934
1940
|
}
|
|
1941
|
+
buildLiveStatusCard(view, chatId) {
|
|
1942
|
+
const en = this.isEn(chatId);
|
|
1943
|
+
const elements = [];
|
|
1944
|
+
// Content area. While running: recent activity lines (each prefixed with the
|
|
1945
|
+
// relative time mm:ss since the run started). When finished: a single run
|
|
1946
|
+
// summary line (total tool calls + elapsed) instead of recent messages.
|
|
1947
|
+
if (view.lines.length > 0) {
|
|
1948
|
+
const iconFor = (kind) => kind === "tool_start" ? "▸"
|
|
1949
|
+
: kind === "tool_end" ? "✓"
|
|
1950
|
+
: kind === "lifecycle" ? "⋯"
|
|
1951
|
+
: kind === "summary" ? "📊"
|
|
1952
|
+
: "•";
|
|
1953
|
+
const fmtAt = (sec) => `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, "0")}`;
|
|
1954
|
+
const content = view.lines
|
|
1955
|
+
.map((l) => {
|
|
1956
|
+
const stamp = l.kind === "summary" ? "" : `\`${fmtAt(l.at)}\` `;
|
|
1957
|
+
return `${stamp}${iconFor(l.kind)} ${this.escapeCardText(l.text)}`;
|
|
1958
|
+
})
|
|
1959
|
+
.join("\n");
|
|
1960
|
+
elements.push({ tag: "markdown", content });
|
|
1961
|
+
}
|
|
1962
|
+
else {
|
|
1963
|
+
elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
|
|
1964
|
+
}
|
|
1965
|
+
elements.push({ tag: "hr" });
|
|
1966
|
+
// Footer: elapsed time + model name.
|
|
1967
|
+
const footerBits = [];
|
|
1968
|
+
footerBits.push(en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`);
|
|
1969
|
+
if (view.model)
|
|
1970
|
+
footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
|
|
1971
|
+
elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
|
|
1972
|
+
// Title color reflects state.
|
|
1973
|
+
const template = view.state === "done" ? "green" : view.state === "failed" ? "orange" : "blue";
|
|
1974
|
+
return {
|
|
1975
|
+
schema: "2.0",
|
|
1976
|
+
// update_multi is REQUIRED for im.message.patch to update the card; Feishu
|
|
1977
|
+
// rejects patches on cards that did not declare it before and after.
|
|
1978
|
+
config: { update_multi: true, width_mode: "fill" },
|
|
1979
|
+
header: {
|
|
1980
|
+
title: { tag: "plain_text", content: view.title },
|
|
1981
|
+
template,
|
|
1982
|
+
},
|
|
1983
|
+
body: { elements },
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
/** Minimal escaping so activity text does not break card markdown. */
|
|
1987
|
+
escapeCardText(text) {
|
|
1988
|
+
return String(text || "").replace(/</g, "\uff1c").replace(/>/g, "\uff1e");
|
|
1989
|
+
}
|
|
1990
|
+
async sendLiveStatusCard(chatId, view) {
|
|
1991
|
+
try {
|
|
1992
|
+
const res = await this.client.im.message.create({
|
|
1993
|
+
params: { receive_id_type: "chat_id" },
|
|
1994
|
+
data: {
|
|
1995
|
+
receive_id: chatId,
|
|
1996
|
+
content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
|
|
1997
|
+
msg_type: "interactive",
|
|
1998
|
+
},
|
|
1999
|
+
});
|
|
2000
|
+
this.store.clearBotUnavailableInChat(this.config.name, chatId);
|
|
2001
|
+
return res?.data?.message_id || res?.message_id;
|
|
2002
|
+
}
|
|
2003
|
+
catch (err) {
|
|
2004
|
+
if (this.isOutOfChatError(err))
|
|
2005
|
+
this.markCurrentBotUnavailable(chatId, err);
|
|
2006
|
+
throw err;
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
async replyLiveStatusCard(messageId, view, chatId) {
|
|
2010
|
+
const res = await this.client.im.message.reply({
|
|
2011
|
+
path: { message_id: messageId },
|
|
2012
|
+
data: {
|
|
2013
|
+
content: JSON.stringify(this.buildLiveStatusCard(view, chatId)),
|
|
2014
|
+
msg_type: "interactive",
|
|
2015
|
+
},
|
|
2016
|
+
});
|
|
2017
|
+
return res?.data?.message_id || res?.message_id;
|
|
2018
|
+
}
|
|
2019
|
+
async patchLiveStatusCard(messageId, view, chatId) {
|
|
2020
|
+
await this.client.im.message.patch({
|
|
2021
|
+
path: { message_id: messageId },
|
|
2022
|
+
data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
1935
2025
|
async sendTextMessage(chatId, text) {
|
|
1936
2026
|
try {
|
|
1937
2027
|
const res = await this.client.im.message.create({
|
|
@@ -2000,11 +2090,12 @@ export class FeishuBot {
|
|
|
2000
2090
|
}
|
|
2001
2091
|
extractBridgeAttachments(reply) {
|
|
2002
2092
|
const attachments = [];
|
|
2003
|
-
// Some models occasionally corrupt the
|
|
2004
|
-
//
|
|
2005
|
-
//
|
|
2006
|
-
//
|
|
2007
|
-
|
|
2093
|
+
// Some models occasionally corrupt the attachment marker while preserving
|
|
2094
|
+
// the exact JSON payload: the closing tag can become </parameter>, or the
|
|
2095
|
+
// opening "<LMA" prefix can be truncated leaving "_BRIDGE_ATTACHMENTS>".
|
|
2096
|
+
// Treat these as recoverable shapes; otherwise the marker leaks as
|
|
2097
|
+
// user-visible text and attachments are not sent.
|
|
2098
|
+
const markerPattern = /(?:<LMA_BRIDGE_ATTACHMENTS>|_BRIDGE_ATTACHMENTS>)([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
|
|
2008
2099
|
let text = reply.replace(markerPattern, (_match, jsonText) => {
|
|
2009
2100
|
try {
|
|
2010
2101
|
const parsed = JSON.parse(String(jsonText).trim());
|
package/dist/live-status.d.ts
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import type { ProgressEvent } from "./openclaw-client";
|
|
2
|
+
/** One activity line shown in the card content area. */
|
|
3
|
+
export type LiveStatusLine = {
|
|
4
|
+
kind: "tool_start" | "tool_end" | "text" | "lifecycle" | "summary";
|
|
5
|
+
text: string;
|
|
6
|
+
/** Relative time (seconds since run start) when this line was produced. */
|
|
7
|
+
at: number;
|
|
8
|
+
};
|
|
9
|
+
/** Structured snapshot the renderer turns into a Feishu interactive card. */
|
|
10
|
+
export type LiveStatusView = {
|
|
11
|
+
/** Card title, e.g. "Claude 正在执行" / "✅ Claude 已完成". */
|
|
12
|
+
title: string;
|
|
13
|
+
/** Activity lines (oldest first); when finished this holds a summary instead. */
|
|
14
|
+
lines: LiveStatusLine[];
|
|
15
|
+
/** Footer: elapsed time like "0:42". */
|
|
16
|
+
elapsed: string;
|
|
17
|
+
/** Footer: model name, e.g. "phgeek-gw/claude-opus-4.8". */
|
|
18
|
+
model?: string;
|
|
19
|
+
/** Terminal state: running / done / failed (lets renderer pick color). */
|
|
20
|
+
state: "running" | "done" | "failed";
|
|
21
|
+
};
|
|
2
22
|
export type LiveStatusCallbacks = {
|
|
3
|
-
|
|
4
|
-
|
|
23
|
+
/** Create the status card; returns the new message id. */
|
|
24
|
+
create: (view: LiveStatusView) => Promise<string | undefined>;
|
|
25
|
+
/** Patch the existing status card. */
|
|
26
|
+
edit: (messageId: string, view: LiveStatusView) => Promise<void>;
|
|
5
27
|
warn?: (message: string, err?: unknown) => void;
|
|
6
28
|
};
|
|
7
29
|
export type LiveStatusOptions = {
|
|
@@ -9,48 +31,67 @@ export type LiveStatusOptions = {
|
|
|
9
31
|
locale?: "zh" | "en";
|
|
10
32
|
delayMs?: number;
|
|
11
33
|
maxChars?: number;
|
|
12
|
-
|
|
13
|
-
|
|
34
|
+
/** Model name shown in the footer. */
|
|
35
|
+
model?: string;
|
|
36
|
+
/** Number of recent activity lines kept in the content area. */
|
|
37
|
+
historySize?: number;
|
|
38
|
+
/** Footer/auto-refresh cadence in ms. */
|
|
39
|
+
tickMs?: number;
|
|
14
40
|
};
|
|
15
41
|
export declare class LiveStatusController {
|
|
16
42
|
private readonly callbacks;
|
|
17
43
|
private readonly opts;
|
|
18
44
|
private messageId?;
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
private
|
|
45
|
+
private lastSentSignature;
|
|
46
|
+
/** Recent activity lines (most-recent last), capped at historySize. */
|
|
47
|
+
private lines;
|
|
48
|
+
private startedAt;
|
|
23
49
|
private createTimer?;
|
|
50
|
+
private tickTimer?;
|
|
24
51
|
private createPromise?;
|
|
25
52
|
private finalized;
|
|
26
53
|
private disabled;
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
private state;
|
|
55
|
+
/** Total tool calls (tool_start events) seen during the run, for the summary. */
|
|
56
|
+
private toolCallCount;
|
|
57
|
+
/** True when finished via noReply(): show a "no content" summary. */
|
|
58
|
+
private noReplyResult;
|
|
29
59
|
constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
|
|
30
60
|
get id(): string | undefined;
|
|
31
61
|
start(initialDetail?: string): void;
|
|
32
62
|
progress(event: ProgressEvent | string): Promise<void>;
|
|
33
|
-
/**
|
|
34
|
-
* Finish the live status when the real (final) reply is delivered separately.
|
|
35
|
-
* The final answer is sent by the normal interactive-card path, so the status
|
|
36
|
-
* message must NOT be overwritten with the answer (text type does not render
|
|
37
|
-
* Markdown). Feishu renders message deletion as a visible "recalled a message"
|
|
38
|
-
* tombstone, so keep the status message and mark it done instead.
|
|
39
|
-
*/
|
|
40
63
|
complete(): Promise<void>;
|
|
64
|
+
fail(): Promise<void>;
|
|
41
65
|
/**
|
|
42
|
-
* Finish
|
|
43
|
-
*
|
|
44
|
-
*
|
|
66
|
+
* Finish when the model explicitly produced no reply (NO_REPLY) or an empty
|
|
67
|
+
* final. Mark the card done but show a "no content" summary instead of the
|
|
68
|
+
* activity window, so the user understands the run finished without output.
|
|
45
69
|
*/
|
|
46
|
-
|
|
70
|
+
noReply(): Promise<void>;
|
|
47
71
|
dispose(): void;
|
|
72
|
+
/**
|
|
73
|
+
* Intermediate assistant text arrives as incremental fragments (each flush
|
|
74
|
+
* sends only the newly-added slice). Consecutive fragments with no tool event
|
|
75
|
+
* in between belong to the same thought, so merge them into ONE line instead
|
|
76
|
+
* of showing each fragment (or each table row) as a separate message. A tool
|
|
77
|
+
* event resets this, so the next text starts a fresh line.
|
|
78
|
+
*/
|
|
79
|
+
private appendText;
|
|
80
|
+
private normalizeText;
|
|
81
|
+
private clip;
|
|
82
|
+
private pushLine;
|
|
48
83
|
private stopTimers;
|
|
49
84
|
private ensureCreatedNow;
|
|
50
85
|
private ensureCreated;
|
|
51
|
-
|
|
86
|
+
/** Refresh the elapsed-time footer periodically even with no new activity. */
|
|
87
|
+
private startTicker;
|
|
52
88
|
private safeEdit;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
private
|
|
89
|
+
/** Dedupe key includes elapsed so the footer timer ticks even without new
|
|
90
|
+
* activity, while identical content within the same second is still skipped. */
|
|
91
|
+
private signature;
|
|
92
|
+
private isNoReplyText;
|
|
93
|
+
private formatTool;
|
|
94
|
+
private formatElapsed;
|
|
95
|
+
private buildView;
|
|
96
|
+
private buildSummaryLine;
|
|
56
97
|
}
|
package/dist/live-status.js
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
|
|
2
2
|
const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
3
|
+
// How many recent activity lines to keep in the card content area.
|
|
4
|
+
const DEFAULT_HISTORY = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_HISTORY || 6);
|
|
5
|
+
// How often to refresh the elapsed-time footer even when no new activity arrives,
|
|
6
|
+
// so the "已用时间" keeps advancing. Card patch has no 20-edit cap (unlike text
|
|
7
|
+
// edit), so a modest cadence is safe.
|
|
8
|
+
const DEFAULT_TICK_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_TICK_MS || 3000);
|
|
7
9
|
export class LiveStatusController {
|
|
8
10
|
callbacks;
|
|
9
11
|
opts;
|
|
10
12
|
messageId;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
lastSentSignature = "";
|
|
14
|
+
/** Recent activity lines (most-recent last), capped at historySize. */
|
|
15
|
+
lines = [];
|
|
16
|
+
startedAt = Date.now();
|
|
15
17
|
createTimer;
|
|
18
|
+
tickTimer;
|
|
16
19
|
createPromise;
|
|
17
20
|
finalized = false;
|
|
18
21
|
disabled = false;
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
state = "running";
|
|
23
|
+
/** Total tool calls (tool_start events) seen during the run, for the summary. */
|
|
24
|
+
toolCallCount = 0;
|
|
25
|
+
/** True when finished via noReply(): show a "no content" summary. */
|
|
26
|
+
noReplyResult = false;
|
|
21
27
|
constructor(callbacks, opts) {
|
|
22
28
|
this.callbacks = callbacks;
|
|
23
29
|
this.opts = opts;
|
|
@@ -26,78 +32,138 @@ export class LiveStatusController {
|
|
|
26
32
|
start(initialDetail) {
|
|
27
33
|
if (this.disabled || this.finalized || this.createTimer || this.messageId)
|
|
28
34
|
return;
|
|
29
|
-
this.
|
|
35
|
+
this.startedAt = Date.now();
|
|
36
|
+
if (initialDetail && initialDetail.trim()) {
|
|
37
|
+
this.pushLine("lifecycle", initialDetail.trim());
|
|
38
|
+
}
|
|
30
39
|
this.createTimer = setTimeout(() => {
|
|
31
40
|
this.createTimer = undefined;
|
|
32
41
|
this.createPromise = this.ensureCreated();
|
|
33
42
|
}, this.opts.delayMs ?? DEFAULT_DELAY_MS);
|
|
34
43
|
}
|
|
35
44
|
async progress(event) {
|
|
36
|
-
if (this.disabled || this.finalized
|
|
45
|
+
if (this.disabled || this.finalized)
|
|
37
46
|
return;
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
// Card patch has no 20-edit cap (verified against Feishu im.message.patch),
|
|
48
|
+
// so we can show a small rolling window of recent activity: tool start, tool
|
|
49
|
+
// end, and intermediate assistant text each count as one line. We ignore
|
|
50
|
+
// tool "error" (the error is delivered through the normal message path) and
|
|
51
|
+
// lifecycle ticks after the first.
|
|
52
|
+
if (typeof event === "string") {
|
|
53
|
+
const t = event.trim();
|
|
54
|
+
if (!t || this.isNoReplyText(t))
|
|
55
|
+
return;
|
|
56
|
+
this.appendText(t);
|
|
57
|
+
}
|
|
58
|
+
else if (event.kind === "tool") {
|
|
59
|
+
if (event.phase === "start") {
|
|
60
|
+
this.toolCallCount++;
|
|
61
|
+
this.pushLine("tool_start", this.formatTool(event));
|
|
62
|
+
}
|
|
63
|
+
else if (event.phase === "end")
|
|
64
|
+
this.pushLine("tool_end", this.formatTool(event));
|
|
65
|
+
else
|
|
66
|
+
return; // tool error -> normal error path
|
|
67
|
+
}
|
|
68
|
+
else if (event.kind === "assistant_note") {
|
|
69
|
+
const t = (event.text || "").trim();
|
|
70
|
+
if (!t || this.isNoReplyText(t))
|
|
43
71
|
return;
|
|
44
|
-
this.
|
|
72
|
+
this.appendText(t);
|
|
45
73
|
}
|
|
46
74
|
else {
|
|
47
|
-
//
|
|
48
|
-
// runtime OpenClaw progress is structured and only tool start passes above.
|
|
49
|
-
this.detail = event.trim();
|
|
75
|
+
return; // lifecycle ticks: ignore (footer timer already advances)
|
|
50
76
|
}
|
|
51
|
-
if (!this.detail)
|
|
52
|
-
return;
|
|
53
77
|
await this.ensureCreatedNow();
|
|
54
|
-
await this.
|
|
78
|
+
await this.safeEdit(this.buildView());
|
|
55
79
|
}
|
|
56
|
-
/**
|
|
57
|
-
* Finish the live status when the real (final) reply is delivered separately.
|
|
58
|
-
* The final answer is sent by the normal interactive-card path, so the status
|
|
59
|
-
* message must NOT be overwritten with the answer (text type does not render
|
|
60
|
-
* Markdown). Feishu renders message deletion as a visible "recalled a message"
|
|
61
|
-
* tombstone, so keep the status message and mark it done instead.
|
|
62
|
-
*/
|
|
63
80
|
async complete() {
|
|
81
|
+
this.state = "done";
|
|
64
82
|
this.finalized = true;
|
|
65
83
|
this.stopTimers();
|
|
66
84
|
if (this.createPromise)
|
|
67
85
|
await this.createPromise.catch(() => { });
|
|
68
86
|
if (!this.messageId || this.disabled)
|
|
69
87
|
return;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
await this.safeEdit(this.buildView(), true);
|
|
89
|
+
}
|
|
90
|
+
async fail() {
|
|
91
|
+
this.state = "failed";
|
|
92
|
+
this.finalized = true;
|
|
93
|
+
this.stopTimers();
|
|
94
|
+
if (this.createPromise)
|
|
95
|
+
await this.createPromise.catch(() => { });
|
|
96
|
+
if (!this.messageId || this.disabled)
|
|
97
|
+
return;
|
|
98
|
+
await this.safeEdit(this.buildView(), true);
|
|
74
99
|
}
|
|
75
100
|
/**
|
|
76
|
-
* Finish
|
|
77
|
-
*
|
|
78
|
-
*
|
|
101
|
+
* Finish when the model explicitly produced no reply (NO_REPLY) or an empty
|
|
102
|
+
* final. Mark the card done but show a "no content" summary instead of the
|
|
103
|
+
* activity window, so the user understands the run finished without output.
|
|
79
104
|
*/
|
|
80
|
-
async
|
|
105
|
+
async noReply() {
|
|
106
|
+
this.state = "done";
|
|
107
|
+
this.noReplyResult = true;
|
|
81
108
|
this.finalized = true;
|
|
82
109
|
this.stopTimers();
|
|
83
110
|
if (this.createPromise)
|
|
84
111
|
await this.createPromise.catch(() => { });
|
|
85
112
|
if (!this.messageId || this.disabled)
|
|
86
113
|
return;
|
|
87
|
-
|
|
88
|
-
? `\u26A0\uFE0F ${this.opts.botName} stopped`
|
|
89
|
-
: `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
|
|
90
|
-
await this.safeEdit(failedText, true);
|
|
114
|
+
await this.safeEdit(this.buildView(), true);
|
|
91
115
|
}
|
|
92
116
|
dispose() {
|
|
93
117
|
this.finalized = true;
|
|
94
118
|
this.stopTimers();
|
|
95
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Intermediate assistant text arrives as incremental fragments (each flush
|
|
122
|
+
* sends only the newly-added slice). Consecutive fragments with no tool event
|
|
123
|
+
* in between belong to the same thought, so merge them into ONE line instead
|
|
124
|
+
* of showing each fragment (or each table row) as a separate message. A tool
|
|
125
|
+
* event resets this, so the next text starts a fresh line.
|
|
126
|
+
*/
|
|
127
|
+
appendText(fragment) {
|
|
128
|
+
const last = this.lines[this.lines.length - 1];
|
|
129
|
+
if (last && last.kind === "text") {
|
|
130
|
+
const merged = `${last.text} ${this.normalizeText(fragment)}`.replace(/\s+/g, " ").trim();
|
|
131
|
+
last.text = this.clip(merged);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.pushLine("text", fragment);
|
|
135
|
+
}
|
|
136
|
+
normalizeText(text) {
|
|
137
|
+
return text.replace(/\s+/g, " ").trim();
|
|
138
|
+
}
|
|
139
|
+
clip(text) {
|
|
140
|
+
const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
|
|
141
|
+
return text.length > maxChars ? `${text.slice(0, Math.max(0, maxChars - 1))}\u2026` : text;
|
|
142
|
+
}
|
|
143
|
+
pushLine(kind, text) {
|
|
144
|
+
const clean = this.normalizeText(text);
|
|
145
|
+
if (!clean)
|
|
146
|
+
return;
|
|
147
|
+
const clipped = this.clip(clean);
|
|
148
|
+
// Collapse consecutive identical lines (e.g. repeated "等待 OpenClaw").
|
|
149
|
+
const last = this.lines[this.lines.length - 1];
|
|
150
|
+
if (last && last.kind === kind && last.text === clipped)
|
|
151
|
+
return;
|
|
152
|
+
const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
|
|
153
|
+
this.lines.push({ kind, text: clipped, at });
|
|
154
|
+
const limit = this.opts.historySize ?? DEFAULT_HISTORY;
|
|
155
|
+
if (this.lines.length > limit)
|
|
156
|
+
this.lines = this.lines.slice(this.lines.length - limit);
|
|
157
|
+
}
|
|
96
158
|
stopTimers() {
|
|
97
159
|
if (this.createTimer) {
|
|
98
160
|
clearTimeout(this.createTimer);
|
|
99
161
|
this.createTimer = undefined;
|
|
100
162
|
}
|
|
163
|
+
if (this.tickTimer) {
|
|
164
|
+
clearInterval(this.tickTimer);
|
|
165
|
+
this.tickTimer = undefined;
|
|
166
|
+
}
|
|
101
167
|
}
|
|
102
168
|
async ensureCreatedNow() {
|
|
103
169
|
if (this.createTimer) {
|
|
@@ -111,45 +177,45 @@ export class LiveStatusController {
|
|
|
111
177
|
async ensureCreated() {
|
|
112
178
|
if (this.disabled || this.finalized || this.messageId)
|
|
113
179
|
return;
|
|
114
|
-
const
|
|
180
|
+
const view = this.buildView();
|
|
115
181
|
try {
|
|
116
|
-
const id = await this.callbacks.create(
|
|
182
|
+
const id = await this.callbacks.create(view);
|
|
117
183
|
if (!id) {
|
|
118
184
|
this.disabled = true;
|
|
119
185
|
return;
|
|
120
186
|
}
|
|
121
187
|
this.messageId = id;
|
|
122
|
-
this.
|
|
188
|
+
this.lastSentSignature = this.signature(view);
|
|
189
|
+
this.startTicker();
|
|
123
190
|
}
|
|
124
191
|
catch (err) {
|
|
125
192
|
this.disabled = true;
|
|
126
193
|
this.callbacks.warn?.("live status create failed", err);
|
|
127
194
|
}
|
|
128
195
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const maxEdits = this.opts.maxEdits ?? DEFAULT_MAX_EDITS;
|
|
133
|
-
const runningBudget = Math.max(0, maxEdits - 1); // reserve final edit for complete()
|
|
134
|
-
if (this.editCount >= runningBudget)
|
|
196
|
+
/** Refresh the elapsed-time footer periodically even with no new activity. */
|
|
197
|
+
startTicker() {
|
|
198
|
+
if (this.tickTimer || this.disabled || this.finalized)
|
|
135
199
|
return;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
this.
|
|
200
|
+
const tickMs = this.opts.tickMs ?? DEFAULT_TICK_MS;
|
|
201
|
+
this.tickTimer = setInterval(() => {
|
|
202
|
+
if (this.finalized || this.disabled || !this.messageId)
|
|
203
|
+
return;
|
|
204
|
+
void this.safeEdit(this.buildView());
|
|
205
|
+
}, tickMs);
|
|
206
|
+
this.tickTimer.unref?.();
|
|
141
207
|
}
|
|
142
|
-
async safeEdit(
|
|
208
|
+
async safeEdit(view, force = false) {
|
|
143
209
|
if (!this.messageId || this.disabled)
|
|
144
210
|
return false;
|
|
145
211
|
if (!force && this.finalized)
|
|
146
212
|
return false;
|
|
147
|
-
|
|
213
|
+
const sig = this.signature(view);
|
|
214
|
+
if (!force && sig === this.lastSentSignature)
|
|
148
215
|
return true;
|
|
149
216
|
try {
|
|
150
|
-
await this.callbacks.edit(this.messageId,
|
|
151
|
-
this.
|
|
152
|
-
this.editCount++;
|
|
217
|
+
await this.callbacks.edit(this.messageId, view);
|
|
218
|
+
this.lastSentSignature = sig;
|
|
153
219
|
return true;
|
|
154
220
|
}
|
|
155
221
|
catch (err) {
|
|
@@ -159,26 +225,62 @@ export class LiveStatusController {
|
|
|
159
225
|
return false;
|
|
160
226
|
}
|
|
161
227
|
}
|
|
162
|
-
|
|
228
|
+
/** Dedupe key includes elapsed so the footer timer ticks even without new
|
|
229
|
+
* activity, while identical content within the same second is still skipped. */
|
|
230
|
+
signature(view) {
|
|
231
|
+
return `${view.state}|${view.title}|${view.elapsed}|${view.lines.map((l) => `${l.kind}@${l.at}:${l.text}`).join("|")}`;
|
|
232
|
+
}
|
|
233
|
+
isNoReplyText(text) {
|
|
234
|
+
return text.trim().toUpperCase() === "NO_REPLY";
|
|
235
|
+
}
|
|
236
|
+
formatTool(event) {
|
|
163
237
|
const raw = event.text || event.name || "";
|
|
164
|
-
const prefix = `${event.name}
|
|
238
|
+
const prefix = `${event.name} ${event.phase}`;
|
|
165
239
|
let detail = raw.startsWith(prefix) ? raw.slice(prefix.length).trim() : raw.trim();
|
|
166
240
|
if (detail.startsWith(":"))
|
|
167
241
|
detail = detail.slice(1).trim();
|
|
168
242
|
return detail ? `${event.name}: ${detail}` : event.name;
|
|
169
243
|
}
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
:
|
|
244
|
+
formatElapsed() {
|
|
245
|
+
const totalSec = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
|
|
246
|
+
const m = Math.floor(totalSec / 60);
|
|
247
|
+
const s = totalSec % 60;
|
|
248
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
249
|
+
}
|
|
250
|
+
buildView() {
|
|
251
|
+
const en = this.opts.locale === "en";
|
|
252
|
+
let title;
|
|
253
|
+
if (this.state === "done")
|
|
254
|
+
title = en ? `\u2705 ${this.opts.botName} done` : `\u2705 ${this.opts.botName} \u5df2\u5b8c\u6210`;
|
|
255
|
+
else if (this.state === "failed")
|
|
256
|
+
title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
|
|
257
|
+
else
|
|
258
|
+
title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
|
|
259
|
+
// When finished, replace the recent-activity window with a run summary
|
|
260
|
+
// (total tool calls + total time) instead of the last few messages.
|
|
261
|
+
const lines = this.state === "running"
|
|
262
|
+
? [...this.lines]
|
|
263
|
+
: [this.buildSummaryLine(en)];
|
|
264
|
+
return {
|
|
265
|
+
title,
|
|
266
|
+
lines,
|
|
267
|
+
elapsed: this.formatElapsed(),
|
|
268
|
+
model: this.opts.model,
|
|
269
|
+
state: this.state,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
buildSummaryLine(en) {
|
|
273
|
+
const elapsed = this.formatElapsed();
|
|
274
|
+
const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
|
|
275
|
+
if (this.noReplyResult) {
|
|
276
|
+
const text = en
|
|
277
|
+
? `Model returned no content \u00b7 ${this.toolCallCount} tool call${this.toolCallCount === 1 ? "" : "s"} \u00b7 ${elapsed}`
|
|
278
|
+
: `\u6a21\u578b\u6ca1\u6709\u56de\u590d\u5185\u5bb9 \u00b7 \u5171\u8c03\u7528\u5de5\u5177 ${this.toolCallCount} \u6b21 \u00b7 \u8017\u65f6 ${elapsed}`;
|
|
279
|
+
return { kind: "summary", text, at };
|
|
280
|
+
}
|
|
281
|
+
const text = en
|
|
282
|
+
? `${this.toolCallCount} tool call${this.toolCallCount === 1 ? "" : "s"} \u00b7 ${elapsed}`
|
|
283
|
+
: `\u5171\u8c03\u7528\u5de5\u5177 ${this.toolCallCount} \u6b21 \u00b7 \u8017\u65f6 ${elapsed}`;
|
|
284
|
+
return { kind: "summary", text, at };
|
|
183
285
|
}
|
|
184
286
|
}
|
|
@@ -90,6 +90,15 @@ export declare class OpenClawClient {
|
|
|
90
90
|
* 30-minute safety net only for catastrophic WS disconnection.
|
|
91
91
|
*/
|
|
92
92
|
private collectReply;
|
|
93
|
+
/**
|
|
94
|
+
* Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
|
|
95
|
+
* (it auto-compacts and keeps running the same run, then emits a real final).
|
|
96
|
+
* For these we must not reject collectReply immediately, or the bridge reports
|
|
97
|
+
* "没有完成回复" while the agent is still working. Currently this covers
|
|
98
|
+
* context-overflow / prompt-too-large, which OpenClaw recovers from via its
|
|
99
|
+
* context-overflow-recovery + auto-compaction path.
|
|
100
|
+
*/
|
|
101
|
+
private isRecoverableAgentError;
|
|
93
102
|
private pickBestCollectedText;
|
|
94
103
|
createSession(params: {
|
|
95
104
|
key: string;
|
package/dist/openclaw-client.js
CHANGED
|
@@ -810,13 +810,29 @@ export class OpenClawClient {
|
|
|
810
810
|
return;
|
|
811
811
|
}
|
|
812
812
|
if (ev.stream === "lifecycle" && ev.data?.phase === "error") {
|
|
813
|
+
const errText = String(ev.data?.error || "unknown");
|
|
814
|
+
// Some agent errors are recoverable: OpenClaw emits a lifecycle
|
|
815
|
+
// error (e.g. "Context overflow: prompt too large") and then
|
|
816
|
+
// auto-compacts and KEEPS RUNNING the same run, eventually emitting
|
|
817
|
+
// a real final. If we reject immediately the user sees a scary
|
|
818
|
+
// "没有完成回复" while the agent is in fact still working. So for
|
|
819
|
+
// recoverable errors we defer: record the failure text and keep
|
|
820
|
+
// waiting. Subsequent delta/item events reset the idle timer, so a
|
|
821
|
+
// real final wins; if the run truly stalls, the idle timeout falls
|
|
822
|
+
// back to this text.
|
|
823
|
+
if (this.isRecoverableAgentError(errText)) {
|
|
824
|
+
pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
|
|
825
|
+
resetIdleTimer();
|
|
826
|
+
console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
813
829
|
clearTimeout(idleTimer);
|
|
814
830
|
clearInterval(poller);
|
|
815
831
|
if (chatFinalTimer)
|
|
816
832
|
clearTimeout(chatFinalTimer);
|
|
817
833
|
if (lifecycleEndTimer)
|
|
818
834
|
clearTimeout(lifecycleEndTimer);
|
|
819
|
-
reject(new Error(`Agent error: ${
|
|
835
|
+
reject(new Error(`Agent error: ${errText}`));
|
|
820
836
|
return;
|
|
821
837
|
}
|
|
822
838
|
}
|
|
@@ -824,6 +840,22 @@ export class OpenClawClient {
|
|
|
824
840
|
}, 50);
|
|
825
841
|
});
|
|
826
842
|
}
|
|
843
|
+
/**
|
|
844
|
+
* Recoverable agent errors are lifecycle errors that OpenClaw can self-heal
|
|
845
|
+
* (it auto-compacts and keeps running the same run, then emits a real final).
|
|
846
|
+
* For these we must not reject collectReply immediately, or the bridge reports
|
|
847
|
+
* "没有完成回复" while the agent is still working. Currently this covers
|
|
848
|
+
* context-overflow / prompt-too-large, which OpenClaw recovers from via its
|
|
849
|
+
* context-overflow-recovery + auto-compaction path.
|
|
850
|
+
*/
|
|
851
|
+
isRecoverableAgentError(errText) {
|
|
852
|
+
const t = (errText || "").toLowerCase();
|
|
853
|
+
return t.includes("context overflow")
|
|
854
|
+
|| t.includes("prompt too large")
|
|
855
|
+
|| t.includes("context length")
|
|
856
|
+
|| t.includes("too many tokens")
|
|
857
|
+
|| t.includes("maximum context");
|
|
858
|
+
}
|
|
827
859
|
pickBestCollectedText(chatFinalText, assistantText, chatDeltaText, transcriptAssistantText) {
|
|
828
860
|
if (chatFinalText)
|
|
829
861
|
return chatFinalText;
|