@mrrisega/dsh-remote 0.6.10-beta.4 → 0.6.10-beta.7

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.
@@ -1738,6 +1738,26 @@ export function markUnbound(relayDir, reason = "", onWarn) {
1738
1738
  /** 回执有效期文案里的分钟数(§6 ①:审批有寿命,过期静默丢失,文案必须说出来)。 */
1739
1739
  export const DEFAULT_REPLY_TTL_MINUTES = 5;
1740
1740
 
1741
+ /**
1742
+ * 完成通知里「结论」的展示上限(字)。业主口径(2026-09-22):
1743
+ * 「任务完成通知里有些关键结论被大量截断了,加长一些,更完整地展示最终结论 ——
1744
+ * 这可能是用户关注的内容,所以都给他们展示出来」。
1745
+ * 原值 400 对真实结论太短(实测一条正常的修复总结就有 190+ 字,稍详细就超)。
1746
+ */
1747
+ export const COMPLETION_SUMMARY_MAX = 1500;
1748
+ /**
1749
+ * 单条微信消息的总长度上限(字)。超出仍会**显式标注**截断(不静默丢)。
1750
+ * ⚠️ 这个值必须 ≥ COMPLETION_SUMMARY_MAX,否则结论会在拼接后被二次砍掉。
1751
+ */
1752
+ export const COMPLETION_TEXT_MAX = 3000;
1753
+ /**
1754
+ * 从会话历史里取「结论」时的上限(字)。
1755
+ * ⚠️ 历史陷阱:`wechat-runtime.mjs` 的 #sessionSummary 曾写死 `slice(0, 700)`,
1756
+ * 而展示侧是 400 —— 两边各砍一刀,且**取值侧更小**时会在更早的地方就被砍掉,
1757
+ * 排查时只盯着 formatter 会找不到真正的截断点。两个值必须一起看(有测试锁这条关系)。
1758
+ */
1759
+ export const SESSION_SUMMARY_MAX = 3000;
1760
+
1741
1761
  const NODE_LABELS = Object.freeze({
1742
1762
  approval: "需要你拍板",
1743
1763
  question: "在等你回答",
@@ -1841,7 +1861,10 @@ export function formatNotification(node = {}, opts = {}) {
1841
1861
  case "daily": {
1842
1862
  lines.push(`【${title}】`);
1843
1863
  for (const l of Array.isArray(node.lines) ? node.lines : []) lines.push(String(l));
1844
- if (!Array.isArray(node.lines) || !node.lines.length) lines.push("今天暂时没有要做的事。");
1864
+ // 措辞对齐"回顾今天做了什么" —— 日报不是待办清单
1865
+ if (!Array.isArray(node.lines) || !node.lines.length) lines.push("今天这台电脑上没有跑任务。");
1866
+ // 品牌轻露出(业主:让用户知道我们在给他提供服务)。**克制一行**、放在最底,不喧宾夺主。
1867
+ lines.push("", "—— DSH 远程控制 · 微信机器人通道");
1845
1868
  // ⚠️ 这句是**功能**不是客套:微信 24h 推送窗口靠用户回消息续期,
1846
1869
  // 简报的产品作用正是每天制造一次互动(§7)。必须出现「回复」字样,否则用户不会回。
1847
1870
  lines.push("(回复任意一句话即可保持推送窗口有效)");
@@ -1904,6 +1927,85 @@ function shorten(s, n) {
1904
1927
  return t.length > n ? `${t.slice(0, n - 1)}…(已截断)` : t;
1905
1928
  }
1906
1929
 
1930
+ /** 按长度裁剪但**保留换行**(结论要按原样分多行展示 —— 压成一行正是"密密麻麻"的来源)。 */
1931
+ function clipText(s, n) {
1932
+ const t = String(s ?? "").trim();
1933
+ return t.length > n ? `${t.slice(0, n - 1)}…(已截断)` : t;
1934
+ }
1935
+
1936
+ /**
1937
+ * 把模型产出的 **Markdown 转成微信能看的纯文本**。
1938
+ *
1939
+ * 为什么必须转:微信**不渲染 Markdown**(最强证据是腾讯自己的插件在出站路径跑
1940
+ * `StreamingMarkdownFilter` 主动剥离"不支持的 markdown 语法")。所以模型写的表格会以
1941
+ * `| a | b |` 的**字面竖线**出现在气泡里、`**加粗**` 会显示成星号。
1942
+ * 业主原话:「现在的表格形式看起来很奇怪,整个格式在微信上没有做过任何兼容,体验很差很差」
1943
+ * —— 根因就在这里,不是"排版没调好"。
1944
+ *
1945
+ * 规则(只改"怎么显示",不改语义):
1946
+ * · 表格 → 逐行「· 列1:值1 | 列2:值2」(气泡是**比例字体**,任何用空格对齐的尝试都不可靠)
1947
+ * · 标题 `## x` → `【x】` 独占一行
1948
+ * · `**粗**` / `__粗__` / `*斜*` → 去掉标记(留着就是星号噪声)
1949
+ * · `[文字](链接)` → `文字:链接` —— **必须留下裸 URL**,微信才会自动识别成可点链接
1950
+ * · 行内 `` `代码` `` → 去掉反引号;``` 围栏 → 去掉围栏行,内容原样保留
1951
+ * · 无序列表 `-` `*` `+` → 统一成 `·`;有序列表不动
1952
+ * · 引用 `>` → 去掉标记;分隔线 `---`/`***` → `———————`
1953
+ * · 3 个以上连续空行 → 压成 1 个
1954
+ *
1955
+ * ⚠️ 单字符 `*` / `_` 只在"看起来像强调"(前后不贴空格)时才剥 —— 否则 `2 * 3 * 4` 会被吃成 `2 3 4`。
1956
+ */
1957
+ export function markdownToWechatText(md) {
1958
+ const src = String(md ?? "").replace(/\r\n?/g, "\n");
1959
+ if (!src.trim()) return "";
1960
+ const lines = src.split("\n");
1961
+ const out = [];
1962
+ let inFence = false;
1963
+
1964
+ for (let i = 0; i < lines.length; i += 1) {
1965
+ let line = lines[i];
1966
+
1967
+ // 围栏行整行丢掉(纯文本里代码本来就是纯文本,围栏反而碍眼)
1968
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
1969
+ if (inFence) { out.push(line); continue; }
1970
+
1971
+ // 表格:本行是 |…| 且下一行是分隔行 |---|---|
1972
+ if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
1973
+ const cells = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
1974
+ const head = cells(line);
1975
+ i += 2;
1976
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
1977
+ const vals = cells(lines[i]);
1978
+ const parts = vals
1979
+ .map((v, k) => (v ? `${head[k] || `列${k + 1}`}:${v}` : ""))
1980
+ .filter(Boolean);
1981
+ if (parts.length) out.push(`· ${parts.join(" | ")}`);
1982
+ i += 1;
1983
+ }
1984
+ i -= 1; // 外层 for 还要 +1
1985
+ continue;
1986
+ }
1987
+
1988
+ const h = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line);
1989
+ if (h) { out.push(`【${h[2].trim()}】`); continue; }
1990
+
1991
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { out.push("———————"); continue; }
1992
+
1993
+ line = line.replace(/^\s{0,3}>\s?/, ""); // 引用
1994
+ line = line.replace(/^(\s*)[-*+]\s+/, "$1· "); // 无序列表统一
1995
+
1996
+ line = line
1997
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "$1:$2") // 链接 → 文字:裸URL
1998
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1999
+ .replace(/__([^_]+)__/g, "$1")
2000
+ .replace(/\*(?!\s)([^*\n]+?)(?<!\s)\*/g, "$1")
2001
+ .replace(/(?<![\w_])_(?!\s)([^_\n]+?)(?<!\s)_(?![\w_])/g, "$1")
2002
+ .replace(/`([^`]+)`/g, "$1");
2003
+
2004
+ out.push(line);
2005
+ }
2006
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
2007
+ }
2008
+
1907
2009
  const STOP_REASONS = Object.freeze({
1908
2010
  completed: "正常完成",
1909
2011
  aborted: "被中止",
@@ -1958,50 +2060,58 @@ export function formatCompletion({ title, reason, summary, sessionId, hanging }
1958
2060
  const reasonText = stopReasonText(reasonKey);
1959
2061
  const name = String(title ?? "").trim();
1960
2062
  const sid = String(sessionId ?? "").trim();
1961
- const body = String(summary ?? "").trim();
1962
- const summaryMax = Number.isFinite(opts.summaryMaxLength) ? opts.summaryMaxLength : 400;
2063
+ // 结论是**模型产出**,通常是 Markdown —— 先转成微信能看的纯文本(表格/加粗/链接见转换器注释)
2064
+ const bodyText = markdownToWechatText(summary);
2065
+ const summaryMax = Number.isFinite(opts.summaryMaxLength) ? opts.summaryMaxLength : COMPLETION_SUMMARY_MAX;
1963
2066
  const ttlMinutes = Number.isFinite(opts.ttlMinutes) ? opts.ttlMinutes : DEFAULT_REPLY_TTL_MINUTES;
1964
- const lines = [];
1965
2067
 
1966
- // ① 结果头
1967
- lines.push(`【任务${reasonText}】`);
2068
+ // ① 头部:结束方式 + 是哪个任务(用户第一眼要找的两件事)
2069
+ const head = [`【任务${reasonText}】`];
1968
2070
  const note = COMPLETION_REASON_NOTES[reasonKey];
1969
- if (note) lines.push(note);
1970
-
1971
- // ② 哪个任务(用户第一眼要找的就是它)
2071
+ if (note) head.push(note);
1972
2072
  if (name) {
1973
- lines.push(`会话:${name}`);
2073
+ head.push(`会话:${name}`);
1974
2074
  } else if (sid) {
1975
2075
  // 没名字但知道 id → 给短号,用户能在 /ls 里对上号(总比"未命名"强)
1976
- lines.push(`会话:未命名(${sid.replace(/^session-/, "").slice(0, 8)})`);
2076
+ head.push(`会话:未命名(${sid.replace(/^session-/, "").slice(0, 8)})`);
1977
2077
  } else {
1978
- lines.push("会话:未命名(这次没取到会话名)");
2078
+ head.push("会话:未命名(这次没取到会话名)");
1979
2079
  }
1980
2080
 
1981
- // ③ 结论
2081
+ // ② 结论(**独立成段**,保留原有换行 —— 压成一行正是"密密麻麻"的来源)。
1982
2082
  // 只有「能回话」的档位才邀请用户回复 —— 否则那是对一个做不到的人下指令
1983
2083
  // (免费用户回复纯文本只会拿到付费引导,见 wechat-runtime 的 #upsellText)。
1984
2084
  const canContinue = opts.continuationHint === undefined;
1985
- if (body) {
1986
- lines.push(`结论:${shorten(body, summaryMax)}`);
2085
+ let conclusion;
2086
+ if (bodyText) {
2087
+ conclusion = clipText(bodyText, summaryMax);
1987
2088
  } else if (hanging) {
1988
- lines.push(canContinue
1989
- ? "结论:暂时没取到(任务可能还在收尾)。回复 /summary 可以再要一次。"
1990
- : "结论:暂时没取到(任务可能还在收尾)。");
2089
+ conclusion = canContinue
2090
+ ? "暂时没取到(任务可能还在收尾)。回复 /summary 可以再要一次。"
2091
+ : "暂时没取到(任务可能还在收尾)。";
1991
2092
  } else {
1992
- lines.push(canContinue
1993
- ? "结论:这次没有产出结论。回复一句话就能追问。"
1994
- : "结论:这次没有产出结论。");
2093
+ conclusion = canContinue
2094
+ ? "这次没有产出结论。回复一句话就能追问。"
2095
+ : "这次没有产出结论。";
1995
2096
  }
1996
2097
 
1997
- // ④ 收尾:回复即续接同一会话(付费档的产品核心,别删这句)。
1998
- // 免费档由调用方传 `continuationHint` 换成"会员可用 + App 链接"的说法。
1999
- lines.push(opts.continuationHint || "回复这条消息(直接说下一步)就能接着这个会话往下做,不用重新交代背景。");
2098
+ // ③ 下一步:系统注入的内容**单独分区**(不再和正文拖在一起)。
2099
+ // 付费档 = "回复即可续接"(产品核心);免费档由调用方换成"会员可用 + App 链接"。
2100
+ const continuation = opts.continuationHint
2101
+ || "· 直接回复一句话,就能接着这个会话往下做(不用重新交代背景)";
2000
2102
 
2001
- // 纯文本 + 长度上限:微信不是富客户端,超长整条截断并显式标注
2002
- let text = lines.join("\n");
2003
- const maxLen = Number.isFinite(opts.maxLength) ? opts.maxLength : 900;
2004
- if (text.length > maxLen) text = `${text.slice(0, maxLen - 20)}\n…(内容过长已截断)`;
2103
+ const headSection = head.join("\n");
2104
+ const tailSection = ["—— 下一步 ——", continuation].join("\n");
2105
+ let text = `${headSection}\n\n—— 结论 ——\n${conclusion}\n\n${tailSection}`;
2106
+
2107
+ // ④ 长度安全网:**只压正文**,头部与「下一步」永不截断。
2108
+ // ⚠️ 旧实现把整条 join 后盲切尾部 —— 而"下一步/回复 N"恰好拼在最后,
2109
+ // 于是一条超长消息会把**用户唯一能照做的那句话**切掉(真机投诉过的形态)。
2110
+ const maxLen = Number.isFinite(opts.maxLength) ? opts.maxLength : COMPLETION_TEXT_MAX;
2111
+ if (text.length > maxLen) {
2112
+ const room = Math.max(80, maxLen - headSection.length - tailSection.length - 30);
2113
+ text = `${headSection}\n\n—— 结论 ——\n${clipText(bodyText || conclusion, room)}\n\n${tailSection}`;
2114
+ }
2005
2115
 
2006
2116
  return { kind: "completed", title: `任务${reasonText}`, text, replyable: false, ttlMinutes };
2007
2117
  }