koishi-plugin-chat-patch 3.0.2 → 5.0.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 (194) hide show
  1. package/client/vue/index.vue +60 -634
  2. package/client/web/env.d.ts +56 -0
  3. package/client/web/index.html +21 -0
  4. package/client/web/package-lock.json +3419 -0
  5. package/client/web/package.json +62 -0
  6. package/client/web/public/bcui/css/color-dark.css +35 -0
  7. package/client/web/public/bcui/css/color-light.css +35 -0
  8. package/client/web/public/bcui/css/style.css +468 -0
  9. package/client/web/public/bcui/js/main.js +193 -0
  10. package/client/web/public/css/append-dark.css +13 -0
  11. package/client/web/public/css/append-light.css +13 -0
  12. package/client/web/public/css/append-napcat-dark.css +14 -0
  13. package/client/web/public/css/append-napcat-light.css +14 -0
  14. package/client/web/public/favicon.ico +0 -0
  15. package/client/web/public/img/icons/icon-for-fuck-apple.png +0 -0
  16. package/client/web/public/img/icons/icon-maskable.png +0 -0
  17. package/client/web/public/img/icons/icon.png +0 -0
  18. package/client/web/public/img/icons/icon.svg +10 -0
  19. package/client/web/public/robots.txt +2 -0
  20. package/client/web/public/sw.js +58 -0
  21. package/client/web/scripts/copy-bcui.mjs +17 -0
  22. package/client/web/src/App.vue +1127 -0
  23. package/client/web/src/assets/css/append/append_darwin.css +68 -0
  24. package/client/web/src/assets/css/append/append_full_vibrancy.css +290 -0
  25. package/client/web/src/assets/css/append/append_linux.css +125 -0
  26. package/client/web/src/assets/css/append/append_linux_vibrancy.css +22 -0
  27. package/client/web/src/assets/css/append/append_napcat.css +35 -0
  28. package/client/web/src/assets/css/append/append_new.css +843 -0
  29. package/client/web/src/assets/css/append/append_vibrancy.css +113 -0
  30. package/client/web/src/assets/css/append/append_win32.css +109 -0
  31. package/client/web/src/assets/css/append/mobile/append_mobile_horizontal.css +349 -0
  32. package/client/web/src/assets/css/append/mobile/append_mobile_vertical.css +96 -0
  33. package/client/web/src/assets/css/chat.css +2104 -0
  34. package/client/web/src/assets/css/msg.css +941 -0
  35. package/client/web/src/assets/css/options.css +811 -0
  36. package/client/web/src/assets/css/sys_notice.css +62 -0
  37. package/client/web/src/assets/css/view.css +2105 -0
  38. package/client/web/src/assets/font//346/226/271/346/255/243/345/226/265/345/221/234/344/275/223.ttf +0 -0
  39. package/client/web/src/assets/img/Vue.png +0 -0
  40. package/client/web/src/assets/img/cakes.svg +189 -0
  41. package/client/web/src/assets/img/icons/AppIcon.png +0 -0
  42. package/client/web/src/assets/img/icons/AppIconDark.png +0 -0
  43. package/client/web/src/assets/img/icons/CapacitorAppIcon.png +0 -0
  44. package/client/web/src/assets/img/icons/CapacitorAppIconDark.png +0 -0
  45. package/client/web/src/assets/img/icons/NapcatAppIcon.png +0 -0
  46. package/client/web/src/assets/img/icons/NapcatAppIconDark.png +0 -0
  47. package/client/web/src/assets/img/icons/exit.png +0 -0
  48. package/client/web/src/assets/img/icons/pen.png +0 -0
  49. package/client/web/src/assets/img/stars.svg +89 -0
  50. package/client/web/src/assets/img//346/234/237/345/276/205.png +0 -0
  51. package/client/web/src/assets/l10n/_About I10n.md +35 -0
  52. package/client/web/src/assets/l10n/_l10nconfig.json +7 -0
  53. package/client/web/src/assets/l10n/zh-CN.po +1941 -0
  54. package/client/web/src/assets/logo.png +0 -0
  55. package/client/web/src/assets/pathMap/LLOneBot.yaml +2 -0
  56. package/client/web/src/assets/pathMap/Lagrange.OneBot.yaml +180 -0
  57. package/client/web/src/assets/pathMap/NapCat.Onebot.yaml +224 -0
  58. package/client/web/src/assets/pathMap/SnowLuma.yaml +80 -0
  59. package/client/web/src/assets/qed.txt +1 -0
  60. package/client/web/src/components/AboutPan.vue +197 -0
  61. package/client/web/src/components/BulletinBody.vue +129 -0
  62. package/client/web/src/components/DepPan.vue +310 -0
  63. package/client/web/src/components/EmojiFace.vue +32 -0
  64. package/client/web/src/components/FacePan.vue +461 -0
  65. package/client/web/src/components/FileBody.vue +129 -0
  66. package/client/web/src/components/FileManager.vue +532 -0
  67. package/client/web/src/components/FriendBody.vue +54 -0
  68. package/client/web/src/components/GlobalSessionSearchBar.vue +301 -0
  69. package/client/web/src/components/History.vue +49 -0
  70. package/client/web/src/components/LazyLottie.vue +82 -0
  71. package/client/web/src/components/MergePan.vue +127 -0
  72. package/client/web/src/components/MsgBody.vue +1478 -0
  73. package/client/web/src/components/MusicPlayer.vue +549 -0
  74. package/client/web/src/components/NoticeBody.vue +168 -0
  75. package/client/web/src/components/QzonePermissionPan.vue +150 -0
  76. package/client/web/src/components/RawMsgRenderPreviewPan.vue +190 -0
  77. package/client/web/src/components/ThemeColorPickerPan.vue +420 -0
  78. package/client/web/src/components/TinySessionBody.vue +51 -0
  79. package/client/web/src/components/UmamiInfoPan.vue +1549 -0
  80. package/client/web/src/components/UpdatePan.vue +263 -0
  81. package/client/web/src/components/ViewerCom.vue +1360 -0
  82. package/client/web/src/components/VoiceMsg.vue +626 -0
  83. package/client/web/src/components/WelPan.vue +894 -0
  84. package/client/web/src/components/msg-body/.gitkeep +0 -0
  85. package/client/web/src/components/msg-component/JsonSegComp.vue +138 -0
  86. package/client/web/src/components/msg-component/XmlSegComp.vue +132 -0
  87. package/client/web/src/components/msg-component/jsonComp/AutoReply.vue +97 -0
  88. package/client/web/src/components/msg-component/jsonComp/Contact.lua.vue +129 -0
  89. package/client/web/src/components/msg-component/jsonComp/Feed.lua.vue +94 -0
  90. package/client/web/src/components/msg-component/jsonComp/Forum.vue +84 -0
  91. package/client/web/src/components/msg-component/jsonComp/Mannounce.vue +111 -0
  92. package/client/web/src/components/msg-component/jsonComp/Map.vue +52 -0
  93. package/client/web/src/components/msg-component/jsonComp/Miniapp.lua.vue +53 -0
  94. package/client/web/src/components/msg-component/jsonComp/Miniapp.vue +69 -0
  95. package/client/web/src/components/msg-component/jsonComp/Music.lua.vue +135 -0
  96. package/client/web/src/components/msg-component/jsonComp/Tuwen.lua.vue +56 -0
  97. package/client/web/src/components/notice-component/.gitkeep +0 -0
  98. package/client/web/src/components/notice-component/MealHungryPan.vue +119 -0
  99. package/client/web/src/components/tooltip/CustomFaceTooltip.vue +22 -0
  100. package/client/web/src/components/tooltip/Tooltip.vue +50 -0
  101. package/client/web/src/components/tooltip/Tooltips.vue +14 -0
  102. package/client/web/src/components/tooltip/UserInfoTooltip.vue +69 -0
  103. package/client/web/src/function/base.ts +238 -0
  104. package/client/web/src/function/connect.ts +2042 -0
  105. package/client/web/src/function/debug.ts +10 -0
  106. package/client/web/src/function/elements/information.ts +141 -0
  107. package/client/web/src/function/elements/system.ts +101 -0
  108. package/client/web/src/function/elements/vueComp.ts +62 -0
  109. package/client/web/src/function/favicon.ts +76 -0
  110. package/client/web/src/function/input.ts +69 -0
  111. package/client/web/src/function/model/emoji.ts +127 -0
  112. package/client/web/src/function/model/img.ts +150 -0
  113. package/client/web/src/function/model/msg-body.ts +51 -0
  114. package/client/web/src/function/msg.ts +2674 -0
  115. package/client/web/src/function/notify.ts +225 -0
  116. package/client/web/src/function/option.ts +749 -0
  117. package/client/web/src/function/satori-model.ts +818 -0
  118. package/client/web/src/function/satori.ts +325 -0
  119. package/client/web/src/function/sender.ts +178 -0
  120. package/client/web/src/function/tooltip.ts +49 -0
  121. package/client/web/src/function/utils/appUtil.ts +2288 -0
  122. package/client/web/src/function/utils/avatarUtil.ts +31 -0
  123. package/client/web/src/function/utils/backgroundUtil.ts +110 -0
  124. package/client/web/src/function/utils/linkViewUtil.ts +101 -0
  125. package/client/web/src/function/utils/localHistoryUtil.ts +517 -0
  126. package/client/web/src/function/utils/msgUtil.ts +1242 -0
  127. package/client/web/src/function/utils/pinyin.ts +141 -0
  128. package/client/web/src/function/utils/recordUtil.ts +187 -0
  129. package/client/web/src/function/utils/sessionUtil.ts +114 -0
  130. package/client/web/src/function/utils/systemUtil.ts +664 -0
  131. package/client/web/src/main.ts +91 -0
  132. package/client/web/src/pages/Chat.vue +3453 -0
  133. package/client/web/src/pages/Friends.vue +636 -0
  134. package/client/web/src/pages/Info.vue +717 -0
  135. package/client/web/src/pages/Messages.vue +875 -0
  136. package/client/web/src/pages/Options.vue +82 -0
  137. package/client/web/src/pages/Qzone.vue +1178 -0
  138. package/client/web/src/pages/chat-view/ChatDanmaku.vue +759 -0
  139. package/client/web/src/pages/chat-view/ChatGlagame.vue +642 -0
  140. package/client/web/src/pages/chat-view/ChatTerminal.vue +1044 -0
  141. package/client/web/src/pages/chat-view/SystemNotice.vue +171 -0
  142. package/client/web/src/pages/options/OptAccount.vue +107 -0
  143. package/client/web/src/pages/options/OptAddon.vue +185 -0
  144. package/client/web/src/pages/options/OptDev.vue +704 -0
  145. package/client/web/src/pages/options/OptFunction.vue +403 -0
  146. package/client/web/src/pages/options/OptInfo.vue +165 -0
  147. package/client/web/src/pages/options/OptView.vue +846 -0
  148. package/client/web/src/runtime/backend.ts +310 -0
  149. package/client/web/src/state/auth.ts +14 -0
  150. package/client/web/src/state/chat.ts +36 -0
  151. package/client/web/src/state/connection.ts +20 -0
  152. package/client/web/src/state/contact.ts +37 -0
  153. package/client/web/src/state/qzone.ts +18 -0
  154. package/client/web/src/state/sessionHistory.ts +25 -0
  155. package/client/web/src/state/settings.ts +18 -0
  156. package/client/web/src/state/sticker.ts +10 -0
  157. package/client/web/src/state/ui.ts +56 -0
  158. package/client/web/tsconfig.json +27 -0
  159. package/client/web/tsconfig.tsbuildinfo +1 -0
  160. package/client/web/vite.config.ts +33 -0
  161. package/dist/index.js +1 -12
  162. package/dist/style.css +1 -1
  163. package/lib/index.js +2070 -2077
  164. package/package.json +15 -2
  165. package/readme.md +0 -22
  166. package/src/bootstrap.ts +71 -0
  167. package/src/cache.ts +319 -0
  168. package/src/config.ts +23 -35
  169. package/src/database.ts +650 -0
  170. package/src/index.ts +27 -59
  171. package/src/media.ts +302 -0
  172. package/src/recorder.ts +175 -0
  173. package/src/self-message.ts +456 -0
  174. package/src/server.d.ts +26 -0
  175. package/src/types.ts +108 -48
  176. package/src/web.ts +712 -0
  177. package/client/vue/chat-logic.ts +0 -683
  178. package/client/vue/composables/useChatActions.ts +0 -61
  179. package/client/vue/composables/useChatData.ts +0 -185
  180. package/client/vue/composables/useImageCache.ts +0 -281
  181. package/client/vue/composables/useVideoCache.ts +0 -125
  182. package/client/vue/types.ts +0 -72
  183. package/lib/api-handlers.d.ts +0 -25
  184. package/lib/config.d.ts +0 -14
  185. package/lib/file-manager.d.ts +0 -124
  186. package/lib/index.d.ts +0 -17
  187. package/lib/logger.d.ts +0 -9
  188. package/lib/message-handler.d.ts +0 -35
  189. package/lib/types.d.ts +0 -57
  190. package/lib/utils.d.ts +0 -18
  191. package/src/api-handlers.ts +0 -646
  192. package/src/file-manager.ts +0 -1436
  193. package/src/message-handler.ts +0 -507
  194. package/src/utils.ts +0 -189
@@ -0,0 +1,517 @@
1
+ /**
2
+ * @FileDescription: 本地历史消息工具(Tauri 平台)
3
+ * @Description:
4
+ * 封装对 Tauri 后端 db_* 命令的调用,提供类型安全的本地 SQLite 历史消息读写接口。
5
+ * 非 Tauri 平台调用时会静默 no-op / 返回空数组,不影响其他平台逻辑。
6
+ */
7
+
8
+ import { backend } from '../../runtime/backend'
9
+ import { getMsgRawTxt } from './msgUtil'
10
+ import { Logger } from '../base'
11
+ import { useSettingsStore } from '../../state/settings'
12
+ import { useAuthStore } from '../../state/auth'
13
+
14
+ const logger = new Logger()
15
+
16
+ // ── 类型定义(与 Rust MsgRecord 对应) ───────────────────────────
17
+
18
+ export interface LocalMsgRecord {
19
+ /** Bot 侧的消息 ID */
20
+ message_id: string
21
+ /** 会话 ID(group_id 或 user_id) */
22
+ chat_id: number | string
23
+ /** 会话类型:"group" | "private" */
24
+ chat_type: string
25
+ /** 发送者 user_id */
26
+ sender_id: number | string
27
+ /** 发送者昵称(card 优先,fallback nickname) */
28
+ sender_name: string | null
29
+ /** 消息序号(并非所有 Bot 都提供,可能为 null) */
30
+ seq: number | null
31
+ /** 消息时间戳(秒,Bot 原始值) */
32
+ time: number
33
+ /** JSON 序列化的 MsgItemElem[] 消息段数组 */
34
+ message: string
35
+ /** 纯文本摘要 */
36
+ raw_message: string | null
37
+ /** 是否已撤回 */
38
+ revoked: boolean
39
+ }
40
+
41
+ function isTauriHistoryAvailable(): boolean {
42
+ const settingsStore = useSettingsStore()
43
+ return backend.type === 'tauri' && settingsStore.sysConfig.enable_local_history === true
44
+ }
45
+
46
+ async function callDbRecordList(
47
+ selfId: string | number,
48
+ command: string,
49
+ payload: Record<string, any>,
50
+ errorTag: string,
51
+ ): Promise<any[]> {
52
+ if (!isTauriHistoryAvailable()) return []
53
+ try {
54
+ const records: LocalMsgRecord[] = await backend.call(
55
+ undefined,
56
+ command,
57
+ true,
58
+ { selfId: String(selfId), ...payload },
59
+ )
60
+ return (records ?? []).map(deserializeRecord)
61
+ } catch (e) {
62
+ logger.error(e as unknown as Error, errorTag)
63
+ return []
64
+ }
65
+ }
66
+
67
+ async function callDb(
68
+ selfId: string | number,
69
+ command: string,
70
+ payload: Record<string, any>,
71
+ fallback: any,
72
+ errorTag: string,
73
+ ): Promise<any> {
74
+ if (!isTauriHistoryAvailable()) return fallback
75
+ try {
76
+ return await backend.call(
77
+ undefined,
78
+ command,
79
+ true,
80
+ { selfId: String(selfId), ...payload },
81
+ )
82
+ } catch (e) {
83
+ logger.error(e as unknown as Error, errorTag)
84
+ return fallback
85
+ }
86
+ }
87
+
88
+ const META_SEGMENT_TYPE = 'chat-patch-meta'
89
+
90
+ function serializeMsgSegments(segments: any[] | undefined, timeMs?: number): string {
91
+ try {
92
+ const next = [...(segments ?? [])]
93
+ if (Number.isFinite(timeMs) && timeMs && timeMs > 0) {
94
+ next.push({ type: META_SEGMENT_TYPE, time: timeMs })
95
+ }
96
+ return JSON.stringify(next)
97
+ } catch {
98
+ return '[]'
99
+ }
100
+ }
101
+
102
+ function deserializeMsgSegments(serialized: string): any[] {
103
+ try {
104
+ return JSON.parse(serialized)
105
+ } catch {
106
+ return []
107
+ }
108
+ }
109
+
110
+ function computeRawMessage(msg: any): string | null {
111
+ try {
112
+ return getMsgRawTxt(msg) || msg.raw_message || null
113
+ } catch {
114
+ return msg.raw_message ?? null
115
+ }
116
+ }
117
+
118
+ function deriveChatId(selfId: string | number, msgs: any[]): number | string | undefined {
119
+ const firstMsg = msgs[0]
120
+ let chatId: number | string | undefined = firstMsg?.infoList?.group_id ?? firstMsg?.infoList?.target_id
121
+ if (chatId != null) return chatId
122
+
123
+ for (const item of msgs) {
124
+ if (item?.infoList?.sender != null && String(item.infoList.sender) !== String(selfId)) {
125
+ chatId = item.infoList.sender
126
+ break
127
+ }
128
+ }
129
+ return chatId
130
+ }
131
+
132
+ export function ensureChatIdOnMsgs(selfId: string | number, msgs: any[]): any[] {
133
+ const chatId = deriveChatId(selfId, msgs)
134
+ if (chatId == null) return msgs
135
+
136
+ return msgs.map((item: any) => {
137
+ if (!item?.infoList) return item
138
+ if (item.infoList.group_id != null || item.infoList.target_id != null) {
139
+ return item
140
+ }
141
+ return {
142
+ ...item,
143
+ infoList: {
144
+ ...item.infoList,
145
+ target_id: chatId,
146
+ },
147
+ }
148
+ })
149
+ }
150
+
151
+ // ── 辅助:将运行时消息对象转换为 LocalMsgRecord ──────────────────
152
+
153
+ /**
154
+ * 将已经过 msgPreprocess 处理的消息对象转成可存入 DB 的 LocalMsgRecord。
155
+ * 若必要字段缺失则返回 null,调用方需过滤掉 null。
156
+ */
157
+ export function msgToRecord(msg: any): LocalMsgRecord | null {
158
+ const messageId = msg.message_id
159
+ if (!messageId) return null
160
+
161
+ const chatId: number | string = msg.infoList.group_id ?? msg.infoList.target_id
162
+ if (chatId == null) return null
163
+
164
+ const chatType: string =
165
+ msg.message_type ?? (msg.group_id != null ? 'group' : 'private')
166
+ const senderId: number | string = msg.infoList.sender
167
+ if (senderId == null) return null
168
+
169
+ const senderName: string | null =
170
+ (msg.sender?.card && msg.sender.card !== '') ? msg.sender.card : (msg.sender?.nickname ?? null)
171
+
172
+ const rawMessage = computeRawMessage(msg)
173
+ const timeMs = Number(msg.local_time ?? msg.timestamp_ms ?? msg.time_ms ?? 0)
174
+ const messageSerialized = serializeMsgSegments(
175
+ msg.message,
176
+ Number.isFinite(timeMs) && timeMs > 0 ? timeMs : undefined,
177
+ )
178
+
179
+ return {
180
+ message_id: String(messageId),
181
+ chat_id: chatId,
182
+ chat_type: chatType,
183
+ sender_id: senderId,
184
+ sender_name: senderName,
185
+ seq: msg.seq_id != null ? Number(msg.seq_id) : null,
186
+ time: Number(msg.time),
187
+ message: messageSerialized,
188
+ raw_message: rawMessage,
189
+ revoked: false,
190
+ }
191
+ }
192
+
193
+ // ── 读写接口 ──────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * 批量将消息保存到本地 SQLite(已有的 message_id 自动忽略,不覆盖)。
197
+ *
198
+ * @param selfId 当前登录账号 uin
199
+ * @param msgs 已完成预处理的消息对象数组(来自 chatStore.messageList 或 newMsg)
200
+ */
201
+ export async function dbSaveMessages(selfId: string | number, msgs: any[]): Promise<void> {
202
+ if (!isTauriHistoryAvailable()) return
203
+
204
+ const persistableMsgs = ensureChatIdOnMsgs(selfId, msgs)
205
+ const records: LocalMsgRecord[] = persistableMsgs
206
+ .map(msgToRecord)
207
+ .filter((r): r is LocalMsgRecord => r !== null)
208
+
209
+ if (records.length === 0) {
210
+ logger.error(null, '[LocalHistory] dbSaveMessages: 没有有效消息可保存')
211
+ return
212
+ }
213
+
214
+ try {
215
+ await backend.call(undefined, 'db:saveMessages', true, {
216
+ selfId: String(selfId),
217
+ messages: records,
218
+ })
219
+ } catch (e) {
220
+ logger.error(e as unknown as Error, '[LocalHistory] dbSaveMessages 失败')
221
+ }
222
+ }
223
+
224
+ export async function saveMessagesWithSideEffects(selfId: string | number, msgs: any[]): Promise<void> {
225
+ const settingsStore = useSettingsStore()
226
+ const persistableMsgs = ensureChatIdOnMsgs(selfId, msgs)
227
+ await dbSaveMessages(selfId, persistableMsgs)
228
+ if (settingsStore.sysConfig.disable_local_history_image_cache === true) return
229
+ cacheImagesFromMsgs(selfId, persistableMsgs).catch(() => {})
230
+ }
231
+
232
+ /**
233
+ * 获取某会话最新 n 条本地消息(正序,revoked 消息不包含)。
234
+ *
235
+ * @returns 消息段数组已反序列化的消息对象数组,出错或非 Tauri 返回空数组
236
+ */
237
+ export async function dbGetLatest(
238
+ selfId: string | number,
239
+ chatId: number | string,
240
+ n: number,
241
+ ): Promise<any[]> {
242
+ return callDbRecordList(selfId, 'db:getLatest', { chatId, n }, '[LocalHistory] dbGetLatest 失败')
243
+ }
244
+
245
+ /**
246
+ * 获取锚点消息之前(更旧)的 n 条,不含锚点本身,正序返回。
247
+ *
248
+ * 典型用途:上拉加载更多历史。
249
+ */
250
+ export async function dbGetBefore(
251
+ selfId: string | number,
252
+ chatId: number | string,
253
+ messageId: string,
254
+ n: number,
255
+ ): Promise<any[]> {
256
+ return callDbRecordList(selfId, 'db:getBefore', { chatId, messageId, n }, '[LocalHistory] dbGetBefore 失败')
257
+ }
258
+
259
+ /**
260
+ * 获取某个时间戳之前(更旧)的 n 条消息,正序返回。
261
+ */
262
+ export async function dbGetBeforeByTime(
263
+ selfId: string | number,
264
+ chatId: number | string,
265
+ beforeTime: number,
266
+ n: number,
267
+ ): Promise<any[]> {
268
+ return callDbRecordList(selfId, 'db:getBeforeByTime', { chatId, beforeTime, n }, '[LocalHistory] dbGetBeforeByTime 失败')
269
+ }
270
+
271
+ /**
272
+ * 获取锚点消息之后(更新)的 n 条,不含锚点本身,正序返回。
273
+ *
274
+ * 典型用途:从指定消息位置向后展开查看。
275
+ */
276
+ export async function dbGetAfter(
277
+ selfId: string | number,
278
+ chatId: number | string,
279
+ messageId: string,
280
+ n: number,
281
+ ): Promise<any[]> {
282
+ return callDbRecordList(selfId, 'db:getAfter', { chatId, messageId, n }, '[LocalHistory] dbGetAfter 失败')
283
+ }
284
+
285
+ /**
286
+ * 将指定 message_id 在本地 DB 中标记为已撤回。
287
+ *
288
+ * @returns 是否命中(true = DB 中存在该消息并更新成功)
289
+ */
290
+ export async function dbRevokeMessage(
291
+ selfId: string | number,
292
+ messageId: string,
293
+ ): Promise<boolean> {
294
+ return callDb(selfId, 'db:revokeMessage', { messageId }, false, '[LocalHistory] dbRevokeMessage 失败')
295
+ }
296
+
297
+ /**
298
+ * 在指定会话的本地 DB 中按关键词搜索消息(对 raw_message 做 LIKE 匹配)。
299
+ *
300
+ * @returns 匹配消息列表(正序),出错或非 Tauri 返回空数组
301
+ */
302
+ export async function dbSearchMessages(
303
+ selfId: string | number,
304
+ chatId: number | string,
305
+ query: string,
306
+ ): Promise<any[]> {
307
+ if (!isTauriHistoryAvailable() || !query) return []
308
+ return callDbRecordList(selfId, 'db:searchMessages', { chatId, query }, '[LocalHistory] dbSearchMessages 失败')
309
+ }
310
+
311
+ export async function dbGetStats(
312
+ selfId: string | number,
313
+ ): Promise<{ totalMessages: number; imageCount: number; imageCacheBytes: number; dbSizeBytes: number } | null> {
314
+ return callDb(selfId, 'db:getStats', {}, null, '[LocalHistory] dbGetStats 失败')
315
+ }
316
+
317
+ /**
318
+ * 计算 URL 的 SHA-256 十六进制摘要,用作图片缓存的唯一键。
319
+ */
320
+ export async function hashUrl(url: string): Promise<string> {
321
+ const data = new TextEncoder().encode(url)
322
+ const buf = await crypto.subtle.digest('SHA-256', data)
323
+ return Array.from(new Uint8Array(buf))
324
+ .map(b => b.toString(16).padStart(2, '0'))
325
+ .join('')
326
+ }
327
+
328
+ /**
329
+ * 将图片缓存到本地加密数据库。
330
+ * `data` 为 base64 编码的原始图片字节。
331
+ */
332
+ export async function dbCacheImage(
333
+ selfId: string | number,
334
+ urlHash: string,
335
+ mimeType: string,
336
+ data: string,
337
+ ): Promise<void> {
338
+ if (!isTauriHistoryAvailable()) return
339
+
340
+ try {
341
+ await backend.call(undefined, 'db:cacheImage', true, {
342
+ selfId: String(selfId),
343
+ urlHash,
344
+ mimeType,
345
+ data,
346
+ })
347
+ } catch (e) {
348
+ logger.error(e as unknown as Error, '[LocalHistory] dbCacheImage 失败')
349
+ }
350
+ }
351
+
352
+ /**
353
+ * 从本地数据库读取已缓存的图片。
354
+ * 返回 `{ mimeType, data }` (data 为 base64),未缓存则返回 `null`。
355
+ */
356
+ export async function dbGetImage(
357
+ selfId: string | number,
358
+ urlHash: string,
359
+ ): Promise<{ mimeType: string; data: string } | null> {
360
+ return callDb(selfId, 'db:getImage', { urlHash }, null, '[LocalHistory] dbGetImage 失败')
361
+ }
362
+
363
+ export interface DbClearImagesProgress {
364
+ selfId: string
365
+ total: number
366
+ deleted: number
367
+ batchDeleted: number
368
+ progress: number
369
+ done: boolean
370
+ }
371
+
372
+ export interface DbClearImagesResult {
373
+ total: number
374
+ deleted: number
375
+ batches: number
376
+ }
377
+
378
+ export async function dbClearImages(
379
+ selfId: string | number,
380
+ onProgress?: (progress: DbClearImagesProgress) => void,
381
+ ): Promise<DbClearImagesResult> {
382
+ if (!isTauriHistoryAvailable()) {
383
+ return { total: 0, deleted: 0, batches: 0 }
384
+ }
385
+
386
+ let unlisten: undefined | (() => void | Promise<void>)
387
+ if (onProgress && backend.type === 'tauri') {
388
+ const { listen } = await import('@tauri-apps/api/event')
389
+ unlisten = await listen<DbClearImagesProgress>('db:clearImagesProgress', (event) => {
390
+ const payload = event.payload
391
+ if (!payload) return
392
+ if (String(payload.selfId) !== String(selfId)) return
393
+ onProgress(payload)
394
+ })
395
+ }
396
+
397
+ try {
398
+ const result = await callDb(
399
+ selfId,
400
+ 'db:clearImages',
401
+ {},
402
+ { total: 0, deleted: 0, batches: 0 },
403
+ '[LocalHistory] dbClearImages 失败',
404
+ )
405
+ return {
406
+ total: Number(result?.total ?? 0),
407
+ deleted: Number(result?.deleted ?? 0),
408
+ batches: Number(result?.batches ?? 0),
409
+ }
410
+ } finally {
411
+ if (unlisten) await unlisten()
412
+ }
413
+ }
414
+
415
+ // ── 内部工具 ──────────────────────────────────────────────────────
416
+
417
+ /**
418
+ * 遍历消息列表,将所有图片段下载并缓存到本地数据库。
419
+ * 已缓存的图片(url_hash 命中)不会重复下载。
420
+ */
421
+ async function cacheImagesFromMsgs(selfId: string | number, msgs: any[]): Promise<void> {
422
+ if (!isTauriHistoryAvailable()) return
423
+ const urls = extractImageUrlsFromMsgs(msgs)
424
+ for (const url of urls) {
425
+ try {
426
+ await cacheSingleImage(selfId, url)
427
+ } catch {
428
+ // 单张图片失败不影响其余图片
429
+ }
430
+ }
431
+ }
432
+
433
+ function extractImageUrlsFromMsgs(msgs: any[]): string[] {
434
+ const urls: string[] = []
435
+ for (const msg of msgs) {
436
+ if (!Array.isArray(msg.message)) continue
437
+ for (const seg of msg.message) {
438
+ if (seg.type === 'image' && seg.url && seg.url.startsWith('http')) {
439
+ urls.push(seg.url)
440
+ }
441
+ }
442
+ }
443
+ return urls
444
+ }
445
+
446
+ async function downloadImageViaProxy(url: string): Promise<{ mimeType: string; base64: string } | null> {
447
+ const fetchUrl = backend.proxy? `http://localhost:${backend.proxy}/proxy?url=${encodeURIComponent(url)}`: url
448
+
449
+ const resp = await fetch(fetchUrl)
450
+ if (!resp.ok) return null
451
+
452
+ const mimeType = resp.headers.get('Content-Type')?.split(';')[0]?.trim() ?? 'image/jpeg'
453
+ const buffer = await resp.arrayBuffer()
454
+ const bytes = new Uint8Array(buffer)
455
+ let binary = ''
456
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i])
457
+ const base64 = btoa(binary)
458
+ return { mimeType, base64 }
459
+ }
460
+
461
+ async function cacheSingleImage(selfId: string | number, url: string): Promise<void> {
462
+ const urlHash = await hashUrl(url)
463
+ const existing = await dbGetImage(selfId, urlHash)
464
+ if (existing) return
465
+
466
+ const downloaded = await downloadImageViaProxy(url)
467
+ if (!downloaded) return
468
+
469
+ await dbCacheImage(selfId, urlHash, downloaded.mimeType, downloaded.base64)
470
+ }
471
+
472
+ /**
473
+ * 将 DB 返回的 LocalMsgRecord 还原为与 chatStore.messageList 兼容的消息对象。
474
+ */
475
+ function deserializeRecord(record: LocalMsgRecord): any {
476
+ const authStore = useAuthStore()
477
+ const parsed = deserializeMsgSegments(record.message)
478
+ const meta = parsed.find((item) => item?.type === META_SEGMENT_TYPE)
479
+ const timeMs = Number(meta?.time ?? 0)
480
+ const message = parsed.filter((item) => item?.type !== META_SEGMENT_TYPE)
481
+
482
+ // 判断是否为自己发送的消息,还原 post_type
483
+ const isSelf = record.sender_id === Number(authStore.loginInfo.uin)
484
+ const postType = isSelf ? 'message_sent' : 'message'
485
+
486
+ // 群消息:sender_name 来自 card,私聊来自 nickname
487
+ const isGroup = record.chat_type === 'group'
488
+ const sender = isGroup ? { user_id: record.sender_id, card: record.sender_name ?? '', nickname: record.sender_name ?? '' } : { user_id: record.sender_id, card: '', nickname: record.sender_name ?? '' }
489
+ const infoList = {
490
+ message_id: record.message_id,
491
+ private_id: isGroup ? undefined : record.chat_id,
492
+ group_id: isGroup ? record.chat_id : undefined,
493
+ target_id: isGroup ? undefined : record.chat_id,
494
+ sender: record.sender_id,
495
+ }
496
+
497
+ return {
498
+ post_type: postType,
499
+ message_id: record.message_id,
500
+ message_type: record.chat_type,
501
+ // 根据 chat_type 恢复对应的 id 字段
502
+ ...(isGroup ? { group_id: record.chat_id } : { user_id: record.chat_id }),
503
+ sender,
504
+ time: record.time,
505
+ timestamp_ms: timeMs || undefined,
506
+ local_time: timeMs || undefined,
507
+ time_ms: timeMs || undefined,
508
+ message,
509
+ infoList,
510
+ raw_message: record.raw_message ?? '',
511
+ revoked: record.revoked,
512
+ // 消息序列号(并非所有 Bot 都提供,可为 null)
513
+ ...(record.seq != null ? { message_seq: record.seq, seq_id: record.seq } : {}),
514
+ // 标记来源为本地缓存,业务层可按需用此字段区分
515
+ _from_local_db: true,
516
+ }
517
+ }