dsh-qqchat 0.2.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 (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +276 -0
  3. package/README.md +211 -0
  4. package/client-src/plugin.cts +15 -0
  5. package/client-src/settings.cts +216 -0
  6. package/client-src/shared.cts +104 -0
  7. package/client-src/workspace.cts +164 -0
  8. package/cordis.patch.yml +7 -0
  9. package/docs/ARCHITECTURE.en.md +346 -0
  10. package/docs/ARCHITECTURE.md +411 -0
  11. package/docs/DEVELOPMENT.md +69 -0
  12. package/docs/MEDIA_AND_QUOTES.md +644 -0
  13. package/docs/MEMORY.md +99 -0
  14. package/docs/MEMORY_CONTEXT.md +254 -0
  15. package/docs/QUOTES.md +355 -0
  16. package/docs/README.md +22 -0
  17. package/docs/SECURITY.md +62 -0
  18. package/docs/STREAMING.md +347 -0
  19. package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
  20. package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
  21. package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
  22. package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
  23. package/docs/devlog.md +24 -0
  24. package/lib/client.js +433 -0
  25. package/lib/commands/dispatch.d.ts +16 -0
  26. package/lib/commands/dispatch.js +48 -0
  27. package/lib/config.d.ts +3 -0
  28. package/lib/config.js +63 -0
  29. package/lib/gateway/api.d.ts +30 -0
  30. package/lib/gateway/api.js +202 -0
  31. package/lib/gateway/auth.d.ts +35 -0
  32. package/lib/gateway/auth.js +80 -0
  33. package/lib/gateway/crypto.d.ts +2 -0
  34. package/lib/gateway/crypto.js +17 -0
  35. package/lib/gateway/gateway.d.ts +32 -0
  36. package/lib/gateway/gateway.js +219 -0
  37. package/lib/gateway/normalize.d.ts +6 -0
  38. package/lib/gateway/normalize.js +248 -0
  39. package/lib/index.d.ts +14 -0
  40. package/lib/index.js +35 -0
  41. package/lib/media/image-tool.d.ts +4 -0
  42. package/lib/media/image-tool.js +62 -0
  43. package/lib/media/media-tools.d.ts +3 -0
  44. package/lib/media/media-tools.js +68 -0
  45. package/lib/media/store.d.ts +14 -0
  46. package/lib/media/store.js +176 -0
  47. package/lib/session/agent-bridge.d.ts +56 -0
  48. package/lib/session/agent-bridge.js +613 -0
  49. package/lib/session/memory-snapshot.d.ts +23 -0
  50. package/lib/session/memory-snapshot.js +27 -0
  51. package/lib/session/runtime.d.ts +41 -0
  52. package/lib/session/runtime.js +395 -0
  53. package/lib/shared/augmentations.d.ts +40 -0
  54. package/lib/shared/augmentations.js +1 -0
  55. package/lib/shared/logging.d.ts +14 -0
  56. package/lib/shared/logging.js +41 -0
  57. package/lib/storage/db.d.ts +82 -0
  58. package/lib/storage/db.js +669 -0
  59. package/lib/storage/memory.d.ts +50 -0
  60. package/lib/storage/memory.js +566 -0
  61. package/lib/transport/rpc.d.ts +18 -0
  62. package/lib/transport/rpc.js +318 -0
  63. package/lib/types.d.ts +416 -0
  64. package/lib/types.js +1 -0
  65. package/package.json +115 -0
  66. package/scripts/wrap-client.mjs +20 -0
  67. package/src/commands/dispatch.ts +75 -0
  68. package/src/config.ts +70 -0
  69. package/src/gateway/api.ts +260 -0
  70. package/src/gateway/auth.ts +99 -0
  71. package/src/gateway/crypto.ts +16 -0
  72. package/src/gateway/gateway.ts +207 -0
  73. package/src/gateway/normalize.ts +236 -0
  74. package/src/index.ts +46 -0
  75. package/src/media/image-tool.ts +62 -0
  76. package/src/media/media-tools.ts +66 -0
  77. package/src/media/store.ts +157 -0
  78. package/src/session/agent-bridge.ts +624 -0
  79. package/src/session/memory-snapshot.ts +41 -0
  80. package/src/session/runtime.ts +368 -0
  81. package/src/shared/augmentations.ts +45 -0
  82. package/src/shared/logging.ts +36 -0
  83. package/src/storage/db.ts +760 -0
  84. package/src/storage/memory.ts +581 -0
  85. package/src/transport/rpc.ts +316 -0
  86. package/src/types.ts +456 -0
  87. package/tsconfig.build.json +12 -0
  88. package/tsconfig.client.json +14 -0
  89. package/tsconfig.json +23 -0
@@ -0,0 +1,216 @@
1
+ import { h, useCallback, useEffect, useState, call, Button, Input, Menu, Modal, dateTime } from './shared.cjs'
2
+ import type { Account, GroupReceiveMode, KnownMember, PluginLog, ReplyFormat, Rpc, Settings } from './shared.cjs'
3
+
4
+ interface QQSettingsCache {
5
+ account: Account | null
6
+ settings: Settings | null
7
+ members: KnownMember[]
8
+ }
9
+
10
+ let qqSettingsCache: QQSettingsCache | undefined
11
+
12
+ export function QQSettings({ rpc }: { rpc: Rpc }) {
13
+ const [account, setAccount] = useState<Account | null>(() => qqSettingsCache?.account || null)
14
+ const [settings, setSettings] = useState<Settings | null>(() => qqSettingsCache?.settings || null)
15
+ const [members, setMembers] = useState<KnownMember[]>(() => qqSettingsCache?.members || [])
16
+ const [loading, setLoading] = useState(() => qqSettingsCache === undefined)
17
+ const [auth, setAuth] = useState<{ taskId: string; qrDataUrl: string } | null>(null)
18
+ const [logs, setLogs] = useState<PluginLog[] | null>(null)
19
+ const [busy, setBusy] = useState(false)
20
+ const [error, setError] = useState('')
21
+ const [groupReplyMenuOpen, setGroupReplyMenuOpen] = useState(false)
22
+ const [directReplyMenuOpen, setDirectReplyMenuOpen] = useState(false)
23
+ const [ownerMenuOpen, setOwnerMenuOpen] = useState(false)
24
+
25
+ const refresh = useCallback(async () => {
26
+ try {
27
+ const [status, prefs] = await Promise.all([
28
+ call<{ accounts: Account[] }>(rpc, 'status'),
29
+ call<{ settings: Settings; members: KnownMember[] }>(rpc, 'settings/get'),
30
+ ])
31
+ const next = {
32
+ account: status.accounts.find(item => item.enabled) || null,
33
+ settings: prefs.settings,
34
+ members: prefs.members || [],
35
+ }
36
+ qqSettingsCache = next
37
+ setAccount(next.account)
38
+ setSettings(next.settings)
39
+ setMembers(next.members)
40
+ setError('')
41
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setLoading(false) }
42
+ }, [rpc])
43
+
44
+ useEffect(() => { void refresh() }, [refresh])
45
+ useEffect(() => {
46
+ if (!auth) return
47
+ let stopped = false
48
+ let timer: ReturnType<typeof setTimeout> | undefined
49
+ const poll = async () => {
50
+ if (stopped) return
51
+ try {
52
+ const state = await call<{ status: string; reason?: string }>(rpc, 'auth/poll', { taskId: auth.taskId })
53
+ if (state.status === 'success') { setAuth(null); await refresh(); return }
54
+ if (state.status === 'fail' || state.status === 'expired') { setError(state.reason || '二维码已失效'); setAuth(null); return }
55
+ } catch (err) {
56
+ if (!stopped) { setError(err instanceof Error ? err.message : String(err)); setAuth(null) }
57
+ return
58
+ }
59
+ if (!stopped) timer = setTimeout(() => void poll(), 3000)
60
+ }
61
+ timer = setTimeout(() => void poll(), 3000)
62
+ return () => { stopped = true; if (timer) clearTimeout(timer) }
63
+ }, [auth, rpc, refresh])
64
+
65
+ const patch = useCallback(async (value: Partial<Settings>) => {
66
+ if (!settings) return
67
+ const optimistic = { ...settings, ...value }
68
+ setSettings(optimistic)
69
+ try {
70
+ const result = await call<{ settings: Settings }>(rpc, 'settings/update', { patch: value })
71
+ setSettings(result.settings)
72
+ if (qqSettingsCache) qqSettingsCache = { ...qqSettingsCache, settings: result.settings }
73
+ setError('')
74
+ } catch (err) { setSettings(settings); setError(err instanceof Error ? err.message : String(err)) }
75
+ }, [rpc, settings])
76
+
77
+ const startAuth = async () => {
78
+ setBusy(true)
79
+ try { setAuth(await call<{ taskId: string; qrDataUrl: string }>(rpc, 'auth/start')); setError('') } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setBusy(false) }
80
+ }
81
+ const disconnect = async () => {
82
+ if (!account) return
83
+ setBusy(true)
84
+ try {
85
+ await call(rpc, 'account/disconnect', { accountId: account.id })
86
+ setAccount(null)
87
+ if (qqSettingsCache) qqSettingsCache = { ...qqSettingsCache, account: null }
88
+ setAuth(null)
89
+ setError('已取消连接,可以重新扫码绑定 Bot。')
90
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setBusy(false) }
91
+ }
92
+ const openLogs = async () => {
93
+ try { setLogs((await call<{ logs: PluginLog[] }>(rpc, 'logs/list', { limit: 300 })).logs) } catch (err) { setError(err instanceof Error ? err.message : String(err)) }
94
+ }
95
+
96
+ const ownerPicker = h(Menu, {
97
+ open: ownerMenuOpen,
98
+ onClose: () => setOwnerMenuOpen(false),
99
+ selectedId: settings?.ownerUserId,
100
+ onSelect: (id: string) => { setOwnerMenuOpen(false); void patch({ ownerUserId: id }) },
101
+ anchor: h(Button, { size: 'sm', variant: 'outline', onClick: () => setOwnerMenuOpen(!ownerMenuOpen) }, '选择用户'),
102
+ align: 'end',
103
+ items: [{ id: '', label: '不设置 Owner' }, ...members.map(member => ({ id: member.platformUserId, label: member.displayName || 'QQ 群友' }))],
104
+ })
105
+ const formatLabel = (value: ReplyFormat | undefined) => value === 'markdown' ? 'Markdown' : value === 'compat' ? '纯文本兼容' : '智能兼容'
106
+ const formatItems = [{ id: 'smart', label: '智能兼容' }, { id: 'markdown', label: 'Markdown' }, { id: 'compat', label: '纯文本兼容' }]
107
+ const groupReplyPicker = h(Menu, {
108
+ open: groupReplyMenuOpen,
109
+ onClose: () => setGroupReplyMenuOpen(false),
110
+ selectedId: settings?.groupReplyFormat,
111
+ onSelect: (id: string) => { setGroupReplyMenuOpen(false); void patch({ groupReplyFormat: id as ReplyFormat }) },
112
+ anchor: h(Button, { size: 'sm', variant: 'outline', onClick: () => setGroupReplyMenuOpen(!groupReplyMenuOpen) }, formatLabel(settings?.groupReplyFormat)),
113
+ items: formatItems,
114
+ align: 'end',
115
+ })
116
+ const directReplyPicker = h(Menu, {
117
+ open: directReplyMenuOpen,
118
+ onClose: () => setDirectReplyMenuOpen(false),
119
+ selectedId: settings?.directReplyFormat,
120
+ onSelect: (id: string) => { setDirectReplyMenuOpen(false); void patch({ directReplyFormat: id as ReplyFormat }) },
121
+ anchor: h(Button, { size: 'sm', variant: 'outline', onClick: () => setDirectReplyMenuOpen(!directReplyMenuOpen) }, formatLabel(settings?.directReplyFormat)),
122
+ items: formatItems,
123
+ align: 'end',
124
+ })
125
+
126
+ const head = h('div', { className: 'qqsHead' },
127
+ h('div', null, h('div', { className: 'qqsTitle' }, 'QQ Chat'), h('div', { className: 'qqMuted' }, 'QQ Bot 接收、兼容与权限设置')),
128
+ h('div', { className: 'qqStatus' }, loading ? '加载中…' : account ? (account.gatewayStatus === 'online' ? '已连接' : '已授权') : '未连接', account && h(Button, { size: 'sm', variant: 'outline', disabled: busy, onClick: () => void disconnect() }, '取消连接')))
129
+
130
+ if (loading) return h('div', { className: 'qqs' }, head, h('div', { className: 'qqMuted' }, '正在读取 QQ Chat 设置…'))
131
+ if (!account) return h('div', { className: 'qqs' }, head, error && h('div', { className: 'qqError' }, error), h('div', { className: 'qqConnect' }, h('div', { className: 'qqsTitle' }, auth ? '使用 QQ 扫码授权' : '连接 QQ Bot'), h('div', { className: 'qqMuted' }, '扫码和凭据解密都在 DSH Host 侧完成。'), auth && h('div', { className: 'qqQr' }, h('img', { src: auth.qrDataUrl, alt: 'QQ 授权二维码' })), h(Button, { variant: 'primary', disabled: busy, onClick: () => void startAuth() }, auth ? '重新生成二维码' : '扫码连接')))
132
+ if (!settings) return h('div', { className: 'qqs' }, head, h('div', { className: 'qqMuted' }, '正在读取设置…'))
133
+
134
+ const receiveButtons = h('div', { className: 'qqChoiceButtons' },
135
+ ...(['auto', 'mention', 'silent'] as const).map(mode => h(Button, {
136
+ key: mode,
137
+ size: 'sm',
138
+ variant: settings.groupReceiveMode === mode ? 'primary' : 'outline',
139
+ onClick: () => void patch({ groupReceiveMode: mode }),
140
+ }, mode === 'auto' ? '自动回应' : mode === 'mention' ? '@回复' : '静默记录')))
141
+
142
+ const receiveGroup = h('div', { className: 'qqSettingsGroup' },
143
+ h('h3', null, '消息接收'),
144
+ h('div', { className: 'qqSetting' },
145
+ h('div', null, h('div', { className: 'qqSettingTitle' }, '群聊接收方式'), h('div', { className: 'qqSettingHelp' }, '自动回应:每条群消息都会触发 Agent;@回复:所有消息都记录,但只有 @Bot 才唤醒 Agent;静默记录:只记录群聊与记忆,不主动回应。')),
146
+ h('div', { className: 'qqControl' }, receiveButtons)),
147
+ h('div', { className: 'qqSetting' },
148
+ h('div', null, h('div', { className: 'qqSettingTitle' }, '群聊消息兼容格式'), h('div', { className: 'qqSettingHelp' }, '仅控制发送到 QQ 群聊时使用的消息格式。')),
149
+ h('div', { className: 'qqControl' }, groupReplyPicker)),
150
+ h('div', { className: 'qqSetting' },
151
+ h('div', null, h('div', { className: 'qqSettingTitle' }, '私聊消息兼容格式'), h('div', { className: 'qqSettingHelp' }, '仅控制发送到 QQ 私聊时使用的消息格式。')),
152
+ h('div', { className: 'qqControl' }, directReplyPicker)),
153
+ h('div', { className: 'qqSetting' },
154
+ h('label', { className: 'qqCheckRow' },
155
+ h('input', {
156
+ type: 'checkbox',
157
+ checked: settings.directStreamingEnabled && settings.directReplyFormat !== 'compat',
158
+ disabled: settings.directReplyFormat === 'compat',
159
+ onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => void patch({ directStreamingEnabled: event.target.checked }),
160
+ }),
161
+ h('span', null,
162
+ h('div', { className: 'qqSettingTitle' }, '私聊流式回复'),
163
+ h('div', { className: 'qqSettingHelp' }, settings.directReplyFormat === 'compat'
164
+ ? '纯文本兼容模式不支持流式传输,已自动关闭。'
165
+ : settings.directStreamingEnabled
166
+ ? '使用 QQ 官方流式接口逐步更新私聊回复;部分 QQ 客户端可能看不到流式效果。'
167
+ : '使用普通一次性消息发送,兼容性更好。'))))
168
+ )
169
+ const memoryGroup = h('div', { className: 'qqSettingsGroup' },
170
+ h('h3', null, '记忆系统'),
171
+ h('div', { className: 'qqSetting' },
172
+ h('div', null,
173
+ h('div', { className: 'qqSettingTitle' }, '启用记忆系统'),
174
+ h('div', { className: 'qqSettingHelp' }, '记忆会注入每轮 Agent 上下文,并在空闲时整理近期消息;这可能降低上下文缓存命中率并增加输入 token。关闭后停止记忆注入和后台整理,但不会删除已有记忆。')),
175
+ h('div', { className: 'qqControl' }, h(Button, { size: 'sm', variant: settings.memoryEnabled ? 'primary' : 'outline', onClick: () => void patch({ memoryEnabled: !settings.memoryEnabled }) }, settings.memoryEnabled ? '已启用' : '已关闭'))))
176
+ const ownerSetting = !settings.groupMembersCanUseTools
177
+ ? h('div', { className: 'qqSetting' },
178
+ h('div', null,
179
+ h('div', { className: 'qqSettingTitle' }, 'Owner stable ID'),
180
+ h('div', { className: 'qqSettingHelp' }, '使用 stable sender ID,不根据昵称判断。')),
181
+ h('div', { className: 'qqControl' },
182
+ h(Input, {
183
+ value: settings.ownerUserId,
184
+ placeholder: '粘贴 stable ID',
185
+ onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => setSettings({ ...settings, ownerUserId: event.target.value }),
186
+ onBlur: (event: import('react').FocusEvent<HTMLInputElement>) => void patch({ ownerUserId: event.currentTarget.value }),
187
+ }),
188
+ ownerPicker))
189
+ : null
190
+ const toolsGroup = h('div', { className: 'qqSettingsGroup' },
191
+ h('h3', null, '工具权限'),
192
+ h('div', { className: 'qqSetting' },
193
+ h('label', { className: 'qqCheckRow' },
194
+ h('input', { type: 'checkbox', checked: settings.groupMembersCanUseTools, onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => void patch({ groupMembersCanUseTools: event.target.checked }) }),
195
+ h('span', null,
196
+ h('div', { className: 'qqSettingTitle' }, '群成员可用工具'),
197
+ h('div', { className: 'qqSettingHelp' }, settings.groupMembersCanUseTools ? '群友触发的 Agent 回合可以使用当前 preset 的全部工具。' : '关闭后只有 Owner 触发的群聊回合可以执行工具;其他群友仍可正常聊天,但工具调用会被拒绝。')))),
198
+ ownerSetting)
199
+ const mediaGroup = h('div', { className: 'qqSettingsGroup' },
200
+ h('h3', null, '媒体与文件权限'),
201
+ h('div', { className: 'qqSetting' },
202
+ h('label', { className: 'qqCheckRow' },
203
+ h('input', { type: 'checkbox', checked: settings.groupMembersCanReceiveMedia, onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => void patch({ groupMembersCanReceiveMedia: event.target.checked }) }),
204
+ h('span', null,
205
+ h('div', { className: 'qqSettingTitle' }, '接收群成员媒体/文件'),
206
+ h('div', { className: 'qqSettingHelp' }, settings.groupMembersCanReceiveMedia ? '下载并在会话中显示群成员发送的图片、文件、音频和视频。' : '群成员发送的媒体不会下载、存储或注入会话;Owner 和私聊不受影响。')))),
207
+ h('div', { className: 'qqSetting' },
208
+ h('label', { className: 'qqCheckRow' },
209
+ h('input', { type: 'checkbox', checked: settings.groupMembersCanReadMedia, onChange: (event: import('react').ChangeEvent<HTMLInputElement>) => void patch({ groupMembersCanReadMedia: event.target.checked }) }),
210
+ h('span', null,
211
+ h('div', { className: 'qqSettingTitle' }, '允许群成员读取媒体/文件'),
212
+ h('div', { className: 'qqSettingHelp' }, settings.groupMembersCanReadMedia ? '群成员触发的回合可以调用 QQChat 媒体工具读取当前消息附件。' : '群成员仍可看到已接收的媒体,但不能调用媒体工具读取内容;Owner 和私聊不受影响。')))))
213
+ const diagnosticsGroup = h('div', { className: 'qqSettingsGroup' }, h('h3', null, '诊断'), h('div', { className: 'qqSetting' }, h('div', null, h('div', { className: 'qqSettingTitle' }, '插件日志'), h('div', { className: 'qqSettingHelp' }, account.gatewayLastError ? `最近网关错误:${account.gatewayLastError}` : '查看 QQ Gateway、授权、Agent bridge 与消息发送的最近日志。')), h(Button, { size: 'sm', variant: 'outline', onClick: () => void openLogs() }, '查看日志')))
214
+ return h('div', { className: 'qqs' }, head, error && h('div', { className: 'qqError' }, error), receiveGroup, memoryGroup, mediaGroup, toolsGroup, diagnosticsGroup,
215
+ logs && h(Modal, { title: 'QQ Chat 日志', onClose: () => setLogs(null) }, logs.length ? logs.map(log => h('div', { key: log.id, className: `qqLog ${log.level}` }, h('span', { className: 'qqLogMeta' }, `${dateTime(log.time)} [${log.level}]`), log.message)) : h('div', { className: 'qqMuted' }, '暂无日志。')))
216
+ }
@@ -0,0 +1,104 @@
1
+ export const React: typeof import('react') = require('react')
2
+ const dshPrimitives = require('@deepseek-ai/dsh-client-ui-primitives') as {
3
+ Button: import('react').ComponentType<any>
4
+ Input: import('react').ComponentType<any>
5
+ Menu: import('react').ComponentType<any>
6
+ Modal: import('react').ComponentType<any>
7
+ }
8
+ const { Button: DshButton, Input: DshInput, Menu: DshMenu, Modal: DshModal } = dshPrimitives
9
+ export const Button = DshButton as unknown as import('react').ComponentType<any>
10
+ export const Input = DshInput as unknown as import('react').ComponentType<any>
11
+ export const Menu = DshMenu as unknown as import('react').ComponentType<any>
12
+ export const { useCallback, useEffect, useMemo, useState, useSyncExternalStore } = React
13
+ export const h = React.createElement
14
+
15
+ let qqRpc: Rpc | undefined
16
+ export function setQQRpc(rpc: Rpc): void { qqRpc = rpc }
17
+ export function getQQRpc(): Rpc | undefined { return qqRpc }
18
+
19
+ export type ChatType = 'c2c' | 'group'
20
+ export type GroupReceiveMode = 'auto' | 'mention' | 'silent'
21
+ export type ReplyFormat = 'smart' | 'markdown' | 'compat'
22
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
23
+ export type RpcResult<T> = { ok: true; value: T } | { ok: false; error?: { message?: string } }
24
+ export interface Rpc { call(channel: string, endpoint: string, payload: unknown): Promise<RpcResult<unknown>> }
25
+ export interface SessionListSnapshot { byId: Record<string, unknown>; current?: string }
26
+ export interface Observable<T> { getSnapshot(): T; subscribe(listener: () => void): () => void }
27
+ export interface SessionsService { list: Observable<SessionListSnapshot>; open(id: string): void }
28
+ export interface SlotRegistry {
29
+ inject(name: string, fn: () => unknown): unknown
30
+ register<P>(options: Record<string, unknown>, component: import('react').ComponentType<P>): unknown
31
+ }
32
+ export interface ConversationEvents { register(definition: Record<string, unknown>): unknown }
33
+ export interface Ctx {
34
+ connection: { rpc: Rpc }
35
+ sessions: SessionsService
36
+ conversationEvents: ConversationEvents
37
+ effect(fn: () => (() => void) | void, label: string): unknown
38
+ slots: SlotRegistry
39
+ }
40
+ export interface Account { id: number; appId: string; enabled: boolean; gatewayStatus: string; gatewayLastError?: string | null }
41
+ export interface Settings { memoryEnabled: boolean; groupReceiveMode: GroupReceiveMode; groupReplyFormat: ReplyFormat; directReplyFormat: ReplyFormat; directStreamingEnabled: boolean; groupMembersCanUseTools: boolean; groupMembersCanReceiveMedia: boolean; groupMembersCanReadMedia: boolean; ownerUserId: string }
42
+ export interface KnownMember { id: number; platformUserId: string; displayName: string; lastSeenAt: number }
43
+ export interface ChatItem { chatType: ChatType; rowId: number; platformId: string; displayName: string; dshSessionId: string | null; lastMessageAt: number | null; messageCount: number }
44
+ export interface Memory { profile: string; summary: string; daily: string; memory: string; pattern: string }
45
+ export interface ChatMember { id: number; platformUserId: string; displayName: string; aliases: string[]; nicknames: string[]; messageCount: number; memory: Memory }
46
+ export interface ChatInfo { chatType: ChatType; rowId: number; title: string; platformId: string; memory: Memory; members: ChatMember[] }
47
+ export interface PluginLog { id: number; time: number; level: LogLevel; message: string }
48
+ export interface QQAttachmentData { id: string; kind: string; filename: string; contentType?: string; sizeBytes: number; quoted?: boolean; imageRef?: Record<string, unknown> }
49
+ export interface QQQuoteData { messageId?: string; senderId?: string; senderName?: string; text: string; attachments?: QQAttachmentData[] }
50
+ export interface QQEventData { messageId: string; chatType: ChatType; chatId: string; direction: 'inbound' | 'outbound'; senderId: string; senderName: string; isOwner?: boolean; content: string; quotedText: string; mentioned: boolean; createdAt: number; sessionId?: string; attachments?: QQAttachmentData[]; quote?: QQQuoteData | null }
51
+ export interface QQNode { data: QQEventData }
52
+ export interface SessionUtilityProps { sessionId: string }
53
+ export interface SidebarActionProps { wide: boolean; rpc: Rpc; sessions: SessionsService }
54
+
55
+ export const CHANNEL = '/qqchat'
56
+ const STYLE_ID = 'dsh-qqchat/client-ui-v2'
57
+
58
+ const css = `
59
+ .qqs{display:flex;flex-direction:column;gap:18px;color:var(--dsw-alias-label-primary);font:inherit}.qqs *{box-sizing:border-box}
60
+ .qqsHead{display:flex;align-items:center;justify-content:space-between;gap:12px}.qqsBrand{display:flex;align-items:center;gap:10px}.qqsLogo{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-weight:700}.qqsTitle{font-size:15px;font-weight:620}.qqMuted{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.qqStatus{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--dsw-alias-label-secondary)}.qqDot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-state-warn-primary)}.qqDot.online{background:var(--dsw-alias-state-success-primary)}
61
+ .qqCard{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}.qqCardHead{padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:13px;font-weight:600}.qqSetting{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:13px 14px;border-bottom:1px solid var(--dsw-alias-border-l1)}.qqSetting:last-child{border-bottom:0}.qqSettingTitle{font-size:13px}.qqSettingHelp{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:4px;line-height:1.5;max-width:470px}.qqControl{min-width:190px;display:flex;justify-content:flex-end;align-items:center;gap:8px}.qqSelect,.qqInput{font:inherit;font-size:12px;color:inherit;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:7px 9px;outline:none}.qqInput{width:250px}.qqSelect{min-width:180px}.qqSwitch{position:relative;width:38px;height:22px;border:0;border-radius:999px;background:var(--dsw-alias-bg-overlay);cursor:pointer}.qqSwitch.on{background:var(--dsw-alias-brand-primary)}.qqSwitch:after{content:'';position:absolute;width:16px;height:16px;border-radius:50%;top:3px;left:3px;background:var(--dsw-alias-bg-base);transition:transform .15s}.qqSwitch.on:after{transform:translateX(16px)}
62
+ .qqSegment{display:flex;padding:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-base)}.qqSegment button{font:inherit;font-size:11px;border:0;background:transparent;color:var(--dsw-alias-label-secondary);padding:6px 9px;border-radius:6px;cursor:pointer;white-space:nowrap}.qqSegment button.active{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
63
+ .qqBtn{font:inherit;font-size:12px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:inherit;border-radius:8px;padding:7px 11px;cursor:pointer}.qqBtn:hover{background:var(--dsw-alias-bg-layer-2)}.qqBtn.primary{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-base);border-color:transparent}.qqBtn:disabled{opacity:.45;cursor:default}.qqError{font-size:12px;color:var(--dsw-alias-state-error-primary)}
64
+ .qqConnect{padding:24px;display:flex;flex-direction:column;align-items:center;gap:14px;text-align:center}.qqQr{padding:14px;background:#fff;border-radius:14px}.qqQr img{display:block;width:210px;height:210px}
65
+ .qqModalMask{position:fixed;inset:0;z-index:10000;background:#0007;display:grid;place-items:center;padding:24px}.qqModal{width:min(720px,calc(100vw - 40px));max-height:min(760px,calc(100vh - 40px));display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:14px;box-shadow:0 16px 60px #0007;overflow:hidden}.qqModalHead{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 15px;border-bottom:1px solid var(--dsw-alias-border-l1)}.qqModalTitle{font-size:14px;font-weight:620}.qqModalBody{overflow:auto;padding:14px}.qqClose{border:0;background:transparent;color:inherit;font-size:18px;cursor:pointer}
66
+ .qqLog{font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;padding:7px 0;border-bottom:1px solid var(--dsw-alias-border-l1);white-space:pre-wrap;word-break:break-word}.qqLogMeta{opacity:.65;margin-right:8px}.qqLog.warn{color:var(--dsw-alias-state-warn-primary)}.qqLog.error{color:var(--dsw-alias-state-error-primary)}
67
+ .qqFootButton{border:0;background:transparent;color:var(--dsw-alias-label-secondary);height:36px;border-radius:8px;display:flex;align-items:center;justify-content:center;gap:8px;padding:0 10px;cursor:pointer;font:inherit;font-size:12px}.qqFootButton:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}.qqFootIcon{width:20px;height:20px;border-radius:6px;display:grid;place-items:center;font-size:10px;font-weight:700;border:1px solid var(--dsw-alias-border-l2)}
68
+ .qqChatToolbar{display:flex;gap:8px;margin-bottom:12px}.qqSearch{flex:1;font:inherit;background:var(--dsw-alias-bg-base);color:inherit;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;padding:8px 10px}.qqChatSection{margin:14px 0 6px;font-size:11px;color:var(--dsw-alias-label-secondary)}.qqChatRow{width:100%;display:flex;align-items:center;gap:10px;padding:10px;border:0;background:transparent;color:inherit;border-radius:9px;text-align:left;cursor:pointer}.qqChatRow:hover{background:var(--dsw-alias-bg-layer-2)}.qqAvatar{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);font-size:11px;font-weight:650;flex:0 0 auto}.qqGrow{min-width:0;flex:1}.qqEllipsis{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.qqBadge{font-size:10px;padding:2px 5px;border-radius:5px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-secondary)}
69
+ .qqTranscript{display:flex;gap:9px;align-items:flex-start;margin:14px 0}.qqTranscript.out{flex-direction:row-reverse}.qqTranscriptBody{max-width:min(78%,680px)}.qqTranscript.out .qqTranscriptBody{text-align:right}.qqTranscriptMeta{font-size:11px;color:var(--dsw-alias-label-secondary);margin:0 3px 5px}.qqBubble{display:inline-block;text-align:left;white-space:pre-wrap;word-break:break-word;font-size:13px;line-height:1.55;padding:9px 11px;border-radius:5px 13px 13px 13px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.qqTranscript.out .qqBubble{border-radius:13px 5px 13px 13px;background:var(--dsw-static-deepseek-50,var(--dsw-alias-bg-layer-2))}.qqQuote{max-width:100%;margin:0 0 7px;padding:7px 10px 8px;border:1px solid var(--dsw-alias-border-l1);border-left:3px solid var(--dsw-alias-brand-primary);border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-bg-layer-2) 72%,transparent);color:var(--dsw-alias-label-secondary);text-align:left;white-space:normal;word-break:break-word;overflow:hidden}.qqQuoteLabel{margin-bottom:3px;font-size:10px;line-height:14px;font-weight:650;color:var(--dsw-alias-brand-primary);letter-spacing:.02em}.qqQuoteText{font-size:12px;line-height:1.5;white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden}.qqTranscript.out .qqQuote{border-left:1px solid var(--dsw-alias-border-l1);border-right:3px solid var(--dsw-alias-brand-primary)}
70
+ .qqHeaderButton{font:inherit;font-size:11px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);border-radius:7px;padding:5px 8px;cursor:pointer}
71
+ .qqMemoryGrid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.qqMemoryCard{border:1px solid var(--dsw-alias-border-l1);border-radius:9px;overflow:hidden}.qqMemoryCard h4{font-size:11px;margin:0;padding:8px 9px;border-bottom:1px solid var(--dsw-alias-border-l1)}.qqMemoryDoc{padding:9px;font-size:11px;line-height:1.55;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;max-height:180px;overflow:auto}.qqMemberList{margin-top:12px}.qqMemberRow{display:flex;align-items:center;gap:8px;padding:8px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}.qqMemberId{font:10px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dsw-alias-label-secondary)}
72
+ @media(max-width:720px){.qqSetting{flex-direction:column}.qqControl{width:100%;justify-content:flex-start}.qqMemoryGrid{grid-template-columns:1fr}.qqInput{width:100%}}
73
+
74
+ /* QQChat contributes layout only; controls, dialogs and selection surfaces are
75
+ DSH primitives. Message colors use the host token system directly. */
76
+ .qqs{display:block;width:100%;min-width:0}.qqsHead{width:100%}.qqSettingsGroup{display:block;width:100%;min-width:0;margin-top:22px}.qqSettingsGroup h3{margin:0 0 8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}.qqSettingsGroup .qqSetting{display:flex;width:100%;min-width:0;flex:0 0 auto;padding:12px 0;border-bottom:1px solid var(--dsw-alias-border-l1)}.qqSettingsGroup .qqSetting:last-child{border-bottom:0}.qqSetting>div:first-child{flex:1 1 auto;min-width:0}.qqControl{flex:0 0 auto}.qqChoiceButtons{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.qqSettingsGroup select{font:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:5px 8px}.qqCheckRow{display:flex;align-items:flex-start;gap:10px;cursor:pointer}.qqCheckRow input{flex:none;width:16px;height:16px;margin:2px 0 0;accent-color:var(--dsw-alias-button-primary-fill);cursor:pointer}.qqConnect{display:flex;flex-direction:column;align-items:center;gap:14px;padding:24px 0;text-align:center}.qqTranscript{margin:16px 0}.qqTranscriptBody{max-width:min(78%,680px)}.qqBubble{border:0;border-radius:18px;padding:9px 13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}.qqTranscript.out .qqBubble{border-radius:18px;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}.qqTranscriptMeta{margin:0 4px 4px;font-size:12px;color:var(--dsw-alias-label-secondary)}.qqQuote{border-color:var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1)}
77
+ .qqWideModal{width:min(760px,calc(100vw - 48px));max-height:calc(100vh - 48px);min-height:0;overflow:hidden}.qqModalContent{min-height:0;overflow:auto}.qqModalContent .qqMemoryGrid{min-width:0}.qqModalContent .qqLog{overflow-wrap:anywhere;word-break:break-word}
78
+ `.concat('.qqQuoteSender{margin-top:4px;font-size:10px;color:var(--dsw-alias-label-secondary)}.qqMediaList{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px}.qqMediaCard{display:flex;align-items:center;gap:6px;max-width:100%;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);font-size:11px}.qqMediaKind{color:var(--dsw-alias-brand-primary);font-weight:650}.qqMediaPreview{width:42px;height:42px;object-fit:cover;border-radius:6px}.qqMediaName{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.qqMediaSize,.qqMediaQuoted{color:var(--dsw-alias-label-secondary)}')
79
+
80
+ const qqThemeOverrides = '.qqTranscript.out .qqBubble{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}'
81
+
82
+ export function installStyles(): () => void {
83
+ if (typeof document === 'undefined' || document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`)) return () => {}
84
+ const node = document.createElement('style')
85
+ node.dataset.pluginCss = STYLE_ID
86
+ node.textContent = css + qqThemeOverrides
87
+ document.head.appendChild(node)
88
+ return () => node.remove()
89
+ }
90
+
91
+ export async function call<T>(rpc: Rpc, endpoint: string, payload: unknown = {}): Promise<T> {
92
+ const result = await rpc.call(CHANNEL, endpoint, payload) as RpcResult<T>
93
+ if (!result.ok) throw new Error(result.error?.message || 'QQ Chat 请求失败')
94
+ return result.value
95
+ }
96
+
97
+ export const short = (value: string) => value.length > 10 ? `…${value.slice(-10)}` : value
98
+ export const initials = (value: string) => String(value || '?').trim().slice(0, 2).toUpperCase()
99
+ export const time = (value: number) => value ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
100
+ export const dateTime = (value: number) => value ? new Date(value).toLocaleString() : ''
101
+
102
+ export function Modal({ title, onClose, children }: { title: string; onClose(): void; children?: import('react').ReactNode }) {
103
+ return h(DshModal, { open: true, title, onClose, closeLabel: '关闭', className: 'qqWideModal', contentClassName: 'qqModalContent' }, children)
104
+ }
@@ -0,0 +1,164 @@
1
+ import { React, h, useEffect, useState, call, getQQRpc, Modal, Button, time } from './shared.cjs'
2
+ import type { ChatInfo, ChatMember, QQAttachmentData, QQEventData, QQNode, Rpc, SessionUtilityProps } from './shared.cjs'
3
+
4
+ function formatBytes(value: number): string {
5
+ if (!value || value < 1024) return `${value || 0} B`
6
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
7
+ return `${(value / 1024 / 1024).toFixed(1)} MB`
8
+ }
9
+
10
+ function AttachmentCards({ attachments, sessionId }: { attachments?: QQAttachmentData[]; sessionId?: string }) {
11
+ if (!attachments?.length) return null
12
+ const rpc = getQQRpc()
13
+ const [previews, setPreviews] = useState<Record<string, string>>({})
14
+ useEffect(() => {
15
+ if (!rpc || !sessionId) return
16
+ let cancelled = false
17
+ void Promise.all(attachments.filter(item => item.kind === 'image').map(async item => {
18
+ try {
19
+ const result = await call<{ dataUrl?: string }>(rpc, 'attachment/read', { sessionId, attachmentId: item.id })
20
+ return result.dataUrl ? [item.id, result.dataUrl] as const : null
21
+ } catch { return null }
22
+ })).then(items => {
23
+ if (cancelled) return
24
+ setPreviews(current => ({ ...current, ...Object.fromEntries(items.filter((item): item is readonly [string, string] => Boolean(item))) }))
25
+ })
26
+ return () => { cancelled = true }
27
+ }, [attachments, rpc, sessionId])
28
+ return h('div', { className: 'qqMediaList' }, ...attachments.map(attachment =>
29
+ h('div', { className: 'qqMediaCard', key: `${attachment.id}:${attachment.quoted ? 'quote' : 'own'}` },
30
+ h('span', { className: 'qqMediaKind' }, attachment.kind === 'image' ? '图片' : attachment.kind === 'video' ? '视频' : attachment.kind === 'voice' || attachment.kind === 'audio' ? '语音' : '文件'),
31
+ previews[attachment.id] ? h('img', { className: 'qqMediaPreview', src: previews[attachment.id], alt: attachment.filename }) : null,
32
+ h('span', { className: 'qqMediaName' }, attachment.filename),
33
+ h('span', { className: 'qqMediaSize' }, formatBytes(attachment.sizeBytes)),
34
+ attachment.quoted ? h('span', { className: 'qqMediaQuoted' }, '引用') : null)))
35
+ }
36
+
37
+ export function QQTranscriptNode({ node }: { node: QQNode }) {
38
+ const data = node.data
39
+ const outbound = data.direction === 'outbound' || data.isOwner === true
40
+ const hasQuote = Boolean(data.quote) || Boolean(data.quotedText)
41
+ const quoteText = data.quote?.text || data.quotedText || ''
42
+ return h('div', { className: `qqTranscript${outbound ? ' out' : ''}` },
43
+ h('div', { className: 'qqTranscriptBody' },
44
+ !data.isOwner && h('div', { className: 'qqTranscriptMeta' }, data.senderName || (outbound ? 'Owner' : 'QQ 用户'), ' · ', time(data.createdAt)),
45
+ hasQuote && h('div', { className: 'qqQuote', title: quoteText },
46
+ h('div', { className: 'qqQuoteLabel' }, '引用消息'),
47
+ quoteText ? h('div', { className: 'qqQuoteText' }, quoteText) : null,
48
+ data.quote?.senderName ? h('div', { className: 'qqQuoteSender' }, data.quote.senderName) : null,
49
+ h(AttachmentCards, { attachments: data.quote?.attachments, sessionId: data.sessionId })),
50
+ h('div', { className: 'qqBubble' }, data.content),
51
+ h(AttachmentCards, { attachments: data.attachments, sessionId: data.sessionId })))
52
+ }
53
+
54
+ export function MemoryCard({ title, value }: { title: string; value: string }) {
55
+ return h('section', { className: 'qqMemoryCard' },
56
+ h('h4', null, title),
57
+ h('div', { className: 'qqMemoryDoc' }, value || '还没有形成这部分记忆。'))
58
+ }
59
+
60
+ function MemberMemory({ member, onBack }: { member: ChatMember; onBack(): void }) {
61
+ return h(React.Fragment, null,
62
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 } },
63
+ h(Button, { size: 'sm', variant: 'outline', onClick: onBack }, '返回群记忆'),
64
+ h('div', { className: 'qqGrow' },
65
+ h('div', { className: 'qqsTitle' }, member.displayName || '群友'),
66
+ h('div', { className: 'qqMemberId' }, member.platformUserId))),
67
+ h('div', { className: 'qqMemoryGrid' },
68
+ h(MemoryCard, { title: '成员画像 · profile', value: member.memory.profile }),
69
+ h(MemoryCard, { title: '行为模式 · pattern', value: member.memory.pattern }),
70
+ h(MemoryCard, { title: '成员摘要 · summary', value: member.memory.summary }),
71
+ h(MemoryCard, { title: '成员长期记忆 · memory', value: member.memory.memory })))
72
+ }
73
+
74
+ export function QQSessionUtility({ sessionId, rpc }: SessionUtilityProps & { rpc: Rpc }) {
75
+ const [info, setInfo] = useState<ChatInfo | null>(null)
76
+ const [open, setOpen] = useState(false)
77
+ const [selectedMember, setSelectedMember] = useState<ChatMember | null>(null)
78
+ const isQQSession = String(sessionId).startsWith('qqchat-')
79
+ if (!isQQSession) return null
80
+
81
+ const show = async () => {
82
+ try {
83
+ setInfo(await call<ChatInfo>(rpc, 'chat/info', { sessionId }))
84
+ setSelectedMember(null)
85
+ setOpen(true)
86
+ } catch {
87
+ // Host may still be reconciling a just-created Session.
88
+ }
89
+ }
90
+
91
+ const memoryBody = info
92
+ ? selectedMember
93
+ ? h(MemberMemory, { member: selectedMember, onBack: () => setSelectedMember(null) })
94
+ : h(React.Fragment, null,
95
+ h('div', { className: 'qqMemoryGrid' },
96
+ h(MemoryCard, { title: '画像 · profile', value: info.memory.profile }),
97
+ h(MemoryCard, { title: '摘要 · summary', value: info.memory.summary }),
98
+ info.chatType === 'group'
99
+ ? h(MemoryCard, { title: '长期记忆 · memory', value: info.memory.memory })
100
+ : h(MemoryCard, { title: '行为模式 · pattern', value: info.memory.pattern }),
101
+ info.chatType === 'group'
102
+ ? h(MemoryCard, { title: '近期沉淀 · daily', value: info.memory.daily })
103
+ : null),
104
+ info.chatType === 'group'
105
+ ? h('div', { className: 'qqMemberList' },
106
+ h('div', { className: 'qqChatSection' }, `群友 · ${info.members.length} · 点击查看成员记忆`),
107
+ ...info.members.map(member => h('button', {
108
+ type: 'button',
109
+ key: member.id,
110
+ className: 'qqMemberRow',
111
+ onClick: () => setSelectedMember(member),
112
+ style: { width: '100%', border: 0, background: 'transparent', color: 'inherit', textAlign: 'left', cursor: 'pointer' },
113
+ },
114
+ h('div', { className: 'qqGrow' },
115
+ h('div', null, member.displayName || '群友'),
116
+ member.aliases?.length ? h('div', { className: 'qqMemberId' }, `历史昵称:${member.aliases.join('、')}`) : null,
117
+ member.nicknames?.length ? h('div', { className: 'qqMemberId' }, `群内称呼:${member.nicknames.join('、')}`) : null),
118
+ h('span', { className: 'qqBadge' }, '查看记忆'))))
119
+ : null)
120
+ : null
121
+
122
+ return h(React.Fragment, null,
123
+ h(Button, { size: 'sm', variant: 'outline', onClick: () => void show() }, 'QQ 记忆'),
124
+ open && info
125
+ ? h(Modal, {
126
+ title: selectedMember
127
+ ? `${selectedMember.displayName || '群友'} · 成员记忆`
128
+ : info.chatType === 'group' ? `${info.title} · 群记忆` : `${info.title} · 用户记忆`,
129
+ onClose: () => { setOpen(false); setSelectedMember(null) },
130
+ }, memoryBody)
131
+ : null)
132
+ }
133
+
134
+ export function createQQMessageDefinition(): Record<string, unknown> {
135
+ return {
136
+ kind: 'qqchat-message',
137
+ target: 'chat',
138
+ match: (event: { type?: string; data?: QQEventData }) => event.type === 'qqchat/message' && event.data
139
+ ? { id: event.data.messageId, role: 'start' }
140
+ : null,
141
+ start: (_context: unknown, match: { event: { data: QQEventData } }) => match.event.data,
142
+ update: (context: { state?: QQEventData }) => context.state,
143
+ buildViewNode: (context: {
144
+ key: string
145
+ id: string
146
+ state?: QQEventData
147
+ start?: { event: { seq: number }; location?: unknown }
148
+ matches?: Array<{ event: { seq: number }; location?: unknown }>
149
+ }) => {
150
+ if (!context.state) return null
151
+ const first = context.start ?? context.matches?.[0]
152
+ return {
153
+ key: context.key,
154
+ kind: 'qqchat-message',
155
+ id: context.id,
156
+ target: 'chat',
157
+ anchorSeq: first?.event.seq ?? 0,
158
+ location: first?.location ?? { kind: 'unresolved' },
159
+ visibility: 'visible',
160
+ data: context.state,
161
+ }
162
+ },
163
+ }
164
+ }
@@ -0,0 +1,7 @@
1
+ # dsh-qqchat is a profile bundle: installing it through `dsh plugin` appends
2
+ # this patch after the selected profile's shipped bundles.
3
+ - insert:
4
+ - id: dsh-qqchat
5
+ name: dsh-qqchat
6
+ config:
7
+ dataDir: !!js dshHomePath('plugins/dsh-qqchat')