@keyou007/agent-bot 0.1.15-alpha.0 → 0.1.15-alpha.2
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/CHANGELOG.md +14 -0
- package/README.md +23 -20
- package/README.zh.md +23 -20
- package/config.example.yaml +1 -0
- package/dist/cli/FeishuAppConfiguration.js +42 -3
- package/dist/cli/FeishuAppConfiguration.js.map +1 -1
- package/dist/cli.js +4 -1
- package/dist/cli.js.map +1 -1
- package/dist/feishu/CardRenderer.js +114 -16
- package/dist/feishu/CardRenderer.js.map +1 -1
- package/dist/feishu/FeishuConnector.js +21 -6
- package/dist/feishu/FeishuConnector.js.map +1 -1
- package/dist/feishu/FeishuMarkdown.js +58 -6
- package/dist/feishu/FeishuMarkdown.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +33 -4
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/FeishuTurnPresenter.js +7 -6
- package/dist/feishu/FeishuTurnPresenter.js.map +1 -1
- package/dist/feishu/FinalResponseBranding.js +15 -0
- package/dist/feishu/FinalResponseBranding.js.map +1 -0
- package/dist/logging/DailyLogStream.js +73 -0
- package/dist/logging/DailyLogStream.js.map +1 -0
- package/dist/logging/logger.js +2 -1
- package/dist/logging/logger.js.map +1 -1
- package/dist/proxy/ProxySessionController.js +270 -40
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +126 -0
- package/dist/state/StateStore.js.map +1 -1
- package/dist/state/migrations.js +11 -0
- package/dist/state/migrations.js.map +1 -1
- package/dist/supervision/SupervisorDiagnostics.js +37 -21
- package/dist/supervision/SupervisorDiagnostics.js.map +1 -1
- package/dist/supervisor.js +11 -4
- package/dist/supervisor.js.map +1 -1
- package/dist/utils/executeShellCommand.js +83 -16
- package/dist/utils/executeShellCommand.js.map +1 -1
- package/docs/technical-reference.md +9 -8
- package/docs/technical-reference.zh.md +9 -8
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/cli/FeishuAppConfiguration.ts +76 -3
- package/src/cli.ts +6 -1
- package/src/feishu/CardRenderer.ts +150 -17
- package/src/feishu/FeishuConnector.ts +32 -7
- package/src/feishu/FeishuMarkdown.ts +64 -5
- package/src/feishu/FeishuMessageClient.ts +41 -12
- package/src/feishu/FeishuTurnPresenter.ts +9 -6
- package/src/feishu/FinalResponseBranding.ts +18 -0
- package/src/logging/DailyLogStream.ts +88 -0
- package/src/logging/logger.ts +2 -1
- package/src/proxy/ProxySessionController.ts +292 -39
- package/src/state/StateStore.ts +180 -0
- package/src/state/migrations.ts +11 -0
- package/src/supervision/SupervisorDiagnostics.ts +41 -25
- package/src/supervisor.ts +10 -4
- package/src/utils/executeShellCommand.ts +103 -15
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
ToolState,
|
|
12
12
|
} from "../runtime/types.js";
|
|
13
13
|
import type { TurnActivity, TurnViewState, TurnViewStatus } from "../presentation/turnViewTypes.js";
|
|
14
|
-
import { truncateText } from "../utils/markdown.js";
|
|
14
|
+
import { truncateMiddle, truncateText } from "../utils/markdown.js";
|
|
15
15
|
import { localCardImage } from "./LocalCardImage.js";
|
|
16
16
|
|
|
17
17
|
export interface StartupStatusView {
|
|
@@ -195,7 +195,6 @@ export interface DirectoryBrowserCardEntry {
|
|
|
195
195
|
name: string;
|
|
196
196
|
kind: "directory" | "drive" | "file" | "image" | "binary";
|
|
197
197
|
openAction?: TaskListCardAction;
|
|
198
|
-
actions?: TaskListCardAction[];
|
|
199
198
|
}
|
|
200
199
|
|
|
201
200
|
export interface DirectoryBrowserCardView {
|
|
@@ -206,6 +205,13 @@ export interface DirectoryBrowserCardView {
|
|
|
206
205
|
footerLines: string[];
|
|
207
206
|
}
|
|
208
207
|
|
|
208
|
+
export interface DirectoryNewFolderCardView {
|
|
209
|
+
directory: string;
|
|
210
|
+
displayDirectory?: string;
|
|
211
|
+
contextKey: string;
|
|
212
|
+
page: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
209
215
|
const DIRECTORY_BROWSER_ROW_COUNT = 16;
|
|
210
216
|
|
|
211
217
|
export type ThinkingCardLayout = "grouped" | "timeline";
|
|
@@ -233,6 +239,17 @@ export interface DismissGroupCardView {
|
|
|
233
239
|
requestedBy: string;
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
export interface ShellCommandCardView {
|
|
243
|
+
command: string;
|
|
244
|
+
cwd: string;
|
|
245
|
+
stdout: string;
|
|
246
|
+
stderr: string;
|
|
247
|
+
status: "running" | "completed" | "failed" | "timed_out";
|
|
248
|
+
exitCode?: number | null;
|
|
249
|
+
elapsedMs: number;
|
|
250
|
+
outputTruncated: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
236
253
|
export class CardRenderer {
|
|
237
254
|
private readonly thinkingCardLayout: ThinkingCardLayout;
|
|
238
255
|
|
|
@@ -240,6 +257,35 @@ export class CardRenderer {
|
|
|
240
257
|
this.thinkingCardLayout = options.thinkingCardLayout ?? "grouped";
|
|
241
258
|
}
|
|
242
259
|
|
|
260
|
+
renderShellCommandCard(view: ShellCommandCardView): Record<string, unknown> {
|
|
261
|
+
const stdout = normalizeTerminalOutput(view.stdout).trimEnd();
|
|
262
|
+
const stderr = normalizeTerminalOutput(view.stderr).trimEnd();
|
|
263
|
+
const rawOutput = [
|
|
264
|
+
stdout,
|
|
265
|
+
stderr ? `[stderr]\n${stderr}` : "",
|
|
266
|
+
].filter(Boolean).join("\n");
|
|
267
|
+
const displayOutput = truncateMiddle(
|
|
268
|
+
rawOutput,
|
|
269
|
+
6_000,
|
|
270
|
+
"\n\n... 中间输出已截断 ...\n\n",
|
|
271
|
+
);
|
|
272
|
+
const command = truncateMiddle(view.command.trim(), 600, " ... ");
|
|
273
|
+
const output = displayOutput || (view.status === "running" ? "等待输出..." : "(无输出)");
|
|
274
|
+
const status = shellCommandStatus(view);
|
|
275
|
+
const outputWasTruncated = view.outputTruncated || rawOutput.length > 6_000;
|
|
276
|
+
return sectionCard(
|
|
277
|
+
status.title,
|
|
278
|
+
[
|
|
279
|
+
markdown(codeBlock(`$ ${command}\n${output}`, 6_700)),
|
|
280
|
+
markdown([
|
|
281
|
+
`${inlineCode(view.cwd)} · ${status.label} · ${formatTurnDuration(view.elapsedMs)}`,
|
|
282
|
+
...(outputWasTruncated ? ["输出过长,已保留开头和结尾并截断中间内容。"] : []),
|
|
283
|
+
].join("\n")),
|
|
284
|
+
],
|
|
285
|
+
status.template,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
243
289
|
renderExecutionSettings(view: ExecutionSettingsCardView): Record<string, unknown> {
|
|
244
290
|
const baseAction: Record<string, string> = {
|
|
245
291
|
contextKey: view.contextKey,
|
|
@@ -1174,7 +1220,7 @@ export class CardRenderer {
|
|
|
1174
1220
|
renderDirectoryBrowserCard(view: DirectoryBrowserCardView): Record<string, unknown> {
|
|
1175
1221
|
const elements: Record<string, unknown>[] = [
|
|
1176
1222
|
markdown(`**当前目录**:${inlineCode(view.directory)}`),
|
|
1177
|
-
taskActionRow(view.currentActions),
|
|
1223
|
+
taskActionRow(view.currentActions, undefined, true),
|
|
1178
1224
|
{ tag: "hr" },
|
|
1179
1225
|
];
|
|
1180
1226
|
const entryRows = view.entries.length === 0
|
|
@@ -1191,6 +1237,54 @@ export class CardRenderer {
|
|
|
1191
1237
|
return sectionCard("文件浏览", elements, "blue", "2px");
|
|
1192
1238
|
}
|
|
1193
1239
|
|
|
1240
|
+
renderDirectoryNewFolderCard(view: DirectoryNewFolderCardView): Record<string, unknown> {
|
|
1241
|
+
const actionValue = {
|
|
1242
|
+
action: "directory_new_folder_submit",
|
|
1243
|
+
directory: view.directory,
|
|
1244
|
+
contextKey: view.contextKey,
|
|
1245
|
+
page: String(view.page),
|
|
1246
|
+
};
|
|
1247
|
+
return compactCard("新建目录", "blue", [
|
|
1248
|
+
markdown(`将在当前目录下创建一个子目录:\n${inlineCode(view.displayDirectory ?? view.directory)}`),
|
|
1249
|
+
{
|
|
1250
|
+
tag: "form",
|
|
1251
|
+
name: "directory_new_folder_form",
|
|
1252
|
+
vertical_spacing: "8px",
|
|
1253
|
+
elements: [
|
|
1254
|
+
{
|
|
1255
|
+
tag: "input",
|
|
1256
|
+
element_id: "folder_name_input",
|
|
1257
|
+
name: "folderName",
|
|
1258
|
+
required: true,
|
|
1259
|
+
input_type: "text",
|
|
1260
|
+
width: "fill",
|
|
1261
|
+
max_length: 255,
|
|
1262
|
+
label: { tag: "plain_text", content: "目录名" },
|
|
1263
|
+
placeholder: { tag: "plain_text", content: "请输入目录名" },
|
|
1264
|
+
},
|
|
1265
|
+
{
|
|
1266
|
+
tag: "button",
|
|
1267
|
+
name: "directory_new_folder_create",
|
|
1268
|
+
text: { tag: "plain_text", content: "Create" },
|
|
1269
|
+
type: "primary",
|
|
1270
|
+
width: "fill",
|
|
1271
|
+
action_type: "form_submit",
|
|
1272
|
+
value: actionValue,
|
|
1273
|
+
},
|
|
1274
|
+
],
|
|
1275
|
+
},
|
|
1276
|
+
taskActionRow([{
|
|
1277
|
+
text: "Back",
|
|
1278
|
+
value: {
|
|
1279
|
+
action: "directory_new_folder_cancel",
|
|
1280
|
+
directory: view.directory,
|
|
1281
|
+
contextKey: view.contextKey,
|
|
1282
|
+
page: String(view.page),
|
|
1283
|
+
},
|
|
1284
|
+
}]),
|
|
1285
|
+
]);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1194
1288
|
renderResetHistoryCard(view: ResetHistoryCardView): Record<string, unknown> {
|
|
1195
1289
|
const elements: Record<string, unknown>[] = [
|
|
1196
1290
|
{
|
|
@@ -1958,10 +2052,26 @@ function renderToolDetails(tool: ToolState, projectCwd?: string): string {
|
|
|
1958
2052
|
? unwrapShellCommand(normalizedCommand) ?? normalizedCommand
|
|
1959
2053
|
: normalizedCommand;
|
|
1960
2054
|
const commandText = truncateText(displayCommand, 600);
|
|
1961
|
-
const resultText = result ? truncateToolResult(
|
|
2055
|
+
const resultText = result ? truncateToolResult(normalizeTerminalOutput(result).trim()) : undefined;
|
|
1962
2056
|
return codeBlock([`$ ${commandText}`, resultText].filter((part): part is string => part !== undefined).join("\n"), 2_003);
|
|
1963
2057
|
}
|
|
1964
2058
|
|
|
2059
|
+
function normalizeTerminalOutput(value: string): string {
|
|
2060
|
+
const clean = stripAnsi(value).replace(/\r\n/g, "\n");
|
|
2061
|
+
return clean
|
|
2062
|
+
.split("\n")
|
|
2063
|
+
.map((line) => {
|
|
2064
|
+
if (!line.includes("\r")) return line;
|
|
2065
|
+
const updates = line.split("\r");
|
|
2066
|
+
for (let index = updates.length - 1; index >= 0; index -= 1) {
|
|
2067
|
+
const update = updates[index];
|
|
2068
|
+
if (update) return update;
|
|
2069
|
+
}
|
|
2070
|
+
return "";
|
|
2071
|
+
})
|
|
2072
|
+
.join("\n");
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1965
2075
|
function truncateToolResult(result: string): string {
|
|
1966
2076
|
const maxLength = 900;
|
|
1967
2077
|
const marker = "\n...\n";
|
|
@@ -2048,6 +2158,23 @@ function formatTurnDuration(durationMs: number): string {
|
|
|
2048
2158
|
return `${seconds}s`;
|
|
2049
2159
|
}
|
|
2050
2160
|
|
|
2161
|
+
function shellCommandStatus(view: ShellCommandCardView): {
|
|
2162
|
+
title: string;
|
|
2163
|
+
label: string;
|
|
2164
|
+
template: string;
|
|
2165
|
+
} {
|
|
2166
|
+
if (view.status === "running") {
|
|
2167
|
+
return { title: "正在执行命令", label: "运行中", template: "blue" };
|
|
2168
|
+
}
|
|
2169
|
+
if (view.status === "timed_out") {
|
|
2170
|
+
return { title: "命令执行超时", label: "已超时(120s)", template: "orange" };
|
|
2171
|
+
}
|
|
2172
|
+
if (view.status === "completed") {
|
|
2173
|
+
return { title: "命令执行完成", label: `退出码 ${view.exitCode ?? 0}`, template: "green" };
|
|
2174
|
+
}
|
|
2175
|
+
return { title: "命令执行失败", label: `退出码 ${view.exitCode ?? "未知"}`, template: "red" };
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2051
2178
|
function unwrapShellCommand(command: string): string | undefined {
|
|
2052
2179
|
return unwrapPowerShellCommand(command) ?? unwrapPosixShellCommand(command);
|
|
2053
2180
|
}
|
|
@@ -2409,13 +2536,25 @@ function resetHistoryEntryRow(
|
|
|
2409
2536
|
};
|
|
2410
2537
|
}
|
|
2411
2538
|
|
|
2412
|
-
function taskActionRow(
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2539
|
+
function taskActionRow(
|
|
2540
|
+
actions: TaskListCardAction[],
|
|
2541
|
+
trailingText?: string,
|
|
2542
|
+
dotSeparated = false,
|
|
2543
|
+
): Record<string, unknown> {
|
|
2544
|
+
const columns = actions.flatMap((action, index) => [
|
|
2545
|
+
...(dotSeparated && index > 0 ? [{
|
|
2546
|
+
tag: "column",
|
|
2547
|
+
width: "auto",
|
|
2548
|
+
vertical_align: "center",
|
|
2549
|
+
elements: [markdown("<font color='grey'>·</font>")],
|
|
2550
|
+
}] : []),
|
|
2551
|
+
{
|
|
2552
|
+
tag: "column",
|
|
2553
|
+
width: "auto",
|
|
2554
|
+
vertical_align: "center",
|
|
2555
|
+
elements: [taskActionElement(action)],
|
|
2556
|
+
},
|
|
2557
|
+
]);
|
|
2419
2558
|
if (trailingText) {
|
|
2420
2559
|
columns.push({
|
|
2421
2560
|
tag: "column",
|
|
@@ -2453,12 +2592,6 @@ function directoryBrowserEntryRow(entry: DirectoryBrowserCardEntry): Record<stri
|
|
|
2453
2592
|
? [taskActionElement({ ...entry.openAction, text: label })]
|
|
2454
2593
|
: [markdown(escapeCardHtml(label))],
|
|
2455
2594
|
},
|
|
2456
|
-
...(entry.actions ?? []).map((action) => ({
|
|
2457
|
-
tag: "column",
|
|
2458
|
-
width: "auto",
|
|
2459
|
-
vertical_align: "center",
|
|
2460
|
-
elements: [taskActionElement(action)],
|
|
2461
|
-
})),
|
|
2462
2595
|
],
|
|
2463
2596
|
};
|
|
2464
2597
|
}
|
|
@@ -4,7 +4,7 @@ import { threadContextKey } from "./contextKey.js";
|
|
|
4
4
|
import { resolveFeishuBotOpenId } from "./FeishuBotIdentity.js";
|
|
5
5
|
import { normalizeFeishuPostText } from "./InboundText.js";
|
|
6
6
|
import { allowsFeishuUser } from "./ownerAccess.js";
|
|
7
|
-
import type { ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
|
|
7
|
+
import type { CardAction, ChatUpdatedEvent, FeishuEventHandler, IncomingMessage } from "./types.js";
|
|
8
8
|
|
|
9
9
|
type BotOpenIdResolver = (appId: string, appSecret: string) => Promise<string>;
|
|
10
10
|
type BotOpenIdListener = (botOpenId: string) => void;
|
|
@@ -89,6 +89,7 @@ export class FeishuConnector {
|
|
|
89
89
|
});
|
|
90
90
|
},
|
|
91
91
|
"card.action.trigger": async (data: unknown) => {
|
|
92
|
+
const receivedAt = Date.now();
|
|
92
93
|
const operatorOpenId = getFeishuEvent(data)?.operator?.open_id;
|
|
93
94
|
if (!allowsFeishuUser(this.config, operatorOpenId)) {
|
|
94
95
|
this.logger.debug(
|
|
@@ -103,11 +104,11 @@ export class FeishuConnector {
|
|
|
103
104
|
return {};
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
this.deferCardAction(action, receivedAt);
|
|
108
|
+
this.logger.debug(
|
|
109
|
+
{ actionId: action.actionId, messageId: action.messageId, responsePreparedInMs: Date.now() - receivedAt },
|
|
110
|
+
"Prepared the Feishu card action acknowledgement.",
|
|
111
|
+
);
|
|
111
112
|
return {
|
|
112
113
|
toast: {
|
|
113
114
|
type: "success",
|
|
@@ -123,6 +124,27 @@ export class FeishuConnector {
|
|
|
123
124
|
await wsClient.start({ eventDispatcher });
|
|
124
125
|
this.logger.info("Feishu WebSocket connector started.");
|
|
125
126
|
}
|
|
127
|
+
|
|
128
|
+
private deferCardAction(action: CardAction, receivedAt: number): void {
|
|
129
|
+
setImmediate(() => {
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
this.logger.debug(
|
|
132
|
+
{ actionId: action.actionId, messageId: action.messageId, dispatchDelayMs: startedAt - receivedAt },
|
|
133
|
+
"Started deferred Feishu card action handling.",
|
|
134
|
+
);
|
|
135
|
+
void Promise.resolve()
|
|
136
|
+
.then(() => this.handler.onCardAction(action))
|
|
137
|
+
.then(() => {
|
|
138
|
+
this.logger.debug(
|
|
139
|
+
{ actionId: action.actionId, messageId: action.messageId, durationMs: Date.now() - startedAt },
|
|
140
|
+
"Completed Feishu card action handling.",
|
|
141
|
+
);
|
|
142
|
+
})
|
|
143
|
+
.catch((error: unknown) => {
|
|
144
|
+
this.logger.error({ error, actionId: action.actionId }, "Failed to handle Feishu card action.");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
function toChatUpdatedEvent(data: unknown): ChatUpdatedEvent | undefined {
|
|
@@ -305,7 +327,10 @@ function getCardActionValue(action: Record<string, unknown>): Record<string, unk
|
|
|
305
327
|
const option = parseJsonObject(action.option);
|
|
306
328
|
if (Object.keys(option).length > 0) return option;
|
|
307
329
|
}
|
|
308
|
-
|
|
330
|
+
const value = isRecord(action.value) ? action.value : {};
|
|
331
|
+
return isRecord(action.form_value)
|
|
332
|
+
? { ...value, formValue: action.form_value }
|
|
333
|
+
: value;
|
|
309
334
|
}
|
|
310
335
|
|
|
311
336
|
function getEventId(data: unknown): string | undefined {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
1
3
|
interface FenceState {
|
|
2
4
|
indent: string;
|
|
3
5
|
marker: "`" | "~";
|
|
@@ -9,7 +11,7 @@ const FENCE_OPENER = /^([ \t]*)((?:`{3,})|(?:~{3,}))(.*)$/;
|
|
|
9
11
|
const MARKDOWN_LINK = /(!?)\[([^\]\r\n]*)\]\((<?[^)\r\n]+>?)\)/g;
|
|
10
12
|
const LOCAL_FILE_LINE_REFERENCE = /(:\d+(?::\d+)?)$/;
|
|
11
13
|
|
|
12
|
-
export function normalizeFeishuMarkdown(markdown: string): string {
|
|
14
|
+
export function normalizeFeishuMarkdown(markdown: string, projectCwd?: string): string {
|
|
13
15
|
const lines = markdown.match(/[^\r\n]*(?:\r\n|\n)|[^\r\n]+$/g) ?? [];
|
|
14
16
|
let fence: FenceState | undefined;
|
|
15
17
|
|
|
@@ -19,7 +21,7 @@ export function normalizeFeishuMarkdown(markdown: string): string {
|
|
|
19
21
|
|
|
20
22
|
if (!fence) {
|
|
21
23
|
const opener = FENCE_OPENER.exec(body);
|
|
22
|
-
if (!opener?.[2]) return `${includeLocalFileLineReferences(body)}${ending}`;
|
|
24
|
+
if (!opener?.[2]) return `${includeLocalFileLineReferences(body, projectCwd)}${ending}`;
|
|
23
25
|
|
|
24
26
|
const indent = opener[1] ?? "";
|
|
25
27
|
const delimiter = opener[2];
|
|
@@ -40,7 +42,7 @@ export function normalizeFeishuMarkdown(markdown: string): string {
|
|
|
40
42
|
}).join("");
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
function includeLocalFileLineReferences(markdown: string): string {
|
|
45
|
+
function includeLocalFileLineReferences(markdown: string, projectCwd?: string): string {
|
|
44
46
|
return markdown.replace(MARKDOWN_LINK, (link, imageMarker: string, label: string, rawTarget: string) => {
|
|
45
47
|
if (imageMarker) return link;
|
|
46
48
|
|
|
@@ -48,11 +50,68 @@ function includeLocalFileLineReferences(markdown: string): string {
|
|
|
48
50
|
if (!isLocalFileTarget(target)) return link;
|
|
49
51
|
|
|
50
52
|
const reference = LOCAL_FILE_LINE_REFERENCE.exec(target)?.[1];
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
const projectPathLabel = projectCwd ? projectFilePathLabel(target, projectCwd, reference) : undefined;
|
|
54
|
+
const normalizedLabel = projectPathLabel
|
|
55
|
+
?? (reference && !label.endsWith(reference) ? `${label}${reference}` : label);
|
|
56
|
+
return normalizedLabel === label ? link : `[${normalizedLabel}](${rawTarget})`;
|
|
53
57
|
});
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
function projectFilePathLabel(
|
|
61
|
+
target: string,
|
|
62
|
+
projectCwd: string,
|
|
63
|
+
reference: string | undefined,
|
|
64
|
+
): string | undefined {
|
|
65
|
+
const targetWithoutReference = reference ? target.slice(0, -reference.length) : target;
|
|
66
|
+
const filePath = localFilePath(targetWithoutReference);
|
|
67
|
+
if (!filePath) return undefined;
|
|
68
|
+
if (!isInsideProject(filePath, projectCwd)) {
|
|
69
|
+
return `${displayAbsolutePath(filePath)}${reference ?? ""}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pathApi = usesWindowsPaths(filePath, projectCwd) ? path.win32 : path.posix;
|
|
73
|
+
const parsed = pathApi.parse(filePath);
|
|
74
|
+
if (Array.from(parsed.name).length > 5) return undefined;
|
|
75
|
+
|
|
76
|
+
const parentName = pathApi.basename(pathApi.dirname(filePath));
|
|
77
|
+
if (!parentName) return undefined;
|
|
78
|
+
return `${parentName}${pathApi.sep}${parsed.base}${reference ?? ""}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function localFilePath(target: string): string | undefined {
|
|
82
|
+
if (!/^file:\/\//i.test(target)) return normalizeWindowsDrivePrefix(target);
|
|
83
|
+
|
|
84
|
+
let value = target.slice("file://".length);
|
|
85
|
+
try {
|
|
86
|
+
value = decodeURIComponent(value);
|
|
87
|
+
} catch {
|
|
88
|
+
// Keep malformed percent escapes visible instead of dropping the link.
|
|
89
|
+
}
|
|
90
|
+
if (/^\/[a-z]:[\\/]/i.test(value)) return value.slice(1);
|
|
91
|
+
if (value.startsWith("/")) return value;
|
|
92
|
+
return `//${value}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeWindowsDrivePrefix(value: string): string {
|
|
96
|
+
return /^\/[a-z]:[\\/]/i.test(value) ? value.slice(1) : value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isInsideProject(filePath: string, projectCwd: string): boolean {
|
|
100
|
+
const pathApi = usesWindowsPaths(filePath, projectCwd) ? path.win32 : path.posix;
|
|
101
|
+
const relative = pathApi.relative(pathApi.resolve(projectCwd), pathApi.resolve(filePath));
|
|
102
|
+
return relative === ""
|
|
103
|
+
|| (relative !== ".." && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function displayAbsolutePath(filePath: string): string {
|
|
107
|
+
if (!usesWindowsPaths(filePath)) return path.posix.normalize(filePath);
|
|
108
|
+
return path.win32.normalize(filePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function usesWindowsPaths(...values: string[]): boolean {
|
|
112
|
+
return values.some((value) => /^\/?[a-z]:[\\/]/i.test(value) || /^\\\\/.test(value));
|
|
113
|
+
}
|
|
114
|
+
|
|
56
115
|
function unwrapMarkdownTarget(target: string): string {
|
|
57
116
|
return target.startsWith("<") && target.endsWith(">") ? target.slice(1, -1) : target;
|
|
58
117
|
}
|
|
@@ -7,6 +7,7 @@ import type { AppConfig } from "../config/schema.js";
|
|
|
7
7
|
import { normalizeFeishuMarkdown } from "./FeishuMarkdown.js";
|
|
8
8
|
import { LOCAL_CARD_IMAGE_PATH } from "./LocalCardImage.js";
|
|
9
9
|
import { renderMarkdownWithLocalImages } from "./LocalImageMarkdown.js";
|
|
10
|
+
import { splitFinalResponseBranding } from "./FinalResponseBranding.js";
|
|
10
11
|
import {
|
|
11
12
|
renderMergedForwardPrompt,
|
|
12
13
|
renderReferencedMessage,
|
|
@@ -101,6 +102,7 @@ export class FeishuMessageClient implements FeishuOutbound {
|
|
|
101
102
|
expiresAt: number;
|
|
102
103
|
};
|
|
103
104
|
private readonly sendQueues = new Map<string, Promise<void>>();
|
|
105
|
+
private readonly cardUpdateQueues = new Map<string, Promise<void>>();
|
|
104
106
|
private readonly lastSendAt = new Map<string, number>();
|
|
105
107
|
private readonly imageUploads = new Map<string, { mtimeMs: number; upload: Promise<string> }>();
|
|
106
108
|
private readonly imageDownloads = new Map<string, Promise<string>>();
|
|
@@ -288,11 +290,7 @@ export class FeishuMessageClient implements FeishuOutbound {
|
|
|
288
290
|
|
|
289
291
|
async sendMarkdown(contextKey: string, markdown: string, idempotencyKey?: string): Promise<string | undefined> {
|
|
290
292
|
const normalizedMarkdown = normalizeFeishuMarkdown(markdown);
|
|
291
|
-
const elements = await
|
|
292
|
-
normalizedMarkdown,
|
|
293
|
-
(filePath) => this.uploadImageCached(filePath),
|
|
294
|
-
(error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
|
|
295
|
-
);
|
|
293
|
+
const elements = await this.renderFinalAnswerElements(normalizedMarkdown);
|
|
296
294
|
return this.sendMessage(
|
|
297
295
|
contextKey,
|
|
298
296
|
"interactive",
|
|
@@ -334,11 +332,7 @@ export class FeishuMessageClient implements FeishuOutbound {
|
|
|
334
332
|
idempotencyKey?: string,
|
|
335
333
|
): Promise<string | undefined> {
|
|
336
334
|
const normalizedMarkdown = normalizeFeishuMarkdown(markdown);
|
|
337
|
-
const elements = await
|
|
338
|
-
normalizedMarkdown,
|
|
339
|
-
(filePath) => this.uploadImageCached(filePath),
|
|
340
|
-
(error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
|
|
341
|
-
);
|
|
335
|
+
const elements = await this.renderFinalAnswerElements(normalizedMarkdown);
|
|
342
336
|
return this.replyMessage(
|
|
343
337
|
contextKey,
|
|
344
338
|
target,
|
|
@@ -364,9 +358,28 @@ export class FeishuMessageClient implements FeishuOutbound {
|
|
|
364
358
|
);
|
|
365
359
|
}
|
|
366
360
|
|
|
361
|
+
private async renderFinalAnswerElements(markdown: string): Promise<Array<Record<string, unknown>>> {
|
|
362
|
+
const { content, branding } = splitFinalResponseBranding(markdown);
|
|
363
|
+
const elements = content
|
|
364
|
+
? await renderMarkdownWithLocalImages(
|
|
365
|
+
content,
|
|
366
|
+
(filePath) => this.uploadImageCached(filePath),
|
|
367
|
+
(error, filePath) => this.logger.warn({ error, filePath }, "Failed to upload local image to Feishu."),
|
|
368
|
+
)
|
|
369
|
+
: [];
|
|
370
|
+
if (!branding) return elements;
|
|
371
|
+
return [
|
|
372
|
+
...elements,
|
|
373
|
+
{ tag: "hr" },
|
|
374
|
+
{ tag: "markdown", content: branding, text_size: "notation" },
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
|
|
367
378
|
async updateInteractiveCard(messageId: string, card: Record<string, unknown>): Promise<void> {
|
|
368
|
-
|
|
369
|
-
|
|
379
|
+
await this.enqueueCardUpdate(messageId, async () => {
|
|
380
|
+
const preparedCard = await this.prepareInteractiveCard(card);
|
|
381
|
+
await this.updateInteractiveCardNow(messageId, preparedCard);
|
|
382
|
+
});
|
|
370
383
|
}
|
|
371
384
|
|
|
372
385
|
private async updateInteractiveCardNow(
|
|
@@ -626,6 +639,22 @@ export class FeishuMessageClient implements FeishuOutbound {
|
|
|
626
639
|
return queued;
|
|
627
640
|
}
|
|
628
641
|
|
|
642
|
+
private enqueueCardUpdate(messageId: string, task: () => Promise<void>): Promise<void> {
|
|
643
|
+
const previous = this.cardUpdateQueues.get(messageId) ?? Promise.resolve();
|
|
644
|
+
const queued = previous.catch(() => undefined).then(task);
|
|
645
|
+
const stored = queued.then(
|
|
646
|
+
() => undefined,
|
|
647
|
+
() => undefined,
|
|
648
|
+
);
|
|
649
|
+
this.cardUpdateQueues.set(messageId, stored);
|
|
650
|
+
stored.finally(() => {
|
|
651
|
+
if (this.cardUpdateQueues.get(messageId) === stored) {
|
|
652
|
+
this.cardUpdateQueues.delete(messageId);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
return queued;
|
|
656
|
+
}
|
|
657
|
+
|
|
629
658
|
private async getTenantAccessToken(): Promise<string> {
|
|
630
659
|
const now = Date.now();
|
|
631
660
|
if (this.token && this.token.expiresAt > now + 60_000) {
|
|
@@ -8,7 +8,11 @@ import { splitMarkdown } from "../presentation/splitMarkdown.js";
|
|
|
8
8
|
import type { TurnViewState } from "../presentation/turnViewTypes.js";
|
|
9
9
|
import { CardRenderer } from "./CardRenderer.js";
|
|
10
10
|
import { CardUpdateScheduler } from "./CardUpdateScheduler.js";
|
|
11
|
+
import { normalizeFeishuMarkdown } from "./FeishuMarkdown.js";
|
|
11
12
|
import type { FeishuOutbound, MessageReplyTarget } from "./types.js";
|
|
13
|
+
import {
|
|
14
|
+
FINAL_RESPONSE_BRANDING_BLOCK,
|
|
15
|
+
} from "./FinalResponseBranding.js";
|
|
12
16
|
import { createId } from "../utils/id.js";
|
|
13
17
|
import { createHash } from "node:crypto";
|
|
14
18
|
|
|
@@ -50,7 +54,6 @@ export interface FeishuTurnPresenterOptions {
|
|
|
50
54
|
|
|
51
55
|
const MAX_FINAL_TABLES_PER_CARD = 5;
|
|
52
56
|
const DEFAULT_ELAPSED_UPDATE_INTERVAL_MS = 3_000;
|
|
53
|
-
const FINAL_RESPONSE_BRANDING = "> Powered by [AgentBot](https://github.com/keyou/agent-bot)";
|
|
54
57
|
const FINAL_RESPONSE_BRANDING_MIN_LENGTH = 1_000;
|
|
55
58
|
|
|
56
59
|
interface TurnEntry {
|
|
@@ -536,7 +539,7 @@ export class FeishuTurnPresenter {
|
|
|
536
539
|
if (delivery?.finalDelivered || !state.finalResponse) return;
|
|
537
540
|
const finalChunkLength = this.options.finalChunkLength ?? 4_000;
|
|
538
541
|
const chunks = brandedFinalChunks(
|
|
539
|
-
state.finalResponse,
|
|
542
|
+
normalizeFeishuMarkdown(state.finalResponse, state.projectCwd),
|
|
540
543
|
finalChunkLength,
|
|
541
544
|
);
|
|
542
545
|
const messageIds = [...(delivery?.finalMessageIds ?? [])];
|
|
@@ -582,17 +585,17 @@ function brandedFinalChunks(response: string, maxLength: number): string[] {
|
|
|
582
585
|
|
|
583
586
|
const last = chunks.pop();
|
|
584
587
|
if (last === undefined) return chunks;
|
|
585
|
-
const reservedLength =
|
|
588
|
+
const reservedLength = FINAL_RESPONSE_BRANDING_BLOCK.length + 2;
|
|
586
589
|
const brandedContentLimit = maxLength - reservedLength;
|
|
587
|
-
if (brandedContentLimit < 32) return [...chunks, last,
|
|
590
|
+
if (brandedContentLimit < 32) return [...chunks, last, FINAL_RESPONSE_BRANDING_BLOCK];
|
|
588
591
|
|
|
589
592
|
const lastParts = splitMarkdown(last, brandedContentLimit, MAX_FINAL_TABLES_PER_CARD);
|
|
590
593
|
const lastContent = lastParts.pop();
|
|
591
594
|
if (lastContent === undefined || lastContent.length > brandedContentLimit) {
|
|
592
|
-
return [...chunks, ...lastParts, lastContent ?? last,
|
|
595
|
+
return [...chunks, ...lastParts, lastContent ?? last, FINAL_RESPONSE_BRANDING_BLOCK];
|
|
593
596
|
}
|
|
594
597
|
const separator = lastContent.endsWith("\n\n") ? "" : lastContent.endsWith("\n") ? "\n" : "\n\n";
|
|
595
|
-
return [...chunks, ...lastParts, `${lastContent}${separator}${
|
|
598
|
+
return [...chunks, ...lastParts, `${lastContent}${separator}${FINAL_RESPONSE_BRANDING_BLOCK}`];
|
|
596
599
|
}
|
|
597
600
|
|
|
598
601
|
function eventPriority(event: AgentEvent): "normal" | "critical" {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const FINAL_RESPONSE_BRANDING = "> Powered by [AgentBot](https://github.com/keyou/agent-bot)";
|
|
2
|
+
export const FINAL_RESPONSE_BRANDING_BLOCK = `----\n\n${FINAL_RESPONSE_BRANDING}`;
|
|
3
|
+
|
|
4
|
+
export function splitFinalResponseBranding(markdown: string): {
|
|
5
|
+
content: string;
|
|
6
|
+
branding?: string;
|
|
7
|
+
} {
|
|
8
|
+
if (markdown === FINAL_RESPONSE_BRANDING_BLOCK) {
|
|
9
|
+
return { content: "", branding: FINAL_RESPONSE_BRANDING };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const suffix = `\n\n${FINAL_RESPONSE_BRANDING_BLOCK}`;
|
|
13
|
+
if (!markdown.endsWith(suffix)) return { content: markdown };
|
|
14
|
+
return {
|
|
15
|
+
content: markdown.slice(0, -suffix.length),
|
|
16
|
+
branding: FINAL_RESPONSE_BRANDING,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Writable } from "node:stream";
|
|
4
|
+
|
|
5
|
+
interface DailyLogStreamOptions {
|
|
6
|
+
clock?: () => Date;
|
|
7
|
+
mode?: number;
|
|
8
|
+
beforeOpen?: (filePath: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class DailyLogStream extends Writable {
|
|
12
|
+
private currentPath?: string;
|
|
13
|
+
private lastPath?: string;
|
|
14
|
+
private currentStream?: fs.WriteStream;
|
|
15
|
+
private readonly clock: () => Date;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly basePath: string,
|
|
19
|
+
private readonly options: DailyLogStreamOptions = {},
|
|
20
|
+
) {
|
|
21
|
+
super();
|
|
22
|
+
this.clock = options.clock ?? (() => new Date());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get filePath(): string | undefined {
|
|
26
|
+
return this.currentPath ?? this.lastPath;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override _write(
|
|
30
|
+
chunk: string | Buffer,
|
|
31
|
+
encoding: BufferEncoding,
|
|
32
|
+
callback: (error?: Error | null) => void,
|
|
33
|
+
): void {
|
|
34
|
+
let stream: fs.WriteStream;
|
|
35
|
+
try {
|
|
36
|
+
stream = this.streamFor(this.clock());
|
|
37
|
+
} catch (error) {
|
|
38
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
stream.write(chunk, encoding, callback);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override _final(callback: (error?: Error | null) => void): void {
|
|
45
|
+
const stream = this.currentStream;
|
|
46
|
+
this.currentStream = undefined;
|
|
47
|
+
this.currentPath = undefined;
|
|
48
|
+
if (!stream) {
|
|
49
|
+
callback();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
stream.end(callback);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
|
|
56
|
+
this.currentStream?.destroy();
|
|
57
|
+
this.currentStream = undefined;
|
|
58
|
+
this.currentPath = undefined;
|
|
59
|
+
callback(error);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private streamFor(date: Date): fs.WriteStream {
|
|
63
|
+
const targetPath = dailyLogPath(this.basePath, date);
|
|
64
|
+
if (this.currentStream && this.currentPath === targetPath) return this.currentStream;
|
|
65
|
+
|
|
66
|
+
const previous = this.currentStream;
|
|
67
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
68
|
+
this.options.beforeOpen?.(targetPath);
|
|
69
|
+
this.currentPath = targetPath;
|
|
70
|
+
this.lastPath = targetPath;
|
|
71
|
+
this.currentStream = fs.createWriteStream(targetPath, {
|
|
72
|
+
flags: "a",
|
|
73
|
+
...(this.options.mode === undefined ? {} : { mode: this.options.mode }),
|
|
74
|
+
});
|
|
75
|
+
previous?.end();
|
|
76
|
+
return this.currentStream;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function dailyLogPath(basePath: string, date: Date): string {
|
|
81
|
+
const parsed = path.parse(basePath);
|
|
82
|
+
const dateStamp = [
|
|
83
|
+
date.getFullYear(),
|
|
84
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
85
|
+
String(date.getDate()).padStart(2, "0"),
|
|
86
|
+
].join("-");
|
|
87
|
+
return path.join(parsed.dir, `${parsed.name}.${dateStamp}${parsed.ext}`);
|
|
88
|
+
}
|