dsh-plugin-admin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1751 @@
1
+ window.__ModuleLoader__.load({ id: 'dsh-plugin-admin', factory: (require) => {
2
+ var module = { exports: {} }; var exports = module.exports;
3
+
4
+ /**
5
+ * dsh-plugin-admin browser half: unified "管理中心" (插件管理 & 会话管理)
6
+ * page inside the settings panel.
7
+ *
8
+ * The page rides the shell's `settings.section` slot (the shell owns
9
+ * the dialog chrome, navigation, and close); its component is assembled with
10
+ * the platform-shared React (module-table seed word, same instance the shell
11
+ * renders with), providing a tabbed modern UI to manage both profile plugin
12
+ * layers and session lifecycles over the /api RPC gateway.
13
+ */
14
+
15
+ var React = require('react')
16
+ var createElement = React.createElement
17
+ var useState = React.useState
18
+ var useRef = React.useRef
19
+ var useEffect = React.useEffect
20
+
21
+ var CSS_TEXT = [
22
+ '@keyframes dsh-admin-spin { to { transform: rotate(360deg); } }',
23
+ '@keyframes dsh-admin-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(0.85); } }',
24
+ '[data-dsh-admin-section] { display: flex; flex-direction: column; width: 100%; gap: 14px; padding: 2px; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-primary, #0f1115); }',
25
+ '[data-dsh-admin-section] * { box-sizing: border-box; }',
26
+ '[data-dsh-admin-section] .tabs { display: flex; padding: 3px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.04)); border-radius: 9px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.08)); gap: 3px; }',
27
+ '[data-dsh-admin-section] .tab { flex: 1; border: 0; background: transparent; border-radius: 7px; padding: 7px 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px; font-size: 12px; font-weight: 500; color: var(--dsw-alias-label-secondary, #666); transition: all 0.15s ease; font-family: inherit; }',
28
+ '[data-dsh-admin-section] .tab:hover:not(.active) { color: var(--dsw-alias-label-primary, #111); background: rgba(0,0,0,0.02); }',
29
+ '[data-dsh-admin-section] .tab.active { background: var(--dsw-alias-bg-base, #fff); color: var(--dsw-alias-label-primary, #111); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04); }',
30
+ '[data-dsh-admin-section] .tab-count { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, #e5e7eb); color: var(--dsw-alias-label-secondary, #555); }',
31
+ '[data-dsh-admin-section] .tab.active .tab-count { background: var(--dsw-alias-button-primary-fill, #1677ff); color: var(--dsw-alias-label-primary-foreground, #fff); }',
32
+ '[data-dsh-admin-section] .toolbar { display: flex; gap: 8px; align-items: center; padding: 10px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.08)); border-radius: 12px; background: var(--dsw-alias-bg-base, #fff); box-shadow: 0 1px 2px rgba(0,0,0,0.02); }',
33
+ '[data-dsh-admin-section] .input-wrap { flex: 1; min-width: 0; position: relative; display: flex; align-items: center; }',
34
+ '[data-dsh-admin-section] .input { width: 100%; padding: 7px 12px; border-radius: 9px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.12)); background: var(--dsw-alias-bg-base, #fff); color: inherit; font: inherit; outline: none; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; }',
35
+ '[data-dsh-admin-section] .input:hover { border-color: var(--dsw-alias-label-tertiary, rgba(0,0,0,0.28)); }',
36
+ '[data-dsh-admin-section] .input:focus { border-color: var(--dsw-alias-button-primary-fill, #1677ff); box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.14); }',
37
+ '[data-dsh-admin-section] .filter-bar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }',
38
+ '[data-dsh-admin-section] .pill { font-size: 11px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.1)); background: transparent; cursor: pointer; color: var(--dsw-alias-label-secondary, #666); transition: all 0.12s; font-family: inherit; }',
39
+ '[data-dsh-admin-section] .pill:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.04)); }',
40
+ '[data-dsh-admin-section] .pill.active { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06)); color: var(--dsw-alias-button-primary-fill, #1677ff); font-weight: 600; border-color: currentColor; }',
41
+ '[data-dsh-admin-section] .btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; min-height: 30px; padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 500; cursor: pointer; font-family: inherit; transition: transform 0.15s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.12)); background: transparent; color: inherit; white-space: nowrap; flex: none; }',
42
+ '[data-dsh-admin-section] .btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.04)); border-color: var(--dsw-alias-label-tertiary, rgba(0,0,0,0.3)); }',
43
+ '[data-dsh-admin-section] .btn:active:not(:disabled) { transform: translateY(1px); }',
44
+ '[data-dsh-admin-section] .btn:focus-visible, [data-dsh-admin-section] .pill:focus-visible { outline: 2px solid var(--dsw-alias-button-primary-fill, #1677ff); outline-offset: 2px; }',
45
+ '[data-dsh-admin-section] .btn:disabled { opacity: 0.45; cursor: not-allowed; }',
46
+ '[data-dsh-admin-section] .btn.primary { background: var(--dsw-alias-button-primary-fill, #1677ff); color: var(--dsw-alias-label-primary-foreground, #fff); border-color: transparent; font-weight: 600; }',
47
+ '[data-dsh-admin-section] .btn.primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover, #4096ff); }',
48
+ '[data-dsh-admin-section] .btn.danger { color: var(--dsw-alias-state-error-primary, #ef4444); border-color: var(--dsw-alias-border-subtle, rgba(0,0,0,0.12)); }',
49
+ '[data-dsh-admin-section] .btn.danger:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger, rgba(239,68,68,0.1)); }',
50
+ '[data-dsh-admin-section] .btn.danger-solid { background: var(--dsw-alias-state-error-primary, #ef4444); color: #fff; border-color: transparent; }',
51
+ '[data-dsh-admin-section] .btn.danger-solid:hover:not(:disabled) { opacity: 0.9; }',
52
+ '[data-dsh-admin-section] .btn.sm { padding: 3px 8px; font-size: 11px; border-radius: 6px; }',
53
+ '[data-dsh-admin-section] .spinner { width: 12px; height: 12px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: dsh-admin-spin 0.7s linear infinite; display: inline-block; flex: none; }',
54
+ '[data-dsh-admin-section] .list { display: flex; flex-direction: column; gap: 10px; max-height: 560px; overflow-y: auto; padding: 2px 4px 2px 2px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-subtle, rgba(0,0,0,0.2)) transparent; }',
55
+ '[data-dsh-admin-section] .list::-webkit-scrollbar { width: 6px; }',
56
+ '[data-dsh-admin-section] .list::-webkit-scrollbar-thumb { border-radius: 99px; background: var(--dsw-alias-border-subtle, rgba(0,0,0,0.2)); }',
57
+ '[data-dsh-admin-section] .session-group { display: flex; flex-direction: column; gap: 6px; }',
58
+ '[data-dsh-admin-section] .group-header { display: flex; align-items: center; gap: 8px; padding: 10px 4px 4px 4px; font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary); border-bottom: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.08)); }',
59
+ '[data-dsh-admin-section] .group-header:first-child { padding-top: 4px; }',
60
+ '[data-dsh-admin-section] .group-title { display: inline-flex; align-items: center; gap: 6px; }',
61
+ '[data-dsh-admin-section] .group-count { font-size: 10px; padding: 1px 7px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.05)); color: var(--dsw-alias-label-secondary, #555); font-weight: 500; }',
62
+ '[data-dsh-admin-section] .group-path { font-family: monospace; font-size: 10px; color: var(--dsw-alias-label-tertiary, #888); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; text-align: right; }',
63
+ '[data-dsh-admin-section] .card { display: flex; flex-direction: column; gap: 7px; padding: 12px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.08)); background: var(--dsw-alias-bg-base, #fff); box-shadow: 0 1px 2px rgba(0,0,0,0.02); transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; }',
64
+ '[data-dsh-admin-section] .card:hover { transform: translateY(-1px); border-color: var(--dsw-alias-label-tertiary, rgba(128,128,128,0.3)); box-shadow: 0 8px 20px rgba(0,0,0,0.06); }',
65
+ '[data-dsh-admin-section] .card-header { display: flex; align-items: center; gap: 8px; min-width: 0; }',
66
+ '[data-dsh-admin-section] .card-title { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); display: flex; align-items: center; gap: 7px; min-width: 0; flex: 1; }',
67
+ '[data-dsh-admin-section] .card-title-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; }',
68
+ '[data-dsh-admin-section] .card-summary { display: flex; align-items: flex-start; gap: 6px; padding: 6px 9px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.025)); border-left: 2px solid var(--dsw-alias-button-primary-fill, #1677ff); font-size: 12px; line-height: 1.45; color: var(--dsw-alias-label-secondary, #555); margin-top: 1px; margin-bottom: 1px; }',
69
+ '[data-dsh-admin-section] .summary-icon { font-size: 11px; line-height: 1.45; flex: none; opacity: 0.85; }',
70
+ '[data-dsh-admin-section] .summary-text { flex: 1; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; word-break: break-word; }',
71
+ '[data-dsh-admin-section] .card-sub { font-size: 11px; color: var(--dsw-alias-label-secondary, #666); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }',
72
+ '[data-dsh-admin-section] .card-sub-item { display: inline-flex; align-items: center; gap: 3px; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
73
+ '[data-dsh-admin-section] .card-actions { display: flex; gap: 5px; align-items: center; flex: none; }',
74
+ '[data-dsh-admin-section] .tag { font-size: 10px; font-weight: 500; padding: 1px 6px; border-radius: 4px; flex: none; display: inline-flex; align-items: center; gap: 4px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.04)); color: var(--dsw-alias-label-secondary, #666); }',
75
+ '[data-dsh-admin-section] .tag.plugin { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); }',
76
+ '[data-dsh-admin-section] .tag.local { background: rgba(59, 130, 246, 0.1); color: var(--dsw-alias-button-primary-fill, #1677ff); }',
77
+ '[data-dsh-admin-section] .plugin-path { font-family: monospace; font-size: 11px; line-height: 1.4; color: var(--dsw-alias-label-secondary, #666); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 4px 9px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.025)); }',
78
+ '[data-dsh-admin-section] .tag.version { font-family: monospace; }',
79
+ '[data-dsh-admin-section] .tag.live { background: rgba(16, 185, 129, 0.12); color: var(--dsw-alias-state-success-primary, #10b981); font-weight: 600; }',
80
+ '[data-dsh-admin-section] .tag.archived { background: rgba(245, 158, 11, 0.12); color: var(--dsw-alias-state-warn-label, #f59e0b); }',
81
+ '[data-dsh-admin-section] .tag.turns { background: rgba(59, 130, 246, 0.1); color: var(--dsw-alias-button-primary-fill, #1677ff); font-weight: 500; }',
82
+ '[data-dsh-admin-section] .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dsw-alias-label-tertiary, #999); }',
83
+ '[data-dsh-admin-section] .dot.live { background: var(--dsw-alias-state-success-primary, #10b981); box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.25); animation: dsh-admin-pulse 2s infinite ease-in-out; }',
84
+ '[data-dsh-admin-section] .dot.archived { background: var(--dsw-alias-state-warn-label, #f59e0b); }',
85
+ '[data-dsh-admin-section] .confirm-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover-danger, rgba(239,68,68,0.08)); border: 1px solid rgba(239,68,68,0.2); }',
86
+ '[data-dsh-admin-section] .confirm-text { font-size: 11px; color: var(--dsw-alias-state-error-primary, #ef4444); font-weight: 500; }',
87
+ '[data-dsh-admin-section] .confirm-actions { display: flex; gap: 5px; flex: none; }',
88
+ '[data-dsh-admin-section] .busy-banner { display: flex; align-items: center; gap: 8px; padding: 7px 11px; border-radius: 7px; background: rgba(22, 119, 255, 0.08); color: var(--dsw-alias-button-primary-fill, #1677ff); font-size: 11px; }',
89
+ '[data-dsh-admin-section] .empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 28px 12px; color: var(--dsw-alias-label-tertiary, #999); gap: 4px; font-size: 12px; text-align: center; }',
90
+ '[data-dsh-admin-section] .error { padding: 7px 11px; border-radius: 7px; color: var(--dsw-alias-state-error-primary, #ef4444); background: var(--dsw-alias-interactive-bg-hover-danger, rgba(239,68,68,0.08)); font-size: 12px; white-space: pre-wrap; max-height: 100px; overflow-y: auto; }',
91
+ '[data-dsh-admin-section] .footer { display: flex; justify-content: space-between; align-items: center; padding-top: 4px; font-size: 11px; color: var(--dsw-alias-label-tertiary, #888); border-top: 1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.06)); }',
92
+ '[data-dsh-admin-section] .footer .path { max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
93
+ '[data-dsh-admin-section] .footer .hint { font-style: normal; }',
94
+ '[data-dsh-admin-section] .session-id-badge { font-family: monospace; font-size: 10px; opacity: 0.75; }',
95
+ '[data-dsh-admin-section] .mcp-test-row { padding: 6px 9px; border-radius: 6px; background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.025)); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }',
96
+ '[data-dsh-admin-section] .mcp-test { display: inline-flex; align-items: center; gap: 5px; }',
97
+ '[data-dsh-admin-section] .mcp-test.mcp-test-list { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }',
98
+ '[data-dsh-admin-section] .mcp-test-busy { color: var(--dsw-alias-label-secondary, #666); }',
99
+ '[data-dsh-admin-section] .mcp-test-ok { color: #16a34a; }',
100
+ '[data-dsh-admin-section] .mcp-test-fail { color: var(--dsw-alias-state-error-primary, #ef4444); }',
101
+ '[data-dsh-admin-section] .mcp-test-warn { color: #d97706; opacity: 0.95; }',
102
+ '@media (max-width: 520px) { [data-dsh-admin-section] { gap: 10px; } [data-dsh-admin-section] .toolbar { align-items: stretch; flex-wrap: wrap; padding: 8px; } [data-dsh-admin-section] .toolbar .input-wrap { flex-basis: 100%; } [data-dsh-admin-section] .card-header { align-items: flex-start; } [data-dsh-admin-section] .card-actions { flex-wrap: wrap; justify-content: flex-end; } [data-dsh-admin-section] .footer { gap: 6px; align-items: flex-start; flex-direction: column; } [data-dsh-admin-section] .footer .path { max-width: 100%; } }',
103
+ '@keyframes dsh-toast-in { 0% { opacity: 0; transform: translateY(-12px) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } }',
104
+ '@keyframes dsh-toast-out { 0% { opacity: 1; transform: translateY(0) scale(1); } 100% { opacity: 0; transform: translateY(-8px) scale(0.96); } }',
105
+ '.dsh-admin-toast { position: fixed; top: 16px; left: 50%; transform: translateX(-50%); z-index: 99999; padding: 10px 20px; border-radius: 10px; font-size: 13px; font-weight: 500; line-height: 1.4; box-shadow: 0 8px 30px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.08); display: flex; align-items: center; gap: 8px; pointer-events: none; animation: dsh-toast-in 0.25s ease-out forwards; max-width: 90vw; word-break: break-word; }',
106
+ '.dsh-admin-toast.success { background: rgba(16, 185, 129, 0.95); color: #fff; }',
107
+ '.dsh-admin-toast.error { background: rgba(239, 68, 68, 0.95); color: #fff; }',
108
+ '.dsh-admin-toast.info { background: rgba(22, 119, 255, 0.95); color: #fff; }',
109
+ '.dsh-admin-toast.leaving { animation: dsh-toast-out 0.2s ease-in forwards; }',
110
+ ].join('\n')
111
+
112
+ function injectStyles() {
113
+ var tagId = 'dsh-plugin-admin/unified-section.css'
114
+ if (document.querySelector('style[data-plugin-css="' + tagId + '"]') === null) {
115
+ var tag = document.createElement('style')
116
+ tag.dataset.plugin = 'dsh-plugin-admin'
117
+ tag.dataset.pluginCss = tagId
118
+ tag.textContent = CSS_TEXT
119
+ document.head.appendChild(tag)
120
+ }
121
+ }
122
+
123
+ function baseName(path) {
124
+ if (path === null || path === '') return '(无工作目录)'
125
+ var parts = path.replace(/\\/g, '/').split('/')
126
+ var last = parts[parts.length - 1]
127
+ return last === '' ? parts[parts.length - 2] || path : last
128
+ }
129
+
130
+ function formatDate(ms) {
131
+ if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
132
+ var d = new Date(ms)
133
+ var pad = function (n) { return (n < 10 ? '0' : '') + String(n) }
134
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
135
+ + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
136
+ }
137
+
138
+ function messageOf(error) {
139
+ if (error !== null && typeof error === 'object' && typeof error.message === 'string') return error.message
140
+ if (typeof error === 'string') return error
141
+ return JSON.stringify(error)
142
+ }
143
+
144
+ /**
145
+ * Show a floating toast notification at the top of the viewport.
146
+ * Auto-dismisses after `duration` ms. Supports 'success', 'error', 'info'.
147
+ */
148
+ function showToast(type, text, duration) {
149
+ if (typeof document === 'undefined') return
150
+ duration = duration || 3000
151
+ var toast = document.createElement('div')
152
+ toast.className = 'dsh-admin-toast ' + type
153
+ toast.textContent = text
154
+ document.body.appendChild(toast)
155
+
156
+ var timer = setTimeout(function () {
157
+ toast.classList.add('leaving')
158
+ setTimeout(function () {
159
+ if (toast.parentNode) toast.parentNode.removeChild(toast)
160
+ }, 200)
161
+ }, duration)
162
+
163
+ toast.addEventListener('click', function () {
164
+ clearTimeout(timer)
165
+ if (!toast.classList.contains('leaving')) {
166
+ toast.classList.add('leaving')
167
+ setTimeout(function () {
168
+ if (toast.parentNode) toast.parentNode.removeChild(toast)
169
+ }, 200)
170
+ }
171
+ })
172
+ }
173
+
174
+ /**
175
+ * Copy text to the clipboard. Uses the async Clipboard API when available;
176
+ * falls back to a hidden textarea + execCommand for older browsers or
177
+ * non-secure contexts.
178
+ */
179
+ function copyTextToClipboard(text) {
180
+ if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
181
+ navigator.clipboard.writeText(text).then(function () {
182
+ showToast('success', '📋 会话 ID 已复制')
183
+ }, function () {
184
+ fallbackCopy(text)
185
+ })
186
+ return
187
+ }
188
+ fallbackCopy(text)
189
+ }
190
+
191
+ function fallbackCopy(text) {
192
+ try {
193
+ var ta = document.createElement('textarea')
194
+ ta.value = text
195
+ ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
196
+ document.body.appendChild(ta)
197
+ ta.select()
198
+ ta.setSelectionRange(0, text.length)
199
+ document.execCommand('copy')
200
+ document.body.removeChild(ta)
201
+ } catch (e) {
202
+ showToast('error', '❌ 复制失败:' + messageOf(e))
203
+ }
204
+ }
205
+
206
+ /* ========================================================================== */
207
+ /* Menu Popup Injection (Sessions & Workspaces) */
208
+ /* ========================================================================== */
209
+
210
+ /**
211
+ * Inject plugin items into the existing three-dot menu popup. The workspace
212
+ * bundle renders a Menu component (@deepseek-ai/dsh-client-ui-primitives)
213
+ * with portal:true, so the popup lives as a div[role="menu"] inside
214
+ * document.body with position:fixed. A MutationObserver detects when this
215
+ * popup appears, identifies it as a session or workspace menu by checking
216
+ * the existing button labels, and injects additional items.
217
+ */
218
+ function setupMenuInjection(call, refreshSessions) {
219
+ if (typeof document === 'undefined') return function () {}
220
+
221
+ /**
222
+ * Extract the session title from the row's aria-label. The anchor button
223
+ * aria-label is: 会话"<title>"的操作 (zh) / Session "<title>" actions (en).
224
+ * Extracting from the DOM keeps the injection synchronous — no RPC needed
225
+ * just to show the button, so the menu can't close before it appears.
226
+ */
227
+ function sessionTitleFromRow(row) {
228
+ var btn = row.querySelector('button[aria-label*="会话" i][aria-label*="操作" i], button[aria-label*="session" i][aria-label*="actions" i]')
229
+ if (btn === null) return null
230
+ var label = btn.getAttribute('aria-label') || ''
231
+ // zh: 会话"xxx"的操作 → strip 会话"/"的操作
232
+ var zh = /会话["“]([^"”]+)["”]/.exec(label)
233
+ if (zh) return zh[1].trim()
234
+ // en: Session "xxx" actions
235
+ var en = /session\s*["“]([^"”]+)["”]/i.exec(label)
236
+ if (en) return en[1].trim()
237
+ // fallback: whole label minus known prefixes/suffixes
238
+ return label.replace(/^会话/, '').replace(/^session/i, '').replace(/["“”]|的操作|actions$/gi, '').trim() || null
239
+ }
240
+
241
+ /** Find the treeitem row for the anchor button nearest the menu. */
242
+ function rowForMenu(menuEl) {
243
+ var menuRect = menuEl ? menuEl.getBoundingClientRect() : null
244
+ var allRows = document.querySelectorAll('[role="treeitem"]')
245
+ if (allRows.length === 0) return null
246
+ var rows = []
247
+ for (var i = 0; i < allRows.length; i++) {
248
+ var r = allRows[i]
249
+ // Only rows that contain a session/workspace action button
250
+ if (r.querySelector('button[aria-label*="会话" i][aria-label*="操作" i], button[aria-label*="工作区" i][aria-label*="操作" i], button[aria-label*="session" i][aria-label*="actions" i], button[aria-label*="workspace" i][aria-label*="actions" i]')) {
251
+ rows.push(r)
252
+ }
253
+ }
254
+ if (rows.length === 0) return null
255
+
256
+ // The Menu component renders with side="bottom": menu top sits just
257
+ // below the anchor button (button.bottom + 4). Match the row whose
258
+ // center is closest to the MENU TOP, not the menu center — the menu
259
+ // can be tall (several items), so its center drifts far from the
260
+ // clicked row.
261
+ if (menuRect === null || menuRect.height === 0) {
262
+ // No reliable menu rect. With a single candidate row the choice is
263
+ // unambiguous, so return it (jsdom/test environments have zero rects);
264
+ // with several rows refuse to guess rather than act on the wrong
265
+ // session.
266
+ if (rows.length === 1) return rows[0]
267
+ return null
268
+ }
269
+ var closest = rows[0]
270
+ var closestDist = Infinity
271
+ for (var j = 0; j < rows.length; j++) {
272
+ var rect = rows[j].getBoundingClientRect()
273
+ if (rect.height === 0) continue
274
+ var rowCenter = rect.top + rect.height / 2
275
+ var dist = Math.abs(rowCenter - menuRect.top)
276
+ if (dist < closestDist) {
277
+ closestDist = dist
278
+ closest = rows[j]
279
+ }
280
+ }
281
+ return closest
282
+ }
283
+
284
+ function injectItems(menuEl) {
285
+ if (menuEl.querySelector('[data-dsh-admin-injected]')) return
286
+
287
+ var text = menuEl.textContent || ''
288
+ var isSession = text.indexOf('归档会话') !== -1 || text.indexOf('Archive session') !== -1
289
+ var isWorkspace = !isSession && (text.indexOf('删除') !== -1 || text.indexOf('Delete workspace') !== -1)
290
+ if (!isSession && !isWorkspace) return
291
+
292
+ var viewport = menuEl.querySelector('[role="presentation"]')
293
+ if (viewport === null) return
294
+
295
+ var row = rowForMenu(menuEl)
296
+ if (row === null) return
297
+
298
+ if (isSession) {
299
+ var title = sessionTitleFromRow(row)
300
+ if (title === null) return
301
+ // Resolve sessions by title lazily at click time (fresh RPC) so the
302
+ // match is always current. Copy-id keeps the fuzzy matcher (a wrong
303
+ // match only mis-copies an id); delete must never guess, so it
304
+ // resolves by exact title only and refuses duplicate titles — the
305
+ // panel's rows act on ids and can disambiguate.
306
+ function fetchSessions(done) {
307
+ call('sessionAdmin/list', {}).then(function (listResult) {
308
+ done((listResult && listResult.ok && listResult.value && listResult.value.sessions) || [])
309
+ }, function () { showToast('error', '❌ 无法加载会话列表') })
310
+ }
311
+ // Normalize a title for comparison: trim, collapse spaces, drop
312
+ // trailing ellipsis and truncation artifacts.
313
+ function norm(v) {
314
+ return (v || '').replace(/\s+/g, ' ').replace(/\.{3,}\s*$/, '').trim().toLowerCase()
315
+ }
316
+
317
+ function resolveSessionFuzzy(cb) {
318
+ fetchSessions(function (sessions) {
319
+ var match = null
320
+ var nt = norm(title)
321
+ for (var i = 0; i < sessions.length; i++) {
322
+ var s = sessions[i]
323
+ var st = norm(s.title)
324
+ if (st === nt) { match = s; break }
325
+ // substring both ways (skip too-short needles)
326
+ if (nt.length > 3 && st.indexOf(nt) !== -1) { match = s; break }
327
+ if (st.length > 3 && nt.indexOf(st) !== -1) { match = s; break }
328
+ // fall back to cwd basename match
329
+ var cwdBase = s.cwd ? s.cwd.replace(/\\/g, '/').split('/').pop() : ''
330
+ if (cwdBase && (cwdBase === nt || nt.indexOf(cwdBase) !== -1 || cwdBase.indexOf(nt) !== -1)) { match = s; break }
331
+ }
332
+ if (match === null) { showToast('error', '❌ 未找到匹配的会话'); return }
333
+ cb(match)
334
+ })
335
+ }
336
+
337
+ function resolveSessionExact(cb) {
338
+ fetchSessions(function (sessions) {
339
+ var nt = norm(title)
340
+ var exact = []
341
+ for (var i = 0; i < sessions.length; i++) {
342
+ if (norm(sessions[i].title) === nt) exact.push(sessions[i])
343
+ }
344
+ if (exact.length === 0) { showToast('error', '❌ 未找到匹配的会话'); return }
345
+ if (exact.length > 1) {
346
+ showToast('error', '❌ 存在 ' + String(exact.length) + ' 个同名会话,无法确定要删除的目标;请在 设置 → 管理中心 → 会话管理 中按会话 ID 删除')
347
+ return
348
+ }
349
+ cb(exact[0])
350
+ })
351
+ }
352
+
353
+ // Copy the session id to the clipboard.
354
+ appendMenuItem(viewport, '复制会话 ID', 'normal', function () {
355
+ resolveSessionFuzzy(function (session) {
356
+ copyTextToClipboard(session.id)
357
+ })
358
+ })
359
+
360
+ // Delete the session. Online sessions are now closable through the
361
+ // captured AgentHandle (closeSession disposes the live agent/session
362
+ // first, so removing the log cannot resurrect it) — this stops a
363
+ // running conversation in that session. The item arms on the first
364
+ // click and only fires on a second click within 4s (confirmText below)
365
+ // — deletion is physical, so one misclick must never be enough.
366
+ appendMenuItem(viewport, '删除会话', 'danger', function () {
367
+ resolveSessionExact(function (match) {
368
+ var method = match.live ? 'sessionAdmin/closeSession' : 'sessionAdmin/deleteSession'
369
+ call(method, { sessionId: match.id }).then(function (result) {
370
+ if (result && result.ok) {
371
+ showToast('success', '🗑️ 会话已删除')
372
+ // Nudge the sidebar so the removed session vanishes now
373
+ // instead of lingering in 未分组 until reload.
374
+ if (refreshSessions) refreshSessions()
375
+ } else {
376
+ showToast('error', '❌ 删除会话失败:' + messageOf(result && result.error))
377
+ }
378
+ }, function (err) { showToast('error', '❌ 删除会话失败:' + messageOf(err)) })
379
+ })
380
+ }, '⚠️ 再点一次确认删除(在线会话将先关停)')
381
+ } else {
382
+ // Workspace: reveal in file manager. Path is resolved lazily at click.
383
+ var wsTitle = (row.querySelector('[class*="_title"]') || {}).textContent || ''
384
+ wsTitle = wsTitle.trim()
385
+ if (wsTitle === '') return
386
+ appendMenuItem(viewport, '在资源管理器打开', 'normal', function () {
387
+ call('sessionAdmin/list', {}).then(function (listResult) {
388
+ var workspaces = (listResult && listResult.ok && listResult.value && listResult.value.workspaces) || []
389
+ var wsPath = null
390
+ for (var i = 0; i < workspaces.length; i++) {
391
+ var w = workspaces[i]
392
+ var wTitle = (w && w.title) || ''
393
+ if (wTitle === wsTitle || (wsTitle !== '' && wTitle !== '' && (wsTitle.indexOf(wTitle) !== -1 || wTitle.indexOf(wsTitle) !== -1))) { wsPath = w && w.path; break }
394
+ }
395
+ if (wsPath === null) { showToast('error', '❌ 打开失败:未找到工作区路径'); return }
396
+ call('fsAdmin/reveal', { path: wsPath }).then(function (result) {
397
+ // Success needs no toast — the Explorer window opening is the
398
+ // feedback itself. Only failures get a hint.
399
+ if (!(result && result.ok)) showToast('error', '❌ 打开失败:' + messageOf(result && result.error))
400
+ }, function (err) { showToast('error', '❌ 打开失败:' + messageOf(err)) })
401
+ }, function () { showToast('error', '❌ 打开失败:无法加载工作区列表') })
402
+ })
403
+ }
404
+ }
405
+
406
+ function appendMenuItem(viewport, label, kind, onClick, confirmText) {
407
+ // Separator (only if there are already items — always, to visually group)
408
+ var sep = document.createElement('div')
409
+ sep.setAttribute('role', 'separator')
410
+ sep.setAttribute('data-dsh-admin-injected', '')
411
+ sep.style.cssText = 'margin:3px 0;border-top:1px solid var(--dsw-alias-border-subtle,rgba(0,0,0,0.08))'
412
+ viewport.appendChild(sep)
413
+
414
+ var btn = document.createElement('button')
415
+ btn.type = 'button'
416
+ btn.setAttribute('role', 'menuitem')
417
+ btn.setAttribute('data-dsh-admin-injected', '')
418
+ btn.textContent = label
419
+ var color = kind === 'danger' ? '#ef4444' : 'inherit'
420
+ var hoverBg = kind === 'danger' ? 'rgba(239,68,68,0.1)' : 'rgba(0,0,0,0.06)'
421
+ btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 10px;border:0;background:transparent;cursor:pointer;font:inherit;font-size:12px;color:' + color + ';border-radius:5px;text-align:left'
422
+ btn.addEventListener('mouseenter', function () { btn.style.background = hoverBg })
423
+ btn.addEventListener('mouseleave', function () { btn.style.background = 'transparent' })
424
+ // Optional two-step confirm (confirmText): the first click arms the
425
+ // item and relabels it; only a second click within 4s runs onClick —
426
+ // destructive items must never fire on a single misclick.
427
+ var armed = false
428
+ var disarmTimer = null
429
+ btn.addEventListener('click', function (e) {
430
+ e.stopPropagation()
431
+ if (confirmText === undefined) { onClick(); return }
432
+ if (!armed) {
433
+ armed = true
434
+ btn.textContent = confirmText
435
+ disarmTimer = setTimeout(function () {
436
+ armed = false
437
+ btn.textContent = label
438
+ }, 4000)
439
+ return
440
+ }
441
+ clearTimeout(disarmTimer)
442
+ armed = false
443
+ btn.textContent = label
444
+ onClick()
445
+ })
446
+ viewport.appendChild(btn)
447
+ }
448
+
449
+ var observer = new MutationObserver(function (mutations) {
450
+ for (var i = 0; i < mutations.length; i++) {
451
+ var added = mutations[i].addedNodes
452
+ if (!added || added.length === 0) continue
453
+ for (var j = 0; j < added.length; j++) {
454
+ var node = added[j]
455
+ if (node.nodeType !== 1) continue
456
+ var el = node
457
+ if (el.getAttribute && el.getAttribute('role') === 'menu') {
458
+ injectItems(el)
459
+ } else {
460
+ var menu = el.querySelector && el.querySelector('[role="menu"]')
461
+ if (menu) injectItems(menu)
462
+ }
463
+ }
464
+ }
465
+ })
466
+
467
+ observer.observe(document.body, { childList: true, subtree: true })
468
+
469
+ return function () {
470
+ observer.disconnect()
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Plugins management settings section (standalone, no internal tabs).
476
+ */
477
+ function PluginsSection(props) {
478
+ var pairPlugins = useState({
479
+ profileDir: '',
480
+ plugins: [],
481
+ busy: false,
482
+ error: '',
483
+ spec: '',
484
+ confirming: null,
485
+ note: '',
486
+ output: '',
487
+ filter: 'all',
488
+ })
489
+ var pView = pairPlugins[0]
490
+ var setPView = pairPlugins[1]
491
+
492
+ var alive = useRef(false)
493
+
494
+ function patchPlugin(partial) {
495
+ setPView(function (cur) {
496
+ var next = {}
497
+ for (var k in cur) next[k] = cur[k]
498
+ for (var pk in partial) next[pk] = partial[pk]
499
+ return next
500
+ })
501
+ }
502
+
503
+ function callRemote(method, args) {
504
+ return props.call(method, args)
505
+ }
506
+
507
+ function reloadPlugins() {
508
+ patchPlugin({ busy: true, error: '', note: '' })
509
+ callRemote('pluginAdmin/list', {}).then(function (result) {
510
+ if (!alive.current) return
511
+ if (result.ok) {
512
+ patchPlugin({
513
+ busy: false,
514
+ profileDir: (result.value && result.value.profileDir) || '',
515
+ plugins: (result.value && result.value.plugins) || [],
516
+ })
517
+ } else {
518
+ patchPlugin({ busy: false, error: '加载插件失败:' + messageOf(result.error) })
519
+ }
520
+ }, function (failure) {
521
+ if (!alive.current) return
522
+ patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
523
+ })
524
+ }
525
+
526
+ function installPlugin() {
527
+ if (pView.busy || pView.spec.trim() === '') return
528
+ var spec = pView.spec.trim()
529
+ patchPlugin({ busy: true, error: '', confirming: null, note: '' })
530
+ callRemote('pluginAdmin/install', { spec: spec }).then(function (result) {
531
+ if (!alive.current) return
532
+ if (result.ok) {
533
+ patchPlugin({
534
+ busy: false,
535
+ note: '安装完成。更改在重启 dsh 后生效',
536
+ output: (result.value && result.value.output) || '',
537
+ profileDir: (result.value && result.value.profileDir) || '',
538
+ plugins: (result.value && result.value.plugins) || [],
539
+ spec: '',
540
+ })
541
+ return
542
+ }
543
+ patchPlugin({ busy: false, error: '安装失败:' + messageOf(result.error) })
544
+ reloadPlugins()
545
+ }, function (failure) {
546
+ if (!alive.current) return
547
+ patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
548
+ })
549
+ }
550
+
551
+ function removePlugin(name) {
552
+ patchPlugin({ busy: true, error: '', confirming: null, note: '' })
553
+ callRemote('pluginAdmin/remove', { name: name }).then(function (result) {
554
+ if (!alive.current) return
555
+ if (result.ok) {
556
+ patchPlugin({
557
+ busy: false,
558
+ note: '卸载完成。更改在重启 dsh 后生效',
559
+ output: (result.value && result.value.output) || '',
560
+ profileDir: (result.value && result.value.profileDir) || '',
561
+ plugins: (result.value && result.value.plugins) || [],
562
+ })
563
+ return
564
+ }
565
+ patchPlugin({ busy: false, error: '卸载失败:' + messageOf(result.error) })
566
+ reloadPlugins()
567
+ }, function (failure) {
568
+ if (!alive.current) return
569
+ patchPlugin({ busy: false, error: '调用失败:' + messageOf(failure) })
570
+ })
571
+ }
572
+
573
+ useEffect(function () {
574
+ alive.current = true
575
+ reloadPlugins()
576
+ return function () { alive.current = false }
577
+ }, [])
578
+
579
+ return createElement('div', { 'data-dsh-admin-section': '' },
580
+ renderPluginsView(pView, patchPlugin, reloadPlugins, installPlugin, removePlugin))
581
+ }
582
+
583
+ /**
584
+ * Sessions management settings section (standalone, no internal tabs).
585
+ */
586
+ function SessionsSection(props) {
587
+ var pairSessions = useState({
588
+ sessions: [],
589
+ workspaces: [],
590
+ busy: false,
591
+ error: '',
592
+ needle: '',
593
+ confirming: null,
594
+ filter: 'all',
595
+ })
596
+ var sView = pairSessions[0]
597
+ var setSView = pairSessions[1]
598
+
599
+ var alive = useRef(false)
600
+
601
+ function patchSession(partial) {
602
+ setSView(function (cur) {
603
+ var next = {}
604
+ for (var k in cur) next[k] = cur[k]
605
+ for (var pk in partial) next[pk] = partial[pk]
606
+ return next
607
+ })
608
+ }
609
+
610
+ function callRemote(method, args) {
611
+ return props.call(method, args)
612
+ }
613
+
614
+ function reloadSessions() {
615
+ patchSession({ busy: true, error: '' })
616
+ callRemote('sessionAdmin/list', {}).then(function (result) {
617
+ if (!alive.current) return
618
+ if (result.ok) {
619
+ patchSession({
620
+ busy: false,
621
+ sessions: (result.value && result.value.sessions) || [],
622
+ workspaces: (result.value && result.value.workspaces) || [],
623
+ })
624
+ } else {
625
+ patchSession({ busy: false, error: '加载会话失败:' + messageOf(result.error) })
626
+ }
627
+ }, function (failure) {
628
+ if (!alive.current) return
629
+ patchSession({ busy: false, error: '调用失败:' + messageOf(failure) })
630
+ })
631
+ }
632
+
633
+ function actSession(method, sessionId) {
634
+ patchSession({ busy: true, error: '', confirming: null })
635
+ callRemote('sessionAdmin/' + method, { sessionId: sessionId }).then(function (result) {
636
+ if (!alive.current) return
637
+ if (result.ok) {
638
+ // Refresh this panel's own list, and nudge the sidebar (via the
639
+ // sessions service) so a deleted session disappears immediately
640
+ // instead of lingering until reload.
641
+ if ((method === 'deleteSession' || method === 'closeSession') && props.refreshSessions) props.refreshSessions()
642
+ return reloadSessions()
643
+ }
644
+ patchSession({ busy: false, error: '操作失败:' + messageOf(result.error) })
645
+ }, function (failure) {
646
+ if (!alive.current) return
647
+ patchSession({ busy: false, error: '调用失败:' + messageOf(failure) })
648
+ })
649
+ }
650
+
651
+ useEffect(function () {
652
+ alive.current = true
653
+ reloadSessions()
654
+ return function () { alive.current = false }
655
+ }, [])
656
+
657
+ return createElement('div', { 'data-dsh-admin-section': '' },
658
+ renderSessionsView(sView, patchSession, reloadSessions, actSession))
659
+ }
660
+
661
+ /**
662
+ * MCP configuration settings section (standalone, no internal tabs).
663
+ */
664
+ function McpSection(props) {
665
+ var pairMcp = useState({
666
+ mcpEntries: [],
667
+ mcpBusy: false,
668
+ mcpError: '',
669
+ mcpEditorOpen: false,
670
+ mcpDraft: null,
671
+ mcpTestState: {}, // entryId -> { busy, result }
672
+ })
673
+ var mView = pairMcp[0]
674
+ var setMView = pairMcp[1]
675
+
676
+ var alive = useRef(false)
677
+
678
+ function patchMcp(partial) {
679
+ setMView(function (cur) {
680
+ var next = {}
681
+ for (var k in cur) next[k] = cur[k]
682
+ for (var pk in partial) next[pk] = partial[pk]
683
+ return next
684
+ })
685
+ }
686
+
687
+ // Merge a partial into the CURRENT mcpDraft via a functional update, so
688
+ // rapid successive field edits (React batching) never lose earlier input.
689
+ function patchDraft(partial) {
690
+ setMView(function (cur) {
691
+ var next = {}
692
+ for (var k in cur) next[k] = cur[k]
693
+ next.mcpDraft = mergeDraft(cur.mcpDraft, partial)
694
+ return next
695
+ })
696
+ }
697
+
698
+ function callRemote(method, args) {
699
+ return props.call(method, args)
700
+ }
701
+
702
+ function reloadMcp() {
703
+ patchMcp({ mcpBusy: true, mcpError: '' })
704
+ callRemote('mcpAdmin/list', {}).then(function (result) {
705
+ if (!alive.current) return
706
+ if (result.ok) {
707
+ patchMcp({ mcpBusy: false, mcpEntries: (result.value && result.value.entries) || [] })
708
+ } else {
709
+ patchMcp({ mcpBusy: false, mcpError: '加载 MCP 配置失败:' + messageOf(result.error) })
710
+ }
711
+ }, function (failure) {
712
+ if (!alive.current) return
713
+ patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
714
+ })
715
+ }
716
+
717
+ function openMcpEditor(entry) {
718
+ if (entry !== null && entry !== undefined && (entry.config === null || entry.config === undefined)) {
719
+ patchMcp({ mcpError: '该 MCP 配置无法安全解析,已禁止在此覆盖;请在 cordis.patch.yml 中手动编辑。' })
720
+ return
721
+ }
722
+ var source = entry !== null && entry !== undefined ? entry.config : null
723
+ var draft = source ? {
724
+ id: entry.id,
725
+ isNew: false,
726
+ serverName: source.serverName || entry.id,
727
+ transport: source.transport,
728
+ command: source.command || '',
729
+ url: source.url || '',
730
+ args: (source.args || []).join(' '),
731
+ argsOriginal: source.args || [],
732
+ argsChanged: false,
733
+ env: Object.keys(source.env || {}).map(function (key) { return key + '=' + source.env[key] }).join('\n'),
734
+ envOriginal: source.env || {},
735
+ envChanged: false,
736
+ cwd: source.cwd || '',
737
+ headers: Object.keys(source.headers || {}).map(function (k) { return k + '=' + source.headers[k] }).join('\n'),
738
+ headersOriginal: source.headers || {},
739
+ headersChanged: false,
740
+ toolCallTimeoutMs: source.toolCallTimeoutMs,
741
+ failOnStartupError: source.failOnStartupError === true,
742
+ reconnectEnabled: source.reconnect ? source.reconnect.enabled === true : false,
743
+ reconnectInitialDelayMs: source.reconnect ? source.reconnect.initialDelayMs : 1000,
744
+ reconnectMaxDelayMs: source.reconnect ? source.reconnect.maxDelayMs : 30000,
745
+ reconnectMaxAttempts: source.reconnect ? source.reconnect.maxAttempts : 10,
746
+ } : {
747
+ // New server: pre-fill a collision-free id so the user does not have to
748
+ // invent one; they can still edit it or regenerate it.
749
+ id: generateMcpId(mView.mcpEntries),
750
+ isNew: true,
751
+ serverName: '',
752
+ transport: 'stdio',
753
+ command: '',
754
+ url: '',
755
+ args: '',
756
+ argsOriginal: [],
757
+ argsChanged: false,
758
+ env: '',
759
+ envOriginal: {},
760
+ envChanged: false,
761
+ cwd: '',
762
+ headers: '',
763
+ headersOriginal: {},
764
+ headersChanged: false,
765
+ toolCallTimeoutMs: undefined,
766
+ failOnStartupError: false,
767
+ reconnectEnabled: false,
768
+ reconnectInitialDelayMs: 1000,
769
+ reconnectMaxDelayMs: 30000,
770
+ reconnectMaxAttempts: 10,
771
+ }
772
+ patchMcp({ mcpEditorOpen: true, mcpDraft: draft, mcpError: '' })
773
+ }
774
+
775
+ function closeMcpEditor() {
776
+ patchMcp({ mcpEditorOpen: false, mcpDraft: null, mcpError: '' })
777
+ }
778
+
779
+ function saveMcpDraft() {
780
+ var draft = mView.mcpDraft
781
+ if (draft === null) return
782
+ var config
783
+ if (draft.transport === 'streamable-http') {
784
+ config = { transport: 'streamable-http', serverName: draft.serverName, url: draft.url }
785
+ var headersStr = Object.keys(draft.headersOriginal || {}).map(function (k) { return k + '=' + draft.headersOriginal[k] }).join('\n')
786
+ var headers = (draft.headersChanged && draft.headers !== headersStr) ? {} : draft.headersOriginal
787
+ if (draft.headersChanged && draft.headers !== headersStr && draft.headers !== '') {
788
+ // Newline-only split: header values legitimately contain ';'/','
789
+ // (Cookie, Accept), which must never become pair separators.
790
+ var pairs = draft.headers.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
791
+ for (var i = 0; i < pairs.length; i++) {
792
+ var eq = pairs[i].indexOf('=')
793
+ if (eq > 0) headers[pairs[i].slice(0, eq).trim()] = pairs[i].slice(eq + 1).trim()
794
+ }
795
+ }
796
+ if (Object.keys(headers).length > 0) config.headers = headers
797
+ } else {
798
+ config = { transport: 'stdio', serverName: draft.serverName, command: draft.command }
799
+ var argsStr = (draft.argsOriginal || []).join(' ')
800
+ var args = (draft.argsChanged && draft.args !== argsStr) ? draft.args.split(/\s+/).filter(Boolean) : draft.argsOriginal
801
+ if (args.length > 0) config.args = args
802
+ var envStr = Object.keys(draft.envOriginal || {}).map(function (k) { return k + '=' + draft.envOriginal[k] }).join('\n')
803
+ var env = (draft.envChanged && draft.env !== envStr) ? {} : draft.envOriginal
804
+ if (draft.envChanged && draft.env !== envStr && draft.env !== '') {
805
+ // Newline-only split: values like PATH=C:\a;C:\b would lose their
806
+ // tail (and Windows paths carry ';' everywhere).
807
+ var pairs = draft.env.split('\n').map(function (s) { return s.trim() }).filter(Boolean)
808
+ for (var i = 0; i < pairs.length; i++) {
809
+ var eq = pairs[i].indexOf('=')
810
+ if (eq > 0) env[pairs[i].slice(0, eq).trim()] = pairs[i].slice(eq + 1).trim()
811
+ }
812
+ }
813
+ if (Object.keys(env).length > 0) config.env = env
814
+ if (draft.cwd !== '') config.cwd = draft.cwd
815
+ }
816
+ if (draft.toolCallTimeoutMs !== undefined) config.toolCallTimeoutMs = draft.toolCallTimeoutMs
817
+ if (draft.failOnStartupError) config.failOnStartupError = true
818
+ if (draft.reconnectEnabled) {
819
+ config.reconnect = {
820
+ enabled: true,
821
+ initialDelayMs: Number(draft.reconnectInitialDelayMs),
822
+ maxDelayMs: Number(draft.reconnectMaxDelayMs),
823
+ maxAttempts: Number(draft.reconnectMaxAttempts),
824
+ }
825
+ }
826
+ patchMcp({ mcpBusy: true, mcpError: '' })
827
+ callRemote('mcpAdmin/upsert', { entry: { id: draft.id, config: config } }).then(function (result) {
828
+ if (!alive.current) return
829
+ if (result.ok) {
830
+ patchMcp({ mcpBusy: false, mcpEntries: (result.value && result.value.entries) || [], mcpEditorOpen: false, mcpDraft: null })
831
+ } else {
832
+ patchMcp({ mcpBusy: false, mcpError: '保存 MCP 配置失败:' + messageOf(result.error) })
833
+ }
834
+ }, function (failure) {
835
+ if (!alive.current) return
836
+ patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
837
+ })
838
+ }
839
+
840
+ function removeMcpEntry(id) {
841
+ patchMcp({ mcpBusy: true, mcpError: '' })
842
+ callRemote('mcpAdmin/remove', { id: id }).then(function (result) {
843
+ if (!alive.current) return
844
+ if (result.ok) {
845
+ patchMcp({ mcpBusy: false, mcpEntries: (result.value && result.value.entries) || [] })
846
+ } else {
847
+ patchMcp({ mcpBusy: false, mcpError: '移除 MCP 配置失败:' + messageOf(result.error) })
848
+ }
849
+ }, function (failure) {
850
+ if (!alive.current) return
851
+ patchMcp({ mcpBusy: false, mcpError: '调用失败:' + messageOf(failure) })
852
+ })
853
+ }
854
+
855
+ /** Set one entry's connectivity-test status against the CURRENT state. */
856
+ function patchMcpTest(id, partial) {
857
+ setMView(function (cur) {
858
+ var next = {}
859
+ for (var k in cur) next[k] = cur[k]
860
+ next.mcpTestState = mergeTestState(cur.mcpTestState, id, partial)
861
+ return next
862
+ })
863
+ }
864
+
865
+ /** Run a host-side connectivity probe for one entry and stash the result. */
866
+ function testMcpEntry(id) {
867
+ patchMcpTest(id, { busy: true, result: null, error: null })
868
+ callRemote('mcpAdmin/test', { id: id }).then(function (result) {
869
+ if (!alive.current) return
870
+ if (result.ok && result.value !== null && typeof result.value === 'object') {
871
+ patchMcpTest(id, { busy: false, result: result.value })
872
+ } else {
873
+ patchMcpTest(id, { busy: false, result: null, error: messageOf(result.error) })
874
+ }
875
+ }, function (failure) {
876
+ if (!alive.current) return
877
+ patchMcpTest(id, { busy: false, result: null, error: messageOf(failure) })
878
+ })
879
+ }
880
+
881
+ useEffect(function () {
882
+ alive.current = true
883
+ reloadMcp()
884
+ return function () { alive.current = false }
885
+ }, [])
886
+
887
+ return createElement('div', { 'data-dsh-admin-section': '' },
888
+ renderMcpSection(mView, patchDraft, reloadMcp, openMcpEditor, closeMcpEditor, saveMcpDraft, removeMcpEntry, testMcpEntry))
889
+ }
890
+
891
+ /** Unified settings page; only the selected panel mounts and loads. */
892
+ function AdminCenterSection(props) {
893
+ var pairTab = useState('plugins')
894
+ var activeTab = pairTab[0]
895
+ var setActiveTab = pairTab[1]
896
+ var tabs = [
897
+ { id: 'plugins', label: '插件管理', component: PluginsSection },
898
+ { id: 'sessions', label: '会话管理', component: SessionsSection },
899
+ { id: 'mcp', label: 'MCP 配置', component: McpSection },
900
+ ]
901
+ var selected = tabs.find(function (tab) { return tab.id === activeTab }) || tabs[0]
902
+
903
+ return createElement('div', { 'data-dsh-admin-section': '' },
904
+ createElement('div', { className: 'tabs', role: 'tablist', 'aria-label': '管理中心功能' },
905
+ tabs.map(function (tab) {
906
+ return createElement('button', {
907
+ type: 'button',
908
+ key: tab.id,
909
+ className: 'tab' + (tab.id === selected.id ? ' active' : ''),
910
+ role: 'tab',
911
+ 'aria-selected': tab.id === selected.id,
912
+ onClick: function () { setActiveTab(tab.id) },
913
+ }, tab.label)
914
+ })
915
+ ),
916
+ createElement(selected.component, { key: selected.id, call: props.call, refreshSessions: props.refreshSessions })
917
+ )
918
+ }
919
+
920
+ /* ========================================================================== */
921
+ /* Render Plugins View */
922
+ /* ========================================================================== */
923
+
924
+ function renderPluginsView(view, patch, reload, install, remove) {
925
+ var elements = []
926
+
927
+ // Toolbar
928
+ elements.push(createElement('div', { className: 'toolbar', key: 'toolbar' },
929
+ createElement('div', { className: 'input-wrap', key: 'wrap' },
930
+ createElement('input', {
931
+ className: 'input',
932
+ placeholder: '输入 npm 包名或本地绝对路径...',
933
+ value: view.spec,
934
+ disabled: view.busy,
935
+ onChange: function (e) { patch({ spec: e.target.value }) },
936
+ onKeyDown: function (e) { if (e.key === 'Enter') install() },
937
+ })
938
+ ),
939
+ createElement('button', {
940
+ type: 'button',
941
+ key: 'btn-install',
942
+ className: 'btn primary',
943
+ disabled: view.busy || view.spec.trim() === '',
944
+ onClick: install,
945
+ },
946
+ view.busy ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
947
+ '安装'
948
+ ),
949
+ createElement('button', {
950
+ type: 'button',
951
+ key: 'btn-refresh',
952
+ className: 'btn',
953
+ disabled: view.busy,
954
+ onClick: reload,
955
+ }, '刷新')
956
+ ))
957
+
958
+ // Filter Pills
959
+ var totalPlugins = view.plugins.length
960
+ var thirdPartyCount = 0
961
+ var builtinCount = 0
962
+ for (var i = 0; i < view.plugins.length; i++) {
963
+ if (view.plugins[i].removable) thirdPartyCount++
964
+ else builtinCount++
965
+ }
966
+
967
+ elements.push(createElement('div', { className: 'filter-bar', key: 'filters' },
968
+ createElement('button', {
969
+ type: 'button',
970
+ key: 'filter-all',
971
+ className: 'pill' + (view.filter === 'all' ? ' active' : ''),
972
+ onClick: function () { patch({ filter: 'all' }) },
973
+ }, '全部 (' + String(totalPlugins) + ')'),
974
+ createElement('button', {
975
+ type: 'button',
976
+ key: 'filter-plugin',
977
+ className: 'pill' + (view.filter === 'plugin' ? ' active' : ''),
978
+ onClick: function () { patch({ filter: 'plugin' }) },
979
+ }, '扩展插件 (' + String(thirdPartyCount) + ')'),
980
+ createElement('button', {
981
+ type: 'button',
982
+ key: 'filter-builtin',
983
+ className: 'pill' + (view.filter === 'builtin' ? ' active' : ''),
984
+ onClick: function () { patch({ filter: 'builtin' }) },
985
+ }, '系统内置 (' + String(builtinCount) + ')')
986
+ ))
987
+
988
+ // Busy banner / Error
989
+ if (view.busy) {
990
+ elements.push(createElement('div', { className: 'busy-banner', key: 'busy' },
991
+ createElement('span', { className: 'spinner', key: 'spin' }),
992
+ '正在执行 pnpm 操作(可能需要数秒至数分钟,请勿关闭窗口)...'
993
+ ))
994
+ }
995
+ if (view.error !== '') {
996
+ elements.push(createElement('div', { className: 'error', key: 'error' }, view.error))
997
+ }
998
+
999
+ // Filter and render rows
1000
+ var rows = []
1001
+ for (var j = 0; j < view.plugins.length; j++) {
1002
+ var p = view.plugins[j]
1003
+ if (view.filter === 'plugin' && !p.removable) continue
1004
+ if (view.filter === 'builtin' && p.removable) continue
1005
+ rows.push(renderPluginCard(p, view, remove, patch))
1006
+ }
1007
+
1008
+ if (rows.length === 0) {
1009
+ rows.push(createElement('div', { className: 'empty', key: 'empty' },
1010
+ createElement('div', null, '📦 暂无匹配的插件层')
1011
+ ))
1012
+ }
1013
+
1014
+ elements.push(createElement('div', { className: 'list', key: 'list' }, rows))
1015
+
1016
+ // Footer
1017
+ var hintText = view.note !== '' ? view.note : '更改在重启 dsh 后生效(关闭 dsh 进程后重新运行即可)'
1018
+ var hintTitle = view.output !== '' ? 'pnpm 输出:\n' + view.output : undefined
1019
+ elements.push(createElement('div', { className: 'footer', key: 'footer' },
1020
+ createElement('span', { className: 'path', key: 'profile-path', title: view.profileDir }, view.profileDir || 'Profile: 默认'),
1021
+ createElement('span', { className: 'hint', key: 'hint', title: hintTitle }, hintText)
1022
+ ))
1023
+
1024
+ return createElement('div', { key: 'plugins-panel', style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, elements)
1025
+ }
1026
+
1027
+
1028
+ function renderMcpSection(view, patchDraft, reloadMcp, openMcpEditor, closeMcpEditor, saveMcpDraft, removeMcpEntry, testMcpEntry) {
1029
+ var children = []
1030
+ var header = createElement('div', { className: 'group-header', key: 'mcp-header' },
1031
+ createElement('span', { className: 'group-title', key: 't' }, '🔌 MCP 配置'),
1032
+ createElement('span', { className: 'group-count', key: 'c' }, String(view.mcpEntries.length) + ' 个'),
1033
+ createElement('button', {
1034
+ type: 'button',
1035
+ key: 'btn-refresh-mcp',
1036
+ className: 'btn sm',
1037
+ disabled: view.mcpBusy,
1038
+ onClick: reloadMcp,
1039
+ }, '刷新'),
1040
+ createElement('button', {
1041
+ type: 'button',
1042
+ key: 'btn-add-mcp',
1043
+ className: 'btn primary sm',
1044
+ disabled: view.mcpBusy,
1045
+ onClick: function () { openMcpEditor(null) },
1046
+ }, '添加服务器'),
1047
+ )
1048
+ children.push(header)
1049
+
1050
+ if (view.mcpError !== '') {
1051
+ children.push(createElement('div', { className: 'error', key: 'mcp-error' }, view.mcpError))
1052
+ }
1053
+
1054
+ var rows = []
1055
+ for (var i = 0; i < view.mcpEntries.length; i++) {
1056
+ var entry = view.mcpEntries[i]
1057
+ var tag = createElement('span', { className: 'tag plugin', key: 'mcp-tag-' + entry.id },
1058
+ 'MCP ' + String(entry.serverName || entry.id))
1059
+ var testState = (view.mcpTestState && view.mcpTestState[entry.id]) || null
1060
+ var testIndicator = null
1061
+ if (testState !== null && testState.busy) {
1062
+ testIndicator = createElement('span', { className: 'mcp-test mcp-test-busy', key: 'test-busy' },
1063
+ createElement('span', { className: 'spinner', key: 'sp' }),
1064
+ ' 检测中...')
1065
+ } else if (testState !== null && testState.result !== null && testState.result !== undefined) {
1066
+ var r = testState.result
1067
+ var ok = r.ok === true
1068
+ var label = ok ? '✅ 连通'
1069
+ : '❌ 不通'
1070
+ var detail = ok
1071
+ ? (r.serverInfo ? (r.serverInfo.name || '') + (r.serverInfo.version ? ' v' + r.serverInfo.version : '') : '') +
1072
+ (typeof r.toolCount === 'number' ? ' · ' + r.toolCount + ' 个工具' : '') +
1073
+ (r.pingOk === false ? ' · 初始化成功但 ping 失败' : '')
1074
+ : (r.error || '连接失败') + (r.ms !== undefined ? ' (' + r.ms + 'ms)' : '')
1075
+ var detailChildren = [
1076
+ createElement('span', { key: 'lbl' }, label + (detail !== '' ? ' ' + detail : '')),
1077
+ ]
1078
+ if (ok && Array.isArray(r.tools) && r.tools.length > 0) {
1079
+ detailChildren.push(createElement('span', { key: 'tools-hint', style: { opacity: 0.75 } },
1080
+ '工具:' + r.tools.join('、')))
1081
+ }
1082
+ if (r.warning) {
1083
+ detailChildren.push(createElement('span', { key: 'warn', className: 'mcp-test-warn', title: r.warning },
1084
+ '⚠️ ' + r.warning))
1085
+ }
1086
+ testIndicator = createElement('div', {
1087
+ className: 'mcp-test mcp-test-list ' + (ok ? 'mcp-test-ok' : 'mcp-test-fail'),
1088
+ key: 'test-result',
1089
+ title: detail,
1090
+ }, detailChildren)
1091
+ } else if (testState !== null && testState.error !== null && testState.error !== undefined) {
1092
+ testIndicator = createElement('span', {
1093
+ className: 'mcp-test mcp-test-fail',
1094
+ key: 'test-error',
1095
+ title: testState.error,
1096
+ }, '❌ ' + testState.error)
1097
+ }
1098
+ var rowChildren = [
1099
+ createElement('div', { className: 'card-header', key: 'h' },
1100
+ createElement('span', { className: 'card-title-text', key: 'name' }, entry.id),
1101
+ tag,
1102
+ createElement('div', { className: 'card-actions', key: 'a' },
1103
+ createElement('button', {
1104
+ type: 'button',
1105
+ className: 'btn sm',
1106
+ disabled: view.mcpBusy || (testState !== null && testState.busy) || entry.config === null || entry.config === undefined,
1107
+ title: entry.config === null || entry.config === undefined ? '该配置无法安全解析,请手动编辑 cordis.patch.yml' : (testState !== null && testState.busy ? '检测中...' : '测试该服务器的连通性'),
1108
+ onClick: function (id) { return function () { testMcpEntry(id) } }(entry.id),
1109
+ }, testState !== null && testState.busy ? '检测中' : '🔌 测试'),
1110
+ createElement('button', {
1111
+ type: 'button',
1112
+ className: 'btn sm',
1113
+ disabled: view.mcpBusy || (testState !== null && testState.busy) || entry.config === null || entry.config === undefined,
1114
+ title: entry.config === null || entry.config === undefined ? '该配置无法安全解析,请手动编辑 cordis.patch.yml' : undefined,
1115
+ onClick: function (value) { return function () { openMcpEditor(value) } }(entry),
1116
+ }, '编辑'),
1117
+ createElement('button', {
1118
+ type: 'button',
1119
+ className: 'btn danger sm',
1120
+ disabled: view.mcpBusy,
1121
+ onClick: function (id) { return function () { removeMcpEntry(id) } }(entry.id),
1122
+ }, '移除'),
1123
+ ),
1124
+ ),
1125
+ ]
1126
+ if (testIndicator !== null) {
1127
+ rowChildren.push(createElement('div', { className: 'mcp-test-row', key: 'test-row' }, testIndicator))
1128
+ }
1129
+ rows.push(createElement('div', { className: 'card', key: 'mcp-' + entry.id }, rowChildren))
1130
+ }
1131
+ if (rows.length === 0) {
1132
+ rows.push(createElement('div', { className: 'empty', key: 'mcp-empty' },
1133
+ createElement('div', null, '🔌 暂无 MCP 服务器配置')
1134
+ ))
1135
+ }
1136
+ children.push(createElement('div', { className: 'list', key: 'mcp-list' }, rows))
1137
+
1138
+ // Editor form. patchDraft merges against the CURRENT draft in state so
1139
+ // typing across fields never clobbers earlier edits (stale-closure guard).
1140
+ if (view.mcpEditorOpen && view.mcpDraft !== null) {
1141
+ var d = view.mcpDraft
1142
+ var fieldStyle = { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '8px' }
1143
+ var labelStyle = { fontSize: '11px', color: 'var(--dsw-alias-label-secondary, #666)' }
1144
+ var editor = createElement('div', { className: 'card', key: 'mcp-editor' },
1145
+ createElement('div', { className: 'card-header', key: 'h' },
1146
+ createElement('span', { className: 'card-title-text', key: 't' }, d.isNew ? '添加 MCP 服务器' : '编辑 ' + d.id),
1147
+ ),
1148
+ createElement('div', { style: fieldStyle, key: 'f-id' },
1149
+ createElement('label', { style: labelStyle }, 'ID(唯一标识,[A-Za-z0-9_-])'),
1150
+ createElement('div', { style: { display: 'flex', gap: '6px' } },
1151
+ createElement('input', {
1152
+ className: 'input',
1153
+ value: d.id,
1154
+ disabled: !d.isNew,
1155
+ onChange: function (e) { patchDraft({ id: e.target.value }) },
1156
+ }),
1157
+ d.isNew ? createElement('button', {
1158
+ type: 'button',
1159
+ className: 'btn sm',
1160
+ title: '重新生成一个随机 ID',
1161
+ onClick: function () { patchDraft({ id: generateMcpId(view.mcpEntries) }) },
1162
+ }, '🔄') : null,
1163
+ ),
1164
+ ),
1165
+ createElement('div', { style: fieldStyle, key: 'f-server' },
1166
+ createElement('label', { style: labelStyle }, 'serverName(模型命名空间)'),
1167
+ createElement('input', {
1168
+ className: 'input',
1169
+ value: d.serverName,
1170
+ onChange: function (e) { patchDraft({ serverName: e.target.value }) },
1171
+ }),
1172
+ ),
1173
+ createElement('div', { style: fieldStyle, key: 'f-transport' },
1174
+ createElement('label', { style: labelStyle }, '传输方式'),
1175
+ createElement('select', {
1176
+ className: 'input',
1177
+ value: d.transport,
1178
+ onChange: function (e) { patchDraft({ transport: e.target.value }) },
1179
+ },
1180
+ createElement('option', { value: 'stdio' }, 'stdio(子进程)'),
1181
+ createElement('option', { value: 'streamable-http' }, 'streamable-http(HTTP)'),
1182
+ ),
1183
+ ),
1184
+ d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-cmd' },
1185
+ createElement('label', { style: labelStyle }, 'command(启动命令)'),
1186
+ createElement('input', {
1187
+ className: 'input',
1188
+ value: d.command,
1189
+ placeholder: 'npx -y @modelcontextprotocol/server-github',
1190
+ onChange: function (e) { patchDraft({ command: e.target.value }) },
1191
+ }),
1192
+ ) : createElement('div', { style: fieldStyle, key: 'f-url' },
1193
+ createElement('label', { style: labelStyle }, 'url(MCP 端点)'),
1194
+ createElement('input', {
1195
+ className: 'input',
1196
+ value: d.url,
1197
+ placeholder: 'http://localhost:3000/mcp',
1198
+ onChange: function (e) { patchDraft({ url: e.target.value }) },
1199
+ }),
1200
+ ),
1201
+ d.transport === 'streamable-http' ? createElement('div', { style: fieldStyle, key: 'f-headers' },
1202
+ createElement('label', { style: labelStyle }, 'headers(每行 KEY=VALUE,可选)'),
1203
+ createElement('textarea', {
1204
+ className: 'input',
1205
+ style: { minHeight: '48px' },
1206
+ value: d.headers,
1207
+ onChange: function (e) { patchDraft({ headers: e.target.value, headersChanged: true }) },
1208
+ }),
1209
+ ) : null,
1210
+ d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-args' },
1211
+ createElement('label', { style: labelStyle }, 'args(空格分隔,可选)'),
1212
+ createElement('input', {
1213
+ className: 'input',
1214
+ value: d.args,
1215
+ onChange: function (e) { patchDraft({ args: e.target.value, argsChanged: true }) },
1216
+ }),
1217
+ ) : null,
1218
+ d.transport === 'stdio' ? createElement('div', { style: fieldStyle, key: 'f-env' },
1219
+ createElement('label', { style: labelStyle }, 'env(每行 KEY=VALUE,可选)'),
1220
+ createElement('textarea', {
1221
+ className: 'input',
1222
+ style: { minHeight: '48px' },
1223
+ value: d.env,
1224
+ onChange: function (e) { patchDraft({ env: e.target.value, envChanged: true }) },
1225
+ }),
1226
+ ) : null,
1227
+ createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 0', marginBottom: '4px', borderTop: '1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.06))', paddingTop: '8px' }, key: 'f-reconnect-header' },
1228
+ createElement('input', {
1229
+ type: 'checkbox',
1230
+ id: 'reconnect-toggle',
1231
+ checked: d.reconnectEnabled,
1232
+ onChange: function (e) { patchDraft({ reconnectEnabled: e.target.checked }) },
1233
+ style: { margin: '0' },
1234
+ }),
1235
+ createElement('label', { htmlFor: 'reconnect-toggle', style: { fontSize: '12px', fontWeight: 500, cursor: 'pointer', color: 'var(--dsw-alias-label-primary, #111)' } }, '启用自动重连'),
1236
+ ),
1237
+ d.reconnectEnabled ? createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '8px', paddingLeft: '4px' }, key: 'f-reconnect-fields' },
1238
+ createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-id' },
1239
+ createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'initialDelayMs'),
1240
+ createElement('input', {
1241
+ className: 'input',
1242
+ type: 'number',
1243
+ min: 0,
1244
+ value: d.reconnectInitialDelayMs,
1245
+ onChange: function (e) { patchDraft({ reconnectInitialDelayMs: Number(e.target.value) }) },
1246
+ }),
1247
+ ),
1248
+ createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-md' },
1249
+ createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'maxDelayMs'),
1250
+ createElement('input', {
1251
+ className: 'input',
1252
+ type: 'number',
1253
+ min: 0,
1254
+ value: d.reconnectMaxDelayMs,
1255
+ onChange: function (e) { patchDraft({ reconnectMaxDelayMs: Number(e.target.value) }) },
1256
+ }),
1257
+ ),
1258
+ createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 120px' }, key: 'f-reconnect-ma' },
1259
+ createElement('label', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-secondary, #666)' } }, 'maxAttempts'),
1260
+ createElement('input', {
1261
+ className: 'input',
1262
+ type: 'number',
1263
+ min: 0,
1264
+ value: d.reconnectMaxAttempts,
1265
+ onChange: function (e) { patchDraft({ reconnectMaxAttempts: Number(e.target.value) }) },
1266
+ }),
1267
+ ),
1268
+ ) : null,
1269
+ createElement('div', { className: 'card-actions', key: 'f-actions', style: { justifyContent: 'flex-end', gap: '6px' } },
1270
+ createElement('button', {
1271
+ type: 'button',
1272
+ className: 'btn',
1273
+ disabled: view.mcpBusy,
1274
+ onClick: closeMcpEditor,
1275
+ }, '取消'),
1276
+ createElement('button', {
1277
+ type: 'button',
1278
+ className: 'btn primary',
1279
+ disabled: view.mcpBusy || d.id.trim() === '' || d.serverName.trim() === '' ||
1280
+ (d.transport === 'stdio' ? d.command.trim() === '' : d.url.trim() === ''),
1281
+ onClick: saveMcpDraft,
1282
+ }, view.mcpBusy ? '保存中...' : '保存'),
1283
+ ),
1284
+ )
1285
+ children.push(editor)
1286
+ }
1287
+
1288
+ return createElement('div', { key: 'mcp-section', style: { display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '6px', borderTop: '1px solid var(--dsw-alias-border-subtle, rgba(0,0,0,0.08))', paddingTop: '8px' } }, children)
1289
+ }
1290
+
1291
+ // Characters allowed in an MCP entry id: [A-Za-z0-9_-]. The random part uses
1292
+ // alphanumerics only so the generated id reads cleanly and always matches the
1293
+ // host-side validation regex.
1294
+ var MCP_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
1295
+
1296
+ /**
1297
+ * Generate a fresh MCP entry id that is not already used by any listed entry
1298
+ * (an id collision would silently overwrite the existing entry on upsert).
1299
+ */
1300
+ function generateMcpId(entries) {
1301
+ var taken = {}
1302
+ for (var i = 0; i < entries.length; i++) taken[entries[i].id] = true
1303
+ var candidate
1304
+ do {
1305
+ candidate = 'mcp-'
1306
+ for (var j = 0; j < 8; j++) {
1307
+ candidate += MCP_ID_ALPHABET.charAt(Math.floor(Math.random() * MCP_ID_ALPHABET.length))
1308
+ }
1309
+ } while (taken[candidate])
1310
+ return candidate
1311
+ }
1312
+
1313
+ function mergeDraft(draft, partial) {
1314
+ var next = {}
1315
+ for (var k in draft) next[k] = draft[k]
1316
+ for (var pk in partial) next[pk] = partial[pk]
1317
+ return next
1318
+ }
1319
+
1320
+ /** Set one entry's test status inside the shared mcpTestState map. */
1321
+ function mergeTestState(map, id, partial) {
1322
+ var next = {}
1323
+ for (var k in map) next[k] = map[k]
1324
+ next[id] = {}
1325
+ var cur = map[id] || {}
1326
+ for (var ck in cur) next[id][ck] = cur[ck]
1327
+ for (var pk in partial) next[id][pk] = partial[pk]
1328
+ return next
1329
+ }
1330
+ function renderPluginCard(plugin, view, remove, patch) {
1331
+ var isConfirming = view.confirming === plugin.name
1332
+
1333
+ var titleChildren = [
1334
+ createElement('span', { className: 'card-title-text', key: 'name' }, plugin.name),
1335
+ ]
1336
+ if (plugin.version) {
1337
+ titleChildren.push(createElement('span', { className: 'tag version', key: 'version' }, 'v' + plugin.version))
1338
+ }
1339
+ var isLocal = Boolean(plugin.localPath)
1340
+ titleChildren.push(createElement('span', {
1341
+ className: plugin.removable ? (isLocal ? 'tag local' : 'tag plugin') : 'tag',
1342
+ key: 'type-tag',
1343
+ },
1344
+ plugin.removable ? (isLocal ? '本地安装' : '包安装') : '内置'
1345
+ ))
1346
+
1347
+ var headerChildren = [
1348
+ createElement('div', { className: 'card-title', key: 'title' }, titleChildren),
1349
+ ]
1350
+
1351
+ if (plugin.removable && !isConfirming) {
1352
+ headerChildren.push(createElement('div', { className: 'card-actions', key: 'actions' },
1353
+ createElement('button', {
1354
+ type: 'button',
1355
+ className: 'btn danger sm',
1356
+ disabled: view.busy,
1357
+ onClick: function () { patch({ confirming: plugin.name }) },
1358
+ }, '卸载')
1359
+ ))
1360
+ }
1361
+
1362
+ var header = createElement('div', { className: 'card-header', key: 'header' }, headerChildren)
1363
+ var cardChildren = [header]
1364
+
1365
+ if (plugin.localPath) {
1366
+ cardChildren.push(createElement('div', { className: 'plugin-path', key: 'path', title: plugin.localPath },
1367
+ '📂 ' + plugin.localPath
1368
+ ))
1369
+ }
1370
+
1371
+ if (isConfirming) {
1372
+ cardChildren.push(createElement('div', { className: 'confirm-bar', key: 'confirm' },
1373
+ createElement('span', { className: 'confirm-text', key: 'text' }, '⚠️ 确定要卸载该插件吗?'),
1374
+ createElement('div', { className: 'confirm-actions', key: 'actions' },
1375
+ createElement('button', {
1376
+ type: 'button',
1377
+ key: 'btn-confirm',
1378
+ className: 'btn danger-solid sm',
1379
+ disabled: view.busy,
1380
+ onClick: function () { remove(plugin.name) },
1381
+ }, '确认卸载'),
1382
+ createElement('button', {
1383
+ type: 'button',
1384
+ key: 'btn-cancel',
1385
+ className: 'btn sm',
1386
+ disabled: view.busy,
1387
+ onClick: function () { patch({ confirming: null }) },
1388
+ }, '取消')
1389
+ )
1390
+ ))
1391
+ }
1392
+
1393
+ return createElement('div', { key: plugin.name, className: 'card' }, cardChildren)
1394
+ }
1395
+
1396
+ /* ========================================================================== */
1397
+ /* Render Sessions View */
1398
+ /* ========================================================================== */
1399
+
1400
+ /**
1401
+ * Live-session display hint. "Online" means the session is still mounted in
1402
+ * the dsh host's in-memory SessionStore (created or opened at least once in
1403
+ * this process) — NOT that a turn is running. Online sessions can now be
1404
+ * closed directly: the admin panel's "关停并删除" disposes the captured agent
1405
+ * handle (stopping any running turn), removes the session from the store,
1406
+ * and then deletes the log — no restart needed.
1407
+ */
1408
+ var LIVE_HINT = '会话在线:仍挂载于 dsh host 内存(本进程内创建或打开过的会话保持在线,不代表正在运行);可直接"关停并删除"(会中断该会话正在进行的对话)'
1409
+
1410
+ /**
1411
+ * Canonical session status. Live takes precedence over archived (a session
1412
+ * that is both live and in the archived set is shown as 会话在线); otherwise
1413
+ * archived wins over ended. Used by both the filter pills and the pill
1414
+ * counts so they can never disagree about membership.
1415
+ */
1416
+ function sessionStatus(s) {
1417
+ if (s.live) return 'live'
1418
+ if (s.archived) return 'archived'
1419
+ return 'ended'
1420
+ }
1421
+
1422
+ function filterSessions(sessions, filter, needle) {
1423
+ var result = []
1424
+ for (var i = 0; i < sessions.length; i++) {
1425
+ var s = sessions[i]
1426
+ if (filter !== 'all' && sessionStatus(s) !== filter) continue
1427
+ if (needle !== '') {
1428
+ var hay = ((s.title || '') + ' ' + (s.summary || '') + ' ' + (s.cwd || '') + ' '
1429
+ + (s.workspaceTitle || '') + ' ' + s.id).toLowerCase()
1430
+ if (hay.indexOf(needle) === -1) continue
1431
+ }
1432
+ result.push(s)
1433
+ }
1434
+ return result
1435
+ }
1436
+
1437
+ /**
1438
+ * Group sessions by their accounting workspace in registry order; sessions
1439
+ * without a workspace trail under an "未分组" bucket. Empty workspaces are
1440
+ * omitted so the user only sees groups that currently hold a session.
1441
+ */
1442
+ function buildSessionGroups(filtered, workspaceOrder) {
1443
+ var byWs = new Map()
1444
+ for (var i = 0; i < workspaceOrder.length; i++) {
1445
+ var ws = workspaceOrder[i]
1446
+ byWs.set(ws.workspaceId, {
1447
+ workspaceId: ws.workspaceId, title: ws.title, path: ws.path, sessions: [],
1448
+ })
1449
+ }
1450
+ var ungrouped = []
1451
+ for (var j = 0; j < filtered.length; j++) {
1452
+ var s = filtered[j]
1453
+ var g = s.workspaceId ? byWs.get(s.workspaceId) : undefined
1454
+ if (g !== undefined) g.sessions.push(s)
1455
+ else ungrouped.push(s)
1456
+ }
1457
+ var groups = []
1458
+ for (var k = 0; k < workspaceOrder.length; k++) {
1459
+ var bucket = byWs.get(workspaceOrder[k].workspaceId)
1460
+ if (bucket !== undefined && bucket.sessions.length > 0) groups.push(bucket)
1461
+ }
1462
+ if (ungrouped.length > 0) {
1463
+ groups.push({ workspaceId: null, title: '未分组', path: null, sessions: ungrouped })
1464
+ }
1465
+ return groups
1466
+ }
1467
+
1468
+ function renderSessionsView(view, patch, reload, act) {
1469
+ var elements = []
1470
+
1471
+ // Toolbar
1472
+ elements.push(createElement('div', { className: 'toolbar', key: 'toolbar' },
1473
+ createElement('div', { className: 'input-wrap', key: 'wrap' },
1474
+ createElement('input', {
1475
+ className: 'input',
1476
+ placeholder: '搜索标题、内容摘要、目录或 Session ID...',
1477
+ value: view.needle,
1478
+ onChange: function (e) { patch({ needle: e.target.value }) },
1479
+ })
1480
+ ),
1481
+ createElement('button', {
1482
+ type: 'button',
1483
+ key: 'btn-refresh',
1484
+ className: 'btn',
1485
+ disabled: view.busy,
1486
+ onClick: reload,
1487
+ },
1488
+ view.busy ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
1489
+ '刷新'
1490
+ )
1491
+ ))
1492
+
1493
+ // Filter Pills
1494
+ var totalSessions = view.sessions.length
1495
+ var liveCount = 0
1496
+ var archivedCount = 0
1497
+ var endedCount = 0
1498
+ for (var i = 0; i < view.sessions.length; i++) {
1499
+ var s = view.sessions[i]
1500
+ var st = sessionStatus(s)
1501
+ if (st === 'live') liveCount++
1502
+ else if (st === 'archived') archivedCount++
1503
+ else endedCount++
1504
+ }
1505
+
1506
+ elements.push(createElement('div', { className: 'filter-bar', key: 'filters' },
1507
+ createElement('button', {
1508
+ type: 'button',
1509
+ key: 'filter-all',
1510
+ className: 'pill' + (view.filter === 'all' ? ' active' : ''),
1511
+ onClick: function () { patch({ filter: 'all' }) },
1512
+ }, '全部 (' + String(totalSessions) + ')'),
1513
+ createElement('button', {
1514
+ type: 'button',
1515
+ key: 'filter-live',
1516
+ className: 'pill' + (view.filter === 'live' ? ' active' : ''),
1517
+ title: LIVE_HINT,
1518
+ onClick: function () { patch({ filter: 'live' }) },
1519
+ }, '在线 (' + String(liveCount) + ')'),
1520
+ createElement('button', {
1521
+ type: 'button',
1522
+ key: 'filter-archived',
1523
+ className: 'pill' + (view.filter === 'archived' ? ' active' : ''),
1524
+ onClick: function () { patch({ filter: 'archived' }) },
1525
+ }, '已归档 (' + String(archivedCount) + ')'),
1526
+ createElement('button', {
1527
+ type: 'button',
1528
+ key: 'filter-ended',
1529
+ className: 'pill' + (view.filter === 'ended' ? ' active' : ''),
1530
+ onClick: function () { patch({ filter: 'ended' }) },
1531
+ }, '已结束 (' + String(endedCount) + ')')
1532
+ ))
1533
+
1534
+ if (view.error !== '') {
1535
+ elements.push(createElement('div', { className: 'error', key: 'error' }, view.error))
1536
+ }
1537
+
1538
+ // Filter then group by workspace (registry order); sessions without an
1539
+ // accounting workspace trail under "未分组", matching the sidebar model.
1540
+ var needle = view.needle.trim().toLowerCase()
1541
+ var filtered = filterSessions(view.sessions, view.filter, needle)
1542
+ var groups = buildSessionGroups(filtered, view.workspaces || [])
1543
+ var rows = []
1544
+ for (var gi = 0; gi < groups.length; gi++) {
1545
+ var g = groups[gi]
1546
+ var groupKey = g.workspaceId === null ? 'ungrouped' : 'ws-' + g.workspaceId
1547
+ rows.push(createElement('div', { className: 'group-header', key: 'header-' + groupKey },
1548
+ createElement('span', { className: 'group-title', key: 'title' },
1549
+ (g.workspaceId === null ? '📂 ' : '📁 ') + (g.title || '未命名')
1550
+ ),
1551
+ createElement('span', { className: 'group-count', key: 'count' }, String(g.sessions.length) + ' 个'),
1552
+ g.path !== null
1553
+ ? createElement('span', { className: 'group-path', key: 'path', title: g.path }, g.path)
1554
+ : null
1555
+ ))
1556
+ for (var si = 0; si < g.sessions.length; si++) {
1557
+ rows.push(renderSessionCard(g.sessions[si], view, act, patch))
1558
+ }
1559
+ }
1560
+
1561
+ if (rows.length === 0) {
1562
+ rows.push(createElement('div', { className: 'empty', key: 'empty' },
1563
+ createElement('div', null, needle !== '' ? '🔍 无匹配的会话内容' : '💬 没有已持久化的会话')
1564
+ ))
1565
+ }
1566
+
1567
+ elements.push(createElement('div', { className: 'list', key: 'list' }, rows))
1568
+
1569
+ // Footer
1570
+ var summaryText = view.busy ? '加载中...'
1571
+ : '共 ' + String(totalSessions) + ' 个会话,当前展示 ' + String(filtered.length) + ' 个'
1572
+
1573
+ elements.push(createElement('div', { className: 'footer', key: 'footer' },
1574
+ createElement('span', { key: 'summary' }, summaryText),
1575
+ createElement('span', { className: 'hint', key: 'hint' }, '会话修改即时同步到侧边栏')
1576
+ ))
1577
+
1578
+ return createElement('div', { key: 'sessions-panel', style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, elements)
1579
+ }
1580
+
1581
+ function renderSessionCard(session, view, act, patch) {
1582
+ var isConfirming = view.confirming === session.id
1583
+ var dotClass = 'dot' + (session.live ? ' live' : session.archived ? ' archived' : '')
1584
+ var dotTitle = session.live ? LIVE_HINT : session.archived ? '已归档' : '已结束'
1585
+
1586
+ var actions = []
1587
+ if (!isConfirming) {
1588
+ if (!session.archived && !session.live) {
1589
+ actions.push(createElement('button', {
1590
+ type: 'button',
1591
+ className: 'btn sm',
1592
+ key: 'btn-archive',
1593
+ disabled: view.busy,
1594
+ onClick: function () { act('archive', session.id) },
1595
+ }, '归档'))
1596
+ }
1597
+ if (session.archived) {
1598
+ actions.push(createElement('button', {
1599
+ type: 'button',
1600
+ className: 'btn sm',
1601
+ key: 'btn-unarchive',
1602
+ disabled: view.busy,
1603
+ onClick: function () { act('unarchive', session.id) },
1604
+ }, '取消归档'))
1605
+ }
1606
+ if (!session.live) {
1607
+ actions.push(createElement('button', {
1608
+ type: 'button',
1609
+ className: 'btn danger sm',
1610
+ key: 'btn-delete',
1611
+ disabled: view.busy,
1612
+ onClick: function () { patch({ confirming: session.id }) },
1613
+ }, '删除'))
1614
+ } else {
1615
+ // Online sessions can now be closed through the captured AgentHandle
1616
+ // (closeSession disposes the live agent/session first, so the log can
1617
+ // be removed without resurrection). This stops a running conversation,
1618
+ // so it is a destructive action with its own confirm flow.
1619
+ actions.push(createElement('button', {
1620
+ type: 'button',
1621
+ className: 'btn danger sm',
1622
+ key: 'btn-close',
1623
+ disabled: view.busy,
1624
+ title: '关停该在线会话(停止其 agent 运行)并永久删除日志记录',
1625
+ onClick: function () { patch({ confirming: session.id }) },
1626
+ }, '关停并删除'))
1627
+ }
1628
+ }
1629
+
1630
+ var titleChildren = [
1631
+ createElement('span', { className: dotClass, title: dotTitle, key: 'dot' }),
1632
+ createElement('div', { className: 'card-title', key: 'title' },
1633
+ createElement('span', { className: 'card-title-text', key: 'name', title: session.title || session.cwd },
1634
+ session.title || (session.cwd ? baseName(session.cwd) : '未命名会话')
1635
+ ),
1636
+ session.live ? createElement('span', { className: 'tag live', key: 'tag-live', title: LIVE_HINT }, '会话在线')
1637
+ : session.archived ? createElement('span', { className: 'tag archived', key: 'tag-archived' }, '已归档')
1638
+ : null,
1639
+ session.messageCount > 0 ? createElement('span', { className: 'tag turns', key: 'tag-turns' }, String(session.messageCount) + ' 条消息') : null
1640
+ ),
1641
+ ]
1642
+ var headerChildren
1643
+ if (actions.length > 0) {
1644
+ headerChildren = titleChildren.concat([createElement('div', { className: 'card-actions', key: 'actions' }, actions)])
1645
+ } else {
1646
+ headerChildren = titleChildren
1647
+ }
1648
+
1649
+ var header = createElement('div', { className: 'card-header', key: 'header' }, headerChildren)
1650
+ var cardChildren = [header]
1651
+
1652
+ // Content summary block
1653
+ if (session.summary) {
1654
+ cardChildren.push(createElement('div', { className: 'card-summary', key: 'summary', title: session.summary },
1655
+ createElement('span', { className: 'summary-icon', key: 'icon' }, '💬'),
1656
+ createElement('span', { className: 'summary-text', key: 'text' }, session.summary)
1657
+ ))
1658
+ } else if (session.summaryError) {
1659
+ cardChildren.push(createElement('div', { className: 'error', key: 'summary-error' },
1660
+ '摘要读取失败:' + session.summaryError
1661
+ ))
1662
+ }
1663
+
1664
+ // Sub metadata line
1665
+ var subChildren = [
1666
+ createElement('span', { className: 'card-sub-item', title: session.cwd || '无工作目录', key: 'path' },
1667
+ '📁 ' + (session.cwd ? baseName(session.cwd) + ' (' + session.cwd + ')' : '(无工作目录)')
1668
+ ),
1669
+ createElement('span', { key: 'sep1' }, '·'),
1670
+ createElement('span', { className: 'card-sub-item', key: 'time' }, '🕒 ' + formatDate(session.createdAt)),
1671
+ ]
1672
+ if (session.id) {
1673
+ subChildren.push(createElement('span', { key: 'sep2' }, '·'))
1674
+ subChildren.push(createElement('span', { className: 'card-sub-item session-id-badge', key: 'id', title: '会话 ID: ' + session.id },
1675
+ '🆔 ' + session.id.slice(0, 8)
1676
+ ))
1677
+ }
1678
+
1679
+ var sub = createElement('div', { className: 'card-sub', key: 'sub' }, subChildren)
1680
+ cardChildren.push(sub)
1681
+
1682
+ if (isConfirming) {
1683
+ cardChildren.push(createElement('div', { className: 'confirm-bar', key: 'confirm' },
1684
+ createElement('span', { className: 'confirm-text', key: 'text' },
1685
+ session.live
1686
+ ? '⚠️ 将关停该在线会话(正在运行则会中断)并永久删除记录与日志,确定?'
1687
+ : '⚠️ 确定永久删除该会话记录及日志文件?'),
1688
+ createElement('div', { className: 'confirm-actions', key: 'actions' },
1689
+ createElement('button', {
1690
+ type: 'button',
1691
+ key: 'btn-confirm',
1692
+ className: 'btn danger-solid sm',
1693
+ disabled: view.busy,
1694
+ onClick: function () { act(session.live ? 'closeSession' : 'deleteSession', session.id) },
1695
+ }, '确认删除'),
1696
+ createElement('button', {
1697
+ type: 'button',
1698
+ key: 'btn-cancel',
1699
+ className: 'btn sm',
1700
+ disabled: view.busy,
1701
+ onClick: function () { patch({ confirming: null }) },
1702
+ }, '取消')
1703
+ )
1704
+ ))
1705
+ }
1706
+
1707
+ return createElement('div', { key: session.id, className: 'card' }, cardChildren)
1708
+ }
1709
+
1710
+ /* ========================================================================== */
1711
+ /* Plugin Entrypoint */
1712
+ /* ========================================================================== */
1713
+
1714
+ function apply(ctx) {
1715
+ injectStyles()
1716
+
1717
+ var call = function (method, args) {
1718
+ return ctx.connection.rpc.call('/api', method, { args: args })
1719
+ }
1720
+
1721
+ // Sidebar right-click menus: delete session (after archive) on session
1722
+ // rows, reveal in explorer on workspace rows. Mounted once for the page
1723
+ // lifetime; dispose when the plugin unmounts.
1724
+ // The sessions service lets us nudge the sidebar list after a delete so
1725
+ // the removed session disappears immediately instead of lingering in
1726
+ // "未分组" until the next reload.
1727
+ var refreshSessions = null
1728
+ try {
1729
+ var sessionsSvc = ctx.get && ctx.get('sessions')
1730
+ if (sessionsSvc && typeof sessionsSvc.refresh === 'function') {
1731
+ refreshSessions = function () { sessionsSvc.refresh().catch(function () {}) }
1732
+ }
1733
+ } catch (e) { refreshSessions = null }
1734
+ var disposeSidebar = ctx.effect(function () { return setupMenuInjection(call, refreshSessions) })
1735
+
1736
+ // One management-center entry keeps related local administration together;
1737
+ // its tab container mounts only the selected panel.
1738
+ ctx.slots.inject('settings.section', function () {
1739
+ return ctx.slots.register({
1740
+ name: 'settings.section',
1741
+ id: 'plugin-admin',
1742
+ order: 25,
1743
+ label: '管理中心',
1744
+ inject: function () { return { call: call, refreshSessions: refreshSessions } },
1745
+ }, AdminCenterSection)
1746
+ })
1747
+ }
1748
+
1749
+ module.exports = { apply: apply, inject: ['slots', 'connection'] }
1750
+ return module.exports
1751
+ } });