lark-remote 0.4.0 → 0.4.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 +35 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/inbound-media.d.ts +13 -20
- package/dist/bridge/inbound-media.js +107 -93
- package/dist/bridge/index.d.ts +6 -6
- package/dist/bridge/index.js +6 -12
- package/dist/card/approval-render.js +9 -8
- package/dist/cli.js +5 -1
- package/dist/config/dir.d.ts +9 -0
- package/dist/config/dir.js +69 -0
- package/dist/config/index.d.ts +22 -3
- package/dist/config/index.js +25 -3
- package/dist/connector/index.d.ts +22 -1
- package/dist/connector/index.js +79 -14
- package/dist/inbound/event.d.ts +41 -0
- package/dist/inbound/event.js +11 -0
- package/dist/inbound/placeholder.d.ts +26 -0
- package/dist/inbound/placeholder.js +149 -0
- package/dist/inbound/prompt.d.ts +20 -0
- package/dist/inbound/prompt.js +47 -0
- package/dist/inbound/receipt.d.ts +28 -0
- package/dist/inbound/receipt.js +62 -0
- package/dist/inbound/turn-assembler.d.ts +63 -0
- package/dist/inbound/turn-assembler.js +233 -0
- package/dist/inbound/turn.d.ts +48 -0
- package/dist/inbound/turn.js +10 -0
- package/dist/index.js +96 -71
- package/dist/router/card-helpers.d.ts +38 -2
- package/dist/router/card-helpers.js +79 -2
- package/dist/router/config/opencode.d.ts +1 -1
- package/dist/router/config/opencode.js +11 -0
- package/dist/router/index.d.ts +56 -6
- package/dist/router/index.js +256 -78
- package/dist/runner/common/connection-based-runner.js +6 -2
- package/dist/runner/dsh/fake-dsh-server.d.ts +6 -0
- package/dist/runner/dsh/fake-dsh-server.js +6 -0
- package/dist/runner/kimi/acp/runner.d.ts +1 -1
- package/dist/runner/kimi/acp/runner.js +1 -1
- package/dist/runner/opencode/acp/runner.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt 组装(结构化 `<attachments>` 标签块,设计依据见
|
|
3
|
+
* `docs/zh/architecture/inbound-unified-input-design.md`)。
|
|
4
|
+
*
|
|
5
|
+
* 输出形态:用户文本(按到达顺序、原样)+ 空行 + 附件块。
|
|
6
|
+
*
|
|
7
|
+
* <用户文本,原样>
|
|
8
|
+
*
|
|
9
|
+
* <attachments>
|
|
10
|
+
* <file path="/abs/x.png" kind="image"/>
|
|
11
|
+
* <file path="/abs/a.mp4" kind="video" duration="80.9s" name="a.mp4"/>
|
|
12
|
+
* </attachments>
|
|
13
|
+
*
|
|
14
|
+
* 约定:无附件时 prompt 就是纯文本(与旧行为兼容,缩小回归面);**绝不输出 file_key**
|
|
15
|
+
* (对 agent 无意义,且是敏感凭据)。
|
|
16
|
+
*/
|
|
17
|
+
import type { InboundTurn } from './turn.js';
|
|
18
|
+
/** 时长格式化(对齐 SDK 的 formatDuration:80.9s / 3s / 500ms)。 */
|
|
19
|
+
export declare function formatDuration(ms: number): string | undefined;
|
|
20
|
+
export declare function buildPrompt(turn: InboundTurn): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt 组装(结构化 `<attachments>` 标签块,设计依据见
|
|
3
|
+
* `docs/zh/architecture/inbound-unified-input-design.md`)。
|
|
4
|
+
*
|
|
5
|
+
* 输出形态:用户文本(按到达顺序、原样)+ 空行 + 附件块。
|
|
6
|
+
*
|
|
7
|
+
* <用户文本,原样>
|
|
8
|
+
*
|
|
9
|
+
* <attachments>
|
|
10
|
+
* <file path="/abs/x.png" kind="image"/>
|
|
11
|
+
* <file path="/abs/a.mp4" kind="video" duration="80.9s" name="a.mp4"/>
|
|
12
|
+
* </attachments>
|
|
13
|
+
*
|
|
14
|
+
* 约定:无附件时 prompt 就是纯文本(与旧行为兼容,缩小回归面);**绝不输出 file_key**
|
|
15
|
+
* (对 agent 无意义,且是敏感凭据)。
|
|
16
|
+
*/
|
|
17
|
+
/** 属性值转义:只处理会破坏引号包裹的字符。 */
|
|
18
|
+
function escapeAttr(value) {
|
|
19
|
+
return value.replace(/"/g, '"');
|
|
20
|
+
}
|
|
21
|
+
/** 时长格式化(对齐 SDK 的 formatDuration:80.9s / 3s / 500ms)。 */
|
|
22
|
+
export function formatDuration(ms) {
|
|
23
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (ms < 1000)
|
|
26
|
+
return `${Math.round(ms)}ms`;
|
|
27
|
+
if (ms % 1000 === 0)
|
|
28
|
+
return `${ms / 1000}s`;
|
|
29
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
30
|
+
}
|
|
31
|
+
export function buildPrompt(turn) {
|
|
32
|
+
const text = turn.texts.join('\n');
|
|
33
|
+
if (turn.attachments.length === 0)
|
|
34
|
+
return text;
|
|
35
|
+
const lines = turn.attachments.map((att) => {
|
|
36
|
+
let line = ` <file path="${escapeAttr(att.path)}" kind="${att.kind}"`;
|
|
37
|
+
const duration = att.durationMs === undefined ? undefined : formatDuration(att.durationMs);
|
|
38
|
+
if (duration)
|
|
39
|
+
line += ` duration="${duration}"`;
|
|
40
|
+
if (att.originalName)
|
|
41
|
+
line += ` name="${escapeAttr(att.originalName)}"`;
|
|
42
|
+
return `${line}/>`;
|
|
43
|
+
});
|
|
44
|
+
const block = `<attachments>\n${lines.join('\n')}\n</attachments>`;
|
|
45
|
+
return text === '' ? block : `${text}\n\n${block}`;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=prompt.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 入站回执文案(设计依据见 `docs/zh/architecture/inbound-unified-input-design.md`)。
|
|
3
|
+
*
|
|
4
|
+
* 两类回执合并成一条:
|
|
5
|
+
* 1. 无文本纯附件(决策 2):不自动起 turn,只回已保存路径 + 引导语;
|
|
6
|
+
* 2. 拿不到的东西(不支持类型 / 超限 / 下载失败 / 媒体保存已关闭):明确告知,
|
|
7
|
+
* 而不是把占位符塞给 agent。
|
|
8
|
+
*
|
|
9
|
+
* 用户可见文案指代本程序时只能写 `lark-remote`(AGENTS.md 命名纪律)。
|
|
10
|
+
*/
|
|
11
|
+
import type { PlaceholderKind } from './placeholder.js';
|
|
12
|
+
import type { RejectedItem } from './turn.js';
|
|
13
|
+
/** 单条回执最多列出的文件数;超出折叠为 "… 等 N 个文件"(沿用旧提示口径)。 */
|
|
14
|
+
export declare const MAX_PATHS_IN_RECEIPT = 10;
|
|
15
|
+
/** 「不支持」回执句式(§5.7 表:不支持类型统一「暂不支持 X」)。 */
|
|
16
|
+
export declare function unsupportedReason(kind: PlaceholderKind): string;
|
|
17
|
+
export interface ReceiptInput {
|
|
18
|
+
/** 本次已落盘的绝对路径(仅无文本场景展示)。 */
|
|
19
|
+
saved: string[];
|
|
20
|
+
/** 未处理项(不支持 / 超限 / 失败)。 */
|
|
21
|
+
rejected: RejectedItem[];
|
|
22
|
+
/** 本次没有用户文本:附上「说一句话我就开始处理」引导。 */
|
|
23
|
+
startWorkHint: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 生成回执文案;没有任何需要告知的内容时返回 undefined(调用方不发消息)。
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildReceipt(input: ReceiptInput): string | undefined;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 入站回执文案(设计依据见 `docs/zh/architecture/inbound-unified-input-design.md`)。
|
|
3
|
+
*
|
|
4
|
+
* 两类回执合并成一条:
|
|
5
|
+
* 1. 无文本纯附件(决策 2):不自动起 turn,只回已保存路径 + 引导语;
|
|
6
|
+
* 2. 拿不到的东西(不支持类型 / 超限 / 下载失败 / 媒体保存已关闭):明确告知,
|
|
7
|
+
* 而不是把占位符塞给 agent。
|
|
8
|
+
*
|
|
9
|
+
* 用户可见文案指代本程序时只能写 `lark-remote`(AGENTS.md 命名纪律)。
|
|
10
|
+
*/
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
/** 单条回执最多列出的文件数;超出折叠为 "… 等 N 个文件"(沿用旧提示口径)。 */
|
|
13
|
+
export const MAX_PATHS_IN_RECEIPT = 10;
|
|
14
|
+
/** 占位符种类 → 面向人的名称。 */
|
|
15
|
+
const PLACEHOLDER_LABELS = {
|
|
16
|
+
image: '图片',
|
|
17
|
+
file: '文件',
|
|
18
|
+
video: '视频',
|
|
19
|
+
audio: '语音',
|
|
20
|
+
sticker: '表情',
|
|
21
|
+
share: '名片/群分享',
|
|
22
|
+
location: '位置',
|
|
23
|
+
folder: '文件夹',
|
|
24
|
+
vote: '投票',
|
|
25
|
+
todo: '待办',
|
|
26
|
+
meeting: '视频会议',
|
|
27
|
+
calendar: '日程',
|
|
28
|
+
hongbao: '红包',
|
|
29
|
+
forwarded: '合并转发',
|
|
30
|
+
unsupported: '此类',
|
|
31
|
+
unknown: '未知类型',
|
|
32
|
+
};
|
|
33
|
+
/** 「不支持」回执句式(§5.7 表:不支持类型统一「暂不支持 X」)。 */
|
|
34
|
+
export function unsupportedReason(kind) {
|
|
35
|
+
return `暂不支持${PLACEHOLDER_LABELS[kind]}消息`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 生成回执文案;没有任何需要告知的内容时返回 undefined(调用方不发消息)。
|
|
39
|
+
*/
|
|
40
|
+
export function buildReceipt(input) {
|
|
41
|
+
const parts = [];
|
|
42
|
+
if (input.saved.length > 0) {
|
|
43
|
+
const lines = input.saved.slice(0, MAX_PATHS_IN_RECEIPT).map((p) => `- ${p}`);
|
|
44
|
+
const overflow = input.saved.length > MAX_PATHS_IN_RECEIPT ? `\n… 等 ${input.saved.length} 个文件` : '';
|
|
45
|
+
parts.push(`📎 已保存 ${input.saved.length} 个文件:\n${lines.join('\n')}${overflow}`);
|
|
46
|
+
}
|
|
47
|
+
if (input.rejected.length > 0) {
|
|
48
|
+
parts.push(`⚠️ ${input.rejected.map((r) => r.reason).join(';')}`);
|
|
49
|
+
}
|
|
50
|
+
if (input.startWorkHint && input.saved.length > 0) {
|
|
51
|
+
// 保存目录去重(跨分钟时可能落在两个目录,此时全部列出)。
|
|
52
|
+
const dirs = [...new Set(input.saved.map((p) => path.dirname(p)))];
|
|
53
|
+
const example = dirs.length === 1
|
|
54
|
+
? `请处理 ${dirs[0]} 下的文件`
|
|
55
|
+
: `请处理以下目录下的文件:${dirs.join('、')}`;
|
|
56
|
+
parts.push(`💡 说一句话我就开始处理,例如:${example}`);
|
|
57
|
+
}
|
|
58
|
+
if (parts.length === 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
return parts.join('\n');
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=receipt.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboundTurnAssembler:统一静默期窗口 + 合并 + 下载等待 + commit。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`docs/zh/architecture/inbound-unified-input-design.md` §4/§5.2。
|
|
5
|
+
*
|
|
6
|
+
* 规则:
|
|
7
|
+
* - 任何事件到达 → **重置** 700ms 定时器(按 `userId:chatId` 分桶),保证图/文任意顺序等价;
|
|
8
|
+
* - 定时器到点 → 若本 turn 仍有 in-flight 下载 → 等待,落定后再 commit;
|
|
9
|
+
* - 下载失败/超时 → 该项进 `rejected`,然后照常 commit;
|
|
10
|
+
* - commit → 构造 prompt(`prompt.ts`)→ `onCommit`;
|
|
11
|
+
* - **无文本且只有附件 → 不 commit**(决策 2),只发 `onReceipt` 回执;
|
|
12
|
+
* - 命令消息由调用方 `flush()` 强制提交,不等窗口。
|
|
13
|
+
*/
|
|
14
|
+
import type { InboundEvent } from './event.js';
|
|
15
|
+
import { type CommitReason, type InboundTurn } from './turn.js';
|
|
16
|
+
/** 统一静默期窗口(ms)——决策 1:不做「纯文本零延迟」快路径。 */
|
|
17
|
+
export declare const DEFAULT_TURN_WINDOW_MS = 700;
|
|
18
|
+
export interface AssemblerContext {
|
|
19
|
+
userId: string;
|
|
20
|
+
chatId: string;
|
|
21
|
+
messageId: string;
|
|
22
|
+
}
|
|
23
|
+
export interface AssemblerDeps {
|
|
24
|
+
/** 静默期窗口(毫秒),默认 700。 */
|
|
25
|
+
windowMs?: number;
|
|
26
|
+
/** 定时器注入(便于 vitest fake timers / 单元测试确定性)。 */
|
|
27
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
28
|
+
clearTimer?: (handle: unknown) => void;
|
|
29
|
+
/** commit 回调:组装完成的 turn + prompt 交给上层(进 agent)。 */
|
|
30
|
+
onCommit: (turn: InboundTurn, prompt: string) => void | Promise<void>;
|
|
31
|
+
/** 回执回调:面向用户的提示(无文本纯附件 / 有 rejected 项)。 */
|
|
32
|
+
onReceipt?: (ctx: AssemblerContext, text: string) => void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export declare class InboundTurnAssembler {
|
|
35
|
+
private readonly deps;
|
|
36
|
+
private readonly buckets;
|
|
37
|
+
private readonly windowMs;
|
|
38
|
+
private readonly setTimer;
|
|
39
|
+
private readonly clearTimer;
|
|
40
|
+
private disposed;
|
|
41
|
+
constructor(deps: AssemblerDeps);
|
|
42
|
+
/** 投递一个入站事件(文本 / 媒体[in-flight] / 拒绝)。 */
|
|
43
|
+
ingest(event: InboundEvent): void;
|
|
44
|
+
/**
|
|
45
|
+
* 强制提交窗口内已装配的内容(命令到达、退出前冲刷)。
|
|
46
|
+
* 仍有 in-flight 下载时先等它落定(避免丢掉附件路径)。
|
|
47
|
+
*/
|
|
48
|
+
flush(userId: string, chatId: string, reason?: CommitReason): Promise<void>;
|
|
49
|
+
/** 冲刷全部窗口(/exit、/restart 干净退出前调用)。 */
|
|
50
|
+
flushAll(reason?: CommitReason): Promise<void>;
|
|
51
|
+
/** 进程退出:丢弃定时器,避免悬挂。 */
|
|
52
|
+
dispose(): void;
|
|
53
|
+
private ensureBucket;
|
|
54
|
+
private pushRejected;
|
|
55
|
+
private armTimer;
|
|
56
|
+
/**
|
|
57
|
+
* 登记一次 in-flight 媒体下载:落定后合并结果,并在窗口已到期时触发 commit。
|
|
58
|
+
* 下载异常(含超时)记入 rejected,不阻断本 turn 的其它内容。
|
|
59
|
+
*/
|
|
60
|
+
private trackMediaOutcome;
|
|
61
|
+
private tryCommit;
|
|
62
|
+
private commit;
|
|
63
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InboundTurnAssembler:统一静默期窗口 + 合并 + 下载等待 + commit。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`docs/zh/architecture/inbound-unified-input-design.md` §4/§5.2。
|
|
5
|
+
*
|
|
6
|
+
* 规则:
|
|
7
|
+
* - 任何事件到达 → **重置** 700ms 定时器(按 `userId:chatId` 分桶),保证图/文任意顺序等价;
|
|
8
|
+
* - 定时器到点 → 若本 turn 仍有 in-flight 下载 → 等待,落定后再 commit;
|
|
9
|
+
* - 下载失败/超时 → 该项进 `rejected`,然后照常 commit;
|
|
10
|
+
* - commit → 构造 prompt(`prompt.ts`)→ `onCommit`;
|
|
11
|
+
* - **无文本且只有附件 → 不 commit**(决策 2),只发 `onReceipt` 回执;
|
|
12
|
+
* - 命令消息由调用方 `flush()` 强制提交,不等窗口。
|
|
13
|
+
*/
|
|
14
|
+
import { getLogger } from '../logger/index.js';
|
|
15
|
+
import { isDownloadablePlaceholder } from './placeholder.js';
|
|
16
|
+
import { buildPrompt } from './prompt.js';
|
|
17
|
+
import { buildReceipt, unsupportedReason } from './receipt.js';
|
|
18
|
+
import { bucketKey, } from './turn.js';
|
|
19
|
+
/** 统一静默期窗口(ms)——决策 1:不做「纯文本零延迟」快路径。 */
|
|
20
|
+
export const DEFAULT_TURN_WINDOW_MS = 700;
|
|
21
|
+
export class InboundTurnAssembler {
|
|
22
|
+
deps;
|
|
23
|
+
buckets = new Map();
|
|
24
|
+
windowMs;
|
|
25
|
+
setTimer;
|
|
26
|
+
clearTimer;
|
|
27
|
+
disposed = false;
|
|
28
|
+
constructor(deps) {
|
|
29
|
+
this.deps = deps;
|
|
30
|
+
this.windowMs = deps.windowMs ?? DEFAULT_TURN_WINDOW_MS;
|
|
31
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
32
|
+
this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
33
|
+
}
|
|
34
|
+
/** 投递一个入站事件(文本 / 媒体[in-flight] / 拒绝)。 */
|
|
35
|
+
ingest(event) {
|
|
36
|
+
if (this.disposed) {
|
|
37
|
+
// 已 dispose(进程收尾):不再装配,但必须吞掉 in-flight promise 的 rejection,
|
|
38
|
+
// 否则它会以 unhandledRejection 形式打死进程。
|
|
39
|
+
if (event.kind === 'media')
|
|
40
|
+
void event.outcome.catch(() => { });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const key = bucketKey(event.userId, event.chatId);
|
|
44
|
+
const bucket = this.ensureBucket(key, event);
|
|
45
|
+
if (!bucket.messageIds.includes(event.messageId))
|
|
46
|
+
bucket.messageIds.push(event.messageId);
|
|
47
|
+
if (bucket.replyToMessageId === undefined && event.replyToMessageId !== undefined) {
|
|
48
|
+
bucket.replyToMessageId = event.replyToMessageId;
|
|
49
|
+
}
|
|
50
|
+
switch (event.kind) {
|
|
51
|
+
case 'text': {
|
|
52
|
+
if (event.text !== '')
|
|
53
|
+
bucket.texts.push(event.text);
|
|
54
|
+
// 可下载占位符(图/文件/视频/语音/表情)由媒体通道处理,不在此记 rejected;
|
|
55
|
+
// 其余(位置/名片/投票/合并转发/降级态/未知)拿不到,明确回执。
|
|
56
|
+
for (const kind of event.placeholders) {
|
|
57
|
+
if (isDownloadablePlaceholder(kind))
|
|
58
|
+
continue;
|
|
59
|
+
// 合并转发的子消息文本已被 SDK 渲染(text 非空)→ 只保留文本;
|
|
60
|
+
// 附件能不能拿到由媒体通道的 outcome 决定,不在这里重复报「不支持」。
|
|
61
|
+
if (kind === 'forwarded' && event.text !== '')
|
|
62
|
+
continue;
|
|
63
|
+
this.pushRejected(bucket, {
|
|
64
|
+
kind,
|
|
65
|
+
reason: unsupportedReason(kind),
|
|
66
|
+
sourceMsgId: event.messageId,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'media': {
|
|
72
|
+
this.trackMediaOutcome(bucket, key, event.messageId, event.outcome);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case 'rejected': {
|
|
76
|
+
this.pushRejected(bucket, {
|
|
77
|
+
kind: event.rejectedKind ?? 'unknown',
|
|
78
|
+
reason: event.reason,
|
|
79
|
+
sourceMsgId: event.messageId,
|
|
80
|
+
});
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
this.armTimer(key, bucket);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 强制提交窗口内已装配的内容(命令到达、退出前冲刷)。
|
|
88
|
+
* 仍有 in-flight 下载时先等它落定(避免丢掉附件路径)。
|
|
89
|
+
*/
|
|
90
|
+
async flush(userId, chatId, reason = 'flush') {
|
|
91
|
+
const key = bucketKey(userId, chatId);
|
|
92
|
+
const bucket = this.buckets.get(key);
|
|
93
|
+
if (!bucket)
|
|
94
|
+
return;
|
|
95
|
+
bucket.windowElapsed = true;
|
|
96
|
+
bucket.forcedReason = reason;
|
|
97
|
+
if (bucket.pendingOutcomes.size > 0) {
|
|
98
|
+
await Promise.all([...bucket.pendingOutcomes]);
|
|
99
|
+
}
|
|
100
|
+
await (bucket.commitPromise ?? this.tryCommit(key, reason));
|
|
101
|
+
}
|
|
102
|
+
/** 冲刷全部窗口(/exit、/restart 干净退出前调用)。 */
|
|
103
|
+
async flushAll(reason = 'flush') {
|
|
104
|
+
const buckets = [...this.buckets.values()];
|
|
105
|
+
await Promise.all(buckets.map((b) => this.flush(b.userId, b.chatId, reason)));
|
|
106
|
+
}
|
|
107
|
+
/** 进程退出:丢弃定时器,避免悬挂。 */
|
|
108
|
+
dispose() {
|
|
109
|
+
this.disposed = true;
|
|
110
|
+
for (const bucket of this.buckets.values()) {
|
|
111
|
+
if (bucket.timer !== undefined)
|
|
112
|
+
this.clearTimer(bucket.timer);
|
|
113
|
+
}
|
|
114
|
+
this.buckets.clear();
|
|
115
|
+
}
|
|
116
|
+
ensureBucket(key, event) {
|
|
117
|
+
const existing = this.buckets.get(key);
|
|
118
|
+
if (existing)
|
|
119
|
+
return existing;
|
|
120
|
+
const bucket = {
|
|
121
|
+
userId: event.userId,
|
|
122
|
+
chatId: event.chatId,
|
|
123
|
+
texts: [],
|
|
124
|
+
attachments: [],
|
|
125
|
+
rejected: [],
|
|
126
|
+
messageIds: [],
|
|
127
|
+
pendingOutcomes: new Set(),
|
|
128
|
+
windowElapsed: false,
|
|
129
|
+
committed: false,
|
|
130
|
+
};
|
|
131
|
+
this.buckets.set(key, bucket);
|
|
132
|
+
return bucket;
|
|
133
|
+
}
|
|
134
|
+
pushRejected(bucket, item) {
|
|
135
|
+
const dup = bucket.rejected.some((r) => r.sourceMsgId === item.sourceMsgId && r.reason === item.reason);
|
|
136
|
+
if (!dup)
|
|
137
|
+
bucket.rejected.push(item);
|
|
138
|
+
}
|
|
139
|
+
armTimer(key, bucket) {
|
|
140
|
+
if (bucket.timer !== undefined)
|
|
141
|
+
this.clearTimer(bucket.timer);
|
|
142
|
+
bucket.windowElapsed = false;
|
|
143
|
+
bucket.timer = this.setTimer(() => {
|
|
144
|
+
bucket.timer = undefined;
|
|
145
|
+
bucket.windowElapsed = true;
|
|
146
|
+
if (bucket.pendingOutcomes.size > 0)
|
|
147
|
+
return; // 等 in-flight 下载落定
|
|
148
|
+
void this.tryCommit(key, bucket.forcedReason ?? 'idle');
|
|
149
|
+
}, this.windowMs);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 登记一次 in-flight 媒体下载:落定后合并结果,并在窗口已到期时触发 commit。
|
|
153
|
+
* 下载异常(含超时)记入 rejected,不阻断本 turn 的其它内容。
|
|
154
|
+
*/
|
|
155
|
+
trackMediaOutcome(bucket, key, messageId, outcome) {
|
|
156
|
+
const tracked = Promise.resolve(outcome)
|
|
157
|
+
.then((result) => {
|
|
158
|
+
bucket.attachments.push(...result.attachments);
|
|
159
|
+
for (const item of result.rejected)
|
|
160
|
+
this.pushRejected(bucket, item);
|
|
161
|
+
}, (err) => {
|
|
162
|
+
this.pushRejected(bucket, {
|
|
163
|
+
kind: 'file',
|
|
164
|
+
reason: `下载失败: ${err?.message ?? String(err)}`,
|
|
165
|
+
sourceMsgId: messageId,
|
|
166
|
+
});
|
|
167
|
+
})
|
|
168
|
+
.then(() => {
|
|
169
|
+
bucket.pendingOutcomes.delete(tracked);
|
|
170
|
+
if (bucket.windowElapsed && bucket.pendingOutcomes.size === 0 && !bucket.committed) {
|
|
171
|
+
void this.tryCommit(key, bucket.forcedReason ?? 'idle');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
bucket.pendingOutcomes.add(tracked);
|
|
175
|
+
}
|
|
176
|
+
tryCommit(key, reason) {
|
|
177
|
+
const bucket = this.buckets.get(key);
|
|
178
|
+
if (!bucket || bucket.committed)
|
|
179
|
+
return Promise.resolve();
|
|
180
|
+
if (bucket.pendingOutcomes.size > 0)
|
|
181
|
+
return Promise.resolve();
|
|
182
|
+
bucket.committed = true;
|
|
183
|
+
this.buckets.delete(key);
|
|
184
|
+
if (bucket.timer !== undefined) {
|
|
185
|
+
this.clearTimer(bucket.timer);
|
|
186
|
+
bucket.timer = undefined;
|
|
187
|
+
}
|
|
188
|
+
const promise = this.commit(bucket, reason);
|
|
189
|
+
bucket.commitPromise = promise;
|
|
190
|
+
return promise;
|
|
191
|
+
}
|
|
192
|
+
async commit(bucket, reason) {
|
|
193
|
+
const noText = bucket.texts.length === 0;
|
|
194
|
+
const turn = {
|
|
195
|
+
userId: bucket.userId,
|
|
196
|
+
chatId: bucket.chatId,
|
|
197
|
+
replyToMessageId: bucket.replyToMessageId,
|
|
198
|
+
texts: bucket.texts,
|
|
199
|
+
attachments: bucket.attachments,
|
|
200
|
+
rejected: bucket.rejected,
|
|
201
|
+
messageIds: bucket.messageIds,
|
|
202
|
+
commitReason: noText ? 'no-text' : reason,
|
|
203
|
+
};
|
|
204
|
+
const ctx = {
|
|
205
|
+
userId: bucket.userId,
|
|
206
|
+
chatId: bucket.chatId,
|
|
207
|
+
messageId: bucket.messageIds[bucket.messageIds.length - 1] ?? '',
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
if (noText) {
|
|
211
|
+
// 决策 2:纯附件不自动起 turn,只回执并引导用户说一句话。
|
|
212
|
+
const text = buildReceipt({
|
|
213
|
+
saved: bucket.attachments.map((a) => a.path),
|
|
214
|
+
rejected: bucket.rejected,
|
|
215
|
+
startWorkHint: true,
|
|
216
|
+
});
|
|
217
|
+
if (text)
|
|
218
|
+
await this.deps.onReceipt?.(ctx, text);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
await this.deps.onCommit(turn, buildPrompt(turn));
|
|
222
|
+
if (bucket.rejected.length > 0) {
|
|
223
|
+
const text = buildReceipt({ saved: [], rejected: bucket.rejected, startWorkHint: false });
|
|
224
|
+
if (text)
|
|
225
|
+
await this.deps.onReceipt?.(ctx, text);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
getLogger().error('[inbound] turn commit failed:', err?.message ?? String(err));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=turn-assembler.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一入站回合(InboundTurn)模型。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`docs/zh/architecture/inbound-unified-input-design.md` §4。
|
|
5
|
+
* 核心不变量:**一个用户意图 = 一个 turn**,且任何进 agent 的内容都必须已落到本地路径。
|
|
6
|
+
*/
|
|
7
|
+
/** 附件种类(命名/展示语义)。与下载语义 `InboundMediaItem.type` 分离(§5.3)。 */
|
|
8
|
+
export type AttachmentKind = 'image' | 'file' | 'video' | 'audio' | 'sticker';
|
|
9
|
+
export interface InboundAttachment {
|
|
10
|
+
/** 绝对路径(落盘后位置)。 */
|
|
11
|
+
path: string;
|
|
12
|
+
kind: AttachmentKind;
|
|
13
|
+
/** 来源飞书 messageId:下载凭据的一部分,也是排障锚点。 */
|
|
14
|
+
sourceMsgId: string;
|
|
15
|
+
/** 原始文件名(有则保留,用于 prompt 标注与排障)。 */
|
|
16
|
+
originalName?: string;
|
|
17
|
+
/** 视频/语音时长(毫秒)。 */
|
|
18
|
+
durationMs?: number;
|
|
19
|
+
}
|
|
20
|
+
/** 拿不到、不支持、超限、失败的项 —— 需要回执给人(不进 agent)。 */
|
|
21
|
+
export interface RejectedItem {
|
|
22
|
+
/** 原 msg_type / 占位符种类 / 资源种类。 */
|
|
23
|
+
kind: string;
|
|
24
|
+
/** 面向人的说明。 */
|
|
25
|
+
reason: string;
|
|
26
|
+
sourceMsgId: string;
|
|
27
|
+
}
|
|
28
|
+
/** 提交原因,用于观测与测试断言。 */
|
|
29
|
+
export type CommitReason = 'idle' | 'timeout' | 'flush' | 'no-text';
|
|
30
|
+
export interface InboundTurn {
|
|
31
|
+
userId: string;
|
|
32
|
+
chatId: string;
|
|
33
|
+
replyToMessageId?: string;
|
|
34
|
+
/** 用户文本片段,按到达顺序;已剥离结构占位符。 */
|
|
35
|
+
texts: string[];
|
|
36
|
+
/** 已落盘的附件(绝对路径),按到达顺序。 */
|
|
37
|
+
attachments: InboundAttachment[];
|
|
38
|
+
rejected: RejectedItem[];
|
|
39
|
+
/** 本 turn 覆盖的飞书 messageId(回执、去重、排障)。 */
|
|
40
|
+
messageIds: string[];
|
|
41
|
+
commitReason: CommitReason;
|
|
42
|
+
}
|
|
43
|
+
/** 媒体下载 + 落盘的结果(装配器等待它落定后才 commit)。 */
|
|
44
|
+
export interface MediaOutcome {
|
|
45
|
+
attachments: InboundAttachment[];
|
|
46
|
+
rejected: RejectedItem[];
|
|
47
|
+
}
|
|
48
|
+
export declare function bucketKey(userId: string, chatId: string): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一入站回合(InboundTurn)模型。
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:`docs/zh/architecture/inbound-unified-input-design.md` §4。
|
|
5
|
+
* 核心不变量:**一个用户意图 = 一个 turn**,且任何进 agent 的内容都必须已落到本地路径。
|
|
6
|
+
*/
|
|
7
|
+
export function bucketKey(userId, chatId) {
|
|
8
|
+
return `${userId}:${chatId}`;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=turn.js.map
|