@xmanrui/dsh-im 4.10.0 → 4.12.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 (62) hide show
  1. package/PROACTIVE_DELIVERY.en.md +15 -1
  2. package/PROACTIVE_DELIVERY.md +15 -1
  3. package/README.en.md +7 -2
  4. package/README.md +8 -3
  5. package/lib/client.js +2341 -1315
  6. package/lib/index.js +250 -245
  7. package/package.json +4 -3
  8. package/plugin-src/client/channel-card-meta.js +9 -1
  9. package/plugin-src/client/channels/dingtalk/api.js +4 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +41 -7
  11. package/plugin-src/client/channels/feishu/api.js +4 -0
  12. package/plugin-src/client/channels/feishu/group-settings.js +581 -0
  13. package/plugin-src/client/channels/feishu/index.js +64 -233
  14. package/plugin-src/client/channels/qq/api.js +4 -0
  15. package/plugin-src/client/channels/qq/index.js +41 -7
  16. package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
  17. package/plugin-src/client/channels/shared/token-api.js +4 -0
  18. package/plugin-src/client/channels/shared/token-channel.js +62 -29
  19. package/plugin-src/client/channels/wecom/api.js +4 -0
  20. package/plugin-src/client/channels/wecom/index.js +41 -7
  21. package/plugin-src/client/channels/weixin/api.js +4 -0
  22. package/plugin-src/client/channels/weixin/index.js +41 -7
  23. package/plugin-src/client/channels/whatsapp/api.js +4 -0
  24. package/plugin-src/client/channels/whatsapp/index.js +40 -7
  25. package/plugin-src/client/delivery-settings.js +73 -5
  26. package/plugin-src/client/i18n.js +22 -0
  27. package/plugin-src/client/model-setting.js +104 -0
  28. package/plugin-src/client/styles.js +62 -0
  29. package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
  30. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  31. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  32. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  33. package/plugin-src/host/channels/qq/production.mjs +3 -0
  34. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  35. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
  36. package/plugin-src/host/channels/shared/production.mjs +3 -0
  37. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  38. package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
  39. package/plugin-src/host/channels/slack/production.mjs +3 -0
  40. package/plugin-src/host/channels/slack/rpc.mjs +9 -0
  41. package/plugin-src/host/channels/wecom/production.mjs +3 -0
  42. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  43. package/plugin-src/host/channels/weixin/production.mjs +3 -0
  44. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  45. package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
  46. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  47. package/plugin-src/host/delivery-adapter.mjs +141 -3
  48. package/plugin-src/host/delivery-rpc.mjs +12 -0
  49. package/plugin-src/host/delivery-service.mjs +110 -3
  50. package/plugin-src/host/delivery-suggestions.mjs +46 -0
  51. package/plugin-src/host/harness-session-coordinator.mjs +6 -5
  52. package/plugin-src/host/index.mjs +18 -1
  53. package/plugin-src/host/session-sync-coordinator.mjs +217 -0
  54. package/scripts/verify-package.mjs +12 -6
  55. package/src/channels/shared/bot-workspace-store.mjs +285 -38
  56. package/src/channels/shared/harness-client.mjs +124 -43
  57. package/src/channels/shared/i18n-en/wecom.mjs +24 -0
  58. package/src/channels/shared/model-setting.mjs +110 -0
  59. package/src/channels/wecom/wecom-bridge.mjs +275 -2
  60. package/src/channels/wecom/wecom-cards.mjs +101 -0
  61. package/src/channels/wecom/wecom-runtime.mjs +2 -0
  62. package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
@@ -2,13 +2,13 @@ import * as React from "react";
2
2
 
3
3
  import { FeishuLogoGlyph } from "../../channel-logos.js";
4
4
  import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from "../../credential-binding.js";
5
+ import { CollapsibleAccountSection } from "../shared/collapsible-account.js";
5
6
  import { h } from "../../i18n.js";
6
7
  import {
7
8
  FEISHU_ENDPOINTS,
8
9
  FEISHU_REGISTRATION_OPERATIONS,
9
10
  formatRemaining,
10
11
  normalizeBotsSnapshot,
11
- normalizeGroupResponseMode,
12
12
  normalizePollResult,
13
13
  normalizeProvisioning,
14
14
  presentError,
@@ -22,6 +22,11 @@ import {
22
22
  AgentPresetEditor,
23
23
  EMPTY_AGENT_PRESET_CATALOG,
24
24
  } from "../../agent-preset.js";
25
+ import {
26
+ EMPTY_MODEL_CATALOG,
27
+ ModelCatalogContext,
28
+ ModelEditor,
29
+ } from "../../model-setting.js";
25
30
  import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
26
31
  import {
27
32
  BotSettingsButton,
@@ -459,133 +464,6 @@ function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
459
464
  );
460
465
  }
461
466
 
462
- function GroupResponseModeEditor({
463
- value,
464
- permissionGranted = false,
465
- disabled = false,
466
- authorizationDisabled = false,
467
- onSave,
468
- onAuthorize,
469
- }) {
470
- const current = normalizeGroupResponseMode(value);
471
- const [saving, setSaving] = React.useState(false);
472
- const [authorizing, setAuthorizing] = React.useState(false);
473
- const [error, setError] = React.useState(null);
474
-
475
- const change = async (event) => {
476
- const next = normalizeGroupResponseMode(event.target.value);
477
- if (next === current || saving || disabled) return;
478
- setSaving(true);
479
- setError(null);
480
- try {
481
- await onSave?.(next);
482
- } catch (cause) {
483
- setError(cause?.message ?? "群聊响应方式修改失败,请重试。");
484
- } finally {
485
- setSaving(false);
486
- }
487
- };
488
-
489
- const authorize = async () => {
490
- if (current !== "all" || saving || authorizing || disabled || authorizationDisabled) return;
491
- setAuthorizing(true);
492
- setError(null);
493
- try {
494
- await onAuthorize?.();
495
- } catch (cause) {
496
- setError(cause?.message ?? "群消息权限授权失败,请重试。");
497
- } finally {
498
- setAuthorizing(false);
499
- }
500
- };
501
-
502
- return h("div", { className: "bxf-responseMode dim-responseMode" },
503
- h("div", { className: "bxf-responseModeHeader dim-responseModeHeader" },
504
- h("span", null, "群聊响应方式"),
505
- saving || authorizing
506
- ? h("span", { className: "bxf-responseModeStatus dim-responseModeStatus" },
507
- saving ? "保存中…" : "正在准备授权…")
508
- : null),
509
- h("select", {
510
- className: "bxf-responseModeSelect dim-responseModeSelect",
511
- value: current,
512
- disabled: disabled || saving,
513
- "aria-label": "群聊响应方式",
514
- onChange: (event) => { void change(event); },
515
- },
516
- h("option", { value: "mention" }, "仅在 @机器人时响应(推荐)"),
517
- h("option", { value: "all" }, "响应所有群消息"),
518
- ),
519
- h("small", { className: "bxf-responseModeHelp dim-responseModeHelp" },
520
- current === "mention"
521
- ? permissionGranted
522
- ? "私聊始终响应;群聊仅处理明确 @当前机器人的消息。群消息权限已开通,再次切换无需授权。"
523
- : "私聊始终响应;群聊仅处理明确 @当前机器人的消息。选择全部消息后会打开飞书官方授权流程。"
524
- : permissionGranted
525
- ? "已开通“获取群组中所有消息”权限(im:message.group_msg);机器人会处理群聊中的所有可见消息。"
526
- : "尚未确认“获取群组中所有消息”权限,请完成飞书授权。"),
527
- current === "all"
528
- ? h("div", { className: "bxf-responseModePermissionAction dim-responseModePermissionAction" },
529
- h(Button, {
530
- className: "bxf-responseModePermissionButton",
531
- size: "small",
532
- disabled: disabled || authorizationDisabled || saving || authorizing,
533
- "aria-busy": authorizing ? "true" : undefined,
534
- "aria-label": permissionGranted ? "重新授权群消息权限" : "授权群消息权限",
535
- onClick: () => { void authorize(); },
536
- }, authorizing ? "正在准备…" : permissionGranted ? "重新授权" : "去授权"))
537
- : null,
538
- error ? h("p", {
539
- className: "bxf-responseModeError dim-responseModeError",
540
- role: "alert",
541
- }, error) : null,
542
- );
543
- }
544
-
545
- function GroupTopicReplyEditor({ value = false, disabled = false, onSave }) {
546
- const current = value === true ? "on" : "off";
547
- const [saving, setSaving] = React.useState(false);
548
- const [error, setError] = React.useState(null);
549
-
550
- const change = async (event) => {
551
- const next = event.target.value === "on";
552
- if ((next ? "on" : "off") === current || saving || disabled) return;
553
- setSaving(true);
554
- setError(null);
555
- try {
556
- await onSave?.(next);
557
- } catch (cause) {
558
- setError(cause?.message ?? "群聊以话题方式回复设置保存失败,请重试。");
559
- } finally {
560
- setSaving(false);
561
- }
562
- };
563
-
564
- return h("div", { className: "bxf-responseMode dim-responseMode" },
565
- h("div", { className: "bxf-responseModeHeader dim-responseModeHeader" },
566
- h("span", null, "群聊以话题方式回复"),
567
- saving
568
- ? h("span", { className: "bxf-responseModeStatus dim-responseModeStatus" }, "保存中…")
569
- : null),
570
- h("select", {
571
- className: "bxf-responseModeSelect dim-responseModeSelect",
572
- value: current,
573
- disabled: disabled || saving,
574
- "aria-label": "群聊以话题方式回复",
575
- onChange: (event) => { void change(event); },
576
- },
577
- h("option", { value: "off" }, "关闭(群内直接回复)"),
578
- h("option", { value: "on" }, "开启(自动开启独立飞书话题)"),
579
- ),
580
- h("small", { className: "bxf-responseModeHelp dim-responseModeHelp" },
581
- "开启后,群聊中向机器人提问会自动开启独立飞书话题,回复落在话题内;每个话题是 dsh 会话列表里一条独立会话,上下文互不串。私聊不受影响。"),
582
- error ? h("p", {
583
- className: "bxf-responseModeError dim-responseModeError",
584
- role: "alert",
585
- }, error) : null,
586
- );
587
- }
588
-
589
467
  export function BotCard({
590
468
  connection,
591
469
  busy,
@@ -598,11 +476,9 @@ export function BotCard({
598
476
  onReconnect,
599
477
  onRepairCallback,
600
478
  onWorkspaceSave,
479
+ onModelSave,
601
480
  onAgentPresetSave,
602
481
  onContextEnhancementSave,
603
- onGroupResponseModeSave,
604
- onGroupTopicReplySave,
605
- onGroupMessagePermissionAuthorize,
606
482
  onRequestRemove,
607
483
  onConfirmRemove,
608
484
  onCancelRemove,
@@ -631,36 +507,53 @@ export function BotCard({
631
507
  ref: cardRef,
632
508
  },
633
509
  h("div", { className: "bxf-cardBody dim-botCardBody" },
634
- h("div", { className: "bxf-connectedTop dim-botCardTop" },
635
- h("div", { className: "bxf-botIdentity dim-botIdentity" },
636
- h("div", { className: "bxf-avatar dim-botAvatar", "aria-hidden": "true" },
637
- h(FeishuLogoGlyph, { size: 34 })),
638
- h("div", { className: "bxf-botName dim-botName" },
639
- h("h3", { id: titleId, title: bot.name }, bot.name),
640
- h("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "应用标识已安全保存")),
641
- ),
642
- h("div", { className: "dim-botCardTools" },
643
- h(BotStatusMeta, {
644
- className: "bxf-healthPill",
645
- dotClassName: "bxf-dot",
646
- tone,
647
- stateLabel: HEALTH_LABELS[stateForDisplay] ?? "状态未知",
648
- lastCheckedAt: health.lastCheckedAt,
649
- formatCheckedTime,
650
- healthState: stateForDisplay,
651
- }),
652
- h(BotSettingsButton, {
653
- channel: "feishu",
654
- botId: connection.botId,
655
- botName: bot.name,
656
- connected,
657
- accessPolicy: connection.accessPolicy,
658
- })),
659
- ),
660
- h(WorkspaceEditor, {
661
- workspace: connection.workspace,
510
+ h(CollapsibleAccountSection, {
511
+ id: `bxf-settings-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`,
512
+ header: h("div", { className: "bxf-connectedTop dim-botCardTop" },
513
+ h("div", { className: "bxf-botIdentity dim-botIdentity" },
514
+ h("div", { className: "bxf-avatar dim-botAvatar", "aria-hidden": "true" },
515
+ h(FeishuLogoGlyph, { size: 34 })),
516
+ h("div", { className: "bxf-botName dim-botName" },
517
+ h("h3", { id: titleId, title: bot.name }, bot.name),
518
+ h("p", { title: bot.appIdMasked }, bot.appIdMasked ?? "应用标识已安全保存")),
519
+ ),
520
+ h("div", {
521
+ className: "dim-botCardTools",
522
+ // The header is the collapse toggle; keep inner controls clickable.
523
+ onClick: (event) => { event.stopPropagation(); },
524
+ onKeyDown: (event) => { if (event.key === "Enter" || event.key === " ") event.stopPropagation(); },
525
+ },
526
+ h(BotStatusMeta, {
527
+ className: "bxf-healthPill",
528
+ dotClassName: "bxf-dot",
529
+ tone,
530
+ stateLabel: HEALTH_LABELS[stateForDisplay] ?? "状态未知",
531
+ lastCheckedAt: health.lastCheckedAt,
532
+ formatCheckedTime,
533
+ healthState: stateForDisplay,
534
+ }),
535
+ h(BotSettingsButton, {
536
+ channel: "feishu",
537
+ botId: connection.botId,
538
+ botName: bot.name,
539
+ connected,
540
+ accessPolicy: connection.accessPolicy,
541
+ channelSettings: {
542
+ groupResponseMode: connection.groupResponseMode,
543
+ groupTopicReply: connection.groupTopicReply,
544
+ groupMessagePermissionGranted: connection.groupMessagePermissionGranted,
545
+ },
546
+ }))),
547
+ },
548
+ h(WorkspaceEditor, {
549
+ workspace: connection.workspace,
550
+ disabled: Boolean(busy),
551
+ onSave: onWorkspaceSave,
552
+ }),
553
+ h(ModelEditor, {
554
+ model: connection.model,
662
555
  disabled: Boolean(busy),
663
- onSave: onWorkspaceSave,
556
+ onSave: onModelSave,
664
557
  }),
665
558
  h(AgentPresetEditor, {
666
559
  agentPreset: connection.agentPreset,
@@ -672,19 +565,6 @@ export function BotCard({
672
565
  disabled: Boolean(busy),
673
566
  onSave: onContextEnhancementSave,
674
567
  }),
675
- h(GroupResponseModeEditor, {
676
- value: connection.groupResponseMode,
677
- permissionGranted: connection.groupMessagePermissionGranted,
678
- disabled: Boolean(busy),
679
- authorizationDisabled: repairDisabled,
680
- onSave: onGroupResponseModeSave,
681
- onAuthorize: onGroupMessagePermissionAuthorize,
682
- }),
683
- h(GroupTopicReplyEditor, {
684
- value: connection.groupTopicReply,
685
- disabled: Boolean(busy),
686
- onSave: onGroupTopicReplySave,
687
- }),
688
568
  provisionContent
689
569
  ? h("section", {
690
570
  className: "bxf-botProvision dim-botProvision",
@@ -733,6 +613,7 @@ export function BotCard({
733
613
  className: "bxf-healthSummary dim-cardFeedback",
734
614
  role: "status",
735
615
  }, testNotice) : null),
616
+ ),
736
617
  ),
737
618
  ),
738
619
  removing
@@ -773,11 +654,9 @@ function BotList(props) {
773
654
  onReconnect: () => props.onReconnect(bot),
774
655
  onRepairCallback: () => props.onRepairCallback(bot),
775
656
  onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
657
+ onModelSave: (model) => props.onModelSave(bot, model),
776
658
  onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
777
659
  onContextEnhancementSave: (config) => props.onContextEnhancementSave(bot, config),
778
- onGroupResponseModeSave: (groupResponseMode) => props.onGroupResponseModeSave(bot, groupResponseMode),
779
- onGroupTopicReplySave: (groupTopicReply) => props.onGroupTopicReplySave(bot, groupTopicReply),
780
- onGroupMessagePermissionAuthorize: () => props.onGroupMessagePermissionAuthorize(bot),
781
660
  onRequestRemove: () => props.onRequestRemove(bot),
782
661
  onConfirmRemove: () => props.onConfirmRemove(bot),
783
662
  onCancelRemove: props.onCancelRemove,
@@ -830,6 +709,7 @@ export function mergeFeishuSnapshotState(
830
709
  pageError: null,
831
710
  statusError: null,
832
711
  agentPresetCatalog: snapshot.agentPresetCatalog ?? current.agentPresetCatalog,
712
+ modelCatalog: snapshot.modelCatalog ?? current.modelCatalog,
833
713
  };
834
714
  }
835
715
 
@@ -843,6 +723,7 @@ export function FeishuSettingsTab({ rpcCall }) {
843
723
  pageError: null,
844
724
  statusError: null,
845
725
  agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
726
+ modelCatalog: EMPTY_MODEL_CATALOG,
846
727
  });
847
728
  const [pageBusy, setPageBusy] = React.useState(false);
848
729
  const [provisionBusy, setProvisionBusy] = React.useState(false);
@@ -1384,56 +1265,6 @@ export function FeishuSettingsTab({ rpcCall }) {
1384
1265
  }
1385
1266
  }, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
1386
1267
 
1387
- const authorizeGroupMessages = React.useCallback(async (connection) => {
1388
- const { botId } = connection;
1389
- if (model.provisioning) {
1390
- throw new Error("请先完成当前飞书授权操作,再开通群消息权限。");
1391
- }
1392
- setRemoveTargetId(null);
1393
- setBotError(botId, null);
1394
- setTestNoticesByBot((current) => {
1395
- const next = { ...current };
1396
- delete next[botId];
1397
- return next;
1398
- });
1399
- await startProvisioning({
1400
- operation: GROUP_MESSAGE_PERMISSION_OPERATION,
1401
- bot: connection,
1402
- });
1403
- }, [model.provisioning, setBotError, startProvisioning]);
1404
-
1405
- const saveGroupResponseMode = React.useCallback(async (connection, groupResponseMode) => {
1406
- const { botId } = connection;
1407
- if (groupResponseMode === "all" && connection.groupMessagePermissionGranted !== true) {
1408
- await authorizeGroupMessages(connection);
1409
- return;
1410
- }
1411
- const snapshotVersion = workspaceFence.beginMutation();
1412
- setBotBusy(botId, "group-response-mode");
1413
- setBotError(botId, null);
1414
- try {
1415
- const snapshot = normalizeBotsSnapshot(await invoke(
1416
- FEISHU_ENDPOINTS.setGroupResponseMode,
1417
- { botId, groupResponseMode },
1418
- ));
1419
- if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
1420
- mergeSnapshot(snapshot);
1421
- }
1422
- } finally {
1423
- const shouldRefresh = workspaceFence.endMutation();
1424
- if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
1425
- if (mountedRef.current) setBotBusy(botId, null);
1426
- }
1427
- }, [
1428
- invoke,
1429
- authorizeGroupMessages,
1430
- loadStatus,
1431
- mergeSnapshot,
1432
- setBotBusy,
1433
- setBotError,
1434
- workspaceFence,
1435
- ]);
1436
-
1437
1268
  const requestRemove = React.useCallback((connection) => {
1438
1269
  setRemoveTargetId(connection.botId);
1439
1270
  }, []);
@@ -1541,7 +1372,9 @@ export function FeishuSettingsTab({ rpcCall }) {
1541
1372
  else removeButtonRefs.current.delete(botId);
1542
1373
  }, []);
1543
1374
 
1544
- return h(AgentPresetCatalogContext.Provider, {
1375
+ return h(ModelCatalogContext.Provider, {
1376
+ value: model.modelCatalog ?? EMPTY_MODEL_CATALOG,
1377
+ }, h(AgentPresetCatalogContext.Provider, {
1545
1378
  value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
1546
1379
  }, h("section", { className: "bxf-page dim-channelPage", "aria-label": "飞书机器人设置" },
1547
1380
  h(Heading, {
@@ -1589,17 +1422,15 @@ export function FeishuSettingsTab({ rpcCall }) {
1589
1422
  onReconnect: (bot) => void reconnectOneBot(bot),
1590
1423
  onRepairCallback: repairCallback,
1591
1424
  onWorkspaceSave: saveWorkspace,
1425
+ onModelSave: (connection, selectedModel) => saveBotSetting(
1426
+ connection, "model", FEISHU_ENDPOINTS.setModel, { model: selectedModel },
1427
+ ),
1592
1428
  onAgentPresetSave: (connection, agentPreset) => saveBotSetting(
1593
1429
  connection, "preset", FEISHU_ENDPOINTS.setAgentPreset, { agentPreset },
1594
1430
  ),
1595
1431
  onContextEnhancementSave: (connection, config) => saveBotSetting(
1596
1432
  connection, "context-enhancement", FEISHU_ENDPOINTS.setContextEnhancement, { config },
1597
1433
  ),
1598
- onGroupResponseModeSave: saveGroupResponseMode,
1599
- onGroupTopicReplySave: (connection, groupTopicReply) => saveBotSetting(
1600
- connection, "group-topic-reply", FEISHU_ENDPOINTS.setGroupTopicReply, { groupTopicReply },
1601
- ),
1602
- onGroupMessagePermissionAuthorize: authorizeGroupMessages,
1603
1434
  onRequestRemove: requestRemove,
1604
1435
  onConfirmRemove: (bot) => void confirmRemove(bot),
1605
1436
  onCancelRemove: cancelRemove,
@@ -1608,5 +1439,5 @@ export function FeishuSettingsTab({ rpcCall }) {
1608
1439
  })
1609
1440
  : null,
1610
1441
  ),
1611
- ));
1442
+ )));
1612
1443
  }
@@ -1,4 +1,5 @@
1
1
  import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
2
+ import { normalizeModelCatalog, normalizeModelSelection, SET_MODEL_ENDPOINT } from '../../model-setting.js';
2
3
  import { normalizeLastMessageError } from '../../last-message-error.js';
3
4
  import { normalizeAccessPolicy } from '../../../../src/channels/shared/access-policy.mjs';
4
5
  import { normalizeContextEnhancementConfig } from '../../../../src/channels/shared/context-enhancement.mjs';
@@ -14,6 +15,7 @@ export const QQ_ENDPOINTS = Object.freeze({
14
15
  reconnectBot: 'bot.reconnect',
15
16
  deleteBot: 'bot.delete',
16
17
  setWorkspace: 'bot.workspace.set',
18
+ setModel: SET_MODEL_ENDPOINT,
17
19
  setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
18
20
  setContextEnhancement: 'bot.context-enhancement.set',
19
21
  setAccessPolicy: 'bot.access-policy.set',
@@ -90,6 +92,7 @@ function normalizeBot(value) {
90
92
  connected,
91
93
  state: connected ? 'connected' : state,
92
94
  workspace: text(value.workspace, '', 4_096),
95
+ model: normalizeModelSelection(value.model),
93
96
  agentPreset: normalizeAgentPresetId(value.agentPreset),
94
97
  contextEnhancement: normalizeContextEnhancementConfig(value.contextEnhancement),
95
98
  ...(Object.hasOwn(value, 'accessPolicy')
@@ -132,6 +135,7 @@ export function normalizeSnapshot(value) {
132
135
  totals: { configured: bots.length, connected: bots.filter((bot) => bot.connected).length },
133
136
  provisioning: source.provisioning ? normalizeProvisioning(source.provisioning) : null,
134
137
  agentPresetCatalog: normalizeAgentPresetCatalog(source.agentPresetCatalog),
138
+ modelCatalog: normalizeModelCatalog(source.modelCatalog),
135
139
  ...(testMessage ? { testMessage } : {}),
136
140
  };
137
141
  }
@@ -2,6 +2,7 @@ import * as React from 'react';
2
2
 
3
3
  import { QqLogoGlyph } from '../../channel-logos.js';
4
4
  import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
5
+ import { CollapsibleAccountSection } from '../shared/collapsible-account.js';
5
6
  import { h } from '../../i18n.js';
6
7
  import { WorkspaceEditor } from '../../workspace-editor.js';
7
8
  import { ContextEnhancementEditor } from '../../context-enhancement.js';
@@ -10,6 +11,11 @@ import {
10
11
  AgentPresetEditor,
11
12
  EMPTY_AGENT_PRESET_CATALOG,
12
13
  } from '../../agent-preset.js';
14
+ import {
15
+ EMPTY_MODEL_CATALOG,
16
+ ModelCatalogContext,
17
+ ModelEditor,
18
+ } from '../../model-setting.js';
13
19
  import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
14
20
  import {
15
21
  BotSettingsButton,
@@ -167,6 +173,7 @@ export function AccountCard({
167
173
  removing,
168
174
  onReconnect,
169
175
  onWorkspaceSave,
176
+ onModelSave,
170
177
  onAgentPresetSave,
171
178
  onContextEnhancementSave,
172
179
  onRequestRemove,
@@ -178,12 +185,19 @@ export function AccountCard({
178
185
  const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
179
186
  return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
180
187
  h('div', { className: 'ddt-cardBody dim-botCardBody' },
181
- h('div', { className: 'ddt-accountTop dim-botCardTop' },
188
+ h(CollapsibleAccountSection, {
189
+ id: `qq-settings-${account.botId.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
190
+ header: h('div', { className: 'ddt-accountTop dim-botCardTop' },
182
191
  h('div', { className: 'ddt-accountIdentity dim-botIdentity' },
183
192
  h('div', { className: 'ddt-avatar dim-botAvatar dqq-avatar', 'aria-hidden': 'true' }, h(QqLogoGlyph, { size: 29 })),
184
193
  h('div', { className: 'dim-botName' },
185
194
  h('h3', null, account.bot.name), h('p', null, account.bot.appIdMasked))),
186
- h('div', { className: 'dim-botCardTools' },
195
+ h('div', {
196
+ className: 'dim-botCardTools',
197
+ // The header is the collapse toggle; keep inner controls clickable.
198
+ onClick: (event) => { event.stopPropagation(); },
199
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation(); },
200
+ },
187
201
  h(BotStatusMeta, {
188
202
  className: 'ddt-health',
189
203
  dotClassName: 'ddt-dot',
@@ -198,12 +212,18 @@ export function AccountCard({
198
212
  botName: account.bot.name,
199
213
  connected: account.connected,
200
214
  accessPolicy: account.accessPolicy,
201
- }))),
202
- h(WorkspaceEditor, {
215
+ })))
216
+ },
217
+ h(WorkspaceEditor, {
203
218
  workspace: account.workspace,
204
219
  disabled: Boolean(busy),
205
220
  onSave: onWorkspaceSave,
206
221
  }),
222
+ h(ModelEditor, {
223
+ model: account.model,
224
+ disabled: Boolean(busy),
225
+ onSave: onModelSave,
226
+ }),
207
227
  h(AgentPresetEditor, {
208
228
  agentPreset: account.agentPreset,
209
229
  disabled: Boolean(busy),
@@ -228,7 +248,9 @@ export function AccountCard({
228
248
  className: 'ddt-summary dim-cardFeedback',
229
249
  role: 'status',
230
250
  'aria-live': 'polite',
231
- }, feedback) : null))),
251
+ }, feedback) : null)),
252
+ ),
253
+ ),
232
254
  removing ? h(RemoveConfirmation, {
233
255
  account, busy: busy === 'delete', onConfirm: onConfirmRemove, onCancel: onCancelRemove,
234
256
  }) : null);
@@ -238,6 +260,7 @@ export function QqSettingsTab({ rpcCall }) {
238
260
  const [model, setModel] = React.useState({
239
261
  phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
240
262
  agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
263
+ modelCatalog: EMPTY_MODEL_CATALOG,
241
264
  });
242
265
  const [provision, setProvision] = React.useState(null);
243
266
  const [busy, setBusy] = React.useState(false);
@@ -278,6 +301,7 @@ export function QqSettingsTab({ rpcCall }) {
278
301
  setModel({
279
302
  phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
280
303
  agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
304
+ modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
281
305
  });
282
306
  if (restore && snapshot.provisioning) setProvision({
283
307
  ...snapshot.provisioning,
@@ -345,6 +369,7 @@ export function QqSettingsTab({ rpcCall }) {
345
369
  setModel({
346
370
  phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
347
371
  agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
372
+ modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
348
373
  });
349
374
  }
350
375
  setCredentialOpen(false);
@@ -408,6 +433,7 @@ export function QqSettingsTab({ rpcCall }) {
408
433
  setModel({
409
434
  phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
410
435
  agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
436
+ modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
411
437
  });
412
438
  }
413
439
  return snapshot;
@@ -478,6 +504,12 @@ export function QqSettingsTab({ rpcCall }) {
478
504
  QQ_ENDPOINTS.setWorkspace,
479
505
  { botId: account.botId, workspace },
480
506
  ),
507
+ onModelSave: (selectedModel) => botAction(
508
+ account,
509
+ 'model',
510
+ QQ_ENDPOINTS.setModel,
511
+ { botId: account.botId, model: selectedModel },
512
+ ),
481
513
  onAgentPresetSave: (agentPreset) => botAction(
482
514
  account,
483
515
  'preset',
@@ -513,7 +545,9 @@ export function QqSettingsTab({ rpcCall }) {
513
545
  })
514
546
  : null;
515
547
 
516
- return h(AgentPresetCatalogContext.Provider, {
548
+ return h(ModelCatalogContext.Provider, {
549
+ value: model.modelCatalog ?? EMPTY_MODEL_CATALOG,
550
+ }, h(AgentPresetCatalogContext.Provider, {
517
551
  value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
518
552
  }, h('section', { className: 'ddt-page dqq-page dim-channelPage', 'aria-label': 'QQ 设置' },
519
553
  h(Heading, {
@@ -533,5 +567,5 @@ export function QqSettingsTab({ rpcCall }) {
533
567
  provisionView,
534
568
  model.bots.length === 0 && !provision && !credentialOpen
535
569
  ? h(EmptyView, { busy, onStart: () => void startProvisioning() }) : null,
536
- botList)));
570
+ botList))));
537
571
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Collapsible per-account card, shared by every IM channel.
3
+ *
4
+ * When several accounts are connected, each account's workspace / preset /
5
+ * context-enhancement editors make the list extremely long. This component
6
+ * renders the account header as the toggle itself: collapsed, the card shows
7
+ * only the header line; clicking the header expands the settings details.
8
+ *
9
+ * Design notes:
10
+ * - The whole header is the toggle (click + keyboard). Buttons rendered inside
11
+ * the header must call stopPropagation so they keep their own behaviour.
12
+ * - A chevron sits at the end of the header line as the affordance.
13
+ * - Height animation uses grid-template-rows: 0fr -> 1fr (pure CSS).
14
+ * - Full a11y: the header is a button-like region with aria-expanded /
15
+ * aria-controls; the details region keeps native semantics.
16
+ * - Honors prefers-reduced-motion.
17
+ * - Styles are managed by the IM settings plugin, not individual accounts.
18
+ */
19
+ import * as React from 'react';
20
+ import { h } from '../../i18n.js';
21
+
22
+ /**
23
+ * Collapsible account card.
24
+ *
25
+ * `header` renders the always-visible header line; clicking it toggles.
26
+ * Any interactive control inside the header should stop the click event so it
27
+ * does not toggle the section (e.g. the bot settings button).
28
+ * `children` is the collapsed details region.
29
+ */
30
+ export function CollapsibleAccountSection({
31
+ header,
32
+ defaultOpen = false,
33
+ open: controlledOpen,
34
+ onToggle,
35
+ id,
36
+ className = '',
37
+ children,
38
+ }) {
39
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
40
+ const open = controlledOpen ?? uncontrolledOpen;
41
+ const contentId = id ? `${id}-content` : undefined;
42
+
43
+ const toggle = () => {
44
+ if (controlledOpen === undefined) setUncontrolledOpen((value) => !value);
45
+ onToggle?.(!open);
46
+ };
47
+
48
+ const onKeyDown = (event) => {
49
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
50
+ event.preventDefault();
51
+ toggle();
52
+ }
53
+ };
54
+
55
+ return h('div', {
56
+ className: `dim-collapsibleAccount ${open ? 'is-open' : ''} ${className}`.trim(),
57
+ 'data-open': open ? 'true' : 'false',
58
+ },
59
+ h('div', {
60
+ className: 'dim-collapsibleHead',
61
+ role: 'button',
62
+ tabIndex: 0,
63
+ onClick: toggle,
64
+ onKeyDown,
65
+ 'aria-expanded': open ? 'true' : 'false',
66
+ 'aria-controls': contentId,
67
+ 'aria-label': open ? '收起该账号的设置' : '展开该账号的设置',
68
+ },
69
+ h('div', { className: 'dim-collapsibleHeaderContent' }, header),
70
+ h('span', { className: 'dim-collapsibleChevron', 'aria-hidden': 'true' }),
71
+ ),
72
+ h('div', {
73
+ id: contentId,
74
+ className: 'dim-collapsibleBody',
75
+ role: 'region',
76
+ },
77
+ h('div', { className: 'dim-collapsibleBodyInner' }, children),
78
+ ),
79
+ );
80
+ }