weflow-cli 1.9.0 → 1.11.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 (134) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1051 -0
  3. package/OPERATIONS.md +99 -13
  4. package/README.en.md +14 -5
  5. package/README.md +23 -8
  6. package/bin/weflow-cli.ts +364 -40
  7. package/dist/bin/weflow-cli.js +381 -69
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/exportService.d.ts +43 -0
  32. package/dist/src/services/exportService.d.ts.map +1 -1
  33. package/dist/src/services/exportService.js +116 -11
  34. package/dist/src/services/exportService.js.map +1 -1
  35. package/dist/src/services/messageQuery.d.ts +18 -0
  36. package/dist/src/services/messageQuery.d.ts.map +1 -1
  37. package/dist/src/services/messageQuery.js +29 -0
  38. package/dist/src/services/messageQuery.js.map +1 -1
  39. package/dist/src/services/wechatClient.d.ts +125 -0
  40. package/dist/src/services/wechatClient.d.ts.map +1 -0
  41. package/dist/src/services/wechatClient.js +344 -0
  42. package/dist/src/services/wechatClient.js.map +1 -0
  43. package/dist/src/services/wechatMedia.d.ts +44 -0
  44. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  45. package/dist/src/services/wechatMedia.js +155 -0
  46. package/dist/src/services/wechatMedia.js.map +1 -0
  47. package/dist/src/services/wechatMessageService.d.ts +11 -0
  48. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  49. package/dist/src/services/wechatMessageService.js +55 -11
  50. package/dist/src/services/wechatMessageService.js.map +1 -1
  51. package/dist/src/types.d.ts +4 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  54. package/docs/DECISIONS.md +1274 -0
  55. package/docs/EXTENDING.md +69 -7
  56. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
  57. package/docs/HEALTH-CHECK.md +12 -7
  58. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  59. package/docs/MCP.md +2 -2
  60. package/docs/PROJECT_STATE.md +64 -5
  61. package/docs/RELEASING.md +128 -1
  62. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  63. package/logo.png +0 -0
  64. package/mcp-server/index.ts +18 -3
  65. package/package.json +5 -3
  66. package/resources/js/graph3d/NOTICE.txt +35 -0
  67. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  68. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  69. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  70. package/resources/js/graph3d/d3-octree.min.js +2 -0
  71. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  72. package/resources/js/graph3d/d3-timer.min.js +2 -0
  73. package/resources/js/graph3d/layout.mjs +63 -0
  74. package/resources/js/graph3d/three.min.js +6 -0
  75. package/resources/js/graph3d/viewer2d.js +448 -0
  76. package/resources/panel/index.html +12 -0
  77. package/resources/panel/mascot-blink-1.png +0 -0
  78. package/resources/panel/mascot-blink-2.png +0 -0
  79. package/resources/panel/mascot-lift-down-1.png +0 -0
  80. package/resources/panel/mascot-lift-down-2.png +0 -0
  81. package/resources/panel/mascot-lift-down-3.png +0 -0
  82. package/resources/panel/mascot-lift-down-4.png +0 -0
  83. package/resources/panel/mascot-lift-down-5.png +0 -0
  84. package/resources/panel/mascot-lift-down-6.png +0 -0
  85. package/resources/panel/mascot-lift-up-1.png +0 -0
  86. package/resources/panel/mascot-lift-up-2.png +0 -0
  87. package/resources/panel/mascot-lift-up-3.png +0 -0
  88. package/resources/panel/mascot-lift-up-4.png +0 -0
  89. package/resources/panel/mascot-lift-up-5.png +0 -0
  90. package/resources/panel/mascot-lift-up-6.png +0 -0
  91. package/resources/panel/mascot-tickle-1.png +0 -0
  92. package/resources/panel/mascot-tickle-2.png +0 -0
  93. package/resources/panel/mascot-tickle-3.png +0 -0
  94. package/resources/panel/mascot-tickle-4.png +0 -0
  95. package/resources/panel/panel.css +111 -0
  96. package/resources/panel/renderer.js +480 -8
  97. package/scripts/_utils.py +1203 -977
  98. package/scripts/biz_daily.py +25 -10
  99. package/scripts/chat_notes.py +15 -5
  100. package/scripts/check_readme_commands.py +426 -0
  101. package/scripts/compile_wiki.py +163 -57
  102. package/scripts/contact_schema.py +339 -0
  103. package/scripts/data/wechat_emoji.json +225 -0
  104. package/scripts/export_chat_html.py +346 -6
  105. package/scripts/fav_notes.py +4 -1
  106. package/scripts/graph_2d.py +151 -0
  107. package/scripts/graph_3d.py +646 -0
  108. package/scripts/health_check.py +163 -26
  109. package/scripts/nt_decrypt.py +3 -3
  110. package/scripts/panel_frames.py +683 -0
  111. package/scripts/pipeline.py +21 -10
  112. package/scripts/quality_eval.py +5 -0
  113. package/scripts/rag_chat.py +33 -16
  114. package/scripts/reply_debt.py +26 -6
  115. package/scripts/semantic_search.py +212 -58
  116. package/scripts/vault_rag.py +42 -16
  117. package/scripts/vault_search.py +44 -8
  118. package/scripts/wechat_emoticon.py +6 -0
  119. package/scripts/wiki_lint.py +94 -17
  120. package/src/core/dbPathService.ts +22 -4
  121. package/src/panel/server.ts +33 -0
  122. package/src/services/assistantService.ts +26 -0
  123. package/src/services/assistantTools.ts +2452 -2319
  124. package/src/services/configService.ts +5 -2
  125. package/src/services/exportService.ts +129 -12
  126. package/src/services/messageQuery.ts +29 -0
  127. package/src/services/wechatClient.ts +380 -0
  128. package/src/services/wechatMedia.ts +166 -0
  129. package/src/services/wechatMessageService.ts +62 -11
  130. package/src/types.ts +4 -0
  131. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  132. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  133. package/dist/src/utils/wechatEmoji.js +0 -35
  134. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -28,6 +28,8 @@ interface CliConfig {
28
28
  wechatOcSyncBuf: string
29
29
  /** 持久化的 context_token: wxid -> token (加密 JSON 字符串) */
30
30
  wechatOcContextTokens: string
31
+ /** 官方通道的**入站媒体**是否下载到本地(默认 'false'):'true' 才下载,落到 output/wechat-media/ */
32
+ wechatMediaDownload: string
31
33
  // 白名单
32
34
  whitelist: string[]
33
35
  // 黑名单 (绝对禁止收发)
@@ -109,7 +111,7 @@ const CONFIG_DIR = join(homedir(), '.weflow-cli')
109
111
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
110
112
 
111
113
  export class ConfigService {
112
- private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', 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' }
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' }
113
115
 
114
116
  /** 本进程修改过、待回写的字段 (多进程并发写保护) */
115
117
  private dirty = new Set<keyof CliConfig>()
@@ -149,6 +151,7 @@ export class ConfigService {
149
151
  wechatOcBaseUrl: data.wechatOcBaseUrl || '',
150
152
  wechatOcSyncBuf: data.wechatOcSyncBuf || '',
151
153
  wechatOcContextTokens: data.wechatOcContextTokens || '',
154
+ wechatMediaDownload: data.wechatMediaDownload === 'true' ? 'true' : 'false',
152
155
  whitelist,
153
156
  blacklist,
154
157
  whitelistEntries,
@@ -366,7 +369,7 @@ export class ConfigService {
366
369
  }
367
370
 
368
371
  clear(): void {
369
- this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', 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' }
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' }
370
373
  // clear 意图是全量重置: 所有字段标记为脏, 覆盖磁盘上的全部旧值
371
374
  this.dirty = new Set(Object.keys(this.config) as (keyof CliConfig)[])
372
375
  this.save()
@@ -1,5 +1,5 @@
1
1
  import { join, basename, dirname } from 'path'
2
- import { existsSync, mkdirSync, writeFileSync } from 'fs'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
3
3
  import { execFile } from 'child_process'
4
4
  import { promisify } from 'util'
5
5
  import { chatService } from './chatService.js'
@@ -15,10 +15,67 @@ export type ExportContract = 'raw' | 'weflow-v1'
15
15
  export interface ExportResult { success: boolean; path?: string; count?: number; error?: string }
16
16
 
17
17
  const execFileAsync = promisify(execFile)
18
+
19
+ /**
20
+ * md 导出落盘的媒体清单:`<output>/<talker>_assets/manifest.json`(`local_id:create_time` → 条目)。
21
+ *
22
+ * 这是"其余格式也能带图/带文件"的那一半:**md 导出抽取媒体,json/txt/excel 消费同一份清单** —— 匹配
23
+ * 逻辑(缓存缩略图、资源 md5、原图那档、`msg/file` 里的文档本体)只存在于 Python 那条路上,在这里重写
24
+ * 一遍必然与它漂移。读不到就返回空表:那说明还没跑过 md/`--media` 导出,行为与从前一致。
25
+ */
26
+ export interface MediaRecord {
27
+ /** 相对输出目录的路径;`kind: 'file'` 时指向 `files/` 下的文档。 */
28
+ path: string
29
+ kind: 'image' | 'file'
30
+ /** 文档的原始文件名(图片没有)。 */
31
+ name?: string
32
+ }
33
+
34
+ /**
35
+ * 从清单里取这条消息的媒体条目。
36
+ *
37
+ * 键是 `local_id:create_time`:**local_id 会在分片之间重复**(同一会话的表可能存在于多个分片,
38
+ * 各片自己编号),只按 local_id 查会让 json/txt/excel 引用到别的消息的图。两条消息硬要同 id 同秒
39
+ * 才算撞上,那个概率可以忽略;回退查裸 local_id 是为了兼容更早写出的清单。
40
+ */
41
+ export function mediaFor(manifest: Record<string, MediaRecord>, message: { localId?: unknown; createTime?: unknown }): MediaRecord | undefined {
42
+ const id = String(message.localId)
43
+ return manifest[`${id}:${message.createTime}`] ?? manifest[id]
44
+ }
45
+
46
+ export function readMediaManifest(outputDir: string, talker: string): Record<string, MediaRecord> {
47
+ try {
48
+ const file = join(outputDir, `${talker}_assets`, 'manifest.json')
49
+ if (!existsSync(file)) return {}
50
+ const data = JSON.parse(readFileSync(file, 'utf8'))
51
+ const map = data && data.messages
52
+ if (!map || typeof map !== 'object') return {}
53
+ // v1 的值是裸字符串(只有图片),v2 起是带 `kind` 的对象。升级不该让磁盘上已有的清单失效 ——
54
+ // 用户不会因为升了个版本就重跑一次导出。
55
+ const normalized: Record<string, MediaRecord> = {}
56
+ for (const [key, value] of Object.entries(map as Record<string, unknown>)) {
57
+ if (typeof value === 'string') {
58
+ normalized[key] = { path: value, kind: 'image' }
59
+ } else if (value && typeof value === 'object' && typeof (value as MediaRecord).path === 'string') {
60
+ const record = value as MediaRecord
61
+ normalized[key] = { ...record, kind: record.kind === 'file' ? 'file' : 'image' }
62
+ }
63
+ }
64
+ return normalized
65
+ } catch {
66
+ return {}
67
+ }
68
+ }
18
69
  function getPackageRoot(): string {
19
70
  return resolvePackageRoot(import.meta.url)
20
71
  }
21
72
 
73
+ /** txt/excel 共用的文字标签:文档要带上原名,光一个相对路径看不出那是什么东西。 */
74
+ function mediaLabel(record: MediaRecord | undefined): string {
75
+ if (!record) return ''
76
+ return record.kind === 'file' ? `${record.name || record.path} (${record.path})` : record.path
77
+ }
78
+
22
79
  export class ExportService {
23
80
  async exportJson(talker: string, outputDir: string, limit = 0, from?: number, to?: number, contract: ExportContract = 'raw'): Promise<ExportResult> {
24
81
  try {
@@ -29,13 +86,26 @@ export class ExportService {
29
86
 
30
87
  const dir = this.ensureOutputDir(outputDir)
31
88
  const filePath = join(dir, `${talker}_messages.json`)
89
+ // 有清单就带上媒体相对路径(清单由 markdown 导出产生,见 readMediaManifest)
90
+ const mediaMap = readMediaManifest(dir, talker)
91
+ const withMedia = Object.keys(mediaMap).length
92
+ ? messages.map(m => {
93
+ const media = mediaFor(mediaMap, m)
94
+ // `media` 保持"相对路径字符串"这个老形状(已有消费方按字符串读);`mediaKind`/`mediaName`
95
+ // 是新增的 —— 不带这两项,读 JSON 的人只能靠扩展名猜这是图片还是文档,而导出明明知道。
96
+ if (!media) return m
97
+ return media.kind === 'file'
98
+ ? { ...m, media: media.path, mediaKind: media.kind, mediaName: media.name }
99
+ : { ...m, media: media.path, mediaKind: media.kind }
100
+ })
101
+ : messages
32
102
  const payload = contract === 'weflow-v1'
33
- ? createWeFlowEnvelope(messages, new Date().toISOString(), {
103
+ ? createWeFlowEnvelope(withMedia, new Date().toISOString(), {
34
104
  requestedFrom: from,
35
105
  requestedTo: to,
36
106
  requestedLimit: limit,
37
107
  })
38
- : messages
108
+ : withMedia
39
109
  writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8')
40
110
  return { success: true, path: filePath, count: messages.length }
41
111
  } catch {
@@ -50,10 +120,15 @@ export class ExportService {
50
120
  return { success: false, error: '未找到消息' }
51
121
  }
52
122
 
123
+ const mediaMap = readMediaManifest(this.ensureOutputDir(outputDir), talker)
53
124
  const lines = messages.map(m => {
54
125
  const time = new Date(m.createTime * 1000).toLocaleString('zh-CN')
55
126
  const sender = m.isSend ? '我' : (m.senderUsername || talker)
56
- const content = m.parsedContent || m.rawContent || ''
127
+ // 有清单就把 `[图片]` / `[文件] 名字` 换成能直接打开的引用,没有就保持原样
128
+ const media = mediaFor(mediaMap, m)
129
+ const content = media
130
+ ? (media.kind === 'file' ? `[文件: ${mediaLabel(media)}]` : `[图片: ${media.path}]`)
131
+ : (m.parsedContent || m.rawContent || '')
57
132
  return `[${time}] ${sender}: ${content}`
58
133
  })
59
134
 
@@ -66,7 +141,12 @@ export class ExportService {
66
141
  }
67
142
  }
68
143
 
69
- async exportHtml(talker: string, outputDir: string, limit = 0, date = '', from?: number, to?: number, quiet = false, options: { fullImages?: boolean } = {}): Promise<ExportResult> {
144
+ async exportHtml(talker: string, outputDir: string, limit = 0, date = '', from?: number, to?: number, quiet = false, options: { fullImages?: boolean; format?: 'html' | 'md'; assetsOnly?: boolean } = {}): Promise<ExportResult> {
145
+ const format = options.format ?? 'html'
146
+ if (format === 'md' && (from !== undefined || to !== undefined)) {
147
+ // 不静默降级成「导出全部」:范围是调用方明确要的,做不到就说出来。
148
+ return { success: false, error: 'markdown 导出暂不支持 --from/--to:请用 --date,或整段导出' }
149
+ }
70
150
  try {
71
151
  if (from !== undefined || to !== undefined) {
72
152
  return this.exportHtmlBasic(talker, outputDir, limit, from, to)
@@ -83,6 +163,9 @@ export class ExportService {
83
163
  const passphrase = configService.get('favPassphrase') || cfg.decryptKey || ''
84
164
 
85
165
  if (!db || !key || !salt) {
166
+ if (format === 'md') {
167
+ return { success: false, error: 'markdown 导出需要已配置的数据库与密钥(这条路没有简版回退)' }
168
+ }
86
169
  // Fallback: basic HTML
87
170
  let fallbackFrom: number | undefined = from
88
171
  let fallbackTo: number | undefined = to
@@ -114,11 +197,16 @@ export class ExportService {
114
197
  }
115
198
  } catch {}
116
199
 
117
- const args: string[] = [
118
- script,
200
+ const args: string[] = [script]
201
+ if (format === 'md') {
202
+ // 图片落盘 + 清单。资产目录按 **talker** 命名,好让 json/txt/excel 导出能确定性找到它
203
+ // (它们的文件前缀是 talker,而 md 文件名用的是显示名,不显式指定就对不上)。
204
+ args.push('--format', 'md', '--assets-dir', join(outputDir || './output', `${talker}_assets`))
205
+ if (options.assetsOnly) args.push('--assets-only')
206
+ } else {
119
207
  // Paginate: a whole year of chat in one file makes the browser crawl.
120
- '--per-page', '100',
121
- ]
208
+ args.push('--per-page', '100')
209
+ }
122
210
  const ownWxid = String(cfg.wxid || '').trim()
123
211
  const exportCacheDir = cacheDir && existsSync(cacheDir) ? cacheDir : undefined
124
212
  const exportAccountDir = accountDir && existsSync(accountDir) ? accountDir : undefined
@@ -189,11 +277,36 @@ export class ExportService {
189
277
  if (date) {
190
278
  return { success: false, error: `指定日期导出失败: ${failure}` }
191
279
  }
192
- // Fallback to basic HTML
280
+ if (format === 'md') {
281
+ // markdown 没有「简版」:回退成 HTML 会让调用方以为拿到了 .md。失败就是失败。
282
+ return { success: false, error: failure }
283
+ }
284
+ // Fallback to basic HTML:**这条回退不带内嵌图片**,调用方得知道 ——
285
+ // 2026-10-13 实测:Python 因一个 AttributeError 退出后,这里静默回退,
286
+ // 而 CLI 照样打印「✓ 导出成功」,人只会以为导出正常。
287
+ if (!quiet) console.error(' ⚠ 已回退到简版 HTML(不含内嵌图片)')
193
288
  return this.exportHtmlBasic(talker, outputDir, limit, from, to)
194
289
  }
195
290
  }
196
291
 
292
+ /**
293
+ * 只抽媒体(图片与文档落盘 + 清单),不产出正文 —— `export … json|txt|excel --media` 先跑这一步。
294
+ * 与 markdown 导出共用同一条 Python 通路与同一个资产目录,所以清单只有一份。
295
+ *
296
+ * `limit`/`date` 必须跟着调用方走:抽取原本固定按"整个会话"跑,而消费它的 json/txt/excel 是按
297
+ * `--limit` 切片的 —— `--limit 200` 于是把整段历史的媒体都抽出来(文档还要拷成文件),
298
+ * 实测 67 份里只有 33 份落在导出的 200 条里。
299
+ */
300
+ async extractMedia(talker: string, outputDir: string, quiet = false, options: { fullImages?: boolean } = {}, limit = 0, date = ''): Promise<ExportResult> {
301
+ return this.exportHtml(talker, outputDir, limit, date, undefined, undefined, quiet,
302
+ { ...options, format: 'md', assetsOnly: true })
303
+ }
304
+
305
+ /** markdown + 图片落盘到 `<output>/<talker>_assets/`(同一条 Python 通路,换个输出形态)。 */
306
+ async exportMarkdown(talker: string, outputDir: string, limit = 0, date = '', from?: number, to?: number, quiet = false, options: { fullImages?: boolean } = {}): Promise<ExportResult> {
307
+ return this.exportHtml(talker, outputDir, limit, date, from, to, quiet, { ...options, format: 'md' })
308
+ }
309
+
197
310
  private async exportHtmlBasic(talker: string, outputDir: string, limit = 0, from?: number, to?: number): Promise<ExportResult> {
198
311
  const messages = await chatService.getMessagesInRange(talker, limit, from, to)
199
312
  if (messages.length === 0) {
@@ -228,16 +341,20 @@ export class ExportService {
228
341
  { header: '发送者', key: 'sender', width: 15 },
229
342
  { header: '类型', key: 'type', width: 10 },
230
343
  { header: '内容', key: 'content', width: 80 },
231
- { header: '消息ID', key: 'msgId', width: 15 }
344
+ { header: '消息ID', key: 'msgId', width: 15 },
345
+ // 有清单时填相对路径(清单由 markdown 导出产生)
346
+ { header: '媒体', key: 'media', width: 40 }
232
347
  ]
233
348
 
349
+ const mediaMap = readMediaManifest(this.ensureOutputDir(outputDir), talker)
234
350
  for (const m of messages) {
235
351
  sheet.addRow({
236
352
  time: new Date(m.createTime * 1000).toLocaleString('zh-CN'),
237
353
  sender: m.isSend ? '我' : (m.senderUsername || talker),
238
354
  type: this.getMessageTypeName(m.localType),
239
355
  content: m.parsedContent || m.rawContent || '',
240
- msgId: m.localId
356
+ msgId: m.localId,
357
+ media: mediaLabel(mediaFor(mediaMap, m))
241
358
  })
242
359
  }
243
360
 
@@ -5,6 +5,35 @@ export interface MessageTimeRange {
5
5
  to?: number
6
6
  }
7
7
 
8
+ /**
9
+ * 这三种形状的输入会被 `resolveTalker` **原样返回**(见 `src/utils/talkerUtils.ts`),
10
+ * 于是拼错的 ID 会一路走到「取不到消息」,和「这条会话在窗口内确实没有消息」长得一模一样。
11
+ * 只看形状,不碰 IO —— 判定所需的名单由调用方传进来。
12
+ */
13
+ export function looksLikeTalkerId(input: string): boolean {
14
+ const value = String(input || '')
15
+ return value.startsWith('wxid_') || value.includes('@chatroom') || value.includes('@openim')
16
+ }
17
+
18
+ /**
19
+ * 空结果时补的一句话:**只在"看起来是 ID、但会话与联系人都没有它"时**才给。
20
+ * 返回空串表示没什么可补的 —— 名字形状的输入本来就由 `resolveTalker` 报错,认识的 ID
21
+ * 取不到消息是正常结果。
22
+ *
23
+ * 这里**不改退出码、也不改 `success`**:既有契约里「没有消息」不是错误(脚本与 MCP 依赖它,
24
+ * 见 `docs/HEALTH-CHECK.md` 的那一行),所以只把歧义说出来,不动语义。
25
+ */
26
+ export function emptyMessagesNote(
27
+ input: string,
28
+ known: { sessionUsernames: Iterable<string>; contactUsernames: Iterable<string> },
29
+ ): string {
30
+ if (!looksLikeTalkerId(input)) return ''
31
+ for (const id of known.sessionUsernames) if (id === input) return ''
32
+ for (const id of known.contactUsernames) if (id === input) return ''
33
+ return `这个 ID 既不在会话里、也不在联系人里:${input}` +
34
+ ' —— 先确认有没有拼错(weflow-cli sessions / weflow-cli contacts)'
35
+ }
36
+
8
37
  export async function collectMessagesInRange(
9
38
  fetchPage: (limit: number, offset: number) => Promise<Message[]>,
10
39
  limit: number,