weflow-cli 1.12.0 → 1.13.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 (51) hide show
  1. package/CHANGELOG.md +217 -1
  2. package/OPERATIONS.md +254 -19
  3. package/README.md +4 -0
  4. package/bin/weflow-cli.ts +260 -51
  5. package/dist/bin/weflow-cli.js +255 -32
  6. package/dist/bin/weflow-cli.js.map +1 -1
  7. package/dist/src/core/dbPathService.d.ts.map +1 -1
  8. package/dist/src/core/dbPathService.js +5 -0
  9. package/dist/src/core/dbPathService.js.map +1 -1
  10. package/dist/src/core/ntCore.d.ts +12 -1
  11. package/dist/src/core/ntCore.d.ts.map +1 -1
  12. package/dist/src/core/ntCore.js +23 -3
  13. package/dist/src/core/ntCore.js.map +1 -1
  14. package/dist/src/core/sqlcipherCore.d.ts +8 -0
  15. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  16. package/dist/src/core/sqlcipherCore.js +46 -11
  17. package/dist/src/core/sqlcipherCore.js.map +1 -1
  18. package/dist/src/services/configService.d.ts +3 -0
  19. package/dist/src/services/configService.d.ts.map +1 -1
  20. package/dist/src/services/configService.js +6 -3
  21. package/dist/src/services/configService.js.map +1 -1
  22. package/dist/src/services/diagnosticReport.d.ts +80 -0
  23. package/dist/src/services/diagnosticReport.d.ts.map +1 -0
  24. package/dist/src/services/diagnosticReport.js +191 -0
  25. package/dist/src/services/diagnosticReport.js.map +1 -0
  26. package/dist/src/services/wechatClient.d.ts +9 -3
  27. package/dist/src/services/wechatClient.d.ts.map +1 -1
  28. package/dist/src/services/wechatClient.js +55 -22
  29. package/dist/src/services/wechatClient.js.map +1 -1
  30. package/docs/DECISIONS.md +347 -1
  31. package/docs/NEXT_ACTIONS.md +7 -1
  32. package/docs/POSITIONING.md +68 -0
  33. package/docs/PROJECT_STATE.md +29 -8
  34. package/package.json +88 -86
  35. package/scripts/_utils.py +120 -4
  36. package/scripts/biz_daily.py +118 -11
  37. package/scripts/daily_task.ps1 +132 -0
  38. package/scripts/export_chat_html.py +12 -1
  39. package/scripts/install_tasks.cmd +32 -0
  40. package/scripts/install_tasks.ps1 +91 -0
  41. package/scripts/jev_client.py +335 -3
  42. package/scripts/nt_decrypt.py +79 -14
  43. package/scripts/pipeline.py +21 -0
  44. package/scripts/repro_customer_init.cmd +86 -0
  45. package/scripts/semantic_search.py +286 -24
  46. package/src/core/dbPathService.ts +5 -0
  47. package/src/core/ntCore.ts +34 -3
  48. package/src/core/sqlcipherCore.ts +47 -11
  49. package/src/services/configService.ts +12 -3
  50. package/src/services/diagnosticReport.ts +237 -0
  51. package/src/services/wechatClient.ts +418 -380
@@ -375,13 +375,17 @@ export class SqlcipherCore {
375
375
  if (!this.db) return { success: false, error: '数据库未打开' }
376
376
 
377
377
  try {
378
+ // 预览必须取**类型**,不能只取正文:非文本消息的 `StrContent` 是 XML/base64 碎片,
379
+ // 直接打出来就是一屏 `(/`$` 这种看不懂的字符(2026-10-19 用户在 `sessions` 里看到)。
380
+ // `Type` 与 `StrContent` 用 char(1) 拼在一次查询里取回 —— 分两条子查询等于把
381
+ // 每个会话的排序做两遍(400 个会话就是两倍的排序)。
378
382
  const stmt = this.db.prepare(`
379
383
  SELECT
380
384
  m.StrTalker as username,
381
385
  MAX(m.CreateTime) as lastTimestamp,
382
- (SELECT m2.StrContent FROM MSG m2
386
+ (SELECT m2.Type || char(1) || COALESCE(m2.StrContent, '') FROM MSG m2
383
387
  WHERE m2.StrTalker = m.StrTalker
384
- ORDER BY m2.CreateTime DESC LIMIT 1) as summary
388
+ ORDER BY m2.CreateTime DESC LIMIT 1) as lastMsg
385
389
  FROM MSG m
386
390
  WHERE m.StrTalker != ''
387
391
  GROUP BY m.StrTalker
@@ -390,15 +394,21 @@ export class SqlcipherCore {
390
394
  `)
391
395
  const rows = stmt.all() as any[]
392
396
 
393
- const sessions: ChatSession[] = rows.map((row: any) => ({
394
- username: row.username || '',
395
- type: (row.username || '').includes('@chatroom') ? 1 : 0,
396
- unreadCount: 0,
397
- summary: row.summary || '',
398
- sortTimestamp: row.lastTimestamp || 0,
399
- lastTimestamp: row.lastTimestamp || 0,
400
- displayName: row.username || '',
401
- }))
397
+ const sessions: ChatSession[] = rows.map((row: any) => {
398
+ const raw = String(row.lastMsg || '')
399
+ const sep = raw.indexOf('\u0001')
400
+ const summaryType = sep >= 0 ? Number(raw.slice(0, sep)) || 0 : 0
401
+ const summaryBody = sep >= 0 ? raw.slice(sep + 1) : raw
402
+ return {
403
+ username: row.username || '',
404
+ type: (row.username || '').includes('@chatroom') ? 1 : 0,
405
+ unreadCount: 0,
406
+ summary: this.previewOf(summaryType, summaryBody),
407
+ sortTimestamp: row.lastTimestamp || 0,
408
+ lastTimestamp: row.lastTimestamp || 0,
409
+ displayName: row.username || '',
410
+ }
411
+ })
402
412
 
403
413
  return { success: true, sessions }
404
414
  } catch (e) {
@@ -453,6 +463,32 @@ export class SqlcipherCore {
453
463
  }
454
464
  }
455
465
 
466
+ /**
467
+ * 会话列表里那一列的**显示形态**(不是完整解析)。
468
+ *
469
+ * 只做两件事:按 `Type` 给标签、把控制字符清掉。不做缩略图、不解 appmsg 的完整 XML ——
470
+ * 这里是列表预览,400 行里每行都解析一次会把 `sessions` 拖成几秒。
471
+ * 口径与 `processMessage` 保持一致(`[图片]` / `[语音]` / `[链接]`…),免得出两套说法。
472
+ */
473
+ private previewOf(localType: number, raw: string): string {
474
+ switch (localType) {
475
+ case 1: // 文本:清掉控制字符后原样显示
476
+ return raw.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim()
477
+ case 3: return '[图片]'
478
+ case 34: return '[语音]'
479
+ case 43: return '[视频]'
480
+ case 47: return '[表情]'
481
+ case 49: return '[链接/文件]'
482
+ case 48: return '[位置]'
483
+ case 42: return '[名片]'
484
+ case 10000: return '[系统消息]'
485
+ case 10002: return '[系统消息]'
486
+ default:
487
+ // 未知类型:给个可读的兜底,**绝不把原始二进制/XML 打到终端里**
488
+ return localType ? `[消息类型 ${localType}]` : '[非文本]'
489
+ }
490
+ }
491
+
456
492
  /**
457
493
  * 处理单条消息,根据类型解析内容
458
494
  */
@@ -49,6 +49,12 @@ interface CliConfig {
49
49
  typesafeApiKey: string
50
50
  // 阿里云百炼(DashScope)API Key —— 语义检索 / RAG 的 embedding 用它
51
51
  dashscopeApiKey: string
52
+ // 决策模型走哪条通路:native(默认,api.typesafe.ai)/ openrouter(本机原生被地区封锁时的第二条路)
53
+ judgeProvider: string
54
+ // OpenRouter API Key —— judgeProvider=openrouter 时用它(D-120)
55
+ openrouterApiKey: string
56
+ // OpenRouter 上用的模型 id(默认 typesafe/jev-router,即 TypeSafe 发布的决策模型)
57
+ openrouterJudgeModel: string
52
58
  // 朋友圈 (SNS) 数据库
53
59
  snsDbPath: string
54
60
  snsKey: string
@@ -104,14 +110,14 @@ export interface ListEntry {
104
110
  const ENCRYPTED_KEYS: ReadonlySet<keyof CliConfig> = new Set([
105
111
  'decryptKey', 'decryptKey3x', 'ntKey', 'contactKey',
106
112
  'wechatOcToken', 'wereadApiKey', 'deepseekApiKey', 'typesafeApiKey', 'dashscopeApiKey', 'snsKey',
107
- 'favKey', 'favPassphrase',
113
+ 'favKey', 'favPassphrase', 'openrouterApiKey',
108
114
  ])
109
115
 
110
116
  const CONFIG_DIR = join(homedir(), '.weflow-cli')
111
117
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
112
118
 
113
119
  export class ConfigService {
114
- private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
120
+ private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
115
121
 
116
122
  /** 本进程修改过、待回写的字段 (多进程并发写保护) */
117
123
  private dirty = new Set<keyof CliConfig>()
@@ -162,6 +168,9 @@ export class ConfigService {
162
168
  deepseekApiKey: data.deepseekApiKey || '',
163
169
  typesafeApiKey: data.typesafeApiKey || '',
164
170
  dashscopeApiKey: data.dashscopeApiKey || '',
171
+ judgeProvider: data.judgeProvider || 'native',
172
+ openrouterApiKey: data.openrouterApiKey || '',
173
+ openrouterJudgeModel: data.openrouterJudgeModel || '',
165
174
  snsDbPath: data.snsDbPath || '',
166
175
  snsKey: data.snsKey || '',
167
176
  snsSalt: data.snsSalt || '',
@@ -369,7 +378,7 @@ export class ConfigService {
369
378
  }
370
379
 
371
380
  clear(): void {
372
- this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
381
+ this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', judgeProvider: 'native', openrouterApiKey: '', openrouterJudgeModel: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
373
382
  // clear 意图是全量重置: 所有字段标记为脏, 覆盖磁盘上的全部旧值
374
383
  this.dirty = new Set(Object.keys(this.config) as (keyof CliConfig)[])
375
384
  this.save()
@@ -0,0 +1,237 @@
1
+ /**
2
+ * 「可粘贴的诊断块」—— 用户在排障时能一键复制、贴给 AI 或贴进 issue 的那段文本。
3
+ *
4
+ * 为什么要有它:这个项目最贵的一类故障是**静默失败**——`init` 报"完成"、`sessions` 空着,
5
+ * 用户只能猜("是不是微信版本太新?""是不是库坏了?"),于是同一个根因被报成四种问题
6
+ * (issue #3 / #9 / #10 与一封邮件,2026-10-18 核对过)。让用户把现象口头描述一遍,
7
+ * 双方都要猜;给他一段**已经判定过、且脱敏的结构化事实**,就不用了。
8
+ *
9
+ * 三条约束:
10
+ * 1. **脱敏**:报告里永远不出现密钥、口令、聊天内容;路径保留结构但把用户名与 wxid 换掉。
11
+ * 这些值本来就只以布尔("已配置/未配置")形式进入输入。
12
+ * 2. **判定,不只是罗列**:`diagnose()` 把"Python 不可用 / 缺 sqlcipher3 / 还没初始化 / 客户端没在跑"
13
+ * 翻成人话结论——用户拿到的不是一串 true/false。
14
+ * 3. **纯函数**:输入是普通对象,输出是字符串。不读文件、不碰配置,所以能直接测。
15
+ */
16
+
17
+ export type DiagnosticLevel = 'blocker' | 'warning' | 'info'
18
+
19
+ export interface DiagnosticFinding {
20
+ level: DiagnosticLevel
21
+ /** 一句话结论(给人看,也用来做断言) */
22
+ title: string
23
+ /** 下一步做什么(可执行的命令或检查) */
24
+ hint: string
25
+ }
26
+
27
+ export interface DiagnosticInput {
28
+ cliVersion: string
29
+ /** 传入而不是内部取,测试才能稳定 */
30
+ generatedAt: string
31
+ platform: string
32
+ arch: string
33
+ nodeVersion: string
34
+ python: { command: string; available: boolean; version: string }
35
+ dependencies: { required: Record<string, boolean>; optional?: Record<string, boolean> }
36
+ configuration: {
37
+ initialized: boolean
38
+ messageDatabase: boolean
39
+ momentsDatabase?: boolean
40
+ favoritesDatabase?: boolean
41
+ favoritesReady?: boolean
42
+ }
43
+ /** 只传布尔:这些键的值(密钥/口令)在这里连读都不读 */
44
+ keys: { messageKeyConfigured: boolean; passphraseConfigured: boolean }
45
+ wechatClient?: {
46
+ running: boolean
47
+ version: string | null
48
+ note?: string
49
+ dataDirMatchesConfig?: boolean | null
50
+ dataDirs?: string[]
51
+ unusableVersionDirs?: Array<{ dir: string; files: number }>
52
+ }
53
+ /** 最近一次失败的原文(可选;`redactForReport` 会再过一遍) */
54
+ lastError?: string | null
55
+ }
56
+
57
+ const LEVEL_ORDER: Record<DiagnosticLevel, number> = { blocker: 0, warning: 1, info: 2 }
58
+
59
+ const LEVEL_LABEL: Record<DiagnosticLevel, string> = {
60
+ blocker: '卡住了',
61
+ warning: '可能有问题',
62
+ info: '只是提醒',
63
+ }
64
+
65
+ /**
66
+ * 报告里的脱敏。**只处理"可能带身份"的那几类**,不做通用 PII 扫描——
67
+ * 这份文本是给作者/AI 看排障事实的,过度打码会把"路径里有个中文目录"这种线索也删掉。
68
+ */
69
+ export function redactForReport(text: string): string {
70
+ return String(text)
71
+ // Windows 用户名目录:保留盘符与后面的结构,只换掉用户名本身
72
+ .replace(/([A-Za-z]:\\Users\\)[^\\]+/gi, '$1<用户>')
73
+ .replace(/(\/home\/)[^/\s]+/g, '$1<用户>')
74
+ .replace(/(\/Users\/)[^/\s]+/g, '$1<用户>')
75
+ // wxid 是账号标识(AGENTS.md 点名的敏感项之一)
76
+ .replace(/wxid_[A-Za-z0-9_\-]+/g, 'wxid_<已隐藏>')
77
+ // 密钥形态:宁可按形态拦,不靠"这一定是密钥"的判断
78
+ .replace(/sk-[A-Za-z0-9\-_]{16,}/g, '<密钥>')
79
+ .replace(/(?<![0-9A-Za-z])[0-9a-fA-F]{32,}(?![0-9A-Za-z])/g, '<hex>')
80
+ // 邮箱与手机号:用户可能把作者邮箱/手机贴进来
81
+ .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '<邮箱>')
82
+ .replace(/(?<!\d)1[3-9]\d{9}(?!\d)/g, '<手机号>')
83
+ }
84
+
85
+ /** 把输入翻成结论。返回按严重度排好序的清单(同级别保持发现顺序)。 */
86
+ export function diagnose(input: DiagnosticInput): DiagnosticFinding[] {
87
+ const out: DiagnosticFinding[] = []
88
+ const required = input.dependencies.required || {}
89
+ const missing = Object.entries(required).filter(([, ok]) => !ok).map(([name]) => name)
90
+
91
+ if (!input.python.available) {
92
+ out.push({
93
+ level: 'blocker',
94
+ title: 'Python 不可用(读微信库的那一步跑不起来)',
95
+ hint: 'Windows 上最常见的是只装了应用商店的 python 占位程序。装一个真正的 Python 3.10+,'
96
+ + '确认 `python --version` 能打出版本号,再重试。',
97
+ })
98
+ } else if (missing.includes('sqlcipher3')) {
99
+ out.push({
100
+ level: 'blocker',
101
+ title: '缺少 sqlcipher3(Python 装了,但解不了加密库)',
102
+ hint: '运行 `pip install sqlcipher3`;Windows 上装的是预编译轮子,不需要自己编 OpenSSL。',
103
+ })
104
+ }
105
+
106
+ const otherMissing = missing.filter(name => name !== 'sqlcipher3')
107
+ if (input.python.available && otherMissing.length) {
108
+ out.push({
109
+ level: 'warning',
110
+ title: `缺少可选功能依赖:${otherMissing.join('、')}`,
111
+ hint: '只影响对应功能(抓正文/生成 HTML 等)。按需 `pip install ' + otherMissing.join(' ') + '`。',
112
+ })
113
+ }
114
+
115
+ if (!input.configuration.initialized) {
116
+ out.push({
117
+ level: 'blocker',
118
+ title: '还没有初始化(配置里没有可用的数据目录/密钥)',
119
+ hint: '运行 `weflow-cli init`,并保持微信已登录、不要退出。',
120
+ })
121
+ } else if (!input.configuration.messageDatabase) {
122
+ out.push({
123
+ level: 'blocker',
124
+ title: '配置指向的消息库不存在(路径失效或换了账号)',
125
+ hint: '运行 `weflow-cli init --refresh` 重新识别数据目录;换过微信账号时尤其需要。',
126
+ })
127
+ }
128
+
129
+ if (!input.keys.messageKeyConfigured && !input.keys.passphraseConfigured) {
130
+ out.push({
131
+ level: 'blocker',
132
+ title: '配置里没有任何数据库密钥/口令(读不出数据是必然的)',
133
+ hint: '运行 `weflow-cli init`;之前失败过就先 `weflow-cli config forget-keys --yes` 再重来。',
134
+ })
135
+ }
136
+
137
+ const client = input.wechatClient
138
+ if (client) {
139
+ if (!client.running) {
140
+ out.push({
141
+ level: 'warning',
142
+ title: '微信客户端没有在运行',
143
+ hint: '初始化时的扫描与"检查更新"都需要它。读已配置好的库不需要,但刚才若跑过 init,请启动微信后重试。',
144
+ })
145
+ }
146
+ if (client.dataDirMatchesConfig === false) {
147
+ out.push({
148
+ level: 'warning',
149
+ title: '客户端自报的数据目录与配置里推出来的不是同一份',
150
+ hint: '你可能在读另一个账号/另一份客户端正在写的库。确认后用 `weflow-cli init --refresh` 对齐。',
151
+ })
152
+ }
153
+ for (const dead of client.unusableVersionDirs || []) {
154
+ out.push({
155
+ level: 'info',
156
+ title: `磁盘上有残留版本目录 ${dead.dir}(只剩 ${dead.files} 个文件,没有 Weixin.dll)`,
157
+ hint: '指向它的加载会失败。如果客户端起不来,先看这里;正常情况下可以忽略。',
158
+ })
159
+ }
160
+ }
161
+
162
+ if (!out.length) {
163
+ out.push({
164
+ level: 'info',
165
+ title: '没发现明显问题',
166
+ hint: '这不是"保证能用":具体命令失败时,把那条命令的报错原文一起贴出来。',
167
+ })
168
+ }
169
+ return out.sort((a, b) => LEVEL_ORDER[a.level] - LEVEL_ORDER[b.level])
170
+ }
171
+
172
+ function yesNo(value: boolean | null | undefined): string {
173
+ if (value === null || value === undefined) return '未知'
174
+ return value ? '是' : '否'
175
+ }
176
+
177
+ function depLine(label: string, deps: Record<string, boolean> | undefined): string {
178
+ const entries = Object.entries(deps || {})
179
+ if (!entries.length) return `${label}: (无)`
180
+ return `${label}: ` + entries.map(([name, ok]) => `${name} ${ok ? '✓' : '✗'}`).join(' / ')
181
+ }
182
+
183
+ /**
184
+ * 渲染成一段可以直接粘贴的文本。
185
+ *
186
+ * 刻意用 `【】` 分段、不用表格与颜色:粘到微信/issue/AI 对话框里都不会散架,
187
+ * 而且 `--json` 那份是给机器读的,这份是给人(和 AI)读的,两者不混。
188
+ */
189
+ export function renderDiagnosticReport(input: DiagnosticInput): string {
190
+ const lines: string[] = []
191
+ const say = (line = ''): void => { lines.push(redactForReport(line)) }
192
+
193
+ say(`weflow-cli 诊断报告 v${input.cliVersion}`)
194
+ say(`生成时间 ${input.generatedAt}`)
195
+ say()
196
+ say('【环境】')
197
+ say(`平台 ${input.platform} ${input.arch} / Node ${input.nodeVersion}`)
198
+ // `python --version` 的输出自带 "Python" 前缀,拼上去会变成 "Python Python 3.11.9"
199
+ const pyVersion = input.python.version.replace(/^python\s*/i, '').trim()
200
+ say(`Python ${input.python.available ? pyVersion || '可用' : '不可用'}`)
201
+ say(` · 解析到的命令: ${input.python.command || '(未解析)'}`)
202
+ say(depLine('必装依赖', input.dependencies.required))
203
+ if (input.dependencies.optional) say(depLine('可选依赖', input.dependencies.optional))
204
+ say()
205
+ say('【微信客户端】')
206
+ const client = input.wechatClient
207
+ if (!client) {
208
+ say('没读到客户端信息(这一步需要活进程;微信没在跑时是正常的)')
209
+ } else {
210
+ say(`运行中: ${yesNo(client.running)}`)
211
+ say(`加载版本: ${client.version || `未知${client.note ? `(${client.note})` : ''}`}`)
212
+ if (client.dataDirs?.length) say(`客户端自报数据目录: ${client.dataDirs.join('、')}`)
213
+ say(`与配置推出来的同一份: ${yesNo(client.dataDirMatchesConfig)}`)
214
+ if (client.unusableVersionDirs?.length) {
215
+ say('残留版本目录: ' + client.unusableVersionDirs.map(d => `${d.dir}(${d.files} 个文件)`).join('、'))
216
+ }
217
+ }
218
+ say()
219
+ say('【配置】')
220
+ say(`已初始化: ${yesNo(input.configuration.initialized)}`)
221
+ say(`消息库存在: ${yesNo(input.configuration.messageDatabase)}`)
222
+ say(`数据库密钥已配置: ${yesNo(input.keys.messageKeyConfigured)}`)
223
+ say(`全库口令已配置: ${yesNo(input.keys.passphraseConfigured)}`)
224
+ say()
225
+ say('【结论】')
226
+ const findings = diagnose(input)
227
+ findings.forEach((f, i) => {
228
+ say(`${i + 1}. [${LEVEL_LABEL[f.level]}] ${f.title}`)
229
+ say(` → ${f.hint}`)
230
+ })
231
+ if (input.lastError) {
232
+ say()
233
+ say('【最近一次报错原文】')
234
+ say(input.lastError.trim())
235
+ }
236
+ return lines.join('\n')
237
+ }