From 885b90fe4a8e14d291d3a6150961f4973c00230f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Wed, 11 Nov 2026 21:06:13 +0800 Subject: [PATCH] Fold a steered Feishu card to one line instead of recalling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishu shows every recall in the chat, so recalling the card a steering message replaced left a notice in the middle of the conversation. The earlier card now shrinks to one grey line, 已并入下方回复. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- example/parsar/README.md | 2 +- example/parsar/README.zh.md | 4 ++-- example/parsar/server/feishu.mjs | 9 ++++----- example/parsar/tests/feishu.test.mjs | 10 ++++++---- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/example/parsar/README.md b/example/parsar/README.md index 0d30e870a..238632803 100644 --- a/example/parsar/README.md +++ b/example/parsar/README.md @@ -86,7 +86,7 @@ Quick Tunnel URLs last only for that tunnel process and may change after restart **新对话** opens the home page: choose an Agent, describe the work and send. The sidebar lists recent conversations; **全部对话** opens the full list with search, Agent, source and date filters, plus **运行**, **用量** and **审计日志**. **运行** also sits directly under **新对话** with the number of running conversations. It lists every running turn from Web and Feishu with its stage and waiting time, and the recently finished ones with their duration, automatic recoveries and plain-language failures; it also shows how many conversations are running, waiting for an answer or failed in the last five minutes. Running turns can be stopped and the latest failed turn of a conversation retried there. Date filters use the browser’s local dates. Usage totals cover all matching records; missing usage remains unknown. Optional model prices are USD per million input/output tokens, frozen when a conversation starts; costs are estimates, not provider bills. -A conversation streams replies and tool activity, groups work steps, navigates user turns with the turn rail, supports follow-ups and cancellation and reopens durable history. Messages sent while the Agent is working, from Web or Feishu, join the running task: **运行** shows it once, and in Feishu the task's progress card moves below the new message, so its result always follows the latest message. Answer business questions on their card. Enter sends; Shift+Enter adds a line. Drafts survive reloads in the same browser tab. The folder button uploads inputs up to 50 MiB while idle through Core's Files API, inserts their path into the draft and downloads Artifacts after the live workspace is gone; ask the Agent to save deliverables under `./outputs`. The info button shows the Agent, model, Harness, environment template, Feishu identity, source and the configuration recorded at creation, without variable values, script contents or credentials. When a creation response is lost, the conversation page offers **恢复创建**, which reuses the frozen request and idempotency key. +A conversation streams replies and tool activity, groups work steps, navigates user turns with the turn rail, supports follow-ups and cancellation and reopens durable history. Messages sent while the Agent is working, from Web or Feishu, join the running task: **运行** shows it once, and in Feishu the task's progress card moves below the new message, so its result always follows the latest message; the earlier card shrinks to one line, **已并入下方回复**. Answer business questions on their card. Enter sends; Shift+Enter adds a line. Drafts survive reloads in the same browser tab. The folder button uploads inputs up to 50 MiB while idle through Core's Files API, inserts their path into the draft and downloads Artifacts after the live workspace is gone; ask the Agent to save deliverables under `./outputs`. The info button shows the Agent, model, Harness, environment template, Feishu identity, source and the configuration recorded at creation, without variable values, script contents or credentials. When a creation response is lost, the conversation page offers **恢复创建**, which reuses the frozen request and idempotency key. When a follow-up response is lost while Core resumes the environment, Web keeps the input in the running ledger and confirms receipt with the original idempotency key before monitoring its outcome. Explicit request rejections still return an error; input that never starts is reported after its reservation expires. diff --git a/example/parsar/README.zh.md b/example/parsar/README.zh.md index c59b09627..40eedb9e0 100644 --- a/example/parsar/README.zh.md +++ b/example/parsar/README.zh.md @@ -1,6 +1,6 @@ --- source: example/parsar/README.md -source_hash: 9458db13284a852375751cc018b6291853220002d64b26a35186e63cfba59931 +source_hash: 0a4d6a1ee7fb588c82d9f0accd102cdf16e8cd4f3306f54a3417640808032796 --- # Parsar 工作空间 @@ -91,7 +91,7 @@ Quick Tunnel 地址只在该隧道进程运行期间有效,重启可能换地 **新对话** 打开首页:选择 Agent、描述工作并发送。侧栏列出最近对话;**全部对话** 打开完整列表,支持搜索以及按 Agent、来源和日期筛选,并提供 **运行**、**用量** 和 **审计日志**。**运行** 也直接放在 **新对话** 下方,并显示正在进行的对话数。它列出网页和飞书中每一轮正在执行的对话,显示所处阶段和已等待时间;也列出最近完成的轮次,显示用时、自动恢复次数和通俗的失败原因,并统计正在进行、等你回复和近 5 分钟失败的数量。可以在这里停止正在执行的轮次,或重试对话中最近失败的一轮。日期筛选按浏览器本地日期计算;用量汇总覆盖所有匹配记录,缺失用量保持未知。模型可配置每百万输入/输出 Token 的美元价格,在对话开始时冻结;费用为估算,不是服务商账单。 -对话会流式展示回复和工具活动,归组工作步骤,可用对话导航条跳转用户轮次,支持追问和取消,并可重新打开持久历史。Agent 执行中发送的消息(网页和飞书都一样)会并入正在运行的任务:**运行** 里只显示这一轮,飞书里这一轮的进度卡片会挪到新消息下面,结果总是出现在最新那条消息之后。业务提问请在提问卡片上回答。Enter 发送,Shift+Enter 换行。草稿在同一浏览器标签页中刷新后保留。文件夹按钮可在空闲时通过 Core 的 Files API 上传最大 50 MiB 的输入文件,并把路径插入草稿;实时工作区回收后仍可下载 Artifacts,请让 Agent 将交付物保存到 `./outputs`。信息按钮展示 Agent、模型、Harness、环境模板、飞书身份、来源以及创建时记录的配置,不含变量值、脚本内容或凭据。创建响应丢失时,对话页提供 **恢复创建**,复用冻结的请求和幂等键。 +对话会流式展示回复和工具活动,归组工作步骤,可用对话导航条跳转用户轮次,支持追问和取消,并可重新打开持久历史。Agent 执行中发送的消息(网页和飞书都一样)会并入正在运行的任务:**运行** 里只显示这一轮,飞书里这一轮的进度卡片会挪到新消息下面,结果总是出现在最新那条消息之后;原来的卡片缩成一行 **已并入下方回复**。业务提问请在提问卡片上回答。Enter 发送,Shift+Enter 换行。草稿在同一浏览器标签页中刷新后保留。文件夹按钮可在空闲时通过 Core 的 Files API 上传最大 50 MiB 的输入文件,并把路径插入草稿;实时工作区回收后仍可下载 Artifacts,请让 Agent 将交付物保存到 `./outputs`。信息按钮展示 Agent、模型、Harness、环境模板、飞书身份、来源以及创建时记录的配置,不含变量值、脚本内容或凭据。创建响应丢失时,对话页提供 **恢复创建**,复用冻结的请求和幂等键。 恢复环境时,如果后续消息的请求响应超时或丢失,网页会保留运行记录,使用原幂等键确认接收后继续跟踪执行结果,不会重复执行。明确被拒绝的请求仍返回错误;始终未开始执行的输入会在预留期结束后报告失败。 diff --git a/example/parsar/server/feishu.mjs b/example/parsar/server/feishu.mjs index 906a96bbd..f41533fb2 100644 --- a/example/parsar/server/feishu.mjs +++ b/example/parsar/server/feishu.mjs @@ -180,6 +180,7 @@ const cardTones = { cancelled: ["已停止", "grey"], failed: ["执行失败", "red"], missing_scopes: ["需要开通权限", "orange"], + moved: ["已并入下方回复", "grey"], }; const grey = (text) => `${text}`; @@ -635,9 +636,6 @@ export function feishuOperations(client) { throw new Error("Feishu did not return a message identifier"); return { id: value.message_id }; }, - async recall(id) { - confirmed(await client.im.message.delete({ path: { message_id: id } })); - }, async edit(id, text) { const result = await client.im.message.patch({ path: { message_id: id }, @@ -1229,13 +1227,14 @@ export function feishuWorker(store, core, product, makeChannel, configId) { "已请求取消,等待 Agent 确认停止。", ); // A message that steers the running Turn takes over its card, which moves - // below that message: one card per Turn, always under the latest message. + // below that message: the Turn's live card always follows the latest message. else { const previous = steered && thread.active_outbound_id && store.get("feishu_outbound", thread.active_outbound_id); progress(previous?.payload || thinking); + // Feishu marks every recall in the chat, so the old card shrinks to one line instead. if (previous?.sent_message_id) { store.put("feishu_outbound", { ...previous, moved: true }); - await channel.recall(previous.sent_message_id).catch(() => {}); + await channel.edit(previous.sent_message_id, statusCard("moved", grey("回复在下方。")), thread).catch(() => {}); } else if (previous) store.remove("feishu_outbound", previous.id); } } diff --git a/example/parsar/tests/feishu.test.mjs b/example/parsar/tests/feishu.test.mjs index ffb106fb8..f556c01c9 100644 --- a/example/parsar/tests/feishu.test.mjs +++ b/example/parsar/tests/feishu.test.mjs @@ -2093,8 +2093,9 @@ test("a message sent while the Turn runs steers it, and the Turn's card moves be if (method === "POST" && path.endsWith("/events")) posted.push(body.events[0]); return f.core(path, method, body, ...rest); }; - const recalled = []; - f.channel.rawClient.im.message.delete = async ({ path }) => (recalled.push(path.message_id), { code: 0 }); + const patched = []; + const patch = f.channel.rawClient.im.message.patch; + f.channel.rawClient.im.message.patch = async (request) => (patched.push(request), patch(request)); const worker = feishuWorker(f.store, core, f.api, async () => f.channel); const dm = { ...f.message, chatType: "p2p", mentionedBot: false }; worker.receive("cli_0123456789abcdef", dm); @@ -2108,10 +2109,11 @@ test("a message sent while the Turn runs steers it, and the Turn's card moves be assert.equal(f.store.list("turns").length, 1); assert.equal(f.store.list("turns")[0].steers.length, 1); assert.equal(f.store.list("feishu_inbound").find((row) => row.message_id === "steer").state, "delivered"); - // The card moves: a new one answers the steer and the old one is recalled. + // The card moves: a new one answers the steer and the old one shrinks to one line. assert.equal(f.sent.length, 2); assert.equal(f.sent.at(-1).path.message_id, "steer"); - assert.deepEqual(recalled, [first.sent_message_id]); + const folded = patched.find((request) => request.path.message_id === first.sent_message_id); + assert.match(folded.data.content, /已并入下方回复/); const thread = f.store.list("feishu_threads")[0]; assert.equal(f.store.get("feishu_outbound", thread.active_outbound_id).reply_to, "steer"); });