@sophiacyl/dsh-plugin-waves 0.0.0-stage → 0.1.0-beta.1

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 (140) hide show
  1. package/DSH-GO-VERIFICATION.md +57 -0
  2. package/ICON.png +0 -0
  3. package/LICENSE +674 -0
  4. package/README.md +251 -2
  5. package/cordis.patch.yml +8 -0
  6. package/install.ps1 +0 -0
  7. package/install.sh +209 -0
  8. package/lib/client.js +1511 -0
  9. package/lib/host/aliashint.js +70 -0
  10. package/lib/host/attachments.js +25 -0
  11. package/lib/host/command.js +34 -0
  12. package/lib/host/consts.js +16 -0
  13. package/lib/host/dataguide.js +35 -0
  14. package/lib/host/imagebridge.js +41 -0
  15. package/lib/host/native-result.js +91 -0
  16. package/lib/host/native.js +191 -0
  17. package/lib/host/proxy.js +59 -0
  18. package/lib/host/results.js +67 -0
  19. package/lib/host/service.js +263 -0
  20. package/lib/host/tool.js +185 -0
  21. package/lib/host/troubleshoot.js +31 -0
  22. package/lib/host/worldbook.js +80 -0
  23. package/lib/index.js +74 -0
  24. package/locale/en.json +6 -0
  25. package/locale/zh.json +6 -0
  26. package/package.json +136 -4
  27. package/service/ICON.png +0 -0
  28. package/service/ai/AI_DATA.md +221 -0
  29. package/service/knowledge/additional/manual.jsonl +5 -0
  30. package/service/knowledge/wiki/baike.jsonl +1231 -0
  31. package/service/knowledge/wiki/chengjiu.jsonl +36 -0
  32. package/service/knowledge/wiki/gonglve.jsonl +383 -0
  33. package/service/knowledge/wiki/huodong.jsonl +427 -0
  34. package/service/knowledge/wiki/renwu.jsonl +412 -0
  35. package/service/knowledge/wiki/tujian.jsonl +2889 -0
  36. package/service/knowledge/wiki/zixun.jsonl +47 -0
  37. package/service/plugins/__init__.py +0 -0
  38. package/service/pyproject.toml +40 -0
  39. package/service/rover/__init__.py +0 -0
  40. package/service/rover/ai_core/__init__.py +0 -0
  41. package/service/rover/ai_core/models.py +50 -0
  42. package/service/rover/ai_core/rag/__init__.py +5 -0
  43. package/service/rover/ai_core/rag/knowledge.py +23 -0
  44. package/service/rover/ai_core/register.py +86 -0
  45. package/service/rover/ai_core/resource.py +6 -0
  46. package/service/rover/ai_core/skills/__init__.py +0 -0
  47. package/service/rover/ai_core/skills/operations.py +8 -0
  48. package/service/rover/api.py +720 -0
  49. package/service/rover/api_render.py +161 -0
  50. package/service/rover/app_life.py +154 -0
  51. package/service/rover/aps.py +47 -0
  52. package/service/rover/avatar.py +85 -0
  53. package/service/rover/bot.py +133 -0
  54. package/service/rover/builtin.py +152 -0
  55. package/service/rover/cards/__init__.py +26 -0
  56. package/service/rover/cards/_shared.py +129 -0
  57. package/service/rover/cards/char_list.py +258 -0
  58. package/service/rover/cards/char_panel.py +1353 -0
  59. package/service/rover/cards/echo_list.py +206 -0
  60. package/service/rover/cards/matrix_roles.py +25 -0
  61. package/service/rover/cards/rank_boards.py +714 -0
  62. package/service/rover/cards/rank_total.py +664 -0
  63. package/service/rover/compat.py +215 -0
  64. package/service/rover/config.py +163 -0
  65. package/service/rover/custom_panel.py +220 -0
  66. package/service/rover/data_store.py +31 -0
  67. package/service/rover/gss.py +19 -0
  68. package/service/rover/handler.py +191 -0
  69. package/service/rover/help/__init__.py +0 -0
  70. package/service/rover/help/draw_new_plugin_help.py +332 -0
  71. package/service/rover/help/model.py +16 -0
  72. package/service/rover/help/utils.py +7 -0
  73. package/service/rover/hooks.py +420 -0
  74. package/service/rover/html_actions.py +236 -0
  75. package/service/rover/i18n.py +19 -0
  76. package/service/rover/knowledge.py +235 -0
  77. package/service/rover/logger.py +102 -0
  78. package/service/rover/main.py +33 -0
  79. package/service/rover/media.py +222 -0
  80. package/service/rover/message_models.py +23 -0
  81. package/service/rover/models.py +92 -0
  82. package/service/rover/native_media.py +64 -0
  83. package/service/rover/ocr.py +79 -0
  84. package/service/rover/pool.py +43 -0
  85. package/service/rover/restart.py +36 -0
  86. package/service/rover/segment.py +143 -0
  87. package/service/rover/server.py +131 -0
  88. package/service/rover/status/__init__.py +0 -0
  89. package/service/rover/status/models.py +10 -0
  90. package/service/rover/status/plugin_status.py +17 -0
  91. package/service/rover/subscribe.py +148 -0
  92. package/service/rover/sv.py +403 -0
  93. package/service/rover/templates/char_list.html +182 -0
  94. package/service/rover/templates/char_panel.html +465 -0
  95. package/service/rover/templates/echo_list.html +221 -0
  96. package/service/rover/templates/rank_all_total.html +193 -0
  97. package/service/rover/templates/rank_board.html +107 -0
  98. package/service/rover/templates/rank_level_total.html +57 -0
  99. package/service/rover/templates/rank_matrix_total.html +66 -0
  100. package/service/rover/templates/rank_phantom_total.html +180 -0
  101. package/service/rover/templates/rank_slash_total.html +38 -0
  102. package/service/rover/trigger.py +98 -0
  103. package/service/rover/utils/__init__.py +0 -0
  104. package/service/rover/utils/boardcast/__init__.py +0 -0
  105. package/service/rover/utils/boardcast/models.py +15 -0
  106. package/service/rover/utils/cookie_manager/__init__.py +0 -0
  107. package/service/rover/utils/cookie_manager/qrlogin.py +24 -0
  108. package/service/rover/utils/database/__init__.py +0 -0
  109. package/service/rover/utils/database/base_models.py +1771 -0
  110. package/service/rover/utils/database/models.py +80 -0
  111. package/service/rover/utils/database/startup.py +60 -0
  112. package/service/rover/utils/default_bg/bg.jpg +0 -0
  113. package/service/rover/utils/download_resource/__init__.py +0 -0
  114. package/service/rover/utils/download_resource/download_core.py +164 -0
  115. package/service/rover/utils/download_resource/download_file.py +46 -0
  116. package/service/rover/utils/fonts/__init__.py +0 -0
  117. package/service/rover/utils/fonts/fonts.py +33 -0
  118. package/service/rover/utils/image/__init__.py +0 -0
  119. package/service/rover/utils/image/convert.py +51 -0
  120. package/service/rover/utils/image/image_tools.py +30 -0
  121. package/service/rover/utils/image/utils.py +70 -0
  122. package/service/rover/utils/plugins_config/__init__.py +0 -0
  123. package/service/rover/utils/plugins_config/gs_config.py +506 -0
  124. package/service/rover/utils/plugins_config/models.py +182 -0
  125. package/service/rover/web_app.py +3 -0
  126. package/service/rover/webconsole/__init__.py +0 -0
  127. package/service/rover/webconsole/mount_app.py +26 -0
  128. package/service/tools/migrate.py +351 -0
  129. package/service/tools/regression.py +87 -0
  130. package/service/tools/smoke.py +63 -0
  131. package/service/tools/update_plugins.py +212 -0
  132. package/service/tools/verify_actions.py +104 -0
  133. package/service/tools/verify_card.py +82 -0
  134. package/service/tools/wwdata.py +665 -0
  135. package/tests/compat.test.js +181 -0
  136. package/tests/imagebridge.test.js +58 -0
  137. package/tests/media.test.js +68 -0
  138. package/tests/native.test.js +127 -0
  139. package/tests/runtime.test.js +69 -0
  140. package/tests/service.test.js +48 -0
package/lib/client.js ADDED
@@ -0,0 +1,1511 @@
1
+ // 浏览器端 bundle: dsh 按 exports["./client"] 整文件下发, 保持单文件(ModuleLoader 包装)
2
+
3
+ window.__ModuleLoader__.load({
4
+ id: '@sophiacyl/dsh-plugin-waves',
5
+ factory: (require) => {
6
+ var module = { exports: {} }
7
+ var exports = module.exports
8
+ const React = require('react')
9
+
10
+ const API = '/waves-api'
11
+ const CSS = `
12
+ .ww-card{display:flex;flex-direction:column;gap:8px;padding:8px 0;max-width:100%;min-width:0}
13
+ .ww-head{display:flex;align-items:center;gap:8px;font-size:12px;min-height:24px}
14
+ /* 卡面是深底描金的鸣潮风, 标签跟着走: 渐变金字 + 细金边 + 左侧竖条 */
15
+ .ww-title{flex:none;position:relative;font-size:11px;font-weight:800;letter-spacing:.14em;
16
+ font-family:'Oswald',ui-sans-serif,system-ui,sans-serif;padding:3px 9px 3px 11px;border-radius:4px;
17
+ background:linear-gradient(135deg,rgba(30,34,42,.92) 0%,rgba(15,17,21,.92) 100%);
18
+ border:1px solid rgba(212,177,99,.34);
19
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 1px 6px rgba(0,0,0,.28)}
20
+ .ww-title span{background:linear-gradient(180deg,#f4e3b4 0%,#d4b163 100%);
21
+ -webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
22
+ .ww-title::before{content:"";position:absolute;left:3px;top:4px;bottom:4px;width:2px;border-radius:2px;
23
+ background:linear-gradient(180deg,#f4e3b4,#d4b163);box-shadow:0 0 6px rgba(212,177,99,.55)}
24
+ .ww-cmd{font-family:ui-monospace,monospace;background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));padding:2px 6px;
25
+ border-radius:4px;opacity:.75;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
26
+ .ww-head-gap{flex:1;min-width:8px}
27
+ .ww-text{white-space:pre-wrap;line-height:1.6;overflow-wrap:anywhere;word-break:break-word;max-width:100%;min-width:0}
28
+ .ww-img{max-width:100%;width:auto;align-self:flex-start;object-fit:contain;
29
+ border-radius:8px;border:1px solid var(--dsw-alias-border-l1,#3a3a3a);cursor:zoom-in;
30
+ transition:filter .15s}
31
+ .ww-img:hover{filter:brightness(1.06)}
32
+ .ww-shot{position:relative;align-self:flex-start;max-width:100%;line-height:0}
33
+ .ww-shot-tip{position:absolute;right:8px;bottom:8px;line-height:1;font-size:11px;padding:3px 7px;
34
+ border-radius:999px;background:rgba(0,0,0,.55);color:#fff;opacity:0;transition:opacity .15s;
35
+ pointer-events:none}
36
+ .ww-shot:hover .ww-shot-tip{opacity:1}
37
+ .ww-pending{height:0!important;overflow:hidden}
38
+ .ww-shot .ww-img{transition:opacity .18s}
39
+ /* 标题行右侧的一排工具: 同高同描边, 卡片 hover 时整组淡入 */
40
+ .ww-tools{flex:none;display:flex;align-items:center;gap:6px;opacity:0;transition:opacity .15s}
41
+ .ww-card:hover .ww-tools,.ww-tools:focus-within{opacity:1}
42
+ .ww-tool{flex:none;display:inline-flex;align-items:center;gap:6px;height:22px;padding:0 8px;
43
+ box-sizing:border-box;font:inherit;font-size:11px;line-height:1;border-radius:6px;
44
+ color:var(--dsw-alias-label-tertiary,#8b93a7);background:none;
45
+ border:1px solid var(--dsw-alias-border-l3,rgba(128,140,170,.28));
46
+ transition:color .15s,border-color .15s,background .15s}
47
+ button.ww-tool{cursor:pointer}
48
+ button.ww-tool:hover{color:var(--dsw-alias-label-primary,#e8eaf0);
49
+ border-color:var(--dsw-alias-state-business-primary,#4176e6);
50
+ background:var(--dsw-alias-interactive-bg-hover,rgba(128,140,170,.12))}
51
+ button.ww-tool[disabled]{cursor:progress}
52
+ .ww-tool input[type=range]{width:78px;height:2px;appearance:none;border-radius:999px;cursor:ew-resize;
53
+ background:var(--dsw-alias-border-l3,rgba(128,140,170,.45))}
54
+ .ww-tool input[type=range]::-webkit-slider-thumb{appearance:none;width:10px;height:10px;border-radius:50%;
55
+ cursor:ew-resize;background:var(--dsw-alias-state-business-primary,#4176e6);
56
+ box-shadow:0 0 0 2px var(--dsw-alias-bg-layer-1,#1b1e24)}
57
+ .ww-tool input[type=range]::-moz-range-thumb{width:10px;height:10px;border:0;border-radius:50%;
58
+ cursor:ew-resize;background:var(--dsw-alias-state-business-primary,#4176e6)}
59
+ .ww-fit-val{min-width:30px;text-align:right;font-variant-numeric:tabular-nums}
60
+ /* 缩放走 transform: iframe 内部不重排, 拖动时不闪。
61
+ iframe 要保持整幅排版宽度, 不能被外层的 max-width:100% 夹窄, 否则右半边会被裁掉 */
62
+ .ww-frame-scale{overflow:hidden}
63
+ .ww-frame-scale .ww-frame{max-width:none;transform-origin:0 0}
64
+ .ww-lightbox{position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.86);
65
+ display:flex;align-items:center;justify-content:center;overflow:auto;cursor:zoom-out}
66
+ .ww-lightbox img{display:block;margin:auto}
67
+ .ww-lightbox.fit img{max-width:96vw;max-height:94vh;object-fit:contain}
68
+ .ww-lightbox.raw{align-items:flex-start;justify-content:flex-start;cursor:zoom-in}
69
+ .ww-lightbox.raw img{max-width:none;max-height:none}
70
+ .ww-lb-bar{position:fixed;top:12px;right:16px;display:flex;gap:8px;z-index:10000}
71
+ .ww-lb-btn{font-size:12px;padding:5px 12px;border-radius:999px;cursor:pointer;color:#fff;
72
+ border:1px solid rgba(255,255,255,.3);background:rgba(0,0,0,.5)}
73
+ .ww-lb-btn:hover{background:rgba(255,255,255,.16)}
74
+ .ww-frame-wrap{width:100%;line-height:0}
75
+ .ww-frame{max-width:100%;border:0;background:transparent;display:block;overflow:hidden;
76
+ border-radius:8px;border:1px solid var(--dsw-alias-border-l1,#3a3a3a);color-scheme:normal}
77
+ .ww-btn{font-size:12px;padding:4px 10px;border-radius:999px;cursor:pointer;
78
+ border:1px solid var(--dsw-alias-border-l1,#444);background:transparent;color:inherit}
79
+ .ww-btn:hover{background:var(--dsw-alias-interactive-bg-hover,#333)}
80
+ .ww-btn[disabled]{opacity:.5;cursor:progress}
81
+ .ww-quick{display:flex;flex-direction:column;gap:7px;padding-top:2px}
82
+ .ww-quick-head{display:flex;align-items:center;gap:6px;font-size:11px;line-height:1;
83
+ letter-spacing:.02em;color:var(--dsw-alias-label-tertiary,#8b93a7)}
84
+ .ww-quick-dot{width:5px;height:5px;flex:none;border-radius:50%;
85
+ background:var(--dsw-alias-state-business-primary,#4176e6);
86
+ box-shadow:0 0 0 3px rgba(65,118,230,.18)}
87
+ .ww-btns{display:flex;flex-wrap:wrap;gap:8px}
88
+ .ww-chip{display:inline-flex;align-items:center;gap:6px;max-width:100%;box-sizing:border-box;
89
+ font:inherit;font-size:12.5px;line-height:18px;padding:6px 13px 6px 10px;border-radius:999px;
90
+ color:var(--dsw-alias-label-primary,#e8eaf0);cursor:pointer;
91
+ border:1px solid var(--dsw-alias-border-l3,rgba(128,140,170,.28));
92
+ background:var(--dsw-alias-interactive-bg-hover,rgba(128,140,170,.12));
93
+ transition:background .15s,border-color .15s,box-shadow .15s,transform .12s}
94
+ .ww-chip:hover{transform:translateY(-1px);
95
+ border-color:var(--dsw-alias-state-business-primary,#4176e6);
96
+ background:var(--dsw-alias-interactive-bg-active,rgba(128,140,170,.2));
97
+ box-shadow:0 3px 10px rgba(15,23,42,.16)}
98
+ .ww-chip:active{transform:translateY(0) scale(.97);box-shadow:none}
99
+ .ww-chip:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4176e6);
100
+ outline-offset:2px}
101
+ .ww-chip-mark{flex:none;width:11px;text-align:center;font-family:ui-monospace,monospace;
102
+ font-size:11px;color:var(--dsw-alias-label-tertiary,#8b93a7)}
103
+ .ww-chip:hover .ww-chip-mark{color:var(--dsw-alias-state-business-primary,#4176e6)}
104
+ .ww-chip-text{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
105
+ .ww-chip[disabled]{opacity:.45;cursor:default}
106
+ .ww-chip[disabled]:hover{transform:none;box-shadow:none;
107
+ border-color:var(--dsw-alias-border-l3,rgba(128,140,170,.28));
108
+ background:var(--dsw-alias-interactive-bg-hover,rgba(128,140,170,.12))}
109
+ .ww-chip[disabled]:hover .ww-chip-mark{color:var(--dsw-alias-label-tertiary,#8b93a7)}
110
+ .ww-chip.busy{opacity:1;cursor:progress;
111
+ border-color:var(--dsw-alias-state-business-primary,#4176e6)}
112
+ .ww-chip.busy:hover{border-color:var(--dsw-alias-state-business-primary,#4176e6)}
113
+ .ww-spin{width:11px;height:11px;flex:none;box-sizing:border-box;border-radius:50%;
114
+ border:1.5px solid var(--dsw-alias-state-business-primary,#4176e6);border-top-color:transparent;
115
+ animation:ww-spin .7s linear infinite}
116
+ @keyframes ww-spin{to{transform:rotate(360deg)}}
117
+ @media (prefers-reduced-motion:reduce){
118
+ .ww-chip{transition:none}
119
+ .ww-chip:hover{transform:none}
120
+ .ww-spin{animation-duration:1.8s}
121
+ }
122
+ .ww-node{border-left:2px solid var(--dsw-alias-border-l1,#444);padding-left:10px;
123
+ display:flex;flex-direction:column;gap:8px}
124
+ .ww-echo{margin:6px 0;display:flex;flex-direction:column;gap:5px}
125
+ .ww-echo-cap{font-size:11px;color:var(--dsw-alias-label-tertiary,#8b93a7)}
126
+ .ww-echo-img{max-width:320px;max-height:260px;border-radius:8px;display:block;cursor:zoom-in;
127
+ border:1px solid var(--dsw-alias-border-l1,#3a3a3a)}
128
+ .ww-err{color:#e5534b;font-size:12px}
129
+ .ww-hint{font-size:11px;opacity:.55}
130
+ .ww-stale{display:flex;align-items:center;gap:10px}
131
+ .ww-set{display:flex;flex-direction:column;gap:22px;padding-bottom:8px}
132
+ .ww-set-group{display:flex;flex-direction:column;gap:2px}
133
+ .ww-set-title{font-size:14px;font-weight:600;padding:0 0 6px;
134
+ border-bottom:1px solid var(--dsw-alias-border-l1,#444)}
135
+ .ww-set-row{display:flex;align-items:flex-start;gap:12px;padding:10px 0;
136
+ border-bottom:1px solid var(--dsw-alias-border-l1,#3a3a3a)}
137
+ .ww-set-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}
138
+ .ww-set-label{font-size:13px;line-height:1.4}
139
+ .ww-set-desc{font-size:11px;opacity:.55;line-height:1.5;white-space:pre-wrap}
140
+ .ww-set-ctl{flex:none;width:240px;max-width:45%;display:flex;flex-direction:column;gap:4px;align-items:flex-end}
141
+ .ww-set-input,.ww-set-area{width:100%;box-sizing:border-box;font:inherit;font-size:12px;color:inherit;
142
+ padding:5px 8px;border-radius:8px;border:1px solid var(--dsw-alias-border-l3,#444);
143
+ background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))}
144
+ .ww-set-area{min-height:66px;font-family:ui-monospace,monospace;resize:vertical}
145
+ .ww-set-switch{width:38px;height:22px;flex:none;border-radius:999px;cursor:pointer;padding:0;
146
+ border:1px solid var(--dsw-alias-border-l3,#444);background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12));
147
+ display:flex;align-items:center}
148
+ .ww-set-switch.on{background:#4c8dff;border-color:#4c8dff}
149
+ .ww-set-knob{width:16px;height:16px;border-radius:50%;background:#ddd;margin-left:2px;transition:margin .15s}
150
+ .ww-set-switch.on .ww-set-knob{margin-left:18px;background:#fff}
151
+ .ww-set-file{display:flex;align-items:center;gap:8px;width:100%;justify-content:flex-end}
152
+ .ww-set-file .name{flex:1;min-width:0;font-size:11px;opacity:.6;overflow:hidden;
153
+ white-space:nowrap;text-overflow:ellipsis;direction:rtl;text-align:left}
154
+ .ww-up{flex:none;font-size:12px;padding:5px 12px;border-radius:8px;cursor:pointer;
155
+ border:1px solid var(--dsw-alias-border-l3,#444);background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))}
156
+ .ww-up:hover{border-color:var(--dsw-alias-state-business-primary,#4176e6)}
157
+ .ww-up input{display:none}
158
+ .ww-set-state{font-size:11px;opacity:.6}
159
+ .ww-set-state.ok{color:#3fb950;opacity:1}
160
+ .ww-set-state.err{color:#e5534b;opacity:1}
161
+ .ww-set-divider{margin:6px 0;border-top:1px dashed var(--dsw-alias-border-l1,#444);
162
+ font-size:11px;opacity:.55;padding-top:6px}
163
+ `
164
+
165
+ // 宽度上限(占会话区百分比), 取自服务配置; 图片和 HTML 卡片走同一套
166
+ const LIMIT_KEY = 'ww-media-limits'
167
+ let imgLimits = { portrait: 50, landscape: 75 }
168
+ try {
169
+ const cached = JSON.parse(localStorage.getItem(LIMIT_KEY) || 'null')
170
+ if (cached && cached.portrait) imgLimits = cached
171
+ } catch (e) {
172
+ // 缓存坏了就用默认值, 等接口回来再纠正
173
+ }
174
+ fetch(API + '/api/config/media')
175
+ .then((r) => (r.ok ? r.json() : null))
176
+ .then((d) => {
177
+ if (d && d.image_portrait_max_percent) {
178
+ imgLimits = {
179
+ portrait: d.image_portrait_max_percent,
180
+ landscape: d.image_landscape_max_percent || 100,
181
+ }
182
+ try {
183
+ localStorage.setItem(LIMIT_KEY, JSON.stringify(imgLimits))
184
+ } catch (e) {
185
+ // 存不下无所谓
186
+ }
187
+ }
188
+ })
189
+ .catch(() => {})
190
+
191
+ // 同一条命令每次出的卡片尺寸基本不变: 记下来, 下次占位就按它留白, 不再从默认框跳一下
192
+ const SIZE_KEY = 'ww-card-size'
193
+ const SIZE_KEEP = 200
194
+ let sizeMemo = null
195
+
196
+ function allSizes() {
197
+ if (sizeMemo) return sizeMemo
198
+ try {
199
+ sizeMemo = JSON.parse(localStorage.getItem(SIZE_KEY) || '{}')
200
+ } catch (e) {
201
+ sizeMemo = {}
202
+ }
203
+ return sizeMemo
204
+ }
205
+
206
+ function recallSize(key) {
207
+ return key ? allSizes()[key] || null : null
208
+ }
209
+
210
+ function rememberSize(key, value) {
211
+ if (!key) return
212
+ const all = allSizes()
213
+ const old = all[key]
214
+ if (old && JSON.stringify(old) === JSON.stringify(value)) return
215
+ all[key] = value
216
+ const keys = Object.keys(all)
217
+ if (keys.length > SIZE_KEEP) delete all[keys[0]]
218
+ try {
219
+ localStorage.setItem(SIZE_KEY, JSON.stringify(all))
220
+ } catch (e) {
221
+ // 存不下就只是下次还会跳一下
222
+ }
223
+ }
224
+
225
+ // 高>宽算竖, 其余算横; 卡片和图片用同一判定
226
+ function widthCap(width, height) {
227
+ return height > width ? imgLimits.portrait : imgLimits.landscape
228
+ }
229
+
230
+ // 单张卡就地调宽; 数值就是它在会话区里占的宽度百分比。不写回配置, 只影响这一张
231
+ function FitBar({ value, onChange }) {
232
+ return React.createElement(
233
+ 'div',
234
+ { className: 'ww-tool ww-fit-bar', onClick: (e) => e.stopPropagation() },
235
+ React.createElement('input', {
236
+ type: 'range',
237
+ min: 25,
238
+ max: 100,
239
+ step: 5,
240
+ value,
241
+ title: '显示宽度',
242
+ 'aria-label': '显示宽度',
243
+ onChange: (e) => onChange(Number(e.target.value)),
244
+ }),
245
+ React.createElement('span', { className: 'ww-fit-val' }, Math.round(value) + '%'),
246
+ )
247
+ }
248
+
249
+ function download(url, filename) {
250
+ const a = document.createElement('a')
251
+ a.href = url
252
+ a.download = filename
253
+ a.rel = 'noreferrer'
254
+ document.body.appendChild(a)
255
+ a.click()
256
+ a.remove()
257
+ }
258
+
259
+ function safeName(text) {
260
+ return (String(text || 'waves').replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 40)) || 'waves'
261
+ }
262
+
263
+ // 存一份到本地: HTML 卡片存服务端按原始宽度渲的截图, 图片存原图
264
+ function SaveButton({ segments, label }) {
265
+ const [busy, setBusy] = React.useState(false)
266
+ const target = React.useMemo(() => firstMedia(segments), [segments])
267
+ if (!target) return null
268
+
269
+ const save = async () => {
270
+ if (busy) return
271
+ setBusy(true)
272
+ const base = safeName(label)
273
+ try {
274
+ if (target.type === 'image') {
275
+ download(API + target.url, base + target.url.slice(target.url.lastIndexOf('.')))
276
+ } else {
277
+ const name = target.url.slice(target.url.lastIndexOf('/') + 1)
278
+ // 截图由服务端按原始设计宽度渲染, 首次要等一会儿
279
+ const res = await fetch(API + '/api/media/' + name + '/png')
280
+ if (!res.ok) throw new Error('截图失败 ' + res.status)
281
+ const objectUrl = URL.createObjectURL(await res.blob())
282
+ download(objectUrl, base + '.png')
283
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 10000)
284
+ }
285
+ } catch (e) {
286
+ console.error('[waves] 保存失败', e)
287
+ } finally {
288
+ setBusy(false)
289
+ }
290
+ }
291
+
292
+ return React.createElement(
293
+ 'button',
294
+ {
295
+ type: 'button',
296
+ className: 'ww-tool',
297
+ disabled: busy,
298
+ title: target.type === 'image' ? '保存图片' : '保存为图片',
299
+ onClick: save,
300
+ },
301
+ busy ? '保存中…' : '保存',
302
+ )
303
+ }
304
+
305
+ // 一张卡里通常只有一个图/HTML, 保存按第一个来
306
+ function firstMedia(segments) {
307
+ for (const seg of segments || []) {
308
+ if (!seg) continue
309
+ if (seg.type === 'image' || seg.type === 'html') return seg
310
+ if (seg.type === 'node') {
311
+ const found = firstMedia(seg.items)
312
+ if (found) return found
313
+ }
314
+ }
315
+ return null
316
+ }
317
+
318
+ function CardTools({ segments, label, value, onChange }) {
319
+ if (!hasMedia(segments)) return null
320
+ return React.createElement(
321
+ 'div',
322
+ { className: 'ww-tools' },
323
+ React.createElement(FitBar, { value, onChange }),
324
+ React.createElement(
325
+ 'button',
326
+ {
327
+ type: 'button',
328
+ className: 'ww-tool',
329
+ title: '恢复默认宽度',
330
+ onClick: () => onChange(null),
331
+ },
332
+ '↺',
333
+ ),
334
+ React.createElement(SaveButton, { segments, label }),
335
+ )
336
+ }
337
+
338
+ // 卡片里有图/HTML 才给滑块
339
+ function hasMedia(segments) {
340
+ return (segments || []).some(
341
+ (seg) =>
342
+ seg &&
343
+ (seg.type === 'image' || seg.type === 'html' || (seg.type === 'node' && hasMedia(seg.items))),
344
+ )
345
+ }
346
+
347
+ // 剥图提示在聊天区只剩路径文字; 识别到附件路径就把图从服务端取回, 贴在该段下方
348
+ const ECHO_RE = /attachments\/v1\/objects\/[0-9a-f]{2}\/([0-9a-f]{64})/g
349
+
350
+ function echoImages() {
351
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT)
352
+ const targets = []
353
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
354
+ if (n.nodeValue && n.nodeValue.indexOf('attachments/v1/objects/') !== -1) targets.push(n)
355
+ }
356
+ for (const node of targets) {
357
+ const host = node.parentElement
358
+ if (!host) continue
359
+ const hashes = []
360
+ let m
361
+ ECHO_RE.lastIndex = 0
362
+ while ((m = ECHO_RE.exec(node.nodeValue))) {
363
+ if (hashes.indexOf(m[1]) === -1) hashes.push(m[1])
364
+ }
365
+ if (!hashes.length) continue
366
+ const block = host.closest('p,dd,li,div') || host
367
+ if (block.getAttribute('data-ww-echo') === '1') continue
368
+ const next = block.nextElementSibling
369
+ if (next && next.classList && next.classList.contains('ww-echo')) continue
370
+ block.setAttribute('data-ww-echo', '1')
371
+ const wrap = document.createElement('div')
372
+ wrap.className = 'ww-echo'
373
+ const cap = document.createElement('div')
374
+ cap.className = 'ww-echo-cap'
375
+ cap.textContent = hashes.length > 1 ? `已随消息附 ${hashes.length} 张图片` : '已随消息附图'
376
+ wrap.appendChild(cap)
377
+ for (const hex of hashes) {
378
+ const img = document.createElement('img')
379
+ img.className = 'ww-echo-img'
380
+ img.src = API + '/api/attachment/' + hex
381
+ img.title = '点击查看原图'
382
+ img.onclick = () => window.open(img.src, '_blank')
383
+ img.onerror = () => img.remove()
384
+ wrap.appendChild(img)
385
+ }
386
+ // 技术文本(路径/提示)整段收起, 只留图和说明
387
+ if (node.nodeValue.indexOf('已暂存') !== -1 || node.nodeValue.indexOf('随消息附了') !== -1) {
388
+ block.style.display = 'none'
389
+ }
390
+ block.insertAdjacentElement('afterend', wrap)
391
+ }
392
+ }
393
+
394
+ let echoTimer = null
395
+ function installImageEcho() {
396
+ const run = () => {
397
+ try {
398
+ echoImages()
399
+ } catch (e) {
400
+ // DOM 形态变了就静默放弃, 不影响页面
401
+ }
402
+ }
403
+ new MutationObserver(() => {
404
+ clearTimeout(echoTimer)
405
+ echoTimer = setTimeout(run, 300)
406
+ }).observe(document.body, { childList: true, subtree: true })
407
+ run()
408
+ }
409
+
410
+ function ensureStyles() {
411
+ let el = document.getElementById('ww-plugin-style')
412
+ if (!el) {
413
+ el = document.createElement('style')
414
+ el.id = 'ww-plugin-style'
415
+ el.setAttribute('data-plugin', '@sophiacyl/dsh-plugin-waves')
416
+ document.head.appendChild(el)
417
+ }
418
+ el.textContent = CSS
419
+ }
420
+
421
+ async function runCommand(command, opts) {
422
+ const res = await fetch(API + '/api/chat', {
423
+ method: 'POST',
424
+ headers: { 'content-type': 'application/json' },
425
+ body: JSON.stringify({
426
+ command,
427
+ scene: (opts && opts.scene) || 'group',
428
+ session_id: (opts && opts.sessionId) || 'dsh-ui',
429
+ }),
430
+ })
431
+ if (!res.ok) throw new Error('请求失败 ' + res.status)
432
+ return res.json()
433
+ }
434
+
435
+ function HtmlFrame({ url, onCommand, zoom, onCap, sizeKey }) {
436
+ const wrapRef = React.useRef(null)
437
+ const frameRef = React.useRef(null)
438
+ const [dims, setDims] = React.useState(() => recallSize(sizeKey))
439
+ const cap = dims ? widthCap(dims.w, dims.h) : 100
440
+ React.useEffect(() => {
441
+ if (dims && onCap) onCap(cap)
442
+ }, [dims, cap, onCap])
443
+
444
+ // 模板是固定宽度的: 告诉卡片可用宽度, 它等比缩放后回报精确尺寸, iframe 按内容定尺寸
445
+ const pushWidth = React.useCallback(() => {
446
+ const frame = frameRef.current
447
+ const wrap = wrapRef.current
448
+ if (!frame || !wrap || !frame.contentWindow) return
449
+ const width = wrap.clientWidth
450
+ if (width > 0) {
451
+ frame.contentWindow.postMessage({ source: 'ww-host', kind: 'width', width }, '*')
452
+ }
453
+ }, [])
454
+
455
+ React.useEffect(() => {
456
+ function onMessage(e) {
457
+ const data = e.data
458
+ if (!data || typeof data !== 'object' || data.source !== 'ww-card') return
459
+ const frame = frameRef.current
460
+ if (frame && e.source !== frame.contentWindow) return
461
+ if (data.kind === 'size' && typeof data.height === 'number') {
462
+ const next = {
463
+ w: Math.max(0, Math.min(data.width || 0, 4000)),
464
+ h: Math.min(Math.max(data.height, 40), 20000),
465
+ }
466
+ rememberSize(sizeKey, next)
467
+ // 只在尺寸真的变了才 setState, 避免桥的重复上报引起抖动
468
+ setDims((prev) =>
469
+ prev && prev.w === next.w && prev.h === next.h ? prev : next,
470
+ )
471
+ } else if (data.kind === 'ready') {
472
+ pushWidth()
473
+ } else if (data.kind === 'command' && data.command) {
474
+ onCommand(data.command)
475
+ }
476
+ }
477
+ window.addEventListener('message', onMessage)
478
+ return () => window.removeEventListener('message', onMessage)
479
+ }, [onCommand, pushWidth, sizeKey])
480
+
481
+ React.useEffect(() => {
482
+ const wrap = wrapRef.current
483
+ if (!wrap || !window.ResizeObserver) return
484
+ const observer = new ResizeObserver(pushWidth)
485
+ observer.observe(wrap)
486
+ return () => observer.disconnect()
487
+ }, [pushWidth])
488
+
489
+ // src 只认第一次: 切后台再回来时不重新加载, 避免重渲染闪烁
490
+ const srcRef = React.useRef(API + url)
491
+ // 卡片始终按整幅可用宽度排版(不重排、更清晰), 只改显示倍率; 百分比就是最终占宽
492
+ const w = dims && dims.w > 0 ? dims.w : 0
493
+ const h = dims ? dims.h : 320
494
+ const percent = zoom === null || zoom === undefined ? cap : zoom
495
+ const k = dims ? percent / 100 : 1
496
+ return React.createElement(
497
+ 'div',
498
+ { className: 'ww-frame-wrap', ref: wrapRef },
499
+ React.createElement(
500
+ 'div',
501
+ {
502
+ className: 'ww-frame-scale' + (dims ? '' : ' ww-pending'),
503
+ style: dims
504
+ ? { width: Math.round(w * k) + 'px', height: Math.round(h * k) + 'px' }
505
+ : { width: '100%' },
506
+ },
507
+ React.createElement('iframe', {
508
+ ref: frameRef,
509
+ className: 'ww-frame',
510
+ src: srcRef.current,
511
+ scrolling: 'no',
512
+ style: {
513
+ height: h + 'px',
514
+ width: w ? w + 'px' : '100%',
515
+ transform: k === 1 ? undefined : 'scale(' + k + ')',
516
+ },
517
+ sandbox: 'allow-scripts allow-same-origin',
518
+ onLoad: pushWidth,
519
+ }),
520
+ ),
521
+ )
522
+ }
523
+
524
+ function Lightbox({ src, onClose }) {
525
+ const [raw, setRaw] = React.useState(false)
526
+
527
+ React.useEffect(() => {
528
+ function onKey(e) {
529
+ if (e.key === 'Escape') onClose()
530
+ }
531
+ document.addEventListener('keydown', onKey)
532
+ const prev = document.body.style.overflow
533
+ document.body.style.overflow = 'hidden'
534
+ return () => {
535
+ document.removeEventListener('keydown', onKey)
536
+ document.body.style.overflow = prev
537
+ }
538
+ }, [onClose])
539
+
540
+ return React.createElement(
541
+ 'div',
542
+ {
543
+ className: 'ww-lightbox' + (raw ? ' raw' : ' fit'),
544
+ onClick: onClose,
545
+ },
546
+ React.createElement(
547
+ 'div',
548
+ { className: 'ww-lb-bar', onClick: (e) => e.stopPropagation() },
549
+ React.createElement(
550
+ 'button',
551
+ { className: 'ww-lb-btn', onClick: () => setRaw((v) => !v) },
552
+ raw ? '适应窗口' : '原始大小',
553
+ ),
554
+ React.createElement(
555
+ 'a',
556
+ { className: 'ww-lb-btn', href: src, target: '_blank', rel: 'noreferrer' },
557
+ '新窗口',
558
+ ),
559
+ React.createElement('button', { className: 'ww-lb-btn', onClick: onClose }, '关闭'),
560
+ ),
561
+ React.createElement('img', {
562
+ src,
563
+ onClick: (e) => {
564
+ e.stopPropagation()
565
+ setRaw((v) => !v)
566
+ },
567
+ }),
568
+ )
569
+ }
570
+
571
+ function ImageSegment({ url, zoom, onCap, sizeKey }) {
572
+ const src = url.startsWith('/api/') ? API + url : url
573
+ const [open, setOpen] = React.useState(false)
574
+ const [box, setBox] = React.useState(() => recallSize(sizeKey))
575
+ // 加载完才知道长宽: 用 aspect-ratio 固定占位, 避免图到位时页面跳动
576
+ const onLoad = (e) => {
577
+ const img = e.target
578
+ const next = { cap: widthCap(img.naturalWidth, img.naturalHeight),
579
+ ratio: img.naturalWidth + ' / ' + img.naturalHeight }
580
+ rememberSize(sizeKey, next)
581
+ setBox(next)
582
+ if (onCap) onCap(next.cap)
583
+ }
584
+ const percent = zoom === null || zoom === undefined ? (box ? box.cap : 100) : zoom
585
+ return React.createElement(
586
+ 'div',
587
+ {
588
+ className: 'ww-shot' + (box ? '' : ' ww-pending'),
589
+ style: box ? { maxWidth: percent + '%', aspectRatio: box.ratio } : undefined,
590
+ },
591
+ React.createElement('img', {
592
+ className: 'ww-img',
593
+ src,
594
+ onLoad,
595
+ onClick: () => setOpen(true),
596
+ }),
597
+ React.createElement('span', { className: 'ww-shot-tip' }, '点击查看大图'),
598
+ open && React.createElement(Lightbox, { src, onClose: () => setOpen(false) }),
599
+ )
600
+ }
601
+
602
+ // 快捷指令: 一排胶囊, 点了当场转圈, 期间其余按钮压暗避免连点
603
+ function ButtonsSegment({ rows, onCommand }) {
604
+ const [busy, setBusy] = React.useState(-1)
605
+ const alive = React.useRef(true)
606
+ React.useEffect(() => () => {
607
+ alive.current = false
608
+ }, [])
609
+
610
+ const items = []
611
+ ;(rows || []).forEach((row) => {
612
+ const list = Array.isArray(row) ? row : [row]
613
+ list.forEach((btn) => {
614
+ if (!btn) return
615
+ const command = (btn.prefix || '') + (btn.data || btn.text || '')
616
+ if (command) items.push({ label: btn.text || btn.data, command })
617
+ })
618
+ })
619
+ if (!items.length) return null
620
+
621
+ const fire = (item, index) => {
622
+ if (busy >= 0) return
623
+ setBusy(index)
624
+ Promise.resolve(onCommand(item.command)).then(
625
+ () => alive.current && setBusy(-1),
626
+ () => alive.current && setBusy(-1),
627
+ )
628
+ }
629
+
630
+ return React.createElement(
631
+ 'div',
632
+ { className: 'ww-quick' },
633
+ React.createElement(
634
+ 'div',
635
+ { className: 'ww-quick-head' },
636
+ React.createElement('span', { className: 'ww-quick-dot' }),
637
+ React.createElement('span', null, '快捷指令'),
638
+ ),
639
+ React.createElement(
640
+ 'div',
641
+ { className: 'ww-btns' },
642
+ items.map((item, i) =>
643
+ React.createElement(
644
+ 'button',
645
+ {
646
+ key: i,
647
+ type: 'button',
648
+ className: 'ww-chip' + (busy === i ? ' busy' : ''),
649
+ disabled: busy >= 0,
650
+ title: item.command,
651
+ onClick: () => fire(item, i),
652
+ },
653
+ busy === i
654
+ ? React.createElement('span', { className: 'ww-spin' })
655
+ : React.createElement('span', { className: 'ww-chip-mark' }, '/'),
656
+ React.createElement('span', { className: 'ww-chip-text' }, item.label),
657
+ ),
658
+ ),
659
+ ),
660
+ )
661
+ }
662
+
663
+ function Segment({ seg, onCommand, zoom, onCap, sizeKey }) {
664
+ if (!seg) return null
665
+ switch (seg.type) {
666
+ case 'text':
667
+ return React.createElement('div', { className: 'ww-text' }, seg.text)
668
+ case 'image':
669
+ return React.createElement(ImageSegment, { url: seg.url, zoom, onCap, sizeKey })
670
+ case 'html':
671
+ return React.createElement(HtmlFrame, { url: seg.url, onCommand, zoom, onCap, sizeKey })
672
+ case 'node':
673
+ return React.createElement(
674
+ 'div',
675
+ { className: 'ww-node' },
676
+ (seg.items || []).map((child, i) =>
677
+ React.createElement(Segment, {
678
+ key: i, seg: child, onCommand, zoom, onCap,
679
+ sizeKey: sizeKey ? sizeKey + '.' + i : '',
680
+ }),
681
+ ),
682
+ )
683
+ case 'file':
684
+ return React.createElement(
685
+ 'a',
686
+ { href: API + seg.url, download: seg.name, className: 'ww-text' },
687
+ '下载文件: ' + seg.name,
688
+ )
689
+ case 'buttons':
690
+ return React.createElement(ButtonsSegment, { rows: seg.buttons, onCommand })
691
+ default:
692
+ return null
693
+ }
694
+ }
695
+
696
+ // 卡片里点角色/按钮时再下一条命令, 结果追加在下方
697
+ function useRunner(sessionId) {
698
+ const [extras, setExtras] = React.useState([])
699
+ const [pending, setPending] = React.useState(null)
700
+ const [error, setError] = React.useState('')
701
+
702
+ // 返回 promise, 让触发方(快捷指令胶囊)能显示自己的 loading
703
+ const onCommand = React.useCallback((command) => {
704
+ if (!command) return Promise.resolve()
705
+ setPending(command)
706
+ setError('')
707
+ return runCommand(command, { sessionId })
708
+ .then((data) => {
709
+ const segs = (data && data.segments) || []
710
+ setExtras((prev) => prev.concat([{ command, segments: segs }]))
711
+ })
712
+ .catch((e) => setError(String(e.message || e)))
713
+ .finally(() => setPending(null))
714
+ }, [sessionId])
715
+
716
+ return { extras, pending, error, onCommand }
717
+ }
718
+
719
+ // 点出来的结果追加在下方; 新卡到位后把视口带过去, 不用手动往下翻
720
+ function ExtraCard({ extra, onCommand, fresh }) {
721
+ const ref = React.useRef(null)
722
+ const [zoom, setZoom] = React.useState(null)
723
+ const [cap, setCap] = React.useState(100)
724
+ React.useEffect(() => {
725
+ if (!fresh || !ref.current) return
726
+ const id = requestAnimationFrame(() =>
727
+ ref.current &&
728
+ ref.current.scrollIntoView({ block: 'start', behavior: 'smooth' }),
729
+ )
730
+ return () => cancelAnimationFrame(id)
731
+ }, [fresh])
732
+
733
+ return React.createElement(
734
+ 'div',
735
+ { className: 'ww-card', ref },
736
+ React.createElement(
737
+ 'div',
738
+ { className: 'ww-head' },
739
+ React.createElement(
740
+ 'span',
741
+ { className: 'ww-title' },
742
+ React.createElement('span', null, 'XWUID'),
743
+ ),
744
+ React.createElement('span', { className: 'ww-cmd' }, extra.command),
745
+ React.createElement('span', { className: 'ww-head-gap' }),
746
+ React.createElement(CardTools, {
747
+ segments: extra.segments,
748
+ label: extra.command,
749
+ value: zoom === null ? cap : zoom,
750
+ onChange: setZoom,
751
+ }),
752
+ ),
753
+ extra.segments.map((seg, j) =>
754
+ React.createElement(Segment, {
755
+ key: j, seg, onCommand, zoom, onCap: setCap,
756
+ sizeKey: extra.command + '#' + j,
757
+ }),
758
+ ),
759
+ )
760
+ }
761
+
762
+ function Extras({ extras, pending, error, onCommand }) {
763
+ return React.createElement(
764
+ React.Fragment,
765
+ null,
766
+ extras.map((extra, i) =>
767
+ React.createElement(ExtraCard, {
768
+ key: 'e' + i,
769
+ extra,
770
+ onCommand,
771
+ fresh: i === extras.length - 1,
772
+ }),
773
+ ),
774
+ pending && React.createElement('div', { className: 'ww-hint' }, '正在执行 ' + pending),
775
+ error && React.createElement('div', { className: 'ww-err' }, error),
776
+ )
777
+ }
778
+
779
+ // 图文没能还原(产物被清理/结果太老)时的兜底: 一行提示加重跑, 不铺裸链接
780
+ function StaleResult({ text, onRerun }) {
781
+ const [busy, setBusy] = React.useState(false)
782
+ const rich = /已出图$/.test(String(text || ''))
783
+ const run = () => {
784
+ setBusy(true)
785
+ Promise.resolve(onRerun()).then(
786
+ () => setBusy(false),
787
+ () => setBusy(false),
788
+ )
789
+ }
790
+ if (!rich) return React.createElement('div', { className: 'ww-text' }, text)
791
+ return React.createElement(
792
+ 'div',
793
+ { className: 'ww-stale' },
794
+ React.createElement('span', { className: 'ww-hint' }, '图文结果已不在缓存里'),
795
+ React.createElement(
796
+ 'button',
797
+ { type: 'button', className: 'ww-chip', disabled: busy, onClick: run },
798
+ busy
799
+ ? React.createElement('span', { className: 'ww-spin' })
800
+ : React.createElement('span', { className: 'ww-chip-mark' }, '↻'),
801
+ React.createElement('span', { className: 'ww-chip-text' }, '重新执行'),
802
+ ),
803
+ )
804
+ }
805
+
806
+ function WavesCard(props) {
807
+ ensureStyles()
808
+ const block = props.block || {}
809
+ // 真参数是 JSON 串: 已结算在 block.call.argsRaw, 执行中在 block.argsRaw
810
+ const rawArgs = (block.call && block.call.argsRaw) || block.argsRaw || ''
811
+ let args = block.arguments || block.args || {}
812
+ if (rawArgs) {
813
+ try {
814
+ args = JSON.parse(rawArgs) || args
815
+ } catch (e) {
816
+ // 半截 JSON(流式中)就先用空参数
817
+ }
818
+ }
819
+ const meta =
820
+ (block.result && (block.result.meta || block.result.presentationMeta)) ||
821
+ block.meta ||
822
+ {}
823
+ // waves 工具带 command; waves_panel 工具带 char
824
+ const label = String(args.command || (args.char ? args.char + ' 自定义面板' : ''))
825
+ const initial = Array.isArray(meta.segments) ? meta.segments : []
826
+ const runner = useRunner(props.sessionId)
827
+ const [zoom, setZoom] = React.useState(null)
828
+ const [cap, setCap] = React.useState(100)
829
+
830
+ return React.createElement(
831
+ 'div',
832
+ { className: 'ww-card' },
833
+ React.createElement(
834
+ 'div',
835
+ { className: 'ww-head' },
836
+ React.createElement(
837
+ 'span',
838
+ { className: 'ww-title' },
839
+ React.createElement('span', null, 'XWUID'),
840
+ ),
841
+ React.createElement('span', { className: 'ww-cmd' }, label),
842
+ React.createElement('span', { className: 'ww-head-gap' }),
843
+ React.createElement(CardTools, {
844
+ segments: initial,
845
+ label,
846
+ value: zoom === null ? cap : zoom,
847
+ onChange: setZoom,
848
+ }),
849
+ ),
850
+ initial.map((seg, i) =>
851
+ React.createElement(Segment, {
852
+ key: 'i' + i, seg, onCommand: runner.onCommand, zoom, onCap: setCap,
853
+ sizeKey: label + '#' + i,
854
+ }),
855
+ ),
856
+ React.createElement(Extras, runner),
857
+ !initial.length &&
858
+ !runner.extras.length &&
859
+ !runner.pending &&
860
+ React.createElement('div', { className: 'ww-hint' }, '无展示内容'),
861
+ )
862
+ }
863
+
864
+ // /ww 命令的行渲染: 结果图文按 commandId 从 host 内存取回, 取不到时退回文本
865
+ function WavesCommandCard(props) {
866
+ ensureStyles()
867
+ const node = props.node || {}
868
+ const outcome = node.outcome || null
869
+ const commandId = node.commandId
870
+ const settled = outcome ? outcome.kind : ''
871
+ const [result, setResult] = React.useState(null)
872
+ const [loaded, setLoaded] = React.useState(false)
873
+ const [zoom, setZoom] = React.useState(null)
874
+ const [cap, setCap] = React.useState(100)
875
+ const runner = useRunner(props.sessionId)
876
+
877
+ React.useEffect(() => {
878
+ if (!commandId) return undefined
879
+ let alive = true
880
+ let timer = null
881
+ let tries = 0
882
+ // 结果由 host 在命令跑完后才落, 这里轮询到拿到为止(卡片挂载常早于结果)
883
+ const poll = () => {
884
+ fetch(API + '/host/results/' + encodeURIComponent(commandId))
885
+ .then((res) => (res.ok ? res.json() : null))
886
+ .then((data) => {
887
+ if (!alive) return
888
+ if (data && Array.isArray(data.segments)) {
889
+ setResult(data)
890
+ setLoaded(true)
891
+ return
892
+ }
893
+ tries += 1
894
+ if (tries > 150) {
895
+ setLoaded(true)
896
+ return
897
+ }
898
+ timer = setTimeout(poll, tries < 20 ? 400 : 1500)
899
+ })
900
+ .catch(() => {
901
+ if (!alive) return
902
+ tries += 1
903
+ if (tries > 150) setLoaded(true)
904
+ else timer = setTimeout(poll, 1500)
905
+ })
906
+ }
907
+ poll()
908
+ return () => {
909
+ alive = false
910
+ if (timer) clearTimeout(timer)
911
+ }
912
+ }, [commandId, settled])
913
+
914
+ const label = String(node.args || '').trim() || node.name || 'ww'
915
+ const segments = (result && result.segments) || []
916
+
917
+ return React.createElement(
918
+ 'div',
919
+ { className: 'ww-card' },
920
+ React.createElement(
921
+ 'div',
922
+ { className: 'ww-head' },
923
+ React.createElement(
924
+ 'span',
925
+ { className: 'ww-title' },
926
+ React.createElement('span', null, 'XWUID'),
927
+ ),
928
+ React.createElement('span', { className: 'ww-cmd' }, '/' + (node.name || 'ww') + ' ' + label),
929
+ !outcome && React.createElement('span', { className: 'ww-hint' }, '执行中…'),
930
+ React.createElement('span', { className: 'ww-head-gap' }),
931
+ React.createElement(CardTools, {
932
+ segments, label, value: zoom === null ? cap : zoom, onChange: setZoom,
933
+ }),
934
+ ),
935
+ settled === 'error' &&
936
+ React.createElement('div', { className: 'ww-err' }, outcome.text || '执行失败'),
937
+ segments.map((seg, i) =>
938
+ React.createElement(Segment, {
939
+ key: 'i' + i, seg, onCommand: runner.onCommand, zoom, onCap: setCap,
940
+ sizeKey: label + '#' + i,
941
+ }),
942
+ ),
943
+ settled === 'success' &&
944
+ loaded &&
945
+ !segments.length &&
946
+ React.createElement(StaleResult, {
947
+ text: outcome.text || '已执行',
948
+ onRerun: () => runner.onCommand(label),
949
+ }),
950
+ React.createElement(Extras, runner),
951
+ )
952
+ }
953
+
954
+ // ---- 设置页 ----
955
+
956
+ function toText(value) {
957
+ if (value === null || value === undefined) return ''
958
+ if (Array.isArray(value)) return value.join(', ')
959
+ if (typeof value === 'object') return JSON.stringify(value, null, 2)
960
+ return String(value)
961
+ }
962
+
963
+ // 配置项 type + 值形态决定用哪种控件
964
+ function controlOf(item) {
965
+ const value = item.value
966
+ if (item.type === 'Divider') return 'divider'
967
+ // 纯上传项不给输入框; with_text 的(如头像)还留着填链接/QQ号的位置
968
+ if (item.upload && !item.upload.with_text) return 'file'
969
+ if (item.type === 'BoolConfig' || typeof value === 'boolean') return 'bool'
970
+ if (item.type === 'IntConfig' || item.type === 'FloatConfig') {
971
+ return item.options && item.options.length ? 'select' : 'number'
972
+ }
973
+ if (item.type === 'ListStrConfig' || item.type === 'ListConfig') return 'list'
974
+ if (item.type === 'DictConfig') return 'json'
975
+ if (typeof value === 'string') return 'text'
976
+ if (typeof value === 'number') return 'number'
977
+ if (value && typeof value === 'object') return 'json'
978
+ return 'text'
979
+ }
980
+
981
+ // 落盘位置是写死的, 不给前端改; 只显示有没有、以及换一个
982
+ function UploadButton({ item, onUpload }) {
983
+ const suffix = (item.upload && item.upload.suffix) || ''
984
+ return React.createElement(
985
+ 'label',
986
+ { className: 'ww-up' },
987
+ (item.upload && item.upload.exists ? '更换' : '上传') + (suffix ? ' .' + suffix : ''),
988
+ React.createElement('input', {
989
+ type: 'file',
990
+ accept: suffix ? '.' + suffix : 'image/*',
991
+ onChange: (e) => {
992
+ const file = e.target.files && e.target.files[0]
993
+ e.target.value = ''
994
+ if (file) onUpload(item, file)
995
+ },
996
+ }),
997
+ )
998
+ }
999
+
1000
+ function FileControl({ item, onUpload }) {
1001
+ return React.createElement(
1002
+ 'div',
1003
+ { className: 'ww-set-file' },
1004
+ React.createElement(
1005
+ 'span',
1006
+ { className: 'name' },
1007
+ item.upload && item.upload.exists ? '已上传' : '未设置 · 用内置的',
1008
+ ),
1009
+ React.createElement(UploadButton, { item, onUpload }),
1010
+ )
1011
+ }
1012
+
1013
+ function ConfigRow({ group, item, state, onSave, onFail, onUpload }) {
1014
+ const kind = controlOf(item)
1015
+ const secret = !!item.secret
1016
+ const [draft, setDraft] = React.useState(() => (secret ? '' : toText(item.value)))
1017
+
1018
+ React.useEffect(() => {
1019
+ setDraft(secret ? '' : toText(item.value))
1020
+ }, [item.value, secret])
1021
+
1022
+ const commit = React.useCallback(() => {
1023
+ if (secret) {
1024
+ if (!draft) return
1025
+ onSave(item, draft)
1026
+ setDraft('')
1027
+ return
1028
+ }
1029
+ if (draft === toText(item.value)) return
1030
+ if (kind === 'json') {
1031
+ let parsed
1032
+ try {
1033
+ parsed = JSON.parse(draft)
1034
+ } catch (e) {
1035
+ onFail(item, 'JSON 格式错误: ' + (e.message || e))
1036
+ return
1037
+ }
1038
+ onSave(item, parsed)
1039
+ return
1040
+ }
1041
+ onSave(item, draft)
1042
+ }, [draft, item, kind, secret, onSave, onFail])
1043
+
1044
+ if (kind === 'divider') {
1045
+ return React.createElement(
1046
+ 'div',
1047
+ { className: 'ww-set-divider' },
1048
+ item.value || item.title || '',
1049
+ )
1050
+ }
1051
+
1052
+ const current = toText(item.value)
1053
+ let control
1054
+ if (kind === 'file') {
1055
+ control = React.createElement(FileControl, { item, onUpload })
1056
+ } else if (kind === 'bool') {
1057
+ const on = !!item.value
1058
+ control = React.createElement(
1059
+ 'button',
1060
+ {
1061
+ type: 'button',
1062
+ className: 'ww-set-switch' + (on ? ' on' : ''),
1063
+ 'aria-pressed': on ? 'true' : 'false',
1064
+ onClick: () => onSave(item, !on),
1065
+ },
1066
+ React.createElement('span', { className: 'ww-set-knob' }),
1067
+ )
1068
+ } else if (kind === 'select') {
1069
+ const options = (item.options || []).map(String)
1070
+ if (!options.includes(current)) options.unshift(current)
1071
+ control = React.createElement(
1072
+ 'select',
1073
+ {
1074
+ className: 'ww-set-input',
1075
+ value: current,
1076
+ onChange: (e) => onSave(item, e.target.value),
1077
+ },
1078
+ options.map((opt) => React.createElement('option', { key: opt, value: opt }, opt)),
1079
+ )
1080
+ } else if (kind === 'json') {
1081
+ control = React.createElement('textarea', {
1082
+ className: 'ww-set-area',
1083
+ value: draft,
1084
+ spellCheck: false,
1085
+ onChange: (e) => setDraft(e.target.value),
1086
+ onBlur: commit,
1087
+ })
1088
+ } else {
1089
+ const listId = ('ww-dl-' + group + '-' + item.key).replace(/[^\w-]/g, '')
1090
+ const hasOptions = kind === 'text' && !secret && !!(item.options && item.options.length)
1091
+ control = React.createElement(
1092
+ React.Fragment,
1093
+ null,
1094
+ React.createElement('input', {
1095
+ className: 'ww-set-input',
1096
+ type: secret ? 'password' : kind === 'number' ? 'number' : 'text',
1097
+ value: draft,
1098
+ spellCheck: false,
1099
+ list: hasOptions ? listId : undefined,
1100
+ placeholder: secret ? current + ' · 输入新值覆盖' : kind === 'list' ? '逗号分隔' : '',
1101
+ onChange: (e) => setDraft(e.target.value),
1102
+ onBlur: commit,
1103
+ onKeyDown: (e) => {
1104
+ if (e.key === 'Enter') e.target.blur()
1105
+ },
1106
+ }),
1107
+ hasOptions &&
1108
+ React.createElement(
1109
+ 'datalist',
1110
+ { id: listId },
1111
+ item.options.map((opt) =>
1112
+ React.createElement('option', { key: String(opt), value: String(opt) }),
1113
+ ),
1114
+ ),
1115
+ )
1116
+ }
1117
+
1118
+ return React.createElement(
1119
+ 'div',
1120
+ { className: 'ww-set-row' },
1121
+ React.createElement(
1122
+ 'div',
1123
+ { className: 'ww-set-main' },
1124
+ React.createElement('div', { className: 'ww-set-label' }, item.title || item.key),
1125
+ item.desc && React.createElement('div', { className: 'ww-set-desc' }, item.desc),
1126
+ ),
1127
+ React.createElement(
1128
+ 'div',
1129
+ { className: 'ww-set-ctl' },
1130
+ control,
1131
+ item.upload &&
1132
+ item.upload.with_text &&
1133
+ React.createElement(UploadButton, { item, onUpload }),
1134
+ state
1135
+ ? React.createElement('div', { className: 'ww-set-state ' + state.kind }, state.msg)
1136
+ : item.needs_restart &&
1137
+ React.createElement('div', { className: 'ww-set-state' }, '改后需重启生效'),
1138
+ ),
1139
+ )
1140
+ }
1141
+
1142
+ function WavesSettings() {
1143
+ ensureStyles()
1144
+ const [groups, setGroups] = React.useState([])
1145
+ const [loading, setLoading] = React.useState(true)
1146
+ const [error, setError] = React.useState('')
1147
+ const [states, setStates] = React.useState({})
1148
+
1149
+ React.useEffect(() => {
1150
+ let alive = true
1151
+ fetch(API + '/api/config')
1152
+ .then((res) => {
1153
+ if (!res.ok) throw new Error('请求失败 ' + res.status)
1154
+ return res.json()
1155
+ })
1156
+ .then((data) => {
1157
+ if (!alive) return
1158
+ setGroups((data && data.groups) || [])
1159
+ setLoading(false)
1160
+ })
1161
+ .catch((e) => {
1162
+ if (!alive) return
1163
+ setError(String(e.message || e))
1164
+ setLoading(false)
1165
+ })
1166
+ return () => {
1167
+ alive = false
1168
+ }
1169
+ }, [])
1170
+
1171
+ const setState = React.useCallback((group, key, kind, msg) => {
1172
+ setStates((prev) => Object.assign({}, prev, { [group + '/' + key]: { kind, msg } }))
1173
+ }, [])
1174
+
1175
+ const applyItem = React.useCallback((group, next) => {
1176
+ setGroups((prev) =>
1177
+ prev.map((g) =>
1178
+ g.name === group
1179
+ ? Object.assign({}, g, {
1180
+ items: g.items.map((it) => (it.key === next.key ? next : it)),
1181
+ })
1182
+ : g,
1183
+ ),
1184
+ )
1185
+ }, [])
1186
+
1187
+ const post = React.useCallback(
1188
+ (path, group, item, body, busyText, okText) => {
1189
+ setState(group, item.key, '', busyText)
1190
+ fetch(API + path, {
1191
+ method: 'POST',
1192
+ headers: { 'content-type': 'application/json' },
1193
+ body: JSON.stringify(body),
1194
+ })
1195
+ .then(async (res) => {
1196
+ const data = await res.json().catch(() => null)
1197
+ if (!res.ok) {
1198
+ throw new Error((data && (data.detail || data.error)) || '请求失败 ' + res.status)
1199
+ }
1200
+ return data
1201
+ })
1202
+ .then((data) => {
1203
+ applyItem(group, data.item)
1204
+ setState(group, item.key, 'ok', data.needs_restart ? okText + ' · 重启后生效' : okText)
1205
+ })
1206
+ .catch((e) => setState(group, item.key, 'err', String(e.message || e)))
1207
+ },
1208
+ [applyItem, setState],
1209
+ )
1210
+
1211
+ const save = React.useCallback(
1212
+ (group, item, value) =>
1213
+ post('/api/config', group, item, { group, key: item.key, value }, '保存中…', '已保存'),
1214
+ [post],
1215
+ )
1216
+
1217
+ const upload = React.useCallback(
1218
+ (group, item, file) => {
1219
+ setState(group, item.key, '', '上传中…')
1220
+ const reader = new FileReader()
1221
+ reader.onerror = () => setState(group, item.key, 'err', '文件读取失败')
1222
+ reader.onload = () =>
1223
+ post(
1224
+ '/api/config/upload',
1225
+ group,
1226
+ item,
1227
+ { group, key: item.key, data: String(reader.result).split(',').pop() },
1228
+ '上传中…',
1229
+ '已上传',
1230
+ )
1231
+ reader.readAsDataURL(file)
1232
+ },
1233
+ [post, setState],
1234
+ )
1235
+
1236
+ if (loading) return React.createElement('div', { className: 'ww-hint' }, '正在读取配置…')
1237
+ if (error) {
1238
+ return React.createElement('div', { className: 'ww-err' }, '配置读取失败: ' + error)
1239
+ }
1240
+
1241
+ return React.createElement(
1242
+ 'div',
1243
+ { className: 'ww-set' },
1244
+ groups.map((group) =>
1245
+ React.createElement(
1246
+ 'div',
1247
+ { key: group.name, className: 'ww-set-group' },
1248
+ React.createElement('div', { className: 'ww-set-title' }, group.name),
1249
+ (group.items || []).map((item) =>
1250
+ React.createElement(ConfigRow, {
1251
+ key: item.key,
1252
+ group: group.name,
1253
+ item,
1254
+ state: states[group.name + '/' + item.key],
1255
+ onSave: (target, value) => save(group.name, target, value),
1256
+ onUpload: (target, file) => upload(group.name, target, file),
1257
+ onFail: (target, msg) => setState(group.name, target.key, 'err', msg),
1258
+ }),
1259
+ ),
1260
+ ),
1261
+ ),
1262
+ )
1263
+ }
1264
+
1265
+ // ---- 斜杠命令接管 ----
1266
+
1267
+ // dsh 的命令名只认 [a-z0-9_-] 且要空格收尾, /wwmr 会被当成命令名 wwmr、/ww深塔 直接不匹配,
1268
+ // 两者都落到模型。输入框的 enter 仲裁里认领所有 /ww 开头的行, 改写成 ww 命令当场执行。
1269
+ const SLASH = '/ww'
1270
+ // 前缀可配置(逗号分隔多个); 服务启动慢, 失败要重试而不是一锤定音
1271
+ let slashPrefixes = null
1272
+ let slashLoading = null
1273
+ // 前缀要等服务应答, 但槽位注册是同步的; 上次拿到的值缓存下来, 下次开页就能直接用
1274
+ const PREFIX_KEY = 'ww-slash-prefixes'
1275
+
1276
+ function cachedPrefixes() {
1277
+ try {
1278
+ const list = JSON.parse(localStorage.getItem(PREFIX_KEY) || 'null')
1279
+ return Array.isArray(list) && list.length ? list : null
1280
+ } catch (e) {
1281
+ return null
1282
+ }
1283
+ }
1284
+
1285
+ function loadPrefixes() {
1286
+ if (slashPrefixes) return Promise.resolve(slashPrefixes)
1287
+ if (slashLoading) return slashLoading
1288
+ slashLoading = fetch(API + '/api/health')
1289
+ .then((r) => r.json())
1290
+ .then((d) => {
1291
+ const list = (d && d.prefixes) || []
1292
+ if (list.length) {
1293
+ slashPrefixes = list.map((p) => '/' + String(p).toLowerCase())
1294
+ try {
1295
+ localStorage.setItem(PREFIX_KEY, JSON.stringify(slashPrefixes))
1296
+ } catch (e) {
1297
+ // 存不下就算了, 只是下次开页要再等一次请求
1298
+ }
1299
+ }
1300
+ return slashPrefixes
1301
+ })
1302
+ .catch(() => null)
1303
+ .finally(() => {
1304
+ slashLoading = null
1305
+ })
1306
+ return slashLoading
1307
+ }
1308
+ slashPrefixes = cachedPrefixes()
1309
+ loadPrefixes()
1310
+
1311
+ // '/wwmr' '/xw深塔' → 'mr' '深塔'; 不是本插件的行给空串。
1312
+ // '/ww 查询' 这种带空格的 dsh 自己就认得, 这里必须放手, 否则会连着提交两遍。
1313
+ function wavesArgs(line, prefixes) {
1314
+ const text = String(line || '').trim()
1315
+ const lower = text.toLowerCase()
1316
+ for (const p of prefixes || slashPrefixes || [SLASH]) {
1317
+ if (!lower.startsWith(p)) continue
1318
+ const rest = text.slice(p.length)
1319
+ if (!rest || /^\s/.test(rest)) return ''
1320
+ return rest.trim()
1321
+ }
1322
+ return ''
1323
+ }
1324
+
1325
+ // 空白新会话停在英雄页(composerPhase=blank), 斜杠命令会执行但对话区是 null,
1326
+ // 看起来像没响应; 发一句普通消息才会翻成会话。以前 return undefined 指望模型接手,
1327
+ // 但 dsh 自带的 command 源仍会认领 /ww, 英雄页翻不过去。所以自己认领执行,
1328
+ // 若仍是 blank 再补一次 prompt, 把界面拉起来。
1329
+ function sessionFace(sctx, sessionId) {
1330
+ try {
1331
+ const binding = sctx.sessions && sctx.sessions.binding(sessionId)
1332
+ return (binding && binding.session) || null
1333
+ } catch (e) {
1334
+ return null
1335
+ }
1336
+ }
1337
+
1338
+ function sessionIsBlank(sctx, sessionId) {
1339
+ const face = sessionFace(sctx, sessionId)
1340
+ if (!face || typeof face.getSnapshot !== 'function') return false
1341
+ try {
1342
+ const snap = face.getSnapshot()
1343
+ return !!(snap && (snap.blank || snap.composerPhase === 'blank'))
1344
+ } catch (e) {
1345
+ return false
1346
+ }
1347
+ }
1348
+
1349
+ async function kickstartIfBlank(sctx, sessionId) {
1350
+ const face = sessionFace(sctx, sessionId)
1351
+ if (!face || typeof face.prompt !== 'function') return
1352
+ if (!sessionIsBlank(sctx, sessionId)) return
1353
+ try {
1354
+ await face.prompt(
1355
+ [
1356
+ {
1357
+ type: 'text',
1358
+ text: '你好',
1359
+ },
1360
+ ],
1361
+ 'queue',
1362
+ )
1363
+ } catch (e) {}
1364
+ }
1365
+
1366
+ // 走 host 的命令执行, 结果照常落成命令流水节点, 由 WavesCommandCard 出卡片
1367
+ async function execCommand(ctx, sessionId, line, attachments = []) {
1368
+ let res
1369
+ try {
1370
+ res = await ctx.remote.commands.execute(sessionId, line, attachments)
1371
+ } catch (error) {
1372
+ // rc.7 的 RPC 只有两个业务参数,反射校验在执行前拒绝第三参。
1373
+ if (attachments.length || !/expected 2 business argument/.test(String(error?.message))) {
1374
+ throw error
1375
+ }
1376
+ res = await ctx.remote.commands.execute(sessionId, line)
1377
+ }
1378
+ if (!res || !res.ok) {
1379
+ const err = res && res.error
1380
+ throw new Error('鸣潮命令下发失败: ' + ((err && err.message) || '连接异常'))
1381
+ }
1382
+ if (!res.value) return { kind: 'error', text: '无法识别的命令: ' + line }
1383
+ if (res.value.result?.kind === 'error') return res.value.result
1384
+ return { kind: 'success' }
1385
+ }
1386
+
1387
+ function isWavesCommandName(name) {
1388
+ const raw = String(name || '').toLowerCase()
1389
+ const prefixes = slashPrefixes || [SLASH]
1390
+ return prefixes.some((p) => raw === String(p).replace(/^\//, '').toLowerCase())
1391
+ }
1392
+
1393
+ function registerKickstart(ctx) {
1394
+ ctx.inject(['sessions'], (sctx) => {
1395
+ sctx.effect(
1396
+ () =>
1397
+ sctx.on('command/executed', (sessionId, name, result) => {
1398
+ if (!isWavesCommandName(name) || result?.kind === 'error') return
1399
+ Promise.resolve(kickstartIfBlank(sctx, sessionId)).catch(() => {})
1400
+ }),
1401
+ 'waves: 空白会话补 prompt',
1402
+ )
1403
+ })
1404
+ }
1405
+
1406
+ // 配置页在侧栏「插件」页; 没有该页的旧版 dsh 才挂进设置
1407
+ function registerConfigPage(ctx) {
1408
+ let pageLive = false
1409
+ let offSection
1410
+ ctx.slots.inject('plugins.bundle.config', () => {
1411
+ pageLive = true
1412
+ offSection?.()
1413
+ return ctx.slots.register(
1414
+ { name: 'plugins.bundle.config', key: '@sophiacyl/dsh-plugin-waves' },
1415
+ WavesSettings,
1416
+ )
1417
+ })
1418
+ ctx.slots.inject('settings.section', () => {
1419
+ if (pageLive) return () => {}
1420
+ const off = ctx.slots.register(
1421
+ { name: 'settings.section', id: 'waves', order: 80, label: () => '鸣潮' },
1422
+ WavesSettings,
1423
+ )
1424
+ offSection = () => {
1425
+ offSection = undefined
1426
+ if (typeof off === 'function') off()
1427
+ }
1428
+ return () => offSection?.()
1429
+ })
1430
+ }
1431
+
1432
+ function registerSlash(ctx) {
1433
+ ctx.inject(['inputTriggers', 'remote', 'remote.commands', 'sessions'], (sctx) => {
1434
+ sctx.effect(
1435
+ () =>
1436
+ sctx.inputTriggers.registerSource({
1437
+ trigger: '/',
1438
+ name: 'waves',
1439
+ // 候选菜单留给 dsh 自带的 command 源, 这里只接管 enter, 免得抢走回车
1440
+ candidates: () => Promise.resolve([]),
1441
+ onPick: () => undefined,
1442
+ matchEnter: async (session, line) => {
1443
+ let prefixes = slashPrefixes
1444
+ if (!prefixes) {
1445
+ prefixes = await Promise.race([
1446
+ loadPrefixes(),
1447
+ new Promise((r) => setTimeout(() => r(null), 1500)),
1448
+ ])
1449
+ }
1450
+ const args = wavesArgs(line, prefixes)
1451
+ if (!args) return undefined
1452
+ const submit = async (rest, _actx, attachments) => {
1453
+ const out = await execCommand(
1454
+ sctx,
1455
+ session.sessionId,
1456
+ SLASH + ' ' + ((rest && rest.trim()) || args),
1457
+ attachments,
1458
+ )
1459
+ if (out.kind === 'success') await kickstartIfBlank(sctx, session.sessionId)
1460
+ return out
1461
+ }
1462
+ const matched = (prefixes || slashPrefixes || [SLASH]).find((x) =>
1463
+ line.toLowerCase().startsWith(x),
1464
+ )
1465
+ return { claim: { name: (matched || SLASH).slice(1), token: (matched || SLASH) + ' ', attachments: true, submit } }
1466
+ },
1467
+ }),
1468
+ 'waves: 斜杠命令接管',
1469
+ )
1470
+ })
1471
+ }
1472
+
1473
+ exports.inject = ['slots']
1474
+ exports.apply = (ctx) => {
1475
+ ensureStyles()
1476
+ installImageEcho()
1477
+ registerSlash(ctx)
1478
+ registerKickstart(ctx)
1479
+ ctx.slots.inject('tool.call.toolview', () =>
1480
+ ctx.slots.register({ name: 'tool.call.toolview', key: 'waves' }, WavesCard),
1481
+ )
1482
+ ctx.slots.inject('tool.call.toolview', () =>
1483
+ ctx.slots.register({ name: 'tool.call.toolview', key: 'waves_panel' }, WavesCard),
1484
+ )
1485
+ registerConfigPage(ctx)
1486
+ // 每个前缀都是一个命令名, 各自的结果卡都要挂。命令名对不上就会退回 dsh 的纯文本卡,
1487
+ // 所以前缀是异步取到的也得补注册, 不能只认注册那一刻的值。
1488
+ ctx.slots.inject('conversation.chat.commandview', () => {
1489
+ const offs = new Map()
1490
+ const add = (list) => {
1491
+ for (const item of list || []) {
1492
+ const key = String(item).replace(/^\//, '')
1493
+ if (!key || offs.has(key)) continue
1494
+ offs.set(
1495
+ key,
1496
+ ctx.slots.register(
1497
+ { name: 'conversation.chat.commandview', key },
1498
+ WavesCommandCard,
1499
+ ),
1500
+ )
1501
+ }
1502
+ }
1503
+ add(slashPrefixes || ['/ww'])
1504
+ loadPrefixes().then(add)
1505
+ return () => offs.forEach((off) => typeof off === 'function' && off())
1506
+ })
1507
+ }
1508
+
1509
+ return module.exports
1510
+ },
1511
+ })