@qzsy/dsh-bridge 2.18.0 → 3.0.1

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 (81) hide show
  1. package/CHANGELOG.md +15 -1094
  2. package/LICENSE +21 -21
  3. package/README.md +73 -476
  4. package/client/build.mjs +1 -3
  5. package/client/client.js +381 -9747
  6. package/client/index.js +242 -6828
  7. package/client/locales.mjs +99 -233
  8. package/cordis.patch.yml +5 -3
  9. package/lib/config.js +93 -0
  10. package/lib/data-dir.js +16 -0
  11. package/lib/dsh-cookie.js +111 -0
  12. package/lib/gate.js +414 -0
  13. package/lib/identity.js +23 -0
  14. package/lib/index.js +303 -3496
  15. package/lib/saas/tunnel-client.mjs +529 -467
  16. package/locale/en.json +3 -3
  17. package/locale/zh.json +3 -3
  18. package/package.json +15 -70
  19. package/README.en.md +0 -488
  20. package/client/host-families.js +0 -442
  21. package/client/mobile-styles.js +0 -1299
  22. package/client/mobile-yield.js +0 -219
  23. package/client/panel-locales.mjs +0 -777
  24. package/client/picker-yield.js +0 -77
  25. package/client/resource-url-compat.js +0 -90
  26. package/client/settings-nav-icon.js +0 -105
  27. package/client/theme-tokens.js +0 -49
  28. package/client/tunnel-guard.js +0 -46
  29. package/client/unlock-manager.js +0 -144
  30. package/lib/auth/dsh-native-cookie.js +0 -148
  31. package/lib/auth/login-template.js +0 -516
  32. package/lib/auth/manager.js +0 -596
  33. package/lib/bridge-rpc-constants.js +0 -66
  34. package/lib/bridge-rpc.js +0 -722
  35. package/lib/cloudflared-manager.mjs +0 -1277
  36. package/lib/cloudflared-network-args.js +0 -99
  37. package/lib/cloudflared-process-scan.js +0 -153
  38. package/lib/compat.js +0 -172
  39. package/lib/connection-compat.js +0 -115
  40. package/lib/feishu/comment.js +0 -50
  41. package/lib/feishu/gateway.js +0 -644
  42. package/lib/feishu/index.js +0 -432
  43. package/lib/feishu/lark-bundled.mjs +0 -131929
  44. package/lib/feishu/markdown.js +0 -148
  45. package/lib/feishu/node.js +0 -1152
  46. package/lib/feishu/slash-commands.js +0 -255
  47. package/lib/platform/account-ops.js +0 -248
  48. package/lib/platform/activity.js +0 -169
  49. package/lib/platform/ask-user.js +0 -293
  50. package/lib/platform/base.js +0 -147
  51. package/lib/platform/changes-digest.js +0 -93
  52. package/lib/platform/commands.js +0 -267
  53. package/lib/platform/config-restore.js +0 -78
  54. package/lib/platform/conversation-bridge.js +0 -1544
  55. package/lib/platform/dsh-storage.js +0 -222
  56. package/lib/platform/index.js +0 -10
  57. package/lib/platform/instances.js +0 -458
  58. package/lib/platform/manager.js +0 -130
  59. package/lib/platform/message-split.js +0 -229
  60. package/lib/platform/session-catalog.js +0 -405
  61. package/lib/platform/session-config.js +0 -52
  62. package/lib/platform/stream-slices.js +0 -21
  63. package/lib/qq/gateway.js +0 -1107
  64. package/lib/qq/index.js +0 -561
  65. package/lib/qq/node.js +0 -1153
  66. package/lib/qq/qqbot-bundled.mjs +0 -8273
  67. package/lib/restart-helper.mjs +0 -133
  68. package/lib/security/log-sanitize.js +0 -50
  69. package/lib/security/path-validator.js +0 -195
  70. package/lib/security/rate-limiter.js +0 -93
  71. package/lib/session-strip.js +0 -100
  72. package/lib/sse-keepalive.js +0 -115
  73. package/lib/telegram/gateway.js +0 -643
  74. package/lib/telegram/index.js +0 -255
  75. package/lib/telegram/node.js +0 -410
  76. package/lib/tunnel-client.mjs +0 -537
  77. package/lib/wechat/gateway.js +0 -1000
  78. package/lib/wechat/index.js +0 -282
  79. package/lib/wechat/media.js +0 -302
  80. package/lib/wechat/node.js +0 -360
  81. package/llms.txt +0 -36
@@ -1,293 +0,0 @@
1
- // Agent 决策问答(ask_user_question / 计划审批)的**平台无关**核心。
2
- //
3
- // 背景:DSH 把"Agent 需要用户做个决定"统一收敛到 `dsh-user-questions` 服务,
4
- // 机制是 cordis **waterfall 钩子** `user-questions/request`:
5
- //
6
- // agent 调 ask_user_question 工具
7
- // → userQuestions.askTimed(request, callId, timeoutMs)
8
- // → ctx.waterfall('user-questions/request', request, noAnswerer)
9
- // ↑ 应答者在这里接管:渲染问题、等用户选择、返回答案批次
10
- // ↑ 无人接管 → reject('NO_PROVIDER')
11
- //
12
- // 在此之前,本插件**完全没有注册该钩子**:IM 用户看不到任何提问,Agent 只能拿到
13
- // NO_PROVIDER(表现为"它想问却问不出来",或超时后以 pending 继续)。
14
- // 本模块提供渲染与答案规整的**纯函数**,供各 IM 平台复用;桥接与平台渲染分别在
15
- // conversation-bridge.js(归属判定/生命周期)与各平台节点(卡片/按钮)里实现。
16
- //
17
- // 请求/答案的数据形状(对齐宿主 schema,勿凭想象改):
18
- // request = { questions: Question[], agent?, signal?, wait?: { callId, timed } }
19
- // Question = { id, question, detail?, header?, options?: [{label, description?}], multiSelect? }
20
- // answer = { answers: [{ id, selected: string[], custom?: string }] } ← 每问恰好一项
21
- //
22
- // 一个重要语义(决定交互设计):**选项可以为空**(纯自由输入式提问);`multiSelect`
23
- // 才允许选多个。答案里 `selected: []` 且无 `custom` 表示"用户显式跳过",与"pending"
24
- // (超时未答,稍后仍可答)是两件事。
25
-
26
- /** 常规输入的单选项上限(超出时提示用户改用文字回复,避免卡片按钮爆炸)。 */
27
- export const MAX_CARD_OPTIONS = 6
28
-
29
- /** 截断过长文本(标题/详情),保持 IM 可读。 */
30
- export function clip(text, max = 120) {
31
- const s = String(text ?? '').replace(/\s+/g, ' ').trim()
32
- return s.length > max ? `${s.slice(0, max - 1)}…` : s
33
- }
34
-
35
- /**
36
- * 问题的展示标题:header 优先,其次问题正文单行。
37
- * @param {object} question
38
- */
39
- export function questionTitle(question) {
40
- const header = clip(question?.header, 40)
41
- if (header) return header
42
- return clip(question?.question, 60) || '请选择'
43
- }
44
-
45
- /**
46
- * 是否属于"计划审批"型提问。
47
- *
48
- * 宿主把 plan-review 也塞进同一入口,用 `question.intent.kind === 'plan-review'` 标记
49
- * (见 dsh-client-ui-user-questions 的 planReviewOf)。识别出来就能共用一套 UI,
50
- * 只是标题/按钮措辞更贴合"审阅计划"。
51
- */
52
- export function isPlanReview(question) {
53
- return question?.intent?.kind === 'plan-review'
54
- }
55
-
56
- /**
57
- * 判断该问题能否用**纯按钮**表达。
58
- *
59
- * 不满足时任选其一:
60
- * - 选项为空 → 必须让用户自由输入(卡片按钮无能为力);
61
- * - 选项过多 → 卡片按钮会撑爆(IM 卡片没有滚动列表),退回文字回复更好用。
62
- * 注意 multiSelect **不**排除按钮方案:多选可用"逐个切换 + 确认"实现,但首版
63
- * 为稳妥起见,把多选也交回文字回复(见 formatAskPrompt 的提示语),
64
- * 避免用户在卡片上误以为只能单选。
65
- *
66
- * @returns {boolean}
67
- */
68
- export function canRenderAsButtons(question, { maxOptions = MAX_CARD_OPTIONS } = {}) {
69
- const options = Array.isArray(question?.options) ? question.options : []
70
- if (options.length === 0) return false
71
- if (options.length > maxOptions) return false
72
- if (question?.multiSelect === true) return false
73
- return true
74
- }
75
-
76
- /**
77
- * 把一批问题渲染成 IM 文本(**回退路径**,也是不支持按钮的平台的主路径)。
78
- *
79
- * 编号规则:`Q1/Q2…` 问题、`1/2…` 选项。用户回复形如:
80
- * `/ask 1` → Q1 选第 1 项
81
- * `/ask 1 2` → Q1 选第 2 项
82
- * `/ask 1=2,3` → Q1 多选(配合 multiSelect)
83
- * `/ask 1=自由文本` → 自由输入
84
- * 多条用 `;` 分隔,例如 `/ask 1; 2=1`。
85
- *
86
- * @param {object[]} questions
87
- * @param {{ number?: number, timeoutSec?: number, header?: string }} [opts]
88
- * @returns {string}
89
- */
90
- export function formatAskPrompt(questions, { number = 1, timeoutSec = 0, header = '' } = {}) {
91
- const list = Array.isArray(questions) ? questions : []
92
- const lines = []
93
- const plan = list.some(isPlanReview)
94
- lines.push(`## ${plan ? '📋 计划待确认' : '❓ 需要你的决定'}${number ? ` (#${number})` : ''}`)
95
- if (header) lines.push('', String(header))
96
- if (timeoutSec > 0) {
97
- const min = Math.max(1, Math.round(timeoutSec / 60))
98
- lines.push('', `> 等待超时:${min} 分钟(超时后 Agent 可能先继续其它工作,你稍后仍可回复)`)
99
- }
100
- lines.push('')
101
-
102
- list.forEach((q, qi) => {
103
- const title = questionTitle(q)
104
- const detail = clip(q?.detail, 500)
105
- lines.push(`**Q${qi + 1}. ${title}**`)
106
- if (detail) lines.push(detail)
107
- const options = Array.isArray(q?.options) ? q.options : []
108
- if (options.length === 0) {
109
- lines.push('- (自由输入,无预设选项)')
110
- } else {
111
- options.forEach((opt, oi) => {
112
- const desc = clip(opt?.description, 120)
113
- // 推荐项:宿主约定把推荐项放第一位并带 "(Recommended)" 标注
114
- const recommended = /\(recommended\)|(推荐)/i.test(String(opt?.label ?? ''))
115
- lines.push(`- \`${oi + 1}\` ${clip(opt?.label, 60)}${recommended ? ' ⭐' : ''}${desc ? ` — ${desc}` : ''}`)
116
- })
117
- if (q?.multiSelect === true) lines.push(' *(可多选,用逗号分隔,如 `1,3`)*')
118
- }
119
- lines.push('')
120
- })
121
-
122
- const n = list.length
123
- lines.push('**回复方式**')
124
- if (n === 1) {
125
- const multiSel = list[0]?.multiSelect === true
126
- lines.push(multiSel ? '- 选多项:`/ask 1,3`(逗号分隔)' : '- 选一项:`/ask 1`(选第 1 个选项)')
127
- lines.push('- 自由输入:`/ask =你的答案`')
128
- } else {
129
- lines.push(`- 逐个作答:\`/ask 1; 2; 3\`(按顺序对应 Q1/Q2/Q3;共 ${n} 问)`)
130
- lines.push('- 指定问题:`/ask 2=1`(Q2 选第 1 项)')
131
- lines.push('- 跳过某问:留空,如 `/ask 1; ; 2`')
132
- }
133
- return lines.join('\n').trim()
134
- }
135
-
136
- /**
137
- * 解析用户文字回复为答案批次。
138
- *
139
- * 支持的写法(宽松解析,便于手机输入):
140
- * `/ask 1` 单问:选第 1 项
141
- * `/ask =自由文本` 单问:自由输入
142
- * `/ask 1; 2; 3` 多问按序作答
143
- * `/ask 1=2` 指定 Q1 选第 2 项
144
- * `/ask Q1=2` Q 前缀大小写不敏感
145
- * `/ask 1=1,3` 多选
146
- * `/ask 1=2; 2=自由文本` 混合
147
- *
148
- * @param {string} text 用户消息原文(可含 `/ask` 前缀)
149
- * @param {object[]} questions 与请求同序的问题数组
150
- * @returns {{ ok: true, answers: object[] } | { ok: false, error: string }}
151
- */
152
- export function parseAskReply(text, questions) {
153
- const list = Array.isArray(questions) ? questions : []
154
- if (list.length === 0) return { ok: false, error: '当前没有待回答的问题。' }
155
-
156
- let raw = String(text ?? '').trim()
157
- if (!raw) return { ok: false, error: '回复内容为空。' }
158
- // 允许带 /ask、/answer、/a 前缀;也允许纯内容(如 `1`)
159
- raw = raw.replace(/^\/(?:ask|answer|a)\b[::]?\s*/i, '').trim()
160
- if (!raw) return { ok: false, error: '请在 /ask 后给出你的选择,例如 `/ask 1`。' }
161
- // 全局前缀 `=`:`/ask =自由文本` 表示"直接自由输入"(不指定问题编号,
162
- // 单问时等价于给该问 custom;多问时报错提示需指定编号,避免歧义)。
163
- if (raw.startsWith('=')) {
164
- const body = raw.slice(1).trim()
165
- if (list.length !== 1) {
166
- return { ok: false, error: `请指明问题编号,例如 \`/ask 1=${body || '你的答案'}\`(共 ${list.length} 问)。` }
167
- }
168
- if (!body) return { ok: false, error: '自由输入内容为空。' }
169
- return { ok: true, answers: [{ id: list[0].id, selected: [], custom: body }] }
170
- }
171
-
172
- // 分段:分号优先;单问时整串就是一段(允许内容里含逗号等)
173
- const segments = raw.split(/[;;]/).map((s) => s.trim()).filter((s) => s !== '')
174
- const byIndex = new Map()
175
- const positional = []
176
-
177
- for (const seg of segments) {
178
- // Q1=... / 1=...
179
- const m = /^(?:q|Q)?\s*(\d+)\s*[=::]\s*([\s\S]*)$/.exec(seg)
180
- if (m) {
181
- const qi = Number(m[1]) - 1
182
- if (!Number.isInteger(qi) || qi < 0 || qi >= list.length) {
183
- return { ok: false, error: `问题编号 ${m[1]} 超出范围(共 ${list.length} 问)。` }
184
- }
185
- byIndex.set(qi, m[2])
186
- } else {
187
- positional.push(seg)
188
- }
189
- }
190
-
191
- // 位置式:按序填进尚未指定的问题
192
- let posCursor = 0
193
- for (const seg of positional) {
194
- while (posCursor < list.length && byIndex.has(posCursor)) posCursor += 1
195
- if (posCursor >= list.length) return { ok: false, error: `回复的问题数量多于待答问题(共 ${list.length} 问)。` }
196
- byIndex.set(posCursor, seg)
197
- posCursor += 1
198
- }
199
-
200
- const answers = list.map((q, qi) => {
201
- const value = byIndex.get(qi)
202
- // 未提及 → 视为显式跳过(selected 为空、无 custom)
203
- if (value === undefined || value === '') return { id: q.id, selected: [] }
204
- const options = Array.isArray(q?.options) ? q.options : []
205
-
206
- // 纯数字/逗号 → 选选项
207
- if (/^\s*\d+(\s*[,,]\s*\d+)*\s*$/.test(value)) {
208
- const picks = value.split(/[,,]/).map((x) => Number(x.trim()))
209
- const selected = []
210
- for (const p of picks) {
211
- if (!Number.isInteger(p) || p < 1 || p > options.length) {
212
- return { __error: `Q${qi + 1} 的选项编号 ${p} 无效(共 ${options.length} 个选项)。` }
213
- }
214
- const label = String(options[p - 1]?.label ?? '')
215
- if (selected.includes(label)) continue
216
- if (selected.length > 0 && q?.multiSelect !== true) {
217
- return { __error: `Q${qi + 1} 不是多选,请只给一个选项编号。` }
218
- }
219
- selected.push(label)
220
- }
221
- return { id: q.id, selected }
222
- }
223
-
224
- // 字母编号(a/b/c)→ 也当选项(部分用户会这么回)
225
- const letters = value.trim().toLowerCase()
226
- if (/^[a-z](\s*[,,]\s*[a-z])*$/.test(letters) && options.length > 0) {
227
- const picks = letters.split(/[,,]/).map((x) => x.trim().charCodeAt(0) - 96)
228
- if (picks.every((p) => p >= 1 && p <= options.length)) {
229
- const selected = picks.map((p) => String(options[p - 1]?.label ?? ''))
230
- if (selected.length > 1 && q?.multiSelect !== true) return { __error: `Q${qi + 1} 不是多选,请只给一个选项。` }
231
- return { id: q.id, selected }
232
- }
233
- }
234
-
235
- // 其它一律当自由输入
236
- return { id: q.id, selected: [], custom: value }
237
- })
238
-
239
- const bad = answers.find((a) => a.__error)
240
- if (bad) return { ok: false, error: bad.__error }
241
- return { ok: true, answers: answers.map(({ __error, ...rest }) => rest) }
242
- }
243
-
244
- /**
245
- * 校验答案批次是否满足宿主约束:**每个问题恰好一项**。
246
- *
247
- * 宿主 `userQuestions.answer()` 对不满足的批次抛 BAD_ANSWER,因此必须在这里先自查,
248
- * 避免把非法批次送出去(表现为"点了没反应")。
249
- *
250
- * @param {object[]} answers
251
- * @param {object[]} questions
252
- * @returns {{ ok: true } | { ok: false, error: string }}
253
- */
254
- export function validateAnswerBatch(answers, questions) {
255
- const list = Array.isArray(questions) ? questions : []
256
- const batch = Array.isArray(answers) ? answers : []
257
- if (batch.length !== list.length) {
258
- return { ok: false, error: `答案条目数(${batch.length})与问题数(${list.length})不一致。` }
259
- }
260
- const seen = new Set()
261
- for (const ans of batch) {
262
- if (!ans || typeof ans.id !== 'string') return { ok: false, error: '答案缺少 id。' }
263
- if (seen.has(ans.id)) return { ok: false, error: `答案重复命名了问题 ${ans.id}。` }
264
- seen.add(ans.id)
265
- if (!Array.isArray(ans.selected)) return { ok: false, error: `问题 ${ans.id} 的 selected 必须是数组。` }
266
- if (ans.custom !== undefined && typeof ans.custom !== 'string') return { ok: false, error: `问题 ${ans.id} 的 custom 必须是字符串。` }
267
- }
268
- for (const q of list) {
269
- if (!seen.has(q?.id)) return { ok: false, error: `答案未覆盖问题 ${q?.id}。` }
270
- }
271
- return { ok: true }
272
- }
273
-
274
- /**
275
- * 生成的答案在 IM 里回显一行,便于用户确认自己的选择被正确理解。
276
- * @param {object[]} answers
277
- * @param {object[]} questions
278
- */
279
- export function formatAnswerAck(answers, questions, { number = 0 } = {}) {
280
- const list = Array.isArray(questions) ? questions : []
281
- const rows = (Array.isArray(answers) ? answers : []).map((ans) => {
282
- const q = list.find((item) => item?.id === ans.id)
283
- const title = clip(q?.header || q?.question, 30) || ans.id
284
- const picked = ans.selected?.length ? ans.selected.join('、') : (ans.custom ? '' : '(已跳过)')
285
- const value = ans.custom ? `${picked ? `${picked};` : ''}${clip(ans.custom, 80)}` : picked
286
- return `- **${title}** → ${value || '(已跳过)'}`
287
- })
288
- const head = `✓ 已提交你的选择${number ? ` (#${number})` : ''}`
289
- return [head, ...rows].join('\n')
290
- }
291
-
292
- /** 按钮回调数据里承载的前缀(与审批的 action 值区分开)。 */
293
- export const ASK_ACTION_PREFIX = 'ask'
@@ -1,147 +0,0 @@
1
- // dsh-bridge 平台抽象基类
2
- //
3
- // 定义 IM 平台适配器的统一接口。每个平台(微信/QQ/飞书/Telegram…)继承本类,
4
- // 实现协议层(登录、收发消息、typing)。平台无关的会话桥逻辑在 ConversationBridge
5
- // (lib/platform/conversation-bridge.js)中实现,通过 platform 注入到 bridge。
6
- //
7
- // 生命周期:constructor → start() → stop() → dispose()
8
- // 消息抽象:sendText / sendTyping / sendMedia(由子类实现)
9
-
10
- export class Platform {
11
- /**
12
- * @param {object} opts
13
- * @param {object} opts.ctx Cordis 上下文
14
- * @param {object} opts.logger 日志器
15
- * @param {object} [opts.config] 已持久化的平台配置(凭证等)
16
- * @param {(patch: object) => (void|Promise<void>)} [opts.onPersist] 主插件保存回调
17
- * @param {import('./conversation-bridge.js').ConversationBridge} [opts.bridge] 会话桥实例
18
- */
19
- constructor({ ctx, logger, config = {}, onPersist, bridge } = {}) {
20
- this.ctx = ctx
21
- this.logger = logger
22
- this.config = { ...config }
23
- this.onPersist = onPersist ?? (() => {})
24
- this.bridge = bridge ?? null
25
-
26
- // 平台标识(子类必须设置)
27
- this.id = ''
28
- this.name = ''
29
-
30
- // 连接状态与账号:子类可能用 getter 覆盖(如委托给 gateway),
31
- // 因此仅在未被子类覆盖时才初始化默认值。
32
- if (!('status' in this)) this.status = 'idle'
33
- if (!('accountId' in this)) this.accountId = null
34
-
35
- // 扫码/登录的流式状态(RPC 轮询读取)
36
- this.loginState = {
37
- phase: 'idle', // idle | qr | scaned | confirmed | done | error
38
- qrPayload: null, // 待渲染内容:dataURL 图片 或 二维码文本
39
- qrKind: null, // 'img' | 'text'
40
- error: null,
41
- }
42
-
43
- this.disposers = []
44
- }
45
-
46
- // ---- 平台能力声明(子类可覆盖)----
47
-
48
- get capabilities() {
49
- return {
50
- supportsGroup: false, // 是否支持群聊
51
- supportsMedia: false, // 是否支持媒体收发
52
- supportsVoice: false, // 是否支持语音
53
- supportsTyping: false, // 是否支持 typing 状态
54
- maxMessageChars: 2000, // 单条消息最大字符数
55
- }
56
- }
57
-
58
- get configured() {
59
- return false
60
- }
61
-
62
- // ---- 生命周期(子类必须实现 start/stop;dispose 已提供默认实现)----
63
-
64
- async start() {
65
- throw new Error(`${this.id || 'platform'}: start() not implemented`)
66
- }
67
-
68
- async stop() {
69
- throw new Error(`${this.id || 'platform'}: stop() not implemented`)
70
- }
71
-
72
- dispose() {
73
- for (const disposer of this.disposers) {
74
- try { disposer() } catch { /* 忽略 */ }
75
- }
76
- this.disposers = []
77
- this.bridge?.dispose?.()
78
- this.bridge = null
79
- }
80
-
81
- // ---- 消息抽象(子类必须实现)----
82
-
83
- async sendText(peerId, text, opts = {}) {
84
- throw new Error(`${this.id || 'platform'}: sendText() not implemented`)
85
- }
86
-
87
- async sendTyping(peerId, state) {
88
- return Promise.resolve()
89
- }
90
-
91
- async sendMedia(peerId, media, opts = {}) {
92
- throw new Error(`${this.id || 'platform'}: sendMedia() not implemented`)
93
- }
94
-
95
- // ---- 登录(子类必须实现 login;getLoginState 已提供默认)----
96
-
97
- async login(opts = {}) {
98
- throw new Error(`${this.id || 'platform'}: login() not implemented`)
99
- }
100
-
101
- getLoginState() {
102
- return { ...this.loginState }
103
- }
104
-
105
- // ---- 状态汇总(供 RPC/UI 读取)----
106
-
107
- getStatus() {
108
- return {
109
- id: this.id,
110
- name: this.name,
111
- status: this.status,
112
- configured: this.configured,
113
- accountId: this.accountId,
114
- login: this.getLoginState(),
115
- peerId: this.bridge?.peerId ?? null,
116
- sessionId: this.bridge?.activeSessionId ?? null,
117
- config: this.getEditableConfig?.(),
118
- }
119
- }
120
-
121
- /** 可编辑配置(供 UI 设置面板读取);子类可覆盖返回具体字段。 */
122
- getEditableConfig() {
123
- return {}
124
- }
125
-
126
- // ---- 工具 ----
127
-
128
- setStatus(status) {
129
- if (this.status === status) return
130
- this.status = status
131
- try {
132
- this.ctx.emit?.(`${this.id}/status`, status)
133
- } catch { /* emit 失败不致命 */ }
134
- }
135
-
136
- async persist(patch) {
137
- try {
138
- await this.onPersist(patch)
139
- } catch (err) {
140
- this.logger?.warn?.(`[dsh-bridge ${this.id}] persist failed: %s`, err?.message ?? err)
141
- }
142
- }
143
-
144
- async destroy() {
145
- this.dispose()
146
- }
147
- }
@@ -1,93 +0,0 @@
1
- // 本轮改动摘要 / 统一 diff 生成(纯函数,便于单测)
2
- //
3
- // 数据来源:宿主 `@deepseek-ai/dsh-workspace-changes` 服务(Web 端「改动/交付物」面板的同一份数据)
4
- // ctx.workspaceChanges.summary(sessionId, seq) → {
5
- // turn, cwd, total, added, deleted,
6
- // files: [{ path, display, added, deleted, binary?, oversized? }] // 已按 display 排序、条数受宿主 cap
7
- // }
8
- // ctx.workspaceChanges.diff(sessionId, seq, index, signal) → { kind:'text', hunks:[{oldStart,oldLines,newStart,newLines,lines[]}] }
9
- // | { kind:'binary'|'oversized', path, display }
10
- //
11
- // 本模块只负责「把这些数据变成 IM 里可读的摘要文本 / 可下载的 patch 文本」,不做任何 IO。
12
-
13
- /** 单个文件的改动量标注(二进制/过大文件宿主不给行数) */
14
- function describeCounts(file) {
15
- if (file?.oversized) return '过大,未统计'
16
- if (file?.binary) return '二进制'
17
- return `+${Number(file?.added) || 0}/-${Number(file?.deleted) || 0}`
18
- }
19
-
20
- /** 去掉可能破坏 Markdown 代码块的字符(路径里出现反引号时) */
21
- function safeLabel(text) {
22
- return String(text ?? '').replace(/`/g, '')
23
- }
24
-
25
- /**
26
- * 生成本轮改动摘要文本;**没有改动时返回空串**(调用方据此静默,不刷屏)。
27
- *
28
- * @param {object} summary ctx.workspaceChanges.summary(...) 的返回值
29
- * @param {object} [opts]
30
- * @param {number} [opts.maxFiles=8] 最多逐条列出的文件数(其余折成"…等 N 个")
31
- * @returns {string}
32
- */
33
- export function formatChangesDigest(summary, { maxFiles = 8 } = {}) {
34
- const total = Number(summary?.total) || 0
35
- if (!summary || total <= 0) return ''
36
- const files = Array.isArray(summary.files) ? summary.files : []
37
- const added = Number(summary.added) || 0
38
- const deleted = Number(summary.deleted) || 0
39
-
40
- const head = `📝 **本轮改动** ${total} 个文件(+${added}/-${deleted})`
41
- const shown = files.slice(0, Math.max(0, maxFiles))
42
- const lines = shown.map((f) => `- \`${safeLabel(f?.display || f?.path || '(未知)')}\` ${describeCounts(f)}`)
43
- const rest = total - shown.length
44
- if (rest > 0) lines.push(`- …共 ${total} 个(其余 ${rest} 个未逐条列出)`)
45
- if (lines.length === 0) lines.push(`- (宿主未提供逐文件明细,共 ${total} 个)`)
46
- return [head, ...lines].join('\n')
47
- }
48
-
49
- /**
50
- * 生成统一 diff(.patch)文本:逐文件 `diff --git` + `---/+++` + hunk 头 + hunk 行。
51
- * 与 `git apply` 的路径匹配不保证(宿主给的是 display 路径,可能是 `../` 或 `~` 形式),
52
- * 主要用途是"发给用户看/存档",因此保留宿主原样 display 作为路径。
53
- *
54
- * @param {object} summary
55
- * @param {Array<object|undefined>} diffs 与 summary.files 同序的 diff 结果
56
- * @returns {string}
57
- */
58
- export function buildUnifiedPatch(summary, diffs = []) {
59
- const total = Number(summary?.total) || 0
60
- const files = Array.isArray(summary.files) ? summary.files : []
61
- const head = [
62
- `# dsh-bridge 本轮改动(第 ${Number(summary?.turn) || 0} 轮)`,
63
- `# 共 ${total} 个文件,+${Number(summary?.added) || 0}/-${Number(summary?.deleted) || 0}`,
64
- `# 工作区:${safeLabel(summary?.cwd || '(未知)')}`,
65
- '',
66
- ].join('\n')
67
-
68
- const body = files.map((file, i) => {
69
- const display = safeLabel(file?.display || file?.path || `file-${i}`)
70
- const diff = diffs[i]
71
- const header = [`diff --git a/${display} b/${display}`, `--- a/${display}`, `+++ b/${display}`]
72
- if (!diff || diff.kind === 'binary' || diff.kind === 'oversized') {
73
- const why = diff?.kind === 'oversized' ? '文件过大,未提供行级 diff' : diff?.kind === 'binary' ? '二进制文件,无行级 diff' : '宿主未提供该文件的 diff'
74
- return [...header, `# ${why}`, ''].join('\n')
75
- }
76
- const hunks = Array.isArray(diff.hunks) ? diff.hunks : []
77
- if (hunks.length === 0) return [...header, '# 无行级差异', ''].join('\n')
78
- const hunkText = hunks.map((h) => {
79
- const head2 = `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`
80
- const lines = Array.isArray(h.lines) ? h.lines : []
81
- return [head2, ...lines].join('\n')
82
- }).join('\n')
83
- return [...header, hunkText, ''].join('\n')
84
- }).join('\n')
85
-
86
- return head + body
87
- }
88
-
89
- /** patch 附件文件名(含轮次,便于用户区分) */
90
- export function changesPatchFileName(turn) {
91
- const n = Number(turn) || 0
92
- return `changes-turn-${n}.patch`
93
- }