@vui-design/openclaw-plugin-feishu-progress 0.3.0 → 0.4.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +89 -57
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vui-design/openclaw-plugin-feishu-progress",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "飞书任务进度卡片插件 — 在 AI 执行复杂多步骤任务时,实时更新飞书进度卡片",
5
5
  "keywords": [
6
6
  "openclaw-plugin",
package/src/index.ts CHANGED
@@ -1,26 +1,25 @@
1
1
  import { FeishuClient } from './feishu-client.js';
2
2
 
3
- // ── 工具名称标签构建(解析实际参数,生成语义化中文描述)──────────────────────
3
+ // ── 工具参数 → 语义化中文标签 ────────────────────────────────────────────────
4
4
 
5
5
  function basename(p: string): string {
6
6
  return p.split(/[/\\]/).filter(Boolean).pop() ?? p;
7
7
  }
8
8
 
9
- function str(v: unknown, max = 60): string {
9
+ function s(v: unknown, max = 60): string {
10
10
  return String(v ?? '').slice(0, max).trim();
11
11
  }
12
12
 
13
- function buildToolLabel(toolName: string, params: Record<string, unknown>): string {
13
+ function buildToolLabel(toolName: string, args: Record<string, unknown>): string {
14
14
  switch (toolName) {
15
15
  case 'exec':
16
16
  case 'bash': {
17
- const cmd = str(params?.command ?? params?.cmd ?? params?.input, 150);
17
+ const cmd = s(args?.command ?? args?.cmd ?? args?.input, 150);
18
18
 
19
19
  const brewInstall = cmd.match(/brew\s+install\s+([\w@./-]+)/);
20
20
  if (brewInstall) return `📦 安装 ${brewInstall[1]}`;
21
21
  const brewUninstall = cmd.match(/brew\s+uninstall\s+([\w@./-]+)/);
22
22
  if (brewUninstall) return `🗑️ 卸载 ${brewUninstall[1]}`;
23
-
24
23
  const which = cmd.match(/^(?:which|command\s+-v)\s+([\w.-]+)/);
25
24
  if (which) return `🔍 检查 ${which[1]} 是否已安装`;
26
25
 
@@ -40,7 +39,6 @@ function buildToolLabel(toolName: string, params: Record<string, unknown>): stri
40
39
 
41
40
  const mkdir = cmd.match(/^mkdir\s+(?:-[\w]+\s+)?([\w./~-]+)/);
42
41
  if (mkdir) return `📁 创建目录 ${basename(mkdir[1])}`;
43
-
44
42
  const rm = cmd.match(/^rm\s+(?:-[\w]+\s+)?([\w./~-]+)/);
45
43
  if (rm) return `🗑️ 删除 ${basename(rm[1])}`;
46
44
 
@@ -52,57 +50,57 @@ function buildToolLabel(toolName: string, params: Record<string, unknown>): stri
52
50
  }
53
51
 
54
52
  case 'process': {
55
- const cmd = str(params?.command ?? params?.cmd, 80);
56
- return cmd ? `⚡ 运行:${cmd.slice(0, 40)}` : '⚡ 执行进程';
53
+ const cmd = s(args?.command ?? args?.cmd, 80);
54
+ return cmd ? `⚡ ${cmd.slice(0, 40)}` : '⚡ 执行进程';
57
55
  }
58
56
 
59
57
  case 'read':
60
58
  case 'read_file': {
61
- const p = str(params?.path ?? params?.file_path ?? params?.filePath);
59
+ const p = s(args?.path ?? args?.file_path ?? args?.filePath);
62
60
  return p ? `📂 读取 ${basename(p)}` : '📂 读取文件';
63
61
  }
64
62
 
65
63
  case 'write':
66
64
  case 'write_file':
67
65
  case 'create_file': {
68
- const p = str(params?.path ?? params?.file_path ?? params?.filePath);
66
+ const p = s(args?.path ?? args?.file_path ?? args?.filePath);
69
67
  return p ? `✏️ 写入 ${basename(p)}` : '✏️ 写入文件';
70
68
  }
71
69
 
72
70
  case 'str_replace_editor':
73
71
  case 'edit_file': {
74
- const p = str(params?.path ?? params?.file_path);
72
+ const p = s(args?.path ?? args?.file_path);
75
73
  return p ? `✏️ 编辑 ${basename(p)}` : '✏️ 编辑文件';
76
74
  }
77
75
 
78
76
  case 'list_directory': {
79
- const p = str(params?.path ?? params?.directory);
77
+ const p = s(args?.path ?? args?.directory);
80
78
  return p ? `📋 浏览 ${basename(p) || p}` : '📋 浏览目录';
81
79
  }
82
80
 
83
81
  case 'search':
84
82
  case 'web_search':
85
83
  case 'tavily_search': {
86
- const q = str(params?.query ?? params?.q, 30);
84
+ const q = s(args?.query ?? args?.q, 30);
87
85
  return q ? `🌐 搜索「${q}」` : '🌐 网络搜索';
88
86
  }
89
87
 
90
88
  case 'fetch':
91
89
  case 'web_fetch': {
92
- const url = str(params?.url ?? params?.href);
90
+ const url = s(args?.url ?? args?.href);
93
91
  try { return `🌐 访问 ${new URL(url).hostname}`; } catch { /**/ }
94
92
  return '🌐 抓取网页';
95
93
  }
96
94
 
97
95
  case 'computer': {
98
- const action = str(params?.action);
96
+ const action = s(args?.action);
99
97
  return action ? `🖥️ 操作电脑:${action}` : '🖥️ 操作电脑';
100
98
  }
101
99
 
102
100
  case 'screenshot': return '📸 截图';
103
101
 
104
102
  case 'delete_file': {
105
- const p = str(params?.path ?? params?.file_path);
103
+ const p = s(args?.path ?? args?.file_path);
106
104
  return p ? `🗑️ 删除 ${basename(p)}` : '🗑️ 删除文件';
107
105
  }
108
106
 
@@ -110,22 +108,21 @@ function buildToolLabel(toolName: string, params: Record<string, unknown>): stri
110
108
  }
111
109
  }
112
110
 
113
- // ── 提取 AI 文本中最有意义的一句(用于工具调用前的说明)─────────────────────
114
-
111
+ // AI 输出文本中提取最末一句(作为进度提示优先显示)
115
112
  function extractSnippet(text: string): string {
116
- // 取最后一段非空文本,截取前 50 字
117
113
  const lines = text.split(/\n+/).map(l => l.trim()).filter(Boolean);
118
- const last = lines[lines.length - 1] ?? '';
119
- return last.slice(0, 50);
114
+ return (lines[lines.length - 1] ?? '').slice(0, 50);
120
115
  }
121
116
 
122
- // ── 主插件逻辑 ──────────────────────────────────────────────────────────────
123
-
124
- // agentId → 飞书裸 ID(ou_xxx / oc_xxx)
125
- const sessionChatMap = new Map<string, string>();
117
+ // ── 主插件 ────────────────────────────────────────────────────────────────
126
118
 
127
- // runId → 最近一次 LLM turn 的 AI 文本(用于 before_tool_call 显示上下文)
128
- const runTextMap = new Map<string, string>();
119
+ interface RunState {
120
+ chatId: string;
121
+ assistantBuffer: string;
122
+ startedAt: number;
123
+ lastSentAt: number;
124
+ heartbeatTimer: ReturnType<typeof setInterval>;
125
+ }
129
126
 
130
127
  export default {
131
128
  id: 'feishu-progress',
@@ -150,51 +147,86 @@ export default {
150
147
  domain: cfg.domain ?? 'feishu',
151
148
  });
152
149
 
153
- // ── 1. 收到用户消息时,记录 agentId 飞书 ID ────────────────────────────
150
+ // accountId ("main")飞书裸 ID(ou_xxx / oc_xxx)
151
+ const accountChatMap = new Map<string, string>();
152
+
153
+ // runId → RunState(每次 agent run 的状态)
154
+ const runStateMap = new Map<string, RunState>();
155
+
156
+ // ── 1. 收到用户消息 → 记录 accountId 对应的飞书 chat ID ─────────────────
154
157
  api.on('message_received', async (_event: any, ctx: any) => {
155
158
  if (ctx.channelId !== 'feishu') return;
156
159
  if (!ctx.conversationId) return;
157
- // OpenClaw 内部格式 "user:ou_xxx" / "group:oc_xxx",剥离前缀取裸 Feishu ID
160
+ // OpenClaw 内部格式 "user:ou_xxx",剥离前缀
158
161
  const rawId = ctx.conversationId.includes(':')
159
162
  ? ctx.conversationId.split(':').pop()!
160
163
  : ctx.conversationId;
161
- sessionChatMap.set(ctx.accountId ?? 'default', rawId);
164
+ accountChatMap.set(ctx.accountId ?? 'default', rawId);
162
165
  });
163
166
 
164
- // ── 2. LLM 完成一轮生成后,缓存 AI 文本供 before_tool_call 使用 ──────────
165
- api.on('llm_output', async (event: any, _ctx: any) => {
166
- if (!event.runId) return;
167
- const texts: string[] = event.assistantTexts ?? [];
168
- const text = texts.join('').trim();
169
- if (text) runTextMap.set(event.runId, text);
170
- });
167
+ // ── 2. LLM 调用前 runId+agentId 建立关联,并启动心跳 ─────────────
168
+ api.on('llm_input', async (event: any, ctx: any) => {
169
+ if (!event.runId || !ctx.agentId) return;
170
+ if (runStateMap.has(event.runId)) return; // 同一 run 只初始化一次
171
171
 
172
- // ── 3. 工具调用前,推送进度到飞书 ───────────────────────────────────────
173
- api.on('before_tool_call', async (event: any, ctx: any) => {
174
- const key = ctx.agentId ?? ctx.sessionKey ?? 'default';
175
- const chatId = sessionChatMap.get(key);
172
+ const chatId = accountChatMap.get(ctx.agentId);
176
173
  if (!chatId) return;
177
174
 
178
- // AI 文本优先(取自本轮 LLM 输出的最后一句),没有则用参数解析标签
179
- let label: string;
180
- if (event.runId && runTextMap.has(event.runId)) {
181
- const snippet = extractSnippet(runTextMap.get(event.runId)!);
182
- runTextMap.delete(event.runId); // 每轮只用一次
183
- label = snippet || buildToolLabel(event.toolName, event.params ?? {});
184
- } else {
185
- label = buildToolLabel(event.toolName, event.params ?? {});
175
+ const startedAt = Date.now();
176
+ const state: RunState = {
177
+ chatId,
178
+ assistantBuffer: '',
179
+ startedAt,
180
+ lastSentAt: startedAt,
181
+ heartbeatTimer: setInterval(() => {
182
+ if (!runStateMap.has(event.runId)) return;
183
+ const idleSec = (Date.now() - state.lastSentAt) / 1000;
184
+ if (idleSec >= 55) {
185
+ const elapsed = Math.round((Date.now() - state.startedAt) / 1000);
186
+ state.lastSentAt = Date.now();
187
+ client.sendText(chatId, `⏳ 处理中,已用时约 ${elapsed} 秒...`).catch(() => {});
188
+ }
189
+ }, 30_000),
190
+ };
191
+
192
+ runStateMap.set(event.runId, state);
193
+ });
194
+
195
+ // ── 3. 实时流事件 → 核心逻辑 ─────────────────────────────────────────────
196
+ api.runtime.events.onAgentEvent((evt: any) => {
197
+ const { runId, stream, data } = evt;
198
+ const state = runStateMap.get(runId);
199
+ if (!state) return;
200
+
201
+ // assistant 流:累积 AI 输出文本
202
+ if (stream === 'assistant') {
203
+ state.assistantBuffer += (data?.delta ?? '') as string;
204
+ return;
186
205
  }
187
206
 
188
- try {
189
- await client.sendText(chatId, label);
190
- } catch (err: any) {
191
- api.logger?.warn(`[feishu-progress] 推送失败: ${err?.message}`);
207
+ // tool:start → 推送进度消息
208
+ if (stream === 'tool' && data?.phase === 'start') {
209
+ // AI 文本优先,没有则用参数解析标签
210
+ const snippet = extractSnippet(state.assistantBuffer);
211
+ state.assistantBuffer = ''; // 每次工具调用前消费一次
212
+
213
+ const label = snippet || buildToolLabel(
214
+ data.name as string,
215
+ (data.args ?? {}) as Record<string, unknown>,
216
+ );
217
+
218
+ state.lastSentAt = Date.now();
219
+ client.sendText(state.chatId, label).catch((err: any) => {
220
+ api.logger?.warn(`[feishu-progress] 推送失败: ${err?.message}`);
221
+ });
222
+ return;
192
223
  }
193
- });
194
224
 
195
- // ── 4. 任务结束后清理 session 映射 ─────────────────────────────────────
196
- api.on('agent_end', async (_event: any, ctx: any) => {
197
- sessionChatMap.delete(ctx.agentId ?? ctx.sessionKey ?? 'default');
225
+ // lifecycle:end/error 清理
226
+ if (stream === 'lifecycle' && (data?.phase === 'end' || data?.phase === 'error')) {
227
+ clearInterval(state.heartbeatTimer);
228
+ runStateMap.delete(runId);
229
+ }
198
230
  });
199
231
  },
200
232
  };