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.
- package/LICENSE +21 -0
- package/README.en.md +276 -0
- package/README.md +211 -0
- package/client-src/plugin.cts +15 -0
- package/client-src/settings.cts +216 -0
- package/client-src/shared.cts +104 -0
- package/client-src/workspace.cts +164 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.en.md +346 -0
- package/docs/ARCHITECTURE.md +411 -0
- package/docs/DEVELOPMENT.md +69 -0
- package/docs/MEDIA_AND_QUOTES.md +644 -0
- package/docs/MEMORY.md +99 -0
- package/docs/MEMORY_CONTEXT.md +254 -0
- package/docs/QUOTES.md +355 -0
- package/docs/README.md +22 -0
- package/docs/SECURITY.md +62 -0
- package/docs/STREAMING.md +347 -0
- package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
- package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
- package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
- package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
- package/docs/devlog.md +24 -0
- package/lib/client.js +433 -0
- package/lib/commands/dispatch.d.ts +16 -0
- package/lib/commands/dispatch.js +48 -0
- package/lib/config.d.ts +3 -0
- package/lib/config.js +63 -0
- package/lib/gateway/api.d.ts +30 -0
- package/lib/gateway/api.js +202 -0
- package/lib/gateway/auth.d.ts +35 -0
- package/lib/gateway/auth.js +80 -0
- package/lib/gateway/crypto.d.ts +2 -0
- package/lib/gateway/crypto.js +17 -0
- package/lib/gateway/gateway.d.ts +32 -0
- package/lib/gateway/gateway.js +219 -0
- package/lib/gateway/normalize.d.ts +6 -0
- package/lib/gateway/normalize.js +248 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +35 -0
- package/lib/media/image-tool.d.ts +4 -0
- package/lib/media/image-tool.js +62 -0
- package/lib/media/media-tools.d.ts +3 -0
- package/lib/media/media-tools.js +68 -0
- package/lib/media/store.d.ts +14 -0
- package/lib/media/store.js +176 -0
- package/lib/session/agent-bridge.d.ts +56 -0
- package/lib/session/agent-bridge.js +613 -0
- package/lib/session/memory-snapshot.d.ts +23 -0
- package/lib/session/memory-snapshot.js +27 -0
- package/lib/session/runtime.d.ts +41 -0
- package/lib/session/runtime.js +395 -0
- package/lib/shared/augmentations.d.ts +40 -0
- package/lib/shared/augmentations.js +1 -0
- package/lib/shared/logging.d.ts +14 -0
- package/lib/shared/logging.js +41 -0
- package/lib/storage/db.d.ts +82 -0
- package/lib/storage/db.js +669 -0
- package/lib/storage/memory.d.ts +50 -0
- package/lib/storage/memory.js +566 -0
- package/lib/transport/rpc.d.ts +18 -0
- package/lib/transport/rpc.js +318 -0
- package/lib/types.d.ts +416 -0
- package/lib/types.js +1 -0
- package/package.json +115 -0
- package/scripts/wrap-client.mjs +20 -0
- package/src/commands/dispatch.ts +75 -0
- package/src/config.ts +70 -0
- package/src/gateway/api.ts +260 -0
- package/src/gateway/auth.ts +99 -0
- package/src/gateway/crypto.ts +16 -0
- package/src/gateway/gateway.ts +207 -0
- package/src/gateway/normalize.ts +236 -0
- package/src/index.ts +46 -0
- package/src/media/image-tool.ts +62 -0
- package/src/media/media-tools.ts +66 -0
- package/src/media/store.ts +157 -0
- package/src/session/agent-bridge.ts +624 -0
- package/src/session/memory-snapshot.ts +41 -0
- package/src/session/runtime.ts +368 -0
- package/src/shared/augmentations.ts +45 -0
- package/src/shared/logging.ts +36 -0
- package/src/storage/db.ts +760 -0
- package/src/storage/memory.ts +581 -0
- package/src/transport/rpc.ts +316 -0
- package/src/types.ts +456 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.client.json +14 -0
- 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
|
+
}
|
package/cordis.patch.yml
ADDED