@sidleo3/dsh-chat-weixin 0.0.4

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.
@@ -0,0 +1,605 @@
1
+ /**
2
+ * dsh-chat-weixin(client 侧):微信设置页(扫码接入 + 账号状态)。
3
+ *
4
+ * 渠道无关的面板(上下文增强等)直接用 hub 的共享组件与 hook。
5
+ *
6
+ * @module dsh-chat-weixin/client
7
+ */
8
+
9
+ import * as React from 'react';
10
+
11
+ export const name = 'dsh-chat-weixin-client';
12
+
13
+ export const inject = ['slots', 'locale', 'connection', 'chatChannels', 'chatUi'];
14
+
15
+ const CHANNEL_ID = 'weixin';
16
+ const PAGE_SLOT = 'chat.channel.page';
17
+ const LOCALE_NAMESPACE = 'dsh-chat-weixin';
18
+
19
+ const zh = {
20
+ '关闭': '关闭',
21
+ '删除': '删除',
22
+ '启用': '启用',
23
+ '填入示例': '填入示例',
24
+ '备注名(可选)': '备注名(可选)',
25
+ '张三': '张三',
26
+ '新增': '新增',
27
+ '来源字段': '来源字段',
28
+ '清空': '清空',
29
+ '还没有指定设置。': '还没有指定设置。',
30
+ '上下文增强': '上下文增强',
31
+ '上下文增强范围': '上下文增强范围',
32
+ '叠加全局提示词(不勾选则只使用上面的专属提示词)': '叠加全局提示词(不勾选则只使用上面的专属提示词)',
33
+ '告诉模型如何使用来源字段。只填正文,插件会自动包成来源增强块。': '告诉模型如何使用来源字段。只填正文,插件会自动包成来源增强块。',
34
+ '增强提示词': '增强提示词',
35
+ '来源字段只在当前消息已提供时才会发送,不会额外查询平台接口。': '来源字段只在当前消息已提供时才会发送,不会额外查询平台接口。',
36
+ '从会话里选…': '从会话里选…',
37
+ 'Agent 预设': 'Agent 预设',
38
+ '模型': '模型',
39
+ '默认模型': '默认模型',
40
+ '推理等级': '推理等级',
41
+ '这个模型没有可选的推理等级。': '这个模型没有可选的推理等级。',
42
+ '先选一个模型。': '先选一个模型。',
43
+ '模型默认': '模型默认',
44
+ '部分 provider 读取失败:': '部分 provider 读取失败:',
45
+ '当前 Host 读不到模型目录。': '当前 Host 读不到模型目录。',
46
+ '还没有会话时用哪个模型:选完对下一条消息新建的会话生效。会话内还能单独改(面板的模型下拉)。':
47
+ '还没有会话时用哪个模型:选完对下一条消息新建的会话生效。会话内还能单独改(面板的模型下拉)。',
48
+
49
+ '下拉里是这台机器人用过的目录。': '下拉里是这台机器人用过的目录。',
50
+ '仅名单内可用': '仅名单内可用',
51
+ '从会话里选一个人设为属主': '从会话里选一个人设为属主',
52
+ '任何人可用': '任何人可用',
53
+ '允许执行命令': '允许执行命令',
54
+ '可执行命令': '可执行命令',
55
+ '名单为空时只有属主可用。': '名单为空时只有属主可用。',
56
+ '对方的平台 id,回车添加': '对方的平台 id,回车添加',
57
+ '属主': '属主',
58
+ '属主不需要进白名单:消息与命令都直接放行。这里改完会重连一次,立刻生效。': '属主不需要进白名单:消息与命令都直接放行。这里改完会重连一次,立刻生效。',
59
+ '工作区': '工作区',
60
+ '当前 Host 读不到 Agent Preset 列表。': '当前 Host 读不到 Agent Preset 列表。',
61
+ '当前没有属主:没有人绕过访问策略,谁能用完全由下面的「访问策略」决定。': '当前没有属主:没有人绕过访问策略,谁能用完全由下面的「访问策略」决定。',
62
+ '机器人跑在哪个目录:能读写哪些文件、用哪份 AGENTS.md。只对新建会话生效。': '机器人跑在哪个目录:能读写哪些文件、用哪份 AGENTS.md。只对新建会话生效。',
63
+ '没有可选的会话(先和机器人聊一次)': '没有可选的会话(先和机器人聊一次)',
64
+ '添加': '添加',
65
+ '清空后没有人绕过访问策略': '清空后没有人绕过访问策略',
66
+ '清空(无属主)': '清空(无属主)',
67
+ '目录': '目录',
68
+ '显示项': '显示项',
69
+ '控制面板显示项': '控制面板显示项',
70
+ '只影响 /menu 发出来的那张卡片:关掉的项不显示,功能照旧(私聊与群聊分别设置)。': '只影响 /menu 发出来的那张卡片:关掉的项不显示,功能照旧(私聊与群聊分别设置)。',
71
+ '模型与推理等级': '模型与推理等级',
72
+ '会话': '会话',
73
+ 'Agent 预设与工作区': 'Agent 预设与工作区',
74
+ '上下文增强(本会话)': '上下文增强(本会话)',
75
+ '访问策略(本会话)': '访问策略(本会话)',
76
+ '渠道设置(任务过程展示等)': '渠道设置(任务过程展示等)',
77
+ '渠道动作按钮(重连等)': '渠道动作按钮(重连等)',
78
+ '命令按钮(新会话/状态/诊断…)': '命令按钮(新会话/状态/诊断…)',
79
+ '私聊': '私聊',
80
+ '移除': '移除',
81
+ '群聊': '群聊',
82
+ '设为属主': '设为属主',
83
+ '访问模式': '访问模式',
84
+ '访问策略': '访问策略',
85
+ '谁能跟机器人说话、谁能执行命令。改动立即生效;属主始终可用。': '谁能跟机器人说话、谁能执行命令。改动立即生效;属主始终可用。',
86
+ '跟随 Host 默认': '跟随 Host 默认',
87
+ '这个机器人用哪套 Agent 预设(人设与工具集)。只对新建会话生效。': '这个机器人用哪套 Agent 预设(人设与工具集)。只对新建会话生效。',
88
+ '微信': '微信',
89
+ '找不到这个机器人': '找不到这个机器人',
90
+ '它不在当前渠道的名单里(可能已被移除,或 Host 与页面版本不一致)': '它不在当前渠道的名单里(可能已被移除,或 Host 与页面版本不一致)',
91
+ '微信渠道': '微信渠道',
92
+ '已绑定的账号': '已绑定的账号',
93
+ '扫码接入': '扫码接入',
94
+ '重新连接': '重新连接',
95
+ '移除接入': '移除接入',
96
+ '确认移除': '确认移除',
97
+ '取消': '取消',
98
+ '读取状态': '读取状态',
99
+ '读取中…': '读取中…',
100
+ '正在生成二维码…': '正在生成二维码…',
101
+ '用手机微信扫描二维码并在手机上确认。': '用手机微信扫描二维码并在手机上确认。',
102
+ '二维码由腾讯微信 iLink 服务签发;账号凭据只写入本机 Host,浏览器拿不到 token。':
103
+ '二维码由腾讯微信 iLink 服务签发;账号凭据只写入本机 Host,浏览器拿不到 token。',
104
+ '等待扫码': '等待扫码',
105
+ '已扫码,请在手机上确认': '已扫码,请在手机上确认',
106
+ '需要配对码,请在下方输入手机上显示的配对码': '需要配对码,请在下方输入手机上显示的配对码',
107
+ '提交配对码': '提交配对码',
108
+ '二维码已失效,请重新生成': '二维码已失效,请重新生成',
109
+ '该微信账号已在别处绑定': '该微信账号已在别处绑定',
110
+ '已接入,正在启动长轮询…': '已接入,正在启动长轮询…',
111
+ '重新生成二维码': '重新生成二维码',
112
+ '没有已绑定的微信账号': '没有已绑定的微信账号',
113
+ '本机还没有微信账号。点上方「扫码接入」用手机微信扫码绑定。':
114
+ '本机还没有微信账号。点上方「扫码接入」用手机微信扫码绑定。',
115
+ '点「扫码接入」用手机微信扫码绑定新账号。': '点「扫码接入」用手机微信扫码绑定新账号。',
116
+ '已处理消息': '已处理消息',
117
+ '状态': '状态',
118
+ '启动中': '启动中',
119
+ '运行正常': '运行正常',
120
+ '重连中': '重连中',
121
+ '启动失败': '启动失败',
122
+ '已停止': '已停止',
123
+ '仅私聊': '仅私聊',
124
+ // hub 的共享组件(上下文增强编辑器)用本渠道的 t 取文案,因此这些键必须在渠道字典里。
125
+ '保存': '保存',
126
+ '保存中…': '保存中…',
127
+ '保存失败,请重试。': '保存失败,请重试。',
128
+ };
129
+
130
+ const en = {
131
+ '显示项': 'Section',
132
+ '控制面板显示项': 'Control panel sections',
133
+ '只影响 /menu 发出来的那张卡片:关掉的项不显示,功能照旧(私聊与群聊分别设置)。': 'Only affects the card sent by /menu: hidden items are not drawn, everything keeps working (direct and group are configured separately).',
134
+ '模型与推理等级': 'Model & reasoning',
135
+ '会话': 'Session',
136
+ 'Agent 预设与工作区': 'Agent preset & workspace',
137
+ '上下文增强(本会话)': 'Context enhancement (this chat)',
138
+ '访问策略(本会话)': 'Access policy (this chat)',
139
+ '渠道设置(任务过程展示等)': 'Channel settings (step display etc.)',
140
+ '渠道动作按钮(重连等)': 'Channel actions (reconnect etc.)',
141
+ '命令按钮(新会话/状态/诊断…)': 'Command buttons (new session/status/diagnostics…)',
142
+ '关闭': 'Close',
143
+ '删除': 'Delete',
144
+ '启用': 'Enabled',
145
+ '填入示例': 'Fill with example',
146
+ '备注名(可选)': 'Display name (optional)',
147
+ '张三': 'Alice',
148
+ '新增': 'Add',
149
+ '来源字段': 'Source fields',
150
+ '清空': 'Clear',
151
+ '还没有指定设置。': 'Nothing configured yet.',
152
+ '上下文增强': 'Context enhancement',
153
+ '上下文增强范围': 'Context enhancement scope',
154
+ '叠加全局提示词(不勾选则只使用上面的专属提示词)': 'Also stack the global prompt (unchecked: use only the prompt above)',
155
+ '告诉模型如何使用来源字段。只填正文,插件会自动包成来源增强块。': 'Tell the model how to use the source fields. Write the body only — the plugin wraps it into a source block.',
156
+ '增强提示词': 'Prepended prompt',
157
+ '来源字段只在当前消息已提供时才会发送,不会额外查询平台接口。': 'Source fields are sent only when the incoming message already carries them; no extra platform calls are made.',
158
+ '从会话里选…': 'Pick a conversation…',
159
+ 'Agent 预设': 'Agent preset',
160
+ '模型': 'Model',
161
+ '默认模型': 'Default model',
162
+ '推理等级': 'Reasoning effort',
163
+ '这个模型没有可选的推理等级。': 'This model has no reasoning efforts to choose from.',
164
+ '先选一个模型。': 'Pick a model first.',
165
+ '模型默认': 'Model default',
166
+ '部分 provider 读取失败:': 'Some providers failed to load: ',
167
+ '当前 Host 读不到模型目录。': 'The model catalog is unavailable right now.',
168
+ '还没有会话时用哪个模型:选完对下一条消息新建的会话生效。会话内还能单独改(面板的模型下拉)。':
169
+ 'Model used before a conversation exists; it applies to the session created by your next message. You can still change it per conversation from the panel.',
170
+
171
+ '下拉里是这台机器人用过的目录。': 'The suggestions are directories this bot has used before.',
172
+ '仅名单内可用': 'Allowlist only',
173
+ '从会话里选一个人设为属主': 'Pick a person from a conversation to make them the owner',
174
+ '任何人可用': 'Anyone',
175
+ '允许执行命令': 'Allow commands',
176
+ '可执行命令': 'Allow commands',
177
+ '名单为空时只有属主可用。': 'An empty allowlist means only the owner can use it.',
178
+ '对方的平台 id,回车添加': 'Their platform id — press Enter to add',
179
+ '属主': 'Owner',
180
+ '属主不需要进白名单:消息与命令都直接放行。这里改完会重连一次,立刻生效。': 'An owner does not need to be on the allowlist: their messages and commands always pass. Saving reconnects this bot once so the change takes effect immediately.',
181
+ '工作区': 'Workspace',
182
+ '当前 Host 读不到 Agent Preset 列表。': 'This Host does not expose an agent preset list.',
183
+ '当前没有属主:没有人绕过访问策略,谁能用完全由下面的「访问策略」决定。': 'No owner right now: nobody bypasses the access policy, so who may use this bot is decided entirely by the access policy below.',
184
+ '机器人跑在哪个目录:能读写哪些文件、用哪份 AGENTS.md。只对新建会话生效。': 'Which directory the bot runs in: which files it may read and write, and which AGENTS.mdapplies. Applies to new conversations only.',
185
+ '没有可选的会话(先和机器人聊一次)': 'No conversation to pick yet (talk to the bot once first)',
186
+ '添加': 'Add',
187
+ '清空后没有人绕过访问策略': 'After clearing, nobody bypasses the access policy',
188
+ '清空(无属主)': 'Clear (no owner)',
189
+ '目录': 'Directory',
190
+ '私聊': 'Direct',
191
+ '移除': 'Remove',
192
+ '群聊': 'Group',
193
+ '设为属主': 'Make owner',
194
+ '访问模式': 'Access mode',
195
+ '访问策略': 'Access policy',
196
+ '谁能跟机器人说话、谁能执行命令。改动立即生效;属主始终可用。': 'Who may talk to the bot and who may run commands. Changes apply immediately;the owner always has access.',
197
+ '跟随 Host 默认': 'Follow the Host default',
198
+ '这个机器人用哪套 Agent 预设(人设与工具集)。只对新建会话生效。': 'Which agent preset this bot uses (persona and tool set). Applies to new conversations only.',
199
+ '微信': 'WeChat',
200
+ '找不到这个机器人': 'Bot not found',
201
+ '它不在当前渠道的名单里(可能已被移除,或 Host 与页面版本不一致)':
202
+ "It is not in this channel's bot list (it may have been removed, or the Host and the page are on different versions)",
203
+ '微信渠道': 'WeChat channel',
204
+ '已绑定的账号': 'Linked accounts',
205
+ '扫码接入': 'Scan to link',
206
+ '重新连接': 'Reconnect',
207
+ '移除接入': 'Remove',
208
+ '确认移除': 'Confirm removal',
209
+ '取消': 'Cancel',
210
+ '读取状态': 'Reload',
211
+ '读取中…': 'Loading…',
212
+ '正在生成二维码…': 'Requesting a QR code…',
213
+ '用手机微信扫描二维码并在手机上确认。':
214
+ 'Scan the QR code with WeChat on your phone and confirm there.',
215
+ '二维码由腾讯微信 iLink 服务签发;账号凭据只写入本机 Host,浏览器拿不到 token。':
216
+ 'The QR code is issued by Tencent iLink; credentials are written on the Host only.',
217
+ '等待扫码': 'Waiting for a scan',
218
+ '已扫码,请在手机上确认': 'Scanned — confirm on your phone',
219
+ '需要配对码,请在下方输入手机上显示的配对码':
220
+ 'A pairing code is required; enter the code shown on your phone',
221
+ '提交配对码': 'Submit code',
222
+ '二维码已失效,请重新生成': 'The QR code expired; generate a new one',
223
+ '该微信账号已在别处绑定': 'This WeChat account is already linked elsewhere',
224
+ '已接入,正在启动长轮询…': 'Linked — starting the message connection…',
225
+ '重新生成二维码': 'Generate a new QR code',
226
+ '没有已绑定的微信账号': 'No WeChat account linked',
227
+ '本机还没有微信账号。点上方「扫码接入」用手机微信扫码绑定。':
228
+ 'No WeChat account on this Host yet. Use “Scan to link” above.',
229
+ '点「扫码接入」用手机微信扫码绑定新账号。': 'Use “Scan to link” to bind a new account with WeChat.',
230
+ '已处理消息': 'Messages handled',
231
+ '状态': 'State',
232
+ '启动中': 'Starting',
233
+ '运行正常': 'Connected',
234
+ '重连中': 'Reconnecting',
235
+ '启动失败': 'Failed',
236
+ '已停止': 'Stopped',
237
+ '仅私聊': 'Direct messages only',
238
+ '保存': 'Save',
239
+ '保存中…': 'Saving…',
240
+ '保存失败,请重试。': 'Could not save. Try again.',
241
+ };
242
+
243
+ const h = React.createElement;
244
+
245
+ const STATE_TEXT = {
246
+ starting: '启动中',
247
+ running: '运行正常',
248
+ reconnecting: '重连中',
249
+ failed: '启动失败',
250
+ stopped: '已停止',
251
+ };
252
+
253
+ const STATUS_TEXT = {
254
+ wait: '等待扫码',
255
+ scaned: '已扫码,请在手机上确认',
256
+ need_verifycode: '需要配对码,请在下方输入手机上显示的配对码',
257
+ expired: '二维码已失效,请重新生成',
258
+ verify_code_blocked: '二维码已失效,请重新生成',
259
+ binded_redirect: '该微信账号已在别处绑定',
260
+ connected: '已接入,正在启动长轮询…',
261
+ };
262
+
263
+ function QrLogin({ chatUi, connection, translate, onDone }) {
264
+ const t = typeof translate === 'function' ? translate : (key) => key;
265
+ const [state, setState] = React.useState({ phase: 'idle' });
266
+ const [verifyCode, setVerifyCode] = React.useState('');
267
+ const aliveRef = React.useRef(true);
268
+
269
+ React.useEffect(() => () => { aliveRef.current = false; }, []);
270
+
271
+ const begin = React.useCallback(async () => {
272
+ setState({ phase: 'starting' });
273
+ try {
274
+ const result = await chatUi.callChannelRpc(connection, CHANNEL_ID, 'login.begin', {});
275
+ const value = chatUi.unwrapRpc(result);
276
+ if (!aliveRef.current) return;
277
+ setState({ phase: 'waiting', attemptId: value.attemptId, qrcodeUrl: value.qrcodeUrl, status: 'wait' });
278
+ } catch (error) {
279
+ if (aliveRef.current) setState({ phase: 'error', error });
280
+ }
281
+ }, [chatUi, connection]);
282
+
283
+ // 轮询扫码状态(服务端长轮询,这里每 2s 一次)。
284
+ React.useEffect(() => {
285
+ if (state.phase !== 'waiting' || !state.attemptId) return undefined;
286
+ let stopped = false;
287
+ const tick = async () => {
288
+ try {
289
+ const result = await chatUi.callChannelRpc(connection, CHANNEL_ID, 'login.poll', {
290
+ attemptId: state.attemptId,
291
+ ...(verifyCode ? { verifyCode } : {}),
292
+ });
293
+ const value = chatUi.unwrapRpc(result);
294
+ if (stopped || !aliveRef.current) return;
295
+ if (value.status === 'connected') {
296
+ setState({ phase: 'done' });
297
+ onDone?.();
298
+ return;
299
+ }
300
+ setState((current) => ({ ...current, status: value.status }));
301
+ if (value.status === 'expired' || value.status === 'verify_code_blocked') return;
302
+ } catch (error) {
303
+ if (!stopped && aliveRef.current) setState((current) => ({ ...current, error }));
304
+ return;
305
+ }
306
+ if (!stopped) timer = setTimeout(tick, 2_000);
307
+ };
308
+ let timer = setTimeout(tick, 500);
309
+ return () => {
310
+ stopped = true;
311
+ clearTimeout(timer);
312
+ };
313
+ }, [state.phase, state.attemptId, verifyCode, chatUi, connection, onDone]);
314
+
315
+ const { Panel } = chatUi.components;
316
+
317
+ if (state.phase === 'idle') {
318
+ return h(Panel, {
319
+ title: t('扫码接入'),
320
+ description: t('二维码由腾讯微信 iLink 服务签发;账号凭据只写入本机 Host,浏览器拿不到 token。'),
321
+ actions: h('button', {
322
+ type: 'button', className: 'dchat-button dchat-buttonPrimary', onClick: () => { void begin(); },
323
+ }, t('扫码接入')),
324
+ });
325
+ }
326
+ if (state.phase === 'starting') {
327
+ return h(Panel, { title: t('扫码接入'), description: t('正在生成二维码…') });
328
+ }
329
+ if (state.phase === 'done') {
330
+ return h(Panel, { title: t('扫码接入'), description: t('已接入,正在启动长轮询…') });
331
+ }
332
+ if (state.phase === 'error') {
333
+ return h(Panel, {
334
+ title: t('扫码接入'),
335
+ actions: h('button', {
336
+ type: 'button', className: 'dchat-button', onClick: () => { void begin(); },
337
+ }, t('重新生成二维码')),
338
+ }, h('p', { className: 'dchat-error', role: 'alert' }, state.error?.message ?? '发起扫码失败。'));
339
+ }
340
+
341
+ const expired = status => status === 'expired' || status === 'verify_code_blocked';
342
+ return h(Panel, {
343
+ title: t('扫码接入'),
344
+ description: t('用手机微信扫描二维码并在手机上确认。'),
345
+ actions: h('button', {
346
+ type: 'button', className: 'dchat-button', onClick: () => { void begin(); },
347
+ }, t('重新生成二维码')),
348
+ },
349
+ state.qrcodeUrl
350
+ ? h('img', {
351
+ src: state.qrcodeUrl,
352
+ alt: t('扫码接入'),
353
+ style: { width: 200, height: 200, imageRendering: 'pixelated' },
354
+ })
355
+ : null,
356
+ h('p', { className: 'dchat-cardDescription' }, t(STATUS_TEXT[state.status] ?? '等待扫码')),
357
+ state.status === 'need_verifycode'
358
+ ? h('div', { className: 'dchat-actions' },
359
+ h('input', {
360
+ type: 'text', value: verifyCode, placeholder: t('提交配对码'),
361
+ onChange: (event) => setVerifyCode(event.target.value),
362
+ }))
363
+ : null,
364
+ expired(state.status) ? h('p', { className: 'dchat-error' }, t('二维码已失效,请重新生成')) : null);
365
+ }
366
+
367
+ // 导出给布局守门用:整张渠道卡在窄栏下的真实渲染(真机炸过「卡片头逐字竖排」)。
368
+ export function AccountCard({ account, chatUi, connection, translate, onChanged }) {
369
+ const t = typeof translate === 'function' ? translate : (key) => key;
370
+ const { Panel, StatusPill, ContextEnhancementEditor, DeliveryTargetsEditor,
371
+ WorkspaceEditor, PresetEditor, ModelEditor, AccessPolicyEditor,
372
+ PanelSectionsEditor } = chatUi.components;
373
+ const settings = chatUi.hooks.useBotSettings({
374
+ connection, channelId: CHANNEL_ID, botId: account.botId,
375
+ });
376
+ // 可选项(这台机器人用过的目录、Host 的 Agent Preset 列表)进来读一次。
377
+ React.useEffect(() => {
378
+ void settings.loadOptions();
379
+ }, [settings.loadOptions]);
380
+ // 会话 → 该平台的用户 id(微信只有私聊,route 里是 userId)。
381
+ const sessions = chatUi.hooks.useConversations({
382
+ connection, channelId: CHANNEL_ID, botId: account.botId,
383
+ }).conversations
384
+ .map((item) => ({
385
+ id: item.kind === 'group' ? item.route?.chatId : item.route?.userId,
386
+ name: item.name,
387
+ kind: item.kind,
388
+ }))
389
+ .filter((item) => typeof item.id === 'string' && item.id);
390
+ const shared = {
391
+ workspace: settings.record?.workspace ?? null,
392
+ agentPreset: settings.record?.agentPreset ?? null,
393
+ // 机器人默认模型(还没有会话时用它):与工作区/预设同一条口径,只对新建会话生效。
394
+ model: settings.record?.model ?? null,
395
+ accessPolicy: settings.record?.accessPolicy ?? null,
396
+ // 控制面板卡片的显示项(null = 全显示)。
397
+ panelSections: settings.record?.panelSections ?? null,
398
+ };
399
+ const [busy, setBusy] = React.useState(false);
400
+ const [error, setError] = React.useState(null);
401
+ const [confirming, setConfirming] = React.useState(false);
402
+
403
+ const run = async (method, payload) => {
404
+ setBusy(true);
405
+ setError(null);
406
+ try {
407
+ const result = await chatUi.callChannelRpc(connection, CHANNEL_ID, method, payload);
408
+ return chatUi.unwrapRpc(result);
409
+ } catch (cause) {
410
+ setError(cause.message);
411
+ throw cause;
412
+ } finally {
413
+ setBusy(false);
414
+ }
415
+ };
416
+
417
+ return h(Panel, {
418
+ title: account.botName ?? account.accountIdMasked,
419
+ description: `${account.accountIdMasked} · ${t('仅私聊')}`,
420
+ actions: h('div', { className: 'dchat-actions' },
421
+ h(StatusPill, { status: account.state, label: t(STATE_TEXT[account.state] ?? '状态') }),
422
+ h('button', {
423
+ type: 'button', className: 'dchat-button', disabled: busy,
424
+ onClick: async () => {
425
+ try {
426
+ await run('account.reconnect', { botId: account.botId });
427
+ await onChanged?.();
428
+ } catch { /* 错误已展示 */ }
429
+ },
430
+ }, t('重新连接')),
431
+ confirming
432
+ ? h(React.Fragment, null,
433
+ h('button', {
434
+ type: 'button', className: 'dchat-button dchat-buttonDangerSolid', disabled: busy,
435
+ onClick: async () => {
436
+ try {
437
+ await run('account.delete', { botId: account.botId, confirm: true });
438
+ setConfirming(false);
439
+ await onChanged?.();
440
+ } catch { /* 错误已展示 */ }
441
+ },
442
+ }, t('确认移除')),
443
+ h('button', {
444
+ type: 'button', className: 'dchat-button', disabled: busy,
445
+ onClick: () => setConfirming(false),
446
+ }, t('取消')))
447
+ : h('button', {
448
+ type: 'button', className: 'dchat-button dchat-buttonDanger', disabled: busy,
449
+ onClick: () => setConfirming(true),
450
+ }, t('移除接入'))),
451
+ },
452
+ account.errorMessage
453
+ ? h('p', { className: 'dchat-error', role: 'alert' }, account.errorMessage)
454
+ : null,
455
+ error ? h('p', { className: 'dchat-error', role: 'alert' }, error) : null,
456
+ h('div', { className: 'dchat-list' },
457
+ h('div', { className: 'dchat-listItem' },
458
+ h('span', null, t('已处理消息')),
459
+ h('span', null, String(account.handled ?? 0)))),
460
+ h(WorkspaceEditor, {
461
+ value: shared.workspace,
462
+ options: settings.options?.workspacePaths ?? [],
463
+ translate: t,
464
+ onSave: settings.saveWorkspace,
465
+ }),
466
+
467
+ h(PresetEditor, {
468
+ value: shared.agentPreset,
469
+ options: settings.options?.presets ?? [],
470
+ translate: t,
471
+ onSave: settings.saveAgentPreset,
472
+ }),
473
+
474
+ h(ModelEditor, {
475
+ value: shared.model ?? null,
476
+ options: settings.options?.models ?? [],
477
+ hostDefault: settings.options?.hostDefault ?? null,
478
+ failures: settings.options?.modelFailures ?? [],
479
+ translate: t,
480
+ onSave: settings.saveModel,
481
+ }),
482
+
483
+ h(AccessPolicyEditor, {
484
+ value: shared.accessPolicy,
485
+ translate: t,
486
+ onSave: settings.saveAccessPolicy,
487
+ }),
488
+
489
+ h(PanelSectionsEditor, {
490
+ value: shared.panelSections ?? null,
491
+ disabled: settings.phase !== 'ready',
492
+ translate: t,
493
+ onSave: settings.savePanelSections,
494
+ }),
495
+
496
+ h(ContextEnhancementEditor, {
497
+ config: settings.record?.contextEnhancement ?? null,
498
+ disabled: settings.phase !== 'ready',
499
+ translate: t,
500
+ onSave: settings.saveContextEnhancement,
501
+ // 「指定用户」用平台 userId(route.userId),不是投递目标 id。
502
+ conversations: sessions,
503
+ }),
504
+
505
+ // 渠道无关面板:目标清单与测试发送都由 hub 的共享组件负责。
506
+ h(DeliveryTargetsEditor, {
507
+ chatUi,
508
+ connection,
509
+ channelId: CHANNEL_ID,
510
+ botId: account.botId,
511
+ }));
512
+ }
513
+
514
+ /**
515
+ * 渠道设置页(`chat.channel.page`)。
516
+ *
517
+ * **两个视图严格分开,页面上不混**(与飞书同一条口径):
518
+ * - `botId` 为空 = 「扫码接入」:**只画扫码相关的东西**。已绑定的账号属于左栏那份列表,
519
+ * 点它们的「设置」才是各自的配置页。
520
+ * - `botId` 给了 = 这一个账号的设置页。
521
+ *
522
+ * 导出给布局守门用:守门断言"扫码页里不出现已绑定账号的配置"。
523
+ */
524
+ export function WeixinPage(props) {
525
+ // hub 从机器人列表点「设置」进来时会带上 botId:只展示这一个账号的设置。
526
+ const { chatUi, connection, translate, botId = null } = props;
527
+ const t = typeof translate === 'function' ? translate : (key) => key;
528
+ const [state, setState] = React.useState({ phase: 'idle', value: null, error: null });
529
+
530
+ const load = React.useCallback(async () => {
531
+ setState((current) => ({ ...current, phase: 'loading' }));
532
+ try {
533
+ const result = await chatUi.callChannelRpc(connection, CHANNEL_ID, 'connection.status', {});
534
+ setState({ phase: 'ready', value: chatUi.unwrapRpc(result), error: null });
535
+ } catch (error) {
536
+ setState({ phase: 'error', value: null, error });
537
+ }
538
+ }, [chatUi, connection]);
539
+
540
+ React.useEffect(() => {
541
+ void load();
542
+ }, [load]);
543
+
544
+ const { EmptyState } = chatUi.components;
545
+ const allAccounts = state.value?.accounts ?? state.value?.bots ?? [];
546
+ const scoped = Boolean(botId);
547
+ const accounts = scoped
548
+ ? allAccounts.filter((account) => (account?.botId ?? account?.id ?? null) === botId)
549
+ : [];
550
+ // 指定了 botId 却没匹配到:明确报出来,绝不退回"显示全部账号"。
551
+ const missing = scoped && state.phase === 'ready' && accounts.length === 0;
552
+
553
+ return h(React.Fragment, null,
554
+ // 读状态失败照旧要显示:这个页面上不能只留一行日志。
555
+ state.error ? h('p', { className: 'dchat-error', role: 'alert' }, state.error.message) : null,
556
+ scoped ? null : h(QrLogin, { chatUi, connection, translate: t, onDone: load }),
557
+ missing
558
+ ? h(EmptyState, {
559
+ title: t('找不到这个机器人'),
560
+ description: `${t('它不在当前渠道的名单里(可能已被移除,或 Host 与页面版本不一致)')}:${botId}`,
561
+ })
562
+ : null,
563
+ scoped
564
+ ? accounts.map((account) => h(AccountCard, {
565
+ key: account.botId, account, chatUi, connection, translate: t, onChanged: load,
566
+ }))
567
+ : null);
568
+ }
569
+
570
+ /**
571
+ * Cordis client 插件入口。
572
+ *
573
+ * @param ctx - client 上下文。
574
+ */
575
+ export function apply(ctx) {
576
+ ctx.effect(() => ctx.locale.register(LOCALE_NAMESPACE, { zh, en }),
577
+ 'dsh-chat-weixin: 双语文案');
578
+ const t = typeof ctx.locale?.bind === 'function'
579
+ ? ctx.locale.bind(LOCALE_NAMESPACE)
580
+ : (key) => zh[key] ?? key;
581
+
582
+ ctx.effect(() => ctx.chatChannels.register({
583
+ id: CHANNEL_ID,
584
+ order: 10,
585
+ label: () => t('微信'),
586
+ // 侧边栏会话行的渠道徽标(微信品牌绿)。
587
+ // 图标(SVG 字符串):左栏渠道卡片与侧边栏会话行徽标共用同一份,避免两处画风不一致。
588
+ // 出处见 THIRD_PARTY_NOTICES.md。
589
+ icon: { svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path fill="#07C160" d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>' },
590
+ sessionBadge: { text: '微', color: '#07c160' },
591
+ capabilities: {
592
+ groups: false,
593
+ note: t('仅私聊'),
594
+ // 微信的渠道设置页就是「扫码接入」:入口名与空状态说明都由渠道给(hub 不认识这些语义)。
595
+ setup: { label: t('扫码接入'), hint: t('点「扫码接入」用手机微信扫码绑定新账号。') },
596
+ },
597
+ }), 'dsh-chat-weixin: 渠道元数据');
598
+
599
+ ctx.effect(() => ctx.slots.inject(PAGE_SLOT, () => ctx.slots.register({
600
+ name: PAGE_SLOT,
601
+ key: CHANNEL_ID,
602
+ locale: LOCALE_NAMESPACE,
603
+ inject: () => ({ chatUi: ctx.chatUi, connection: ctx.connection, translate: t }),
604
+ }, WeixinPage)), 'dsh-chat-weixin: 渠道设置页');
605
+ }
@@ -0,0 +1,5 @@
1
+ # 这一行把插件挂进 DSH profile 的插件树。
2
+ # name 必须是**包名**(作用域包要带引号,YAML 里 @ 是保留字符);id 只用于定向补丁。
3
+ - insert:
4
+ - id: dsh-chat-weixin
5
+ name: '@sidleo3/dsh-chat-weixin'